@gavin-hu/my-pi-agent 0.13.1 → 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 (237) hide show
  1. package/README.md +66 -41
  2. package/README.zh-CN.md +53 -36
  3. package/extensions/bytefly/README.md +321 -186
  4. package/extensions/bytefly/block/component.ts +7 -15
  5. package/extensions/bytefly/block/register.ts +6 -1
  6. package/extensions/bytefly/doctor/config.ts +4 -1
  7. package/extensions/bytefly/doctor/register.ts +24 -5
  8. package/extensions/bytefly/doctor/tools.ts +4 -0
  9. package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
  10. package/extensions/bytefly/footer/config.ts +10 -6
  11. package/extensions/bytefly/footer/price/config.ts +3 -2
  12. package/extensions/bytefly/footer/price/price.ts +5 -3
  13. package/extensions/bytefly/footer/price/rates.ts +63 -26
  14. package/extensions/bytefly/footer/render/status.ts +3 -3
  15. package/extensions/bytefly/header/component.ts +33 -10
  16. package/extensions/bytefly/header/config.ts +22 -0
  17. package/extensions/bytefly/header/register.ts +2 -1
  18. package/extensions/bytefly/index.ts +6 -6
  19. package/extensions/bytefly/notice/component.ts +8 -3
  20. package/extensions/bytefly/notify/config.ts +7 -12
  21. package/extensions/bytefly/notify/dispatch.ts +13 -5
  22. package/extensions/bytefly/panel/commands.ts +22 -2
  23. package/extensions/bytefly/panel/format.ts +17 -21
  24. package/extensions/bytefly/panel/settings-runtime.ts +86 -22
  25. package/extensions/bytefly/panel/settings.ts +279 -46
  26. package/extensions/bytefly/panel/themes.ts +32 -0
  27. package/extensions/bytefly/panel/tui/editing.ts +77 -0
  28. package/extensions/bytefly/panel/tui/entries.ts +24 -0
  29. package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
  30. package/extensions/bytefly/panel/tui/frame.ts +121 -4
  31. package/extensions/bytefly/panel/tui/settings-view.ts +98 -13
  32. package/extensions/bytefly/panel/tui.ts +176 -166
  33. package/extensions/bytefly/picker/discovery.ts +1 -0
  34. package/extensions/bytefly/picker/types.ts +2 -0
  35. package/extensions/bytefly/separator/component.ts +17 -6
  36. package/extensions/bytefly/separator/config.ts +15 -0
  37. package/extensions/bytefly/separator/format.ts +60 -6
  38. package/extensions/bytefly/separator/register.ts +68 -9
  39. package/extensions/bytefly/separator/types.ts +8 -0
  40. package/extensions/bytefly/update/cache.ts +58 -19
  41. package/extensions/bytefly/update/check.ts +35 -0
  42. package/extensions/bytefly/update/format.ts +76 -35
  43. package/extensions/bytefly/update/register.ts +144 -55
  44. package/extensions/bytefly/update/state.ts +4 -2
  45. package/extensions/bytefly/update/suppress.ts +66 -0
  46. package/extensions/bytefly/update/types.ts +15 -0
  47. package/extensions/bytefly/working/config.ts +4 -5
  48. package/extensions/bytefly/working/format.ts +24 -7
  49. package/extensions/bytefly/working/register.ts +34 -3
  50. package/extensions/check/README.md +253 -0
  51. package/extensions/check/commands.ts +128 -0
  52. package/extensions/check/config.ts +39 -0
  53. package/extensions/check/detect.ts +269 -0
  54. package/extensions/check/failures.ts +230 -0
  55. package/extensions/check/format.ts +135 -0
  56. package/extensions/check/index.ts +26 -0
  57. package/extensions/check/render.ts +185 -0
  58. package/extensions/check/runner.ts +176 -0
  59. package/extensions/check/runtime.ts +244 -0
  60. package/extensions/check/source.ts +80 -0
  61. package/extensions/check/tools.ts +173 -0
  62. package/extensions/check/tui.ts +195 -0
  63. package/extensions/check/types.ts +99 -0
  64. package/extensions/document/README.md +1 -1
  65. package/extensions/document/extract/pdf.ts +8 -16
  66. package/extensions/document/formats.ts +2 -2
  67. package/extensions/file-browse/README.md +66 -32
  68. package/extensions/file-browse/client.ts +95 -32
  69. package/extensions/file-browse/config.ts +9 -9
  70. package/extensions/file-browse/git.ts +37 -47
  71. package/extensions/file-browse/html.ts +39 -58
  72. package/extensions/file-browse/markdown.ts +110 -92
  73. package/extensions/file-browse/raw-html.ts +478 -0
  74. package/extensions/file-browse/router.ts +32 -70
  75. package/extensions/file-browse/server.ts +18 -56
  76. package/extensions/file-browse/urls.ts +80 -0
  77. package/extensions/goal/README.md +103 -47
  78. package/extensions/goal/clarify-parse.ts +4 -9
  79. package/extensions/goal/clarify.ts +27 -4
  80. package/extensions/goal/commands.ts +24 -11
  81. package/extensions/goal/format.ts +41 -10
  82. package/extensions/goal/index.ts +39 -16
  83. package/extensions/goal/plan-route.ts +51 -0
  84. package/extensions/goal/schema.ts +6 -23
  85. package/extensions/goal/state.ts +39 -57
  86. package/extensions/goal/tools.ts +26 -20
  87. package/extensions/goal/types.ts +11 -1
  88. package/extensions/job/README.md +20 -6
  89. package/extensions/job/format.ts +18 -68
  90. package/extensions/job/index.ts +26 -41
  91. package/extensions/job/log-writer.ts +208 -0
  92. package/extensions/job/logs.ts +3 -2
  93. package/extensions/job/process.ts +6 -99
  94. package/extensions/job/runtime.ts +42 -181
  95. package/extensions/job/schema.ts +1 -4
  96. package/extensions/job/tools.ts +1 -15
  97. package/extensions/job/tui.ts +117 -307
  98. package/extensions/job/ui.ts +7 -33
  99. package/extensions/memory/README.md +3 -1
  100. package/extensions/memory/errors.ts +5 -6
  101. package/extensions/memory/tui.ts +14 -24
  102. package/extensions/plan/README.md +106 -18
  103. package/extensions/plan/commands.ts +9 -5
  104. package/extensions/plan/format.ts +87 -0
  105. package/extensions/plan/index.ts +31 -15
  106. package/extensions/plan/plans.ts +2 -3
  107. package/extensions/plan/policy.ts +0 -6
  108. package/extensions/plan/runtime.ts +4 -2
  109. package/extensions/plan/steps.ts +37 -8
  110. package/extensions/plan/todos.ts +44 -3
  111. package/extensions/plan/tools/enter.ts +94 -0
  112. package/extensions/plan/tools/exit.ts +296 -0
  113. package/extensions/plan/tools/nested.ts +21 -0
  114. package/extensions/plan/tools/steps.ts +136 -0
  115. package/extensions/plan/tools/todo-seed.ts +76 -0
  116. package/extensions/plan/tools/write.ts +127 -0
  117. package/extensions/plan/tools.ts +18 -703
  118. package/extensions/plan/web-client.ts +6 -22
  119. package/extensions/plan/web-page.ts +19 -30
  120. package/extensions/plan/web-router.ts +34 -71
  121. package/extensions/plan/web.ts +13 -40
  122. package/extensions/question/answers.ts +3 -8
  123. package/extensions/question/tui.ts +6 -6
  124. package/extensions/rewind/README.md +95 -22
  125. package/extensions/rewind/checklist.ts +185 -0
  126. package/extensions/rewind/commands.ts +8 -0
  127. package/extensions/rewind/config.ts +3 -11
  128. package/extensions/rewind/format.ts +220 -2
  129. package/extensions/rewind/git.ts +74 -7
  130. package/extensions/rewind/hunk-picker.ts +114 -0
  131. package/extensions/rewind/patch.ts +88 -0
  132. package/extensions/rewind/path-picker.ts +67 -100
  133. package/extensions/rewind/paths.ts +93 -19
  134. package/extensions/rewind/restore.ts +53 -10
  135. package/extensions/rewind/review-tui.ts +320 -0
  136. package/extensions/rewind/review.ts +397 -0
  137. package/extensions/rewind/rewind-tui.ts +4 -3
  138. package/extensions/rewind/runtime.ts +94 -17
  139. package/extensions/subagent/README.md +118 -8
  140. package/extensions/subagent/background-tui.ts +208 -0
  141. package/extensions/subagent/background.ts +470 -0
  142. package/extensions/subagent/commands.ts +80 -0
  143. package/extensions/subagent/config.ts +62 -0
  144. package/extensions/subagent/format.ts +24 -1
  145. package/extensions/subagent/index.ts +116 -1
  146. package/extensions/subagent/orchestrate.ts +16 -3
  147. package/extensions/subagent/render.ts +25 -0
  148. package/extensions/subagent/run.ts +113 -49
  149. package/extensions/subagent/schema.ts +12 -0
  150. package/extensions/subagent/transcripts.ts +202 -0
  151. package/extensions/subagent/types.ts +23 -0
  152. package/extensions/subagent/ui.ts +50 -0
  153. package/extensions/todo/README.md +18 -13
  154. package/extensions/todo/format.ts +3 -2
  155. package/extensions/todo/runtime.ts +3 -2
  156. package/extensions/todo/schema.ts +8 -84
  157. package/extensions/todo/state.ts +5 -46
  158. package/extensions/todo/tools.ts +2 -1
  159. package/extensions/todo/types.ts +6 -15
  160. package/extensions/watch/README.md +16 -6
  161. package/extensions/watch/child.ts +150 -0
  162. package/extensions/watch/format.ts +8 -48
  163. package/extensions/watch/index.ts +42 -58
  164. package/extensions/watch/runtime.ts +49 -128
  165. package/extensions/watch/schema.ts +1 -4
  166. package/extensions/watch/tools.ts +1 -11
  167. package/extensions/watch/tui.ts +103 -273
  168. package/extensions/watch/ui.ts +5 -32
  169. package/extensions/web-access/README.md +1 -1
  170. package/extensions/web-access/fetch/page.ts +1 -1
  171. package/extensions/web-access/fetch/tool.ts +6 -2
  172. package/extensions/web-access/search/search.ts +2 -5
  173. package/extensions/wechat/README.md +3 -1
  174. package/extensions/wechat/client.ts +23 -0
  175. package/extensions/wechat/commands.ts +57 -42
  176. package/extensions/wechat/deliver.ts +5 -9
  177. package/extensions/wechat/errors.ts +3 -6
  178. package/extensions/wechat/inbound.ts +2 -5
  179. package/extensions/wechat/index.ts +2 -8
  180. package/extensions/wechat/outbound.ts +2 -4
  181. package/extensions/wechat/peer-worker.ts +3 -8
  182. package/extensions/wechat/poll.ts +10 -9
  183. package/extensions/wechat/presence.ts +35 -62
  184. package/extensions/wechat/reply-pipeline.ts +3 -1
  185. package/extensions/wechat/status-screen.ts +1 -1
  186. package/extensions/wechat/timer.ts +57 -0
  187. package/extensions/worktree/config.ts +8 -20
  188. package/extensions/worktree/enter.ts +7 -12
  189. package/extensions/worktree/git.ts +38 -69
  190. package/extensions/worktree/index.ts +5 -4
  191. package/extensions/worktree/registry.ts +7 -7
  192. package/extensions/worktree/root-tools.ts +2 -2
  193. package/extensions/worktree/status.ts +3 -2
  194. package/lib/README.md +22 -8
  195. package/lib/config.ts +83 -12
  196. package/lib/errors.ts +12 -0
  197. package/lib/format.ts +56 -0
  198. package/lib/git/read.ts +8 -0
  199. package/lib/goal-state.ts +23 -8
  200. package/lib/http/headers.ts +54 -0
  201. package/lib/http/index.ts +13 -0
  202. package/lib/http/response.ts +46 -0
  203. package/lib/http/router.ts +52 -0
  204. package/lib/http/server.ts +91 -0
  205. package/lib/lines.ts +77 -0
  206. package/lib/pdf.ts +82 -0
  207. package/lib/plan-entry.ts +71 -0
  208. package/lib/plan-state.ts +34 -0
  209. package/lib/report.ts +99 -0
  210. package/lib/requirements.ts +25 -19
  211. package/lib/spawn.ts +170 -0
  212. package/lib/todo-state.ts +110 -23
  213. package/lib/tool-names.ts +7 -0
  214. package/lib/tool-result.ts +33 -0
  215. package/lib/tui/README.md +68 -3
  216. package/lib/tui/chrome.ts +28 -4
  217. package/lib/tui/columns.ts +6 -1
  218. package/lib/tui/confirm.ts +65 -0
  219. package/lib/tui/dock-screen.ts +410 -0
  220. package/lib/tui/glyphs.ts +2 -1
  221. package/lib/tui/indent.ts +1 -1
  222. package/lib/tui/index.ts +2 -0
  223. package/lib/tui/rows.ts +21 -0
  224. package/lib/tui/status-controller.ts +35 -1
  225. package/lib/tui/tool.ts +59 -1
  226. package/lib/web/components.ts +20 -1
  227. package/lib/web/markup.ts +32 -0
  228. package/lib/web/runtime.ts +50 -0
  229. package/package.json +23 -5
  230. package/skills/init/SKILL.md +165 -0
  231. package/skills/init/references/agents-md-quality.md +103 -0
  232. package/skills/init/references/interview-questions.md +81 -0
  233. package/skills/refactor/SKILL.md +141 -0
  234. package/themes/bytefly-dark.json +2 -3
  235. package/themes/bytefly-light.json +2 -3
  236. package/extensions/watch/process.ts +0 -63
  237. package/extensions/web-access/fetch/pdf.ts +0 -45
