@gavin-hu/my-pi-agent 0.13.0 → 0.14.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 (64) hide show
  1. package/README.md +14 -11
  2. package/README.zh-CN.md +12 -12
  3. package/extensions/bytefly/README.md +163 -61
  4. package/extensions/bytefly/doctor/config.ts +4 -1
  5. package/extensions/bytefly/doctor/runtime.ts +2 -0
  6. package/extensions/bytefly/footer/budget.ts +64 -0
  7. package/extensions/bytefly/footer/collect/snapshot.ts +22 -4
  8. package/extensions/bytefly/footer/price/config.ts +18 -1
  9. package/extensions/bytefly/footer/price/types.ts +5 -0
  10. package/extensions/bytefly/footer/register.ts +34 -3
  11. package/extensions/bytefly/header/component.ts +30 -8
  12. package/extensions/bytefly/header/config.ts +22 -0
  13. package/extensions/bytefly/header/register.ts +2 -1
  14. package/extensions/bytefly/index.ts +7 -5
  15. package/extensions/bytefly/notify/config.ts +1 -1
  16. package/extensions/bytefly/notify/register.ts +15 -1
  17. package/extensions/bytefly/panel/commands.ts +1 -1
  18. package/extensions/bytefly/panel/format.ts +12 -19
  19. package/extensions/bytefly/panel/settings-runtime.ts +62 -18
  20. package/extensions/bytefly/panel/settings.ts +269 -42
  21. package/extensions/bytefly/panel/tui/settings-view.ts +49 -3
  22. package/extensions/bytefly/panel/tui.ts +87 -19
  23. package/extensions/bytefly/separator/component.ts +7 -6
  24. package/extensions/bytefly/separator/config.ts +15 -0
  25. package/extensions/bytefly/separator/format.ts +60 -6
  26. package/extensions/bytefly/separator/register.ts +68 -9
  27. package/extensions/bytefly/separator/types.ts +8 -0
  28. package/extensions/bytefly/working/config.ts +4 -5
  29. package/extensions/bytefly/working/format.ts +24 -7
  30. package/extensions/bytefly/working/register.ts +34 -3
  31. package/extensions/job/format.ts +4 -7
  32. package/extensions/memory/README.md +2 -0
  33. package/extensions/plan/README.md +12 -0
  34. package/extensions/subagent/README.md +60 -5
  35. package/extensions/subagent/config.ts +58 -0
  36. package/extensions/subagent/index.ts +27 -1
  37. package/extensions/subagent/orchestrate.ts +16 -3
  38. package/extensions/subagent/render.ts +12 -0
  39. package/extensions/subagent/run.ts +100 -37
  40. package/extensions/subagent/transcripts.ts +202 -0
  41. package/extensions/subagent/types.ts +12 -0
  42. package/extensions/todo/nudge.ts +5 -1
  43. package/extensions/watch/README.md +13 -5
  44. package/extensions/watch/commands.ts +4 -3
  45. package/extensions/watch/config.ts +1 -1
  46. package/extensions/watch/format.ts +4 -7
  47. package/extensions/watch/runtime.ts +83 -12
  48. package/extensions/wechat/README.md +2 -0
  49. package/extensions/worktree/README.md +7 -1
  50. package/extensions/worktree/enter.ts +10 -3
  51. package/extensions/worktree/exit.ts +25 -5
  52. package/extensions/worktree/git.ts +28 -0
  53. package/extensions/worktree/index.ts +2 -0
  54. package/extensions/worktree/root-tools.ts +1 -1
  55. package/extensions/worktree/runtime.ts +5 -0
  56. package/lib/README.md +5 -3
  57. package/lib/config.ts +33 -9
  58. package/lib/format.ts +6 -0
  59. package/lib/requirements.ts +80 -1
  60. package/lib/tui/columns.ts +17 -0
  61. package/lib/tui/tool.ts +6 -2
  62. package/package.json +2 -1
  63. package/themes/bytefly-dark.json +2 -3
  64. package/themes/bytefly-light.json +2 -3
