@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
@@ -25,6 +25,9 @@ import {
25
25
  formatAgentList,
26
26
  formatAgentNames,
27
27
  } from "./agents.ts";
28
+ import { createBackgroundRuntime } from "./background.ts";
29
+ import { registerAgentsCommand } from "./commands.ts";
30
+ import { createSubagentUi } from "./ui.ts";
28
31
  import { loadSubagentConfig, type SubagentConfig } from "./config.ts";
29
32
  import { runChainMode, runParallelMode, runSingleMode, type ModeContext } from "./orchestrate.ts";
30
33
  import { renderSubagentCall, renderSubagentResult } from "./render.ts";
@@ -48,6 +51,7 @@ const DESCRIPTION = [
48
51
  "Delegate tasks to specialized subagents with isolated context windows.",
49
52
  "Modes: single (`agent` + `task`), parallel (`tasks`), chain (sequential, `{previous}` placeholder).",
50
53
  "Each runs in its own process, inheriting the current model and thinking level.",
54
+ "A single run can start in the background (`background: true`) and is managed with `/agents`.",
51
55
  `Built-in agents: ${AGENT_NAMES}.`,
52
56
  ].join(" ");
53
57
 
@@ -55,6 +59,7 @@ const GUIDELINES = [
55
59
  "Use `subagent` for broad codebase exploration, parallel research, or isolated implementation work that would bloat the main context.",
56
60
  "Prefer `explorer` first when another agent would otherwise re-read many files, then pass its output to `planner` or `worker`.",
57
61
  "Run independent tasks with `tasks` (max 8, up to 4 at once); use `chain` only when each step needs the previous step's output.",
62
+ "Use `background: true` only for one long task you will collect from `/agents`.",
58
63
  "Project-local agents are repository-controlled and load only when `agentScope` includes them; an untrusted project requires confirmation.",
59
64
  ];
60
65
 
@@ -73,6 +78,7 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
73
78
  // Read once per session; a call that arrives before `session_start` reads the
74
79
  // same files lazily, so the two paths cannot disagree.
75
80
  let settings: SubagentConfig | undefined;
81
+ let activeCtx: ExtensionContext | undefined;
76
82
 
77
83
  const startSession = async (ctx: ExtensionContext): Promise<void> => {
78
84
  settings = loadSubagentConfig(ctx.cwd);
@@ -84,8 +90,6 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
84
90
  }
85
91
  };
86
92
 
