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
103 changes: 101 additions & 2 deletions .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,104 @@ or deleted from here.
directory or handed to `scp` (OpenSSH 9 runs scp over SFTP, where quoting would
become part of the name — the validation is the guard, not quoting).

- **Session descriptors ride the SAME ssh call as the inventory (issue #211)
— never a second connection.** The CLI writes one descriptor file per live
session to `~/.claude/sessions/<pid>.json` on the remote host, alongside
unrelated `*.key` secret files (mode 600) in the same directory.
`remote-transport.js`'s `LIST_COMMAND` (`remote-transport.js:25-29`) is a
single shell command: the existing `find .claude/projects`
inventory, then a `printf` of a marker line, then a bounded pull of
`.claude/sessions`. `listFiles(alias)` still spawns exactly one `ssh` — the
test "listFiles spawns one bounded ssh…" in `test/remote-transport.test.js`
asserts both `find .claude/projects` and `find .claude/sessions` appear
inside that single command string.
- **The two halves fail independently, in opposite directions — neither
direction should be mistaken for the other.** The inventory `find` is
followed by `|| exit $?`: if it fails (missing `.claude/projects`, an
unmounted home, a permission change, a BusyBox `find` with no `-printf`),
the whole command aborts immediately with that `find`'s own exit status —
`listFiles` still throws and `syncMirror` still refuses to touch the local
mirror, exactly as before issue #211 introduced the sessions pull. The
sessions half's own failure (a missing or unreadable `.claude/sessions`)
is independently swallowed (`2>/dev/null`, and the final pipeline's exit
status is the trailing `while` loop's, not `find`'s) and degrades to zero
descriptors without affecting the inventory half. Before this fix, the `;`
between the two stages let the sessions half's `while` loop mask a failed
inventory `find` — an unreachable/misconfigured host's inventory failure
was silently read as "nothing remote exists", which `syncMirror` then
read as license to delete every locally mirrored file for that host.
- **The marker (`SESSIONS_MARKER = '\u0001SWITCHBOARD-SESSIONS\u0001'`)
cannot collide with real descriptor content, by construction.** It is
wrapped in a raw SOH control byte (0x01) on each side — written in source
with an explicit `\u0001` escape, not a raw byte, so the constant stays
legible in a diff — sent to the shell as `\001` inside a `printf` format
string. Valid JSON text can never contain a raw, unescaped control byte —
the JSON spec requires control characters inside a string to be escaped
(per RFC 8259 section 7) rather than appear as a raw byte — so no
legitimate descriptor line can ever contain the two raw 0x01 bytes that
frame the marker. `splitListOutput(stdout)` (`remote-transport.js`) only
accepts a marker occurrence preceded by a newline (or at byte offset 0 —
the legitimate case when the inventory `find` found zero files) and
followed by a newline, consuming that trailing newline into the boundary;
a marker substring that doesn't sit on its own line, or an absent marker
(an unexpected truncation), both degrade to treating the whole stdout as
the inventory block and return `sessionsBlock: ''` rather than throwing —
`parseInventory` keeps working exactly as before on the degraded input.
- **The command assumes a POSIX-sh-compatible remote login shell.** `;`,
`||`, pipes, `2>/dev/null` and `while … do … done` are `sh`/`bash`/`dash`/
`ksh` syntax, not portable to `fish` (different loop syntax, no `do`/`done`)
or `csh`/`tcsh` (different control-flow syntax entirely) — a host whose
login shell is one of those would get a syntax error from `ssh`, whereas
the pre-#211 command (a single `find` invocation with no shell operators
at all) was portable to any shell. No such host is declared today; this is
a known, undemonstrated limitation, not something this change attempts to
fix.
- **`-name '[0-9]*.json'` is the only thing standing between this feature and
reading a `.key` secret file** — it structurally cannot match any `*.key`
filename regardless of the digit-prefix part, because the extension itself
is wrong. This is a property of the glob, not an added exclude-filter.
Pinned by two tests in `test/remote-transport.test.js`: an exact-string pin
of `LIST_COMMAND` (so widening the glob, or swapping the `find`+`head -c`
pipeline for a bare `cat *`, changes the string and fails immediately), and
a belt-and-suspenders `!LIST_COMMAND.includes('.key')` check that survives
unrelated wording changes.
- **Byte and count caps are enforced remotely, in the shell command itself**
(defense in depth, not just in JS): `head -c 8192 "$f"` bounds each
descriptor, `head -n 200` bounds the count. Worst case the sessions payload
adds ≈ 200 × 8193 ≈ 1.64 MiB to a cycle, comfortably under the existing
8 MiB `MAX_LIST_BYTES` combined-output cap on its own — so this addition
cannot by itself push a cycle over that cap. A projects inventory already
near 8 MiB could still combine with this to overflow, but that is the
pre-existing risk of an oversized inventory, not a new failure mode.
`LC_ALL=C sort` orders the descriptor files deterministically (byte order,
not locale-dependent) — cosmetic, but keeps test/log output stable.
- **A missing or unreadable `~/.claude/sessions` degrades to zero
descriptors instead of failing the whole cycle.** The sessions half is a
pipeline ending in `while IFS= read -r f; do …; done`, whose exit status is
the *loop's* status (0, even on empty input) — not the `find`'s. `find`'s
own stderr is swallowed by `2>/dev/null`, so a missing directory produces
empty stdout and exit 0. The two `find` stages are joined by `;`, not
`&&`, and deliberately carry no `set -e`/`pipefail` — a failure in the
sessions half must never fail the inventory half it rides alongside.
- **`parseSessions(block)` preserves every field verbatim** — the schema
belongs to the CLI, not to Switchboard — validating only `pid` (positive
integer) and `sessionId` (non-empty string) before accepting a line.
Malformed lines (a `head -c`-truncated descriptor produces incomplete
JSON) are dropped with a **fixed, generic warning string only** — never the
raw line, the parsed object, or any field value — because descriptor
content must never be logged. `listFiles`'s return contract changed from a
plain array to `{ files, sessions }`; every stub of `transport.listFiles`
across `remote-mirror.test.js`, `remote-index.test.js` and
`remote-indexing-e2e.test.js` was updated to match in the same change.
- **`remote-index.js` keeps the latest descriptors per alias, keyed and
pruned exactly like folder keys.** `createRemoteIndexer()`'s private
`remoteSessions` map is set from `result.sessions` inside `refreshHost()`
and read back through `getRemoteSessions(alias)` (defaults to `[]` for an
alias never refreshed). `pruneUnknownAliases()` deletes its entries for any
alias no longer declared, the same pass that prunes folder keys, so the map
cannot grow unboundedly across host-list edits. No IPC, no renderer surface
and no attach/injection exist yet for this data — that is issue #212's job.