@@ -15,7 +15,7 @@
15
15
  * Load with: pi --extension ./extensions/subagent
16
16
  */
17
17
 
18
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
19
  import { isExtensionEnabled, resolveEffectiveCwd } from "../../lib/env.ts";
20
20
  import {
21
21
  type AgentConfig,
@@ -25,8 +25,13 @@ 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";
31
+ import { loadSubagentConfig, type SubagentConfig } from "./config.ts";
28
32
  import { runChainMode, runParallelMode, runSingleMode, type ModeContext } from "./orchestrate.ts";
29
33
  import { renderSubagentCall, renderSubagentResult } from "./render.ts";
34
+ import { pruneTranscripts, transcriptsDirFor } from "./transcripts.ts";
30
35
  import { SUBAGENT_FINISHED_CHANNEL } from "../../lib/events.ts";
31
36
  import { finishedEvent } from "./accounting.ts";
32
37
  import { runSingleAgent, type RunOptions } from "./run.ts";
@@ -46,6 +51,7 @@ const DESCRIPTION = [
46
51
  "Delegate tasks to specialized subagents with isolated context windows.",
47
52
  "Modes: single (`agent` + `task`), parallel (`tasks`), chain (sequential, `{previous}` placeholder).",
48
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`.",
49
55
  `Built-in agents: ${AGENT_NAMES}.`,
50
56
  ].join(" ");
51
57
 
@@ -53,6 +59,7 @@ const GUIDELINES = [
53
59
  "Use `subagent` for broad codebase exploration, parallel research, or isolated implementation work that would bloat the main context.",
54
60
  "Prefer `explorer` first when another agent would otherwise re-read many files, then pass its output to `planner` or `worker`.",
55
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`.",
56
63
  "Project-local agents are repository-controlled and load only when `agentScope` includes them; an untrusted project requires confirmation.",
57
64
  ];
58
65
 
@@ -68,6 +75,20 @@ function requestedAgentNames(args: SubagentArgs): string[] {
68
75
  export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): void {
69
76
  if (!isExtensionEnabled("subagent")) return;
70
77
  const runner = deps.run ?? runSingleAgent;
78
+ // Read once per session; a call that arrives before `session_start` reads the
79
+ // same files lazily, so the two paths cannot disagree.
80
+ let settings: SubagentConfig | undefined;
81
+ let activeCtx: ExtensionContext | undefined;
82
+
83
+ const startSession = async (ctx: ExtensionContext): Promise<void> => {
84
+ settings = loadSubagentConfig(ctx.cwd);
85
+ if (settings.transcripts === "off") return;
86
+ try {
87
+ pruneTranscripts(await transcriptsDirFor(pi, ctx.cwd), settings);
88
+ } catch {
89
+ // Retention is housekeeping: an unreadable store must not fail a session.
90
+ }
91
+ };
71
92
 
72
93
  /**
73
94
  * Account for each finished child, then hand the result on unchanged.
@@ -89,6 +110,29 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
89
110
  return result;
90
111
  };
91
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
+
92
136
  pi.registerTool({
93
137
  name: TOOL_NAME,
94
138
  label: "Subagent",
@@ -127,6 +171,13 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
127
171
  const discovery = discoverAgents(root, agentScope);
128
172
  const agents = discovery.agents;
129
173
  const requestedTotal = args.chain?.length ?? args.tasks?.length ?? 1;
174
+ const config = settings ?? loadSubagentConfig(ctx.cwd);
175
+ // Resolved against the session cwd, not the active root: a run's record outlives
176
+ // the worktree it ran in, the way the `job` registry does.
177
+ const transcripts =
178
+ config.transcripts === "off"
179
+ ? undefined
180
+ : { dir: await transcriptsDirFor(pi, ctx.cwd), mode: config.transcripts };
130
181
  const makeDetails = (results: SingleResult[]): SubagentDetails => ({
131
182
  mode,
132
183
  results,
@@ -171,6 +222,68 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
171
222
  }
172
223
  }
173
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
+
174
287
  const context: ModeContext = {
175
288
  run,
176
289
  args,
@@ -180,6 +293,8 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
180
293
  onUpdate,
181
294
  makeDetails,
182
295
  agents,
296
+ timeoutMs: config.timeoutMs,
297
+ transcripts,
183
298
  };
184
299
 
185
300
  if (mode === "chain" && args.chain) return runChainMode(context);
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
8
8
  import type { AgentConfig } from "./agents.ts";
9
+ import type { TranscriptMode } from "./config.ts";
9
10
  import type { RunOptions } from "./run.ts";
10
11
  import { MAX_CONCURRENCY, PER_TASK_OUTPUT_CAP, type SubagentArgs } from "./schema.ts";
11
12
  import {
@@ -30,11 +31,15 @@ export interface ModeContext {
30
31
  onUpdate?: OnUpdateCallback;
31
32
  /** Builds the details payload for this mode from the current results. */
32
33
  makeDetails: (results: SingleResult[]) => SubagentDetails;
34
+ /** Per-run wall-clock limit in ms; `0` or absent disables it. */
35
+ timeoutMs?: number;
36
+ /** Where and when a finished run writes a transcript. */
37
+ transcripts?: { dir: string; mode: Exclude<TranscriptMode, "off"> };
33
38
  }
34
39
 
35
40
  /** Sequential chain: each step's `{previous}` is the prior step's final output. */
36
41
  export async function runChainMode(ctx: ModeContext): Promise<AgentToolResult<SubagentDetails>> {
37
- const { run, args, defaults, defaultCwd, signal, onUpdate, makeDetails, agents } = ctx;
42
+ const { run, args, defaults, defaultCwd, signal, onUpdate, makeDetails, agents, timeoutMs, transcripts } = ctx;
38
43
  const chain = args.chain ?? [];
39
44
  const results: SingleResult[] = [];
40
45
  let previous = "";
@@ -62,6 +67,8 @@ export async function runChainMode(ctx: ModeContext): Promise<AgentToolResult<Su
62
67
  signal,
63
68
  onUpdate: chainUpdate,
64
69
  makeDetails,
70
+ timeoutMs,
71
+ transcripts,
65
72
  });
