Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Where it applies:

| Consumer of a transcript cwd | Status |
|---|---|
| `deriveProjectPath` (sidebar project path, `session.projectPath`, `cache_meta`, `getKnownProjectPaths`, and the cold-start scan in `workers/scan-projects.js`) | Verified: a JSONL whose cwd does not verify is skipped, then the worktree collapse applies to the verified cwd; no verified JSONL gives `null` |
| `deriveProjectPath` (sidebar project path, `session.projectPath`, `cache_meta`, `getKnownProjectPaths`, and the cold-start scan in `workers/scan-projects.js`) | Verified: a JSONL with no cwd that verifies, in its head or tail window (see "A transcript moved into a worktree folder"), is skipped, then the worktree collapse applies to the verified cwd; no verified JSONL gives `null` |
| `refreshFolder` reuse of a stored `cache_meta.projectPath`; `buildProjectsFromCache` (empty-folder section); `reconcileCacheFromFilesystem` | Verified by `storedProjectPathMatchesFolder` (the verified path, the repository of a worktree folder, or the remap record). A value stored before the upgrade is re-derived: `reconcileCacheFromFilesystem` refreshes a folder whose stored path fails the check even when its mtime is current, and `refreshFolder` rewrites a cached row whose `projectPath` differs from the folder's even when its file is unchanged. A folder with no verifiable transcript has its cached rows deleted |
| `resolveSessionRealCwd` (resume and fork spawn cwd in `open-terminal`, Changes panel, panel terminal, terminal path links, subagent worktree discovery, the sandbox bind folder, which follows the spawn cwd) | Verified against the folder holding the session's JSONL, which main finds on disk; a transcript that does not verify is skipped for the next folder holding the same id; none left means resume starts in the requested project path as for a session without a recorded cwd |
| `create-schedule-session` `mkdir enc(projectPath)` and `open-terminal` registration of `projectPath` | The path comes from the sidebar, so from `deriveProjectPath`; the registration stays a plain launch registration |
Expand All @@ -69,6 +69,27 @@ A folder that holds transcripts with a cwd but none that verifies is logged once

Residuals: `encodeProjectPath` truncates at 200 characters and appends a 32-bit hash, so a path of 200 characters or more can collide (`enc(P/long) === enc(P)`), seed included. The hash is taken over the raw characters of the path, as the CLI does; normalising before hashing would stop matching the CLI's folder names, so it was left alone (the verified cwd is already `path.resolve`d before it is encoded). A transcript written by the CLI whose cwd does not encode to its folder (a symlinked cwd named by its real path, say) no longer gives a project path or a resume directory. A `projectPath` persisted by session restore before the upgrade is not re-verified; the renderer's own strings are out of scope.

## Bounded cwd scan

A transcript's cwd is read from bounded windows of the file (`CWD_SCAN_BYTES`, 256 KB, in `derive-project-path.js`), never from the whole of it. Reading the whole file froze the main process: `refreshFolder()` derives the project path on every watcher flush, so a 338 MB host-session JSONL meant a multi-second `readFileSync` per flush, back to back (witnessed 2026-06-11: main thread pegged ~65% CPU re-reading the same file in a loop, UI freezes). A cwd that appears only between the head and tail windows is not seen.

## A transcript moved into a worktree folder

When a session started at a repository's root enters a worktree (`EnterWorktree`), the CLI moves its transcript from `enc(P)` to `enc(P/.claude/worktrees/x)`; the old folder can disappear with it. The lines written before the move keep `cwd = P`, which does not encode to the new folder; only the lines written after carry the worktree's cwd. Witnessed 2026-10-07 with a 600 KB transcript: the first worktree cwd sat at byte 483 010, past the 256 KB head window, so `deriveProjectPath` returned `null` for the folder, `refreshFolder` dropped it, and the session, whose transcript had also left `enc(P)`, vanished from the sidebar while its PTY kept running.

