@gavin-hu/my-pi-agent 0.14.0 → 0.15.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 (222) hide show
  1. package/README.md +55 -33
  2. package/README.zh-CN.md +44 -27
  3. package/extensions/bytefly/README.md +193 -140
  4. package/extensions/bytefly/block/component.ts +7 -15
  5. package/extensions/bytefly/block/register.ts +6 -1
  6. package/extensions/bytefly/doctor/register.ts +24 -5
  7. package/extensions/bytefly/doctor/tools.ts +4 -0
  8. package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
  9. package/extensions/bytefly/footer/config.ts +10 -6
  10. package/extensions/bytefly/footer/price/price.ts +5 -3
  11. package/extensions/bytefly/footer/price/rates.ts +63 -26
  12. package/extensions/bytefly/footer/render/status.ts +3 -3
  13. package/extensions/bytefly/header/component.ts +3 -2
  14. package/extensions/bytefly/index.ts +3 -3
  15. package/extensions/bytefly/notice/component.ts +8 -3
  16. package/extensions/bytefly/notify/config.ts +6 -11
  17. package/extensions/bytefly/notify/dispatch.ts +13 -5
  18. package/extensions/bytefly/panel/commands.ts +21 -1
  19. package/extensions/bytefly/panel/format.ts +5 -2
  20. package/extensions/bytefly/panel/settings-runtime.ts +26 -6
  21. package/extensions/bytefly/panel/settings.ts +41 -4
  22. package/extensions/bytefly/panel/themes.ts +32 -0
  23. package/extensions/bytefly/panel/tui/editing.ts +77 -0
  24. package/extensions/bytefly/panel/tui/entries.ts +24 -0
  25. package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
  26. package/extensions/bytefly/panel/tui/frame.ts +121 -4
  27. package/extensions/bytefly/panel/tui/settings-view.ts +54 -15
  28. package/extensions/bytefly/panel/tui.ts +94 -152
  29. package/extensions/bytefly/picker/discovery.ts +1 -0
  30. package/extensions/bytefly/picker/types.ts +2 -0
  31. package/extensions/bytefly/separator/component.ts +14 -4
  32. package/extensions/bytefly/update/cache.ts +58 -19
  33. package/extensions/bytefly/update/check.ts +35 -0
  34. package/extensions/bytefly/update/format.ts +76 -35
  35. package/extensions/bytefly/update/register.ts +144 -55
  36. package/extensions/bytefly/update/state.ts +4 -2
  37. package/extensions/bytefly/update/suppress.ts +66 -0
  38. package/extensions/bytefly/update/types.ts +15 -0
  39. package/extensions/check/README.md +253 -0
  40. package/extensions/check/commands.ts +128 -0
  41. package/extensions/check/config.ts +39 -0
  42. package/extensions/check/detect.ts +269 -0
  43. package/extensions/check/failures.ts +230 -0
  44. package/extensions/check/format.ts +135 -0
  45. package/extensions/check/index.ts +26 -0
  46. package/extensions/check/render.ts +185 -0
  47. package/extensions/check/runner.ts +176 -0
  48. package/extensions/check/runtime.ts +244 -0
  49. package/extensions/check/source.ts +80 -0
  50. package/extensions/check/tools.ts +173 -0
  51. package/extensions/check/tui.ts +195 -0
  52. package/extensions/check/types.ts +99 -0
  53. package/extensions/document/README.md +1 -1
  54. package/extensions/document/extract/pdf.ts +8 -16
  55. package/extensions/document/formats.ts +2 -2
  56. package/extensions/file-browse/README.md +66 -32
  57. package/extensions/file-browse/client.ts +95 -32
  58. package/extensions/file-browse/config.ts +9 -9
  59. package/extensions/file-browse/git.ts +37 -47
  60. package/extensions/file-browse/html.ts +39 -58
  61. package/extensions/file-browse/markdown.ts +110 -92
  62. package/extensions/file-browse/raw-html.ts +478 -0
  63. package/extensions/file-browse/router.ts +32 -70
  64. package/extensions/file-browse/server.ts +18 -56
  65. package/extensions/file-browse/urls.ts +80 -0
  66. package/extensions/goal/README.md +103 -47
  67. package/extensions/goal/clarify-parse.ts +4 -9
  68. package/extensions/goal/clarify.ts +27 -4
  69. package/extensions/goal/commands.ts +24 -11
  70. package/extensions/goal/format.ts +41 -10
  71. package/extensions/goal/index.ts +39 -16
  72. package/extensions/goal/plan-route.ts +51 -0
  73. package/extensions/goal/schema.ts +6 -23
  74. package/extensions/goal/state.ts +39 -57
  75. package/extensions/goal/tools.ts +26 -20
  76. package/extensions/goal/types.ts +11 -1
  77. package/extensions/job/README.md +20 -6
  78. package/extensions/job/format.ts +18 -68
  79. package/extensions/job/index.ts +26 -41
  80. package/extensions/job/log-writer.ts +208 -0
  81. package/extensions/job/logs.ts +3 -2
  82. package/extensions/job/process.ts +6 -99
  83. package/extensions/job/runtime.ts +42 -181
  84. package/extensions/job/schema.ts +1 -4
  85. package/extensions/job/tools.ts +1 -15
  86. package/extensions/job/tui.ts +117 -307
  87. package/extensions/job/ui.ts +7 -33
  88. package/extensions/memory/README.md +1 -1
  89. package/extensions/memory/errors.ts +5 -6
  90. package/extensions/memory/tui.ts +14 -24
  91. package/extensions/plan/README.md +94 -18
  92. package/extensions/plan/commands.ts +9 -5
  93. package/extensions/plan/format.ts +87 -0
  94. package/extensions/plan/index.ts +31 -15
  95. package/extensions/plan/plans.ts +2 -3
  96. package/extensions/plan/policy.ts +0 -6
  97. package/extensions/plan/runtime.ts +4 -2
  98. package/extensions/plan/steps.ts +37 -8
  99. package/extensions/plan/todos.ts +44 -3
  100. package/extensions/plan/tools/enter.ts +94 -0
  101. package/extensions/plan/tools/exit.ts +296 -0
  102. package/extensions/plan/tools/nested.ts +21 -0
  103. package/extensions/plan/tools/steps.ts +136 -0
  104. package/extensions/plan/tools/todo-seed.ts +76 -0
  105. package/extensions/plan/tools/write.ts +127 -0
  106. package/extensions/plan/tools.ts +18 -703
  107. package/extensions/plan/web-client.ts +6 -22
  108. package/extensions/plan/web-page.ts +19 -30
  109. package/extensions/plan/web-router.ts +34 -71
  110. package/extensions/plan/web.ts +13 -40
  111. package/extensions/question/answers.ts +3 -8
  112. package/extensions/question/tui.ts +6 -6
  113. package/extensions/rewind/README.md +95 -22
  114. package/extensions/rewind/checklist.ts +185 -0
  115. package/extensions/rewind/commands.ts +8 -0
  116. package/extensions/rewind/config.ts +3 -11
  117. package/extensions/rewind/format.ts +220 -2
  118. package/extensions/rewind/git.ts +74 -7
  119. package/extensions/rewind/hunk-picker.ts +114 -0
  120. package/extensions/rewind/patch.ts +88 -0
  121. package/extensions/rewind/path-picker.ts +67 -100
  122. package/extensions/rewind/paths.ts +93 -19
  123. package/extensions/rewind/restore.ts +53 -10
  124. package/extensions/rewind/review-tui.ts +320 -0
  125. package/extensions/rewind/review.ts +397 -0
  126. package/extensions/rewind/rewind-tui.ts +4 -3
  127. package/extensions/rewind/runtime.ts +94 -17
  128. package/extensions/subagent/README.md +61 -6
  129. package/extensions/subagent/background-tui.ts +208 -0
  130. package/extensions/subagent/background.ts +470 -0
  131. package/extensions/subagent/commands.ts +80 -0
  132. package/extensions/subagent/config.ts +4 -0
  133. package/extensions/subagent/format.ts +24 -1
  134. package/extensions/subagent/index.ts +91 -2
  135. package/extensions/subagent/render.ts +13 -0
  136. package/extensions/subagent/run.ts +17 -16
  137. package/extensions/subagent/schema.ts +12 -0
  138. package/extensions/subagent/types.ts +11 -0
  139. package/extensions/subagent/ui.ts +50 -0
  140. package/extensions/todo/README.md +18 -13
  141. package/extensions/todo/format.ts +3 -2
  142. package/extensions/todo/runtime.ts +3 -2
  143. package/extensions/todo/schema.ts +8 -84
  144. package/extensions/todo/state.ts +5 -46
  145. package/extensions/todo/tools.ts +2 -1
  146. package/extensions/todo/types.ts +6 -15
  147. package/extensions/watch/README.md +14 -6
  148. package/extensions/watch/child.ts +150 -0
  149. package/extensions/watch/format.ts +8 -48
  150. package/extensions/watch/index.ts +42 -58
  151. package/extensions/watch/runtime.ts +49 -128
  152. package/extensions/watch/schema.ts +1 -4
  153. package/extensions/watch/tools.ts +1 -11
  154. package/extensions/watch/tui.ts +103 -273
  155. package/extensions/watch/ui.ts +5 -32
  156. package/extensions/web-access/README.md +1 -1
  157. package/extensions/web-access/fetch/page.ts +1 -1
  158. package/extensions/web-access/fetch/tool.ts +6 -2
  159. package/extensions/web-access/search/search.ts +2 -5
  160. package/extensions/wechat/README.md +1 -1
  161. package/extensions/wechat/client.ts +23 -0
  162. package/extensions/wechat/commands.ts +57 -42
  163. package/extensions/wechat/deliver.ts +5 -9
  164. package/extensions/wechat/errors.ts +3 -6
  165. package/extensions/wechat/inbound.ts +2 -5
  166. package/extensions/wechat/index.ts +2 -8
  167. package/extensions/wechat/outbound.ts +2 -4
  168. package/extensions/wechat/peer-worker.ts +3 -8
  169. package/extensions/wechat/poll.ts +10 -9
  170. package/extensions/wechat/presence.ts +35 -62
  171. package/extensions/wechat/reply-pipeline.ts +3 -1
  172. package/extensions/wechat/status-screen.ts +1 -1
  173. package/extensions/wechat/timer.ts +57 -0
  174. package/extensions/worktree/config.ts +8 -20
  175. package/extensions/worktree/enter.ts +7 -12
  176. package/extensions/worktree/git.ts +38 -69
  177. package/extensions/worktree/index.ts +5 -4
  178. package/extensions/worktree/registry.ts +7 -7
  179. package/extensions/worktree/root-tools.ts +2 -2
  180. package/extensions/worktree/status.ts +3 -2
  181. package/lib/README.md +22 -8
  182. package/lib/config.ts +50 -3
  183. package/lib/errors.ts +12 -0
  184. package/lib/format.ts +50 -0
  185. package/lib/git/read.ts +8 -0
  186. package/lib/goal-state.ts +23 -8
  187. package/lib/http/headers.ts +54 -0
  188. package/lib/http/index.ts +13 -0
  189. package/lib/http/response.ts +46 -0
  190. package/lib/http/router.ts +52 -0
  191. package/lib/http/server.ts +91 -0
  192. package/lib/lines.ts +77 -0
  193. package/lib/pdf.ts +82 -0
  194. package/lib/plan-entry.ts +71 -0
  195. package/lib/plan-state.ts +34 -0
  196. package/lib/report.ts +99 -0
  197. package/lib/requirements.ts +25 -19
  198. package/lib/spawn.ts +170 -0
  199. package/lib/todo-state.ts +110 -23
  200. package/lib/tool-names.ts +7 -0
  201. package/lib/tool-result.ts +33 -0
  202. package/lib/tui/README.md +68 -3
  203. package/lib/tui/chrome.ts +28 -4
  204. package/lib/tui/columns.ts +6 -1
  205. package/lib/tui/confirm.ts +65 -0
  206. package/lib/tui/dock-screen.ts +410 -0
  207. package/lib/tui/glyphs.ts +2 -1
  208. package/lib/tui/indent.ts +1 -1
  209. package/lib/tui/index.ts +2 -0
  210. package/lib/tui/rows.ts +21 -0
  211. package/lib/tui/status-controller.ts +35 -1
  212. package/lib/tui/tool.ts +59 -1
  213. package/lib/web/components.ts +20 -1
  214. package/lib/web/markup.ts +32 -0
  215. package/lib/web/runtime.ts +50 -0
  216. package/package.json +22 -5
  217. package/skills/init/SKILL.md +165 -0
  218. package/skills/init/references/agents-md-quality.md +103 -0
  219. package/skills/init/references/interview-questions.md +81 -0
  220. package/skills/refactor/SKILL.md +141 -0
  221. package/extensions/watch/process.ts +0 -63
  222. package/extensions/web-access/fetch/pdf.ts +0 -45