### `stop()` cancels, `dispose()` ends -- they are not the same thing

`createSshTransport().dispose()` is **terminal**: it sets a flag every later
Expand All @@ -190,8 +288,9 @@ usable" and "dispose() is terminal", in `test/remote-index.test.js`.

- `remote-hosts.test.js` — covers folder-key parsing, alias validation and the `isSafeRelPath` guard
- `remote-mirror.test.js` — covers the inventory diff, the no-op second pull, deletions, and both failure modes, against a fake transport
- `remote-transport.test.js` — covers the ssh/scp argv, inventory parsing, the timeout kill and `dispose()`, with `spawn` injected
- `remote-index.test.js` — covers "no host declared: no timer, no ssh call", the 60 s floor, per-host failure isolation and alias pruning
- `remote-transport.test.js` — covers the ssh/scp argv, inventory parsing, the timeout kill and `dispose()`, with `spawn` injected; also covers `LIST_COMMAND`'s exact text (issue #211's `.key`-exclusion and single-ssh-call pins), `splitListOutput()` and `parseSessions()`
- `remote-transport-shell.test.js` — runs `LIST_COMMAND` through a real `sh -c`, not a fake stdout fixture: a missing `.claude/projects` must exit non-zero, a missing `.claude/sessions` must still exit 0 with the marker present, and a `.key` file plus a directory named like a descriptor must both be excluded from what reaches stdout
- `remote-index.test.js` — covers "no host declared: no timer, no ssh call", the 60 s floor, per-host failure isolation and alias pruning, and that `getRemoteSessions()` is cleared (not left stale) after a cycle whose `sync()` throws
- `remote-indexing-e2e.test.js` — covers the `<alias>::` prefix reaching session rows, the search entries, the metrics and the sidebar
- `dom-sidebar-remote-session.test.js` — covers the remote badge and the read-only click routing
- `derive-project-path.test.js` — covers the worktree-collapse + cwd extraction paths
Expand Down
13 changes: 12 additions & 1 deletion remote-index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function createRemoteIndexer(ctx) {
let timer = null;
let inFlight = false;
let stopped = false;
const remoteSessions = new Map(); // alias -> sessions array, from the same ssh cycle as the inventory

function hosts() {
return enabledHosts(ctx.getHosts ? ctx.getHosts() : []);
Expand All @@ -62,6 +63,9 @@ function createRemoteIndexer(ctx) {
ctx.dropFolder(key);
dropped++;
}
for (const alias of [...remoteSessions.keys()]) {
if (!known.has(alias)) remoteSessions.delete(alias);
}
return dropped;
}

Expand All @@ -88,6 +92,8 @@ function createRemoteIndexer(ctx) {
log,
});