87
- pi.on("session_start", (_event, ctx) => startSession(ctx));
88
-
89
93
  /**
90
94
  * Account for each finished child, then hand the result on unchanged.
91
95
  *
@@ -106,6 +110,29 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
106
110
  return result;
107
111
  };
108
112
 
113
+ const background = createBackgroundRuntime({
114
+ appendEntry: (customType, data) => pi.appendEntry(customType, data),
115
+ onFinish: (run) => ui.notifyFinished(run),
116
+ });
117
+ const ui = createSubagentUi({
118
+ runs: () => background.list(activeCtx?.sessionManager.getBranch() ?? []),
119
+ });
120
+
121
+ pi.on("session_start", async (_event, ctx) => {
122
+ background.reset();
123
+ activeCtx = ctx;
124
+ ui.attach(ctx);
125
+ background.subscribe(() => ui.paint());
126
+ ui.paint();
127
+ await startSession(ctx);
128
+ });
129
+ pi.on("session_shutdown", () => {
130
+ background.shutdown();
131
+ ui.detach();
132
+ activeCtx = undefined;
133
+ });
134
+ registerAgentsCommand(pi, background);
135
+
109
136
  pi.registerTool({
110
137
  name: TOOL_NAME,
111
138
  label: "Subagent",
@@ -195,6 +222,68 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
195
222
  }
196
223
  }
197
224
 
225
+ if (args.background) {
226
+ if (mode !== "single") {
227
+ return {
228
+ content: [{ type: "text" as const, text: "`background` supports single mode only." }],
229
+ details: makeDetails([]),
230
+ };
231
+ }
232
+ if (!agents.some((candidate) => candidate.name === args.agent)) {
233
+ return {
234
+ content: [
235
+ {
236
+ type: "text" as const,
237
+ text: `Unknown agent: "${args.agent ?? ""}". Available agents: ${formatAgentList(agents)}`,
238
+ },
239
+ ],
240
+ details: makeDetails([]),
241
+ };
242
+ }
243
+ const started = background.start(
244
+ {
245
+ agent: args.agent ?? "",
246
+ task: args.task ?? "",
247
+ cwd: args.cwd,
248
+ readOnly: args.readOnly,
249
+ },
250
+ ctx.sessionManager.getBranch(),
251
+ {
252
+ run,
253
+ agents,
254
+ defaults,
255
+ defaultCwd: root,
256
+ timeoutMs: config.timeoutMs,
257
+ transcripts,
258
+ backgroundMax: config.backgroundMax,
259
+ },
260
+ );
261
+ if (!started.ok) {
262
+ return {
263
+ content: [{ type: "text" as const, text: started.error }],
264
+ details: makeDetails([]),
265
+ };
266
+ }
267
+ return {
268
+ content: [
269
+ {
270
+ type: "text" as const,
271
+ text: `Started ${started.run.agent} as ${started.run.id} in the background. Use /agents to inspect, collect, or cancel it.`,
272
+ },
273
+ ],
274
+ details: {
275
+ ...makeDetails([]),
276
+ background: {
277
+ runId: started.run.id,
278
+ agent: started.run.agent,
279
+ task: started.run.task,
280
+ cwd: started.run.cwd,
281
+ startedAt: started.run.startedAt,
282
+ },
283
+ } satisfies SubagentDetails,
284
+ };
285
+ }
286
+
198
287
  const context: ModeContext = {
199
288
  run,
200
289
  args,
@@ -488,6 +488,19 @@ export function renderSubagentResult(
488
488
  ): Component {
489
489
  const now = options.now ?? Date.now();
490
490
  const details = toolDetails<SubagentDetails>(result.details);
491
+ if (details?.background) {
492
+ syncElapsedTimer(false, context);
493
+ const text = reuseText(context?.lastComponent);
494
+ const background = details.background;
495
+ text.setText(
496
+ paint(
497
+ theme,
498
+ "meta",
499
+ `Started ${sanitizeLine(background.agent)} as ${sanitizeLine(background.runId)} in the background. Use /agents to collect or cancel it.`,
500
+ ),
501
+ );
502
+ return text;
503
+ }
491
504
  if (!details || details.results.length === 0) {
492
505
  syncElapsedTimer(false, context);
493
506
  const text = reuseText(context?.lastComponent);
@@ -13,6 +13,7 @@ import * as os from "node:os";
13
13
  import * as path from "node:path";
14
14
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
15
15
  import { formatDuration } from "../../lib/format.ts";
16
+ import { LineSplitter } from "../../lib/lines.ts";
16
17
  import { BUILTIN_AGENTS, formatAgentList, type AgentConfig } from "./agents.ts";
17
18
  import type { TranscriptMode } from "./config.ts";
18
19
  import { buildAgentArgs, getPiInvocation, READ_ONLY_NOTE } from "./invocation.ts";
@@ -44,6 +45,8 @@ export interface RunOptions {
44
45
  spawn?: SpawnFn;
45
46
  /** Per-run wall-clock limit in ms; `0` or absent disables it. */
46
47
  timeoutMs?: number;
48
+ /** Return an aborted result instead of throwing (used by background runs). */
49
+ abortAsResult?: boolean;
47
50
  /** Where and when a finished run writes a transcript. */
48
51
  transcripts?: { dir: string; mode: Exclude<TranscriptMode, "off"> };
49
52
  }