package/lib/report.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The pending-report coordinator: how a live feature tells the model what
3
+ * happened without interrupting it.
4
+ *
5
+ * A watcher match and a finished job follow one rule — report at the next turn,
6
+ * and raise at most one triggered turn when the work opted into `wake`. The two
7
+ * traps are subtle enough to have been re-derived once per extension:
8
+ *
9
+ * - a wake is only safe while the agent is idle, so an outcome that arrives
10
+ * mid-turn must be queued rather than sent;
11
+ * - between the triggered send and the `agent_start` it causes, the agent still
12
+ * looks idle, so a second outcome in that window must not trigger a second
13
+ * turn. `waking` closes that window.
14
+ *
15
+ * Delivery is one shared transcript notice per burst, and a burst is marked
16
+ * reported only after the send succeeded, so a throw leaves it for the next
17
+ * turn. Value-only: every flag lives in the returned closure, never in a module
18
+ * variable, because each extension gets its own module instance.
19
+ */
20
+
21
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
22
+ import { pruneStaleNotices, type NoticeMessage } from "./notice.ts";
23
+
24
+ export interface PendingReporterOptions<T> {
25
+ /** Send a notice and ask the host to start one turn for it. */
26
+ wake(notice: NoticeMessage): void;
27
+ /** The outcomes not yet reported, oldest first. Does not mark them. */
28
+ pending(): T[];
29
+ /** Mark outcomes as reported, once the send has succeeded. */
30
+ markReported(items: T[]): void;
31
+ /** Build the notice for one burst: model text plus renderer details. */
32
+ buildNotice(items: T[]): NoticeMessage;
33
+ /** The notice's source, so only its own stale entries are pruned. */
34
+ source: string;
35
+ /** Runs after a wake turn was successfully requested, for an extra announcement. */
36
+ onWoke?(items: T[]): void;
37
+ }
38
+
39
+ /** The handlers an extension wires into its lifecycle hooks. */
40
+ export interface PendingReporter {
41
+ /** An outcome arrived: wake a turn if the agent is idle, else leave it queued. */
42
+ report(): void;
43
+ /** A new session starts idle. */
44
+ sessionStart(): void;
45
+ /** A turn began: outcomes queue up, and none of them triggers a turn. */
46
+ agentStart(): void;
47
+ /** The turn settled: a later outcome may wake again. */
48
+ agentSettled(): void;
49
+ /** Report what is queued at the start of a turn, without triggering one. */
50
+ beforeAgentStart(): { message: NoticeMessage } | undefined;
51
+ /** Drop this source's older notices from the outgoing context. */
52
+ filterContext(messages: readonly AgentMessage[]): { messages: AgentMessage[] } | undefined;
53
+ }
54
+
55
+ export function createPendingReporter<T>(options: PendingReporterOptions<T>): PendingReporter {
56
+ const { wake, pending, markReported, buildNotice, source, onWoke } = options;
57
+ let busy = false;
58
+ let waking = false;
59
+
60
+ return {
61
+ report: () => {
62
+ if (busy || waking) return;
63
+ const items = pending();
64
+ if (items.length === 0) return;
65
+ try {
66
+ wake(buildNotice(items));
67
+ } catch {
68
+ // Reporting is best-effort; the next turn reports the same items.
69
+ return;
70
+ }
71
+ // Mark only after the send succeeded, so a throw changes nothing.
72
+ markReported(items);
73
+ waking = true;
74
+ onWoke?.(items);
75
+ },
76
+ sessionStart: () => {
77
+ busy = false;
78
+ waking = false;
79
+ },
80
+ agentStart: () => {
81
+ busy = true;
82
+ },
83
+ agentSettled: () => {
84
+ busy = false;
85
+ waking = false;
86
+ },
87
+ beforeAgentStart: () => {
88
+ const items = pending();
89
+ if (items.length === 0) return undefined;
90
+ const notice = buildNotice(items);
91
+ markReported(items);
92
+ return { message: notice };
93
+ },
94
+ filterContext: (messages) => {
95
+ const pruned = pruneStaleNotices(messages, source);
96
+ return pruned ? { messages: pruned } : undefined;
97
+ },
98
+ };
99
+ }
@@ -17,6 +17,7 @@
17
17
  import { join } from "node:path";