`extractVerifiedCwdFromJsonl(filePath, folderName, tailBudget)` returns the first cwd of the head window that verifies against the folder. The first rejected cwd ends the scan, as the head-only scan did, unless the folder may be a worktree folder of that cwd or of one of its ancestors (`mayBeWorktreeFolderOf`; a session started in `P/sub` moves the same way); then the rest of the head and, failing that, the file's last 256 KB are searched, last line first. The trust rule is unchanged: whichever line it comes from, a cwd is used only if it encodes to the folder holding the transcript.

- **Long paths.** `encodeProjectPath` truncates a name over 200 characters and appends a hash of the whole path, so the folder of `P/.claude/worktrees/x` does not start with `enc(P)` when either is long. `mayBeWorktreeFolderOf` compares through `encodedFolderMayExtend` (`encode-project-path.js`), which applies the encoder's sanitising and its 200-character cut to `P/<worktree dir>/` and compares that prefix only. It is a gate for reading more of the file, not a trust decision, so a false positive costs one bounded read and nothing else. `storedProjectPathMatchesFolder` keeps its own exact prefix check.
- **Cost.** One derivation (`deriveProjectPath`) gives the tail reads a budget of four windows (1 MiB). A transcript whose tail was skipped for lack of budget, or whose stat, open or read threw (`EBUSY` while the CLI holds it, say), is not memoised, and the derivation reports itself incomplete (`opts.onIncomplete`); so does a failed listing of the folder or of a session subdirectory. The next derivation of the folder starts its walk of the folder's transcripts at the first one skipped for budget (`resumeAt`, per folder, at most 1 024 folders; a transcript that failed is retried but never becomes the starting point, so one that keeps failing cannot hold the walk back), so a valid transcript listed after more unresolved ones than one budget covers is reached even when the memo cannot hold them all. `refreshFolder` then stores the folder's `indexMtimeMs` as 0 instead of its mtime, so `reconcileCacheFromFilesystem` derives it again on its next pass; each pass reads only the transcripts not yet memoised, so the folder resolves, or is marked indexed as unresolved, after a bounded number of passes. Every transcript found unresolved after a complete scan is memoised by path, size and mtime (`unresolvedMemo`, at most 4 096 entries), and is not read again until it changes. A remap rewrites the transcripts it remaps, so it changes their mtime and clears their memo. A file rewritten to the same size with its mtime restored keeps a stale memo; the CLI only appends, so this is not a case it produces. Measured on a folder of 50 rejected 660 KB transcripts: 7.9 ms per flush with the former head-only scan, which read every head on every flush; 0.64 ms per flush averaged over repeated flushes with the memo, the first pass reading the heads as before.
- **Callers.** `deriveProjectPath` (local) and `resolveSessionRealCwd` go through it; the remote path keeps the plain head scan. The Touched panel does not: it resolves a relative touched path against one cwd per transcript, and the worktree cwd of a moved transcript is wrong for the touches made before the move. It keeps the head-only `extractCwdFromJsonl`, so the moved transcript's head cwd, which does not verify, leaves those touches unresolved.
- **Residual.** A transcript whose last line alone exceeds 256 KB (a large tool result) has no complete line in the tail window. While its session runs, the row is kept (next section); after exit, the folder resolves again only once a later line carries the worktree's cwd.

## A running session keeps its row

`dropFolderRows(folder, { search })` (`session-cache.js`) deletes a folder's `session_cache` rows except those of a session with a live, non-plain PTY in `activeSessions` (`exited` false), and deletes the whole folder only when no row is kept. It is the path for every deletion that follows from the disk: a folder that has vanished (`refreshFolder`'s first branch and the watcher's `flushChanges` in `main.js`), a folder that stops resolving to a project, and a cold-scan folder with no verifiable transcript. `refreshFolder` applies the same rule to a single transcript missing from its folder (full walk and targeted). Deliberate deletions (hide or remove a project, drop a remote host) still use `deleteCachedFolder`.