@@ -88,7 +91,7 @@ function spawnAndCollect(context: SpawnContext): Promise<SpawnOutcome> {
88
91
  const { command, args, cwd, timeoutMs, signal, result, onEvent, transcript, spawn } = context;
89
92
  return new Promise<SpawnOutcome>((resolve) => {
90
93
  const proc = spawn(command, args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
91
- let buffer = "";
94
+ const splitter = new LineSplitter();
92
95
  let settled = false;
93
96
  let abortRequested = false;
94
97
  let timedOut = false;
@@ -117,10 +120,8 @@ function spawnAndCollect(context: SpawnContext): Promise<SpawnOutcome> {
117
120
  if (signal) signal.removeEventListener("abort", onAbort);
118
121
  if (killTimer) clearTimeout(killTimer);
119
122
  if (timeoutTimer) clearTimeout(timeoutTimer);
120
- if (buffer.trim()) {
121
- transcript?.line(buffer);
122
- const event = parseJsonLine(buffer);
123
- if (event && applyEvent(result, event)) onEvent();
123
+ for (const line of splitter.flush()) {
124
+ if (line.trim()) processLine(line);
124
125
  }
125
126
  resolve({ exitCode, aborted: abortRequested, timedOut });
126
127
  };
@@ -132,15 +133,8 @@ function spawnAndCollect(context: SpawnContext): Promise<SpawnOutcome> {
132
133
  };
133
134
 
134
135
  proc.stdout?.on("data", (chunk) => {
135
- const text = chunk.toString();
136
- buffer += text;
137
- // Keep the final partial line in the buffer for the close handler.
138
- let newline = buffer.indexOf("\n");
139
- while (newline !== -1) {
140
- processLine(buffer.slice(0, newline));
141
- buffer = buffer.slice(newline + 1);
142
- newline = buffer.indexOf("\n");
143
- }
136
+ // The splitter holds the final partial line for the close handler.
137
+ for (const line of splitter.push(chunk)) processLine(line);
144
138
  });
145
139
 
146
140
  proc.stderr?.on("data", (chunk) => {
@@ -236,12 +230,19 @@ export async function runSingleAgent(options: RunOptions): Promise<SingleResult>
236
230
  spawn,
237
231
  });
238
232
 
239
- result.exitCode = outcome.timedOut ? 1 : outcome.exitCode;
233
+ result.exitCode = outcome.timedOut || outcome.aborted ? 1 : outcome.exitCode;
240
234
  result.finishedAt = Date.now();
241
235
  // A deadline fails the run rather than rejecting it: a chain then stops at
242
236
  // this step and reports why, instead of discarding the steps already done.
243
237
  if (outcome.timedOut) result.errorMessage = `Timed out after ${formatDuration(timeoutMs)}`;
244
- if (outcome.aborted) throw new Error("Subagent was aborted");
238
+ if (outcome.aborted) {
239
+ if (options.abortAsResult) {
240
+ result.stopReason = "aborted";
241
+ result.errorMessage = "Aborted";
242
+ return result;
243
+ }
244
+ throw new Error("Subagent was aborted");
245
+ }
245
246
  return result;
246
247
  } finally {
247
248
  // Flushed here, not after the call: the abort path throws past every caller.
@@ -66,6 +66,11 @@ export const SubagentParams = Type.Object({
66
66
  description: "Force a read-only tool set (readers only). Plan mode sets this.",
67
67
  }),
68
68
  ),
69
+ background: Type.Optional(
70
+ Type.Boolean({
71
+ description: "Start a single run and return immediately; inspect and collect it with /agents.",
72
+ }),
73
+ ),
69
74
  });
70
75
 
71
76
  export type SubagentArgs = Static<typeof SubagentParams>;
@@ -85,6 +90,13 @@ export function resolveMode(params: SubagentArgs): ModeResolution {
85
90
  const hasTasks = taskCount > 0;
86
91
  const hasSingle = Boolean(params.agent && params.task);
87
92
 
93
+ if (params.background === true && (hasChain || hasTasks)) {
94
+ return { error: "`background` supports single mode only; provide `agent` and `task`." };
95
+ }
96
+ if (params.background === true && !hasSingle) {
97
+ return { error: "`background` requires `agent` and `task`." };
98
+ }
99
+
88
100
  if (hasChain && hasTasks) return { error: "Provide either `chain` or `tasks`, not both." };
89
101
  if (hasChain && hasSingle) return { error: "Provide either `chain` or `agent`+`task`, not both." };
90
102
  if (hasTasks && hasSingle) return { error: "Provide either `tasks` or `agent`+`task`, not both." };
@@ -64,6 +64,15 @@ export interface SingleResult {
64
64
  artifactError?: string;
65
65
  }
66
66
 
67
+ /** A background run the tool just started. */
68
+ export interface BackgroundStart {
69
+ runId: string;
70
+ agent: string;
71
+ task: string;
72
+ cwd: string;
73
+ startedAt: number;
74
+ }
75
+
67
76
  /** Structured result carried on the `subagent` tool result for rendering. */
68
77
  export interface SubagentDetails {
69
78
  mode: SubagentMode;
@@ -74,6 +83,8 @@ export interface SubagentDetails {
74
83
  projectAgentsDir?: string | null;
75
84
  /** Whether the run forced a read-only tool set (plan mode). */
76
85
  readOnly?: boolean;
86
+ /** Present when the tool started a background run instead of waiting. */
87
+ background?: BackgroundStart;
77
88
  /**
78
89
  * Total steps/tasks requested, which can exceed `results.length` when a chain
79
90
  * stops early. Defaults to `results.length` when absent.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Footer status and finish notices for background subagent runs.
3
+ *
4
+ * The runtime owns lifecycle and results; this owns the running-count chip and
5
+ * the notice that points back at `/agents`. A cancelled run stays silent because
6
+ * the user initiated it.
7
+ */
8
+
9
+ import { sanitizeLine } from "../../lib/format.ts";
10
+ import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
11
+ import { createStatusController, type StatusController } from "../../lib/tui/status-controller.ts";
12
+ import type { BackgroundRun } from "./background.ts";
13
+
14
+ export interface SubagentUiOptions {
15
+ /** The current runs, read fresh on every paint. */
16
+ runs(): readonly BackgroundRun[];
17
+ }
18
+
19
+ export interface SubagentUiController extends StatusController {
20
+ notifyFinished(run: BackgroundRun): void;
21
+ }
22
+
23
+ /** A status word a notice can read without exposing the type's spelling. */
24
+ function statusText(status: BackgroundRun["status"]): string {
25
+ return status === "timedout" ? "timed out" : status;
26
+ }
27
+
28
+ export function createSubagentUi(options: SubagentUiOptions): SubagentUiController {
29
+ const controller = createStatusController({
30
+ keys: [STATUS_KEYS.agents],
31
+ enabled: () => true,
32
+ chips: () => {
33
+ const running = options.runs().filter((run) => run.status === "running").length;
34
+ return [{ key: STATUS_KEYS.agents, glyph: GLYPHS.delegation, count: running, role: "accent" }];
35
+ },
36
+ });
37
+
38
+ const notifyFinished = (run: BackgroundRun): void => {
39
+ // A cancelled run stays silent because the user initiated it.
40
+ if (run.status === "cancelled") return;
41
+ const failed = run.status === "failed" || run.status === "timedout";
42
+ const action = run.status === "finished" ? "collect" : "inspect";
43
+ controller.notify(
44
+ `Background subagent ${run.id} (${sanitizeLine(run.agent)}) ${statusText(run.status)}. Use /agents to ${action} it.`,
45
+ failed ? "error" : "info",
46
+ );
47
+ };
48
+
49
+ return { ...controller, notifyFinished };
50
+ }
@@ -174,13 +174,17 @@ depends on config.
174
174
  - **Whole-list replacement, not mutations.** Every call is a snapshot, so
175
175
  `todos: []` is the natural clear and there is no "forgot to toggle" failure
176
176
  mode. No ids or per-item update actions exist.
177
- - **State in tool-result `details`.** `reconstructTodos` replays the active
178
- branch and takes the last `todo` result, so `/resume` and `/tree` reproduce
179
- the list for that point in history. `pi.appendEntry()` was rejected because a
180
- side-channel list would not branch; error results are skipped as non-writes.
181
- - **Validation is pure and fail-safe.** `normalizeTodos` throws a
182
- model-readable message, and the tool turns that into an `isError` result that
183
- carries the *previous* list. A rejected call never half-applies.
177
+ - **State in tool-result `details`.** `lib/todo-state.ts`'s `lastTodos` replays
178
+ the active branch and takes the last write that fully validates, so `/resume`
179
+ and `/tree` reproduce the list for that point in history. The same replay is
180
+ what `plan` and `goal` read, so there is one rule for which write is a state
181
+ write. `pi.appendEntry()` was rejected because a side-channel list would not
182
+ branch; error results are skipped as non-writes.
183
+ - **Validation is pure and fail-safe.** The parser (`normalizeTodos` in
184
+ `lib/todo-state.ts`) throws a model-readable message, and the tool turns that
185
+ into an `isError` result that carries the *previous* list. A rejected call
186
+ never half-applies, and branch replay follows the same rule: a stored write
187
+ that fails validation is ignored whole rather than partially applied.
184
188
  - **One `in_progress` item, with a label.** The rule is validated, not silently
185
189
  coerced, so the model learns it from the error. The item must carry a
186
190
  non-blank `activeForm`; branch replay passes `{ requireActiveForm: false }`
@@ -210,10 +214,10 @@ depends on config.
210
214
  answer and never reopens that path. `nudged` caps it at one per user turn,
211
215
  `setTodos` clears the counters, and `context` drops the hidden message on the
212
216
  next user turn so it never accumulates.
213
- - **Content is sanitized at the boundary.** `normalizeTodos` replaces control
214
- characters and collapses whitespace before the length and duplicate checks,
215
- keeping `format.ts` free of terminal concerns and every surface to one row
216
- per item.
217
+ - **Content is sanitized at the boundary.** `normalizeTodos` in
218
+ `lib/todo-state.ts` replaces control characters and collapses whitespace
219
+ before the length and duplicate checks, keeping `format.ts` free of terminal
220
+ concerns and every surface to one row per item.
217
221
  - **The widget summarizes; full views keep the model's order.** The persistent
218
222
  widget is a single line (`currentTodo` picks the `in_progress` item, else the
219
223
  first pending); `/todos` keeps the submitted order, and the transcript result
@@ -234,8 +238,9 @@ depends on config.
234
238
  |---|---|
235
239
  | `index.ts` | Factory: wire the runtime, tool, command, reminder events, and session events. |
236
240
  | `types.ts` | `Todo`, `TodoStatus`, `TodoDetails`. |
237
- | `schema.ts` | TypeBox parameters and pure validation/normalization. |
238
- | `state.ts` | Branch reconstruction and status queries (pure). |
241
+ | `types.ts` | `Todo` and `TodoDetails`; re-exports `TodoStatus` / `TODO_STATUSES` from `lib/todo-state.ts`. |
242
+ | `schema.ts` | TypeBox parameters; the parser and limits live in `lib/todo-state.ts`. |
243
+ | `state.ts` | Status queries over the replayed list (pure). |
239
244
  | `nudge.ts` | Lag detection: mutating and file-write classification, due check, reminder text (pure). |
240
245
  | `format.ts` | Model-facing and transcript text (pure). |
241
246
  | `tui.ts` | Widget and scrollable `/todos` components, drawn from the `lib/tui/` kit (`titleRule`, `dockListFrame`, `listRow`, `emptyState`). |
@@ -4,7 +4,8 @@
4
4
 
5
5
  import type { Theme } from "@earendil-works/pi-coding-agent";
6
6
  import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
7
- import { expandHint, GLYPH_GAP, GLYPHS } from "../../lib/tui/glyphs.ts";
7
+ import { GLYPH_GAP, GLYPHS } from "../../lib/tui/glyphs.ts";
8
+ import { overflowHint } from "../../lib/tui/tool.ts";
8
9
  import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
9
10
  import { completedCount, currentTodo } from "./state.ts";
10
11
  import type { Todo, TodoStatus } from "./types.ts";
@@ -149,7 +150,7 @@ export function todoRailLines(input: TodoRailInput, theme: Theme, width: number)
149
150
  });
150
151
  }
151
152
  if (input.more && input.more > 0) {
152
- lines.push(`${continuation}${paint(theme, "faint", `… ${input.more} more`)} ${expandHint(theme)}`);
153
+ lines.push(`${continuation}${overflowHint(theme, input.more)}`);
153
154
  }
154
155
  return lines.map((line) => truncateToWidth(line, w, "…"));
155
156
  }
@@ -12,9 +12,10 @@
12
12
 
13
13
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
14
  import { onRailsSuppressed, onUpperRailChanged } from "../../lib/rails.ts";
15
+ import { lastTodos } from "../../lib/todo-state.ts";
15
16
  import { DEFAULT_TODO_CONFIG, type TodoConfig } from "./config.ts";
16
17
  import { shouldNudge } from "./nudge.ts";
17
- import { hasOpenTodos, reconstructTodos } from "./state.ts";
18
+ import { hasOpenTodos } from "./state.ts";
18
19
  import { TodoWidget, WIDGET_KEY } from "./tui.ts";
19
20
  import type { Todo } from "./types.ts";
20
21
 
@@ -115,7 +116,7 @@ export function createTodoRuntime(pi?: Pick<ExtensionAPI, "events">): TodoRuntim
115
116
  notify();
116
117
  },
