@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.
- package/README.md +14 -11
- package/README.zh-CN.md +12 -12
- package/extensions/bytefly/README.md +163 -61
- package/extensions/bytefly/doctor/config.ts +4 -1
- package/extensions/bytefly/doctor/runtime.ts +2 -0
- package/extensions/bytefly/footer/budget.ts +64 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +22 -4
- package/extensions/bytefly/footer/price/config.ts +18 -1
- package/extensions/bytefly/footer/price/types.ts +5 -0
- package/extensions/bytefly/footer/register.ts +34 -3
- package/extensions/bytefly/header/component.ts +30 -8
- package/extensions/bytefly/header/config.ts +22 -0
- package/extensions/bytefly/header/register.ts +2 -1
- package/extensions/bytefly/index.ts +7 -5
- package/extensions/bytefly/notify/config.ts +1 -1
- package/extensions/bytefly/notify/register.ts +15 -1
- package/extensions/bytefly/panel/commands.ts +1 -1
- package/extensions/bytefly/panel/format.ts +12 -19
- package/extensions/bytefly/panel/settings-runtime.ts +62 -18
- package/extensions/bytefly/panel/settings.ts +269 -42
- package/extensions/bytefly/panel/tui/settings-view.ts +49 -3
- package/extensions/bytefly/panel/tui.ts +87 -19
- package/extensions/bytefly/separator/component.ts +7 -6
- package/extensions/bytefly/separator/config.ts +15 -0
- package/extensions/bytefly/separator/format.ts +60 -6
- package/extensions/bytefly/separator/register.ts +68 -9
- package/extensions/bytefly/separator/types.ts +8 -0
- package/extensions/bytefly/working/config.ts +4 -5
- package/extensions/bytefly/working/format.ts +24 -7
- package/extensions/bytefly/working/register.ts +34 -3
- package/extensions/job/format.ts +4 -7
- package/extensions/memory/README.md +2 -0
- package/extensions/plan/README.md +12 -0
- package/extensions/subagent/README.md +60 -5
- package/extensions/subagent/config.ts +58 -0
- package/extensions/subagent/index.ts +27 -1
- package/extensions/subagent/orchestrate.ts +16 -3
- package/extensions/subagent/render.ts +12 -0
- package/extensions/subagent/run.ts +100 -37
- package/extensions/subagent/transcripts.ts +202 -0
- package/extensions/subagent/types.ts +12 -0
- package/extensions/todo/nudge.ts +5 -1
- package/extensions/watch/README.md +13 -5
- package/extensions/watch/commands.ts +4 -3
- package/extensions/watch/config.ts +1 -1
- package/extensions/watch/format.ts +4 -7
- package/extensions/watch/runtime.ts +83 -12
- package/extensions/wechat/README.md +2 -0
- package/extensions/worktree/README.md +7 -1
- package/extensions/worktree/enter.ts +10 -3
- package/extensions/worktree/exit.ts +25 -5
- package/extensions/worktree/git.ts +28 -0
- package/extensions/worktree/index.ts +2 -0
- package/extensions/worktree/root-tools.ts +1 -1
- package/extensions/worktree/runtime.ts +5 -0
- package/lib/README.md +5 -3
- package/lib/config.ts +33 -9
- package/lib/format.ts +6 -0
- package/lib/requirements.ts +80 -1
- package/lib/tui/columns.ts +17 -0
- package/lib/tui/tool.ts +6 -2
- package/package.json +2 -1
- package/themes/bytefly-dark.json +2 -3
- 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
|
-
|
|
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.**
|
|
236
|
-
|
|
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
|
|
257
|
-
| Lifecycle |
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
182
|
-
const outcome = await spawnAndCollect(
|
|
183
|
-
invocation.command,
|
|
184
|
-
invocation.args,
|
|
185
|
-
runCwd,
|
|
186
|
-
|
|
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);
|