remoteSessions.set(host.alias, Array.isArray(result.sessions) ? result.sessions : []);

const folderPrefix = host.alias;
const toScan = new Set(result.changedFolders);

Expand Down Expand Up @@ -140,6 +146,7 @@ function createRemoteIndexer(ctx) {
try {
if (await refreshHost(host)) changed = true;
} catch (err) {
remoteSessions.set(host.alias, []);
errors.push({ alias: host.alias, error: err.message });
log.warn(`[remote:${host.alias}] refresh failed: ${err.message}`);
}
Expand Down Expand Up @@ -186,7 +193,11 @@ function createRemoteIndexer(ctx) {
return start();
}

return { start, stop, dispose, restart, refreshNow, isRunning: () => timer !== null };
function getRemoteSessions(alias) {
return remoteSessions.get(alias) || [];
}

return { start, stop, dispose, restart, refreshNow, isRunning: () => timer !== null, getRemoteSessions };
}

module.exports = { createRemoteIndexer };
13 changes: 7 additions & 6 deletions remote-mirror.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,18 @@ function pruneEmptyDirs(root, dir) {
/**
* Bring the local mirror of one host in line with its remote inventory.
* Injected transport:
* listFiles(alias) -> Promise<[{ rel, size, mtimeMs }]>
* listFiles(alias) -> Promise<{ files: [{ rel, size, mtimeMs }], sessions: [object] }>
* fetchFiles(alias, rels, destRoot) -> Promise<{ fetched: [], failed: [] }>
*/
async function syncMirror({ alias, transport, projectsDir, manifestPath, log }) {
const inventory = await transport.listFiles(alias);
if (!Array.isArray(inventory)) throw new Error('transport.listFiles did not return an array');
if (inventory.length > MAX_INVENTORY_ENTRIES) {
throw new Error(`remote inventory too large (${inventory.length} entries)`);
const { files, sessions } = await transport.listFiles(alias);
if (!Array.isArray(files)) throw new Error('transport.listFiles did not return a files array');
if (files.length > MAX_INVENTORY_ENTRIES) {
throw new Error(`remote inventory too large (${files.length} entries)`);
}

const want = new Map();
for (const entry of inventory) {
for (const entry of files) {
if (!entry || !isSafeRelPath(entry.rel)) continue;
if (!topFolderOf(entry.rel)) continue; // a transcript must live under a project folder
want.set(entry.rel, { size: Number(entry.size) || 0, mtimeMs: Number(entry.mtimeMs) || 0 });
Expand Down Expand Up @@ -143,6 +143,7 @@ async function syncMirror({ alias, transport, projectsDir, manifestPath, log })
unchanged: want.size - toFetch.length,
removed,
changedFolders,
sessions: Array.isArray(sessions) ? sessions : [],
};
}

Expand Down
71 changes: 68 additions & 3 deletions remote-transport.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ const path = require('path');
const { isSafeRelPath } = require('./remote-hosts');

const REMOTE_PROJECTS_REL = '.claude/projects';
const REMOTE_SESSIONS_REL = '.claude/sessions';
const SESSIONS_MARKER = '\u0001SWITCHBOARD-SESSIONS\u0001';
const MAX_SESSION_DESCRIPTORS = 200;
const MAX_SESSION_DESCRIPTOR_BYTES = 8192;
const DEFAULT_CONNECT_TIMEOUT_S = 10;
const DEFAULT_LIST_TIMEOUT_MS = 60_000;
const DEFAULT_FETCH_TIMEOUT_MS = 120_000;
Expand All @@ -17,8 +21,12 @@ const SSH_BASE_OPTS = [
'-o', `ConnectTimeout=${DEFAULT_CONNECT_TIMEOUT_S}`,
];

// see .ai/contexts/session-cache.md ("Remote SSH hosts (issue #211)")
const LIST_COMMAND =
`find ${REMOTE_PROJECTS_REL} -type f -name '*.jsonl' -printf '%T@\\t%s\\t%P\\n'`;
`find ${REMOTE_PROJECTS_REL} -type f -name '*.jsonl' -printf '%T@\\t%s\\t%P\\n' || exit $?; ` +
`printf '\\001SWITCHBOARD-SESSIONS\\001\\n'; ` +
`find ${REMOTE_SESSIONS_REL} -maxdepth 1 -type f -name '[0-9]*.json' 2>/dev/null | LC_ALL=C sort | ` +
`head -n ${MAX_SESSION_DESCRIPTORS} | while IFS= read -r f; do head -c ${MAX_SESSION_DESCRIPTOR_BYTES} "$f"; printf '\\n'; done`;

function parseInventory(stdout) {
const out = [];
Expand All @@ -36,6 +44,48 @@ function parseInventory(stdout) {
return out;
}

// see .ai/contexts/session-cache.md ("Remote SSH hosts (issue #211)")
function splitListOutput(stdout) {
const idx = stdout.indexOf(SESSIONS_MARKER);
if (idx === -1) return { inventoryBlock: stdout, sessionsBlock: '' };
const afterIdx = idx + SESSIONS_MARKER.length;
const validStart = idx === 0 || stdout[idx - 1] === '\n';
const validEnd = stdout[afterIdx] === '\n';
if (!validStart || !validEnd) return { inventoryBlock: stdout, sessionsBlock: '' };
return { inventoryBlock: stdout.slice(0, idx), sessionsBlock: stdout.slice(afterIdx + 1) };
}

// see .ai/contexts/session-cache.md ("Remote SSH hosts (issue #211)")
function parseSessions(block) {
const sessions = [];
const warnings = [];
for (const rawLine of block.split('\n')) {
const line = rawLine.replace(/\r$/, '');
if (!line) continue;
let parsed;
try {
parsed = JSON.parse(line);
} catch {
warnings.push('skipped a session descriptor: invalid JSON');
continue;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
warnings.push('skipped a session descriptor: not a JSON object');
continue;
}
if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) {
warnings.push('skipped a session descriptor: missing/invalid pid');
continue;
}
if (typeof parsed.sessionId !== 'string' || !parsed.sessionId) {
warnings.push('skipped a session descriptor: missing/invalid sessionId');
continue;
}
sessions.push(parsed);
}
return { sessions, warnings };
}

/**
* ssh/scp transport. `spawn` is injected so the process bounding, the argv and
* the parsing are all testable without a network or an ssh binary.
Expand Down Expand Up @@ -116,7 +166,11 @@ function createSshTransport(opts = {}) {
if (res.timedOut) throw new Error(`ssh inventory timed out after ${listTimeoutMs} ms`);
if (res.truncated) throw new Error('ssh inventory output exceeded the size cap');
if (res.code !== 0) throw new Error(`ssh inventory failed (exit ${res.code}): ${res.stderr.trim() || 'no stderr'}`);
return parseInventory(res.stdout);
const { inventoryBlock, sessionsBlock } = splitListOutput(res.stdout);
const files = parseInventory(inventoryBlock);
const { sessions, warnings } = parseSessions(sessionsBlock);
for (const w of warnings) log.warn(`[remote:${alias}] ${w}`);
return { files, sessions };
}

async function fetchOne(alias, rel, destRoot) {
Expand Down Expand Up @@ -182,4 +236,15 @@ function createSshTransport(opts = {}) {
return { listFiles, fetchFiles, cancelInFlight, dispose, liveCount: () => live.size };
}

module.exports = { createSshTransport, parseInventory, LIST_COMMAND, REMOTE_PROJECTS_REL };
module.exports = {
createSshTransport,
parseInventory,
parseSessions,
splitListOutput,
LIST_COMMAND,
REMOTE_PROJECTS_REL,
REMOTE_SESSIONS_REL,
SESSIONS_MARKER,
MAX_SESSION_DESCRIPTORS,
MAX_SESSION_DESCRIPTOR_BYTES,
};
66 changes: 65 additions & 1 deletion test/remote-index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,70 @@ test('a failing host is logged and does not stop its peer', async () => {
} finally { fs.rmSync(dataDir, { recursive: true, force: true }); }
});

test('getRemoteSessions surfaces per-host session descriptors from the same sync cycle', async () => {
const dataDir = tmp('idx-sessions');
try {
const sessionsByAlias = {
withSessions: [{ pid: 123, sessionId: 'abc' }],
empty: [],
};
const indexer = createRemoteIndexer({
getHosts: () => [{ alias: 'withSessions' }, { alias: 'empty' }],
dataDir,
transport: {},
scanFolders: () => Promise.resolve({ ok: true }),
listIndexedFolderKeys: () => [],
timers: fakeTimers(),
sync: async ({ alias }) => ({
fetched: 0, unchanged: 0, removed: 0, failed: 0, total: 0,
changedFolders: new Set(), sessions: sessionsByAlias[alias],
}),
});

const r = await indexer.refreshNow();

assert.deepEqual(r.errors, [], 'both hosts complete without error');
assert.deepEqual(indexer.getRemoteSessions('withSessions'), sessionsByAlias.withSessions);
assert.deepEqual(indexer.getRemoteSessions('empty'), []);
assert.deepEqual(indexer.getRemoteSessions('some-alias-never-refreshed'), [],
'an unknown alias must never throw or return undefined');
} finally { fs.rmSync(dataDir, { recursive: true, force: true }); }
});

test('getRemoteSessions is cleared, not left stale, after a cycle where sync() throws', async () => {
const dataDir = tmp('idx-sessions-stale');
try {
let cycle = 0;
const indexer = createRemoteIndexer({
getHosts: () => [{ alias: 'planificator' }],
dataDir,
transport: {},
scanFolders: () => Promise.resolve({ ok: true }),
listIndexedFolderKeys: () => [],
timers: fakeTimers(),
sync: async () => {
cycle++;
if (cycle === 1) {
return {
fetched: 0, unchanged: 0, removed: 0, failed: 0, total: 0,
changedFolders: new Set(), sessions: [{ pid: 1, sessionId: 'still-alive' }],
};
}
throw new Error('ssh: connect to host planificator port 22: timed out');
},
});

const r1 = await indexer.refreshNow();
assert.deepEqual(r1.errors, []);
assert.deepEqual(indexer.getRemoteSessions('planificator'), [{ pid: 1, sessionId: 'still-alive' }]);

const r2 = await indexer.refreshNow();
assert.equal(r2.errors.length, 1, 'the second cycle must be reported as failed');
assert.deepEqual(indexer.getRemoteSessions('planificator'), [],
'a failed cycle must not keep reporting hours-old sessions as live');
} finally { fs.rmSync(dataDir, { recursive: true, force: true }); }
});

test('a mirror already on disk but absent from the cache is indexed once', async () => {
const dataDir = tmp('idx-cold');
try {
Expand Down Expand Up @@ -204,7 +268,7 @@ function lifecycleTransport() {
async listFiles(alias) {
if (disposed) throw new Error('ssh inventory failed (exit -1): transport disposed');
calls.push(alias);
return [];
return { files: [], sessions: [] };
},
fetchFiles: async () => ({ fetched: [], failed: [] }),
cancelInFlight() {},
Expand Down
Loading
Loading