A kept row is recorded with its folder. On PTY exit, `main.js` calls `releaseLiveSession` for both ids after removing them from `activeSessions`; it refreshes that folder again, which drops the row if its transcript is still gone. That call is the exit handler's only database access and is wrapped in a `try`: at quit, `before-quit` kills the PTYs and `will-quit` closes the database, so a late exit event can meet a closed connection. A transcript that leaves a folder while its session runs therefore leaves the session in the sidebar until it reappears elsewhere, where the upsert on the same `sessionId` replaces the row's folder.

## Non-obvious behaviors

- **`resolveWorktreePath` collapses `<repo>/.worktrees/<name>` → `<repo>`** when the parent dir exists. Consequence: many `~/.claude/projects/-home-...workspace-myproject--worktrees-X` folders derive to the same projectPath. Callers must dedupe (see `get-work-files` IPC for the pattern).
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc
- Sessions a program started through the Claude Agent SDK, such as headless runs or review agents, no longer fill the sidebar and grid. Turn off **Hide SDK-launched Sessions** in Global Settings to list them again. Sessions you have open, scheduled tasks and sessions you continued by typing in them stay listed, and the activity heatmap still counts the hidden ones. (#486)
### Fixed
- When the Claude CLI reports a session's id in a different letter case, the subagents that session started or finished during a turn now show up when the turn ends. (#487)
- A session that moves into a worktree while it runs stays in the sidebar, under its project, instead of disappearing while its terminal keeps running. A running session also stays listed when its transcript leaves its folder for any other reason. (#485)
- Worktree folders from another host, or whose repository is not listed, show in the sidebar instead of being nested under the wrong folder or not shown at all. The worktrees of a repository hidden with Hide Project stay hidden. (#473)
- Remote triggers refuse commands containing invisible format characters, default-ignorable characters or braille blanks, including joined emoji, emoji with variation selectors (such as hearts), soft hyphens and right-to-left marks. Fullwidth slash, exclamation and number-sign prefixes are refused too. (#440)

Expand Down
161 changes: 136 additions & 25 deletions derive-project-path.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
const fs = require('fs');
const path = require('path');
const { encodeProjectPath, verifiedTranscriptCwd } = require('./encode-project-path');

// Only the head of the file is scanned: every session/subagent transcript
// carries `cwd` on its first JSONL line. Reading the whole file here froze
// the main process — refreshFolder() derives the project path on every
// watcher flush, so a 338 MB host-session JSONL meant a multi-second
// readFileSync per flush, back to back (witnessed 2026-06-11: main thread
// pegged ~65% CPU re-reading the same file in a loop, UI freezes).
const { encodeProjectPath, encodedFolderMayExtend, verifiedTranscriptCwd } = require('./encode-project-path');

// see .ai/contexts/session-cache.md ("Bounded cwd scan")
const CWD_SCAN_BYTES = 256 * 1024;

function extractCwdFromJsonl(filePath) {
Expand All @@ -33,6 +28,91 @@ function extractCwdFromJsonl(filePath) {
return null;
}

function* cwdsInWindow(fd, start, fileSize) {
const from = Math.max(0, start - 1);
const buf = Buffer.alloc(Math.min(CWD_SCAN_BYTES + start - from, fileSize - from));
const bytesRead = fs.readSync(fd, buf, 0, buf.length, from);
const lines = buf.toString('utf8', 0, bytesRead).split('\n');
if (from + bytesRead < fileSize) lines.pop();
if (start > 0) lines.shift();
for (const line of lines) {
if (!line) continue;
try {
const parsed = JSON.parse(line);
if (parsed.cwd) yield parsed.cwd;
} catch {}
}
}

const WORKTREE_DIRS = ['.worktrees', '.claude-worktrees', path.join('.claude', 'worktrees')];

function mayBeWorktreeFolderOf(cwd, folderName) {
if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) return false;
for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
if (WORKTREE_DIRS.some((wt) => encodedFolderMayExtend(folderName, path.join(dir, wt) + path.sep))) return true;
if (path.dirname(dir) === dir) return false;
}
}

// see .ai/contexts/session-cache.md ("A transcript moved into a worktree folder")
function extractVerifiedCwdFromJsonl(filePath, folderName, tailBudget = null) {
let fd;
let firstRejected = null;
let failed = false;
try {
fd = fs.openSync(filePath, 'r');
const size = fs.fstatSync(fd).size;
for (const cwd of cwdsInWindow(fd, 0, size)) {
const verified = verifiedTranscriptCwd(cwd, folderName);
if (verified) return { cwd: verified, rejected: null, complete: true };
if (firstRejected !== null) continue;
firstRejected = cwd;
if (!mayBeWorktreeFolderOf(cwd, folderName)) return { cwd: null, rejected: cwd, complete: true };
}
if (firstRejected !== null && size > CWD_SCAN_BYTES) {
if (tailBudget && tailBudget.bytes < CWD_SCAN_BYTES) return { cwd: null, rejected: firstRejected, complete: false, skipped: true };
if (tailBudget) tailBudget.bytes -= CWD_SCAN_BYTES;
const tail = [...cwdsInWindow(fd, size - CWD_SCAN_BYTES, size)].reverse();
for (const cwd of tail) {
const verified = verifiedTranscriptCwd(cwd, folderName);
if (verified) return { cwd: verified, rejected: null, complete: true };
}
}
} catch {
failed = true;
} finally {
if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
}
return { cwd: null, rejected: firstRejected, complete: !failed };
}

const DERIVE_TAIL_BUDGET_BYTES = 4 * CWD_SCAN_BYTES;
const UNRESOLVED_MEMO_MAX = 4096;
const unresolvedMemo = new Map();
const RESUME_MAX = 1024;
const resumeAt = new Map();

function rememberResume(folderPath, fileName) {
resumeAt.delete(folderPath);
if (fileName === null) return;
if (resumeAt.size >= RESUME_MAX) resumeAt.delete(resumeAt.keys().next().value);
resumeAt.set(folderPath, fileName);
}

function unresolvedCwdOf(filePath, folderName) {
let stat;
try { stat = fs.statSync(filePath); } catch { return { failed: true }; }
const memo = unresolvedMemo.get(filePath);
if (memo && memo.folderName === folderName && memo.size === stat.size && memo.mtimeMs === stat.mtimeMs) return memo;
return { stat };
}

function rememberUnresolved(filePath, folderName, stat, rejected) {
unresolvedMemo.delete(filePath);
if (unresolvedMemo.size >= UNRESOLVED_MEMO_MAX) unresolvedMemo.delete(unresolvedMemo.keys().next().value);
unresolvedMemo.set(filePath, { folderName, size: stat.size, mtimeMs: stat.mtimeMs, rejected });
}

function resolveWorktreePath(cwd) {
if (!cwd) return cwd;
// Detect worktree paths: <project>/.claude-worktrees/<name>, <project>/.worktrees/<name>, or <project>/.claude/worktrees/<name>
Expand All @@ -49,28 +129,55 @@ function deriveProjectPath(folderPath, folderName, opts) {
const name = folderName || path.basename(folderPath);
const remote = !!(opts && opts.remote);
let firstRejected = null;
const trusted = (cwd) => {
if (remote) return typeof cwd === 'string' && cwd ? cwd : null;
const verified = verifiedTranscriptCwd(cwd, name);
if (!verified && cwd && firstRejected === null) firstRejected = cwd;
return verified;
const tailBudget = { bytes: DERIVE_TAIL_BUDGET_BYTES };
let incomplete = false;
const trustedCwdOf = (filePath) => {
if (remote) {
const cwd = extractCwdFromJsonl(filePath);
return typeof cwd === 'string' && cwd ? cwd : null;
}
const memo = unresolvedCwdOf(filePath, name);
if (memo.failed) {
incomplete = true;
return null;
}
if (!memo.stat) {
if (memo.rejected && firstRejected === null) firstRejected = memo.rejected;
return null;
}
const { cwd, rejected, complete, skipped } = extractVerifiedCwdFromJsonl(filePath, name, tailBudget);
if (cwd) {
unresolvedMemo.delete(filePath);
return cwd;
}
if (rejected && firstRejected === null) firstRejected = rejected;
if (complete) {
rememberUnresolved(filePath, name, memo.stat, rejected);
} else {
incomplete = true;
if (skipped && resumeName === null && path.dirname(filePath) === folderPath) resumeName = path.basename(filePath);
}
return null;
};
const result = deriveVerified(folderPath, trusted);
let resumeName = null;
const result = deriveVerified(folderPath, trustedCwdOf, remote ? null : resumeAt.get(folderPath), () => { incomplete = true; });
if (!remote) rememberResume(folderPath, result === null ? resumeName : null);
if (result === null && firstRejected !== null && opts && typeof opts.onRejected === 'function') {
opts.onRejected(firstRejected);
}
if (result === null && incomplete && opts && typeof opts.onIncomplete === 'function') opts.onIncomplete();
return result;
}

function deriveVerified(folderPath, trusted) {
function deriveVerified(folderPath, trustedCwdOf, startName = null, onListingFailed = () => {}) {
try {
const entries = fs.readdirSync(folderPath, { withFileTypes: true });
// Check direct .jsonl files first
for (const e of entries) {
if (e.isFile() && e.name.endsWith('.jsonl')) {
const cwd = trusted(extractCwdFromJsonl(path.join(folderPath, e.name)));
if (cwd) return resolveWorktreePath(cwd);
}
const direct = entries.filter(e => e.isFile() && e.name.endsWith('.jsonl')).map(e => e.name);
const start = Math.max(0, direct.indexOf(startName));
for (const name of [...direct.slice(start), ...direct.slice(0, start)]) {
const cwd = trustedCwdOf(path.join(folderPath, name));
if (cwd) return resolveWorktreePath(cwd);
}
// Check session subdirectories (UUID folders with subagent .jsonl files)
for (const e of entries) {
Expand All @@ -87,13 +194,17 @@ function deriveVerified(folderPath, trusted) {
if (agentFiles.length > 0) jsonlPath = path.join(subDir, 'subagents', agentFiles[0]);
}
if (jsonlPath) {
const cwd = trusted(extractCwdFromJsonl(jsonlPath));
const cwd = trustedCwdOf(jsonlPath);
if (cwd) return resolveWorktreePath(cwd);
}
}
} catch {}
} catch {
onListingFailed();
}
}
} catch {}
} catch {
onListingFailed();
}
return null;
}

Expand Down Expand Up @@ -170,11 +281,11 @@ function resolveSessionRealCwd(projectsDir, sessionId, preferredFolder) {
for (const folder of folders) {
const jsonl = path.join(projectsDir, folder, sessionId + '.jsonl');
if (!fs.existsSync(jsonl)) continue;
const cwd = verifiedTranscriptCwd(extractCwdFromJsonl(jsonl), folder);
const { cwd } = extractVerifiedCwdFromJsonl(jsonl, folder);
if (cwd) return cwd;
}
} catch {}
return null;
}

module.exports = { deriveProjectPath, storedProjectPathMatchesFolder, resolveWorktreePath, extractCwdFromJsonl, resolveSessionRealCwd, sessionTranscriptExists, isGitRepo };
module.exports = { deriveProjectPath, storedProjectPathMatchesFolder, resolveWorktreePath, extractCwdFromJsonl, extractVerifiedCwdFromJsonl, resolveSessionRealCwd, sessionTranscriptExists, isGitRepo };
Loading
Loading