@zhuxixi/pi-agent-board 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/IMPLEMENTATION_PLAN.md +920 -0
  2. package/LICENSE +21 -0
  3. package/PRD.md +484 -0
  4. package/PROGRESS.md +127 -0
  5. package/README.md +131 -0
  6. package/VERIFY.md +113 -0
  7. package/docs/BATCH_SELECTION_READ_FLOW.md +277 -0
  8. package/docs/EXPLORATION.md +187 -0
  9. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +579 -0
  10. package/docs/superpowers/plans/2026-08-15-screenlog-gc.md +704 -0
  11. package/docs/superpowers/plans/2026-08-16-attach-double-cursor-jiggle-retry.md +499 -0
  12. package/docs/superpowers/plans/2026-08-21-dashboard-keypress-lag.md +366 -0
  13. package/docs/superpowers/specs/2026-08-15-screenlog-gc-design.md +105 -0
  14. package/docs/superpowers/specs/2026-08-16-attach-double-cursor-jiggle-retry-design.md +142 -0
  15. package/docs/superpowers/specs/2026-08-21-dashboard-keypress-lag-design.md +59 -0
  16. package/index.ts +6 -0
  17. package/package.json +81 -0
  18. package/runner/job-runner.mjs +420 -0
  19. package/runner/pty-runner.mjs +310 -0
  20. package/runner/state-runner.mjs +120 -0
  21. package/runner/title-runner.mjs +80 -0
  22. package/scripts/patch-vulns.mjs +59 -0
  23. package/src/commands/agent-board.ts +318 -0
  24. package/src/commands/attach-flow.ts +231 -0
  25. package/src/commands/bg.ts +70 -0
  26. package/src/core/atomic.mjs +145 -0
  27. package/src/core/auto-state.mjs +320 -0
  28. package/src/core/dashboard-render.mjs +10 -0
  29. package/src/core/derive.mjs +114 -0
  30. package/src/core/diagnostics.mjs +109 -0
  31. package/src/core/events.mjs +268 -0
  32. package/src/core/evidence.mjs +242 -0
  33. package/src/core/follow-up-queue.mjs +193 -0
  34. package/src/core/heuristics.mjs +240 -0
  35. package/src/core/ids.mjs +35 -0
  36. package/src/core/invocation.mjs +43 -0
  37. package/src/core/launch-options.mjs +317 -0
  38. package/src/core/launch.mjs +116 -0
  39. package/src/core/locks.mjs +80 -0
  40. package/src/core/paths.mjs +86 -0
  41. package/src/core/pid.mjs +42 -0
  42. package/src/core/prewarm-schedule.mjs +41 -0
  43. package/src/core/prompt-transport.mjs +13 -0
  44. package/src/core/pty-attach-jiggle-retry.mjs +90 -0
  45. package/src/core/pty-attach-render.mjs +51 -0
  46. package/src/core/pty-input.mjs +15 -0
  47. package/src/core/pty-links.mjs +71 -0
  48. package/src/core/pty-scroll.mjs +155 -0
  49. package/src/core/pty-support.mjs +327 -0
  50. package/src/core/repo.mjs +47 -0
  51. package/src/core/rows.mjs +290 -0
  52. package/src/core/screen-log-gc.mjs +198 -0
  53. package/src/core/screen-log.mjs +160 -0
  54. package/src/core/session-view.mjs +174 -0
  55. package/src/core/steering-prompts.mjs +34 -0
  56. package/src/core/steering.mjs +133 -0
  57. package/src/core/store.mjs +308 -0
  58. package/src/core/title.mjs +43 -0
  59. package/src/core/types.mjs +380 -0
  60. package/src/core/worktree.mjs +64 -0
  61. package/src/index.ts +109 -0
  62. package/src/runtime/service.mjs +1194 -0
  63. package/src/ui/dashboard-evidence.mjs +85 -0
  64. package/src/ui/dashboard.ts +1952 -0
  65. package/src/ui/pty-attach.ts +1378 -0
