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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .ai/contexts/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)

Expand Down
149 changes: 149 additions & 0 deletions .ai/contexts/cli-session-state.md
Original file line number Diff line number Diff line change
@@ -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/<pid>.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 || <map key>` 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.
1 change: 1 addition & 0 deletions .ai/shared-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
184 changes: 184 additions & 0 deletions cli-session-state.js
Original file line number Diff line number Diff line change
@@ -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,
};
Loading
Loading