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
4 changes: 3 additions & 1 deletion .ai/contexts/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Context engineering — Switchboard

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`.
Seven sub-system docs (76 to 526 lines as of 2026-09, most have grown well past
their original size), written for AI agents who need to make a focused change
without re-reading `main.js`, now ~2600 LOC.

## When to read which

Expand Down
6 changes: 3 additions & 3 deletions .ai/contexts/ipc-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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('<name>', ...)` and `ipcMain.on('<name>', ...)` handlers, scattered throughout. |
| `preload.js` | ~150 | The `contextBridge.exposeInMainWorld('api', {...})` block. Every renderer-facing function. |
| `main.js` | ~2600 | The `ipcMain.handle('<name>', ...)` and `ipcMain.on('<name>', ...)` handlers, scattered throughout. |

## Public surface (IPC inventory)

Expand Down Expand Up @@ -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:

Expand Down
4 changes: 2 additions & 2 deletions .ai/contexts/schedule-runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

| File | LOC | Role |
|---|---|---|
| `schedule-runner.js` | ~220 | The cron loop, cron parser, file scanner, session pre-seeder, command builder. |
| `schedule-runner.js` | ~310 | The cron loop, cron parser, file scanner, session pre-seeder, command builder. |
| `schedule-ipc.js` | ~220 | IPC handlers + the inline `SCHEDULE_CREATOR_TEMPLATE` (an embedded Claude command that teaches Claude how to write schedule files). |

## Public surface
Expand Down Expand Up @@ -64,7 +64,7 @@ cli:
- `public/memory-workfiles-view.js` brain tab — lists existing `schedule-*.md` files, surfaces the "run now" play button
- `public/sidebar.js` — `.project-schedule-btn` clock icon wiring per project
- `schedule-ipc.js` `SCHEDULE_CREATOR_TEMPLATE` — if you change the schedule file format, update the template's instructions
- `main.js:1618` (or wherever `startScheduler(log, runScheduleCommand)` is invoked at app boot)
- `main.js:2517` (or wherever `startScheduler(log, runScheduleCommand)` is invoked at app boot — checked 2026-09, it moves as main.js grows)
- The `runScheduleCommand` factory in `main.js` — uses `child_process.spawn`, `cleanPtyEnv`, and the global shell profile. Schedules don't get their own shell selector.

## Limitations worth knowing
Expand Down
10 changes: 5 additions & 5 deletions .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@

| File | LOC | Role |
|---|---|---|
| `db.js` | ~450 | SQLite (better-sqlite3) schema + prepared statements. Owns `session_cache`, `session_meta`, `cache_meta`, `settings`, `search_fts` (FTS5 + trigram tokenizer). |
| `session-cache.js` | ~525 | Indexer + watcher. Reads `~/.claude/projects/<folder>/*.jsonl` (+ subagents subdir), populates rows, emits projects-changed events. |
| `read-session-file.js` | ~280 | Streaming JSONL reader. `readSessionFile()` (full) + `readSessionDisplayHeader()` (256 KB / 500 lines — cheap header for huge files). |
| `encode-project-path.js` | 14 | `/path/to/project` → `-path-to-project` folder name. Mirrors Claude CLI's encoding. |
| `derive-project-path.js` | 64 | Inverse: read `cwd` field from JSONL, derive original projectPath. **Collapses worktrees back to parent repo** via `resolveWorktreePath`. |
| `db.js` | ~895 | SQLite (better-sqlite3) schema + prepared statements. Owns `session_cache`, `session_meta`, `cache_meta`, `settings`, `search_fts` (FTS5 + trigram tokenizer). |
| `session-cache.js` | ~690 | Indexer + watcher. Reads `~/.claude/projects/<folder>/*.jsonl` (+ subagents subdir), populates rows, emits projects-changed events. |
| `read-session-file.js` | ~420 | Streaming JSONL reader. `readSessionFile()` (full) + `readSessionDisplayHeader()` (256 KB / 500 lines — cheap header for huge files). |
| `encode-project-path.js` | 28 | `/path/to/project` → `-path-to-project` folder name. Mirrors Claude CLI's encoding. |
| `derive-project-path.js` | ~155 | Inverse: read `cwd` field from JSONL, derive original projectPath. **Collapses worktrees back to parent repo** via `resolveWorktreePath`. |

