pi-subagents 0.17.1 → 0.17.3
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/CHANGELOG.md +22 -0
- package/README.md +47 -1
- package/agents/oracle-executor.md +46 -0
- package/agents/oracle.md +70 -0
- package/async-execution.ts +38 -13
- package/async-job-tracker.ts +36 -18
- package/execution.ts +58 -2
- package/index.ts +1 -1
- package/notify.ts +2 -1
- package/package.json +5 -3
- package/post-exit-stdio-guard.ts +85 -0
- package/schemas.ts +1 -1
- package/session-tokens.ts +48 -0
- package/subagent-executor.ts +35 -29
- package/subagent-runner.ts +72 -42
- package/top-level-async.ts +13 -0
- package/types.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.17.3] - 2026-04-22
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Added builtin `oracle` and `oracle-executor` agents for the `main -> oracle -> main decision -> oracle-executor` workflow, plus README guidance for invoking the oracle pair with forked context.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Migrated extension tool schemas from `@sinclair/typebox` to `typebox` 1.x so packaged installs follow Pi's current extension runtime contract.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- Moved TypeBox from `peerDependencies` to a real `dependencies` entry so `pi install` production installs keep the schema package available at runtime.
|
|
15
|
+
|
|
16
|
+
## [0.17.2] - 2026-04-21
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- Added `forceTopLevelAsync` so depth-0 delegated runs can be forced into background mode with `clarify: false`, while nested runs keep their existing behavior.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- Background completion notifications now render `(no output)` instead of a blank body when a completion summary is empty or whitespace-only.
|
|
23
|
+
- Async status and token reporting now rerender more reliably when cleanup state changes, read token usage from `message.usage`, and prefer the newest session file when multiple async session files exist.
|
|
24
|
+
- Async/background startup now fails fast for invalid resolved `cwd` values and spawn failures instead of reporting false launch success.
|
|
25
|
+
- Sync and async runner paths now drain stuck child processes in bounded time, covering both post-exit stdio holders and children that emit a final message but never exit.
|
|
26
|
+
|
|
5
27
|
## [0.17.1] - 2026-04-20
|
|
6
28
|
|
|
7
29
|
### Added
|
package/README.md
CHANGED
|
@@ -46,7 +46,10 @@ Project discovery also reads legacy `.agents/{name}.md` files. If both `.agents/
|
|
|
46
46
|
|
|
47
47
|
Use `agentScope` parameter to control discovery: `"user"`, `"project"`, or `"both"` (default; project takes priority).
|
|
48
48
|
|
|
49
|
-
**Builtin agents:** The extension ships with ready-to-use agents — `scout`, `planner`, `worker`, `reviewer`, `context-builder`, `researcher`, and `
|
|
49
|
+
**Builtin agents:** The extension ships with ready-to-use agents — `scout`, `planner`, `worker`, `reviewer`, `context-builder`, `researcher`, `delegate`, `oracle`, and `oracle-executor`. They load at lowest priority so any user or project agent with the same name overrides them.
|
|
50
|
+
|
|
51
|
+
- `oracle` is a high-context advisory reviewer on `openai-codex/gpt-5.4:high`. It critiques direction, surfaces hidden risks, and proposes a concrete execution prompt, but it does not edit files directly.
|
|
52
|
+
- `oracle-executor` is a high-context implementation escalator on `openai-codex/gpt-5.3-codex:high`. It is intended to run only after the main agent explicitly approves a course of action.
|
|
50
53
|
|
|
51
54
|
You can also override selected builtin fields without copying the whole agent. Builtin overrides are stored in settings under `subagents.agentOverrides`:
|
|
52
55
|
|
|
@@ -301,6 +304,22 @@ You can combine `--fork` and `--bg` in any order:
|
|
|
301
304
|
/run reviewer "review this diff" --bg --fork
|
|
302
305
|
```
|
|
303
306
|
|
|
307
|
+
For the oracle pair, the intended default control loop is:
|
|
308
|
+
|
|
309
|
+
1. main agent invokes `oracle` with forked context
|
|
310
|
+
2. `oracle` returns diagnosis, recommendation, risks, and a suggested execution prompt
|
|
311
|
+
3. main agent decides whether to accept that direction
|
|
312
|
+
4. only then does main agent invoke `oracle-executor`
|
|
313
|
+
|
|
314
|
+
Example:
|
|
315
|
+
|
|
316
|
+
```text
|
|
317
|
+
/run oracle "Review my current direction, challenge assumptions, and propose the best next move." --fork
|
|
318
|
+
/run oracle-executor "Implement the approved approach: ..." --fork
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
This keeps decision authority in the main thread while still giving you a stronger review/escalation path.
|
|
322
|
+
|
|
304
323
|
## Agents Manager
|
|
305
324
|
|
|
306
325
|
Press **Ctrl+Shift+A** or type `/agents` to open the Agents Manager overlay — a TUI for browsing, viewing, editing, creating, and launching agents and chains.
|
|
@@ -855,6 +874,33 @@ This aggregated output becomes `{previous}` for the next step.
|
|
|
855
874
|
|
|
856
875
|
`pi-subagents` reads optional JSON config from `~/.pi/agent/extensions/subagent/config.json`.
|
|
857
876
|
|
|
877
|
+
### `asyncByDefault`
|
|
878
|
+
|
|
879
|
+
`asyncByDefault` makes top-level subagent calls use background execution when the request does not explicitly set `async`.
|
|
880
|
+
|
|
881
|
+
```json
|
|
882
|
+
{
|
|
883
|
+
"asyncByDefault": true
|
|
884
|
+
}
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
This only changes the default. Callers can still force foreground execution by setting `async: false` unless `forceTopLevelAsync` is also enabled.
|
|
888
|
+
|
|
889
|
+
### `forceTopLevelAsync`
|
|
890
|
+
|
|
891
|
+
`forceTopLevelAsync` forces depth-0 subagent execution into background mode. This is useful for automation setups that never want the top-level orchestrator to block on child runs.
|
|
892
|
+
|
|
893
|
+
```json
|
|
894
|
+
{
|
|
895
|
+
"forceTopLevelAsync": true
|
|
896
|
+
}
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
When enabled:
|
|
900
|
+
- top-level single, parallel, and chain runs are forced to `async: true`
|
|
901
|
+
- top-level clarify UI is bypassed by forcing `clarify: false`
|
|
902
|
+
- nested subagent calls still follow their own inherited depth and async settings
|
|
903
|
+
|
|
858
904
|
### `parallel`
|
|
859
905
|
|
|
860
906
|
`parallel` controls top-level `tasks` mode defaults and limits.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: oracle-executor
|
|
3
|
+
description: High-context implementation agent that executes only after main-agent approval
|
|
4
|
+
tools: read, grep, find, ls, bash, edit, write, intercom
|
|
5
|
+
model: openai-codex/gpt-5.3-codex
|
|
6
|
+
thinking: high
|
|
7
|
+
systemPromptMode: replace
|
|
8
|
+
inheritProjectContext: true
|
|
9
|
+
inheritSkills: false
|
|
10
|
+
defaultProgress: true
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
You are `oracle-executor`: a high-context implementation subagent.
|
|
14
|
+
|
|
15
|
+
You are invoked after the main agent has already decided on a direction, often based on advice from `oracle`. You are allowed to act, but you are not the owner of product or architecture decisions. The main agent remains the final decision authority.
|
|
16
|
+
|
|
17
|
+
If runtime bridge instructions are present, use them as the source of truth for which orchestrator session to contact and how to coordinate. Use `intercom({ action: "ask", ... })` when a new decision is needed to continue safely. Use `intercom({ action: "send", ... })` for concise progress or completion handoffs when that extra coordination is helpful.
|
|
18
|
+
|
|
19
|
+
First understand the inherited context and the explicit task. Then execute carefully and minimally.
|
|
20
|
+
|
|
21
|
+
If the task appears to require a new decision that has not clearly been approved by the main agent, stop and ask via `intercom` instead of making that decision yourself.
|
|
22
|
+
|
|
23
|
+
Default responsibilities:
|
|
24
|
+
- validate the approved direction against the actual code
|
|
25
|
+
- implement the approved change with minimal, coherent edits
|
|
26
|
+
- verify the result with appropriate checks
|
|
27
|
+
- report back clearly, including risks and next steps
|
|
28
|
+
|
|
29
|
+
Working rules:
|
|
30
|
+
- Follow existing patterns in the codebase.
|
|
31
|
+
- Prefer narrow, correct changes over broad rewrites.
|
|
32
|
+
- Do not add speculative scaffolding or future-proofing unless explicitly required.
|
|
33
|
+
- Use `bash` for inspection, validation, and relevant tests.
|
|
34
|
+
- Escalate uncertainty to the main agent with `intercom` when needed.
|
|
35
|
+
- If implementation reveals an unapproved product or architecture choice, pause and ask via `intercom` instead of deciding it yourself.
|
|
36
|
+
- If you send a completion handoff through `intercom`, keep it short and still return the full structured task result normally.
|
|
37
|
+
- Keep `progress.md` accurate when asked to maintain it.
|
|
38
|
+
- Do not silently change the scope of the task.
|
|
39
|
+
|
|
40
|
+
Your completion handoff should follow this exact shape:
|
|
41
|
+
|
|
42
|
+
Implemented X.
|
|
43
|
+
Changed files: Y.
|
|
44
|
+
Validation: Z.
|
|
45
|
+
Open risks/questions: R.
|
|
46
|
+
Recommended next step: N.
|
package/agents/oracle.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: oracle
|
|
3
|
+
description: High-context decision-consistency oracle that protects inherited state and prevents drift
|
|
4
|
+
tools: read, grep, find, ls, bash, intercom
|
|
5
|
+
model: openai-codex/gpt-5.4
|
|
6
|
+
thinking: high
|
|
7
|
+
systemPromptMode: replace
|
|
8
|
+
inheritProjectContext: true
|
|
9
|
+
inheritSkills: false
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
You are the oracle: a high-context decision-consistency subagent.
|
|
13
|
+
|
|
14
|
+
Your primary job is to prevent the main agent from making hidden, conflicting, or inconsistent decisions by treating the inherited forked context as the authoritative contract. You are not the primary executor. You do not silently become a second decision-maker.
|
|
15
|
+
|
|
16
|
+
Before you do anything else, reconstruct the key inherited decisions, constraints, and open questions from the forked conversation, codebase state, and task. Those decisions form your baseline contract. Preserve them unless there is strong evidence they should be overturned.
|
|
17
|
+
|
|
18
|
+
If you need clarification from the main agent, use `intercom`. If runtime bridge instructions are present, use them as the source of truth for which orchestrator session to contact and how to phrase coordination.
|
|
19
|
+
|
|
20
|
+
Use `intercom({ action: "ask", ... })` when you need a real decision or clarification. Use `intercom({ action: "send", ... })` only for brief progress or handoff messages when that extra coordination is actually useful. Keep intercom traffic tight and purposeful. Do not narrate your whole review through intercom.
|
|
21
|
+
|
|
22
|
+
Core responsibilities:
|
|
23
|
+
- reconstruct inherited decisions, constraints, and open questions from the context
|
|
24
|
+
- identify drift between the current trajectory and those inherited decisions
|
|
25
|
+
- surface contradictions and hidden assumptions the main agent may be missing
|
|
26
|
+
- call out when a proposed move conflicts with an earlier decision or constraint
|
|
27
|
+
- protect consistency over novelty; prefer the path that honors existing decisions unless the context clearly supports a pivot
|
|
28
|
+
- when you do recommend a pivot, explain exactly which prior assumption or decision should be revised and why
|
|
29
|
+
|
|
30
|
+
What you do not do by default:
|
|
31
|
+
- do not edit files or write code
|
|
32
|
+
- do not propose additional parallel decision-makers or new subagent trees unless explicitly asked
|
|
33
|
+
- do not assume an `oracle-executor` handoff is the default outcome
|
|
34
|
+
- do not propose broad pivots unless the context clearly supports them
|
|
35
|
+
- do not continue the user conversation directly
|
|
36
|
+
|
|
37
|
+
Working rules:
|
|
38
|
+
- Use `bash` only for inspection, verification, or read-only analysis.
|
|
39
|
+
- If information is missing and it matters, ask the main agent via `intercom` instead of guessing.
|
|
40
|
+
- If the answer depends on a decision the main agent has not made yet, stop and ask via `intercom` before continuing.
|
|
41
|
+
- Prefer narrow, specific corrections to the current path over rewriting the whole plan.
|
|
42
|
+
|
|
43
|
+
Your output should follow this shape. If no executor handoff is warranted, say so plainly.
|
|
44
|
+
|
|
45
|
+
Inherited decisions:
|
|
46
|
+
- the key decisions, constraints, and assumptions already in play
|
|
47
|
+
|
|
48
|
+
Diagnosis:
|
|
49
|
+
- what is actually going on
|
|
50
|
+
- what the main agent may be missing
|
|
51
|
+
|
|
52
|
+
Drift / contradiction check:
|
|
53
|
+
- where the current trajectory conflicts with inherited decisions or constraints
|
|
54
|
+
- what assumptions have quietly changed
|
|
55
|
+
|
|
56
|
+
Recommendation:
|
|
57
|
+
- the best next move
|
|
58
|
+
- why it is the best move
|
|
59
|
+
- if recommending a pivot, which inherited decision is being revised and why
|
|
60
|
+
|
|
61
|
+
Risks:
|
|
62
|
+
- what could still go wrong
|
|
63
|
+
- what assumptions remain uncertain
|
|
64
|
+
|
|
65
|
+
Need from main agent:
|
|
66
|
+
- specific question or decision required before continuing, if any
|
|
67
|
+
|
|
68
|
+
Suggested execution prompt:
|
|
69
|
+
- a concrete prompt for `oracle-executor`, only if an executor handoff is actually warranted
|
|
70
|
+
- if no handoff is warranted, say so explicitly
|
package/async-execution.ts
CHANGED
|
@@ -114,22 +114,39 @@ export function isAsyncAvailable(): boolean {
|
|
|
114
114
|
/**
|
|
115
115
|
* Spawn the async runner process
|
|
116
116
|
*/
|
|
117
|
-
function spawnRunner(cfg: object, suffix: string, cwd: string): number
|
|
118
|
-
if (!jitiCliPath)
|
|
119
|
-
|
|
117
|
+
function spawnRunner(cfg: object, suffix: string, cwd: string): { pid?: number; error?: string } {
|
|
118
|
+
if (!jitiCliPath) {
|
|
119
|
+
return { error: "jiti for TypeScript execution could not be found" };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const cwdStats = fs.statSync(cwd);
|
|
124
|
+
if (!cwdStats.isDirectory()) {
|
|
125
|
+
return { error: `cwd is not a directory: ${cwd}` };
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
return { error: `cwd does not exist: ${cwd}` };
|
|
129
|
+
}
|
|
130
|
+
|
|
120
131
|
fs.mkdirSync(TEMP_ROOT_DIR, { recursive: true });
|
|
121
132
|
const cfgPath = getAsyncConfigPath(suffix);
|
|
122
133
|
fs.writeFileSync(cfgPath, JSON.stringify(cfg));
|
|
123
134
|
const runner = path.join(path.dirname(fileURLToPath(import.meta.url)), "subagent-runner.ts");
|
|
124
|
-
|
|
135
|
+
|
|
125
136
|
const proc = spawn(process.execPath, [jitiCliPath, runner, cfgPath], {
|
|
126
137
|
cwd,
|
|
127
138
|
detached: true,
|
|
128
139
|
stdio: "ignore",
|
|
129
140
|
windowsHide: true,
|
|
130
141
|
});
|
|
142
|
+
proc.on("error", (error) => {
|
|
143
|
+
console.error(`[pi-subagents] async spawn failed: ${error.message}`);
|
|
144
|
+
});
|
|
145
|
+
if (typeof proc.pid !== "number") {
|
|
146
|
+
return { error: `async runner did not produce a pid for cwd: ${cwd}` };
|
|
147
|
+
}
|
|
131
148
|
proc.unref();
|
|
132
|
-
return proc.pid;
|
|
149
|
+
return { pid: proc.pid };
|
|
133
150
|
}
|
|
134
151
|
|
|
135
152
|
function formatAsyncStartError(mode: "single" | "chain", message: string): AsyncExecutionResult {
|
|
@@ -260,9 +277,9 @@ export function executeAsyncChain(
|
|
|
260
277
|
return buildSeqStep(s as SequentialStep, nextSessionFile());
|
|
261
278
|
});
|
|
262
279
|
|
|
263
|
-
let
|
|
280
|
+
let spawnResult: { pid?: number; error?: string } = {};
|
|
264
281
|
try {
|
|
265
|
-
|
|
282
|
+
spawnResult = spawnRunner(
|
|
266
283
|
{
|
|
267
284
|
id,
|
|
268
285
|
steps,
|
|
@@ -289,14 +306,18 @@ export function executeAsyncChain(
|
|
|
289
306
|
return formatAsyncStartError("chain", `Failed to start async chain '${id}': ${message}`);
|
|
290
307
|
}
|
|
291
308
|
|
|
292
|
-
if (
|
|
309
|
+
if (spawnResult.error) {
|
|
310
|
+
return formatAsyncStartError("chain", `Failed to start async chain '${id}': ${spawnResult.error}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (spawnResult.pid) {
|
|
293
314
|
const firstStep = chain[0];
|
|
294
315
|
const firstAgents = isParallelStep(firstStep)
|
|
295
316
|
? firstStep.parallel.map((t) => t.agent)
|
|
296
317
|
: [(firstStep as SequentialStep).agent];
|
|
297
318
|
ctx.pi.events.emit("subagent:started", {
|
|
298
319
|
id,
|
|
299
|
-
pid,
|
|
320
|
+
pid: spawnResult.pid,
|
|
300
321
|
agent: firstAgents[0],
|
|
301
322
|
task: isParallelStep(firstStep)
|
|
302
323
|
? firstStep.parallel[0]?.task?.slice(0, 50)
|
|
@@ -368,9 +389,9 @@ export function executeAsyncSingle(
|
|
|
368
389
|
|
|
369
390
|
const outputPath = resolveSingleOutputPath(params.output, ctx.cwd, runnerCwd);
|
|
370
391
|
const taskWithOutputInstruction = injectSingleOutputInstruction(task, outputPath);
|
|
371
|
-
let
|
|
392
|
+
let spawnResult: { pid?: number; error?: string } = {};
|
|
372
393
|
try {
|
|
373
|
-
|
|
394
|
+
spawnResult = spawnRunner(
|
|
374
395
|
{
|
|
375
396
|
id,
|
|
376
397
|
steps: [
|
|
@@ -418,10 +439,14 @@ export function executeAsyncSingle(
|
|
|
418
439
|
return formatAsyncStartError("single", `Failed to start async run '${id}': ${message}`);
|
|
419
440
|
}
|
|
420
441
|
|
|
421
|
-
if (
|
|
442
|
+
if (spawnResult.error) {
|
|
443
|
+
return formatAsyncStartError("single", `Failed to start async run '${id}': ${spawnResult.error}`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (spawnResult.pid) {
|
|
422
447
|
ctx.pi.events.emit("subagent:started", {
|
|
423
448
|
id,
|
|
424
|
-
pid,
|
|
449
|
+
pid: spawnResult.pid,
|
|
425
450
|
agent,
|
|
426
451
|
task: task?.slice(0, 50),
|
|
427
452
|
cwd: runnerCwd,
|
package/async-job-tracker.ts
CHANGED
|
@@ -7,18 +7,42 @@ import {
|
|
|
7
7
|
} from "./types.ts";
|
|
8
8
|
import { readStatus } from "./utils.ts";
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
interface AsyncJobTrackerOptions {
|
|
11
|
+
completionRetentionMs?: number;
|
|
12
|
+
pollIntervalMs?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createAsyncJobTracker(state: SubagentState, asyncDirRoot: string, options: AsyncJobTrackerOptions = {}): {
|
|
11
16
|
ensurePoller: () => void;
|
|
12
17
|
handleStarted: (data: unknown) => void;
|
|
13
18
|
handleComplete: (data: unknown) => void;
|
|
14
19
|
resetJobs: (ctx?: ExtensionContext) => void;
|
|
15
20
|
} {
|
|
21
|
+
const completionRetentionMs = options.completionRetentionMs ?? 10000;
|
|
22
|
+
const pollIntervalMs = options.pollIntervalMs ?? POLL_INTERVAL_MS;
|
|
23
|
+
const rerenderWidget = (ctx: ExtensionContext, jobs = Array.from(state.asyncJobs.values())) => {
|
|
24
|
+
renderWidget(ctx, jobs);
|
|
25
|
+
ctx.ui.requestRender?.();
|
|
26
|
+
};
|
|
27
|
+
const scheduleCleanup = (asyncId: string) => {
|
|
28
|
+
const existingTimer = state.cleanupTimers.get(asyncId);
|
|
29
|
+
if (existingTimer) clearTimeout(existingTimer);
|
|
30
|
+
const timer = setTimeout(() => {
|
|
31
|
+
state.cleanupTimers.delete(asyncId);
|
|
32
|
+
state.asyncJobs.delete(asyncId);
|
|
33
|
+
if (state.lastUiContext) {
|
|
34
|
+
rerenderWidget(state.lastUiContext);
|
|
35
|
+
}
|
|
36
|
+
}, completionRetentionMs);
|
|
37
|
+
state.cleanupTimers.set(asyncId, timer);
|
|
38
|
+
};
|
|
39
|
+
|
|
16
40
|
const ensurePoller = () => {
|
|
17
41
|
if (state.poller) return;
|
|
18
42
|
state.poller = setInterval(() => {
|
|
19
43
|
if (!state.lastUiContext || !state.lastUiContext.hasUI) return;
|
|
20
44
|
if (state.asyncJobs.size === 0) {
|
|
21
|
-
|
|
45
|
+
rerenderWidget(state.lastUiContext, []);
|
|
22
46
|
if (state.poller) {
|
|
23
47
|
clearInterval(state.poller);
|
|
24
48
|
state.poller = null;
|
|
@@ -27,12 +51,10 @@ export function createAsyncJobTracker(state: SubagentState, asyncDirRoot: string
|
|
|
27
51
|
}
|
|
28
52
|
|
|
29
53
|
for (const job of state.asyncJobs.values()) {
|
|
30
|
-
if (job.status === "complete" || job.status === "failed") {
|
|
31
|
-
continue;
|
|
32
|
-
}
|
|
33
54
|
try {
|
|
34
55
|
const status = readStatus(job.asyncDir);
|
|
35
56
|
if (status) {
|
|
57
|
+
const previousStatus = job.status;
|
|
36
58
|
job.status = status.state;
|
|
37
59
|
job.mode = status.mode;
|
|
38
60
|
job.currentStep = status.currentStep ?? job.currentStep;
|
|
@@ -46,6 +68,9 @@ export function createAsyncJobTracker(state: SubagentState, asyncDirRoot: string
|
|
|
46
68
|
job.outputFile = status.outputFile ?? job.outputFile;
|
|
47
69
|
job.totalTokens = status.totalTokens ?? job.totalTokens;
|
|
48
70
|
job.sessionFile = status.sessionFile ?? job.sessionFile;
|
|
71
|
+
if ((job.status === "complete" || job.status === "failed") && previousStatus !== job.status) {
|
|
72
|
+
scheduleCleanup(job.asyncId);
|
|
73
|
+
}
|
|
49
74
|
continue;
|
|
50
75
|
}
|
|
51
76
|
job.status = job.status === "queued" ? "running" : job.status;
|
|
@@ -57,8 +82,8 @@ export function createAsyncJobTracker(state: SubagentState, asyncDirRoot: string
|
|
|
57
82
|
}
|
|
58
83
|
}
|
|
59
84
|
|
|
60
|
-
|
|
61
|
-
},
|
|
85
|
+
rerenderWidget(state.lastUiContext);
|
|
86
|
+
}, pollIntervalMs);
|
|
62
87
|
state.poller.unref?.();
|
|
63
88
|
};
|
|
64
89
|
|
|
@@ -84,7 +109,7 @@ export function createAsyncJobTracker(state: SubagentState, asyncDirRoot: string
|
|
|
84
109
|
updatedAt: now,
|
|
85
110
|
});
|
|
86
111
|
if (state.lastUiContext) {
|
|
87
|
-
|
|
112
|
+
rerenderWidget(state.lastUiContext);
|
|
88
113
|
ensurePoller();
|
|
89
114
|
}
|
|
90
115
|
};
|
|
@@ -100,16 +125,9 @@ export function createAsyncJobTracker(state: SubagentState, asyncDirRoot: string
|
|
|
100
125
|
if (result.asyncDir) job.asyncDir = result.asyncDir;
|
|
101
126
|
}
|
|
102
127
|
if (state.lastUiContext) {
|
|
103
|
-
|
|
128
|
+
rerenderWidget(state.lastUiContext);
|
|
104
129
|
}
|
|
105
|
-
|
|
106
|
-
state.cleanupTimers.delete(asyncId);
|
|
107
|
-
state.asyncJobs.delete(asyncId);
|
|
108
|
-
if (state.lastUiContext) {
|
|
109
|
-
renderWidget(state.lastUiContext, Array.from(state.asyncJobs.values()));
|
|
110
|
-
}
|
|
111
|
-
}, 10000);
|
|
112
|
-
state.cleanupTimers.set(asyncId, timer);
|
|
130
|
+
scheduleCleanup(asyncId);
|
|
113
131
|
};
|
|
114
132
|
|
|
115
133
|
const resetJobs = (ctx?: ExtensionContext) => {
|
|
@@ -121,7 +139,7 @@ export function createAsyncJobTracker(state: SubagentState, asyncDirRoot: string
|
|
|
121
139
|
state.resultFileCoalescer.clear();
|
|
122
140
|
if (ctx?.hasUI) {
|
|
123
141
|
state.lastUiContext = ctx;
|
|
124
|
-
|
|
142
|
+
rerenderWidget(ctx, []);
|
|
125
143
|
}
|
|
126
144
|
};
|
|
127
145
|
|
package/execution.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
import { buildSkillInjection, resolveSkillsWithFallback } from "./skills.ts";
|
|
35
35
|
import { getPiSpawnCommand } from "./pi-spawn.ts";
|
|
36
36
|
import { createJsonlWriter } from "./jsonl-writer.ts";
|
|
37
|
+
import { attachPostExitStdioGuard, trySignalChild } from "./post-exit-stdio-guard.ts";
|
|
37
38
|
import { applyThinkingSuffix, buildPiArgs, cleanupTempDir } from "./pi-args.ts";
|
|
38
39
|
import { captureSingleOutputSnapshot, resolveSingleOutput, type SingleOutputSnapshot } from "./single-output.ts";
|
|
39
40
|
import {
|
|
@@ -188,6 +189,42 @@ async function runSingleAttempt(
|
|
|
188
189
|
finish(-2);
|
|
189
190
|
};
|
|
190
191
|
|
|
192
|
+
// If the child emits its final assistant message but never exits,
|
|
193
|
+
// start a bounded drain window and force termination if needed.
|
|
194
|
+
const FINAL_DRAIN_MS = 5000;
|
|
195
|
+
const HARD_KILL_MS = 3000;
|
|
196
|
+
let childExited = false;
|
|
197
|
+
let forcedTerminationSignal = false;
|
|
198
|
+
let finalDrainTimer: NodeJS.Timeout | undefined;
|
|
199
|
+
let finalHardKillTimer: NodeJS.Timeout | undefined;
|
|
200
|
+
const clearFinalDrainTimers = () => {
|
|
201
|
+
if (finalDrainTimer) {
|
|
202
|
+
clearTimeout(finalDrainTimer);
|
|
203
|
+
finalDrainTimer = undefined;
|
|
204
|
+
}
|
|
205
|
+
if (finalHardKillTimer) {
|
|
206
|
+
clearTimeout(finalHardKillTimer);
|
|
207
|
+
finalHardKillTimer = undefined;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
const startFinalDrain = () => {
|
|
211
|
+
if (childExited || finalDrainTimer || settled || processClosed || detached) return;
|
|
212
|
+
finalDrainTimer = setTimeout(() => {
|
|
213
|
+
if (settled || processClosed || detached) return;
|
|
214
|
+
const termSent = trySignalChild(proc, "SIGTERM");
|
|
215
|
+
if (!termSent) return;
|
|
216
|
+
forcedTerminationSignal = true;
|
|
217
|
+
result.error = result.error
|
|
218
|
+
?? `Subagent process did not exit within ${FINAL_DRAIN_MS}ms after its final message. Forcing termination.`;
|
|
219
|
+
finalHardKillTimer = setTimeout(() => {
|
|
220
|
+
if (settled || processClosed || detached) return;
|
|
221
|
+
forcedTerminationSignal = trySignalChild(proc, "SIGKILL") || forcedTerminationSignal;
|
|
222
|
+
}, HARD_KILL_MS);
|
|
223
|
+
finalHardKillTimer.unref?.();
|
|
224
|
+
}, FINAL_DRAIN_MS);
|
|
225
|
+
finalDrainTimer.unref?.();
|
|
226
|
+
};
|
|
227
|
+
|
|
191
228
|
const unsubscribeIntercomDetach = options.intercomEvents?.on?.(INTERCOM_DETACH_REQUEST_EVENT, (payload) => {
|
|
192
229
|
if (!options.allowIntercomDetach || detached || processClosed) return;
|
|
193
230
|
if (!payload || typeof payload !== "object") return;
|
|
@@ -202,6 +239,8 @@ async function runSingleAttempt(
|
|
|
202
239
|
const finish = (code: number) => {
|
|
203
240
|
if (settled) return;
|
|
204
241
|
settled = true;
|
|
242
|
+
clearFinalDrainTimers();
|
|
243
|
+
clearStdioGuard();
|
|
205
244
|
unsubscribeIntercomDetach?.();
|
|
206
245
|
removeAbortListener?.();
|
|
207
246
|
resolve(code);
|
|
@@ -279,6 +318,13 @@ async function runSingleAttempt(
|
|
|
279
318
|
if (!result.model && evt.message.model) result.model = evt.message.model;
|
|
280
319
|
if (evt.message.errorMessage) result.error = evt.message.errorMessage;
|
|
281
320
|
appendRecentOutput(progress, extractTextFromContent(evt.message.content).split("\n").slice(-10));
|
|
321
|
+
// Final assistant message: start the exit drain window.
|
|
322
|
+
const stopReason = (evt.message as { stopReason?: string }).stopReason;
|
|
323
|
+
const hasToolCall = Array.isArray(evt.message.content)
|
|
324
|
+
&& evt.message.content.some((part) => (part as { type?: string }).type === "toolCall");
|
|
325
|
+
if (stopReason === "stop" && !hasToolCall) {
|
|
326
|
+
startFinalDrain();
|
|
327
|
+
}
|
|
282
328
|
}
|
|
283
329
|
fireUpdate();
|
|
284
330
|
}
|
|
@@ -292,6 +338,7 @@ async function runSingleAttempt(
|
|
|
292
338
|
|
|
293
339
|
let stderrBuf = "";
|
|
294
340
|
|
|
341
|
+
const clearStdioGuard = attachPostExitStdioGuard(proc, { idleMs: 2000, hardMs: 8000 });
|
|
295
342
|
proc.stdout.on("data", (d) => {
|
|
296
343
|
buf += d.toString();
|
|
297
344
|
const lines = buf.split("\n");
|
|
@@ -301,7 +348,13 @@ async function runSingleAttempt(
|
|
|
301
348
|
proc.stderr.on("data", (d) => {
|
|
302
349
|
stderrBuf += d.toString();
|
|
303
350
|
});
|
|
304
|
-
proc.on("
|
|
351
|
+
proc.on("exit", () => {
|
|
352
|
+
childExited = true;
|
|
353
|
+
clearFinalDrainTimers();
|
|
354
|
+
});
|
|
355
|
+
proc.on("close", (code, signal) => {
|
|
356
|
+
clearFinalDrainTimers();
|
|
357
|
+
clearStdioGuard();
|
|
305
358
|
void jsonlWriter.close().catch(() => {
|
|
306
359
|
// JSONL artifact flush is best effort.
|
|
307
360
|
});
|
|
@@ -315,9 +368,12 @@ async function runSingleAttempt(
|
|
|
315
368
|
if (code !== 0 && stderrBuf.trim() && !result.error) {
|
|
316
369
|
result.error = stderrBuf.trim();
|
|
317
370
|
}
|
|
318
|
-
|
|
371
|
+
const finalCode = forcedTerminationSignal || signal ? (code ?? 1) : (code ?? 0);
|
|
372
|
+
finish(finalCode);
|
|
319
373
|
});
|
|
320
374
|
proc.on("error", (error) => {
|
|
375
|
+
clearFinalDrainTimers();
|
|
376
|
+
clearStdioGuard();
|
|
321
377
|
void jsonlWriter.close().catch(() => {
|
|
322
378
|
// JSONL artifact flush is best effort.
|
|
323
379
|
});
|
package/index.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Toggle: async parameter (default: false, configurable via config.json)
|
|
10
10
|
*
|
|
11
11
|
* Config file: ~/.pi/agent/extensions/subagent/config.json
|
|
12
|
-
* { "asyncByDefault": true, "maxSubagentDepth": 1, "intercomBridge": { "mode": "always", "instructionFile": "./intercom-bridge.md" }, "worktreeSetupHook": "./scripts/setup-worktree.mjs" }
|
|
12
|
+
* { "asyncByDefault": true, "forceTopLevelAsync": true, "maxSubagentDepth": 1, "intercomBridge": { "mode": "always", "instructionFile": "./intercom-bridge.md" }, "worktreeSetupHook": "./scripts/setup-worktree.mjs" }
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import * as fs from "node:fs";
|
package/notify.ts
CHANGED
|
@@ -54,10 +54,11 @@ export default function registerSubagentNotify(pi: ExtensionAPI): void {
|
|
|
54
54
|
extra.push(`Session file: ${result.sessionFile}`);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
const summary = result.summary.trim() ? result.summary : "(no output)";
|
|
57
58
|
const content = [
|
|
58
59
|
`Background task ${status}: **${agent}**${taskInfo}`,
|
|
59
60
|
"",
|
|
60
|
-
|
|
61
|
+
summary,
|
|
61
62
|
extra.length ? "" : undefined,
|
|
62
63
|
extra.length ? extra.join("\n") : undefined,
|
|
63
64
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-subagents",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.3",
|
|
4
4
|
"description": "Pi extension for delegating tasks to subagents with chains, parallel execution, and TUI clarification",
|
|
5
5
|
"author": "Nico Bailon",
|
|
6
6
|
"license": "MIT",
|
|
@@ -50,8 +50,10 @@
|
|
|
50
50
|
"@mariozechner/pi-agent-core": "*",
|
|
51
51
|
"@mariozechner/pi-ai": "*",
|
|
52
52
|
"@mariozechner/pi-coding-agent": "*",
|
|
53
|
-
"@mariozechner/pi-tui": "*"
|
|
54
|
-
|
|
53
|
+
"@mariozechner/pi-tui": "*"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"typebox": "^1.1.24"
|
|
55
57
|
},
|
|
56
58
|
"devDependencies": {
|
|
57
59
|
"@marcfargas/pi-test-harness": "^0.5.0",
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
interface PostExitStdioGuardOptions {
|
|
4
|
+
idleMs: number;
|
|
5
|
+
hardMs: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface ChildWithPipedStdio {
|
|
9
|
+
stdout: ChildProcess["stdout"];
|
|
10
|
+
stderr: ChildProcess["stderr"];
|
|
11
|
+
on: ChildProcess["on"];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ChildWithKill {
|
|
15
|
+
kill(signal?: NodeJS.Signals | number): boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function trySignalChild(child: ChildWithKill, signal: NodeJS.Signals): boolean {
|
|
19
|
+
try {
|
|
20
|
+
return child.kill(signal);
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function attachPostExitStdioGuard(
|
|
27
|
+
child: ChildWithPipedStdio,
|
|
28
|
+
options: PostExitStdioGuardOptions,
|
|
29
|
+
): () => void {
|
|
30
|
+
const { idleMs, hardMs } = options;
|
|
31
|
+
let exited = false;
|
|
32
|
+
let stdoutEnded = false;
|
|
33
|
+
let stderrEnded = false;
|
|
34
|
+
let idleTimer: NodeJS.Timeout | undefined;
|
|
35
|
+
let hardTimer: NodeJS.Timeout | undefined;
|
|
36
|
+
|
|
37
|
+
const destroyUnendedStdio = () => {
|
|
38
|
+
if (!stdoutEnded) {
|
|
39
|
+
try { child.stdout?.destroy(); } catch {}
|
|
40
|
+
}
|
|
41
|
+
if (!stderrEnded) {
|
|
42
|
+
try { child.stderr?.destroy(); } catch {}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const clearTimers = () => {
|
|
47
|
+
if (idleTimer) {
|
|
48
|
+
clearTimeout(idleTimer);
|
|
49
|
+
idleTimer = undefined;
|
|
50
|
+
}
|
|
51
|
+
if (hardTimer) {
|
|
52
|
+
clearTimeout(hardTimer);
|
|
53
|
+
hardTimer = undefined;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const armIdleTimer = () => {
|
|
58
|
+
if (!exited) return;
|
|
59
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
60
|
+
idleTimer = setTimeout(destroyUnendedStdio, idleMs);
|
|
61
|
+
idleTimer.unref?.();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
child.stdout?.on("data", armIdleTimer);
|
|
65
|
+
child.stderr?.on("data", armIdleTimer);
|
|
66
|
+
child.stdout?.on("end", () => {
|
|
67
|
+
stdoutEnded = true;
|
|
68
|
+
if (stdoutEnded && stderrEnded) clearTimers();
|
|
69
|
+
});
|
|
70
|
+
child.stderr?.on("end", () => {
|
|
71
|
+
stderrEnded = true;
|
|
72
|
+
if (stdoutEnded && stderrEnded) clearTimers();
|
|
73
|
+
});
|
|
74
|
+
child.on("exit", () => {
|
|
75
|
+
exited = true;
|
|
76
|
+
armIdleTimer();
|
|
77
|
+
if (hardTimer) return;
|
|
78
|
+
hardTimer = setTimeout(destroyUnendedStdio, hardMs);
|
|
79
|
+
hardTimer.unref?.();
|
|
80
|
+
});
|
|
81
|
+
child.on("close", clearTimers);
|
|
82
|
+
child.on("error", clearTimers);
|
|
83
|
+
|
|
84
|
+
return clearTimers;
|
|
85
|
+
}
|
package/schemas.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* TypeBox schemas for subagent tool parameters
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { Type } from "
|
|
5
|
+
import { Type } from "typebox";
|
|
6
6
|
|
|
7
7
|
// Note: Using Type.Any() for Google API compatibility (doesn't support anyOf)
|
|
8
8
|
const SkillOverride = Type.Any({ description: "Skill name(s) to inject (comma-separated), array of strings, or boolean (false disables, true uses default)" });
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface TokenUsage {
|
|
5
|
+
input: number;
|
|
6
|
+
output: number;
|
|
7
|
+
total: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function findLatestSessionFile(sessionDir: string): string | null {
|
|
11
|
+
try {
|
|
12
|
+
const files = fs.readdirSync(sessionDir)
|
|
13
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
14
|
+
.map((f) => path.join(sessionDir, f));
|
|
15
|
+
if (files.length === 0) return null;
|
|
16
|
+
files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
|
17
|
+
return files[0] ?? null;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseSessionTokens(sessionDir: string): TokenUsage | null {
|
|
24
|
+
const sessionFile = findLatestSessionFile(sessionDir);
|
|
25
|
+
if (!sessionFile) return null;
|
|
26
|
+
try {
|
|
27
|
+
const content = fs.readFileSync(sessionFile, "utf-8");
|
|
28
|
+
let input = 0;
|
|
29
|
+
let output = 0;
|
|
30
|
+
for (const line of content.split("\n")) {
|
|
31
|
+
if (!line.trim()) continue;
|
|
32
|
+
try {
|
|
33
|
+
const entry = JSON.parse(line);
|
|
34
|
+
const usage = entry.usage ?? entry.message?.usage;
|
|
35
|
+
if (usage) {
|
|
36
|
+
input += usage.inputTokens ?? usage.input ?? 0;
|
|
37
|
+
output += usage.outputTokens ?? usage.output ?? 0;
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// Ignore malformed lines while scanning usage entries.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { input, output, total: input + output };
|
|
44
|
+
} catch {
|
|
45
|
+
// Usage extraction should not fail the run.
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
package/subagent-executor.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { createForkContextResolver } from "./fork-context.ts";
|
|
|
26
26
|
import { applyIntercomBridgeToAgent, resolveIntercomBridge, resolveIntercomSessionTarget } from "./intercom-bridge.ts";
|
|
27
27
|
import { finalizeSingleOutput, injectSingleOutputInstruction, resolveSingleOutputPath } from "./single-output.ts";
|
|
28
28
|
import { compactForegroundDetails, getSingleResultOutput, mapConcurrent, resolveChildCwd } from "./utils.ts";
|
|
29
|
+
import { applyForceTopLevelAsyncOverride } from "./top-level-async.ts";
|
|
29
30
|
import {
|
|
30
31
|
cleanupWorktrees,
|
|
31
32
|
createWorktrees,
|
|
@@ -1209,32 +1210,38 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
|
|
|
1209
1210
|
if (normalized.error) return normalized.error;
|
|
1210
1211
|
const normalizedParams = normalized.params!;
|
|
1211
1212
|
|
|
1212
|
-
const
|
|
1213
|
-
|
|
1213
|
+
const effectiveParams = applyForceTopLevelAsyncOverride(
|
|
1214
|
+
normalizedParams,
|
|
1215
|
+
depth,
|
|
1216
|
+
deps.config.forceTopLevelAsync === true,
|
|
1217
|
+
);
|
|
1218
|
+
|
|
1219
|
+
const scope: AgentScope = resolveExecutionAgentScope(effectiveParams.agentScope);
|
|
1220
|
+
const effectiveCwd = effectiveParams.cwd ?? ctx.cwd;
|
|
1214
1221
|
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? null;
|
|
1215
1222
|
deps.state.currentSessionId = parentSessionFile ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1216
1223
|
const discoveredAgents = deps.discoverAgents(effectiveCwd, scope).agents;
|
|
1217
1224
|
const sessionName = resolveIntercomSessionTarget(deps.pi.getSessionName(), ctx.sessionManager.getSessionId());
|
|
1218
1225
|
const intercomBridge = resolveIntercomBridge({
|
|
1219
1226
|
config: deps.config.intercomBridge,
|
|
1220
|
-
context:
|
|
1227
|
+
context: effectiveParams.context,
|
|
1221
1228
|
orchestratorTarget: sessionName,
|
|
1222
1229
|
});
|
|
1223
1230
|
const agents = intercomBridge.active
|
|
1224
1231
|
? discoveredAgents.map((agent) => applyIntercomBridgeToAgent(agent, intercomBridge))
|
|
1225
1232
|
: discoveredAgents;
|
|
1226
1233
|
const runId = randomUUID().slice(0, 8);
|
|
1227
|
-
const shareEnabled =
|
|
1228
|
-
const hasChain = (
|
|
1229
|
-
const hasTasks = (
|
|
1230
|
-
const hasSingle = Boolean(
|
|
1234
|
+
const shareEnabled = effectiveParams.share === true;
|
|
1235
|
+
const hasChain = (effectiveParams.chain?.length ?? 0) > 0;
|
|
1236
|
+
const hasTasks = (effectiveParams.tasks?.length ?? 0) > 0;
|
|
1237
|
+
const hasSingle = Boolean(effectiveParams.agent && effectiveParams.task);
|
|
1231
1238
|
const allowClarifyTaskPrompt = hasChain
|
|
1232
|
-
&&
|
|
1239
|
+
&& effectiveParams.clarify === true
|
|
1233
1240
|
&& ctx.hasUI
|
|
1234
|
-
&& !(
|
|
1241
|
+
&& !(effectiveParams.chain?.some(isParallelStep) ?? false);
|
|
1235
1242
|
|
|
1236
1243
|
const validationError = validateExecutionInput(
|
|
1237
|
-
|
|
1244
|
+
effectiveParams,
|
|
1238
1245
|
agents,
|
|
1239
1246
|
hasChain,
|
|
1240
1247
|
hasTasks,
|
|
@@ -1245,25 +1252,24 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
|
|
|
1245
1252
|
|
|
1246
1253
|
let sessionFileForIndex: (idx?: number) => string | undefined = () => undefined;
|
|
1247
1254
|
try {
|
|
1248
|
-
sessionFileForIndex = createForkContextResolver(ctx.sessionManager,
|
|
1255
|
+
sessionFileForIndex = createForkContextResolver(ctx.sessionManager, effectiveParams.context).sessionFileForIndex;
|
|
1249
1256
|
} catch (error) {
|
|
1250
|
-
return toExecutionErrorResult(
|
|
1257
|
+
return toExecutionErrorResult(effectiveParams, error);
|
|
1251
1258
|
}
|
|
1252
|
-
|
|
1253
|
-
const
|
|
1254
|
-
const backgroundRequestedWhileClarifying = hasTasks && requestedAsync && normalizedParams.clarify === true;
|
|
1259
|
+
const requestedAsync = effectiveParams.async ?? deps.asyncByDefault;
|
|
1260
|
+
const backgroundRequestedWhileClarifying = hasTasks && requestedAsync && effectiveParams.clarify === true;
|
|
1255
1261
|
const effectiveAsync = requestedAsync
|
|
1256
|
-
&& (hasChain ?
|
|
1262
|
+
&& (hasChain ? effectiveParams.clarify === false : effectiveParams.clarify !== true);
|
|
1257
1263
|
|
|
1258
1264
|
const artifactConfig: ArtifactConfig = {
|
|
1259
1265
|
...DEFAULT_ARTIFACT_CONFIG,
|
|
1260
|
-
enabled:
|
|
1266
|
+
enabled: effectiveParams.artifacts !== false,
|
|
1261
1267
|
};
|
|
1262
1268
|
const artifactsDir = effectiveAsync ? deps.tempArtifactsDir : getArtifactsDir(parentSessionFile);
|
|
1263
1269
|
|
|
1264
1270
|
let sessionRoot: string;
|
|
1265
|
-
if (
|
|
1266
|
-
sessionRoot = path.resolve(deps.expandTilde(
|
|
1271
|
+
if (effectiveParams.sessionDir) {
|
|
1272
|
+
sessionRoot = path.resolve(deps.expandTilde(effectiveParams.sessionDir));
|
|
1267
1273
|
} else {
|
|
1268
1274
|
const baseSessionRoot = deps.config.defaultSessionDir
|
|
1269
1275
|
? path.resolve(deps.expandTilde(deps.config.defaultSessionDir))
|
|
@@ -1275,7 +1281,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
|
|
|
1275
1281
|
} catch (error) {
|
|
1276
1282
|
const message = error instanceof Error ? error.message : String(error);
|
|
1277
1283
|
return toExecutionErrorResult(
|
|
1278
|
-
|
|
1284
|
+
effectiveParams,
|
|
1279
1285
|
new Error(`Failed to create session directory '${sessionRoot}': ${message}`),
|
|
1280
1286
|
);
|
|
1281
1287
|
}
|
|
@@ -1283,11 +1289,11 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
|
|
|
1283
1289
|
path.join(sessionRoot, `run-${idx ?? 0}`);
|
|
1284
1290
|
|
|
1285
1291
|
const onUpdateWithContext = onUpdate
|
|
1286
|
-
? (r: AgentToolResult<Details>) => onUpdate(withForkContext(r,
|
|
1292
|
+
? (r: AgentToolResult<Details>) => onUpdate(withForkContext(r, effectiveParams.context))
|
|
1287
1293
|
: undefined;
|
|
1288
1294
|
|
|
1289
1295
|
const execData: ExecutionContextData = {
|
|
1290
|
-
params:
|
|
1296
|
+
params: effectiveParams,
|
|
1291
1297
|
effectiveCwd,
|
|
1292
1298
|
ctx,
|
|
1293
1299
|
signal,
|
|
@@ -1306,18 +1312,18 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
|
|
|
1306
1312
|
|
|
1307
1313
|
try {
|
|
1308
1314
|
const asyncResult = runAsyncPath(execData, deps);
|
|
1309
|
-
if (asyncResult) return withForkContext(asyncResult,
|
|
1315
|
+
if (asyncResult) return withForkContext(asyncResult, effectiveParams.context);
|
|
1310
1316
|
|
|
1311
|
-
if (hasChain &&
|
|
1312
|
-
return withForkContext(await runChainPath(execData, deps),
|
|
1317
|
+
if (hasChain && effectiveParams.chain) {
|
|
1318
|
+
return withForkContext(await runChainPath(execData, deps), effectiveParams.context);
|
|
1313
1319
|
}
|
|
1314
1320
|
|
|
1315
|
-
if (hasTasks &&
|
|
1316
|
-
return withForkContext(await runParallelPath(execData, deps),
|
|
1321
|
+
if (hasTasks && effectiveParams.tasks) {
|
|
1322
|
+
return withForkContext(await runParallelPath(execData, deps), effectiveParams.context);
|
|
1317
1323
|
}
|
|
1318
1324
|
|
|
1319
1325
|
if (hasSingle) {
|
|
1320
|
-
return withForkContext(await runSinglePath(execData, deps),
|
|
1326
|
+
return withForkContext(await runSinglePath(execData, deps), effectiveParams.context);
|
|
1321
1327
|
}
|
|
1322
1328
|
} catch (error) {
|
|
1323
1329
|
return toExecutionErrorResult(normalizedParams, error);
|
|
@@ -1327,7 +1333,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
|
|
|
1327
1333
|
content: [{ type: "text", text: "Invalid params" }],
|
|
1328
1334
|
isError: true,
|
|
1329
1335
|
details: { mode: "single" as const, results: [] },
|
|
1330
|
-
},
|
|
1336
|
+
}, effectiveParams.context);
|
|
1331
1337
|
};
|
|
1332
1338
|
|
|
1333
1339
|
return { execute };
|
package/subagent-runner.ts
CHANGED
|
@@ -28,7 +28,9 @@ import {
|
|
|
28
28
|
} from "./parallel-utils.ts";
|
|
29
29
|
import { buildPiArgs, cleanupTempDir } from "./pi-args.ts";
|
|
30
30
|
import { formatModelAttemptNote, isRetryableModelFailure } from "./model-fallback.ts";
|
|
31
|
+
import { attachPostExitStdioGuard, trySignalChild } from "./post-exit-stdio-guard.ts";
|
|
31
32
|
import { detectSubagentError, extractTextFromContent, extractToolArgsPreview, getFinalOutput } from "./utils.ts";
|
|
33
|
+
import { parseSessionTokens, type TokenUsage } from "./session-tokens.ts";
|
|
32
34
|
import {
|
|
33
35
|
cleanupWorktrees,
|
|
34
36
|
createWorktrees,
|
|
@@ -89,38 +91,6 @@ function findLatestSessionFile(sessionDir: string): string | null {
|
|
|
89
91
|
}
|
|
90
92
|
}
|
|
91
93
|
|
|
92
|
-
interface TokenUsage {
|
|
93
|
-
input: number;
|
|
94
|
-
output: number;
|
|
95
|
-
total: number;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function parseSessionTokens(sessionDir: string): TokenUsage | null {
|
|
99
|
-
const sessionFile = findLatestSessionFile(sessionDir);
|
|
100
|
-
if (!sessionFile) return null;
|
|
101
|
-
try {
|
|
102
|
-
const content = fs.readFileSync(sessionFile, "utf-8");
|
|
103
|
-
let input = 0;
|
|
104
|
-
let output = 0;
|
|
105
|
-
for (const line of content.split("\n")) {
|
|
106
|
-
if (!line.trim()) continue;
|
|
107
|
-
try {
|
|
108
|
-
const entry = JSON.parse(line);
|
|
109
|
-
if (entry.usage) {
|
|
110
|
-
input += entry.usage.inputTokens ?? entry.usage.input ?? 0;
|
|
111
|
-
output += entry.usage.outputTokens ?? entry.usage.output ?? 0;
|
|
112
|
-
}
|
|
113
|
-
} catch {
|
|
114
|
-
// Ignore malformed lines while scanning usage entries.
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
return { input, output, total: input + output };
|
|
118
|
-
} catch {
|
|
119
|
-
// Usage extraction should not fail the run.
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
94
|
function emptyUsage(): Usage {
|
|
125
95
|
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
|
|
126
96
|
}
|
|
@@ -248,13 +218,18 @@ function runPiStreaming(
|
|
|
248
218
|
if (event.message.model) model = event.message.model;
|
|
249
219
|
if (event.message.errorMessage) error = event.message.errorMessage;
|
|
250
220
|
const eventUsage = event.message.usage;
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
221
|
+
if (eventUsage) {
|
|
222
|
+
usage.turns++;
|
|
223
|
+
usage.input += eventUsage.input ?? eventUsage.inputTokens ?? 0;
|
|
224
|
+
usage.output += eventUsage.output ?? eventUsage.outputTokens ?? 0;
|
|
225
|
+
usage.cacheRead += eventUsage.cacheRead ?? 0;
|
|
226
|
+
usage.cacheWrite += eventUsage.cacheWrite ?? 0;
|
|
227
|
+
usage.cost += eventUsage.cost?.total ?? 0;
|
|
228
|
+
}
|
|
229
|
+
const stopReason = (event.message as { stopReason?: string }).stopReason;
|
|
230
|
+
const hasToolCall = Array.isArray(event.message.content)
|
|
231
|
+
&& event.message.content.some((part) => (part as { type?: string }).type === "toolCall");
|
|
232
|
+
if (stopReason === "stop" && !hasToolCall) startFinalDrain();
|
|
258
233
|
}
|
|
259
234
|
};
|
|
260
235
|
|
|
@@ -271,6 +246,16 @@ function runPiStreaming(
|
|
|
271
246
|
}
|
|
272
247
|
};
|
|
273
248
|
|
|
249
|
+
// Guard both cases that can leave the parent waiting on `close` forever:
|
|
250
|
+
// a lingering stdio holder after `exit`, or a child that never exits.
|
|
251
|
+
const FINAL_DRAIN_MS = 5000;
|
|
252
|
+
const HARD_KILL_MS = 3000;
|
|
253
|
+
let childExited = false;
|
|
254
|
+
let forcedTerminationSignal = false;
|
|
255
|
+
let finalDrainTimer: NodeJS.Timeout | undefined;
|
|
256
|
+
let finalHardKillTimer: NodeJS.Timeout | undefined;
|
|
257
|
+
let settled = false;
|
|
258
|
+
const clearStdioGuard = attachPostExitStdioGuard(child, { idleMs: 2000, hardMs: 8000 });
|
|
274
259
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
275
260
|
const text = chunk.toString();
|
|
276
261
|
stdoutBuf += text;
|
|
@@ -282,16 +267,61 @@ function runPiStreaming(
|
|
|
282
267
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
283
268
|
processStderrText(chunk.toString());
|
|
284
269
|
});
|
|
285
|
-
|
|
286
|
-
|
|
270
|
+
const clearDrainTimers = () => {
|
|
271
|
+
if (finalDrainTimer) {
|
|
272
|
+
clearTimeout(finalDrainTimer);
|
|
273
|
+
finalDrainTimer = undefined;
|
|
274
|
+
}
|
|
275
|
+
if (finalHardKillTimer) {
|
|
276
|
+
clearTimeout(finalHardKillTimer);
|
|
277
|
+
finalHardKillTimer = undefined;
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
function startFinalDrain(): void {
|
|
281
|
+
if (childExited || finalDrainTimer || settled) return;
|
|
282
|
+
finalDrainTimer = setTimeout(() => {
|
|
283
|
+
if (settled) return;
|
|
284
|
+
const termSent = trySignalChild(child, "SIGTERM");
|
|
285
|
+
if (!termSent) return;
|
|
286
|
+
forcedTerminationSignal = true;
|
|
287
|
+
if (!error) {
|
|
288
|
+
error = `Subagent process did not exit within ${FINAL_DRAIN_MS}ms after its final message. Forcing termination.`;
|
|
289
|
+
}
|
|
290
|
+
finalHardKillTimer = setTimeout(() => {
|
|
291
|
+
if (settled) return;
|
|
292
|
+
forcedTerminationSignal = trySignalChild(child, "SIGKILL") || forcedTerminationSignal;
|
|
293
|
+
}, HARD_KILL_MS);
|
|
294
|
+
finalHardKillTimer.unref?.();
|
|
295
|
+
}, FINAL_DRAIN_MS);
|
|
296
|
+
finalDrainTimer.unref?.();
|
|
297
|
+
}
|
|
298
|
+
child.on("exit", () => {
|
|
299
|
+
childExited = true;
|
|
300
|
+
clearDrainTimers();
|
|
301
|
+
});
|
|
302
|
+
child.on("close", (exitCode, signal) => {
|
|
303
|
+
settled = true;
|
|
304
|
+
clearDrainTimers();
|
|
305
|
+
clearStdioGuard();
|
|
287
306
|
if (stdoutBuf.trim()) processStdoutLine(stdoutBuf);
|
|
288
307
|
if (stderrBuf.trim()) appendChildLine("subagent.child.stderr", stderrBuf);
|
|
289
308
|
outputStream.end();
|
|
290
309
|
const finalOutput = getFinalOutput(messages) || rawStdoutLines.join("\n").trim();
|
|
291
|
-
resolve({
|
|
310
|
+
resolve({
|
|
311
|
+
stderr,
|
|
312
|
+
exitCode: forcedTerminationSignal || signal ? (exitCode ?? 1) : exitCode,
|
|
313
|
+
messages,
|
|
314
|
+
usage,
|
|
315
|
+
model,
|
|
316
|
+
error,
|
|
317
|
+
finalOutput,
|
|
318
|
+
});
|
|
292
319
|
});
|
|
293
320
|
|
|
294
321
|
child.on("error", (spawnError) => {
|
|
322
|
+
settled = true;
|
|
323
|
+
clearDrainTimers();
|
|
324
|
+
clearStdioGuard();
|
|
295
325
|
outputStream.end();
|
|
296
326
|
const finalOutput = getFinalOutput(messages) || rawStdoutLines.join("\n").trim();
|
|
297
327
|
const spawnErrorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface AsyncOverrideParams {
|
|
2
|
+
async?: boolean;
|
|
3
|
+
clarify?: boolean;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function applyForceTopLevelAsyncOverride<T extends AsyncOverrideParams>(
|
|
7
|
+
params: T,
|
|
8
|
+
depth: number,
|
|
9
|
+
forceTopLevelAsync: boolean,
|
|
10
|
+
): T {
|
|
11
|
+
if (!(depth === 0 && forceTopLevelAsync)) return params;
|
|
12
|
+
return { ...params, async: true, clarify: false };
|
|
13
|
+
}
|
package/types.ts
CHANGED