18
18
  import { configSection, configValueAt, type JsonReadResult, SHARED_CONFIG_FILE } from "./config.ts";
19
19
  import type { WebSearchProviderEvent } from "./events.ts";
20
+ import { pdfGuidance } from "./pdf.ts";
20
21
  import { getPiInvocation, type PiInvocation } from "./pi-invocation.ts";
21
22
 
22
23
  /** How a check came out. Only `missing` is worth interrupting a session for. */
@@ -167,19 +168,6 @@ export interface ExtensionReport {
167
168
  findings: Finding[];
168
169
  }
169
170
 
170
- // --- Install guidance -------------------------------------------------------
171
- //
172
- // Moved here from the extensions that raise it, so `doctor` and the tool error
173
- // say the same thing. Formatting only: no extension-specific logic.
174
-
175
- /** Install command for the optional PDF extractor package. */
176
- const UNPDF_INSTALL = "npm i unpdf";
177
-
178
- /** Model-facing guidance when the optional `unpdf` package is absent. */
179
- export function pdfGuidance(): string {
180
- return `PDF extraction requires the optional 'unpdf' package (${UNPDF_INSTALL}).`;
181
- }
182
-
183
171
  // --- The table --------------------------------------------------------------
184
172
 
185
173
  /**
@@ -224,6 +212,9 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
224
212
  },
225
213
  todo: {},
226
214
  goal: {},
215
+ check: {
216
+ notes: ["Runs the project's detected command through a shell; a stack with no detector needs `check.command`."],
217
+ },
227
218
  plan: {
228
219
  optional: [gitCheck("project-local `.pi/plans` (otherwise plans land in the agent directory)")],
229
220
  notes: ["Needs an interactive UI to enter plan mode."],
@@ -769,19 +760,34 @@ export async function probeExtension(extension: string, seams: ProbeSeams): Prom
769
760
  return { extension, enabled, findings };
770
761
  }
771
762
 
763
+ /** Probes in flight at once: enough to hide spawn latency, not a burst. */
764
+ export const PROBE_CONCURRENCY = 4;
765
+
772
766
  /**
773
- * Probe every declared extension, or just `names`, in declaration order.
767
+ * Probe every declared extension, or just `names`, keeping declaration order.
774
768
  *
775
- * Sequential on purpose: the caller runs this on a session-start budget, and a
776
- * burst of parallel probes would trade a few milliseconds for a burst of child
777
- * processes.
769
+ * The caller is a user-facing report (`run_doctor`) or the panel's refresh, so
770
+ * the probes run a few at a time: a serial pass pays the sum of every spawn and
771
+ * read, while one `Promise.all` over the whole table starts a burst of child
772
+ * processes. The startup check does not come through here; it probes only the
773
+ * hard requirements, all at once (see `probeRequired`).
778
774
  */