@@ -0,0 +1,187 @@
1
+ # Exploration & Decisions — Pi Agent Board Extension
2
+
3
+ **For future agents.** This is the distilled gist of the codebase recon done before
4
+ implementing `IMPLEMENTATION_PLAN.md`. Read this first; it saves re-deriving the Pi API.
5
+
6
+ Runtime target: `/opt/homebrew/lib/node_modules/@earendil-works/pi-coding-agent` (v0.75.5).
7
+ `pi` binary is on PATH at `/opt/homebrew/bin/pi` → `dist/cli.js`.
8
+
9
+ ---
10
+
11
+ ## 1. How a Pi extension is loaded & shaped
12
+
13
+ - An extension is a TS module: `export default function (pi: ExtensionAPI) { ... }`
14
+ (may be `async`; Pi awaits it before `session_start`).
15
+ - Auto-discovered from `~/.pi/agent/extensions/*.ts` or `<name>/index.ts` (global) and
16
+ `.pi/extensions/...` (project). `/reload` hot-reloads them. `pi -e ./path.ts` for quick tests.
17
+ - Pi's loader (jiti) resolves `@earendil-works/pi-coding-agent`, `@earendil-works/pi-ai`,
18
+ `@earendil-works/pi-tui`, `typebox` **for you at runtime** — you do not bundle them.
19
+ For local typechecking we path-map those to the global install (see `tsconfig.json`).
20
+ - Loaded TS runs via jiti. **Detached external processes (our runner) must NOT rely on
21
+ jiti** → the runner is a plain `.mjs` that we spawn with `node`.
22
+
23
+ ## 2. ExtensionAPI surface we actually use
24
+
25
+ - `pi.registerCommand(name, { description, handler(args, ctx), getArgumentCompletions? })`
26
+ — handler `ctx` is `ExtensionCommandContext` (adds `newSession/fork/switchSession/
27
+ navigateTree/reload/waitForIdle`). Session-switch methods deadlock from event handlers,
28
+ so they exist only on command ctx.
29
+ - `pi.registerShortcut(KeyId, { description, handler(ctx) })`.
30
+ - `pi.registerFlag(name, { description, type:"boolean"|"string", default })` + `pi.getFlag`.
31
+ - `pi.exec(cmd, args, { signal, timeout })` → `{ stdout, stderr, code, killed }`.
32
+ - `pi.appendEntry(customType, data)` persists extension state into the session JSONL
33
+ (custom entry, not sent to LLM). Restore by scanning `ctx.sessionManager.getEntries()`.
34
+ - `ctx.ui.*`: `select/confirm/input/editor/notify`, `setStatus/setWidget/setFooter`,
35
+ and **`custom<T>(factory, opts?) => Promise<T>`** (the dashboard surface).
36
+ - `ctx.switchSession(path, { withSession })` — attach = switch the interactive session to
37
+ a managed session file. `withSession(ctx)` runs **after** the old runtime is torn down;
38
+ only use the fresh `ctx` passed in, never captured old `pi`/`ctx`/`sessionManager`.
39
+
40
+ ### `ctx.ui.custom` (the important one)
41
+ ```ts
42
+ const result = await ctx.ui.custom<T>((tui, theme, keybindings, done) => {
43
+ // return a Component: { render(width): string[]; invalidate(); handleInput?(data) }
44
+ // call tui.requestRender() after state changes to repaint
45
+ // call done(value) to close the surface and resolve the promise
46
+ });
47
+ ```
48
+ - It **replaces the editor** with your component until `done()` (full-screen-ish surface).
49
+ - It returns a `Promise`, NOT a handle. Live updates: hold a `tui` ref, call
50
+ `tui.requestRender()` from a `setInterval`/file-watch/poll loop. Terminal height =
51
+ `tui.terminal.rows`, width comes in as the `render(width)` arg.
52
+ - `{ overlay: true, overlayOptions }` renders a floating modal instead (used for peek/dialogs
53
+ if we want; MVP keeps peek as an internal mode of the dashboard component).
54
+
55
+ ## 3. TUI building blocks (`@earendil-works/pi-tui`)
56
+
57
+ Components implement `{ render(width:number):string[]; invalidate():void; handleInput?(data):void }`
58
+ — each rendered line **must not exceed `width`** (use `truncateToWidth`).
59
+ Exports we use: `Text, Box, Container, Spacer, Markdown, SelectList, SettingsList,
60
+ matchesKey, Key, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component`.
61
+ From coding-agent: `DynamicBorder, BorderedLoader, getMarkdownTheme, getSettingsListTheme,
62
+ keyHint`. Theme: `theme.fg(color, text)`, `theme.bold/italic/strikethrough`.
63
+ Key input: `matchesKey(data, Key.up)` etc. After state change in `handleInput`, call
64
+ `tui.requestRender()`.
65
+
66
+ ## 4. Sessions & the headless worker (the core mechanism)
67
+
68
+ - Session files are JSONL trees (`id`/`parentId`); first line is a header
69
+ `{"type":"session","version":3,"id":"<uuid>","timestamp":"<iso>","cwd":"<cwd>"}`.
70
+ - **Launching a managed run:**
71
+ `pi --mode json -p --session <file> "<prompt>"`, spawned with `cwd: <repo|worktree>`.
72
+ - `--session <path>` with a path that contains `/` or ends `.jsonl` → `SessionManager.open(path)`.
73
+ If the file **doesn't exist**, Pi creates a fresh session and persists to that **exact path**
74
+ (verified in `dist/core/session-manager.ts` `open()` → `setSessionFile` else-branch).
75
+ The header `cwd` becomes the spawn `cwd`, so always set `cwd` correctly.
76
+ - `--mode json` = one-shot: it `session.subscribe((e)=>stdout(JSON.stringify(e)+"\n"))`,
77
+ runs the single prompt to completion (a full agent loop, many turns/tools), then exits
78
+ **with `agent_end` as the last event** — `worker.on("close")` is the run-finished signal.
79
+ - **⚠️ AUTH/NETWORK CAVEAT (verified 2026-05-30):** the one-shot exits cleanly *only when the
80
+ worker can actually reach the model provider*. With no/blocked pi provider auth (or a
81
+ network/proxy stall), `pi --mode json -p` records `session → agent_start → turn_start →
82
+ message_start(user) → message_end(user)` and then **blocks indefinitely at the provider
83
+ request** (idle, ~3% CPU) — **no assistant reply, no `agent_end`, no process exit**. This is
84
+ a pi/runtime condition, NOT an extension bug. The runner handles it correctly: the row stays
85
+ `working/alive` until the user stops it (`s` in the dashboard → SIGTERM → finalized `stopped`).
86
+ Note: pi's own provider auth is separate from Claude Code's `/login`. Validate with
87
+ `pi --mode json -p --no-session "say hi"` — it must print events ending in `agent_end` and exit.
88
+ - `-p` forces non-interactive. `--model <m>`, `--tools a,b`, `--append-system-prompt <file>`
89
+ are available. `--no-session` = ephemeral (we do NOT use it for managed rows; we want persistence).
90
+ - Reply / resume = launch another run against the **same** `--session <file>`; Pi appends to it.
91
+
92
+ ### JSON event vocabulary actually emitted (parse these in the runner)
93
+ First line: the session header `{type:"session",...}`. Then per `AgentSessionEvent`:
94
+ `agent_start`, `agent_end`, `turn_start`, `turn_end`,
95
+ `message_start` / `message_update` / `message_end` (each has `.message`: an AgentMessage),
96
+ `tool_execution_start` `{toolCallId,toolName,args}`,
97
+ `tool_execution_update` `{...,partialResult}`,
98
+ `tool_execution_end` `{toolCallId,toolName,result,isError}`,
99
+ `tool_call`, `tool_result`, plus `queue_update`, `compaction_*`, `auto_retry_*`,
100
+ `model_select`, `thinking_level_*`, `session_*`.
101
+ > NOTE: the subagent example reads `tool_result_end` — that string is **never emitted** (dead
102
+ > defensive code). Use `message_end` (role assistant → text/toolCalls/usage/stopReason/errorMessage)
103
+ > and `tool_execution_start/end` for live state. `agent_end` ⇒ run finished.
104
+
105
+ AssistantMessage shape: `{ role:"assistant", content:(TextContent|ThinkingContent|ToolCall)[],
106
+ model, usage, stopReason:"stop"|"length"|"toolUse"|"error"|"aborted", errorMessage? }`.
107
+ ToolCall content block: `{ type:"toolCall", id, name, arguments }`.
108
+
109
+ ## 5. Architecture chosen (matches plan §3.3) — file-backed detached runner
110
+
111
+ ```
112
+ Pi extension (parent, interactive) runner/job-runner.mjs (detached, 1 per run)
113
+ /agent-board dashboard (ctx.ui.custom) ─► spawns: pi --mode json -p --session <file> <prompt>
114
+ store reader/writer parses JSON event lines from worker stdout
115
+ launches runner via child_process writes runs/<runId>/{status.json,events.jsonl,
116
+ polls store files to repaint stdout.log,stderr.log,pid.json}
117
+ updates views/<id>/state.json on progress/exit
118
+ ```
119
+ Why detached `.mjs` runner (not spawn pi directly from the extension, not a daemon):
120
+ survives parent `/reload`/exit, durable status, no socket/daemon complexity. Each run is the
121
+ durable monitor for one worker.
122
+
123
+ ### Store layout (user-scoped) — `~/.pi/agent/agent-board/`
124
+ ```
125
+ roster.json { version, views: [viewId...] }
126
+ views/<viewId>/meta.json stable: id,name,cwd,sessionFile,createdAt,pinned,kind,
127
+ worktreeMode,worktreePath,defaultModel,source,archived
128
+ views/<viewId>/state.json derived: currentRunId,semanticState,processState,summary,
129
+ needsInput,hasError,latestAssistantPreview,latestTool,lastActivityAt
130
+ views/<viewId>/runs/<runId>/status.json per-run durable snapshot (pid,startedAt,endedAt,
131
+ exitCode,semanticState,currentTool,latestAssistantPreview,question,error)
132
+ views/<viewId>/runs/<runId>/{events.jsonl,stdout.log,stderr.log,pid.json}
133
+ sessions/<viewId>.jsonl the managed Pi session file (Pi creates/persists it)
134
+ ```
135
+ - One row = one real Pi session file. One run = one detached worker against it.
136
+ - Both `state.json` (row, long-lived) and `status.json` (run, single attempt) exist so
137
+ reply/resume/attach stay clean.
138
+ - Atomic writes: write tmp + `rename`. Liveness: `pid.json` + `process.kill(pid,0)`.
139
+
140
+ ## 6. State derivation rules (plan §9)
141
+
142
+ Semantic: `queued`→`working` (alive, once assistant/tool activity); on clean exit
143
+ `needs_input` (assistant ends asking) | `completed` | `idle`; on bad exit `failed`;
144
+ user-stopped `stopped`. Process: `alive` if pid live else `exited`.
145
+ Summary priority: model-summary(opt-in) → active tool (`Editing x`,`Running tests`) →
146
+ blocker/question → first sentence of latest assistant → stderr/error → fallback status.
147
+ Needs-input heuristic: assistant text ends in `?` or contains `need your input`/
148
+ `which option`/`should I`/`please confirm`.
149
+
150
+ ## 7. Safety / worktree (plan §11)
151
+
152
+ Same-repo parallel **writers** require git worktree isolation (locked decision). MVP:
153
+ explicit `worktreeMode:"worktree"` per dispatch → `git worktree add` under
154
+ `~/.pi/agent/agent-board/worktrees/<viewId>`; persist `worktreePath`; block a 2nd active
155
+ writer in the same repo if not isolated. Delete row ≠ delete data: archive row, keep
156
+ session file; worktree removal needs explicit confirm.
157
+
158
+ ## 8. Decisions made for this implementation
159
+
160
+ 1. **Runner is `runner/job-runner.mjs` (plain ESM)**, spawned with `node`. It imports only
161
+ `node:*`. The pi binary it spawns is passed in via run-config (`piBin`, default `"pi"`).
162
+ 2. **Extension code is TS**, loaded by Pi via jiti. We add `package.json` + `tsconfig.json`
163
+ only for local typecheck/tests (path-mapped to the global pi install); not needed at runtime.
164
+ 3. **Summaries: cheap model is the DEFAULT** (`DEFAULT_SUMMARY_MODEL = "gpt-4o"` in the runner;
165
+ user choice 2026-05-30), with the **heuristic kept as a graceful fallback** (no API key /
166
+ offline / timeout) and still used for live state detection (needs-input, semantic state).
167
+ Override with `AGENT_BOARD_SUMMARY_MODEL=<model>`, disable with `=off`. The summary call has a
168
+ 15s safety timeout and runs *after* the heuristic terminal state is already persisted, so a
169
+ slow/unreachable summary model never stalls the dashboard (row flips to final state at once,
170
+ summary upgrades a few seconds later). gpt-4o needs OpenAI auth; without it → heuristic.
171
+ 4. **Dashboard = single `ctx.ui.custom` component** owning list + peek + dispatch input as
172
+ internal modes (plan §10.4 recommendation). Live updates via a poll loop calling
173
+ `tui.requestRender()` (store files are the source of truth; cheap to re-read).
174
+ 5. **Attach = `ctx.switchSession(sessionFile, { withSession })`** from the `/agent-board` command
175
+ handler. If the row's run is alive → confirm interrupt (stop run) then attach (locked decision).
176
+ 6. **Verification without a TTY/API key:** unit tests for all pure logic + an integration test
177
+ that runs the real `.mjs` runner against a **fake `pi`** stub script emitting canned JSON
178
+ event lines, asserting the durable store artifacts. (`test-support/fake-pi.mjs`.)
179
+
180
+ ## 9. Gotchas
181
+
182
+ - `ctx.ui.custom` callback's component must return lines ≤ width; always `truncateToWidth`.
183
+ - Rebuild themed content on `invalidate()` (theme change) if you pre-bake `theme.fg(...)`.
184
+ - Don't import `pi-subagents/src/...` internals (unstable) — reference only.
185
+ - Session-switch `withSession` runs post-teardown; capture only plain strings/ids.
186
+ - The runner must update `status.json` atomically and frequently but throttled (avoid fs thrash).
187
+ - `process.kill(pid, 0)` throws ESRCH if dead, EPERM if alive-but-not-ours → treat EPERM as alive.