pi-subagents 0.17.0 → 0.17.2
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 +25 -0
- package/README.md +62 -4
- package/agents/context-builder.md +1 -1
- package/agents/planner.md +1 -1
- package/agents/researcher.md +1 -1
- package/agents/scout.md +1 -1
- package/agents/worker.md +1 -1
- package/async-execution.ts +38 -13
- package/async-job-tracker.ts +36 -18
- package/execution.ts +114 -6
- package/index.ts +1 -1
- package/intercom-bridge.ts +6 -3
- package/notify.ts +2 -1
- package/package.json +1 -1
- package/post-exit-stdio-guard.ts +85 -0
- package/render.ts +58 -24
- package/session-tokens.ts +48 -0
- package/slash-commands.ts +1 -1
- package/subagent-executor.ts +35 -29
- package/subagent-runner.ts +72 -42
- package/subagents-status.ts +7 -1
- package/top-level-async.ts +13 -0
- package/types.ts +7 -3
- package/utils.ts +31 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.17.2] - 2026-04-21
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Added `forceTopLevelAsync` so depth-0 delegated runs can be forced into background mode with `clarify: false`, while nested runs keep their existing behavior.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Background completion notifications now render `(no output)` instead of a blank body when a completion summary is empty or whitespace-only.
|
|
12
|
+
- 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.
|
|
13
|
+
- Async/background startup now fails fast for invalid resolved `cwd` values and spawn failures instead of reporting false launch success.
|
|
14
|
+
- 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.
|
|
15
|
+
|
|
16
|
+
## [0.17.1] - 2026-04-20
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- Foreground subagent runs now make deeper live detail easier to discover. Running cards show an explicit `Ctrl+O` hint, lightweight live-state signals like recent activity, current-tool durations, and artifact output paths when available. Common array-heavy tool previews such as `web_search.queries` and `fetch_content.urls` are now summarized more clearly instead of collapsing into opaque fallback text.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- Forked delegated runs now use stronger prompt-side guidance for `pi-intercom` coordination instead of runtime policing. The default fork preamble and intercom bridge instructions now explicitly treat inherited fork history as reference-only context, tell children not to continue the parent conversation in normal assistant text, and steer upstream questions or handoffs through `intercom` when needed.
|
|
23
|
+
- Documented an opt-in custom agent pattern for forked chat-back workflows so users can make that coordination contract explicit without changing builtin agents.
|
|
24
|
+
- Slash-run status text and `/subagents-status` summary output now use the same more explicit observability language, including clearer live-detail hints and surfaced output/session paths in the async status overlay.
|
|
25
|
+
- Builtin agent defaults now prefer `openai-codex` models for `planner`, `scout`, `researcher`, `context-builder`, and `worker`.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- Removed the short-lived foreground intercom enforcement/retry layer from delegated fork runs. Coordination behavior is now shaped by prompt and agent design only, avoiding hidden retries, heuristic output inspection, and failure paths based on guessed intent.
|
|
29
|
+
|
|
5
30
|
## [0.17.0] - 2026-04-16
|
|
6
31
|
|
|
7
32
|
### Added
|
package/README.md
CHANGED
|
@@ -855,6 +855,33 @@ This aggregated output becomes `{previous}` for the next step.
|
|
|
855
855
|
|
|
856
856
|
`pi-subagents` reads optional JSON config from `~/.pi/agent/extensions/subagent/config.json`.
|
|
857
857
|
|
|
858
|
+
### `asyncByDefault`
|
|
859
|
+
|
|
860
|
+
`asyncByDefault` makes top-level subagent calls use background execution when the request does not explicitly set `async`.
|
|
861
|
+
|
|
862
|
+
```json
|
|
863
|
+
{
|
|
864
|
+
"asyncByDefault": true
|
|
865
|
+
}
|
|
866
|
+
```
|
|
867
|
+
|
|
868
|
+
This only changes the default. Callers can still force foreground execution by setting `async: false` unless `forceTopLevelAsync` is also enabled.
|
|
869
|
+
|
|
870
|
+
### `forceTopLevelAsync`
|
|
871
|
+
|
|
872
|
+
`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.
|
|
873
|
+
|
|
874
|
+
```json
|
|
875
|
+
{
|
|
876
|
+
"forceTopLevelAsync": true
|
|
877
|
+
}
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
When enabled:
|
|
881
|
+
- top-level single, parallel, and chain runs are forced to `async: true`
|
|
882
|
+
- top-level clarify UI is bypassed by forcing `clarify: false`
|
|
883
|
+
- nested subagent calls still follow their own inherited depth and async settings
|
|
884
|
+
|
|
858
885
|
### `parallel`
|
|
859
886
|
|
|
860
887
|
`parallel` controls top-level `tasks` mode defaults and limits.
|
|
@@ -925,12 +952,14 @@ Example `instructionFile`:
|
|
|
925
952
|
```md
|
|
926
953
|
Intercom orchestration channel:
|
|
927
954
|
|
|
955
|
+
The inherited thread is reference-only. Do not continue that conversation or send questions, status updates, or completion handoffs to the orchestrator in normal assistant text.
|
|
956
|
+
|
|
928
957
|
Use `intercom` only to coordinate with the orchestrator session `{orchestratorTarget}`.
|
|
929
958
|
|
|
930
959
|
- Need a decision or you're blocked: `intercom({ action: "ask", to: "{orchestratorTarget}", message: "<question>" })`
|
|
931
|
-
- Need to report progress or completion: `intercom({ action: "send", to: "{orchestratorTarget}", message: "DONE: <summary>" })`
|
|
960
|
+
- Need to report progress or a completion handoff: `intercom({ action: "send", to: "{orchestratorTarget}", message: "DONE: <summary>" })`
|
|
932
961
|
|
|
933
|
-
If
|
|
962
|
+
If no upstream coordination is needed, continue the task normally and return a focused task result.
|
|
934
963
|
```
|
|
935
964
|
|
|
936
965
|
Bridge activation also requires all of the following:
|
|
@@ -941,6 +970,33 @@ Bridge activation also requires all of the following:
|
|
|
941
970
|
|
|
942
971
|
When an unnamed session falls back to `subagent-chat-<id>`, that alias is used only for the live intercom broker. It is not persisted as the Pi session title, so `pi --resume` can still show the transcript snippet.
|
|
943
972
|
|
|
973
|
+
If you want a stronger prompt contract for forked chat-back runs without changing builtins, define a custom agent for it. Keeping that as an opt-in agent works better than teaching every delegated run to behave this way.
|
|
974
|
+
|
|
975
|
+
Example agent:
|
|
976
|
+
|
|
977
|
+
```md
|
|
978
|
+
---
|
|
979
|
+
name: fork-chatback
|
|
980
|
+
description: Forked worker that asks the orchestrator questions through intercom when needed
|
|
981
|
+
tools: read, bash, edit, write, intercom
|
|
982
|
+
systemPromptMode: replace
|
|
983
|
+
inheritProjectContext: true
|
|
984
|
+
inheritSkills: false
|
|
985
|
+
---
|
|
986
|
+
|
|
987
|
+
You are a delegated worker running from a fork of the orchestrator session.
|
|
988
|
+
|
|
989
|
+
Treat the inherited conversation as reference-only context. Do not continue that conversation in normal assistant text.
|
|
990
|
+
|
|
991
|
+
Your job is to do the task. If you need a decision, clarification, or unblock from the orchestrator, use `intercom` to ask the orchestrator session named in the runtime bridge instructions.
|
|
992
|
+
|
|
993
|
+
If you need to send a progress update or completion handoff upstream, use `intercom` to send it to that same orchestrator session.
|
|
994
|
+
|
|
995
|
+
If no upstream coordination is needed, just complete the work and return a focused task result.
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
Pair that with task wording that makes the contract explicit, like "Work from the forked context below. If you need anything from me, ask through `intercom`. Otherwise complete the task and return the result."
|
|
999
|
+
|
|
944
1000
|
### `worktreeSetupHook`
|
|
945
1001
|
|
|
946
1002
|
`worktreeSetupHook` configures an optional setup hook for worktree-isolated parallel runs. The hook runs once per created worktree, after `git worktree add` succeeds and before the agent starts.
|
|
@@ -1005,7 +1061,7 @@ When fallback is used, metadata records both the ordered `attemptedModels` list
|
|
|
1005
1061
|
|
|
1006
1062
|
Session files (JSONL) are stored under a per-run session directory. Directory selection follows the same precedence as session root resolution: explicit `sessionDir` > `config.defaultSessionDir` > parent-session-derived path. The session file path is shown in output.
|
|
1007
1063
|
|
|
1008
|
-
When `context: "fork"` is used, each child run starts with `--session <branched-session-file>` produced from the parent's current leaf. This is a real session fork, not injected summary text.
|
|
1064
|
+
When `context: "fork"` is used, each child run starts with `--session <branched-session-file>` produced from the parent's current leaf. This is a real session fork, not injected summary text. The fork preamble explicitly tells the child to treat the inherited conversation as reference-only context rather than a live thread to continue.
|
|
1009
1065
|
|
|
1010
1066
|
## Session Sharing
|
|
1011
1067
|
|
|
@@ -1041,6 +1097,8 @@ During sync execution, the collapsed view shows real-time progress for single, c
|
|
|
1041
1097
|
- Per-task step cards showing status icon, agent name, model, tool count, and duration
|
|
1042
1098
|
- Current tool and recent output for each running task
|
|
1043
1099
|
|
|
1100
|
+
While a foreground run is active, the compact view also hints when richer detail is available and shows lightweight live-state signals like activity freshness and current-tool duration.
|
|
1101
|
+
|
|
1044
1102
|
Press **Ctrl+O** to expand the full streaming view with complete output per step.
|
|
1045
1103
|
|
|
1046
1104
|
> **Note:** Chain visualization (the `done scout → running planner` line) is only shown for sequential chains. Chains with parallel steps show per-step cards instead.
|
|
@@ -1095,7 +1153,7 @@ subagent_status({ id: "<id>" })
|
|
|
1095
1153
|
subagent_status({ dir: "<tmpdir>/pi-subagents-<scope>/async-subagent-runs/<id>" })
|
|
1096
1154
|
```
|
|
1097
1155
|
|
|
1098
|
-
For an interactive overview, run the `/subagents-status` slash command to open the overlay listing active runs and recent completed/failed runs. The overlay auto-refreshes every 2 seconds while it is open.
|
|
1156
|
+
For an interactive overview, run the `/subagents-status` slash command to open the overlay listing active runs and recent completed/failed runs. The overlay auto-refreshes every 2 seconds while it is open and focuses on summary/status information, including the current output/session paths when available.
|
|
1099
1157
|
|
|
1100
1158
|
## Events
|
|
1101
1159
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: context-builder
|
|
3
3
|
description: Analyzes requirements and codebase, generates context and meta-prompt
|
|
4
4
|
tools: read, grep, find, ls, bash, write, web_search
|
|
5
|
-
model:
|
|
5
|
+
model: openai-codex/gpt-5.4
|
|
6
6
|
systemPromptMode: replace
|
|
7
7
|
inheritProjectContext: true
|
|
8
8
|
inheritSkills: false
|
package/agents/planner.md
CHANGED
package/agents/researcher.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: researcher
|
|
3
3
|
description: Autonomous web researcher — searches, evaluates, and synthesizes a focused research brief
|
|
4
4
|
tools: read, write, web_search, fetch_content, get_search_content
|
|
5
|
-
model:
|
|
5
|
+
model: openai-codex/gpt-5.4
|
|
6
6
|
systemPromptMode: replace
|
|
7
7
|
inheritProjectContext: true
|
|
8
8
|
inheritSkills: false
|
package/agents/scout.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: scout
|
|
3
3
|
description: Fast codebase recon that returns compressed context for handoff
|
|
4
4
|
tools: read, grep, find, ls, bash, write
|
|
5
|
-
model:
|
|
5
|
+
model: openai-codex/gpt-5.4-mini
|
|
6
6
|
systemPromptMode: replace
|
|
7
7
|
inheritProjectContext: true
|
|
8
8
|
inheritSkills: false
|
package/agents/worker.md
CHANGED
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 {
|
|
@@ -63,6 +64,35 @@ function appendRecentOutput(progress: AgentProgress, lines: string[]): void {
|
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
function snapshotProgress(progress: AgentProgress): AgentProgress {
|
|
68
|
+
return {
|
|
69
|
+
...progress,
|
|
70
|
+
skills: progress.skills ? [...progress.skills] : undefined,
|
|
71
|
+
recentTools: progress.recentTools.map((tool) => ({ ...tool })),
|
|
72
|
+
recentOutput: [...progress.recentOutput],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function snapshotResult(result: SingleResult, progress: AgentProgress): SingleResult {
|
|
77
|
+
return {
|
|
78
|
+
...result,
|
|
79
|
+
messages: result.messages ? [...result.messages] : undefined,
|
|
80
|
+
usage: { ...result.usage },
|
|
81
|
+
skills: result.skills ? [...result.skills] : undefined,
|
|
82
|
+
attemptedModels: result.attemptedModels ? [...result.attemptedModels] : undefined,
|
|
83
|
+
modelAttempts: result.modelAttempts
|
|
84
|
+
? result.modelAttempts.map((attempt) => ({
|
|
85
|
+
...attempt,
|
|
86
|
+
usage: attempt.usage ? { ...attempt.usage } : undefined,
|
|
87
|
+
}))
|
|
88
|
+
: undefined,
|
|
89
|
+
progress,
|
|
90
|
+
progressSummary: result.progressSummary ? { ...result.progressSummary } : undefined,
|
|
91
|
+
artifactPaths: result.artifactPaths ? { ...result.artifactPaths } : undefined,
|
|
92
|
+
truncation: result.truncation ? { ...result.truncation } : undefined,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
66
96
|
async function runSingleAttempt(
|
|
67
97
|
runtimeCwd: string,
|
|
68
98
|
agent: AgentConfig,
|
|
@@ -75,6 +105,7 @@ async function runSingleAttempt(
|
|
|
75
105
|
resolvedSkillNames?: string[];
|
|
76
106
|
skillsWarning?: string;
|
|
77
107
|
jsonlPath?: string;
|
|
108
|
+
artifactPaths?: ArtifactPaths;
|
|
78
109
|
attemptNotes: string[];
|
|
79
110
|
outputSnapshot?: SingleOutputSnapshot;
|
|
80
111
|
},
|
|
@@ -105,6 +136,7 @@ async function runSingleAttempt(
|
|
|
105
136
|
messages: [],
|
|
106
137
|
usage: emptyUsage(),
|
|
107
138
|
model: modelArg,
|
|
139
|
+
artifactPaths: shared.artifactPaths,
|
|
108
140
|
skills: shared.resolvedSkillNames,
|
|
109
141
|
skillsWarning: shared.skillsWarning,
|
|
110
142
|
};
|
|
@@ -120,6 +152,7 @@ async function runSingleAttempt(
|
|
|
120
152
|
toolCount: 0,
|
|
121
153
|
tokens: 0,
|
|
122
154
|
durationMs: 0,
|
|
155
|
+
lastActivityAt: Date.now(),
|
|
123
156
|
};
|
|
124
157
|
result.progress = progress;
|
|
125
158
|
|
|
@@ -156,6 +189,42 @@ async function runSingleAttempt(
|
|
|
156
189
|
finish(-2);
|
|
157
190
|
};
|
|
158
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
|
+
|
|
159
228
|
const unsubscribeIntercomDetach = options.intercomEvents?.on?.(INTERCOM_DETACH_REQUEST_EVENT, (payload) => {
|
|
160
229
|
if (!options.allowIntercomDetach || detached || processClosed) return;
|
|
161
230
|
if (!payload || typeof payload !== "object") return;
|
|
@@ -170,20 +239,29 @@ async function runSingleAttempt(
|
|
|
170
239
|
const finish = (code: number) => {
|
|
171
240
|
if (settled) return;
|
|
172
241
|
settled = true;
|
|
242
|
+
clearFinalDrainTimers();
|
|
243
|
+
clearStdioGuard();
|
|
173
244
|
unsubscribeIntercomDetach?.();
|
|
174
245
|
removeAbortListener?.();
|
|
175
246
|
resolve(code);
|
|
176
247
|
};
|
|
177
248
|
|
|
178
|
-
const
|
|
249
|
+
const emitUpdateSnapshot = (text: string) => {
|
|
179
250
|
if (!options.onUpdate || processClosed) return;
|
|
180
|
-
|
|
251
|
+
const progressSnapshot = snapshotProgress(progress);
|
|
252
|
+
const resultSnapshot = snapshotResult(result, progressSnapshot);
|
|
181
253
|
options.onUpdate({
|
|
182
|
-
content: [{ type: "text", text
|
|
183
|
-
details: { mode: "single", results: [
|
|
254
|
+
content: [{ type: "text", text }],
|
|
255
|
+
details: { mode: "single", results: [resultSnapshot], progress: [progressSnapshot] },
|
|
184
256
|
});
|
|
185
257
|
};
|
|
186
258
|
|
|
259
|
+
const fireUpdate = () => {
|
|
260
|
+
if (!options.onUpdate || processClosed) return;
|
|
261
|
+
progress.durationMs = Date.now() - startTime;
|
|
262
|
+
emitUpdateSnapshot(getFinalOutput(result.messages) || "(running...)");
|
|
263
|
+
};
|
|
264
|
+
|
|
187
265
|
const processLine = (line: string) => {
|
|
188
266
|
if (!line.trim()) return;
|
|
189
267
|
jsonlWriter.writeLine(line);
|
|
@@ -197,6 +275,7 @@ async function runSingleAttempt(
|
|
|
197
275
|
|
|
198
276
|
const now = Date.now();
|
|
199
277
|
progress.durationMs = now - startTime;
|
|
278
|
+
progress.lastActivityAt = now;
|
|
200
279
|
|
|
201
280
|
if (evt.type === "tool_execution_start") {
|
|
202
281
|
if (options.allowIntercomDetach && evt.toolName === "intercom") {
|
|
@@ -205,6 +284,7 @@ async function runSingleAttempt(
|
|
|
205
284
|
progress.toolCount++;
|
|
206
285
|
progress.currentTool = evt.toolName;
|
|
207
286
|
progress.currentToolArgs = extractToolArgsPreview((evt.args || {}) as Record<string, unknown>);
|
|
287
|
+
progress.currentToolStartedAt = now;
|
|
208
288
|
fireUpdate();
|
|
209
289
|
}
|
|
210
290
|
|
|
@@ -218,6 +298,7 @@ async function runSingleAttempt(
|
|
|
218
298
|
}
|
|
219
299
|
progress.currentTool = undefined;
|
|
220
300
|
progress.currentToolArgs = undefined;
|
|
301
|
+
progress.currentToolStartedAt = undefined;
|
|
221
302
|
fireUpdate();
|
|
222
303
|
}
|
|
223
304
|
|
|
@@ -237,6 +318,13 @@ async function runSingleAttempt(
|
|
|
237
318
|
if (!result.model && evt.message.model) result.model = evt.message.model;
|
|
238
319
|
if (evt.message.errorMessage) result.error = evt.message.errorMessage;
|
|
239
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
|
+
}
|
|
240
328
|
}
|
|
241
329
|
fireUpdate();
|
|
242
330
|
}
|
|
@@ -250,6 +338,7 @@ async function runSingleAttempt(
|
|
|
250
338
|
|
|
251
339
|
let stderrBuf = "";
|
|
252
340
|
|
|
341
|
+
const clearStdioGuard = attachPostExitStdioGuard(proc, { idleMs: 2000, hardMs: 8000 });
|
|
253
342
|
proc.stdout.on("data", (d) => {
|
|
254
343
|
buf += d.toString();
|
|
255
344
|
const lines = buf.split("\n");
|
|
@@ -259,7 +348,13 @@ async function runSingleAttempt(
|
|
|
259
348
|
proc.stderr.on("data", (d) => {
|
|
260
349
|
stderrBuf += d.toString();
|
|
261
350
|
});
|
|
262
|
-
proc.on("
|
|
351
|
+
proc.on("exit", () => {
|
|
352
|
+
childExited = true;
|
|
353
|
+
clearFinalDrainTimers();
|
|
354
|
+
});
|
|
355
|
+
proc.on("close", (code, signal) => {
|
|
356
|
+
clearFinalDrainTimers();
|
|
357
|
+
clearStdioGuard();
|
|
263
358
|
void jsonlWriter.close().catch(() => {
|
|
264
359
|
// JSONL artifact flush is best effort.
|
|
265
360
|
});
|
|
@@ -273,9 +368,12 @@ async function runSingleAttempt(
|
|
|
273
368
|
if (code !== 0 && stderrBuf.trim() && !result.error) {
|
|
274
369
|
result.error = stderrBuf.trim();
|
|
275
370
|
}
|
|
276
|
-
|
|
371
|
+
const finalCode = forcedTerminationSignal || signal ? (code ?? 1) : (code ?? 0);
|
|
372
|
+
finish(finalCode);
|
|
277
373
|
});
|
|
278
374
|
proc.on("error", (error) => {
|
|
375
|
+
clearFinalDrainTimers();
|
|
376
|
+
clearStdioGuard();
|
|
279
377
|
void jsonlWriter.close().catch(() => {
|
|
280
378
|
// JSONL artifact flush is best effort.
|
|
281
379
|
});
|
|
@@ -343,6 +441,15 @@ async function runSingleAttempt(
|
|
|
343
441
|
result.outputSaveError = resolvedOutput.saveError;
|
|
344
442
|
}
|
|
345
443
|
result.finalOutput = fullOutput;
|
|
444
|
+
if (options.onUpdate) {
|
|
445
|
+
const finalText = result.finalOutput || result.error || "(no output)";
|
|
446
|
+
const progressSnapshot = snapshotProgress(progress);
|
|
447
|
+
const resultSnapshot = snapshotResult(result, progressSnapshot);
|
|
448
|
+
options.onUpdate({
|
|
449
|
+
content: [{ type: "text", text: finalText }],
|
|
450
|
+
details: { mode: "single", results: [resultSnapshot], progress: [progressSnapshot] },
|
|
451
|
+
});
|
|
452
|
+
}
|
|
346
453
|
return result;
|
|
347
454
|
}
|
|
348
455
|
|
|
@@ -417,6 +524,7 @@ export async function runSync(
|
|
|
417
524
|
resolvedSkillNames: resolvedSkills.length > 0 ? resolvedSkills.map((skill) => skill.name) : undefined,
|
|
418
525
|
skillsWarning: missingSkills.length > 0 ? `Skills not found: ${missingSkills.join(", ")}` : undefined,
|
|
419
526
|
jsonlPath,
|
|
527
|
+
artifactPaths: artifactPathsResult,
|
|
420
528
|
attemptNotes,
|
|
421
529
|
outputSnapshot,
|
|
422
530
|
});
|
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";
|