From 0d9e3a9ad7292b38d2ad893e452539f15a420751 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Fri, 4 Sep 2026 13:14:36 +0200 Subject: [PATCH 1/9] docs(trigger-watcher): replace the stale pointer-vs-CLI-activity protocol The "take four control shots" section still told the next investigator to vary pointer position, even though the same section had already shown that Claude Code never enables mouse tracking and xterm.js de-duplicates identical motion - the pointer could never have been the variable. What actually closed the question was arming the trace on an idle session with nobody at the keyboard: a 340s trace found CPR/DECXCPR at 59% of pty.input traffic, not the already-excluded SGR mouse reports at 20%. Replace the protocol with what worked, and flag the 10-code-point cap on the cp field as a reading pitfall. Also correct trigger-watcher.js's LOC estimate (~800 -> ~1050, measured). --- .ai/contexts/trigger-watcher.md | 39 +++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/.ai/contexts/trigger-watcher.md b/.ai/contexts/trigger-watcher.md index b6b13aaf..00069b2b 100644 --- a/.ai/contexts/trigger-watcher.md +++ b/.ai/contexts/trigger-watcher.md @@ -6,7 +6,7 @@ | File | LOC | Role | |---|---|---| -| `trigger-watcher.js` | ~800 | The entire module: directory setup, `fs.watch` listener, idle-wait logic, single + chained trigger processing, submit-with-verify busy-rise/fall polling, input validation, PTY write, result file. | +| `trigger-watcher.js` | ~1050 | The entire module: directory setup, `fs.watch` listener, idle-wait logic, single + chained trigger processing, submit-with-verify busy-rise/fall polling, input validation, PTY write, result file. | | `trigger-context.js` | ~35 | `createTriggerContext({ activeSessions, log })` — builds the whole `ctx` object out of `main.js`'s session map. | | `terminal-input.js` | ~20 | `handleTerminalInput(activeSessions, sessionId, data, now)` — the body of the `terminal-input` IPC handler; feeds `session.composerState`. | | `main.js` (wiring) | 3 | `require('./trigger-watcher').start(createTriggerContext({ activeSessions, log }))` in the `app.whenReady` block, right after `startScheduler`, plus the one-line `terminal-input` registration. | @@ -169,16 +169,33 @@ jq -r 'select(.cat=="pty.input") | "\(.wall)\t\(.len)\t\(.at // "-")\t\(.cp // " jq -r 'select(.cat=="pty.input" and .cp) | .cp' $TRACE | sort | uniq -c | sort -rn ``` -**Take four control shots, not two.** The two measurements taken so far were -n=1 per condition and varied the pointer while the CLI's own activity varied -with it, so they could not tell the two apart — and the fix they motivated -addressed the wrong factor. Run the full grid: {CLI idle, CLI busy — a task -spawning subagents} × {pointer resting over the terminal, pointer moved off the -window}. In each cell: empty the composer with Ctrl+U, touch nothing, drop a -trigger, and record both the result (`waited_ms`, refusal or not) and the -`pty.input` lines in that window. The culprit is whichever factor moves the -chunk rate, and the chunks to look for push the quiet clock while leaving -`pending` at 0 — which excludes X10 and history recall by construction. +**Corrected — the pointer was never the axis to vary.** The paragraph above +already rules it out on its own terms: Claude Code never turns on motion +tracking and xterm.js de-duplicates identical motion, so a resting pointer +cannot put anything on `pty.input` to begin with. A grid that varies pointer +position measures nothing the CLI's own querying doesn't already explain — the +2026-09-02 measurement that seemed to implicate the pointer (§ above, PR #160) +most likely compared two runs where the CLI's own background querying happened +to differ, not two pointer positions; that confound is exactly why n=1 per +condition couldn't tell the two apart. + +**What actually resolved it: arm the trace on a session with nobody at the +keyboard.** The absence of a human, not the pointer, is the control — everything +`pty.input` records in that window is machine-originated by construction. A +340 s trace of two idle sessions this way (2026-09-04) put a number on the +suspects this section used to call unproven: of 2,422 chunks, 1,427 (59%) were +CPR / DECXCPR (`CSI [?] row;col[;page] R`) — the terminal answering a +cursor-position query the CLI issues on its own roughly every 240 ms — against +495 (20%) SGR mouse reports, which PR #160 already excluded correctly and which +were not the cause. `reportLength()` did not recognise CPR, so every one of +those chunks kept resetting the quiet clock, and any session left open on +screen could keep the 3000 ms window from ever opening. See PR #170 (open at +the time of writing) for the fix and the full breakdown. + +One reading pitfall worth flagging for whoever re-runs this: `pty.input`'s `cp` +field is capped at 10 code points (see `docs/activity-trace.md`), so a longer +escape sequence can show up in the trace without its final byte. Classify a +shape from the full chunk or its length, not from a truncated `cp` alone. The SGR-and-focus exemption above (PR #160) is correct and worth keeping, but it **cannot** be the cause of this symptom: exempting mouse reports cannot quiet a From 149aff109c119f0aff80ae563a4de65f5563edb0 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Fri, 4 Sep 2026 13:15:50 +0200 Subject: [PATCH 2/9] docs(shared-guidelines): fix stale facts and mark Linux-only sections - ~/workspace/switchboard doesn't exist on this checkout; it's C:\Serveur\switchboard on Windows 11. Fixed the two path references. - main.js is measured at ~2600 LOC now, not ~1850. - Invariants #1 and #2 (and half of #6) describe the Linux AppImage deployment specifically (appimagelauncherd, npm run build:linux, ~/Applications/). Labelled them as such instead of deleting the field-measured incidents they carry, and noted the Windows equivalent is unverified rather than implicitly safe. - Fixed a broken relative link: [README.md](README.md) from inside .ai/ resolves to .ai/README.md, which doesn't exist; needed ../README.md like the other README link in this same file already has. --- .ai/shared-guidelines.md | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/.ai/shared-guidelines.md b/.ai/shared-guidelines.md index 8567006d..d1e1ec80 100644 --- a/.ai/shared-guidelines.md +++ b/.ai/shared-guidelines.md @@ -23,7 +23,20 @@ For a guided tour of the codebase architecture, start at [contexts/README.md](co ## Critical invariants for AI agents -### 1. Don't spawn a second Electron while JB's AppImage is running +**Invariants #1, #2 and part of #6 below were written against a Linux AppImage +deployment** (`~/Applications/Switchboard.AppImage`, `npm run build:linux`, +`appimagelauncherd`). This repo checkout is on Windows +(`C:\Serveur\switchboard`, Windows 11) — do not follow their commands or paths +literally here. They are kept, not deleted, because they document real +production incidents (a build that killed a running instance, a `cp` that got +an instance killed by `appimagelauncherd`) whose underlying principle — don't +touch a native module or executable a live process has open — applies on any +platform. **The Windows equivalent (packaged `.exe` via NSIS, per +`README.md` "Download") has not been field-tested for the same failure +modes**: whether rebuilding native modules or replacing the installed binary +can kill a running Windows instance is unverified, not "safe by omission." + +### 1. Don't spawn a second Electron while JB's AppImage is running (Linux-specific example — see note above) The user runs `~/Applications/Switchboard.AppImage` daily. **PR #13 (`requestSingleInstanceLock`) means a second `npx electron .` from your worktree quits immediately and focuses the user's window** — your dev session never starts. Use `SWITCHBOARD_DATA_DIR` isolation if you genuinely need a live process, otherwise stay read-only / unit-test-driven. @@ -38,7 +51,7 @@ The AppImage uses `~/.switchboard/switchboard.db`. The dev electron uses `~/.swi To test a specific PR live, alongside the running AppImage, use `task test-pr PR=` — it isolates the DB, the automation triggers dir, and warns about the schedule-runner duplicate-fire risk. See [docs/testing-a-pr.md](docs/testing-a-pr.md) for the full procedure; do not improvise the isolation env vars by hand. -### 2. Running `npm run build:linux` CAN kill the running instance — and so can the `cp` to ~/Applications +### 2. Running `npm run build:linux` CAN kill the running instance — and so can the `cp` to ~/Applications (Linux-specific example — see note above) **Corrected 2026-05-31** — the previous version of this section claimed the build was safe. It isn't. @@ -69,7 +82,7 @@ Workspace-level rule (`~/workspace/CLAUDE.md`). Applies to commits and MR/PR des ### 6. Overnight / unattended work: don't touch the live app while a session is mid-run -If you're working autonomously (overnight, AFK mode) while the user's AppImage is live with an active session open, treat the app as **read-only from the outside** for the duration: no `npm run build:linux` / `task build` without the `--config.npmRebuild=false` flag (§2), no `cp` to `~/Applications/Switchboard.AppImage` (§2 — `appimagelauncherd` can silently kill the running instance), and no second `npx electron .` (§1 — it just quits and steals focus instead of giving you a usable dev process). None of these produce an obvious error at the time you run them; the damage shows up later as a dead session the user didn't ask to lose. If you need a live process to test against, use `SWITCHBOARD_DATA_DIR` isolation (§1) and only do the disruptive steps (uncontrolled rebuild, `cp` swap) once the user is ready to restart. +If you're working autonomously (overnight, AFK mode) while the user's app is live with an active session open, treat it as **read-only from the outside** for the duration. On the Linux AppImage deployment §1/§2 describe: no `npm run build:linux` / `task build` without the `--config.npmRebuild=false` flag (§2), no `cp` to `~/Applications/Switchboard.AppImage` (§2 — `appimagelauncherd` can silently kill the running instance), and no second `npx electron .` (§1 — it just quits and steals focus instead of giving you a usable dev process). None of these produce an obvious error at the time you run them; the damage shows up later as a dead session the user didn't ask to lose. If you need a live process to test against, use `SWITCHBOARD_DATA_DIR` isolation (§1) and only do the disruptive steps (uncontrolled rebuild, binary swap) once the user is ready to restart. The general principle — don't rebuild or replace a binary a live process has open — is platform-independent even though the concrete commands above are not; on Windows, treat `task build` / replacing the installed `.exe` with the same caution until someone actually measures what happens here. > This is a Switchboard-specific writeup of a more general pattern — "don't touch shared mutable state a human is actively using" applies to any AI agent working unattended alongside a live app. @@ -115,7 +128,7 @@ These exist on `devsuitup/switchboard` main but not on `doctly/switchboard` main - `node:test` runner via `npm test` / `task test`. - Renderer tests use jsdom via `test/dom-setup.js` + `vm.runInContext` to evaluate `public/*.js` in isolation. - Pitfall: `installSpies: false` is required when the eval defines functions you also spy on — function declarations from eval overwrite property spies. -- Always test in the **primary checkout** (`~/workspace/switchboard`), not inside `.claude/worktrees/agent-*`. Worktrees may have incomplete `node_modules` and produce false negatives on tests that require native modules (e.g. `morphdom`). +- Always test in the **primary checkout** (`C:\Serveur\switchboard` on this machine), not inside `.claude/worktrees/agent-*`. Worktrees may have incomplete `node_modules` and produce false negatives on tests that require native modules (e.g. `morphdom`). ## When you finish work @@ -136,7 +149,7 @@ These exist on `devsuitup/switchboard` main but not on `doctly/switchboard` main The fork has features upstream maintainers might want. When adapting a fork-only feature for upstream: 1. Branch off `upstream/main` (NOT fork main), name `upstream/`. -2. Cherry-pick the relevant commit(s). Expect manual merges — our `main.js` is ~1850 LOC vs upstream's ~350; insertion points exist but contexts differ. +2. Cherry-pick the relevant commit(s). Expect manual merges — our `main.js` is ~2600 LOC (measured 2026-09) vs upstream's ~350; insertion points exist but contexts differ. 3. Strip fork-specific dependencies (subagent groups, work-files IPC, etc.) — keep the patch minimally scoped. 4. PR against `doctly/switchboard:main`. Link the originating fork PR. @@ -144,7 +157,7 @@ Example: fork PR #13 → upstream PR #56 (`upstream/fix-single-instance-lock` br ## When in doubt -- Read the [README.md](README.md) for what the app does. +- Read the [README.md](../README.md) for what the app does. - `git log --oneline upstream/main..main` shows everything the fork carries. - `.work-files/switchboard/` has session notes from past compaction events. - Recent merged PRs on the fork are the highest-signal "how do we do things" reference. From 43c371d0908cac7f1064b98c8ce65ab6bf21c854 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Fri, 4 Sep 2026 13:16:05 +0200 Subject: [PATCH 3/9] docs(ipc-bridge): correct LOC estimates and a stale line reference preload.js is 152 lines (was ~130), main.js is ~2600 (was ~1850) - both measured with wc -l. The session-forked send in session-transitions.js is at line 427, not 336; the code moved since this was written. --- .ai/contexts/ipc-bridge.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.ai/contexts/ipc-bridge.md b/.ai/contexts/ipc-bridge.md index ba90836e..bdc57b2f 100644 --- a/.ai/contexts/ipc-bridge.md +++ b/.ai/contexts/ipc-bridge.md @@ -8,8 +8,8 @@ This file is the **canonical inventory** of the IPC surface. When you add a new | File | LOC | Role | |---|---|---| -| `preload.js` | ~130 | The `contextBridge.exposeInMainWorld('api', {...})` block. Every renderer-facing function. | -| `main.js` | ~1850 | The `ipcMain.handle('', ...)` and `ipcMain.on('', ...)` handlers, scattered throughout. | +| `preload.js` | ~150 | The `contextBridge.exposeInMainWorld('api', {...})` block. Every renderer-facing function. | +| `main.js` | ~2600 | The `ipcMain.handle('', ...)` and `ipcMain.on('', ...)` handlers, scattered throughout. | ## Public surface (IPC inventory) @@ -155,7 +155,7 @@ session object exists. `cli-busy-state` is emitted **strictly on transitions** (`main.js` OSC 0 / OSC 9;4 handlers only send when `session._cliBusy` flips). A renderer that misses one — reload, mis-keyed id, a `session-forked` re-key — stays wrong forever, because no further event is coming. That is why `get-active-sessions` carries `busy`: `pollActiveSessions()` (3s while any PTY runs, 30s otherwise) hands the snapshot to `reconcileBusyState()` in `public/session-activity.js`, which realigns `sessionBusyState` and the sidebar classes. -> `session-detected` (tempId → realId) has a preload bridge and an `app.js` listener but **no emitter in main today** — `session-transitions.js:336` only sends `session-forked`. The `rekeyActivityState` call in `onSessionDetected` is therefore unreachable; it is kept so the handler stays correct if the channel comes back, not because it runs. +> `session-detected` (tempId → realId) has a preload bridge and an `app.js` listener but **no emitter in main today** — `session-transitions.js:427` only sends `session-forked`. The `rekeyActivityState` call in `onSessionDetected` is therefore unreachable; it is kept so the handler stays correct if the channel comes back, not because it runs. Three things make that safe: From 0098aaa05241c6797b5e3ca96a7e9de01fb342cc Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Fri, 4 Sep 2026 13:16:21 +0200 Subject: [PATCH 4/9] docs(subagent-observability): fix three stale line references The file has moved on since these were written: - the routing branch (dataset.subagent && session.parentSessionId) is now at sidebar.js:1082, not :771 - grid-view.js/sidebar.js load at index.html:135/:139, not :132/:136 Verified against the current tree; the surrounding claims (script load order, dataset discriminator) still hold. --- .ai/contexts/subagent-observability.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.ai/contexts/subagent-observability.md b/.ai/contexts/subagent-observability.md index 135898e6..68fb0cae 100644 --- a/.ai/contexts/subagent-observability.md +++ b/.ai/contexts/subagent-observability.md @@ -88,7 +88,7 @@ This is the **#1 fork-specific feature** (upstream PR #47 still pending). It per `pruneStaleGridSubagents()` in `grid-view.js`, pruned from `wrapInGridCard()` rather than on a timer). Renderer files are plain non-module `