@@ -311,6 +311,18 @@ agent file that overrides it.
311
311
  - `readOnly` is also a normal `subagent` parameter; outside plan mode a caller
312
312
  may set it to run any agent read-only on purpose.
313
313
 
314
+ ## Review screen
315
+
316
+ <img src="../../assets/plan.png" alt="The terminal plan review: a Plan Review title rule, a notice that one section changed since the last review, the plan body with its headings and ◼/◻ task checkboxes, a scroll gauge naming the current section and the visible line range, and the Esc/a/r/w, scroll, and section-jump hints" width="100%">
317
+
318
+ `exit_plan_mode` opens this screen on the plan file it just wrote. The body is
319
+ Pi's Markdown renderer with the active theme, so headings, lists, code, and
320
+ links read as they do in the transcript; the checkboxes in `Steps` use the
321
+ shared `◼`/`◻` vocabulary. A plan taller than the terminal scrolls under a gauge
322
+ that names the section at the top of the viewport and the line range it shows,
323
+ and the footer lists only the keys that apply. `Esc` keeps planning, `a` approves
324
+ and runs, `r` refines inline, and `w` opens the browser review below.
325
+
314
326
  ## Browser review
315
327
 
316
328
  <img src="../../assets/plan-review.png" alt="The browser-based plan review: a sticky header with the plan title, a status chip, and the Approve & run, Save feedback, and Back to Pi actions, a side panel with step progress and a section outline, and the plan as one measured column with a comment rail and an inline review thread" width="100%">
@@ -29,6 +29,10 @@ How the `subagent` call and its result look in the transcript — the call line
29
29
  - **Streaming.** Tool calls and progress update live in the transcript while the
30
30
  subagent runs; the `app.tools.expand` key (`ctrl+o` by default) expands the
31
31
  full task, tool calls, Markdown output, and usage.
32
+ - **Bounded runs.** A per-run deadline kills a child that hangs, and reports it
33
+ as a failed run instead of rejecting the whole call.
34
+ - **Optional transcripts.** Opt-in per-run stream under `.pi/subagents/`,
35
+ written by the parent and pruned by age and count.
32
36
  - **Inherited model.** Subagents use the dispatching session's model and thinking
33
37
  level unless an agent overrides them (none do).
34
38
 
@@ -88,6 +92,42 @@ at most 8 steps (like the parallel task cap).
88
92
  }
