diff --git a/.ai/contexts/cli-session-state.md b/.ai/contexts/cli-session-state.md index 17ccff13..dc1e15a4 100644 --- a/.ai/contexts/cli-session-state.md +++ b/.ai/contexts/cli-session-state.md @@ -328,6 +328,62 @@ The Agents view (`.ai/contexts/bg-agents.md`) reads the same `jobId`, so `guardResume` can answer a `kind: 'bg'` session with an attach instead of a resume confirmation. +## Owner of a /clear transcript + +`/clear` does not continue the open transcript: the CLI opens a **new jsonl +under a new session id** in the same project folder, and writes into it only +the bookkeeping of the command (a `` record, then the +`/clear` record). Nothing in that file names the +session it replaced — no `forkedFrom`, and the `sessionId` on every record is +the new one. `detectSessionTransitions()` (`session-transitions.js`) used to +match new files by those two fields only, so after a `/clear` the open +terminal stayed on the old row and the new conversation, once its first prompt +made it indexable, showed up as a second, unattached row. + +The state file is what links the two: the CLI rewrites +`~/.claude/sessions/.json` with the new `sessionId` on `/clear` (checked +2026-10-05 on CLI 2.1.28x: live state files whose `sessionId` names a +transcript that starts with the `/clear` record). `clearOwner(newId, ptyPid)` +finds the live pid whose state file names `newId` and walks its parent chain +(`/proc//stat`) up to the PTY's pid. Verdicts: + +- `mine` — the CLI runs under this PTY: re-key, exactly like a fork + (`session-forked` to the renderer). +- `other` — another process (a CLI started outside Switchboard, another tab in + the same folder): not this session's file. +- `pending` — no live state file names `newId` yet (the jsonl can land before + the state file is rewritten): the file stays out of `knownJsonlFiles`, and + `scheduleRecheck` runs the folder's detection again a second later, while + the file is less than 60 s old. Nothing else would: the state file is not in + the watched folder, and the transcript gets no other write before the first + prompt. A new file whose first records are not written yet is rechecked the + same way. +- `unknown` — the pid is found but its parent cannot be read (no `/proc`: + macOS, Windows). Never matched, not even when this is the only Claude PTY in + the folder: a `claude` run outside the app in the same folder would be taken + over. The file is recorded and not rechecked, so on macOS and Windows a + `/clear` is followed only when the PTY's own pid is the CLI's. + +Re-keying keeps `oldId → newId` (`currentSessionId`), so a trigger chain that +sent `/clear` reaches the same terminal on its next step (see +trigger-watcher.md, "Re-keyed sessions"). A `/clear` transcript starts with a +`file-history-snapshot` record, so the "snapshot-only fork file" matcher +skips files whose first real turn is `/clear`; otherwise a session waiting for +its fork would take another session's `/clear` as its fork. + +`readNewSessionSignals()` recognises the file by its first user record that is +not local-command bookkeeping, through `classifyUserText()` — the caveat record +comes first and must be skipped, not taken as the file's first turn. + +The renderer re-keys as for a fork, and when the old id was not a pending +(transcript-less) row it adds one for the new id titled "New session": the +`/clear` transcript is not indexed until its first prompt (see +session-cache.md), so without it the open terminal would have no row at all +until then. Like any pending row, it is left out of the saved working set +until that prompt (docs/session-restore.md, "A session with no transcript +yet"). The old conversation stays in the list as an ordinary, +resumable session — it is a real transcript on disk. + ## Canary tests `test/canary-*.test.js` is a convention this module introduces. A canary diff --git a/.ai/contexts/trigger-watcher.md b/.ai/contexts/trigger-watcher.md index 2ad07ad2..c873601b 100644 --- a/.ai/contexts/trigger-watcher.md +++ b/.ai/contexts/trigger-watcher.md @@ -40,6 +40,18 @@ user typed and has not submitted (`composer-state.js`, fed from `null` for an unknown or exited session, and **a `null` — or an absent `getComposerState` — means busy, never free**. +### Re-keyed sessions + +A fork or a `/clear` re-keys the open session in `activeSessions` under its new +id (`session-transitions.js`; see cli-session-state.md, "Owner of a /clear +transcript"), while a chain keeps the id it was given, and `/clear` is one of +the commands a chain may send. `createTriggerContext` takes `resolveSessionId` +(`sessionTransitions.currentSessionId` in main), and every lookup it makes +(`getPtyForSession`, `isSessionBusy`, `getComposerState`, `getTranscriptTurn`, +`getCliStatus`) goes through it, so the old id keeps reaching the same terminal +and the CLI status is read under the id the CLI now writes. Results still +carry the id the trigger named. + ### Session handle (2026-09-08, issue #220) `trigger-watcher.js` never touches `session.pty` or a raw pid. It writes and @@ -953,7 +965,8 @@ session, which has no local descriptor). No new watcher: it reuses the cache the CLI reacted. `idle` alone covers a turn too fast for a poll to see `busy`; `waiting` is a permission dialog our Enter opened. A spinner on the level probe, or a status that began earlier, proves nothing. Otherwise the - existing recovery applies (one bare ` `, only into a free composer, same + existing recovery applies (one bare ` +`, only into a free composer, same window), and the reaction is looked for again. Still nothing: `confirmed: false`. - **The recovery Enter is never written while the descriptor reads `waiting` diff --git a/CHANGELOG.md b/CHANGELOG.md index d681fabb..032d3aa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc - 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) +- `/clear` in a session keeps the open terminal on its sidebar row, which now follows the new conversation, instead of listing that conversation as a separate session. The cleared conversation stays in the list as a past session. (#477) - 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) ## v0.0.89 — 2026-10-04 diff --git a/cli-session-state.js b/cli-session-state.js index 51adbdf8..b709ad8a 100644 --- a/cli-session-state.js +++ b/cli-session-state.js @@ -422,6 +422,34 @@ async function findLiveProcess(sessionId, { exclude = () => false } = {}) { return (await scanLiveProcesses(new Set([key]), exclude)).get(key) || null; } +// see .ai/contexts/cli-session-state.md ("Owner of a /clear transcript") +function clearOwner(sessionId, ptyPid) { + if (typeof sessionId !== 'string' || !sessionId || !Number.isInteger(ptyPid)) return 'pending'; + let names; + try { names = fs.readdirSync(dir); } catch { return 'pending'; } + const key = sessionId.toLowerCase(); + let pid = null; + for (const name of names) { + if (!STATE_FILE_RE.test(name)) continue; + let raw; + try { raw = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')); } catch { continue; } + if (!raw || typeof raw.sessionId !== 'string' || raw.sessionId.toLowerCase() !== key) continue; + if (!Number.isInteger(raw.pid) || raw.pid <= 0) continue; + if (!isProcessAlive(raw.pid)) continue; + pid = raw.pid; + break; + } + if (pid == null) return 'pending'; + let current = pid; + for (let depth = 0; depth < 64 && current && current > 1; depth++) { + if (current === ptyPid) return 'mine'; + const parent = readParentPid(current); + if (parent == null) return depth === 0 ? 'unknown' : 'other'; + current = parent; + } + return 'other'; +} + function ownProcessFilter(ptyPids) { const own = new Set(ptyPids()); return (pid) => own.has(pid) || descendsFromThisProcess(pid); @@ -462,6 +490,7 @@ module.exports = { liveElsewhere, liveElsewhereMany, liveElsewhereChecked, + clearOwner, MAX_LIVE_QUERY_IDS, MAX_PROBE_PIDS, probeProcStartWindows, diff --git a/docs/session-restore.md b/docs/session-restore.md index f5f74624..64d06639 100644 --- a/docs/session-restore.md +++ b/docs/session-restore.md @@ -21,6 +21,14 @@ Each time a session is opened or closed, Switchboard saves the open set — each session's id and project, and which one was active — in its global settings (`openWorkingSet`). +### A session with no transcript yet + +A session started with **+**, or one just cleared with `/clear`, has no +conversation until its first prompt: the CLI has nothing to resume, and the +index does not list it. It is left out of the saved set, so the next start +does not report it as "not in the index". Once its first prompt makes it a +real session, the set is saved again with it. + ## What restore does Restore is a respawn, not a reattach: a session's process is a child of the diff --git a/main.js b/main.js index 818a7bf4..e719875d 100644 --- a/main.js +++ b/main.js @@ -3095,6 +3095,7 @@ const sessionTransitions = require('./session-transitions'); sessionTransitions.init({ PROJECTS_DIR, activeSessions, getMainWindow: () => mainWindow, log, rekeyMcpServer, rekeyActivity: (fromId, toId) => activityReporter.rekey(fromId, toId), + clearOwner: (sessionId, ptyPid) => cliSessionState.clearOwner(sessionId, ptyPid), }); const { detectSessionTransitions } = sessionTransitions; @@ -3412,6 +3413,7 @@ if (!gotSingleInstanceLock) { try { require('./trigger-watcher').start(createTriggerContext({ activeSessions, log, getCliStatus: (id) => cliSessionState.getStatus(id), projectsDir: PROJECTS_DIR, + resolveSessionId: (id) => sessionTransitions.currentSessionId(id), get remote() { const settings = getSetting('global') || {}; if ((settings.remoteTriggers ?? SETTING_DEFAULTS.remoteTriggers) === true) { diff --git a/public/app.js b/public/app.js index 6420ebab..18e0cc24 100644 --- a/public/app.js +++ b/public/app.js @@ -181,6 +181,8 @@ function persistWorkingSet() { if (entry.session.type === 'terminal') continue; // exclude plain shells if (entry.attach) continue; // attach tabs are not restored if (entry.closed) continue; + // see docs/session-restore.md ("A session with no transcript yet") + if (pendingSessions.has(sessionId)) continue; set.push({ sessionId, projectPath: entry.session.projectPath, @@ -510,6 +512,11 @@ window.api.onSessionForked((oldId, newId) => { if (pendingEntry) { pendingEntry.sessionId = newId; pendingSessions.set(newId, pendingEntry); + } else if (entry.session.projectPath) { + // see .ai/contexts/cli-session-state.md ("Owner of a /clear transcript") + const projectPath = entry.session.projectPath; + const session = { ...entry.session, sessionId: newId, summary: 'New session', firstPrompt: '', name: null, messageCount: 0 }; + pendingSessions.set(newId, { session, projectPath, folder: encodeProjectPath(projectPath) }); } sessionMap.delete(oldId); sessionMap.set(newId, entry.session); @@ -1157,6 +1164,7 @@ async function loadProjects({ resort = false } = {}) { const realExists = allProjects.some(p => p.sessions.some(s => s.sessionId === sid)); if (realExists) { pendingSessions.delete(sid); + if (openSessions.has(sid)) schedulePersistWorkingSet(); } else { hasReinjected = true; // Still pending — re-inject into cached data diff --git a/session-transitions.js b/session-transitions.js index ec0cf6ad..f62fab6a 100644 --- a/session-transitions.js +++ b/session-transitions.js @@ -1,6 +1,6 @@ const path = require('path'); const fs = require('fs'); -const { readSubagentMeta } = require('./read-session-file'); +const { readSubagentMeta, classifyUserText } = require('./read-session-file'); const { enabled: TRACE, trace } = require('./activity-trace'); const { SUBAGENT_LIVE_TTL_MS } = require('./public/subagent-timing'); @@ -8,7 +8,7 @@ const { SUBAGENT_LIVE_TTL_MS } = require('./public/subagent-timing'); * Fork detection for active PTY sessions. * Call init(ctx) once with shared context. */ -let PROJECTS_DIR, activeSessions, getMainWindow, log, rekeyMcpServer, rekeyActivity; +let PROJECTS_DIR, activeSessions, getMainWindow, log, rekeyMcpServer, rekeyActivity, clearOwner, setTimeoutFn; function init(ctx) { PROJECTS_DIR = ctx.PROJECTS_DIR; @@ -17,6 +17,35 @@ function init(ctx) { log = ctx.log; rekeyMcpServer = ctx.rekeyMcpServer; rekeyActivity = ctx.rekeyActivity || (() => {}); + clearOwner = ctx.clearOwner || (() => 'pending'); + setTimeoutFn = ctx.setTimeout || setTimeout; + rekeyed.clear(); + for (const timer of recheckTimers.values()) clearTimeout(timer); + recheckTimers.clear(); +} + +// see .ai/contexts/cli-session-state.md ("Owner of a /clear transcript") +const rekeyed = new Map(); +const MAX_REKEY_HOPS = 32; + +/** The id a session is keyed under now, following the re-keys of forks and /clear. */ +function currentSessionId(sessionId) { + let id = sessionId; + for (let hop = 0; hop < MAX_REKEY_HOPS && rekeyed.has(id); hop++) id = rekeyed.get(id); + return id; +} + +const recheckTimers = new Map(); +const RECHECK_MS = 1000; + +function scheduleRecheck(folder) { + if (recheckTimers.has(folder)) return; + const timer = setTimeoutFn(() => { + recheckTimers.delete(folder); + detectSessionTransitions(folder); + }, RECHECK_MS); + if (timer && typeof timer.unref === 'function') timer.unref(); + recheckTimers.set(folder, timer); } // --- Subagent spawn / completion detection --- @@ -309,6 +338,7 @@ function readNewSessionSignals(filePath) { let slug = null; let parentSessionId = null; let hasSnapshots = false; + let cleared = false; for (const line of lines) { const entry = JSON.parse(line); // Skip snapshot lines — they carry no fork/session signals @@ -317,16 +347,29 @@ function readNewSessionSignals(filePath) { if (entry.slug && !slug) slug = entry.slug; // --fork-session copies messages with original sessionId if (entry.sessionId && !parentSessionId) parentSessionId = entry.sessionId; + if (entry.type === 'user') { + const text = typeof entry.message?.content === 'string' ? entry.message.content : ''; + const kind = classifyUserText(text); + // see .ai/contexts/cli-session-state.md ("Owner of a /clear transcript") + if (kind.kind === 'skip') continue; + cleared = kind.kind === 'command' && kind.text === '/clear'; + } // Stop after finding a user or assistant message if (entry.type === 'user' || entry.type === 'assistant') break; } - return { forkedFrom, slug, parentSessionId, hasSnapshots }; + return { forkedFrom, slug, parentSessionId, hasSnapshots, cleared }; } catch { - return { forkedFrom: null, slug: null, parentSessionId: null, hasSnapshots: false }; + return { forkedFrom: null, slug: null, parentSessionId: null, hasSnapshots: false, cleared: false }; } } -/** Detect fork transitions for active PTY sessions in a folder */ +const CLEAR_OWNER_WAIT_MS = 60000; + +function isFresh(filePath) { + try { return Date.now() - fs.statSync(filePath).mtimeMs < CLEAR_OWNER_WAIT_MS; } catch { return false; } +} + +/** Detect fork and /clear transitions for active PTY sessions in a folder */ function detectSessionTransitions(folder) { const folderPath = path.join(PROJECTS_DIR, folder); let currentFiles; @@ -355,6 +398,7 @@ function detectSessionTransitions(folder) { if (newFiles.length === 0) continue; const emptyFiles = new Set(); // files with no signals yet (still being written) + let recheck = false; for (const newFile of newFiles) { const newFilePath = path.join(folderPath, newFile); @@ -378,6 +422,7 @@ function detectSessionTransitions(folder) { log.info(`[detect] session=${sessionId} archiving stale empty file=${newId}`); } else { emptyFiles.add(newFile); + if (isFresh(newFilePath)) recheck = true; } continue; } @@ -400,17 +445,30 @@ function detectSessionTransitions(folder) { if (!matched && session.forkFrom && signals.parentSessionId === session.forkFrom && newId !== session.forkFrom) { matched = true; } - // Fork file with only snapshots — no user turn yet, but this session is waiting for a fork - if (!matched && signals.hasSnapshots && session.forkFrom && !session.realSessionId) { + // Fork file with only snapshots — no user turn yet, but this session is waiting for a fork. + // A /clear transcript also starts with a snapshot; it is never a fork. + if (!matched && signals.hasSnapshots && !signals.cleared && session.forkFrom && !session.realSessionId) { matched = true; } + const awaitingFork = Boolean(session.forkFrom && !session.realSessionId); + if (!matched && signals.cleared && !awaitingFork && newId !== (session.realSessionId || sessionId)) { + const owner = clearOwner(newId, session.pty && session.pty.pid); + if (owner === 'mine') { + matched = true; + } else if (owner === 'pending' && isFresh(newFilePath)) { + emptyFiles.add(newFile); + recheck = true; + } + log.info(`[detect] session=${sessionId} /clear file=${newId} owner=${owner} matched=${matched}`); + } + if (session.forkFrom && !matched) { log.info(`[detect] session=${sessionId} NO MATCH for newFile=${newId} forkFrom=${session.forkFrom} parentSessionId=${signals.parentSessionId||'null'} forkedFrom=${signals.forkedFrom||'null'}`); } if (matched) { - log.info(`[session-transition] ${sessionId} → ${newId} (fork)`); + log.info(`[session-transition] ${sessionId} → ${newId} (${signals.cleared && !awaitingFork ? 'clear' : 'fork'})`); session.knownJsonlFiles = new Set(currentFiles); session.realSessionId = newId; // Subagent scanning follows realSessionId into a different directory — @@ -420,6 +478,7 @@ function detectSessionTransitions(folder) { session._subFileList = null; activeSessions.delete(sessionId); activeSessions.set(newId, session); + rekeyed.set(sessionId, newId); // Re-key MCP server to match new session ID rekeyMcpServer(sessionId, newId); rekeyActivity(sessionId, newId); @@ -436,8 +495,9 @@ function detectSessionTransitions(folder) { const updated = new Set(currentFiles); for (const f of emptyFiles) updated.delete(f); session.knownJsonlFiles = updated; + if (recheck) scheduleRecheck(folder); } } -module.exports = { init, detectSessionTransitions, detectSubagentTransitions }; +module.exports = { init, detectSessionTransitions, detectSubagentTransitions, currentSessionId }; diff --git a/test/clear-pending-persist.test.js b/test/clear-pending-persist.test.js new file mode 100644 index 00000000..76dbed61 --- /dev/null +++ b/test/clear-pending-persist.test.js @@ -0,0 +1,78 @@ +'use strict'; + +// see docs/session-restore.md ("A session with no transcript yet") + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { setupSidebarDom } = require('./dom-setup'); +const { loadAppFunctions } = require('./app-source'); + +const PROJECT = { folder: '-srv-app', projectPath: '/srv/app', sessions: [] }; + +function setup() { + const ctx = setupSidebarDom(); + const { window } = ctx; + const settings = { global: { openWorkingSet: [] } }; + let persists = 0; + let projects = [PROJECT]; + Object.assign(window, { + loadingStatus: window.document.createElement('div'), + reportActivityFocus: () => {}, + reportActivityTitles: () => {}, + pollActiveSessions: async () => {}, + refreshSidebar: () => {}, + renderDefaultStatus: () => {}, + encodeProjectPath: (p) => p.replace(/\//g, '-'), + schedulePersistWorkingSet: () => { persists += 1; }, + pendingRestoreEntries: () => [], + }); + window.api = { + getProjects: async () => JSON.parse(JSON.stringify(projects)), + getActiveTerminals: async () => [], + getSetting: async (key) => JSON.parse(JSON.stringify(settings[key] || null)), + setSetting: async (key, value) => { settings[key] = JSON.parse(JSON.stringify(value)); }, + }; + window.cachedProjects = []; + window.cachedAllProjects = []; + const fns = loadAppFunctions(ctx.context, { + functions: ['dedup', 'loadProjects', 'persistWorkingSet'], + declarations: ['_persistChain', 'skippedWorkingSetEntries'], + }); + const open = (sessionId, { pending }) => { + const session = { sessionId, projectPath: '/srv/app', summary: 'New session' }; + window.openSessions.set(sessionId, { session, closed: false }); + if (pending) window.pendingSessions.set(sessionId, { session, projectPath: '/srv/app', folder: '-srv-app' }); + }; + return { + ...fns, open, settings, + persists: () => persists, + setProjects: (list) => { projects = list; }, + saved: () => settings.global.openWorkingSet.map((e) => e.sessionId), + destroy: () => ctx.destroy(), + }; +} + +test('a session with no transcript yet, after /clear or from +, is not saved: there is nothing to resume', async () => { + const h = setup(); + try { + h.open('real', { pending: false }); + h.open('cleared', { pending: true }); + await h.persistWorkingSet(); + assert.deepEqual(h.saved(), ['real']); + } finally { h.destroy(); } +}); + +test('its first prompt makes it real, and the set is saved again with it', async () => { + const h = setup(); + try { + h.open('cleared', { pending: true }); + await h.loadProjects(); + assert.equal(h.persists(), 0, 'still pending'); + h.setProjects([{ ...PROJECT, sessions: [{ sessionId: 'cleared', projectPath: '/srv/app' }] }]); + await h.loadProjects(); + assert.equal(h.persists(), 1); + await h.persistWorkingSet(); + assert.deepEqual(h.saved(), ['cleared']); + } finally { h.destroy(); } +}); diff --git a/test/cli-session-state.test.js b/test/cli-session-state.test.js index 24543134..5f0b6041 100644 --- a/test/cli-session-state.test.js +++ b/test/cli-session-state.test.js @@ -535,3 +535,32 @@ test('ownProcessFilter is exported and claims our own PTY pids', () => { assert.equal(isOwn(78), false); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); + +// see .ai/contexts/cli-session-state.md ("Owner of a /clear transcript") +test('clearOwner: walks the state file pid up to the PTY pid', () => { + const dir = mkTmp(); + try { + const parents = { 300: 200, 200: 100, 100: 1, 400: 1 }; + cliSessionState.init({ + dir, activeSessions: new Map(), log: silentLog, + isProcessAlive: (pid) => pid !== 500, + readParentPid: (pid) => parents[pid] ?? null, + }); + writeState(dir, 300, { sessionId: 'cleared', status: 'idle' }); + writeState(dir, 400, { sessionId: 'elsewhere', status: 'idle' }); + writeState(dir, 500, { sessionId: 'dead', status: 'idle' }); + writeState(dir, 600, { sessionId: 'no-proc', status: 'idle' }); + + assert.equal(cliSessionState.clearOwner('cleared', 100), 'mine'); + assert.equal(cliSessionState.clearOwner('CLEARED', 100), 'mine', 'ids compare in any letter case, as elsewhere since #487'); + assert.equal(cliSessionState.clearOwner('cleared', 300), 'mine', 'the PTY runs claude itself'); + assert.equal(cliSessionState.clearOwner('elsewhere', 100), 'other'); + assert.equal(cliSessionState.clearOwner('dead', 100), 'pending', 'a dead pid is no owner'); + assert.equal(cliSessionState.clearOwner('absent', 100), 'pending'); + assert.equal(cliSessionState.clearOwner('no-proc', 100), 'unknown', 'parent unreadable'); + assert.equal(cliSessionState.clearOwner('cleared', undefined), 'pending', 'no PTY pid'); + } finally { + cliSessionState.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/restore-live-elsewhere.test.js b/test/restore-live-elsewhere.test.js index 77fb7057..c38be5d0 100644 --- a/test/restore-live-elsewhere.test.js +++ b/test/restore-live-elsewhere.test.js @@ -63,6 +63,7 @@ function setup({ savedSet, liveIds, batchFails = false }) { vm.runInContext(read('resume-guard.js'), ctx); vm.runInContext(` var openSessions = new Map(); + var pendingSessions = new Map(); var sessionMap = new Map(); var activeSessionId = null; var restoringWorkingSet = false; diff --git a/test/restore-pending-persist.test.js b/test/restore-pending-persist.test.js index a62857c9..1fd0cf4b 100644 --- a/test/restore-pending-persist.test.js +++ b/test/restore-pending-persist.test.js @@ -29,6 +29,7 @@ function setup() { vm.runInContext(` var openSessions = new Map([['opened', { session: { projectPath: '/p' }, closed: false }]]); var sessionMap = new Map(); + var pendingSessions = new Map(); var activeSessionId = 'opened'; var _persistChain = Promise.resolve(); var RESTORE_STAGGER_MS = 0; diff --git a/test/session-transitions.test.js b/test/session-transitions.test.js index 6362719b..72887e55 100644 --- a/test/session-transitions.test.js +++ b/test/session-transitions.test.js @@ -727,7 +727,7 @@ test('a completed agent whose file is deleted is forgotten (knownSubagents stays }); /** Init the module with a capturing log and a caller-owned activeSessions map. */ -function setupForkDetection(projectsDir) { +function setupForkDetection(projectsDir, extra = {}) { const win = makeMockWindow(); const activeSessions = new Map(); const logLines = []; @@ -738,6 +738,7 @@ function setupForkDetection(projectsDir) { getMainWindow: () => win, log: { info: capture, debug: capture, warn: capture, error: capture }, rekeyMcpServer: () => {}, + ...extra, }); return { events: win._events, activeSessions, logLines }; } @@ -859,3 +860,258 @@ test('fork detection: the re-key drops the subagent scan state, so the new sessi cleanup(tmp); } }); + +// see .ai/contexts/cli-session-state.md ("Owner of a /clear transcript") +function writeClearTranscript(folderPath, id) { + const rec = (o) => JSON.stringify({ sessionId: id, ...o }); + fs.writeFileSync(path.join(folderPath, `${id}.jsonl`), [ + rec({ type: 'mode', mode: 'normal' }), + JSON.stringify({ type: 'file-history-snapshot', messageId: 'm1', snapshot: {} }), + rec({ type: 'user', isMeta: true, message: { role: 'user', content: 'Caveat: The messages below were generated by the user while running local commands.' } }), + rec({ type: 'user', message: { role: 'user', content: '/clear\n clear\n ' } }), + ].join('\n') + '\n', 'utf8'); +} + +test('/clear: the new transcript of the CLI under this PTY re-keys the session', () => { + const tmp = mkTmp(); + try { + const folder = 'proj'; + fs.mkdirSync(path.join(tmp, folder), { recursive: true }); + const calls = []; + const { events, activeSessions } = setupForkDetection(tmp, { + clearOwner: (id, ptyPid) => { calls.push([id, ptyPid]); return 'mine'; }, + }); + activeSessions.set('old-id', makePtySession(folder, { pty: { pid: 4242 } })); + + writeClearTranscript(path.join(tmp, folder), 'new-id'); + sessionTransitions.detectSessionTransitions(folder); + + assert.deepEqual(calls, [['new-id', 4242]]); + assert.ok(!activeSessions.has('old-id')); + assert.equal(activeSessions.get('new-id').realSessionId, 'new-id'); + const forked = events.find(e => e.channel === 'session-forked'); + assert.equal(forked.payload, 'old-id'); + } finally { + cleanup(tmp); + } +}); + +test('/clear: a transcript owned by another process is recorded and never re-keys', () => { + const tmp = mkTmp(); + try { + const folder = 'proj'; + fs.mkdirSync(path.join(tmp, folder), { recursive: true }); + const { events, activeSessions } = setupForkDetection(tmp, { clearOwner: () => 'other' }); + activeSessions.set('old-id', makePtySession(folder, { pty: { pid: 1 } })); + + writeClearTranscript(path.join(tmp, folder), 'new-id'); + sessionTransitions.detectSessionTransitions(folder); + + assert.ok(activeSessions.has('old-id')); + assert.ok(activeSessions.get('old-id').knownJsonlFiles.has('new-id.jsonl'), 'not rechecked'); + assert.equal(events.filter(e => e.channel === 'session-forked').length, 0); + } finally { + cleanup(tmp); + } +}); + +test('/clear: an owner not yet published is rechecked on the next flush, then matched', () => { + const tmp = mkTmp(); + try { + const folder = 'proj'; + fs.mkdirSync(path.join(tmp, folder), { recursive: true }); + let owner = 'pending'; + const { activeSessions } = setupForkDetection(tmp, { clearOwner: () => owner }); + activeSessions.set('old-id', makePtySession(folder, { pty: { pid: 7 } })); + + writeClearTranscript(path.join(tmp, folder), 'new-id'); + sessionTransitions.detectSessionTransitions(folder); + assert.ok(activeSessions.has('old-id')); + assert.ok(!activeSessions.get('old-id').knownJsonlFiles.has('new-id.jsonl'), 'kept out of the known set'); + + owner = 'mine'; + sessionTransitions.detectSessionTransitions(folder); + assert.ok(activeSessions.has('new-id')); + } finally { + cleanup(tmp); + } +}); + +test('/clear: a pending owner on a transcript older than the wait is given up', () => { + const tmp = mkTmp(); + try { + const folder = 'proj'; + const folderPath = path.join(tmp, folder); + fs.mkdirSync(folderPath, { recursive: true }); + const { activeSessions } = setupForkDetection(tmp, { clearOwner: () => 'pending' }); + activeSessions.set('old-id', makePtySession(folder, { pty: { pid: 7 } })); + + writeClearTranscript(folderPath, 'new-id'); + const t = (Date.now() - 120_000) / 1000; + fs.utimesSync(path.join(folderPath, 'new-id.jsonl'), t, t); + sessionTransitions.detectSessionTransitions(folder); + assert.ok(activeSessions.get('old-id').knownJsonlFiles.has('new-id.jsonl')); + } finally { + cleanup(tmp); + } +}); + +test('/clear: an owner that cannot be established is never matched, even by the only Claude PTY in the folder', () => { + const tmp = mkTmp(); + try { + const folder = 'proj'; + const folderPath = path.join(tmp, folder); + fs.mkdirSync(folderPath, { recursive: true }); + const { events, activeSessions } = setupForkDetection(tmp, { clearOwner: () => 'unknown' }); + activeSessions.set('a', makePtySession(folder, { pty: { pid: 1 } })); + + writeClearTranscript(folderPath, 'new-1'); + sessionTransitions.detectSessionTransitions(folder); + assert.ok(activeSessions.has('a') && !activeSessions.has('new-1'), 'a CLI outside the app may own it'); + assert.ok(activeSessions.get('a').knownJsonlFiles.has('new-1.jsonl'), 'recorded, not retried'); + assert.equal(events.filter(e => e.channel === 'session-forked').length, 0); + } finally { + cleanup(tmp); + } +}); + +test('/clear: a transcript whose first real turn is a prompt is not a /clear', () => { + const tmp = mkTmp(); + try { + const folder = 'proj'; + const folderPath = path.join(tmp, folder); + fs.mkdirSync(folderPath, { recursive: true }); + let asked = false; + const { activeSessions } = setupForkDetection(tmp, { clearOwner: () => { asked = true; return 'mine'; } }); + activeSessions.set('old-id', makePtySession(folder, { pty: { pid: 1 } })); + + fs.writeFileSync(path.join(folderPath, 'new-id.jsonl'), + JSON.stringify({ sessionId: 'new-id', type: 'user', message: { role: 'user', content: 'hello /clear' } }) + '\n', 'utf8'); + sessionTransitions.detectSessionTransitions(folder); + assert.equal(asked, false); + assert.ok(activeSessions.has('old-id')); + } finally { + cleanup(tmp); + } +}); + +test('/clear: a session that already completed its fork still follows a later /clear', () => { + const tmp = mkTmp(); + try { + const folder = 'proj'; + fs.mkdirSync(path.join(tmp, folder), { recursive: true }); + const { activeSessions } = setupForkDetection(tmp, { clearOwner: () => 'mine' }); + activeSessions.set('forked-id', makePtySession(folder, { pty: { pid: 9 }, forkFrom: 'source-id', realSessionId: 'forked-id' })); + + writeClearTranscript(path.join(tmp, folder), 'new-id'); + sessionTransitions.detectSessionTransitions(folder); + assert.ok(activeSessions.has('new-id') && !activeSessions.has('forked-id')); + } finally { + cleanup(tmp); + } +}); + +test('/clear: a session still waiting for its fork is not matched to a /clear file', () => { + const tmp = mkTmp(); + try { + const folder = 'proj'; + fs.mkdirSync(path.join(tmp, folder), { recursive: true }); + let asked = false; + const { events, activeSessions } = setupForkDetection(tmp, { clearOwner: () => { asked = true; return 'mine'; } }); + activeSessions.set('temp-id', makePtySession(folder, { pty: { pid: 9 }, forkFrom: 'source-id' })); + + writeClearTranscript(path.join(tmp, folder), 'new-id'); + sessionTransitions.detectSessionTransitions(folder); + assert.equal(asked, false); + assert.ok(activeSessions.has('temp-id') && !activeSessions.has('new-id'), 'nor taken as its fork, though it starts with a snapshot'); + assert.equal(events.filter(e => e.channel === 'session-forked').length, 0); + } finally { + cleanup(tmp); + } +}); + +test('/clear: a pending owner is rechecked on its own, without waiting for another change in the folder', () => { + const tmp = mkTmp(); + try { + const folder = 'proj'; + fs.mkdirSync(path.join(tmp, folder), { recursive: true }); + const timers = []; + let owner = 'pending'; + const { activeSessions } = setupForkDetection(tmp, { + clearOwner: () => owner, + setTimeout: (fn, ms) => { timers.push({ fn, ms }); return { unref() {} }; }, + }); + activeSessions.set('old-id', makePtySession(folder, { pty: { pid: 7 } })); + + writeClearTranscript(path.join(tmp, folder), 'new-id'); + sessionTransitions.detectSessionTransitions(folder); + sessionTransitions.detectSessionTransitions(folder); + assert.equal(timers.length, 1, 'one recheck per folder at a time'); + owner = 'mine'; + timers.shift().fn(); + assert.ok(activeSessions.has('new-id')); + assert.equal(timers.length, 0, 'nothing left to wait for'); + } finally { + cleanup(tmp); + } +}); + +test('/clear: a new transcript whose first records are not written yet is rechecked on its own while it is fresh', () => { + const tmp = mkTmp(); + try { + const folder = 'proj'; + const folderPath = path.join(tmp, folder); + fs.mkdirSync(folderPath, { recursive: true }); + const timers = []; + const { activeSessions } = setupForkDetection(tmp, { + clearOwner: () => 'mine', + setTimeout: (fn) => { timers.push(fn); return { unref() {} }; }, + }); + activeSessions.set('old-id', makePtySession(folder, { pty: { pid: 7 } })); + + fs.writeFileSync(path.join(folderPath, 'new-id.jsonl'), '{"type":"mo', 'utf8'); + sessionTransitions.detectSessionTransitions(folder); + assert.equal(timers.length, 1); + writeClearTranscript(folderPath, 'new-id'); + timers.shift()(); + assert.ok(activeSessions.has('new-id')); + + fs.writeFileSync(path.join(folderPath, 'old-partial.jsonl'), '{"type":"mo', 'utf8'); + const t = (Date.now() - 120_000) / 1000; + fs.utimesSync(path.join(folderPath, 'old-partial.jsonl'), t, t); + sessionTransitions.detectSessionTransitions(folder); + assert.equal(timers.length, 0, 'an old one waits for the next change in the folder, as before'); + } finally { + cleanup(tmp); + } +}); + +test('/clear: the old id still leads to the session, so a trigger chain that sent /clear can go on', () => { + const tmp = mkTmp(); + try { + const folder = 'proj'; + fs.mkdirSync(path.join(tmp, folder), { recursive: true }); + const { activeSessions } = setupForkDetection(tmp, { clearOwner: () => 'mine' }); + const pty = { pid: 4242, write() {} }; + activeSessions.set('old-id', makePtySession(folder, { pty, cwd: '/w' })); + const { createTriggerContext } = require('../trigger-context'); + const ctx = createTriggerContext({ + activeSessions, log: { info() {}, warn() {}, error() {}, debug() {} }, + getCliStatus: (id) => ({ status: 'idle', id }), + resolveSessionId: (id) => sessionTransitions.currentSessionId(id), + }); + + writeClearTranscript(path.join(tmp, folder), 'new-id'); + sessionTransitions.detectSessionTransitions(folder); + writeClearTranscript(path.join(tmp, folder), 'newer-id'); + sessionTransitions.detectSessionTransitions(folder); + assert.ok(activeSessions.has('newer-id')); + + assert.equal(sessionTransitions.currentSessionId('old-id'), 'newer-id'); + assert.equal(ctx.getPtyForSession('old-id').ptyProcess, pty); + assert.equal(ctx.getCliStatus('old-id').id, 'newer-id', 'the status is read under the id the CLI writes now'); + assert.equal(sessionTransitions.currentSessionId('never-seen'), 'never-seen'); + } finally { + cleanup(tmp); + } +}); diff --git a/trigger-context.js b/trigger-context.js index c801db47..538762d4 100644 --- a/trigger-context.js +++ b/trigger-context.js @@ -34,10 +34,12 @@ function createLocalSessionHandle(ptyProcess) { */ function createTriggerContext(deps) { const { activeSessions, log, isPtyAlive, getCliStatus, projectsDir } = deps; + // see .ai/contexts/trigger-watcher.md, "Re-keyed sessions" + const resolve = deps.resolveSessionId || ((id) => id); const ctx = { log, getPtyForSession(sessionId) { - const session = activeSessions.get(sessionId); + const session = activeSessions.get(resolve(sessionId)); if (!session || session.exited) return null; const handle = (session.host == null) ? createLocalSessionHandle(session.pty) @@ -46,11 +48,11 @@ function createTriggerContext(deps) { return { ptyProcess: session.pty, cwd: session.cwd, handle }; }, isSessionBusy(sessionId) { - const session = activeSessions.get(sessionId); + const session = activeSessions.get(resolve(sessionId)); return session ? !!session._cliBusy : false; }, getComposerState(sessionId) { - const session = activeSessions.get(sessionId); + const session = activeSessions.get(resolve(sessionId)); if (!session || session.exited || !session.composerState) return null; const { pending, lastInputAt } = session.composerState; return { pending, lastInputAt }; @@ -81,9 +83,9 @@ function createTriggerContext(deps) { const reader = createTranscriptTurnReader(); const readPaths = new Map(); ctx.getTranscriptTurn = (sessionId) => { - const session = activeSessions.get(sessionId); + const session = activeSessions.get(resolve(sessionId)); if (!session || session.exited || session.host != null || !session.projectFolder) return null; - const id = session.realSessionId || sessionId; + const id = session.realSessionId || resolve(sessionId); const filePath = path.join(projectsDir, session.projectFolder, id + '.jsonl'); readPaths.set(sessionId, filePath); return reader.read(filePath); @@ -97,9 +99,10 @@ function createTriggerContext(deps) { } if (getCliStatus) { ctx.getCliStatus = (sessionId) => { - const session = activeSessions.get(sessionId); + const id = resolve(sessionId); + const session = activeSessions.get(id); if (!session || session.host != null) return undefined; - return getCliStatus(sessionId); + return getCliStatus(id); }; } return ctx;