779
775
  export async function probeAll(
780
776
  seams: ProbeSeams,
781
777
  names: readonly string[] = Object.keys(REQUIREMENTS),
782
778
  ): Promise<ExtensionReport[]> {
783
- const reports: ExtensionReport[] = [];
784
- for (const name of names) reports.push(await probeExtension(name, seams));
779
+ const reports = new Array<ExtensionReport>(names.length);
780
+ let cursor = 0;
781
+ /** Take the next name until the queue is empty; each worker keeps its slot. */
782
+ const drain = async (): Promise<void> => {
783
+ while (cursor < names.length) {
784
+ const index = cursor;
785
+ cursor += 1;
786
+ reports[index] = await probeExtension(names[index], seams);
787
+ }
788
+ };
789
+ const workers = Array.from({ length: Math.min(PROBE_CONCURRENCY, names.length) }, () => drain());
790
+ await Promise.all(workers);
785
791
  return reports;
786
792
  }
787
793
 
package/lib/spawn.ts ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * The process seam shared by the extensions that run a shell command.
3
+ *
4
+ * `job`, `watch`, and `check` all start a command in its own process group,
5
+ * stream or discard its output, and kill the whole tree — a signal to the POSIX
6
+ * group, or `taskkill /T` on Windows. Only the policy on top differs (durable
7
+ * jobs, a tailed watcher, a bounded check), so the seam lives here.
8
+ *
9
+ * IO is split from the decision: {@link spawnOptions} and {@link killPlan} are
10
+ * pure and testable on every platform, while {@link defaultSpawn} and
11
+ * {@link defaultKillTree} execute them. Value-only, like every `lib/` module:
12
+ * no module state, and each caller keeps its own children.
13
+ */
14
+
15
+ import { spawn as nodeSpawn, spawnSync } from "node:child_process";
16
+ import type { Readable } from "node:stream";
17
+
18
+ /**
19
+ * The writable end of a child's stdin pipe.
20
+ *
21
+ * Only the operations a caller needs, so tests can hand the runtime a recording
22
+ * double instead of a real socket.
23
+ */
24
+ export interface StdinSink {
25
+ write(chunk: string): boolean;
26
+ end(): void;
27
+ destroy?(): void;
28
+ on(event: "error" | "close", listener: (error?: Error) => void): unknown;
29
+ }
30
+
31
+ /** The subset of a spawned child the extensions use. */
32
+ export interface SpawnedProcess {
33
+ pid?: number;
34
+ stdout: Readable | null;
35
+ stderr: Readable | null;
36
+ /** Present only when the child was spawned with `stdin: true`. */
37
+ stdin?: StdinSink | null;
38
+ on(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
39
+ on(event: "error", listener: (error: Error) => void): unknown;
40
+ kill(signal?: NodeJS.Signals): boolean;
41
+ /** Detach from the parent's event loop, so a child cannot hold Pi open. */
42
+ unref?(): void;
43
+ }
44
+
45
+ export interface SpawnOptions {
46
+ cwd: string;
47
+ env?: NodeJS.ProcessEnv;
48
+ /** Give the child a stdin pipe instead of a closed stdin (no immediate EOF). */
49
+ stdin?: boolean;
50
+ /** The command writes its own output to a log; keep no parent pipe. */
51
+ detachOutput?: boolean;
52
+ }
53
+
54
+ export type SpawnFn = (command: string, options: SpawnOptions) => SpawnedProcess;
55
+
56
+ /** Send `signal` to a pid's process tree, escalating to SIGKILL when `force`. */
57
+ export type KillTreeFn = (pid: number, signal: NodeJS.Signals, force?: boolean) => void;
58
+
59
+ /** The `stdio` triple a spawn needs: stdin, stdout, stderr. */
60
+ export type SpawnStdio = ["pipe" | "ignore", "pipe" | "ignore", "pipe" | "ignore"];
61
+
62
+ /** The `child_process.spawn` options this seam produces. */
63
+ export interface NodeSpawnOptions {
64
+ cwd: string;
65
+ shell: boolean;
66
+ detached: boolean;
67
+ stdio: SpawnStdio;
68
+ env?: NodeJS.ProcessEnv;
69
+ }
70
+
71
+ /**
72
+ * The spawn options for one command: a shell in its own process group, so a
73
+ * signal can reach the shell and its descendants together.
74
+ *
75
+ * Stdin stays closed unless asked for, because a closed stdin gives the child
76
+ * EOF — a `read` exits instead of blocking forever. `detachOutput` keeps no
77
+ * parent pipe, for a child that writes its own log file.
78
+ */
79
+ export function spawnOptions(options: SpawnOptions, platform: NodeJS.Platform = process.platform): NodeSpawnOptions {
80
+ return {
81
+ cwd: options.cwd,
82
+ shell: true,
83
+ detached: platform !== "win32",
84
+ stdio: [
85
+ options.stdin ? "pipe" : "ignore",
86
+ options.detachOutput ? "ignore" : "pipe",
87
+ options.detachOutput ? "ignore" : "pipe",
88
+ ],
89
+ env: options.env,
90
+ };
91
+ }
92
+
93
+ /** What killing a pid's tree means on this platform. */
94
+ export interface KillPlan {
95
+ /** A program to run instead of signalling (Windows has no process group). */
96
+ command?: { file: string; args: string[] };
97
+ /**
98
+ * Pids to signal in order: the process group first, then the pid itself for a
99
+ * child that is not a group leader (a reattached pid, for example).
100
+ */
101
+ targets: number[];
102
+ }
103
+
104
+ /** The kill plan for `pid`: `taskkill /T` on Windows, group-then-pid otherwise. */
105
+ export function killPlan(pid: number, force: boolean, platform: NodeJS.Platform = process.platform): KillPlan {
106
+ if (platform === "win32") {
107
+ const args = ["/pid", String(pid), "/T", ...(force ? ["/F"] : [])];
108
+ return { command: { file: "taskkill", args }, targets: [] };
109
+ }
110
+ return { targets: [-pid, pid] };
111
+ }
112
+
113
+ /** A signal-0 probe; injectable so the EPERM branch is testable. */
114
+ export type KillProbe = (pid: number, signal: number) => unknown;
115
+
116
+ /** Whether a pid is alive on this host. EPERM means it exists but is not ours. */
117
+ export function isPidAlive(pid: number, probe: KillProbe = process.kill): boolean {
118
+ if (!Number.isFinite(pid) || pid <= 0) return false;
119
+ try {
120
+ probe(pid, 0);
121
+ return true;
122
+ } catch (error) {
123
+ return (error as NodeJS.ErrnoException).code === "EPERM";
124
+ }
125
+ }
126
+
127
+ /** The host seams {@link applyKillPlan} runs through. */
128
+ export interface KillSeams {
129
+ /** Signal one pid; throws when the pid is gone or not ours. */
130
+ kill(pid: number, signal: NodeJS.Signals): unknown;
131
+ /** Run a program (the Windows `taskkill`). */
132
+ run(file: string, args: string[]): unknown;
133
+ }
134
+
135
+ /**
136
+ * Execute a {@link KillPlan}: run the command, or signal each target in order.
137
+ *
138
+ * The group attempt may answer `ESRCH` for a pid that is not a group leader, and
139
+ * the final attempt is the fallback, so its failure means "already gone" — a
140
+ * kill of a dead process is not an error a caller can act on.
141
+ */
142
+ export function applyKillPlan(plan: KillPlan, signal: NodeJS.Signals, seams: KillSeams): void {
143
+ if (plan.command) {
144
+ seams.run(plan.command.file, plan.command.args);
145
+ return;
146
+ }
147
+ for (const [index, target] of plan.targets.entries()) {
148
+ const isFallback = index === plan.targets.length - 1;
149
+ try {
150
+ seams.kill(target, signal);
151
+ return;
152
+ } catch (error) {
153
+ if (!isFallback && (error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
154
+ }
155
+ }
156
+ }
157
+
158
+ /** The real seams, so a caller needs no adapter. */
159
+ const processSeams: KillSeams = {
160
+ kill: (pid, signal) => process.kill(pid, signal),
161
+ run: (file, args) => spawnSync(file, args, { stdio: "ignore" }),
162
+ };
163
+
164
+ /** Default spawn: a shell command in its own process group, output piped. */
165
+ export const defaultSpawn: SpawnFn = (command, options) => nodeSpawn(command, spawnOptions(options));
166
+
167
+ /** Default tree kill: process group on POSIX, `taskkill /T` on Windows. */
168
+ export const defaultKillTree: KillTreeFn = (pid, signal, force = false) => {
169
+ applyKillPlan(killPlan(pid, force), signal, processSeams);
170
+ };
package/lib/todo-state.ts CHANGED
@@ -1,19 +1,43 @@
1
1
  /**
2
- * The published `todo` state, read back from the session branch (pure).
2
+ * The todo item contract, its parser, and the published state replayed from the
3
+ * session branch.
3
4
  *
4
- * The todo list is a whole-list replacement stored in each `todo` tool result's
5
- * `details`, so replaying the branch is how another extension reads it without
6
- * importing the todo extension. `plan` merges an approved plan's steps into the
7
- * last list, and `goal` mentions the items still open when a goal is marked
8
- * achieved.
5
+ * The list is a whole-list replacement stored in each `todo` tool result's
6
+ * `details`, so replaying the branch is how an extension reads it without
7
+ * importing the todo extension. `todo` reads the branch through the same
8
+ * {@link lastTodos} replay, so the tool, `plan`, and `goal` cannot disagree
9
+ * about which write is a state write: a write that fails validation is ignored
10
+ * and the last valid list stays, exactly as the tool itself keeps the previous
11
+ * list on a rejected call.
12
+ *
13
+ * Stored values are re-validated and re-sanitized here. Branch data can be
14
+ * tampered with, migrated, or written by an older version, and a reader must not
15
+ * pass escapes or unbounded text onward; anything that fails validation is
16
+ * ignored rather than partially applied.
17
+ *
18
+ * Value-only: no module state, no host access.
9
19
  */
10
20
 
21
+ import { sanitizeLine } from "./format.ts";
11
22
  import { TODO_TOOL } from "./tool-names.ts";
12
23
 
13
- /** One item in a `todo` list, as the todo tool stores it. */
24
+ /** Statuses a todo item may carry. */
25
+ export const TODO_STATUSES = ["pending", "in_progress", "completed"] as const;
26
+
27
+ /** One item's status. */
28
+ export type TodoStatus = (typeof TODO_STATUSES)[number];
29
+
30
+ /** Maximum number of items in one list. */
31
+ export const MAX_TODOS = 50;
32
+ /** Maximum length of a single item's text. */
33
+ export const MAX_CONTENT = 500;
34
+
35
+ /** One item in a `todo` list, as the tool stores it. */
14
36
  export interface ExistingTodo {
37
+ /** Imperative description of the task. */
15
38
  content: string;
16
- status: "pending" | "in_progress" | "completed";
39
+ /** Where the item sits in the list. */
40
+ status: TodoStatus;
17
41
  /** Present-continuous label the todo tool requires for an in-progress item. */
18
42
  activeForm?: string;
19
43
  }
@@ -28,14 +52,82 @@ interface BranchEntryLike {
28
52
  };
29
53
  }
30
54
 
31
- function isStatus(value: unknown): value is ExistingTodo["status"] {
32
- return value === "pending" || value === "in_progress" || value === "completed";
55
+ const STATUS_SET: ReadonlySet<string> = new Set(TODO_STATUSES);
56
+
57
+ function normalizeTodoStatus(raw: unknown, index: number): TodoStatus {
58
+ const value = typeof raw === "string" ? raw.trim().toLowerCase() : "";
59
+ if (STATUS_SET.has(value)) return value as TodoStatus;
60
+ throw new Error(`Todo ${index + 1}: status must be one of ${TODO_STATUSES.join(", ")}.`);
61
+ }
62
+
63
+ /** Options for {@link normalizeTodos}. */
64
+ export interface NormalizeOptions {
65
+ /**
66
+ * Require a non-blank `activeForm` on `in_progress` items. Live tool input
67
+ * enforces this (the default); branch replay passes `false` so lists written
68
+ * before the rule was added still load.
69
+ */
70
+ requireActiveForm?: boolean;
33
71
  }
34
72
 
35
73
  /**
36
- * The last valid todo list written on the branch, or an empty list. A rejected
37
- * call carries the unchanged list and is not a state write, so it is skipped;
38
- * an entry that does not re-validate is ignored rather than wiping a good list.
74
+ * Validate and normalize a todo list.
75
+ *
76
+ * Content and `activeForm` are sanitized to a single safe line (see
77
+ * `sanitizeLine`). Rejects an empty description, an unknown status, duplicate
78
+ * content, an over-long field, more than one `in_progress` item, and — unless
79
+ * `options.requireActiveForm` is `false` — an `in_progress` item without a
80
+ * non-blank `activeForm`. `activeForm` is dropped when blank.
81
+ */
82
+ export function normalizeTodos(raw: unknown, options: NormalizeOptions = {}): ExistingTodo[] {
83
+ const requireActiveForm = options.requireActiveForm ?? true;
84
+ if (raw === undefined || raw === null) return [];
85
+ if (!Array.isArray(raw)) throw new Error("todos must be an array.");
86
+ if (raw.length > MAX_TODOS) throw new Error(`At most ${MAX_TODOS} todos are allowed.`);
87
+
88
+ const todos: ExistingTodo[] = [];
89
+ const seen = new Set<string>();
90
+ let inProgress = 0;
91
+
92
+ for (let i = 0; i < raw.length; i++) {
93
+ const item = (raw[i] ?? {}) as Partial<ExistingTodo>;
94
+ const content = typeof item.content === "string" ? sanitizeLine(item.content) : "";
95
+ if (!content) throw new Error(`Todo ${i + 1}: content is required.`);
96
+ if (content.length > MAX_CONTENT) {
97
+ throw new Error(`Todo ${i + 1}: content is longer than ${MAX_CONTENT} characters.`);
98
+ }
99
+
100
+ const key = content.toLowerCase();
101
+ if (seen.has(key)) throw new Error(`Todo ${i + 1}: duplicate content "${content}".`);
102
+ seen.add(key);
103
+
104
+ const status = normalizeTodoStatus(item.status, i);
105
+ if (status === "in_progress") inProgress++;
106
+
107
+ const activeForm = typeof item.activeForm === "string" ? sanitizeLine(item.activeForm) : "";
108
+ if (activeForm.length > MAX_CONTENT) {
109
+ throw new Error(`Todo ${i + 1}: activeForm is longer than ${MAX_CONTENT} characters.`);
110
+ }
111
+ if (status === "in_progress" && requireActiveForm && !activeForm) {
112
+ throw new Error(
113
+ `Todo ${i + 1}: activeForm is required for an in_progress item (present-continuous, e.g. "Running tests").`,
114
+ );
115
+ }
116
+ todos.push(activeForm ? { content, status, activeForm } : { content, status });
117
+ }
118
+
119
+ if (inProgress > 1) {
120
+ throw new Error("At most one todo may be in_progress; mark the rest pending or completed.");
121
+ }
122
+ return todos;
123
+ }
124
+
125
+ /**
126
+ * The last valid todo list written on the branch, or an empty list.
127
+ *
128
+ * A rejected call carries the unchanged list and is not a state write, so it is
129
+ * skipped. A write that fails validation is ignored whole, so a corrupt or
130
+ * tampered entry cannot wipe or partially replace a valid list.
39
131
  */
40
132
  export function lastTodos(entries: Iterable<unknown>): ExistingTodo[] {
41
133
  let todos: ExistingTodo[] = [];
@@ -47,16 +139,11 @@ export function lastTodos(entries: Iterable<unknown>): ExistingTodo[] {
47
139
  const details = message.details as { todos?: unknown; error?: unknown } | undefined;
48
140
  if (!details || details.error) continue;
49
141
  if (!Array.isArray(details.todos)) continue;
50
- todos = details.todos.flatMap((item): ExistingTodo[] => {
51
- if (typeof item !== "object" || item === null) return [];
52
- const value = item as { content?: unknown; status?: unknown; activeForm?: unknown };
53
- if (typeof value.content !== "string" || !isStatus(value.status)) return [];
54
- const content = value.content.trim();
55
- if (!content) return [];
56
- const activeForm =
57
- typeof value.activeForm === "string" && value.activeForm.trim() ? value.activeForm.trim() : undefined;
58
- return [activeForm ? { content, status: value.status, activeForm } : { content, status: value.status }];
59
- });
142
+ try {
143
+ todos = normalizeTodos(details.todos, { requireActiveForm: false });
144
+ } catch {
145
+ // Keep the previous list.
146
+ }
60
147
  }
61
148
  return todos;
62
149
  }
package/lib/tool-names.ts CHANGED
@@ -15,6 +15,13 @@ export const TODO_TOOL = "todo";
15
15
  /** The `goal` tool, allowlisted as a reader by plan and rewind. */
16
16
  export const GOAL_TOOL = "goal";
17
17
 
18
+ /**
19
+ * The `enter_plan_mode` tool. `plan` registers it, and `goal` offers the same
20
+ * phase to the user, so the name is shared: the request channel and the
21
+ * registration probe both look it up.
22
+ */
23
+ export const PLAN_ENTER_TOOL = "enter_plan_mode";
24
+
18
25
  /** The `subagent` tool, which plan mode pins to read-only delegation. */
19
26
  export const SUBAGENT_TOOL = "subagent";
20
27
 
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The shared shape of a rejected tool call.
3
+ *
4
+ * Extension tools return an `isError: true` result carrying their own `details`
5
+ * type. `toolError` builds the content and the flag so the extensions that
6
+ * reject a call (`job` and `watch`, plus any future tool) agree on the shape.
7
+ */
8
+
9
+ /** One text block in a tool result. */
10
+ export interface ToolErrorContent {
11
+ type: "text";
12
+ text: string;
13
+ }
14
+
15
+ /** A rejected tool call: the message as content, the extension's details, and the error flag. */
16
+ export function toolError<Details extends { action: string; error: string }>(
17
+ message: string,
18
+ details: Details,
19
+ ): { content: ToolErrorContent[]; details: Details; isError: true } {
20
+ return {
21
+ content: [{ type: "text", text: `Error: ${message}` }],
22
+ details,
23
+ isError: true,
24
+ };
25
+ }
26
+
27
+ /** A rejected tool call whose details are only the action and the message. */
28
+ export function errorResult<Action extends string>(
29
+ action: Action,
30
+ message: string,
31
+ ): { content: ToolErrorContent[]; details: { action: Action; error: string }; isError: true } {
32
+ return toolError(message, { action, error: message });
33
+ }
package/lib/tui/README.md CHANGED
@@ -30,9 +30,61 @@ build a second terminal renderer.
30
30
  ## Primitives
31
31
 
32
32
  Reach for a `lib/tui/` primitive before composing your own: `plainRule` /
33
- `titleRule` for a heading, `listRow` / `checkbox` for a list, `gaugeBar` for a
34
- meter, `emptyState` for an empty screen, `splitRow` / `fitCells` for two columns,
35
- `LinePane` for a scrollable log, and `ListCursor` / `IdCursor` for selection.
33
+ `titleRule` for a heading, `listRow` / `statusRow` / `checkbox` for a list,
34
+ `gaugeBar` for a meter, `emptyState` for an empty screen, `splitRow` /
35
+ `fitCells` for two columns, `LinePane` for a scrollable log, `ListCursor` /
36
+ `IdCursor` for selection, `byRunningFirst` for a live list's order,
37
+ `DockListScreen` for a list that opens a pane, `TailResult` for a tool result
38
+ that lists rows or tails output, and `ConfirmState` before a destructive action.
39
+
40
+ ## Docked list screens
41
+
42
+ Every list screen is `dockListFrame`: the header rule, an optional summary and
43
+ its blank line, the body, an optional range row, an optional detail block, and
44
+ the blank-padded hint. The frame owns that spacing, so a screen never hand-rolls
45
+ it — including the blank line above the detail block, which keeps a footnote
46
+ from reading as one more row. A screen that sizes its body through `fitRows`
47
+ counts the block with `detailBlockRows`, or the hint row is pushed off the
48
+ bottom of a short terminal.
49
+
50
+ A list that **opens a pane over one row** — `/jobs`, `/watch`, `/agents`,
51
+ `/check` — subclasses [`dock-screen.ts`](./dock-screen.ts) instead of
52
+ re-assembling the frame. `DockListScreen` owns the two modes, the `IdCursor` and
53
+ `LinePane`, the navigation and wheel keys, the y/N confirmation, the one-line
54
+ feedback notice, and the range rows; the subclass supplies `rows`, `idOf`,
55
+ `rowLine`, `detailBlock`, `summaryCells`, `listHints(rows)`, `detailHints(row)`,
56
+ `detailFor`, and `handleVerb`. Keep the ordering in `rows()` (the base re-reads
57
+ it every render) and return it by stable id, so a live re-sort cannot move the
58
+ cursor or swap the open pane out from under the reader.
59
+
60
+ Four optional members cover what a richer screen needs, and each defaults to
61
+ what a plain list wants — do not hand-roll the frame to get one of them:
62
+
63
+ - `DetailContents.facts(width)` — rows *above* the scrollable body that depend on
64
+ the width (key/value facts, a legend). The body's lines are built once at open,
65
+ so anything width-dependent belongs here, and the base counts the rows against
66
+ the pane's height.
67
+ - `liveDetail()` — set it when the pane's source changes between frames on its
68
+ own (a child's output) rather than on a poll. The base then re-reads
69
+ `detailById` while rendering, without asking for another frame, and closes the
70
+ pane if the row is gone.
71
+ - `detailById(id)` — the poll source job and watch read every 500 ms; omit it
72
+ when nothing polls the pane.
73
+ - `handleDetailVerb(key, row)` — a verb that also works while reading the pane
74
+ (`c` collect, `x` cancel). Return `true` for the keys it claimed; everything
75
+ else falls through to the pane's scroll keys.
76
+
77
+ A destructive action asks through [`confirm.ts`](./confirm.ts):
78
+ `ConfirmState.ask(prompt, accept)` holds the question, `resolve(data)` accepts on
79
+ `y`/`Y`/Enter, dismisses on `n`/`N`/Esc/`q`, and returns `undefined` for any
80
+ other key so a stray arrow cannot cancel the question. The prompt ends in
81
+ `? y/N`.
82
+
83
+ The block carries footnotes about the focused row: what the row does not already
84
+ say. Never repeat its label, verdict, or duration — the row is one line above,
85
+ and a second copy is noise the reader has to compare. Prose that the hint row
86
+ already says with keys does not belong there either; a sentence that long is
87
+ clipped mid-word at the widths the screen runs at.
36
88
 
37
89
  ## Color is a role
38
90
 
@@ -42,6 +94,13 @@ token named inline; a theme swap must re-skin every surface. Use the shared
42
94
  `selectionMarker`, `GLYPHS`, `SEPARATORS`, and `STATUS_KEYS` instead of retyping
43
95
  their literals, and `screenHint` / Pi's `keyHint` for the hint row.
44
96
 
97
+ A list accents the focused row's label (`labelRole: selected ? "accent" :
98
+ "label"`); the `❯` marker alone is not enough to tell which row is live. The
99
+ hint row colors by role, not by taste: `accent` for the screen's primary action,
100
+ `warning` for one that discards state (a revert, a kill, a stop, a clear, a
101
+ forget), and `error` for one that ends the session. Keys that navigate, scroll,
102
+ or select stay dim, so a colored key is one worth a second thought.
103
+
45
104
  ## Width is columns
46
105
 
47
106
  Width is terminal columns, not string length. Use `visibleWidth`,
@@ -59,6 +118,12 @@ widget or the terminal: it neutralizes control characters and then collapses the
59
118
  text to one line. `stripControlChars` alone is for multi-line text that must keep
60
119
  its newlines.
61
120
 
121
+ Program output — a log file, a child's stdout, a tailed file — goes through
122
+ `sanitizeUntrustedLine` instead. It removes OSC, CSI, and single-character
123
+ escapes whole (so the printable tail of an escape never reaches the terminal),
124
+ then resolves `\r` the way a terminal would, squeezes whitespace, and clips.
125
+ `sanitizeLine` would leave `[31m` behind, because it turns ESC into a space.
126
+
62
127
  ## Keyboard first
63
128
 
64
129
  Use `matchesKey` / `Key`, give every mouse interaction a keyboard path, and keep