## Public surface

Expand Down
4 changes: 2 additions & 2 deletions .ai/contexts/subagent-observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<script>`s
sharing one global scope and `sidebar.js` loads *after* `grid-view.js`
(`index.html:132` then `:136`), so a top-level function declared under the
(`index.html:135` then `:139`), so a top-level function declared under the
same name in both is silently shadowed — that is what froze the grid's TTL
prune until PR #137. Keep cross-file names distinct;
`test/dom-grid-sidebar-prune-collision.test.js` pins the pair.
Expand Down Expand Up @@ -515,7 +515,7 @@ invariant enforced anywhere.
the resurrection guards above
- `test/dom-grid-subagent-pills.test.js` — pins the grid-view IPC handler arity
(`preload.js` passes the payload as the callback's only argument)
- `public/sidebar.js:771` (the routing branch) — the one-line decision that makes the whole feature work
- `public/sidebar.js:1082` (the routing branch) — the one-line decision that makes the whole feature work
- IPC handler security: `read-subagent-jsonl` MUST validate that `agentId` and `parentSessionId` are filename-safe (see `resolveJsonlPath` calls in main.js — fixed in PR #8 hardening)

## History
Expand Down
39 changes: 28 additions & 11 deletions .ai/contexts/trigger-watcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .ai/contexts/viewer-panel.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

| File | LOC | Role |
|---|---|---|
| `public/viewer-panel.js` | ~365 | The `ViewerPanel` class. Owns CodeMirror state, toolbar wiring, file watch lifecycle, save/format/delete logic. |
| `public/viewer-panel.js` | ~415 | The `ViewerPanel` class. Owns CodeMirror state, toolbar wiring, file watch lifecycle, save/format/delete logic. |
| `public/viewer-toolbar.js` | ~265 | Pure factory `createViewerToolbar(opts)` — builds the toolbar DOM + returns API. No state of its own. |

## Public surface
Expand Down Expand Up @@ -62,7 +62,7 @@ The toolbar factory builds all configured buttons up front; `open()` toggles vis
- **Markdown preview mode is persisted per-storageKey** in `localStorage`. Memory uses `'markdownPreviewMode'`; .work-files uses `'workFilesPreviewMode'`.
- **Line-wrap default depends on file type**: markdown wraps, code doesn't. Wrap state is NOT persisted — resets per file.
- **`format` for `.jsonl` is intentionally non-standard**: each line is pretty-printed and joined with `\n---\n`. This produces human-readable output but is no longer valid JSON. The button is for *viewing*, not for converting files to a different format.
- **Cmd/Ctrl+S keybinding**: CodeMirror dispatches a `cm-save` custom event which the ViewerPanel listens for. Chromium's "Save Page" default is blocked globally in `viewer-toolbar.js:230` (`keydown` listener with `preventDefault`).
- **Cmd/Ctrl+S keybinding**: CodeMirror dispatches a `cm-save` custom event which the ViewerPanel listens for. Chromium's "Save Page" default is blocked globally in `viewer-toolbar.js:256` (`keydown` listener with `preventDefault`).
- **The toolbar API exposes button refs directly** (`toolbar.saveBtn`, `toolbar.formatBtn`, …). The ViewerPanel reads `null` checks instead of asking the toolbar — slightly leaky encapsulation, but harmless.

## If you change this, also check
Expand Down
25 changes: 19 additions & 6 deletions .ai/shared-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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=<number>` — 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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand All @@ -136,15 +149,15 @@ 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/<topic>`.
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.

Example: fork PR #13 → upstream PR #56 (`upstream/fix-single-instance-lock` branch).

## 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.
Loading
Loading