66
73
  results.push(result);
67
74
 
@@ -89,7 +96,7 @@ export async function runChainMode(ctx: ModeContext): Promise<AgentToolResult<Su
89
96
 
90
97
  /** Independent tasks with bounded concurrency, reporting running/done progress. */
91
98
  export async function runParallelMode(ctx: ModeContext): Promise<AgentToolResult<SubagentDetails>> {
92
- const { run, args, defaults, defaultCwd, signal, onUpdate, makeDetails, agents } = ctx;
99
+ const { run, args, defaults, defaultCwd, signal, onUpdate, makeDetails, agents, timeoutMs, transcripts } = ctx;
93
100
  const tasks = args.tasks ?? [];
94
101
  const allResults: SingleResult[] = tasks.map((task) => createResult(task.agent, task.task));
95
102
 
@@ -113,6 +120,10 @@ export async function runParallelMode(ctx: ModeContext): Promise<AgentToolResult
113
120
  cwd: task.cwd,
114
121
  readOnly: args.readOnly,
115
122
  signal,
123
+ // Elapsed time is per run, so a deadline is too: one timer around the mode
124
+ // would bound the batch rather than each child.
125
+ timeoutMs,
126
+ transcripts,
116
127
  onUpdate: (partial) => {
117
128
  const current = partial.details?.results[0];
118
129
  if (!current) return;
@@ -148,7 +159,7 @@ export async function runParallelMode(ctx: ModeContext): Promise<AgentToolResult
148
159
 
149
160
  /** One agent + task. */
150
161
  export async function runSingleMode(ctx: ModeContext): Promise<AgentToolResult<SubagentDetails>> {
151
- const { run, args, defaults, defaultCwd, signal, onUpdate, makeDetails, agents } = ctx;
162
+ const { run, args, defaults, defaultCwd, signal, onUpdate, makeDetails, agents, timeoutMs, transcripts } = ctx;
152
163
  const result = await run({
153
164
  defaultCwd,
154
165
  defaults,
@@ -160,6 +171,8 @@ export async function runSingleMode(ctx: ModeContext): Promise<AgentToolResult<S
160
171
  signal,
161
172
  onUpdate,
162
173
  makeDetails,
174
+ timeoutMs,
175
+ transcripts,
163
176
  });
164
177
 
165
178
  if (isFailedResult(result)) {
@@ -296,6 +296,16 @@ function callText(args: StreamedSubagentArgs, theme: Theme, context?: { cwd?: st
296
296
  return text;
297
297
  }
298
298
 
299
+ /** The transcript this run kept, or why it could not be written. */
300
+ function transcriptLine(result: SingleResult, theme: Theme): string | undefined {
301
+ if (result.artifactError) {
302
+ return paint(theme, "warn", `transcript failed: ${sanitizeLine(result.artifactError)}`);
303
+ }
304
+ if (!result.artifactPath) return undefined;
305
+ const cut = result.artifactTruncated ? paint(theme, "warn", " (truncated)") : "";
306
+ return paint(theme, "faint", `transcript: ${sanitizeLine(result.artifactPath)}${cut}`);
307
+ }
308
+
299
309
  /** Append one result's expanded block: header, error, task, output, usage. */
300
310
  function addExpandedResult(
301
311
  container: Container,
@@ -353,6 +363,8 @@ function addExpandedResult(
353
363
  container.addChild(new Spacer(1));
354
364
  container.addChild(new Text(paint(theme, "faint", usage), 0, 0));
355
365
  }
366
+ const transcript = transcriptLine(result, theme);
367
+ if (transcript) container.addChild(new Text(transcript, 0, 0));
356
368
  }
357
369
 
358
370
  /** The collapsed single result: header, a word, body, expand hint, and usage. */
@@ -476,6 +488,19 @@ export function renderSubagentResult(
476
488
  ): Component {
477
489
  const now = options.now ?? Date.now();
478
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
+ }
479
504
  if (!details || details.results.length === 0) {
480
505
  syncElapsedTimer(false, context);
481
506
  const text = reuseText(context?.lastComponent);
@@ -2,8 +2,9 @@
2
2
  * Running one subagent subprocess.
3
3
  *
4
4
  * Spawns `pi --mode json`, folds the JSON event stream into a `SingleResult`,
5
- * streams partial updates, and cleans up the temporary system-prompt file. The
6
- * process factory is injectable so tests can drive a scripted child.
5
+ * enforces the run's deadline, optionally records the raw stream, streams partial
6
+ * updates, and cleans up the temporary system-prompt file. The process factory and
7
+ * the transcript store are injectable so tests can drive a scripted child.
7
8
  */
8
9
 
9
10
  import { spawn as nodeSpawn } from "node:child_process";
@@ -11,9 +12,13 @@ import * as fs from "node:fs";
11
12
  import * as os from "node:os";
12
13
  import * as path from "node:path";
13
14
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
15
+ import { formatDuration } from "../../lib/format.ts";
16
+ import { LineSplitter } from "../../lib/lines.ts";
14
17
  import { BUILTIN_AGENTS, formatAgentList, type AgentConfig } from "./agents.ts";
18
+ import type { TranscriptMode } from "./config.ts";
15
19
  import { buildAgentArgs, getPiInvocation, READ_ONLY_NOTE } from "./invocation.ts";
16
- import { applyEvent, createResult, parseJsonLine } from "./stream.ts";
20
+ import { applyEvent, createResult, isFailedResult, parseJsonLine } from "./stream.ts";
21
+ import { createTranscript, type Transcript } from "./transcripts.ts";
17
22
  import type { DispatchDefaults, OnUpdateCallback, SingleResult, SpawnFn, SubagentDetails } from "./types.ts";
18
23
 
19
24
  export interface RunOptions {
@@ -38,6 +43,12 @@ export interface RunOptions {
38
43
  makeDetails: (results: SingleResult[]) => SubagentDetails;
39
44
  /** Process factory; defaults to `node:child_process.spawn`. */
40
45
  spawn?: SpawnFn;
46
+ /** Per-run wall-clock limit in ms; `0` or absent disables it. */
47
+ timeoutMs?: number;
48
+ /** Return an aborted result instead of throwing (used by background runs). */
49
+ abortAsResult?: boolean;
50
+ /** Where and when a finished run writes a transcript. */
51
+ transcripts?: { dir: string; mode: Exclude<TranscriptMode, "off"> };
41
52
  }
42
53
 
43
54
  async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
@@ -53,57 +64,83 @@ async function writePromptToTempFile(agentName: string, prompt: string): Promise
53
64
  interface SpawnOutcome {
54
65
  exitCode: number;
55
66
  aborted: boolean;
67
+ timedOut: boolean;
68
+ }
69
+
70
+ /** How long a killed child has to exit before it is killed again, hard. */
71
+ const KILL_ESCALATION_MS = 5000;
72
+
73
+ /** Why a child is being killed; a timeout fails the run, an abort rejects the call. */
74
+ type KillReason = "abort" | "timeout";
75
+
76
+ interface SpawnContext {
77
+ command: string;
78
+ args: string[];
79
+ cwd: string;
80
+ /** Per-run wall-clock limit in ms; `0` disables it. */
81
+ timeoutMs: number;
82
+ signal?: AbortSignal;
83
+ result: SingleResult;
84
+ onEvent: () => void;
85
+ transcript?: Transcript;
86
+ spawn: SpawnFn;
56
87
  }
57
88
 
58
89
  /** Spawn the child, parse stdout line-by-line, and resolve when it closes. */
59
- function spawnAndCollect(
60
- command: string,
61
- args: string[],
62
- cwd: string,
63
- signal: AbortSignal | undefined,
64
- result: SingleResult,
65
- onEvent: () => void,
66
- spawn: SpawnFn,
67
- ): Promise<SpawnOutcome> {
90
+ function spawnAndCollect(context: SpawnContext): Promise<SpawnOutcome> {
91
+ const { command, args, cwd, timeoutMs, signal, result, onEvent, transcript, spawn } = context;
68
92
  return new Promise<SpawnOutcome>((resolve) => {
69
93
  const proc = spawn(command, args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
70
- let buffer = "";
94
+ const splitter = new LineSplitter();
71
95
  let settled = false;
72
- let aborted = false;
96
+ let abortRequested = false;
97
+ let timedOut = false;
73
98
 
74
- let kill: (() => void) | undefined;
75
99
  let killTimer: ReturnType<typeof setTimeout> | undefined;
100
+ let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
101
+
102
+ const kill = (reason: KillReason) => {
103
+ if (settled) return;
104
+ if (reason === "abort") abortRequested = true;
105
+ else {
106
+ timedOut = true;
107
+ result.timedOut = true;
108
+ }
109
+ proc.kill("SIGTERM");
110
+ killTimer = setTimeout(() => {
111
+ if (!proc.killed) proc.kill("SIGKILL");
112
+ }, KILL_ESCALATION_MS);
113
+ killTimer.unref();
114
+ };
115
+ const onAbort = () => kill("abort");
76
116
 
77
117
  const finish = (exitCode: number) => {
78
118
  if (settled) return;
79
119
  settled = true;
80
- if (kill && signal) signal.removeEventListener("abort", kill);
120
+ if (signal) signal.removeEventListener("abort", onAbort);
81
121
  if (killTimer) clearTimeout(killTimer);
82
- if (buffer.trim()) {
83
- const event = parseJsonLine(buffer);
84
- if (event && applyEvent(result, event)) onEvent();
122
+ if (timeoutTimer) clearTimeout(timeoutTimer);
123
+ for (const line of splitter.flush()) {
124
+ if (line.trim()) processLine(line);
85
125
  }
86
- resolve({ exitCode, aborted });
126
+ resolve({ exitCode, aborted: abortRequested, timedOut });
87
127
  };
88
128
 
89
129
  const processLine = (line: string) => {
130
+ transcript?.line(line);
90
131
  const event = parseJsonLine(line);
91
132
  if (event && applyEvent(result, event)) onEvent();
92
133
  };
93
134
 
94
135
  proc.stdout?.on("data", (chunk) => {
95
- buffer += chunk.toString();
96
- // Keep the final partial line in the buffer for the close handler.
97
- let newline = buffer.indexOf("\n");
98
- while (newline !== -1) {
99
- processLine(buffer.slice(0, newline));
100
- buffer = buffer.slice(newline + 1);
101
- newline = buffer.indexOf("\n");
102
- }
136
+ // The splitter holds the final partial line for the close handler.
137
+ for (const line of splitter.push(chunk)) processLine(line);
103
138
  });
104
139
 
105
140
  proc.stderr?.on("data", (chunk) => {
106
- result.stderr += chunk.toString();
141
+ const text = chunk.toString();
142
+ result.stderr += text;
143
+ transcript?.stderr(text);
107
144
  });
108
145
 
109
146
  proc.on("close", (code, signal) => {
@@ -122,16 +159,12 @@ function spawnAndCollect(
122
159
  });
123
160
 
124
161
  if (signal) {
125
- kill = () => {
126
- aborted = true;
127
- proc.kill("SIGTERM");
128
- killTimer = setTimeout(() => {
129
- if (!proc.killed) proc.kill("SIGKILL");
130
- }, 5000);
131
- killTimer.unref();
132
- };
133
- if (signal.aborted) kill();
134
- else signal.addEventListener("abort", kill, { once: true });
162
+ if (signal.aborted) onAbort();
163
+ else signal.addEventListener("abort", onAbort, { once: true });
164
+ }
165
+ if (timeoutMs > 0) {
166
+ timeoutTimer = setTimeout(() => kill("timeout"), timeoutMs);
167
+ timeoutTimer.unref();
135
168
  }
136
169
  });
137
170
  }
@@ -163,6 +196,12 @@ export async function runSingleAgent(options: RunOptions): Promise<SingleResult>
163
196
 
164
197
  const emit = () => options.onUpdate?.({ content: [], details: options.makeDetails([result]) });
165
198
 
199
+ const timeoutMs = options.timeoutMs ?? 0;
200
+ if (timeoutMs > 0) result.timeoutMs = timeoutMs;
201
+ const transcripts = options.transcripts;
202
+ const transcript = transcripts ? createTranscript() : undefined;
203
+ let runCwd = options.defaultCwd;
204
+
166
205
  let tempDir: string | null = null;
167
206
  let tempPath: string | null = null;
168
207
 
@@ -178,22 +217,47 @@ export async function runSingleAgent(options: RunOptions): Promise<SingleResult>
178
217
 
179
218
  const args = buildAgentArgs(agent, options.task, options.defaults, tempPath, options.readOnly === true);
180
219
  const invocation = getPiInvocation(args);
181
- const runCwd = options.cwd ? path.resolve(options.defaultCwd, options.cwd) : options.defaultCwd;
182
- const outcome = await spawnAndCollect(
183
- invocation.command,
184
- invocation.args,
185
- runCwd,
186
- options.signal,
220
+ runCwd = options.cwd ? path.resolve(options.defaultCwd, options.cwd) : options.defaultCwd;
221
+ const outcome = await spawnAndCollect({
222
+ command: invocation.command,
223
+ args: invocation.args,
224
+ cwd: runCwd,
225
+ timeoutMs,
226
+ signal: options.signal,
187
227
  result,
188
- emit,
228
+ onEvent: emit,
229
+ transcript,
189
230
  spawn,
190
- );
231
+ });
191
232
 
192
- result.exitCode = outcome.exitCode;
233
+ result.exitCode = outcome.timedOut || outcome.aborted ? 1 : outcome.exitCode;
193
234
  result.finishedAt = Date.now();
194
- if (outcome.aborted) throw new Error("Subagent was aborted");
235
+ // A deadline fails the run rather than rejecting it: a chain then stops at
236
+ // this step and reports why, instead of discarding the steps already done.
237
+ if (outcome.timedOut) result.errorMessage = `Timed out after ${formatDuration(timeoutMs)}`;
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
+ }
195
246
  return result;
196
247
  } finally {
248
+ // Flushed here, not after the call: the abort path throws past every caller.
249
+ if (transcript && transcripts && (transcripts.mode === "all" || isFailedResult(result))) {
250
+ const written = transcript.write(transcripts.dir, result, {
251
+ cwd: runCwd,
252
+ readOnly: options.readOnly === true,
253
+ });
254
+ if (written.path) {
255
+ result.artifactPath = written.path;
256
+ result.artifactBytes = written.bytes;
257
+ if (written.truncated) result.artifactTruncated = true;
258
+ }
259
+ if (written.error) result.artifactError = written.error;
260
+ }
197
261
  if (tempPath) {
198
262
  try {
199
263
  fs.unlinkSync(tempPath);
@@ -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." };