89
93
  ```
90
94
 
95
+ ## Configuration
96
+
97
+ Read from the shared `bytefly.json` section `subagent` (global then project),
98
+ once per session. Keys and bounds live in the
99
+ [configuration reference](../../docs/references/bytefly-config.md#subagent--delegated-runs);
100
+ the behaviour is here.
101
+
102
+ - **`timeoutMs`** (default ten minutes, `0` disables) bounds one child run. The
103
+ limit is per run, not per call, so a chain is bounded step by step and a
104
+ parallel batch bounds each task separately. On expiry the child gets the same
105
+ `SIGTERM`, then `SIGKILL` after 5 s, as a user abort — but the run fails
106
+ instead of throwing: a chain stops at that step, a parallel call counts a
107
+ failure, and the model sees `Timed out after 10m00s`.
108
+ - **`transcripts`** (`off`, `failed`, or `all`; default `off`) keeps finished
109
+ runs on disk, all of them or only the failed ones.
110
+ - **`retentionDays`** (default 14) and **`maxRuns`** (default 100) bound the
111
+ store. It is pruned at `session_start`, and only while transcripts are on.
112
+
113
+ ### Transcripts
114
+
115
+ ```
116
+ .pi/subagents/20261015-143012-a1b2c3/
117
+ meta.json agent, task, cwd, model, timings, exit, status, usage
118
+ events.jsonl the child's raw stdout lines, verbatim
119
+ stderr.log only when the child wrote any
120
+ ```
121
+
122
+ The store is resolved against the **session** cwd, so inside a worktree it lands
123
+ in the main checkout's `.pi/` and outlives that worktree — the anchoring the
124
+ `job` registry uses. Outside a repository it falls back to
125
+ `<agent-dir>/subagents`. The parent writes it and the child never learns it
126
+ exists, so `readOnly`, the `--tools` allowlist, and the project-agent trust gate
127
+ are unchanged. Writing is best-effort: an unwritable checkout records the failure
128
+ in the result and never fails a run. The stream is buffered in the parent up to
129
+ 32 MiB, then truncated with a marker rather than silently dropped.
130
+
91
131
  ## Security
92
132
 
93
133
  A subagent is a real `pi` process and shares Pi's OS permissions. The built-in
@@ -206,7 +246,10 @@ Markdown, and per-step/per-task usage with a total.
206
246
 
207
247
  A failed or aborted result always shows its `errorMessage`, falling back to the
208
248
  subprocess `stderr`, in both the collapsed and expanded views, in every mode. A
209
- failure with neither falls back to `Error: Subprocess exited with code N.` and an
249
+ run that exceeded its deadline reports `Timed out after …` the same way, and the
250
+ expanded view adds the transcript path, the truncation marker, or why the
251
+ transcript could not be written. A failure with neither falls back to
252
+ `Error: Subprocess exited with code N.` and an
210
253
  `[exit N]` bracket, so a non-zero exit is never silent. A child terminated by a
211
254
  signal (an OOM kill, a crash) has no exit code, so it is recorded as
212
255
  `Terminated by <signal>` and reported as a failure. Parallel and chain
@@ -232,8 +275,10 @@ step's `Output`.
232
275
  does not reimplement tool execution or model streaming.
233
276
  - **No pinned models.** Built-in agents set no `model`, so a subprocess inherits
234
277
  the dispatching session's provider/model and thinking level.
235
- - **No persistence.** Subagent transcripts live only in the tool result details;
236
- they are not written to the parent session.
278
+ - **No persistence by default.** A run's transcript lives in the tool result
279
+ details; keeping it on disk is opt-in, parent-written, and bounded by age and
280
+ count. It is an audit and handoff record, not a resume point: replaying a run
281
+ whose writes already landed is not attempted.
237
282
  - **No cost attribution to the parent session.** A subagent's tokens and cost are
238
283
  captured in the tool result and shown in the transcript, but they belong to a
239
284
  separate process and are not added to the dispatching session's totals. Instead,
@@ -253,8 +298,8 @@ step's `Output`.
253
298
  | Annotations | `readOnlyHint: false`, `openWorldHint: true` |
254
299
  | Output schema | none; the full result lives in tool-result `details` |
255
300
  | Execution | spawns `pi --mode json` subprocesses; external agents discovered from `<agent-dir>/agents` and `.pi/agents` |
256
- | State storage | tool-result `details` only (no session persistence) |
257
- | Lifecycle | none; the tool is stateless and spawns per call |
301
+ | State storage | tool-result `details`; optional per-run transcripts under `.pi/subagents/`, never the session |
302
+ | Lifecycle | `session_start` loads the config and prunes expired transcripts; the tool itself is stateless |
258
303
 
259
304
  ## Design notes
260
305
 
@@ -284,6 +329,14 @@ step's `Output`.
284
329
  results arrive as `message_end` (messages, usage, model, stop reason) and
285
330
  failures as `tool_execution_end`. A tool error does not by itself fail the run,
286
331
  because the agent may recover.
332
+ - **A deadline reuses the abort kill path but not its outcome.** One kill
333
+ closure serves both triggers, so the SIGTERM → 5 s SIGKILL escalation is
334
+ identical; only the reporting differs, because a deadline must leave the
335
+ finished steps in `details` while a user abort rejects the call.
336
+ - **The transcript is the parent's.** Teeing the child's stdout and stderr in
337
+ `spawnAndCollect` and flushing in `runSingleAgent`'s `finally` means the
338
+ artifact exists even on the abort path, and the child needs no tool, no prompt
339
+ line, and no writable store.
287
340
  - **Bounded parallel output.** Parallel mode runs at most 4 of up to 8 tasks at
288
341
  once and caps each model-facing section at 50 KiB, appending an omission notice
289
342
  pointing at the tool details. The full results stay in `details` for rendering.
@@ -318,6 +371,8 @@ step's `Output`.
318
371
  | `schema.ts` | TypeBox params, limits, and `resolveMode`. |
319
372
  | `invocation.ts` | `pi` subprocess invocation and argument construction. |
320
373
  | `stream.ts` | JSON-event parsing, usage accounting, output helpers (pure). |
374
+ | `transcripts.ts` | Per-run transcript buffer, run ids, the store directory, and pruning. |
375
+ | `config.ts` | The `subagent` section of `bytefly.json`: deadline, transcripts, retention. |
321
376
  | `run.ts` | Runs one subagent process and captures its result. |
322
377
  | `format.ts` | Token/usage/tool-call formatting (pure). |
323
378
  | `render.ts` | `renderCall` / `renderResult` transcript rendering. |
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Config-file support for subagent runs.
3
+ *
4
+ * The `subagent` section of the shared `bytefly.json` (global then project,
5
+ * project wins). Only the container is configurable — a run's deadline and
6
+ * whether it keeps a transcript on disk — never an agent's prompt or tools.
7
+ */
8
+
9
+ import { clampInteger, loadConfigSection } from "../../lib/config.ts";
10
+
11
+ /** Which finished runs keep a transcript on disk. */
12
+ export const TRANSCRIPT_MODES = ["off", "failed", "all"] as const;
13
+
14
+ /** One of {@link TRANSCRIPT_MODES}. */
15
+ export type TranscriptMode = (typeof TRANSCRIPT_MODES)[number];
16
+
17
+ export interface SubagentConfig {
18
+ /** Wall-clock limit for one child run in ms; `0` disables it. */
19
+ timeoutMs: number;
20
+ /** Which runs write a transcript under `.pi/subagents/`. */
21
+ transcripts: TranscriptMode;
22
+ /** Age in days after which a run directory is pruned at session start. */
23
+ retentionDays: number;
24
+ /** Most run directories kept, oldest pruned first. */
25
+ maxRuns: number;
26
+ }
27
+
28
+ export const DEFAULT_SUBAGENT_CONFIG: SubagentConfig = {
29
+ timeoutMs: 600_000,
30
+ transcripts: "off",
31
+ retentionDays: 14,
32
+ maxRuns: 100,
33
+ };
34
+
35
+ /** An unknown value read as one of {@link TRANSCRIPT_MODES}, or `fallback`. */
36
+ function asTranscriptMode(value: unknown, fallback: TranscriptMode): TranscriptMode {
37
+ if (typeof value !== "string") return fallback;
38
+ return TRANSCRIPT_MODES.find((mode) => mode === value) ?? fallback;
39
+ }
40
+
41
+ /** Validate a raw config object over `base`. */
42
+ export function normalizeSubagentConfig(
43
+ raw: Record<string, unknown> | undefined,
44
+ base: SubagentConfig,
45
+ ): SubagentConfig {
46
+ if (!raw) return base;
47
+ return {
48
+ timeoutMs: clampInteger(raw.timeoutMs, base.timeoutMs, 0, 3_600_000),
49
+ transcripts: asTranscriptMode(raw.transcripts, base.transcripts),
50
+ retentionDays: clampInteger(raw.retentionDays, base.retentionDays, 1, 3650),
51
+ maxRuns: clampInteger(raw.maxRuns, base.maxRuns, 1, 1000),
52
+ };
53
+ }
54
+
55
+ /** Effective subagent config from the shared `subagent` section. */
56
+ export function loadSubagentConfig(cwd: string): SubagentConfig {
57
+ return loadConfigSection(cwd, "subagent", DEFAULT_SUBAGENT_CONFIG, normalizeSubagentConfig);
58
+ }
@@ -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,10 @@ import {
25
25
  formatAgentList,
26
26
  formatAgentNames,
27
27
  } from "./agents.ts";
28
+ import { loadSubagentConfig, type SubagentConfig } from "./config.ts";
28
29
  import { runChainMode, runParallelMode, runSingleMode, type ModeContext } from "./orchestrate.ts";
29
30
  import { renderSubagentCall, renderSubagentResult } from "./render.ts";
31
+ import { pruneTranscripts, transcriptsDirFor } from "./transcripts.ts";
30
32
  import { SUBAGENT_FINISHED_CHANNEL } from "../../lib/events.ts";
31
33
  import { finishedEvent } from "./accounting.ts";
32
34
  import { runSingleAgent, type RunOptions } from "./run.ts";
@@ -68,6 +70,21 @@ function requestedAgentNames(args: SubagentArgs): string[] {
68
70
  export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): void {
69
71
  if (!isExtensionEnabled("subagent")) return;
70
72
  const runner = deps.run ?? runSingleAgent;
73
+ // Read once per session; a call that arrives before `session_start` reads the
74
+ // same files lazily, so the two paths cannot disagree.
75
+ let settings: SubagentConfig | undefined;
76
+
77
+ const startSession = async (ctx: ExtensionContext): Promise<void> => {
78
+ settings = loadSubagentConfig(ctx.cwd);
79
+ if (settings.transcripts === "off") return;
80
+ try {
81
+ pruneTranscripts(await transcriptsDirFor(pi, ctx.cwd), settings);
82
+ } catch {
83
+ // Retention is housekeeping: an unreadable store must not fail a session.
84
+ }
85
+ };
86
+
87
+ pi.on("session_start", (_event, ctx) => startSession(ctx));
71
88
 
72
89
  /**
73
90
  * Account for each finished child, then hand the result on unchanged.
@@ -127,6 +144,13 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
127
144
  const discovery = discoverAgents(root, agentScope);
128
145
  const agents = discovery.agents;
129
146
  const requestedTotal = args.chain?.length ?? args.tasks?.length ?? 1;
147
+ const config = settings ?? loadSubagentConfig(ctx.cwd);
148
+ // Resolved against the session cwd, not the active root: a run's record outlives
149
+ // the worktree it ran in, the way the `job` registry does.
150
+ const transcripts =
151
+ config.transcripts === "off"
152
+ ? undefined
153
+ : { dir: await transcriptsDirFor(pi, ctx.cwd), mode: config.transcripts };
130
154
  const makeDetails = (results: SingleResult[]): SubagentDetails => ({
131
155
  mode,
132
156
  results,
@@ -180,6 +204,8 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
180
204
  onUpdate,
181
205
  makeDetails,
182
206
  agents,
207
+ timeoutMs: config.timeoutMs,
208
+ transcripts,
183
209
  };
184
210
 
185
211
  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. */
@@ -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,12 @@ 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";
14
16
  import { BUILTIN_AGENTS, formatAgentList, type AgentConfig } from "./agents.ts";
17
+ import type { TranscriptMode } from "./config.ts";
15
18
  import { buildAgentArgs, getPiInvocation, READ_ONLY_NOTE } from "./invocation.ts";
16
- import { applyEvent, createResult, parseJsonLine } from "./stream.ts";
19
+ import { applyEvent, createResult, isFailedResult, parseJsonLine } from "./stream.ts";
20
+ import { createTranscript, type Transcript } from "./transcripts.ts";
17
21
  import type { DispatchDefaults, OnUpdateCallback, SingleResult, SpawnFn, SubagentDetails } from "./types.ts";
18
22
 
19
23
  export interface RunOptions {
@@ -38,6 +42,10 @@ export interface RunOptions {
38
42
  makeDetails: (results: SingleResult[]) => SubagentDetails;
39
43
  /** Process factory; defaults to `node:child_process.spawn`. */
40
44
  spawn?: SpawnFn;
45
+ /** Per-run wall-clock limit in ms; `0` or absent disables it. */
46
+ timeoutMs?: number;
47
+ /** Where and when a finished run writes a transcript. */
48
+ transcripts?: { dir: string; mode: Exclude<TranscriptMode, "off"> };
41
49
  }
42
50
 
43
51
  async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
@@ -53,46 +61,79 @@ async function writePromptToTempFile(agentName: string, prompt: string): Promise
53
61
  interface SpawnOutcome {
54
62
  exitCode: number;
55
63
  aborted: boolean;
64
+ timedOut: boolean;
65
+ }
66
+
67
+ /** How long a killed child has to exit before it is killed again, hard. */
68
+ const KILL_ESCALATION_MS = 5000;
69
+
70
+ /** Why a child is being killed; a timeout fails the run, an abort rejects the call. */
71
+ type KillReason = "abort" | "timeout";
72
+
73
+ interface SpawnContext {
74
+ command: string;
75
+ args: string[];
76
+ cwd: string;
77
+ /** Per-run wall-clock limit in ms; `0` disables it. */
78
+ timeoutMs: number;
79
+ signal?: AbortSignal;
80
+ result: SingleResult;
81
+ onEvent: () => void;
82
+ transcript?: Transcript;
83
+ spawn: SpawnFn;
56
84
  }
57
85
 
58
86
  /** 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> {
87
+ function spawnAndCollect(context: SpawnContext): Promise<SpawnOutcome> {
88
+ const { command, args, cwd, timeoutMs, signal, result, onEvent, transcript, spawn } = context;
68
89
  return new Promise<SpawnOutcome>((resolve) => {
69
90
  const proc = spawn(command, args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
70
91
  let buffer = "";
71
92
  let settled = false;
72
- let aborted = false;
93
+ let abortRequested = false;
94
+ let timedOut = false;
73
95
 
74
- let kill: (() => void) | undefined;
75
96
  let killTimer: ReturnType<typeof setTimeout> | undefined;
97
+ let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
98
+
99
+ const kill = (reason: KillReason) => {
100
+ if (settled) return;
101
+ if (reason === "abort") abortRequested = true;
102
+ else {
103
+ timedOut = true;
104
+ result.timedOut = true;
105
+ }
106
+ proc.kill("SIGTERM");
107
+ killTimer = setTimeout(() => {
108
+ if (!proc.killed) proc.kill("SIGKILL");
109
+ }, KILL_ESCALATION_MS);
110
+ killTimer.unref();
111
+ };
112
+ const onAbort = () => kill("abort");
76
113
 
77
114
  const finish = (exitCode: number) => {
78
115
  if (settled) return;
79
116
  settled = true;
80
- if (kill && signal) signal.removeEventListener("abort", kill);
117
+ if (signal) signal.removeEventListener("abort", onAbort);
81
118
  if (killTimer) clearTimeout(killTimer);
119
+ if (timeoutTimer) clearTimeout(timeoutTimer);
82
120
  if (buffer.trim()) {
121
+ transcript?.line(buffer);
83
122
  const event = parseJsonLine(buffer);
84
123
  if (event && applyEvent(result, event)) onEvent();
85
124
  }
86
- resolve({ exitCode, aborted });
125
+ resolve({ exitCode, aborted: abortRequested, timedOut });
87
126
  };
88
127
 
89
128
  const processLine = (line: string) => {
129
+ transcript?.line(line);
90
130
  const event = parseJsonLine(line);
91
131
  if (event && applyEvent(result, event)) onEvent();
92
132
  };
93
133
 
94
134
  proc.stdout?.on("data", (chunk) => {
95
- buffer += chunk.toString();
135
+ const text = chunk.toString();
136
+ buffer += text;
96
137
  // Keep the final partial line in the buffer for the close handler.
97
138
  let newline = buffer.indexOf("\n");
98
139
  while (newline !== -1) {
@@ -103,7 +144,9 @@ function spawnAndCollect(
103
144
  });
104
145
 
105
146
  proc.stderr?.on("data", (chunk) => {
106
- result.stderr += chunk.toString();
147
+ const text = chunk.toString();
148
+ result.stderr += text;
149
+ transcript?.stderr(text);
107
150
  });
108
151
 
109
152
  proc.on("close", (code, signal) => {
@@ -122,16 +165,12 @@ function spawnAndCollect(
122
165
  });
123
166
 
124
167
  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 });
168
+ if (signal.aborted) onAbort();
169
+ else signal.addEventListener("abort", onAbort, { once: true });
170
+ }
171
+ if (timeoutMs > 0) {
172
+ timeoutTimer = setTimeout(() => kill("timeout"), timeoutMs);
173
+ timeoutTimer.unref();
135
174
  }
136
175
  });
137
176
  }
@@ -163,6 +202,12 @@ export async function runSingleAgent(options: RunOptions): Promise<SingleResult>
163
202
 
164
203
  const emit = () => options.onUpdate?.({ content: [], details: options.makeDetails([result]) });
165
204
 
205
+ const timeoutMs = options.timeoutMs ?? 0;
206
+ if (timeoutMs > 0) result.timeoutMs = timeoutMs;
207
+ const transcripts = options.transcripts;
208
+ const transcript = transcripts ? createTranscript() : undefined;
209
+ let runCwd = options.defaultCwd;
210
+
166
211
  let tempDir: string | null = null;
167
212
  let tempPath: string | null = null;
168
213
 
@@ -178,22 +223,40 @@ export async function runSingleAgent(options: RunOptions): Promise<SingleResult>
178
223
 
179
224
  const args = buildAgentArgs(agent, options.task, options.defaults, tempPath, options.readOnly === true);
180
225
  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,
226
+ runCwd = options.cwd ? path.resolve(options.defaultCwd, options.cwd) : options.defaultCwd;
227
+ const outcome = await spawnAndCollect({
228
+ command: invocation.command,
229
+ args: invocation.args,
230
+ cwd: runCwd,
231
+ timeoutMs,
232
+ signal: options.signal,
187
233
  result,
188
- emit,
234
+ onEvent: emit,
235
+ transcript,
189
236
  spawn,
190
- );
237
+ });
191
238
 
192
- result.exitCode = outcome.exitCode;
239
+ result.exitCode = outcome.timedOut ? 1 : outcome.exitCode;
193
240
  result.finishedAt = Date.now();
241
+ // A deadline fails the run rather than rejecting it: a chain then stops at
242
+ // this step and reports why, instead of discarding the steps already done.
243
+ if (outcome.timedOut) result.errorMessage = `Timed out after ${formatDuration(timeoutMs)}`;
194
244
  if (outcome.aborted) throw new Error("Subagent was aborted");
195
245
  return result;
196
246
  } finally {
247
+ // Flushed here, not after the call: the abort path throws past every caller.
248
+ if (transcript && transcripts && (transcripts.mode === "all" || isFailedResult(result))) {
249
+ const written = transcript.write(transcripts.dir, result, {
250
+ cwd: runCwd,
251
+ readOnly: options.readOnly === true,
252
+ });
253
+ if (written.path) {
254
+ result.artifactPath = written.path;
255
+ result.artifactBytes = written.bytes;
256
+ if (written.truncated) result.artifactTruncated = true;
257
+ }
258
+ if (written.error) result.artifactError = written.error;
259
+ }
197
260
  if (tempPath) {
198
261
  try {
199
262
  fs.unlinkSync(tempPath);