117
118
  reconstruct: (ctx) => {
118
- todos = reconstructTodos(ctx.sessionManager.getBranch());
119
+ todos = lastTodos(ctx.sessionManager.getBranch());
119
120
  mutations = 0;
120
121
  writes = 0;
121
122
  touched = false;
@@ -1,28 +1,22 @@
1
1
  /**
2
- * Parameter schema and validation for the `todo` tool.
2
+ * Parameter schema for the `todo` tool.
3
3
  *
4
- * Everything here is pure: no host APIs, no terminal. Invalid input throws a
5
- * model-readable `Error` before any state changes, so the model can retry with
6
- * a corrected list instead of silently corrupting the list.
4
+ * The schema is the host-facing shape; the semantic validation and the branch
5
+ * replay live together in `lib/todo-state.ts`, so the tool, `plan`, and `goal`
6
+ * read the same rules. Invalid input still throws a model-readable `Error`
7
+ * before any state changes.
7
8
  */
8
9
 
9
10
  import { StringEnum } from "@earendil-works/pi-ai";
10
11
  import { Type, type Static } from "typebox";
11
- import { sanitizeLine } from "../../lib/format.ts";
12
- import { TODO_STATUSES, type Todo, type TodoStatus } from "./types.ts";
13
-
14
- /** Maximum number of items in one list. */
15
- export const MAX_TODOS = 50;
16
- /** Maximum length of a single item's text. */
17
- export const MAX_CONTENT = 500;
12
+ import { MAX_TODOS, TODO_STATUSES } from "../../lib/todo-state.ts";
18
13
 
19
14
  /**
20
15
  * One item in the tool's input and output schemas.
21
16
  *
22
17
  * The input schema (`TodoParams`) enforces `maxItems` and the status enum
23
- * before `execute` runs. `normalizeTodos` re-enforces both for branch replay
24
- * and direct unit use, and owns the semantic rules the schema cannot express
25
- * (one `in_progress`, unique non-empty content, content length).
18
+ * before `execute` runs; `normalizeTodos` in `lib/todo-state.ts` re-enforces
19
+ * both and owns the semantic rules the schema cannot express.
26
20
  */
27
21
  export const TodoItem = Type.Object({
28
22
  content: Type.String({ description: "Imperative description of the task" }),
@@ -53,73 +47,3 @@ export const TodoResult = Type.Object({
53
47
  });
54
48
 
55
49
  export type TodoArgs = Static<typeof TodoParams>;
56
-
57
- const STATUS_SET: ReadonlySet<string> = new Set(TODO_STATUSES);
58
-
59
- function normalizeStatus(raw: unknown, index: number): TodoStatus {
60
- const value = typeof raw === "string" ? raw.trim().toLowerCase() : "";
61
- if (STATUS_SET.has(value)) return value as TodoStatus;
62
- throw new Error(`Todo ${index + 1}: status must be one of ${TODO_STATUSES.join(", ")}.`);
63
- }
64
-
65
- /** Options for {@link normalizeTodos}. */
66
- export interface NormalizeOptions {
67
- /**
68
- * Require a non-blank `activeForm` on `in_progress` items. Live tool input
69
- * enforces this (the default); branch replay passes `false` so lists written
70
- * before the rule was added still load.
71
- */
72
- requireActiveForm?: boolean;
73
- }
74
-
75
- /**
76
- * Validate and normalize the model's list.
77
- *
78
- * Content and `activeForm` are sanitized to a single safe line (see
79
- * `sanitizeLine`). Rejects an empty description, an unknown status, duplicate
80
- * content, an over-long field, more than one `in_progress` item, and — unless
81
- * `options.requireActiveForm` is `false` — an `in_progress` item without a
82
- * non-blank `activeForm`. `activeForm` is dropped when blank.
83
- */
84
- export function normalizeTodos(raw: unknown, options: NormalizeOptions = {}): Todo[] {
85
- const requireActiveForm = options.requireActiveForm ?? true;
86
- if (raw === undefined || raw === null) return [];
87
- if (!Array.isArray(raw)) throw new Error("todos must be an array.");
88
- if (raw.length > MAX_TODOS) throw new Error(`At most ${MAX_TODOS} todos are allowed.`);
89
-
90
- const todos: Todo[] = [];
91
- const seen = new Set<string>();
92
- let inProgress = 0;
93
-
94
- for (let i = 0; i < raw.length; i++) {
95
- const item = (raw[i] ?? {}) as Partial<Todo>;
96
- const content = typeof item.content === "string" ? sanitizeLine(item.content) : "";
97
- if (!content) throw new Error(`Todo ${i + 1}: content is required.`);
98
- if (content.length > MAX_CONTENT) {
99
- throw new Error(`Todo ${i + 1}: content is longer than ${MAX_CONTENT} characters.`);
100
- }
101
-
102
- const key = content.toLowerCase();
103
- if (seen.has(key)) throw new Error(`Todo ${i + 1}: duplicate content "${content}".`);
104
- seen.add(key);
105
-
106
- const status = normalizeStatus(item.status, i);
107
- if (status === "in_progress") inProgress++;
108
-
109
- const activeForm = typeof item.activeForm === "string" ? sanitizeLine(item.activeForm) : "";
110
- if (activeForm.length > MAX_CONTENT) {
111
- throw new Error(`Todo ${i + 1}: activeForm is longer than ${MAX_CONTENT} characters.`);
112
- }
113
- if (status === "in_progress" && requireActiveForm && !activeForm) {
114
- throw new Error(
115
- `Todo ${i + 1}: activeForm is required for an in_progress item (present-continuous, e.g. "Running tests").`,
116
- );
117
- }
118
- todos.push(activeForm ? { content, status, activeForm } : { content, status });
119
- }
120
-
121
- if (inProgress > 1) {
122
- throw new Error("At most one todo may be in_progress; mark the rest pending or completed.");
123
- }
124
- return todos;
125
- }
@@ -1,53 +1,12 @@
1
1
  /**
2
- * Branch-aware state for the todo extension (pure).
2
+ * Status queries over the list the branch published (pure).
3
3
  *
4
- * The list lives in each `todo` tool result's `details`, so navigating or
5
- * branching the session reproduces the list that was correct at that point.
6
- * `reconstructTodos` replays a branch and returns the last valid list; stored
7
- * values are re-validated and re-sanitized so corrupt or tampered branch data
8
- * cannot inject escapes, crash reconstruction, or wipe a valid list.
4
+ * The list itself — the item contract, its validation, and the single strict
5
+ * replay — lives in `lib/todo-state.ts`, shared with `plan` and `goal`. These
6
+ * are the derived facts the widget and the transcript ask of it.
9
7
  */
10
8
 
11
- import { TODO_TOOL } from "../../lib/tool-names.ts";
12
- import { normalizeTodos } from "./schema.ts";
13
- import type { Todo, TodoDetails } from "./types.ts";
14
-
15
- /** Minimal shape of a session entry, used for runtime narrowing. */
16
- interface BranchEntryLike {
17
- type?: string;
18
- message?: {
19
- role?: string;
20
- toolName?: string;
21
- details?: unknown;
22
- };
23
- }
24
-
25
- /** The last valid list written on the branch, or an empty list. */
26
- export function reconstructTodos(entries: Iterable<unknown>): Todo[] {
27
- let todos: Todo[] = [];
28
- for (const raw of entries) {
29
- const entry = raw as BranchEntryLike;
30
- if (entry?.type !== "message") continue;
31
- const message = entry.message;
32
- if (message?.role !== "toolResult" || message.toolName !== TODO_TOOL) continue;
33
- const details = message.details as TodoDetails | undefined;
34
- if (!details || details.todos === undefined) continue;
35
- // A rejected call carries the unchanged list for the model to read; it is
36
- // not a state write and must not be replayed as one.
37
- if (details.error) continue;
38
- // Anything that does not re-validate as a todo list is ignored, so a
39
- // malformed entry cannot wipe a valid one. Replay is lenient about
40
- // `activeForm`: a list written before that field was required still loads
41
- // instead of being dropped.
42
- if (!Array.isArray(details.todos)) continue;
43
- try {
44
- todos = normalizeTodos(details.todos, { requireActiveForm: false });
45
- } catch {
46
- // Keep the previous list.
47
- }
48
- }
49
- return todos;
50
- }
9
+ import type { Todo } from "./types.ts";
51
10
 
52
11
  /** Number of completed items. */
53
12
  export function completedCount(todos: Todo[]): number {
@@ -15,12 +15,13 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
15
  import { sanitizeLine } from "../../lib/format.ts";
16
16
  import { TODO_TOOL } from "../../lib/tool-names.ts";
17
17
  import { GLYPHS } from "../../lib/tui/glyphs.ts";
18
+ import { normalizeTodos } from "../../lib/todo-state.ts";
18
19
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
19
20
  import { rejectionLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts";
20
21
  import { paint } from "../../lib/tui/tokens.ts";
21
22
  import { compareByActivity, formatCallText, formatTodoText, type TodoRailInput } from "./format.ts";
22
23
  import type { TodoRuntime } from "./runtime.ts";
23
- import { normalizeTodos, TodoParams, TodoResult, type TodoArgs } from "./schema.ts";
24
+ import { TodoParams, TodoResult, type TodoArgs } from "./schema.ts";
24
25
  import { TodoResult as TodoResultView } from "./tui.ts";
25
26
  import type { Todo, TodoDetails } from "./types.ts";
26
27
 
@@ -1,26 +1,17 @@
1
1
  /**
2
2
  * Shared types for the todo extension.
3
3
  *
4
- * The todo list is a whole-list replacement, the way Claude Code's `TodoWrite`
5
- * works: the model always sends the complete list, so there is no "add" or
6
- * "toggle" operation and no id bookkeeping.
4
+ * The item contract itself lives in `lib/todo-state.ts`, which owns the parser
5
+ * and the branch replay shared with `plan` and `goal`; this module re-exports it
6
+ * under the name the extension uses and adds the tool-result shape.
7
7
  */
8
8
 
9
- export const TODO_STATUSES = ["pending", "in_progress", "completed"] as const;
9
+ import type { ExistingTodo } from "../../lib/todo-state.ts";
10
10
 
11
- export type TodoStatus = (typeof TODO_STATUSES)[number];
11
+ export { TODO_STATUSES, type TodoStatus } from "../../lib/todo-state.ts";
12
12
 
13
13
  /** One item in the list. */
14
- export type Todo = {
15
- /** Imperative description of the task, e.g. "Write the parser tests". */
16
- content: string;
17
- status: TodoStatus;
18
- /**
19
- * Present-continuous label shown while the item is in progress, e.g.
20
- * "Writing the parser tests". Falls back to `content` when absent.
21
- */
22
- activeForm?: string;
23
- };
14
+ export type Todo = ExistingTodo;
24
15
 
25
16
  /** Structured result carried in the tool's `details` and used for reconstruction. */
26
17
  export interface TodoDetails {