diff --git a/.ai/contexts/README.md b/.ai/contexts/README.md new file mode 100644 index 0000000..5e8df23 --- /dev/null +++ b/.ai/contexts/README.md @@ -0,0 +1,36 @@ +# 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`. + +## When to read which + +| Touching this area | Read | +|---|---| +| SQLite, indexing, search, heatmap aggregation, fs.watch | [session-cache](session-cache.md) | +| Cron schedules, schedule `.md` files, `claude --resume -p` spawn | [schedule-runner](schedule-runner.md) | +| Subagent sidebar grouping, transcript view, parent→child wiring | [subagent-observability](subagent-observability.md) | +| Plans/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) | + +## Reading order for a new contributor (~30 min) + +1. **`ipc-bridge`** — gives you the public surface of the whole app in one page +2. **`session-cache`** — gives you the data model +3. **`subagent-observability`** — the #1 fork-specific feature, touches everything +4. **`viewer-panel`** — the reusable read/edit-file framework +5. **`schedule-runner`** — least-cross-cutting; safe to skip if you're not touching schedules + +## What's NOT in these contexts (intentionally) + +- **Terminal management** — xterm.js + node-pty integration in `public/terminal-manager.js`. Not yet documented because it's largely upstream code with minor extensions. If you're working there, read the file directly. +- **MCP / IDE emulation** — file diff panel, OSC 8 hyperlinks, etc. Lives in `public/file-panel.js` and `main.js` MCP bridge handlers. Not yet documented; in-flight upstream work. +- **Settings UI** — per-project + global, lives in `public/settings-panel.js`. Mostly self-contained, low coupling. +- **Sidebar rendering details** — covered piecemeal in subagent-observability + session-cache; the full sidebar is `public/sidebar.js`. If you're doing UI work there, expect to read the file. +- **Build / electron-builder** — covered in [README.md](../../README.md). Not a code area an agent typically modifies. + +## Updating these docs + +When a feature lands, the corresponding context doc should change in the same PR. **If you can't decide which context owns a change, it probably means a new sub-system is emerging — write a new doc instead of stretching an existing one.** + +Pre-existing observations / nits found while writing these docs are captured in [_issues.md](_issues.md). They're not blockers but worth a follow-up when adjacent work happens. diff --git a/.ai/contexts/_issues.md b/.ai/contexts/_issues.md new file mode 100644 index 0000000..811a7a7 --- /dev/null +++ b/.ai/contexts/_issues.md @@ -0,0 +1,94 @@ +# Observations from writing the context docs + +Captured 2026-05-30 while writing the .ai/contexts/*.md files. None are blockers; they're follow-ups worth picking up when adjacent work happens. + +## Architecture + +- **`main.js` is 1849 LOC**, vs upstream's ~350. It mixes IPC handlers (the bulk), app-lifecycle wiring, scheduling glue, MCP bridge, native-module instantiation, and updater integration. A modest refactor would split it into: + - `ipc/sessions.js`, `ipc/projects.js`, `ipc/work-files.js`, `ipc/stats.js`, `ipc/updater.js` + - `lifecycle.js` (app.on, BrowserWindow, single-instance lock, requestSingleInstanceLock) + - `mcp-bridge.js` (already partial) + + Risk: lots of churn for diff readability. Worth doing once, all at once, **not piecemeal**. + +- **No subagent-observability module** — the feature is implemented across `session-cache.js` (indexing), `main.js` (4+ IPCs), `public/sidebar.js` (rendering), `public/jsonl-viewer.js` (transcript). Considered creating a `subagent/` directory but the cross-cuts are real (it's a feature, not a sub-system). The context doc cross-references the pieces. + +## IPC surface + +- **Inconsistent return shapes**: some IPCs return `{ok: true}`, others return raw values, others return `{error: '...'}` on failure with no `ok` field. No canonical contract. Examples: + - `delete-work-file` returns `{ok, error}` — well-shaped + - `read-work-file` returns a raw string OR a sentinel string like `'[binary file]'` — bad + - `remap-project` returns `{ok: true}` or `{error: '...'}` — well-shaped but no `ok: false` discriminator + + Standardising would be a sweep-style PR; touchable by Sonnet in a couple of hours. + +- **`get-stats` legacy**: kept for backward compat ("fallback") but no caller uses it since PR #7. Could be removed. + +- **`updaterEvent` polymorphism**: one event type covers 5+ subtypes. Different from the per-event `onSubagentSpawned/Completed` pattern. Inconsistency tax with no migration cost — fix when next touching the updater. + +## session-cache + +- **`getCachedByFolder` is `SELECT *`** — convenient for additions but means any new column lands in every consumer's payload silently. Probably fine; just be aware. + +- **`refreshFolder(folder, opts)` does NOT take a "force header-only" knob** — it picks header-only vs full read per file based on whether the file was already cached. For most paths this is right, but a manual cache-bust would need a way to force full read. + +- **No tests for `enumerateSessionFiles` directly** — it's exercised transitively via `derive-project-path.test.js` and `remap-project.test.js`. A dedicated unit test for the file enumeration would be cheap and prevent future regressions. + +- **FTS `'work-file'` entries include the full file body** for files ≤ 64 KB and non-`.jsonl`. No per-file gzip or content-hash; if a 60 KB markdown changes on every byte, the FTS table churns. Acceptable for personal use; not for large monorepos. + +## schedule-runner + +- **No DST handling**. 02:30 on spring-forward = silently skipped. 02:30 on fall-back = fires twice. Document fix would be `new Date().getTimezoneOffset()` watch; real fix is non-trivial. + +- **Hand-rolled cron parser** has no `@daily`/`@hourly` aliases. Most users won't notice but it's surprising vs other cron implementations. + +- **`runningTasks` is a Set per file path** — schedules can't share state. Fine for the current design. + +- **No `kill timeout`** on detached child processes. A schedule that runs forever (or hangs on permission prompt despite `acceptEdits`) will silently consume a slot until process death. + +## viewer-panel + +- **`viewer-panel.js` constructor takes 9+ opts**. Could benefit from a builder or named-option grouping (clipboard, save, delete) but the current shape is workable. + +- **No way to switch language mode after `open()`** — `language: 'auto'` infers from file extension at construction, but if you re-`open()` a `.md` file in a panel constructed with `language: 'auto'`, the editor mode may stale-cache. + +- **`format` JSON-line joiner uses `\n---\n` as separator** — not valid JSON, intentional. Documented in the context doc, but a reader unfamiliar with the choice may file a bug. + +- **No undo across `format` invocations** — CodeMirror's undo stack tracks the format as one large diff. Multiple format clicks accumulate. Acceptable. + +## subagent-observability + +- **The "Resume in terminal anyway" escape hatch is a footgun by design** — re-resuming a subagent that's done can corrupt context. A confirm dialog would prevent accidental clicks. + +- **No persistence of which subagent transcripts the user has "seen"** — every reload shows them as fresh. Could be tracked via `localStorage` for a "new" badge. + +- **Watch leak risk on rapid open/close** — `drainViewerWatches` handles the close case, but if the user opens 20 subagent transcripts in quick succession and only the last one is visible, 19 watchers may still tail their files until panel destroy. Worth profiling. + +## Tests + +- **No `enumerateSessionFiles` unit test** (see session-cache section). +- **No integration test for the full schedule-fire pathway** — `scanSchedules` is tested, `cronMatches` is tested, but the wired-up `setInterval + runScheduleCommand` path is not. +- **Worktree node_modules can have missing native modules** (morphdom-umd.js encountered 2026-05-30) — confused a code-reviewer agent into reporting false "pre-existing fails". Could be fixed by `npm install` after worktree creation as a harness step. + +## Build pitfalls + +- **`npm run build:linux` while Switchboard is running can kill the running instance.** Witnessed 2026-05-31 ~13:49 — the AppImage process stopped logging mid-window during a background `npm run build:linux`, no SIGTERM trace in `~/.config/switchboard/logs/main.log`, just a 9-minute silence then a fresh launch by the user. Confirmed harmless: the file `cp dist/*.AppImage ~/Applications/` AFTER the build completes (the cp itself is safe — the running process is mmap'd from `/tmp/.mount_*`). + + **Suspected mechanism**: electron-builder invokes `@electron/rebuild` for native modules (`better-sqlite3`, `node-pty`). These modules are `dlopen()`-loaded by the running AppImage. If electron-rebuild uses `truncate+write` instead of `atomic rename`, the running process loses access to its `better_sqlite3.node` binding at the next SQLite call → segfault → kernel SIGKILL with no app-level trace. Not confirmed without sudo dmesg access. + + **Mitigations to consider**: + 1. **Skip native rebuild when running locally**: `npm config set npm_config_skip_electron_rebuild true` before build, or use `--config.npmRebuild=false` on electron-builder, or build only the asar (`electron-builder --linux AppImage --dir`). + 2. **Build in a dedicated worktree with its own `node_modules/`**: full isolation, slower (extra `npm install`). + 3. **Document loudly and tell the user before any background build**: lowest tech, highest reliability. + + **The `cp` step itself is safe** — confirmed by replacing the file while the process kept running (mtime 17:36, PID 2620410 still alive). The running AppImage is fully extracted to `/tmp/.mount_*` at launch and does not page back from the on-disk file. + +## Security + +- **`clipboard-write-text` accepts any string from any renderer** — fine given the single-renderer, single-user threat model, but worth a sentence in the security model doc if one exists. +- **`remap-project` validates `newPath` via `lstatSync` (no symlink follow)** — correct since PR #20 fix. Worth a code comment noting why `lstatSync` (not `statSync`). +- **No CSP on the renderer** — likely fine for a local desktop app that doesn't load remote content, but worth a thought if the file-panel ever displays untrusted HTML. + +--- + +*Use these as ammunition for opportunistic follow-ups, not as a TODO list. Most of them are "nice to have" — feature work should keep taking priority.* diff --git a/.ai/contexts/ipc-bridge.md b/.ai/contexts/ipc-bridge.md new file mode 100644 index 0000000..fc8108f --- /dev/null +++ b/.ai/contexts/ipc-bridge.md @@ -0,0 +1,133 @@ +# Context: ipc-bridge + +**Purpose**: The trust boundary between the Electron main process (Node, full filesystem) and the renderer (Chromium, sandboxed). The renderer can only call what `preload.js` exposes via `contextBridge`; everything else is denied. + +This file is the **canonical inventory** of the IPC surface. When you add a new IPC, you change three places — main handler, preload bridge, renderer caller — and every IPC name should appear here. + +## Key files + +| 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. | + +## Public surface (IPC inventory) + +### Sessions (request/response) + +| IPC | Args | Returns | Notes | +|---|---|---|---| +| `get-projects` | `(showArchived)` | `Project[]` | Sidebar payload. Reads from cache. | +| `get-active-sessions` | — | `Session[]` | Currently open PTY sessions | +| `get-active-terminals` | — | `Terminal[]` | Active PTY identifiers | +| `open-terminal` | `(id, projectPath, isNew, sessionOptions)` | `{ok, error?, mcpActive}` | Spawn or attach a PTY. | +| `stop-session` | `(id)` | `{ok}` | Kill the PTY for `id`. | +| `toggle-star` | `(id)` | `{ok}` | Star/unstar in session_meta. | +| `rename-session` | `(id, name)` | `{ok}` | Set customTitle. | +| `archive-session` | `(id, archived)` | `{ok}` | Move to archive. | +| `read-session-jsonl` | `(sessionId)` | `Entry[]` | Full transcript. | +| `read-subagent-jsonl` | `(parentSessionId, agentId)` | `Entry[]` | Subagent transcript. | +| `list-subagents` | `(parentSessionId)` | `Subagent[]` | All children of a parent. | +| `start-subagent-watch` | `(parentSessionId, agentId)` | `watchId` | Begin tailing. | +| `stop-subagent-watch` | `(watchId)` | `{ok}` | Tear down watch. | + +### Projects + worktrees + +| IPC | Args | Notes | +|---|---|---| +| `browse-folder` | — | Native folder picker | +| `add-project` | `(projectPath)` | Register a project (creates folder index) | +| `remove-project` | `(projectPath)` | Hide a project from sidebar | +| `remap-project` | `(oldPath, newPath)` | **Atomic JSONL `cwd` rewrite**, refuses if active sessions exist. See PR #20. | +| `delete-worktree` | `(worktreePath)` | `git worktree remove` | +| `worktree-status` | `(worktreePath)` | Dirty-file count | + +### Tabs (Plans / Memory / .work-files / Stats) + +| IPC | Returns | +|---|---| +| `get-plans` | `Plan[]` (reads `~/.claude/plans/*.md`) | +| `read-plan` / `save-plan` | content / `{ok}` | +| `get-memories` | `{global, projects}` | +| `read-memory` / `save-memory` | content / `{ok}` | +| `get-work-files` | `{projects: WorkFilesProject[]}` — **dedupes by projectPath** since PR #15. Walks `/.work-files/` recursively, capped at 200 files per project. | +| `read-work-file` / `delete-work-file` | content (with `.work-files/` path guard) / `{ok}` | +| `get-stats-from-db` | `{dailyActivity, totalMessages, totalSessions, firstSessionDate, lastComputedDate}` — heatmap source since PR #7 | +| `refresh-stats` | `{stats, usage}` — combined; calls `getDailyActivity` + `fetchAndTransformUsage` | +| `get-usage` | rate-limits payload from Claude `/usage` | +| `get-stats` | `~/.claude/stats-cache.json` raw (legacy; kept for fallback) | + +### Search + +| IPC | Args | Returns | +|---|---|---| +| `search` | `(type, query, titleOnly)` | FTS5 result rows. `type ∈ {session, subagent, plan, memory, work-file, null}` | +| `rebuild-cache` | — | Force a full re-index (heavy) | + +### Settings + +| IPC | Notes | +|---|---| +| `get-setting` / `set-setting` / `delete-setting` | Generic key/value over `settings` table | +| `get-effective-settings` | `(projectPath)` — resolves global + project overrides | +| `get-shell-profiles` | Configured shell list | +| `get-schedule-creator-command` / `create-schedule-session` / `run-schedule-now` | Schedule integration | + +### File panel (IDE mode) + +| IPC | Args | +|---|---| +| `read-file-for-panel` / `save-file-for-panel` | Arbitrary file IO inside the user's projects | +| `watch-file` / `unwatch-file` | fs.watch wrapper, emits `file-changed` event | + +### Misc + +| IPC | Notes | +|---|---| +| `open-external` | Opens https:// URLs in OS browser | +| `clipboard-write-text` | Main-process clipboard write (Wayland fix, PR #18) | +| `get-app-version` | From package.json | +| `updater-check` / `updater-download` / `updater-install` | electron-updater | + +### Send (fire-and-forget, renderer → main) + +| IPC | Notes | +|---|---| +| `terminal-input` | Forward keypress to PTY | +| `terminal-resize` | Resize PTY columns/rows | +| `close-terminal` | Renderer signals tab closed | +| `mcp-diff-response` | Diff accept/reject from MCP IDE mode | + +### Events (main → renderer) + +`terminal-data`, `session-detected`, `process-exited`, `terminal-notification`, `cli-busy-state`, `session-forked`, `subagent-spawned`, `subagent-completed`, `subagent-watch-event`, `projects-changed`, `status-update`, `file-changed`, `mcp-open-diff`, `mcp-open-file`, `mcp-close-all-diffs`, `mcp-close-tab`, `updater-event` + +## Invariants + +- **No `nodeIntegration` in renderer**. The renderer can only call what's in `window.api`. `contextIsolation: true` is mandatory in BrowserWindow options. +- **Every IPC must validate its arguments** at the main-side handler. The renderer is trusted-ish (single user, single window) but a compromised renderer should not be able to escape the user's working directories. +- **Path-touching IPCs (`read-work-file`, `delete-work-file`, `read-memory`, etc.) MUST guard their paths**. Pattern: `path.resolve(input).includes('/.work-files/')` for the work-files IPC. Audit every new path IPC. +- **Trust boundary is the contextBridge call**. Anything passed across must survive structured-clone serialization. No functions, no DOM nodes, no class instances — only plain JSON. +- **Async handlers return promises**. Renderer uses `await window.api.foo(...)`. Throws cross the boundary as rejected promises; return `{ok, error}` if you want graceful failure handling on the renderer side. + +## Non-obvious behaviors + +- **`preload.js` is the *single* surface the renderer sees**. If you add `ipcMain.handle('xyz', ...)` but forget to add `xyz: () => ipcRenderer.invoke('xyz')` in preload, the renderer can't call it. Symptom: `window.api.xyz is not a function`. +- **Webcontents `send` events vs `invoke`**: `invoke`/`handle` is request-response (returns a promise). `send`/`on` is fire-and-forget (no return). Pick based on whether the caller needs the result. +- **`webUtils.getPathForFile(file)`** is the only way to get the absolute path of a drag-and-dropped file in Electron 28+. Exposed at `window.api.getPathForFile`. +- **Updater events use a single `onUpdaterEvent(type, data)` callback** for all 5+ event types — different from the per-event onSubagentSpawned/Completed pattern. Inconsistency tax. + +## If you change this, also check + +- **Three places per new IPC**: handler in `main.js`, bridge entry in `preload.js`, caller in `public/*.js` (and maybe `eslint.config.js` if you expose a new global). +- `eslint.config.js` `rendererCrossFileGlobals` — renderer functions exposed across `