diff --git a/.ai/contexts/README.md b/.ai/contexts/README.md index a0faa98..fa2b48c 100644 --- a/.ai/contexts/README.md +++ b/.ai/contexts/README.md @@ -1,6 +1,6 @@ # Context engineering — Switchboard -Five sub-system docs, ~150 lines each, written for AI agents who need to make a focused change without re-reading 1800 LOC of `main.js`. +Seven sub-system docs, ~150 lines each, written for AI agents who need to make a focused change without re-reading 1800 LOC of `main.js`. ## When to read which @@ -12,6 +12,7 @@ Five sub-system docs, ~150 lines each, written for AI agents who need to make a | Memory/.work-files tabs, CodeMirror panel, format/delete buttons | [viewer-panel](viewer-panel.md) | | New IPC, preload bridge changes, renderer ↔ main protocol | [ipc-bridge](ipc-bridge.md) | | File-trigger watcher, harness input injection, idle-wait | [trigger-watcher](trigger-watcher.md) | +| Claude CLI state files, early subagent rescan, canary tests | [cli-session-state](cli-session-state.md) | ## Reading order for a new contributor (~30 min) diff --git a/.ai/contexts/cli-session-state.md b/.ai/contexts/cli-session-state.md new file mode 100644 index 0000000..e7a4b51 --- /dev/null +++ b/.ai/contexts/cli-session-state.md @@ -0,0 +1,149 @@ +# Context: cli-session-state + +**Purpose**: turn the Claude CLI's own "I just went idle" moment into an +immediate subagent rescan, so a subagent that finished right before its parent's +turn ended is marked complete in seconds instead of waiting for the next +stabilisation tick. + +**Files**: `cli-session-state.js`, wired in `main.js` (three call sites), +`test/cli-session-state.test.js`, `test/canary-cli-session-state.test.js`. + +## Why it exists + +`detectSubagentTransitions()` owns the stability clock that decides a subagent +is finished (see [subagent-observability](subagent-observability.md)). Before +PR #153 that function only ran from the debounced `fs.watch(PROJECTS_DIR)` +flush — and a function driven by file changes cannot notice that a file +*stopped* changing. Measured 2026-08-23: a completion emitted 10 min 37 s after +the last write, because the folder had gone silent. + +PR #153 fixed the scheduling defect with a self-arming 5 s settle tick plus +renderer-side TTL nets. **That is the fix; this module is not.** What is added +here is a *sooner and more precise trigger* for the same scan: the residual +lateness after #153 is up to one tick (5 s) plus the remainder of the stability +window, and the CLI publishes an idle edge that lands well before the next tick +would. It is an optimisation on top of a working mechanism, and everything it +does is also done, later, by the tick. + +## The external file we read + +`~/.claude/sessions/.json`, written by the Claude CLI itself. Observed +shape (CLI 2.1.241, Windows, 2026-08-23): + +```json +{"pid":18176,"sessionId":"6577a487-…","cwd":"C:\\Serveur\\switchboard", + "startedAt":1787520942563,"procStart":"134319945380279381","version":"2.1.241", + "kind":"interactive","entrypoint":"cli","name":"switchboard-main", + "status":"busy","statusUpdatedAt":1787527436145,"updatedAt":1787527436145} +``` + +**This is not a documented interface.** Nothing obliges the CLI to keep it, keep +its field names, or keep its status vocabulary. Every use of it here is +therefore best-effort, and its absence or corruption must be a no-op — see +"Failure is silence" below. `test/canary-cli-session-state.test.js` exists +precisely so a CLI-side change reads as a CLI-side change. + +Facts established by measurement, not by documentation: + +- `status ∈ {busy, idle, waiting, shell}`. +- It is written **on change, not as a heartbeat** — hence `statusUpdatedAt`, and + hence the liveness guards below. A killed CLI leaves its last status engraved + in the file forever. +- Sampling 295 times at 2 s over 10 min with 2–3 subagents writing, `status` + stayed `busy` throughout, with no false dip. The parent is `busy` while any + delegated agent runs (`delegatedActive` in the CLI's own status computation). +- **Unverified reservation**: no permission dialog occurred during that + measurement, so the `waiting` branch was never observed empirically. We treat + `waiting` as "not idle" on the strength of the name alone. If that reading is + wrong, the only consequence is a missed early rescan — the tick still fires. + +The `busy` glyph in the terminal title was considered instead and rejected: it +conflates idle, waiting and shell. The state file distinguishes them, which is +why it is preferred here. + +## The one invariant + +**The idle signal is a trigger, never a verdict.** It calls +`detectSubagentTransitions()` earlier; it never marks anything complete and +never emits `subagent-completed`. The stability clock inside that function +remains the sole judge of what has finished. Grepping `cli-session-state.js` +for any `subagent-` channel must return nothing — if it ever does, the change +has crossed the line this module was built to respect. + +The reason is the falsified converse: parent-idle does **not** imply +no-subagent-running in general (the title glyph proved that), and even a correct +idle would say nothing about *which* child finished. Only mtime stability +carries that. + +## Guards + +**Process liveness.** Because `status` is written on change, a stale file can +say `busy` — or `idle` — indefinitely. Before any rescan the pid is probed with +`process.kill(pid, 0)` (`EPERM` counts as alive). + +**PID reuse.** The file is named by pid alone, so a second CLI can inherit the +name. `procStart` is recorded per file; when it changes, the entry is reset as a +new process and the status change that came with it is *not* read as a +transition. This is the guard that actually matters — the liveness probe is a +cheap sanity check for a file mutated by anything other than a live CLI. + +**First sighting never triggers.** A status is only a transition against a +previously recorded one for the same `procStart`. At attach time the directory +is seeded once so the first real transition after startup still fires — but the +seed is skipped entirely when the directory holds more than `MAX_SEEDED_FILES` +(200) state files, to bound startup cost. Past that threshold every session +loses its first transition, not merely the ones over the cap, and the settle +tick is the only net left. Benign by construction: the cost is a late rescan, +never a wrong verdict. + +**Per-session throttle.** At most one rescan per second per session. + +## Matching a state file to a Switchboard session + +By `sessionId` only, against `session.realSessionId || ` over +`activeSessions`, skipping `exited`, `isPlainTerminal`, and sessions with no +`projectFolder`. `realSessionId` is what makes forked and resumed sessions work: +after a fork the CLI writes the new id while `activeSessions` is still keyed by +the old one, and it is also the id the subagent directory is named after, so it +is the id the rescan must be given. + +The file's `cwd` is deliberately **not** used as a fallback: several sessions +can share a working directory, so it cannot disambiguate. + +**When matching fails, nothing happens** (one debug log line). That covers a CLI +the user started outside Switchboard, and the window between a fork being +written by the CLI and being detected by `detectSessionTransitions`. The settle +tick covers the session either way. + +## Cost at idle + +Zero polling. One `fs.watch` on a directory that holds a handful of tiny files, +with a 150 ms debounce; events only occur when a CLI changes state. No timer is +armed while the module is idle. This is the constraint from +[ADR 0002](../../docs/decisions/0002-discrete-steps-sidebar-animations.md) — +steady-state cost is the thing the repo has repeatedly paid to remove. + +If `~/.claude/sessions/` does not exist there is **no** retry timer and no +fallback poll: `ensureWatching()` is simply called again the next time a Claude +PTY is spawned, plus once 15 s after such a spawn while still unattached. That +covers the machine where the directory only appears with the first CLI run. + +## Failure is silence + +Missing directory, unreadable file, truncated JSON caught mid-write, missing +fields, unknown status: every one of these results in doing nothing, never in a +throw. The CLI does not write this file atomically. Degrading to "the tick +handles it" is always an acceptable outcome, which is what makes depending on an +undocumented file defensible at all. + +## Canary tests + +`test/canary-*.test.js` is a convention this module introduces. A canary +asserts nothing about our code: it pins an assumption we make about something we +do not own, and **skips itself wherever that thing is absent** so CI and +machines without the dependency stay green. Its failure message must name the +pinned assumption and the observed version of the external thing, so the next +reader knows immediately to look outward rather than hunt a bug in Switchboard. + +Add one whenever you build on an undocumented external artefact; do not add one +for an assumption a normal unit test can pin. diff --git a/.ai/shared-guidelines.md b/.ai/shared-guidelines.md index fbbd350..1629f60 100644 --- a/.ai/shared-guidelines.md +++ b/.ai/shared-guidelines.md @@ -13,6 +13,7 @@ Switchboard is an **Electron desktop app**: renderer + main-process, no Domain/A | Change SQLite, indexing, watcher, FTS, heatmap | [contexts/session-cache.md](contexts/session-cache.md) | | Change schedule cron / `.md` files / schedule spawn | [contexts/schedule-runner.md](contexts/schedule-runner.md) | | Change subagent grouping, transcript view, parent→child | [contexts/subagent-observability.md](contexts/subagent-observability.md) | +| Read the Claude CLI's own session state files | [contexts/cli-session-state.md](contexts/cli-session-state.md) | | Change Memory/.work-files panels (CodeMirror) | [contexts/viewer-panel.md](contexts/viewer-panel.md) | | Change the renderer (sidebar, terminal, app.js) | `public/*.js` — entry is `app.js` | | Write a test | `test/*.test.js` — node:test + jsdom for renderer files | diff --git a/cli-session-state.js b/cli-session-state.js new file mode 100644 index 0000000..6072843 --- /dev/null +++ b/cli-session-state.js @@ -0,0 +1,184 @@ +// see .ai/contexts/cli-session-state.md +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const DEFAULT_DIR = path.join(os.homedir(), '.claude', 'sessions'); +const STATE_FILE_RE = /^\d+\.json$/; +const KNOWN_STATUSES = new Set(['busy', 'idle', 'waiting', 'shell']); +const RESCAN_STATUS = 'idle'; +const FLUSH_MS = 150; +const MIN_RESCAN_INTERVAL_MS = 1000; +const MAX_SEEDED_FILES = 200; + +let dir = DEFAULT_DIR; +let activeSessions = null; +let onIdle = null; +let log = null; +let isProcessAlive = defaultIsProcessAlive; + +let watcher = null; +let flushTimer = null; +const pending = new Set(); +const known = new Map(); +const lastRescanAt = new Map(); + +function defaultIsProcessAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (err) { + return err.code === 'EPERM'; + } +} + +function init(ctx) { + dir = ctx.dir || DEFAULT_DIR; + activeSessions = ctx.activeSessions; + onIdle = ctx.onIdle; + log = ctx.log || { info() {}, debug() {}, warn() {}, error() {} }; + isProcessAlive = ctx.isProcessAlive || defaultIsProcessAlive; + stop(); +} + +function parseState(text) { + let raw; + try { raw = JSON.parse(text); } catch { return null; } + if (!raw || typeof raw !== 'object') return null; + if (!Number.isInteger(raw.pid) || raw.pid <= 0) return null; + if (typeof raw.sessionId !== 'string' || !raw.sessionId) return null; + if (typeof raw.status !== 'string' || !KNOWN_STATUSES.has(raw.status)) return null; + return { + pid: raw.pid, + sessionId: raw.sessionId, + status: raw.status, + statusUpdatedAt: Number.isInteger(raw.statusUpdatedAt) ? raw.statusUpdatedAt : null, + procStart: raw.procStart == null ? null : String(raw.procStart), + }; +} + +function findSession(sessionId) { + if (!activeSessions) return null; + for (const [key, session] of activeSessions) { + if (!session || session.exited || session.isPlainTerminal || !session.projectFolder) continue; + const effectiveId = session.realSessionId || key; + if (effectiveId === sessionId) return { sessionId: effectiveId, session }; + } + return null; +} + +function handleFile(name) { + let text; + try { + text = fs.readFileSync(path.join(dir, name), 'utf8'); + } catch { + known.delete(name); + return; + } + + const state = parseState(text); + if (!state) return; + + const prev = known.get(name); + known.set(name, { procStart: state.procStart, status: state.status }); + + const reused = !!prev && prev.procStart !== state.procStart; + if (!prev || reused) return; + if (prev.status === state.status) return; + if (state.status !== RESCAN_STATUS) return; + if (!isProcessAlive(state.pid)) return; + + const match = findSession(state.sessionId); + if (!match) { + log.debug(`[cli-state] no active session for ${state.sessionId} (pid ${state.pid})`); + return; + } + + const now = Date.now(); + const last = lastRescanAt.get(match.sessionId) || 0; + if (now - last < MIN_RESCAN_INTERVAL_MS) return; + lastRescanAt.set(match.sessionId, now); + + try { + onIdle(match.sessionId, match.session); + } catch (err) { + log.warn(`[cli-state] rescan failed for ${match.sessionId}: ${err.message}`); + } +} + +function flush() { + flushTimer = null; + const batch = [...pending]; + pending.clear(); + for (const name of batch) handleFile(name); +} + +function seed() { + let names; + try { names = fs.readdirSync(dir); } catch { return; } + const files = names.filter(n => STATE_FILE_RE.test(n)); + if (files.length > MAX_SEEDED_FILES) return; + for (const name of files) { + let text; + try { text = fs.readFileSync(path.join(dir, name), 'utf8'); } catch { continue; } + const state = parseState(text); + if (state) known.set(name, { procStart: state.procStart, status: state.status }); + } +} + +function ensureWatching() { + if (watcher) return true; + if (!onIdle || !activeSessions) return false; + try { + if (!fs.statSync(dir).isDirectory()) return false; + } catch { + return false; + } + seed(); + try { + watcher = fs.watch(dir, (_eventType, filename) => { + if (!filename || !STATE_FILE_RE.test(filename)) return; + pending.add(filename); + if (flushTimer) return; + flushTimer = setTimeout(flush, FLUSH_MS); + if (typeof flushTimer.unref === 'function') flushTimer.unref(); + }); + watcher.on('error', (err) => { + log.warn(`[cli-state] watcher error: ${err.message}`); + stop(); + }); + } catch (err) { + watcher = null; + log.warn(`[cli-state] cannot watch ${dir}: ${err.message}`); + return false; + } + log.info(`[cli-state] watching ${dir}`); + return true; +} + +function stop() { + if (watcher) { + try { watcher.close(); } catch {} + watcher = null; + } + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + pending.clear(); + known.clear(); + lastRescanAt.clear(); +} + +module.exports = { + init, + ensureWatching, + stop, + parseState, + KNOWN_STATUSES, + DEFAULT_DIR, + FLUSH_MS, + MIN_RESCAN_INTERVAL_MS, +}; diff --git a/main.js b/main.js index 964141a..db627f1 100644 --- a/main.js +++ b/main.js @@ -2013,6 +2013,12 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se }; activeSessions.set(sessionId, session); + // see .ai/contexts/cli-session-state.md + if (!isPlainTerminal && !cliSessionState.ensureWatching()) { + const retry = setTimeout(() => cliSessionState.ensureWatching(), 15000); + if (typeof retry.unref === 'function') retry.unref(); + } + ptyProcess.onData(data => { const currentId = session.realSessionId || sessionId; @@ -2193,6 +2199,18 @@ const sessionTransitions = require('./session-transitions'); sessionTransitions.init({ PROJECTS_DIR, activeSessions, getMainWindow: () => mainWindow, log, rekeyMcpServer }); const { detectSessionTransitions } = sessionTransitions; +// see .ai/contexts/cli-session-state.md +const cliSessionState = require('./cli-session-state'); +cliSessionState.init({ + activeSessions, + log, + onIdle: (sessionId, session) => { + sessionTransitions.detectSubagentTransitions( + sessionId, session, path.join(PROJECTS_DIR, session.projectFolder) + ); + }, +}); + // --- fs.watch on projects directory --- let projectsWatcher = null; @@ -2342,6 +2360,7 @@ if (!gotSingleInstanceLock) { buildMenu(); createWindow(); startProjectsWatcher(); + cliSessionState.ensureWatching(); // Remove IDE lock files left behind by a crashed instance whose PID was // reused (the function only unlinks locks matching our own pid). cleanStaleLockFiles(log); @@ -2489,6 +2508,7 @@ app.on('before-quit', () => { projectsWatcher.close(); projectsWatcher = null; } + cliSessionState.stop(); // Kill all PTY processes on quit for (const [, session] of activeSessions) { diff --git a/test/canary-cli-session-state.test.js b/test/canary-cli-session-state.test.js new file mode 100644 index 0000000..f878f96 --- /dev/null +++ b/test/canary-cli-session-state.test.js @@ -0,0 +1,69 @@ +// test/canary-cli-session-state.test.js — canary over an external dependency. +// +// Convention: a `canary-*.test.js` file asserts nothing about our code. It +// pins an assumption we make about something we do not own, and skips itself +// wherever that thing is absent, so CI and machines without the dependency +// stay green. See .ai/contexts/cli-session-state.md ("Canary tests"). +// +// Pinned here: the shape of the Claude CLI's per-session state files in +// ~/.claude/sessions/.json, which cli-session-state.js reads to rescan a +// session's subagents as soon as its CLI goes idle. The file is not a +// documented interface; a CLI upgrade may change or remove it. This test going +// red means the CLI changed, not that Switchboard broke. +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { KNOWN_STATUSES } = require('../cli-session-state'); + +const SESSIONS_DIR = path.join(os.homedir(), '.claude', 'sessions'); +const STATE_FILE_RE = /^\d+\.json$/; + +function listStateFiles() { + try { + return fs.readdirSync(SESSIONS_DIR).filter(n => STATE_FILE_RE.test(n)); + } catch { + return []; + } +} + +test('CANARY: the Claude CLI still publishes per-session state we can read', (t) => { + const files = listStateFiles(); + if (files.length === 0) { + t.skip(`no ${SESSIONS_DIR}/.json on this machine — nothing to pin`); + return; + } + + const expected = [...KNOWN_STATUSES].join(', '); + let checked = 0; + + for (const name of files) { + let raw; + try { + raw = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, name), 'utf8')); + } catch { + // A file caught mid-write is expected and is not the canary's business: + // cli-session-state.js already treats an unparseable read as "do nothing". + continue; + } + checked++; + const seen = `(${name}, CLI version ${raw.version || 'unknown'})`; + + assert.equal(typeof raw.sessionId, 'string', + `PINNED ASSUMPTION BROKEN: ~/.claude/sessions/.json used to carry a string "sessionId" — that field is how cli-session-state.js matches a CLI process to a Switchboard session ${seen}`); + assert.ok(Number.isInteger(raw.pid), + `PINNED ASSUMPTION BROKEN: "pid" used to be an integer — cli-session-state.js probes it to reject state left by a dead CLI ${seen}`); + assert.ok(Number.isInteger(raw.statusUpdatedAt), + `PINNED ASSUMPTION BROKEN: "statusUpdatedAt" used to be an integer epoch — it is the only evidence that "status" is written on change rather than on a heartbeat ${seen}`); + assert.ok(KNOWN_STATUSES.has(raw.status), + `PINNED ASSUMPTION BROKEN: "status" was one of {${expected}}, got ${JSON.stringify(raw.status)} — cli-session-state.js rescans on "idle" only, so a renamed or added status makes the early rescan silently stop firing ${seen}`); + } + + if (checked === 0) { + t.skip('every state file was mid-write — nothing to pin this run'); + } +}); diff --git a/test/cli-session-state.test.js b/test/cli-session-state.test.js new file mode 100644 index 0000000..d57b90f --- /dev/null +++ b/test/cli-session-state.test.js @@ -0,0 +1,277 @@ +// test/cli-session-state.test.js — node:test suite for cli-session-state.js +// +// Strategy: real fs.watch in a mkdtemp sandbox, ctx injects the session map, +// the rescan callback and the liveness probe. See +// .ai/contexts/cli-session-state.md +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const cliSessionState = require('../cli-session-state'); + +// Same Windows pitfall as trigger-watcher.test.js: os.tmpdir() can be an 8.3 +// short name and fs.watch on one trips a libuv assertion. +function mkTmp() { + return fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'sw-cli-state-'))); +} + +const silentLog = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; + +/** Long enough for a watch event plus FLUSH_MS to have gone through. */ +const SETTLE_MS = 500; + +function writeState(dir, pid, fields) { + fs.writeFileSync(path.join(dir, `${pid}.json`), JSON.stringify({ + pid, + sessionId: 'sess-1', + cwd: dir, + procStart: '111', + version: '2.1.241', + updatedAt: Date.now(), + statusUpdatedAt: Date.now(), + ...fields, + }), 'utf8'); +} + +function waitFor(fn, maxMs = 4000, pollMs = 20) { + return new Promise((resolve, reject) => { + const start = Date.now(); + (function poll() { + if (fn()) return resolve(); + if (Date.now() - start > maxMs) return reject(new Error('timed out waiting for condition')); + setTimeout(poll, pollMs); + })(); + }); +} + +const delay = (ms) => new Promise(r => setTimeout(r, ms)); + +/** Boot the watcher over `dir` with a spy rescan callback. */ +function boot(dir, activeSessions, opts = {}) { + const rescans = []; + cliSessionState.init({ + dir, + activeSessions, + log: silentLog, + isProcessAlive: opts.isProcessAlive || (() => true), + onIdle: (sessionId, session) => rescans.push({ sessionId, session }), + }); + const attached = cliSessionState.ensureWatching(); + return { rescans, attached }; +} + +function oneSession(fields = {}) { + return new Map([['sess-1', { projectFolder: 'folder', ...fields }]]); +} + +test.afterEach(() => cliSessionState.stop()); + +test('a busy → idle transition rescans the matching session immediately', async () => { + const dir = mkTmp(); + try { + writeState(dir, 4242, { status: 'busy' }); + const { rescans, attached } = boot(dir, oneSession()); + assert.equal(attached, true, 'the watcher must attach to an existing directory'); + + writeState(dir, 4242, { status: 'idle' }); + await waitFor(() => rescans.length === 1); + assert.equal(rescans[0].sessionId, 'sess-1'); + } finally { + cliSessionState.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('waiting and shell never rescan — only idle does', async () => { + // The CLI reports four statuses. Only idle means "the turn is over"; waiting + // is a permission prompt and shell is a suspended session, both of which can + // still have live subagents behind them. + const dir = mkTmp(); + try { + writeState(dir, 4242, { status: 'busy' }); + const { rescans } = boot(dir, oneSession()); + + writeState(dir, 4242, { status: 'waiting' }); + await delay(SETTLE_MS); + assert.equal(rescans.length, 0, 'waiting must not rescan'); + + writeState(dir, 4242, { status: 'shell' }); + await delay(SETTLE_MS); + assert.equal(rescans.length, 0, 'shell must not rescan'); + + // Positive control: the harness is wired, the two silences above were real. + writeState(dir, 4242, { status: 'idle' }); + await waitFor(() => rescans.length === 1); + } finally { + cliSessionState.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a dead pid never rescans', async () => { + // status is written on change, not on a heartbeat: a killed CLI leaves its + // last status engraved. Nothing stale may drive a rescan. + const dir = mkTmp(); + try { + writeState(dir, 4242, { status: 'busy' }); + const { rescans } = boot(dir, oneSession(), { isProcessAlive: () => false }); + + writeState(dir, 4242, { status: 'idle' }); + await delay(SETTLE_MS); + assert.equal(rescans.length, 0, 'a state file whose process is gone must be inert'); + } finally { + cliSessionState.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a reused pid is treated as a new process, not as a transition', async () => { + // .json is keyed by pid alone. A second CLI landing on the same pid + // would otherwise read as "the previous process just went idle". + const dir = mkTmp(); + try { + writeState(dir, 4242, { status: 'busy', procStart: 'A' }); + const { rescans } = boot(dir, oneSession()); + + writeState(dir, 4242, { status: 'idle', procStart: 'B' }); + await delay(SETTLE_MS); + assert.equal(rescans.length, 0, 'a different procStart is a different process'); + + // The new process gets its own baseline, and its own transitions work. + writeState(dir, 4242, { status: 'busy', procStart: 'B' }); + await delay(SETTLE_MS); + writeState(dir, 4242, { status: 'idle', procStart: 'B' }); + await waitFor(() => rescans.length === 1); + } finally { + cliSessionState.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a truncated or malformed file is ignored without throwing', async () => { + // The CLI does not write this file atomically, so a read can land mid-write. + const dir = mkTmp(); + try { + writeState(dir, 4242, { status: 'busy' }); + const { rescans } = boot(dir, oneSession()); + + fs.writeFileSync(path.join(dir, '4242.json'), '{"pid":4242,"status":"id', 'utf8'); + await delay(SETTLE_MS); + assert.equal(rescans.length, 0, 'a half-written file must not rescan'); + + fs.writeFileSync(path.join(dir, '4242.json'), JSON.stringify({ status: 'idle' }), 'utf8'); + await delay(SETTLE_MS); + assert.equal(rescans.length, 0, 'a file missing pid/sessionId must not rescan'); + + writeState(dir, 4242, { status: 'idle' }); + await waitFor(() => rescans.length === 1, 4000); + } finally { + cliSessionState.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('an unknown status is ignored and does not break the following transition', async () => { + const dir = mkTmp(); + try { + writeState(dir, 4242, { status: 'busy' }); + const { rescans } = boot(dir, oneSession()); + + writeState(dir, 4242, { status: 'hibernating' }); + await delay(SETTLE_MS); + assert.equal(rescans.length, 0, 'an unknown status must not rescan'); + + writeState(dir, 4242, { status: 'idle' }); + await waitFor(() => rescans.length === 1); + } finally { + cliSessionState.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a state file with no matching Switchboard session rescans nothing', async () => { + const dir = mkTmp(); + try { + writeState(dir, 4242, { status: 'busy', sessionId: 'somebody-elses-session' }); + const { rescans } = boot(dir, oneSession()); + + writeState(dir, 4242, { status: 'idle', sessionId: 'somebody-elses-session' }); + await delay(SETTLE_MS); + assert.equal(rescans.length, 0, 'an unrelated CLI must not drive our sessions'); + } finally { + cliSessionState.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a forked session matches on realSessionId, not on its map key', async () => { + // After a fork the CLI writes the new id while activeSessions is still keyed + // by the old one — matching on the key alone would silently stop working for + // every forked or resumed session. + const dir = mkTmp(); + const activeSessions = new Map([ + ['old-id', { projectFolder: 'folder', realSessionId: 'new-id' }], + ]); + try { + writeState(dir, 4242, { status: 'busy', sessionId: 'new-id' }); + const { rescans } = boot(dir, activeSessions); + + writeState(dir, 4242, { status: 'idle', sessionId: 'new-id' }); + await waitFor(() => rescans.length === 1); + assert.equal(rescans[0].sessionId, 'new-id', + 'the rescan must target the id the subagent directory is named after'); + } finally { + cliSessionState.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('exited and plain-terminal sessions are never rescanned', async () => { + const dir = mkTmp(); + try { + writeState(dir, 4242, { status: 'busy' }); + const { rescans } = boot(dir, oneSession({ exited: true })); + + writeState(dir, 4242, { status: 'idle' }); + await delay(SETTLE_MS); + assert.equal(rescans.length, 0, 'an exited session has nothing left to scan'); + + cliSessionState.stop(); + const second = boot(dir, oneSession({ isPlainTerminal: true })); + writeState(dir, 4242, { status: 'busy' }); + await delay(SETTLE_MS); + writeState(dir, 4242, { status: 'idle' }); + await delay(SETTLE_MS); + assert.equal(second.rescans.length, 0, 'a plain terminal has no subagents'); + } finally { + cliSessionState.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a missing directory attaches nothing and costs nothing', () => { + const dir = path.join(mkTmp(), 'does-not-exist'); + const { attached } = boot(dir, oneSession()); + assert.equal(attached, false, 'no directory, no watcher, no polling fallback'); + cliSessionState.stop(); +}); + +test('parseState rejects everything that is not a usable state file', () => { + const { parseState } = cliSessionState; + assert.equal(parseState('not json'), null); + assert.equal(parseState('null'), null); + assert.equal(parseState('[]'), null, 'an array carries none of the fields'); + assert.equal(parseState(JSON.stringify({ sessionId: 'a', status: 'idle' })), null, 'no pid'); + assert.equal(parseState(JSON.stringify({ pid: 1, status: 'idle' })), null, 'no sessionId'); + assert.equal(parseState(JSON.stringify({ pid: 1, sessionId: 'a', status: 'nope' })), null); + const ok = parseState(JSON.stringify({ + pid: 1, sessionId: 'a', status: 'idle', statusUpdatedAt: 5, procStart: 7, + })); + assert.deepEqual(ok, { + pid: 1, sessionId: 'a', status: 'idle', statusUpdatedAt: 5, procStart: '7', + }); +});