From 031d881d422316ff4e90192b58893b6f5e5fd106 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sat, 12 Sep 2026 23:28:29 +0200 Subject: [PATCH] (remote): show a live remote session before its first transcript exists Closes #278. The inventory tags each live descriptor with whether a transcript matches it, in the same ssh call; the index synthesizes a placeholder row for descriptor-only ones (project from cwd, same session id, status and age, attachable and stoppable, no transcript affordance). Once the transcript is scanned the descriptor is no longer descriptor-only and the real row takes over under the same id. open-terminal resolves the host alias from the index when the session has no cache row yet. --- .ai/contexts/session-cache.md | 87 ++++++++++- .ai/contexts/session-state.md | 14 ++ main.js | 56 ++++++- public/sidebar.js | 3 +- remote-index.js | 60 +++++++- remote-transport.js | 16 +- test/dom-sidebar-remote-placeholder.test.js | 88 +++++++++++ .../get-projects-cold-start-reconcile.test.js | 14 +- test/merge-placeholder-sessions.test.js | 143 ++++++++++++++++++ test/remote-index.test.js | 128 ++++++++++++++++ test/remote-transport.test.js | 66 +++++++- 11 files changed, 654 insertions(+), 21 deletions(-) create mode 100644 test/dom-sidebar-remote-placeholder.test.js create mode 100644 test/merge-placeholder-sessions.test.js diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 9b8689d4..8301ba18 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -438,12 +438,81 @@ one changed file" for `syncMirror`. this file, but still reads *all* of it, not just the new lines. Parse cost is therefore unchanged by this issue; issue #216's second half remains the place to fix that. -- **Known gap, not fixed here**: a remote session with a live descriptor - (`~/.claude/sessions/.json`) but no `.jsonl` written yet (a session - that was launched but has not been prompted) is invisible to the - inventory — `LIST_COMMAND`'s `find .claude/projects` only ever sees files - that exist. Observed 2026-09-11. Candidate for a follow-up issue; not - addressed by issue #257. +- **Gap closed by issue #278 (below)**: a remote session with a live + descriptor (`~/.claude/sessions/.json`) but no `.jsonl` written yet (a + session that was launched but has not been prompted) used to be invisible — + `LIST_COMMAND`'s `find .claude/projects` only ever sees files that exist. + Observed 2026-09-11; not addressed by issue #257 itself, since fixed by a + placeholder session synthesized from the descriptor alone. + +### Remote hosts — descriptor-only sessions (issue #278) + +A CLI launched in tmux on the host writes its descriptor +(`~/.claude/sessions/.json`) immediately; the transcript +(`~/.claude/projects//.jsonl`) only appears after the +first prompt. Measured 2026-09-12 on host `planificator`: launched 00:47, +invisible in the sidebar (and unstoppable) until a first prompt at 00:50 +created the `.jsonl`; a manual host refresh did not help. + +- **`descriptorOnly` is computed in `remote-transport.js`'s `listFiles()`**, + against the SAME ssh call's own inventory — no second round trip. + `transcriptSessionIds(files)` collects every inventory rel's basename (minus + `.jsonl`); a session whose `sessionId` is not among them gets + `descriptorOnly: true` on the object `parseSessions()` already produced. + Dead descriptors (`ALIVE:0`) are dropped exactly as before — that filter + runs first, inside `parseSessions()`, unchanged. +- **The placeholder itself is synthesized in `remote-index.js`**, lazily, from + whatever the last successful cycle stored — never persisted, never mirrored. + `getPlaceholderSessions(alias)` filters `descriptorOnly` sessions that also + carry a `cwd` (nothing to group under, otherwise) and builds a session-shaped + object via `buildPlaceholderSession()`: `sessionId` is the descriptor's own + id (parseSessions already requires one; a `pid:` fallback exists only for + a descriptor shape this build has never produced — that path cannot + smoothly replace itself once a transcript appears, since the real row's id + would then differ from the placeholder's `pid:`), + `folder` is `encodeProjectPath(cwd)` (the same derivation a real session's + folder gets), `remoteDescriptorSeen: true`, `status`/`statusUpdatedAt` from + the descriptor, `placeholder: true`, and `summary` set to the cwd's + basename. `getAllPlaceholderSessions()` aggregates across every alias + `remoteSessions` currently knows about. +- **`main.js`'s `mergePlaceholderSessions(projects)` folds these into the + `get-projects` payload**, BEFORE `annotateRemoteAttachable()` runs — so a + placeholder gets the exact same `status`/`remoteAttachable`/`remoteActiveAt` + annotation a real remote session does, off the same descriptor. For each + placeholder it finds the project group matching `remoteAlias` + + `projectPath` and appends the session (skipping it if a real session with + the same id already won the race), or creates a new project group when the + host has no other indexed session under that cwd yet. +- **Replacement is "same id, same row", not a swap main.js orchestrates.** + Once the transcript is scanned, the very next `listFiles()` cycle sees the + matching inventory entry and reports `descriptorOnly: false`, so + `getPlaceholderSessions()` simply stops offering that session — and the real + row (now present via `buildProjectsFromCache()`, same `sessionId`) is what + the sidebar's key-by-`sessionId` render already treats as the same row. No + code anywhere diffs "was this a placeholder a moment ago" — there is nothing + to reconcile because only one of the two sources is ever offering that id at + a time. +- **`open-terminal`'s remote-attach lookup had to change to reach this row at + all.** It used to derive the alias solely from `getCachedFolder(sessionId)` + (a `session_cache` row) — a placeholder has none, by design (nothing is + mirrored or indexed for it), so that lookup silently found nothing and fell + through to the local-spawn path instead of attaching. It now falls back to + `remoteIndexer.findSessionAlias(sessionId)` — a plain in-memory scan of the + last known descriptors — whenever `getCachedFolder` returns nothing at all + (a folder that IS cached but local is left alone: `alias` stays `null`, + exactly as before). +- **Stop, delete-guard and the DOM row needed no such fix.** `remote-stop-session` + and the sidebar's `resolveSessionStop`/`stopBeforeArchive` already take + `alias`/`sessionId` straight from the session object the renderer holds, never + from a DB lookup — a placeholder's `remoteAlias` is set directly by + `mergePlaceholderSessions`, so stop works unmodified. `read-session-jsonl` + (transcript viewer), `list-subagents` and the subagent-meta paths all key off + `getCachedFolder`/`getCachedSession`/`getCachedByParent`, which return + nothing for an unindexed id and already degrade to an error object rather + than throwing — a placeholder is skipped there, not crashed, with no code + change needed. The one renderer-side change is `sidebar.js`'s + `buildSessionItem`: the `.session-jsonl-btn` ("View messages") is not + rendered for a `session.placeholder` row, since there is nothing to view yet. - **A remote project is never "missing".** `buildProjectsFromCache` sets `missing: false` for any aliased row. Probing the local filesystem for @@ -871,11 +940,13 @@ 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; also covers `LIST_COMMAND`'s exact text (issue #211's `.key`-exclusion and single-ssh-call pins), `splitListOutput()` and `parseSessions()` +- `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()`; and (issue #278) `listFiles()` marking a live descriptor `descriptorOnly` against the same call's own inventory, keeping a descriptor-only entry while still dropping a dead (`ALIVE:0`) one - `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, a `.key` file plus a directory named like a descriptor must both be excluded from what reaches stdout, and (F9) the ALIVE marker reflects real `/proc` liveness for both a live pid (the shell's own `$$`, so it reads as alive on any host) and a dead one -- `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-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; and (issue #278) `getPlaceholderSessions()`/`getAllPlaceholderSessions()` synthesizing and then dropping a placeholder once its transcript is indexed, and `findSessionAlias()` - `remote-indexing-e2e.test.js` — covers the `::` prefix reaching session rows, the search entries, the metrics and the sidebar +- `merge-placeholder-sessions.test.js` — covers `main.js`'s `mergePlaceholderSessions()` (issue #278): appending to an existing project group, creating a new one, and never duplicating a session id a real row already won - `dom-sidebar-remote-session.test.js` — covers the remote badge and the read-only click routing +- `dom-sidebar-remote-placeholder.test.js` — covers the placeholder row (issue #278): renders `.is-alive`, a stop control and status/age, has no transcript affordance, and an attachable placeholder opens a terminal rather than the transcript viewer - `derive-project-path.test.js` — covers the worktree-collapse + cwd extraction paths - `db-daily-activity.test.js` — covers heatmap aggregation - `read-session-file.test.js` — covers header parsing diff --git a/.ai/contexts/session-state.md b/.ai/contexts/session-state.md index c0755e7e..e0c50520 100644 --- a/.ai/contexts/session-state.md +++ b/.ai/contexts/session-state.md @@ -532,6 +532,20 @@ local-transcript `busy: false` transitions always pass `armReady: false` (see "The remote-ssh adapter" and "The local-transcript adapter" above), not a tri-state `busy: unknown`. +## Placeholder rows (issue #278) + +A remote-ssh row synthesized from a live descriptor with no transcript yet +(`main.js`'s `mergePlaceholderSessions`, `remote-index.js`'s +`getPlaceholderSessions` — see `.ai/contexts/session-cache.md`, "Remote hosts +— descriptor-only sessions") carries `placeholder: true` alongside the exact +same `remoteAlias`/`status`/`statusUpdatedAt` fields a real remote session +does. It goes through `annotateRemoteAttachable()` unchanged, so it gets +`remoteAttachable`/`remoteActiveAt` from the same descriptor and its lifecycle +is the ordinary remote-ssh one described above — nothing here adds a third +liveness/attach state. The only thing this row's absent transcript changes is +in the renderer: `sidebar.js` does not render the `.session-jsonl-btn` for a +`placeholder` row, since there is nothing to view. + ## Known limits - **The remote-stop pid-reuse guard is weak.** `remote-stop.js`'s diff --git a/main.js b/main.js index 0ecc8fbd..d35c35b3 100644 --- a/main.js +++ b/main.js @@ -574,6 +574,53 @@ function annotateRemoteAttachable(projects) { return projects; } +// see .ai/contexts/session-cache.md ("Remote hosts — descriptor-only sessions") +function toSidebarPlaceholderSession(ph) { + return { + sessionId: ph.sessionId, + summary: ph.summary, + firstPrompt: null, + created: null, + modified: ph.modified, + messageCount: 0, + projectPath: ph.projectPath, + slug: null, + aiTitle: null, + parentSessionId: null, + agentId: null, + subagentType: null, + description: null, + name: null, + starred: 0, + archived: 0, + remoteAlias: ph.remoteAlias, + remoteDescriptorSeen: ph.remoteDescriptorSeen, + status: ph.status, + statusUpdatedAt: ph.statusUpdatedAt, + placeholder: true, + }; +} + +function mergePlaceholderSessions(projects) { + const placeholders = remoteIndexer.getAllPlaceholderSessions(); + for (const ph of placeholders) { + const project = projects.find(p => p.remoteAlias === ph.remoteAlias && p.projectPath === ph.projectPath); + if (project) { + if (project.sessions.some(s => s.sessionId === ph.sessionId)) continue; + project.sessions.push(toSidebarPlaceholderSession(ph)); + } else { + projects.push({ + folder: joinFolderKey(ph.remoteAlias, ph.folder), + projectPath: ph.projectPath, + remoteAlias: ph.remoteAlias, + sessions: [toSidebarPlaceholderSession(ph)], + missing: false, + }); + } + } + return projects; +} + /** Directory holding a folder key's transcripts, local or mirrored. */ function projectsDirForFolder(folder) { return resolveFolderDir(folder); @@ -1030,7 +1077,7 @@ ipcMain.handle('get-projects', async (_event, showArchived) => { reconcileCacheFromFilesystem(); } - return annotateRemoteAttachable(buildProjectsFromCache(showArchived)); + return annotateRemoteAttachable(mergePlaceholderSessions(buildProjectsFromCache(showArchived))); } catch (err) { console.error('Error listing projects:', err); return []; @@ -2125,8 +2172,11 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se if (!isNew) { let cachedFolder = null; try { cachedFolder = getCachedFolder(sessionId); } catch {} - if (isRemoteFolder(cachedFolder)) { - const { alias } = parseFolderKey(cachedFolder); + // see .ai/contexts/session-cache.md ("Remote hosts — descriptor-only sessions") + const alias = isRemoteFolder(cachedFolder) + ? parseFolderKey(cachedFolder).alias + : (cachedFolder ? null : remoteIndexer.findSessionAlias(sessionId)); + if (alias) { const descriptor = remoteIndexer.getRemoteSessions(alias).sessions.find(s => s.sessionId === sessionId); const localPtySize = normalizePtySize(initialSize); const attachResult = descriptor diff --git a/public/sidebar.js b/public/sidebar.js index 186480a3..08389818 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -1476,7 +1476,8 @@ function buildSessionItem(session) { actions.appendChild(stopBtn); if (session.type !== 'terminal') { actions.appendChild(forkBtn); - actions.appendChild(jsonlBtn); + // see .ai/contexts/session-cache.md ("Remote hosts — descriptor-only sessions") + if (!session.placeholder) actions.appendChild(jsonlBtn); actions.appendChild(archiveBtn); actions.appendChild(launchConfigBtn); actions.appendChild(deleteBtn); diff --git a/remote-index.js b/remote-index.js index 7cd926bc..13b83f95 100644 --- a/remote-index.js +++ b/remote-index.js @@ -11,6 +11,7 @@ const { manifestPathFor, } = require('./remote-hosts'); const { syncMirror } = require('./remote-mirror'); +const { encodeProjectPath } = require('./encode-project-path'); const NOOP_LOG = { info() {}, warn() {}, error() {} }; @@ -23,6 +24,34 @@ function backoffDelayMs(failures, intervalMs) { return Math.min(intervalMs * Math.pow(2, failures - 1), MAX_BACKOFF_MS); } +// see .ai/contexts/session-cache.md ("Remote hosts — descriptor-only sessions") +function placeholderTitle(cwd) { + if (typeof cwd !== 'string' || !cwd) return null; + const trimmed = cwd.replace(/[\\/]+$/, ''); + const parts = trimmed.split(/[\\/]/); + return parts[parts.length - 1] || cwd; +} + +// see .ai/contexts/session-cache.md ("Remote hosts — descriptor-only sessions") +function buildPlaceholderSession(alias, descriptor) { + const id = (typeof descriptor.sessionId === 'string' && descriptor.sessionId) + ? descriptor.sessionId + : `pid:${descriptor.pid}`; + return { + sessionId: id, + remoteAlias: alias, + projectPath: descriptor.cwd, + folder: encodeProjectPath(descriptor.cwd), + remoteDescriptorSeen: true, + status: descriptor.status || null, + statusUpdatedAt: descriptor.statusUpdatedAt || null, + modified: descriptor.statusUpdatedAt || descriptor.startedAt || null, + messageCount: 0, + summary: placeholderTitle(descriptor.cwd), + placeholder: true, + }; +} + /** * Periodic mirror + index of every declared SSH host. * see .ai/contexts/session-cache.md ("Remote SSH hosts") @@ -317,6 +346,32 @@ function createRemoteIndexer(ctx) { }; } + // see .ai/contexts/session-cache.md ("Remote hosts — descriptor-only sessions") + function getPlaceholderSessions(alias) { + const list = remoteSessions.get(alias) || []; + const out = []; + for (const descriptor of list) { + if (!descriptor || !descriptor.descriptorOnly) continue; + if (typeof descriptor.cwd !== 'string' || !descriptor.cwd) continue; + out.push(buildPlaceholderSession(alias, descriptor)); + } + return out; + } + + function getAllPlaceholderSessions() { + const out = []; + for (const alias of remoteSessions.keys()) out.push(...getPlaceholderSessions(alias)); + return out; + } + + // see .ai/contexts/session-cache.md ("Remote hosts — descriptor-only sessions") + function findSessionAlias(sessionId) { + for (const [alias, list] of remoteSessions) { + if (list.some(s => s && s.sessionId === sessionId)) return alias; + } + return null; + } + // see .ai/contexts/session-state.md ("The two lifecycle verbs: detach and stop") function dropRemoteSession(alias, sessionId) { const list = remoteSessions.get(alias); @@ -331,9 +386,12 @@ function createRemoteIndexer(ctx) { start, stop, dispose, restart, refreshNow, refreshHostNow, isRunning: () => timer !== null, getRemoteSessions, + getPlaceholderSessions, + getAllPlaceholderSessions, + findSessionAlias, dropRemoteSession, getRemoteHostState, }; } -module.exports = { createRemoteIndexer, backoffDelayMs }; +module.exports = { createRemoteIndexer, backoffDelayMs, buildPlaceholderSession, placeholderTitle }; diff --git a/remote-transport.js b/remote-transport.js index 8bdf58bd..00abfbb6 100644 --- a/remote-transport.js +++ b/remote-transport.js @@ -63,6 +63,17 @@ function splitListOutput(stdout) { return { inventoryBlock: stdout.slice(0, idx), sessionsBlock: stdout.slice(afterIdx + 1) }; } +// see .ai/contexts/session-cache.md ("Remote hosts — descriptor-only sessions") +function transcriptSessionIds(files) { + const ids = new Set(); + for (const f of files) { + if (!f || typeof f.rel !== 'string' || !f.rel.endsWith('.jsonl')) continue; + const base = f.rel.slice(f.rel.lastIndexOf('/') + 1, -'.jsonl'.length); + if (base) ids.add(base); + } + return ids; +} + // see .ai/contexts/session-cache.md ("Remote SSH hosts (issue #211)", liveness marker) function parseSessions(block) { const lines = block.split('\n'); @@ -210,9 +221,11 @@ function createSshTransport(opts = {}) { if (res.code !== 0) throw new Error(`ssh inventory failed (exit ${res.code}): ${res.stderr.trim() || 'no stderr'}`); const { inventoryBlock, sessionsBlock } = splitListOutput(res.stdout); const files = parseInventory(inventoryBlock); - const { sessions, warnings, dropped } = parseSessions(sessionsBlock); + const { sessions: rawSessions, warnings, dropped } = parseSessions(sessionsBlock); for (const w of warnings) log.warn(`[remote:${alias}] ${w}`); if (dropped) log.warn(`[remote:${alias}] dropped ${dropped} dead session descriptor(s)`); + const transcriptIds = transcriptSessionIds(files); + const sessions = rawSessions.map(s => ({ ...s, descriptorOnly: !transcriptIds.has(s.sessionId) })); return { files, sessions }; } @@ -340,6 +353,7 @@ module.exports = { parseInventory, parseSessions, splitListOutput, + transcriptSessionIds, LIST_COMMAND, ALIVE_MARKER_PREFIX, REMOTE_PROJECTS_REL, diff --git a/test/dom-sidebar-remote-placeholder.test.js b/test/dom-sidebar-remote-placeholder.test.js new file mode 100644 index 00000000..6084445a --- /dev/null +++ b/test/dom-sidebar-remote-placeholder.test.js @@ -0,0 +1,88 @@ +// Issue #278: a live remote descriptor with no transcript yet gets a +// synthesized placeholder row (main.js's mergePlaceholderSessions, see +// test/merge-placeholder-sessions.test.js and +// .ai/contexts/session-cache.md, "Remote hosts — descriptor-only sessions"). +// This covers the DOM side: the row renders as alive and stoppable, shows +// its status/age, has no transcript affordance, and clicking it attaches. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { setupSidebarDom, makeSampleProject } = require('./dom-setup'); + +// Shape mirrors what main.js's toSidebarPlaceholderSession() + +// annotateRemoteAttachable() together produce for a descriptor-only session. +const PLACEHOLDER_SESSION = { + sessionId: 'placeholder-1', + summary: 'switchboard-test', + modified: 1787527436145, + starred: false, + archived: 0, + messageCount: 0, + projectPath: '/srv/echanges/switchboard-test', + remoteAlias: 'planificator', + remoteDescriptorSeen: true, + remoteAttachable: true, + status: 'busy', + statusUpdatedAt: Date.now() - 5000, + placeholder: true, +}; + +function projectWithPlaceholder() { + return makeSampleProject({ + projectPath: '/srv/echanges/switchboard-test', + folder: 'planificator::-srv-echanges-switchboard-test', + remoteAlias: 'planificator', + sessions: [PLACEHOLDER_SESSION], + }); +} + +function register(ctx, sessions) { + for (const s of sessions) ctx.window.sessionMap.set(s.sessionId, s); +} + +test('a placeholder row renders alive, with a stop control, status text, and no transcript affordance', () => { + const ctx = setupSidebarDom(); + try { + register(ctx, [PLACEHOLDER_SESSION]); + ctx.sidebar.renderProjects([projectWithPlaceholder()], true); + + const item = ctx.document.getElementById('si-placeholder-1'); + assert.ok(item, 'the placeholder session must be rendered as a row'); + + assert.ok(item.classList.contains('is-alive'), 'a live descriptor renders the row as alive'); + + assert.ok(item.querySelector('.session-stop-btn'), 'the row is stoppable, same control as any other session'); + + const statusEl = item.querySelector('.session-status'); + assert.ok(statusEl, 'status + age must be shown'); + assert.match(statusEl.textContent, /busy/); + assert.match(statusEl.textContent, /ago/); + + assert.equal(item.querySelector('.session-jsonl-btn'), null, + 'no transcript affordance — there is nothing to view yet'); + + const badge = item.querySelector('.remote-badge'); + assert.ok(badge); + assert.equal(badge.textContent, 'planificator'); + } finally { ctx.destroy(); } +}); + +test('clicking a placeholder row attaches, it never opens the transcript viewer', () => { + const ctx = setupSidebarDom(); + try { + register(ctx, [PLACEHOLDER_SESSION]); + ctx.sidebar.renderProjects([projectWithPlaceholder()], true); + + const opened = []; + const viewed = []; + ctx.window.openSession = (s) => opened.push(s.sessionId); + ctx.window.showJsonlViewer = (s) => viewed.push(s.sessionId); + + const item = ctx.document.getElementById('si-placeholder-1'); + item.onclick(); + + assert.deepEqual(opened, ['placeholder-1'], 'an attachable placeholder opens a terminal, like any other attachable remote row'); + assert.deepEqual(viewed, [], 'the transcript viewer is never reached — there is no transcript'); + } finally { ctx.destroy(); } +}); diff --git a/test/get-projects-cold-start-reconcile.test.js b/test/get-projects-cold-start-reconcile.test.js index b5131332..adc52042 100644 --- a/test/get-projects-cold-start-reconcile.test.js +++ b/test/get-projects-cold-start-reconcile.test.js @@ -45,17 +45,21 @@ function makeHandler(mocks) { const fn = new Function( 'isCachePopulated', 'isSearchIndexPopulated', 'isInitialScanComplete', 'populateCacheViaWorker', - 'reconcileCacheFromFilesystem', 'buildProjectsFromCache', 'annotateRemoteAttachable', 'showArchived', + 'reconcileCacheFromFilesystem', 'buildProjectsFromCache', 'mergePlaceholderSessions', + 'annotateRemoteAttachable', 'showArchived', body ); - // annotateRemoteAttachable (remote-attach join, issue #221) is irrelevant to - // the populate/reconcile/build ordering this file locks down -- a passthrough - // stands in for it unless a test overrides it. + // annotateRemoteAttachable (remote-attach join, issue #221) and + // mergePlaceholderSessions (descriptor-only sessions, issue #278) are both + // irrelevant to the populate/reconcile/build ordering this file locks down + // -- a passthrough stands in for each unless a test overrides it. const annotateRemoteAttachable = mocks.annotateRemoteAttachable || (projects => projects); + const mergePlaceholderSessions = mocks.mergePlaceholderSessions || (projects => projects); return () => fn( mocks.isCachePopulated, mocks.isSearchIndexPopulated, mocks.isInitialScanComplete, mocks.populateCacheViaWorker, - mocks.reconcileCacheFromFilesystem, mocks.buildProjectsFromCache, annotateRemoteAttachable, false + mocks.reconcileCacheFromFilesystem, mocks.buildProjectsFromCache, mergePlaceholderSessions, + annotateRemoteAttachable, false ); } diff --git a/test/merge-placeholder-sessions.test.js b/test/merge-placeholder-sessions.test.js new file mode 100644 index 00000000..7fba3fb0 --- /dev/null +++ b/test/merge-placeholder-sessions.test.js @@ -0,0 +1,143 @@ +// test/merge-placeholder-sessions.test.js — issue #278. +// +// mergePlaceholderSessions() (main.js) folds remote-index.js's synthesized +// descriptor-only sessions into the sidebar payload, before +// annotateRemoteAttachable() runs. Extracted from the real main.js source +// (same brace-matching technique test/annotate-remote-attachable-local-status.test.js +// uses) so this exercises the actual shipped logic. + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const root = path.join(__dirname, '..'); + +function extractMergePlaceholderSessionsSource() { + const src = fs.readFileSync(path.join(root, 'main.js'), 'utf8'); + const start = src.indexOf('function toSidebarPlaceholderSession(ph)'); + assert.ok(start !== -1, 'main.js must define toSidebarPlaceholderSession'); + const marker2 = 'function mergePlaceholderSessions(projects)'; + const start2 = src.indexOf(marker2, start); + assert.ok(start2 !== -1, 'main.js must define mergePlaceholderSessions'); + const bodyOpen = src.indexOf('{', start2); + let depth = 0, end = -1; + for (let i = bodyOpen; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { depth--; if (depth === 0) { end = i; break; } } + } + assert.ok(end !== -1, 'mergePlaceholderSessions body must be balanced'); + return src.slice(start, end + 1); +} + +function makeMerge(remoteIndexer) { + const source = extractMergePlaceholderSessionsSource(); + const factory = new Function( + 'remoteIndexer', 'joinFolderKey', + source + '\nreturn mergePlaceholderSessions;' + ); + return factory(remoteIndexer, (alias, folder) => `${alias}::${folder}`); +} + +function fakePlaceholder(overrides = {}) { + return { + sessionId: 'ph-1', + remoteAlias: 'vps', + projectPath: '/srv/echanges/switchboard-test', + folder: '-srv-echanges-switchboard-test', + remoteDescriptorSeen: true, + status: 'busy', + statusUpdatedAt: 111, + modified: 111, + summary: 'switchboard-test', + placeholder: true, + ...overrides, + }; +} + +test('adds the placeholder session to an already-existing project group', () => { + const merge = makeMerge({ getAllPlaceholderSessions: () => [fakePlaceholder()] }); + const projects = [{ + folder: 'vps::-srv-echanges-switchboard-test', + projectPath: '/srv/echanges/switchboard-test', + remoteAlias: 'vps', + sessions: [{ sessionId: 'other-real-session', remoteAlias: 'vps' }], + missing: false, + }]; + + merge(projects); + + assert.equal(projects.length, 1, 'no new project group when one already exists'); + assert.equal(projects[0].sessions.length, 2); + const ph = projects[0].sessions.find(s => s.sessionId === 'ph-1'); + assert.ok(ph, 'the placeholder session was appended'); + assert.equal(ph.placeholder, true); + assert.equal(ph.remoteAlias, 'vps'); + assert.equal(ph.remoteDescriptorSeen, true); + assert.equal(ph.status, 'busy'); + assert.equal(ph.statusUpdatedAt, 111); + assert.equal(ph.summary, 'switchboard-test'); +}); + +test('creates a new project group when no session for that host+cwd exists yet', () => { + const merge = makeMerge({ getAllPlaceholderSessions: () => [fakePlaceholder()] }); + const projects = []; + + merge(projects); + + assert.equal(projects.length, 1, 'a brand-new project appears for the descriptor-only session'); + const project = projects[0]; + assert.equal(project.remoteAlias, 'vps'); + assert.equal(project.projectPath, '/srv/echanges/switchboard-test'); + assert.equal(project.folder, 'vps::-srv-echanges-switchboard-test'); + assert.equal(project.missing, false); + assert.equal(project.sessions.length, 1); + assert.equal(project.sessions[0].sessionId, 'ph-1'); +}); + +// Acceptance (issue #278): "when the transcript appears, the row is the same +// row (no duplicate, no flicker)". Once buildProjectsFromCache() has already +// indexed the real session under the SAME id, the placeholder must not be +// appended a second time next to it. +test('does not duplicate when a real session with the same id already won the race', () => { + const merge = makeMerge({ getAllPlaceholderSessions: () => [fakePlaceholder({ sessionId: 'now-real' })] }); + const projects = [{ + folder: 'vps::-srv-echanges-switchboard-test', + projectPath: '/srv/echanges/switchboard-test', + remoteAlias: 'vps', + sessions: [{ sessionId: 'now-real', remoteAlias: 'vps', summary: 'the real transcript' }], + missing: false, + }]; + + merge(projects); + + assert.equal(projects[0].sessions.length, 1, 'no duplicate row for the same sessionId'); + assert.equal(projects[0].sessions[0].summary, 'the real transcript', 'the real row is untouched'); +}); + +test('no placeholders: the projects array is returned unchanged', () => { + const merge = makeMerge({ getAllPlaceholderSessions: () => [] }); + const projects = [{ folder: 'local', projectPath: '/home/dev', sessions: [], missing: false }]; + + const result = merge(projects); + + assert.equal(result, projects); + assert.deepEqual(projects, [{ folder: 'local', projectPath: '/home/dev', sessions: [], missing: false }]); +}); + +test('two placeholders on two different hosts each get their own group', () => { + const merge = makeMerge({ + getAllPlaceholderSessions: () => [ + fakePlaceholder({ sessionId: 'a', remoteAlias: 'vps', projectPath: '/srv/a', folder: '-srv-a' }), + fakePlaceholder({ sessionId: 'b', remoteAlias: 'other', projectPath: '/srv/a', folder: '-srv-a' }), + ], + }); + const projects = []; + + merge(projects); + + assert.equal(projects.length, 2, 'same projectPath on two aliases stays two groups'); + assert.deepEqual(projects.map(p => p.remoteAlias).sort(), ['other', 'vps']); +}); diff --git a/test/remote-index.test.js b/test/remote-index.test.js index 8a2bb719..ba6ac7bd 100644 --- a/test/remote-index.test.js +++ b/test/remote-index.test.js @@ -721,6 +721,134 @@ test('refreshHostNow({force:true}) ignores backoff, runs the transport, and clea } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } }); +// Issue #278 — see .ai/contexts/session-cache.md ("Remote hosts — +// descriptor-only sessions"). A live descriptor with no transcript yet gets a +// synthesized placeholder session; one with a transcript already indexed +// (descriptorOnly: false, or the field simply absent, as older/local fixtures +// never carry it) does not. +test('getPlaceholderSessions synthesizes a row for a descriptor-only session, not for one with a transcript', async () => { + const dataDir = tmp('idx-placeholder'); + try { + const indexer = createRemoteIndexer({ + getHosts: () => [{ alias: 'vps' }], + dataDir, + transport: {}, + scanFolders: () => Promise.resolve({ ok: true }), + listIndexedFolderKeys: () => [], + timers: fakeTimers(), + sync: async () => ({ + fetched: 0, unchanged: 0, removed: 0, failed: 0, total: 0, + changedFolders: new Set(), + sessions: [ + { pid: 1, sessionId: 'no-transcript', cwd: '/srv/echanges/switchboard-test', status: 'busy', statusUpdatedAt: 123, descriptorOnly: true }, + { pid: 2, sessionId: 'has-transcript', cwd: '/srv/echanges/other', descriptorOnly: false }, + ], + }), + }); + await indexer.refreshNow(); + + const placeholders = indexer.getPlaceholderSessions('vps'); + assert.equal(placeholders.length, 1, 'only the descriptor-only session gets a placeholder'); + const ph = placeholders[0]; + assert.equal(ph.sessionId, 'no-transcript', 'prefers the descriptor\'s own session id'); + assert.equal(ph.remoteAlias, 'vps'); + assert.equal(ph.remoteDescriptorSeen, true); + assert.equal(ph.status, 'busy'); + assert.equal(ph.statusUpdatedAt, 123); + assert.equal(ph.placeholder, true); + assert.equal(ph.summary, 'switchboard-test', 'title is the cwd basename'); + assert.equal(ph.projectPath, '/srv/echanges/switchboard-test'); + + assert.deepEqual(indexer.getAllPlaceholderSessions(), placeholders, + 'getAllPlaceholderSessions aggregates across every known alias'); + } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } +}); + +// Mutation proof for the deliverable's acceptance criterion: with the +// synthesis line commented out, this test goes red (empty array instead of +// one placeholder) — see the HANDOFF for the exact line. +test('getPlaceholderSessions skips a descriptor with no cwd (nothing to group it under)', async () => { + const dataDir = tmp('idx-placeholder-no-cwd'); + try { + const indexer = createRemoteIndexer({ + getHosts: () => [{ alias: 'vps' }], + dataDir, + transport: {}, + scanFolders: () => Promise.resolve({ ok: true }), + listIndexedFolderKeys: () => [], + timers: fakeTimers(), + sync: async () => ({ + fetched: 0, unchanged: 0, removed: 0, failed: 0, total: 0, + changedFolders: new Set(), + sessions: [{ pid: 1, sessionId: 'no-cwd', descriptorOnly: true }], + }), + }); + await indexer.refreshNow(); + + assert.deepEqual(indexer.getPlaceholderSessions('vps'), []); + } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } +}); + +// Replacement in place: once the transcript is indexed, the transport stops +// reporting the descriptor as descriptorOnly (test/remote-transport.test.js +// covers that half) — the index side of "no duplicate row" is that the +// placeholder simply stops being offered under the SAME session id the real +// row already uses, so the sidebar's key-by-sessionId merge is a no-op swap, +// not an add. +test('getPlaceholderSessions stops offering a placeholder once its transcript is indexed', async () => { + const dataDir = tmp('idx-placeholder-replace'); + try { + let descriptorOnly = true; + const indexer = createRemoteIndexer({ + getHosts: () => [{ alias: 'vps' }], + dataDir, + transport: {}, + scanFolders: () => Promise.resolve({ ok: true }), + listIndexedFolderKeys: () => [], + timers: fakeTimers(), + sync: async () => ({ + fetched: 0, unchanged: 0, removed: 0, failed: 0, total: 0, + changedFolders: new Set(), + sessions: [{ pid: 1, sessionId: 'same-id', cwd: '/srv/a', descriptorOnly }], + }), + }); + + await indexer.refreshNow(); + assert.equal(indexer.getPlaceholderSessions('vps').length, 1, 'placeholder present before the transcript appears'); + + descriptorOnly = false; // the next cycle's transport saw the .jsonl + await indexer.refreshNow(); + assert.deepEqual(indexer.getPlaceholderSessions('vps'), [], 'no placeholder once the real transcript is indexed'); + } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } +}); + +// main.js's open-terminal falls back to this when getCachedFolder(sessionId) +// finds nothing — the normal case for a placeholder, which has no +// session_cache row. see .ai/contexts/session-cache.md ("Remote hosts — +// descriptor-only sessions"). +test('findSessionAlias finds the host owning a live descriptor, by sessionId alone', async () => { + const dataDir = tmp('idx-find-alias'); + try { + const indexer = createRemoteIndexer({ + getHosts: () => [{ alias: 'vps' }, { alias: 'other' }], + 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: alias === 'vps' ? [{ pid: 1, sessionId: 'mine', cwd: '/srv/a', descriptorOnly: true }] : [], + }), + }); + await indexer.refreshNow(); + + assert.equal(indexer.findSessionAlias('mine'), 'vps'); + assert.equal(indexer.findSessionAlias('nobody-has-this'), null); + } finally { fs.rmSync(dataDir, { recursive: true, force: true }); } +}); + test('refreshNow({force:true}) ignores backoff for every host and resets it on success', async () => { const dataDir = tmp('idx-refreshall-force'); try { diff --git a/test/remote-transport.test.js b/test/remote-transport.test.js index 9e6697a6..3365aa88 100644 --- a/test/remote-transport.test.js +++ b/test/remote-transport.test.js @@ -97,7 +97,7 @@ test('listFiles spawns one bounded ssh with the alias as an operand, never as a assert.ok(command.includes('find .claude/sessions'), 'the session descriptors must ride the same command'); assert.deepEqual(result, { files: [{ rel: '-srv-a/a.jsonl', size: 9, mtimeMs: 1757200000000 }], - sessions: [{ pid: 123, sessionId: 'abc' }], + sessions: [{ pid: 123, sessionId: 'abc', descriptorOnly: true }], }); assert.equal(t.liveCount(), 0, 'the child is unregistered once it closes'); }); @@ -174,7 +174,7 @@ test('the real wire-format marker (raw SOH-framed bytes) is recognized with zero const result = await t.listFiles('vps'); - assert.deepEqual(result.sessions, [{ pid: 1, sessionId: 'x' }]); + assert.deepEqual(result.sessions, [{ pid: 1, sessionId: 'x', descriptorOnly: true }]); assert.deepEqual(warnings, [], 'a well-formed descriptor after the real marker must never warn'); }); @@ -313,6 +313,68 @@ test('parseSessions: the ALIVE marker line is consumed and never itself warns as assert.deepEqual(warnings, [], 'the marker lines must never be parsed as their own descriptor'); }); +// Issue #278: a CLI launched in tmux writes its descriptor at once but no +// transcript until the first prompt — see .ai/contexts/session-cache.md +// ("Remote hosts — descriptor-only sessions"). +test('listFiles marks a live descriptor with no matching transcript as descriptorOnly', async () => { + const spawn = spawnRecorder((child) => { + // Inventory has no file for this session at all. + child.stdout.push('1757200000.0\t9\t-srv-a/other-session.jsonl\n'); + child.stdout.push(SESSIONS_MARKER + '\n'); + child.stdout.push(JSON.stringify({ pid: 1, sessionId: 'no-transcript-yet', cwd: '/srv/a' }) + '\n'); + child.stdout.push(`${ALIVE_MARKER_PREFIX}1\n`); + child.stdout.push(null); + child.emit('close', 0); + }); + const t = createSshTransport({ spawn }); + + const result = await t.listFiles('vps'); + + assert.deepEqual(result.sessions, [ + { pid: 1, sessionId: 'no-transcript-yet', cwd: '/srv/a', descriptorOnly: true }, + ]); +}); + +test('listFiles marks a live descriptor as NOT descriptorOnly once its transcript is in the inventory', async () => { + const spawn = spawnRecorder((child) => { + child.stdout.push('1757200000.0\t9\t-srv-a/has-transcript.jsonl\n'); + child.stdout.push(SESSIONS_MARKER + '\n'); + child.stdout.push(JSON.stringify({ pid: 1, sessionId: 'has-transcript', cwd: '/srv/a' }) + '\n'); + child.stdout.push(`${ALIVE_MARKER_PREFIX}1\n`); + child.stdout.push(null); + child.emit('close', 0); + }); + const t = createSshTransport({ spawn }); + + const result = await t.listFiles('vps'); + + assert.deepEqual(result.sessions, [ + { pid: 1, sessionId: 'has-transcript', cwd: '/srv/a', descriptorOnly: false }, + ]); +}); + +// Acceptance (issue #278): a live descriptor-only entry survives, a dead one +// (ALIVE:0) is still dropped — in the same cycle. +test('a live descriptor-only entry is kept and a dead descriptor is still dropped', async () => { + const spawn = spawnRecorder((child) => { + child.stdout.push('1757200000.0\t9\t-srv-a/unrelated.jsonl\n'); + child.stdout.push(SESSIONS_MARKER + '\n'); + child.stdout.push(JSON.stringify({ pid: 1, sessionId: 'alive-no-transcript', cwd: '/srv/a' }) + '\n'); + child.stdout.push(`${ALIVE_MARKER_PREFIX}1\n`); + child.stdout.push(JSON.stringify({ pid: 2, sessionId: 'dead-no-transcript', cwd: '/srv/a' }) + '\n'); + child.stdout.push(`${ALIVE_MARKER_PREFIX}0\n`); + child.stdout.push(null); + child.emit('close', 0); + }); + const t = createSshTransport({ spawn }); + + const result = await t.listFiles('vps'); + + assert.deepEqual(result.sessions, [ + { pid: 1, sessionId: 'alive-no-transcript', cwd: '/srv/a', descriptorOnly: true }, + ]); +}); + test('a non-zero ssh exit is an error, not an empty inventory', async () => { const spawn = spawnRecorder((child) => { child.stderr.push('Permission denied (publickey).');