pi-subagents 0.44.0 → 0.45.1

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 CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.45.1] - 2026-08-09
6
+
7
+ ### Changed
8
+ - Simplified async workflow activity projection and its regression test to reuse canonical status types.
9
+
10
+ ### Fixed
11
+ - Add actionable guidance when Markdown fence backticks make a `workflowScript` invalid JavaScript.
12
+ - Prevent async interrupt requests from signaling unverified runner PIDs, including the shared host PID stored by workflows. Thanks to @kdasme for #925.
13
+
14
+ ## [0.45.0] - 2026-08-09
15
+
16
+ ### Added
17
+ - Surface terminal completion payloads in `subagent_wait` tool-result details (`details.completions`): run identity, per-child agent/`runId`/success, and artifact paths. Async completions previously reached the parent only as text — the result file is consumed and deleted after delivery — so extensions and automation had no structured way to learn which runs finished or where their artifacts live. Workflow result files now also record each child's `runId`, which was previously dropped even though the workflow engine knows it; a workflow child's `artifactPaths` entry points at its saved output (`outputs/<runId>/…`), so without the explicit field the child's identity was not recoverable from the payload. Thanks to @lucasgrecco for #915.
18
+
19
+ ### Changed
20
+ - Clarified mission-use policy in the packaged `pi-subagents` skill.
21
+
22
+ ### Fixed
23
+ - Prefix quoted Herdr pane commands with PowerShell's call operator on Windows. Thanks to @qsgy-edge for #921.
24
+ - Report live child activity for async workflow runs instead of deriving a false activity age from the workflow launch time. Thanks to @alexei-led (Alexei Ledenev) for #920.
25
+ - Expand `reads` home paths and apply configured reads to single-run launches. Thanks to @Adjuvant (Thomas Deacon) for #916.
26
+ - Drop late workflow child responses after worker settlement. Thanks to @xz-dev (Xiangzhe) for #922.
27
+ - Stabilize steering recovery tests by invalidating cached status metadata after fast test rewrites.
28
+
5
29
  ## [0.44.0] - 2026-08-08
6
30
 
7
31
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.44.0",
3
+ "version": "0.45.1",
4
4
  "description": "Pi extension for single-agent delegation and scripted multi-agent workflows",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
@@ -280,6 +280,16 @@ Ordinary launches with a task create a mission by default, so substantial delega
280
280
 
281
281
  Use `mission.update` while work runs to record decisions, artifacts, labels, summaries, or delivery receipts. A receipt records a pull request, CI, deployment, or release link with a concise status; it does not authorize or automate merge, CI polling, or deployment. Record open product, architecture, or safety decisions there and escalate them upward; do not let a child decide silently. Use `mission.attach-run` only for runs launched outside the normal mission-backed path, and use `mission.close` with a terminal status and concise summary when the mission is done.
282
282
 
283
+ ### Mission use policy
284
+
285
+ - **Keep the default.** Every ordinary `workflowScript` launch with a task creates one enclosing mission automatically. All workflow children share it and never get their own. Do not add `mission: {...}` boilerplate. Pass it only to set the title, objective, labels, or to enable `goal` with `budget`.
286
+ - **Use `mission: false` for noise.** Use it for trivial one-shot lookups, scouts, disposable probes, and quick validation where a recovery record is noise. It removes the mission and the `state` global for the whole workflow, so do not use it for monitors or multi-workflow loops that coordinate through `state`. Scheduled runs already launch without automatic missions.
287
+ - **Use `missionId` for follow-up work.** Attach later work to an existing objective with `missionId`; attachment re-marks the mission active. `missionId` and `mission` are mutually exclusive. Explicit attachment fails before launch if the mission is missing, while automatic missions degrade to `details.missionWarning` without blocking the run.
288
+ - **Keep `state` small.** Mission `state` is JSON coordination across workflows on the same mission. Keys use the same format as run keys, values must be JSON, and the whole state file is capped at 256 KiB. Each `set` merges one key under a file lock. Put large content in artifact files and store paths in state. In goal missions, write `state.set("nextReadyAction", "...")` so the next idle-turn notice names the exact ready step.
289
+ - **Use artifacts and receipts as evidence.** Mission-backed launches already record run artifacts such as async `status.json`, `events.jsonl`, child output paths, and handoff manifests. Add `mission.update` artifacts only for extra durable outputs such as `patch`, `review`, or `note` files. Add receipts for external outcomes: `pull_request`, `ci`, `deployment`, or `release`; each receipt needs an absolute URL. Receipts are evidence, not authority to merge, deploy, or release.
290
+ - **Treat decisions as append-only.** `mission.update` `decisions` can only add open decisions. No tool action resolves one. In a goal mission, an unresolved decision becomes the fallback next ready action in each notice. Use decisions sparingly there; record them for escalation and audit, steer goal continuation through `state.nextReadyAction`, and close the mission when the question is settled.
291
+ - **Close missions when done.** `mission.close` takes `missionStatus` `completed`, `failed`, or `cancelled` plus a concise `summary`, and ends any goal loop. Goal notices go only to the owning session and stop silently at `budget-exhausted` without closing or claiming success, so close explicitly. Terminal missions are pruned beyond configured retention, so store durable outputs as artifacts, receipts, and summary before closing.
292
+
283
293
  After compaction, restart, or confusing history, recover from durable state first: `mission.list` in the project, `mission.list` with `missionScope: "global"` for the user-local cross-project pointer index, then `mission.show` for the relevant mission. `mission.show` refreshes linked async status when available and returns warnings instead of hiding the mission if a linked status file is temporarily unreadable. Use the linked run ids with normal `status`, `steer`, `resume`, or `stop` actions. Project mission JSON remains authoritative over chat history.
284
294
 
285
295
  Routing rule:
@@ -18,7 +18,7 @@ export const FULL_SUBAGENT_TOOL_DESCRIPTION = `Run subagents only through { work
18
18
 
19
19
  EXECUTION:
20
20
  • Before executing, use { action: "list" } and run only executable/non-disabled configured agents.
21
- • WORKFLOW SCRIPT: { workflowScript: "return runs.run('main', {agent:'worker', task:'...'})" }. Every execution is a workflow. Use stable-key runs.run for one child and runs.all for parallel children; ordinary JavaScript provides sequence, branching, filtering, retries, and aggregation. workflowScript is an ordinary JavaScript statement body, so use an explicit return for a useful result. Scripts start asynchronously by default; pass async:false only for a small foreground run. Same-repo foreground workflows default to a live in-chat card; set chatProgress to auto, off, or live-card to control that projection. Workflow-level child controls default onto each runs.run launch, and explicit child fields override them. Use {action:"children.list"} to list up to 10 completed retained children from this parent session, then continue one with runs.run(key, {resume:"run-id", task:"follow-up"}); resume and agent are mutually exclusive, and resume keeps the stored agent/model/tool contract. For repository mutation lanes, set worktree:true on the workflow or individual runs.run/runs.all item for managed isolation; each parallel child gets a separate worktree and handoff artifact. A workflow usageBudget is enforced once across the workflow. Available globals are runs.run, runs.all, runs.status, runs.ref/refs, emit, console, and standard JavaScript only. Workflows get async state.get(key) and state.set(key, JSONValue) through their automatic or explicit mission; mission:false workflows do not have a state global. Scripts cannot access filesystem, shell, arbitrary Pi tools, or host globals.
21
+ • WORKFLOW SCRIPT: { workflowScript: "return runs.run('main', {agent:'worker', task:'...'})" }. Every execution is a workflow. Use stable-key runs.run for one child and runs.all for parallel children; ordinary JavaScript provides sequence, branching, filtering, retries, and aggregation. workflowScript is an ordinary JavaScript statement body, so use an explicit return for a useful result. For task text with Markdown fences or shell blocks, build quoted lines instead of nesting raw template literals: \`const task=["Run:","\`\`\`bash","npm test","\`\`\`"].join("\\n")\`. Scripts start asynchronously by default; pass async:false only for a small foreground run. Same-repo foreground workflows default to a live in-chat card; set chatProgress to auto, off, or live-card to control that projection. Workflow-level child controls default onto each runs.run launch, and explicit child fields override them. Use {action:"children.list"} to list up to 10 completed retained children from this parent session, then continue one with runs.run(key, {resume:"run-id", task:"follow-up"}); resume and agent are mutually exclusive, and resume keeps the stored agent/model/tool contract. For repository mutation lanes, set worktree:true on the workflow or individual runs.run/runs.all item for managed isolation; each parallel child gets a separate worktree and handoff artifact. A workflow usageBudget is enforced once across the workflow. Available globals are runs.run, runs.all, runs.status, runs.ref/refs, emit, console, and standard JavaScript only. Workflows get async state.get(key) and state.set(key, JSONValue) through their automatic or explicit mission; mission:false workflows do not have a state global. Scripts cannot access filesystem, shell, arbitrary Pi tools, or host globals.
22
22
  • Sequential example: { workflowScript: "const a = await runs.run('analyze', {agent:'agent-a', task:'Analyze the request'}); return (await runs.run('plan', {agent:'agent-b', task:'Plan from: '+a.output})).output" }
23
23
  • Parallel example: { workflowScript: "const [a,b] = await runs.all([{key:'correctness',agent:'agent-a',task:'Review correctness'},{key:'tests',agent:'agent-b',task:'Review tests'}]); return {correctness:a.output,tests:b.output}" }
24
24
  • Optional context is "fresh" or "fork". timeoutMs/maxRuntimeMs apply to foreground and async workflows; foreground workflows default to 30 minutes and async workflows have no default timeout. Omit acceptance for reviewer/read-only calls; evidence levels end at verified, and acceptance.review.required requests independent writer review.
@@ -37,7 +37,7 @@ export const COMPACT_SUBAGENT_TOOL_DESCRIPTION = `Run subagents only through { w
37
37
 
38
38
  EXECUTE:
39
39
  • Call { action:"list" } first and use only executable/non-disabled agents.
40
- • SCRIPT {workflowScript:"return runs.run('main', {agent:'worker', task:'...'})"}. Use stable-key runs.run for one child and runs.all for parallel work. Use {action:"children.list"} for the last 10 retained children in this parent session, then runs.run(key,{resume:"run-id",task:"follow-up"}) to continue one with its stored contract. Workflows get async state.get/state.set through their automatic or explicit mission; mission:false does not. Scripts are ordinary JavaScript statement bodies; use explicit return for a useful result. Use JavaScript for sequence, branching, retries, and aggregation. For repository mutation lanes, use worktree:true on the workflow or runs.run/runs.all item for managed isolation. Scripts start async by default; async:false is the foreground escape hatch and auto-enables a same-repo live chat card unless chatProgress is off.
40
+ • SCRIPT {workflowScript:"return runs.run('main', {agent:'worker', task:'...'})"}. Use stable-key runs.run for one child and runs.all for parallel work. Use {action:"children.list"} for the last 10 retained children in this parent session, then runs.run(key,{resume:"run-id",task:"follow-up"}) to continue one with its stored contract. Workflows get async state.get/state.set through their automatic or explicit mission; mission:false does not. Scripts are ordinary JavaScript statement bodies; use explicit return for a useful result. For task text with Markdown fences or shell blocks, build quoted lines instead of nesting raw template literals: \`const task=["Run:","\`\`\`bash","npm test","\`\`\`"].join("\\n")\`. Use JavaScript for sequence, branching, retries, and aggregation. For repository mutation lanes, use worktree:true on the workflow or runs.run/runs.all item for managed isolation. Scripts start async by default; async:false is the foreground escape hatch and auto-enables a same-repo live chat card unless chatProgress is off.
41
41
  • Example: {workflowScript:"const [a,b]=await runs.all([{key:'a',agent:'agent-a',task:'Implement A',worktree:true},{key:'b',agent:'agent-b',task:'Implement B',worktree:true}]); return [a.output,b.output]"}
42
42
  • context can be fresh or fork. timeoutMs/maxRuntimeMs apply to foreground and async workflows; foreground workflows default to 30 minutes and async workflows have no default timeout. Omit acceptance for reviewer/read-only calls.
43
43
 
@@ -93,7 +93,7 @@ function inspectorCommand(input: { runnerPath: string; asyncDir: string; runId:
93
93
  const args = [process.execPath, input.runnerPath, "--async-dir", input.asyncDir, "--run-id", input.runId, "--allow-steer", String(input.allowSteer), "--allow-stop", String(input.allowStop)];
94
94
  if (input.index !== undefined) args.push("--index", String(input.index));
95
95
  if (input.missionPath) args.push("--mission-path", input.missionPath);
96
- return args.map(shellQuote).join(" ");
96
+ return `${process.platform === "win32" ? "& " : ""}${args.map(shellQuote).join(" ")}`;
97
97
  }
98
98
 
99
99
  function missionForRun(asyncDir: string, cwd: string, config: MissionStoreConfig | undefined, runId: string): { id: string; path: string } | undefined {
@@ -94,7 +94,7 @@ async function paneExists(client: HerdrClient, paneId: string, signal?: AbortSig
94
94
  function projectPaneCommand(message: string | undefined): string {
95
95
  const args = message?.trim() ? [message.trim()] : [];
96
96
  const command = getPiSpawnCommand(args);
97
- return [command.command, ...command.args].map(shellQuote).join(" ");
97
+ return `${process.platform === "win32" ? "& " : ""}${[command.command, ...command.args].map(shellQuote).join(" ")}`;
98
98
  }
99
99
 
100
100
  export async function handleHerdrProjectPaneAction(action: HerdrProjectPaneAction, params: ProjectPaneParams, deps: ProjectPaneDeps): Promise<AgentToolResult<Details>> {
@@ -15,7 +15,7 @@ import { appendAgentRefinementOverlay } from "../../agents/agent-refinements.ts"
15
15
  import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
16
16
  import { applyThinkingSuffix, projectLaunchResolvedChildExtensions, resolvePiLaunchToolPlan } from "../shared/pi-args.ts";
17
17
  import { injectOutputPathSystemPrompt, injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
18
- import { buildChainInstructions, isCheckpointStep, isDynamicParallelStep, isParallelStep, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
18
+ import { buildChainInstructions, isCheckpointStep, isDynamicParallelStep, isParallelStep, resolveChainPath, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
19
19
  import type { RunnerStep } from "../shared/parallel-utils.ts";
20
20
  import type { ContextMode } from "../shared/context-mode.ts";
21
21
  import { resolvePiPackageRoot } from "../shared/pi-spawn.ts";
@@ -192,6 +192,7 @@ interface AsyncSingleParams {
192
192
  context?: ContextMode;
193
193
  skills?: string[];
194
194
  output?: string | boolean;
195
+ reads?: string[] | false;
195
196
  outputMode?: "inline" | "file-only";
196
197
  outputBaseDir?: string;
197
198
  agentContract?: AgentContract;
@@ -1288,6 +1289,13 @@ export function executeAsyncSingle(
1288
1289
  const validationError = validateFileOnlyOutputMode(outputMode, outputPath, `Async single run (${agent})`);
1289
1290
  if (validationError) return formatAsyncStartError("single", validationError);
1290
1291
  const taskWithOutputInstruction = injectSingleOutputInstruction(task, outputPath, agentConfig);
1292
+ // Reads: caller override > agent defaultReads > none. `~`/`~/` expand to home;
1293
+ // absolute paths pass through; relative paths resolve against the child cwd.
1294
+ const reads = params.reads !== undefined ? params.reads : agentConfig.defaultReads ?? false;
1295
+ const readsInstruction = Array.isArray(reads) && reads.length > 0
1296
+ ? `[Read from: ${reads.map((f) => resolveChainPath(f, runnerCwd)).join(", ")}]\n\n`
1297
+ : "";
1298
+ const taskText = readsInstruction + taskWithOutputInstruction;
1291
1299
  const primaryModel = externalRunner ? undefined : resolveSubagentModelOverride(
1292
1300
  params.modelOverride ?? agentConfig.model,
1293
1301
  ctx.currentModel,
@@ -1415,7 +1423,7 @@ export function executeAsyncSingle(
1415
1423
  permissionRules,
1416
1424
  ...(capabilityCeiling ? { capabilityCeiling } : {}),
1417
1425
  agent,
1418
- task: taskWithOutputInstruction,
1426
+ task: taskText,
1419
1427
  ...(agentConfig.runner ? { runner: agentConfig.runner } : {}),
1420
1428
  ...(params.context ? { context: params.context } : {}),
1421
1429
  cwd: runnerCwd,
@@ -207,7 +207,10 @@ function deriveAsyncActivityState(asyncDir: string, status: AsyncStatus): { acti
207
207
  const currentStep = typeof status.currentStep === "number" ? status.steps?.[status.currentStep] : undefined;
208
208
  return {
209
209
  activityState: status.activityState,
210
- lastActivityAt: status.lastActivityAt ?? outputFileMtime(outputPath) ?? currentStep?.lastActivityAt ?? currentStep?.startedAt ?? status.startedAt,
210
+ lastActivityAt: status.lastActivityAt
211
+ ?? outputFileMtime(outputPath)
212
+ ?? currentStep?.lastActivityAt
213
+ ?? (status.mode === "workflow" ? undefined : currentStep?.startedAt ?? status.startedAt),
211
214
  };
212
215
  }
213
216
 
@@ -9,9 +9,8 @@
9
9
  * This module adds a portable, file-based control inbox inside the run directory.
10
10
  * The parent drops an interrupt request file; the runner watches the inbox and
11
11
  * routes the request into its existing graceful `interruptRunner()` (pause +
12
- * resumable), identically on every platform. The OS signal is kept only as an
13
- * opportunistic fast-path; its failure is non-fatal because the file inbox is
14
- * authoritative.
12
+ * resumable), identically on every platform. The file inbox is authoritative and
13
+ * avoids signaling a PID that the extension cannot prove belongs to the runner.
15
14
  */
16
15
 
17
16
  import { randomUUID } from "node:crypto";
@@ -21,13 +20,6 @@ import { writeAtomicJson } from "../../shared/atomic-json.ts";
21
20
  import { POLL_INTERVAL_MS } from "../../shared/types.ts";
22
21
  import { resolveWatchPath } from "../../shared/utils.ts";
23
22
 
24
- /**
25
- * Opportunistic fast-path interrupt signal. On Unix `SIGUSR2` is trapped by the
26
- * runner; on Windows `process.kill(pid, "SIGBREAK")` is not deliverable
27
- * cross-process and throws `ENOSYS`, so the file inbox below is the real channel.
28
- */
29
- export const INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
30
-
31
23
  export type ControlChannelFs = Pick<typeof fs, "mkdirSync" | "existsSync" | "rmSync" | "watch" | "readdirSync" | "readFileSync" | "realpathSync">;
32
24
  export type ControlChannelTimers = { setInterval: typeof setInterval; clearInterval: typeof clearInterval };
33
25
  type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => unknown;
@@ -545,38 +537,13 @@ export function consumeCheckpointDecisionRequest(
545
537
  return undefined;
546
538
  }
547
539
 
548
- /**
549
- * Parent side: portable interrupt = authoritative file request + best-effort OS
550
- * signal. The signal is only a latency optimization on Unix; ENOSYS on Windows
551
- * is swallowed because the file inbox is authoritative there. Other signal
552
- * failures are surfaced because they usually mean the runner is not alive to
553
- * consume the request.
554
- */
540
+ /** Parent side: write the authoritative portable interrupt request. */
555
541
  export function deliverInterruptRequest(input: {
556
542
  asyncDir: string;
557
- pid?: number;
558
- kill?: KillFn;
559
- signal?: NodeJS.Signals;
560
543
  now?: () => number;
561
544
  source?: string;
562
545
  }): void {
563
- const requestPath = requestAsyncInterrupt(input.asyncDir, input.source ? { source: input.source } : {}, { now: input.now });
564
- if (typeof input.pid === "number" && input.pid > 0) {
565
- try {
566
- (input.kill ?? process.kill)(input.pid, input.signal ?? INTERRUPT_SIGNAL);
567
- } catch (error) {
568
- if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOSYS") {
569
- // File inbox is authoritative when custom cross-process signals are unavailable.
570
- return;
571
- }
572
- try {
573
- fs.rmSync(requestPath, { force: true });
574
- } catch {
575
- // Best effort cleanup; the caller still gets the signal failure.
576
- }
577
- throw error;
578
- }
579
- }
546
+ requestAsyncInterrupt(input.asyncDir, input.source ? { source: input.source } : {}, { now: input.now });
580
547
  }
581
548
 
582
549
  export function deliverTimeoutRequest(input: {
@@ -20,6 +20,7 @@ import {
20
20
  } from "../../intercom/result-intercom.ts";
21
21
  import { projectNestedRegistryForRoot, sanitizeSummary } from "../shared/nested-events.ts";
22
22
  import { resolveWatchPath } from "../../shared/utils.ts";
23
+ import { recordWaitCompletion } from "./wait-completions.ts";
23
24
  import type { CompletionNotifier, CompletionNotification } from "./notify.ts";
24
25
 
25
26
  const WATCHER_RESTART_DELAY_MS = 3000;
@@ -155,6 +156,10 @@ export function createResultWatcher(
155
156
  }
156
157
  const epoch = deliveryEpoch;
157
158
  if (!ownsSession(data.sessionId, epoch)) return;
159
+ // Recorded before dedupe and before the unlink below: the result file is
160
+ // the only durable carrier of the per-run payload, and subagent_wait
161
+ // surfaces this record in details once the file is gone.
162
+ recordWaitCompletion(state, runId, data, Date.now(), completionTtlMs);
158
163
  const hasExplicitNestedChildren = data.nestedChildren !== undefined;
159
164
  let nestedChildren = compactNestedResultChildren(sanitizeNestedResultChildren(data.nestedChildren, resultPath, "nestedChildren"));
160
165
  if (!nestedChildren?.length && !hasExplicitNestedChildren) {
@@ -2287,7 +2287,7 @@ async function runSubagent(
2287
2287
  const nestedAsyncDir = run.asyncDir ?? resolveNestedAsyncDir(config.nestedRoute.rootRunId, run);
2288
2288
  if (!nestedAsyncDir) continue;
2289
2289
  try {
2290
- deliverInterruptRequest(omitUndefinedProperties({ asyncDir: nestedAsyncDir, pid: run.pid, source: "ancestor-interrupt" }));
2290
+ deliverInterruptRequest({ asyncDir: nestedAsyncDir, source: "ancestor-interrupt" });
2291
2291
  } catch (error) {
2292
2292
  appendJsonl(eventsPath, JSON.stringify({
2293
2293
  type: "subagent.nested.interrupt_failed",
@@ -4439,40 +4439,6 @@ async function runSubagent(
4439
4439
  statusPayload.error = `Step failed: ${failedStep.agent}`;
4440
4440
  }
4441
4441
  }
4442
- writeStatusPayload();
4443
- appendJsonl(
4444
- eventsPath,
4445
- JSON.stringify({
4446
- type: "subagent.run.completed",
4447
- lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
4448
- ts: runEndedAt,
4449
- runId: id,
4450
- status: statusPayload.state,
4451
- durationMs: runEndedAt - overallStartTime,
4452
- totalTokens: statusPayload.totalTokens,
4453
- totalCost: finalTotalCost,
4454
- usageBudget: statusPayload.usageBudget,
4455
- }),
4456
- );
4457
- writeRunLog(logPath, omitUndefinedProperties({
4458
- id,
4459
- mode: statusPayload.mode,
4460
- cwd,
4461
- startedAt: overallStartTime,
4462
- endedAt: runEndedAt,
4463
- steps: statusPayload.steps.map((step) => omitUndefinedProperties({
4464
- agent: step.agent,
4465
- status: step.status,
4466
- durationMs: step.durationMs,
4467
- })),
4468
- summary,
4469
- truncated,
4470
- artifactsDir,
4471
- sessionFile: effectiveSessionFile,
4472
- shareUrl,
4473
- shareError,
4474
- }));
4475
-
4476
4442
  try {
4477
4443
  writeAtomicJson(resultPath, {
4478
4444
  lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
@@ -4572,6 +4538,38 @@ async function runSubagent(
4572
4538
  } catch (err) {
4573
4539
  console.error(`Failed to write result file ${resultPath}:`, err);
4574
4540
  }
4541
+ appendJsonl(
4542
+ eventsPath,
4543
+ JSON.stringify({
4544
+ type: "subagent.run.completed",
4545
+ lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
4546
+ ts: runEndedAt,
4547
+ runId: id,
4548
+ status: statusPayload.state,
4549
+ durationMs: runEndedAt - overallStartTime,
4550
+ totalTokens: statusPayload.totalTokens,
4551
+ totalCost: finalTotalCost,
4552
+ usageBudget: statusPayload.usageBudget,
4553
+ }),
4554
+ );
4555
+ writeRunLog(logPath, omitUndefinedProperties({
4556
+ id,
4557
+ mode: statusPayload.mode,
4558
+ cwd,
4559
+ startedAt: overallStartTime,
4560
+ endedAt: runEndedAt,
4561
+ steps: statusPayload.steps.map((step) => omitUndefinedProperties({
4562
+ agent: step.agent,
4563
+ status: step.status,
4564
+ durationMs: step.durationMs,
4565
+ })),
4566
+ summary,
4567
+ truncated,
4568
+ artifactsDir,
4569
+ sessionFile: effectiveSessionFile,
4570
+ shareUrl,
4571
+ shareError,
4572
+ }));
4575
4573
  if (config.runnerProcessInstanceId) {
4576
4574
  const writers: Record<string, Array<{ processInstanceId: string; kind: "pi-writer"; attempt: number; closeObservedAt: number; exitCode: number | null; signal: string | null }>> = {};
4577
4575
  const expectedWriters: Record<string, number> = {};
@@ -4594,6 +4592,7 @@ async function runSubagent(
4594
4592
  console.error(`Failed to write process-terminal candidate for '${id}':`, error);
4595
4593
  }
4596
4594
  }
4595
+ writeStatusPayload();
4597
4596
  }
4598
4597
 
4599
4598
  async function waitForStartupControl(
@@ -58,8 +58,10 @@ import {
58
58
  type Details,
59
59
  type ForegroundResumeRun,
60
60
  type SubagentState,
61
+ type WaitCompletion,
61
62
  } from "../../shared/types.ts";
62
63
  import { formatDuration, shortenPath } from "../../shared/formatters.ts";
64
+ import { collectWaitCompletions } from "./wait-completions.ts";
63
65
  export { WAIT_TOOL_ENABLED_ENV, resolveWaitToolConfig, type ResolvedWaitToolConfig } from "./wait-config.ts";
64
66
 
65
67
  /** States that mean a run is still in flight (not yet resolved). */
@@ -303,11 +305,15 @@ function summarizeTerminalRuns(runs: AsyncRunSummary[], providerFinishedCount =
303
305
  return parts.join(", ");
304
306
  }
305
307
 
306
- function result(text: string, isError = false): AgentToolResult<Details> {
308
+ function result(text: string, isError = false, completions?: WaitCompletion[]): AgentToolResult<Details> {
307
309
  return {
308
310
  content: [{ type: "text", text }],
309
311
  ...(isError ? { isError: true } : {}),
310
- details: { mode: "management", results: [] },
312
+ details: {
313
+ mode: "management",
314
+ results: [],
315
+ ...(completions && completions.length > 0 ? { completions } : {}),
316
+ },
311
317
  };
312
318
  }
313
319
 
@@ -597,6 +603,7 @@ export async function waitForSubagents(
597
603
  let terminalSummary: string;
598
604
  let finishedAsyncCount: number;
599
605
  let failedAsyncCount: number;
606
+ let completions: WaitCompletion[] | undefined;
600
607
  const activeProviderIds = new Set(providerActive.map(backgroundWorkIdentity));
601
608
  const providerFinishedCount = [...initialProviderIds].filter((id) => !activeProviderIds.has(id)).length;
602
609
  try {
@@ -605,6 +612,7 @@ export async function waitForSubagents(
605
612
  finishedAsyncCount = terminal.length;
606
613
  failedAsyncCount = terminal.filter((run) => run.state === "failed").length;
607
614
  terminalSummary = summarizeTerminalRuns(terminal, providerFinishedCount);
615
+ completions = collectWaitCompletions(terminal, deps.state, deps.resultsDir ?? DIRS.results);
608
616
  } catch (error) {
609
617
  return result(error instanceof Error ? error.message : String(error), true);
610
618
  }
@@ -631,6 +639,7 @@ export async function waitForSubagents(
631
639
  return result(
632
640
  `Waited ${elapsed} for ${scope}; ${status}.${outcome}${attentionNote} Completion/control events have been observed; inspect status if a notification is not visible yet.`,
633
641
  deps.failOnFailedRuns === true && failedAsyncCount > 0,
642
+ completions,
634
643
  );
635
644
  }
636
645
 
@@ -647,5 +656,6 @@ export async function waitForSubagents(
647
656
  return result(
648
657
  `Waited ${elapsed}; ${progress}.${outcome}${attentionNote}${remainder} Relevant completion/control events have been observed; inspect status if a notification is not visible yet.`,
649
658
  deps.failOnFailedRuns === true && failedAsyncCount > 0,
659
+ completions,
650
660
  );
651
661
  }
@@ -0,0 +1,112 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { ArtifactPaths, SubagentState, WaitCompletion, WaitCompletionChild } from "../../shared/types.ts";
4
+ import type { AsyncRunSummary } from "./async-status.ts";
5
+
6
+ function asNonEmptyString(value: unknown): string | undefined {
7
+ return typeof value === "string" && value ? value : undefined;
8
+ }
9
+
10
+ function errorCode(error: unknown): string | undefined {
11
+ return typeof error === "object" && error !== null && "code" in error
12
+ ? (error as NodeJS.ErrnoException).code
13
+ : undefined;
14
+ }
15
+
16
+ function errorMessage(error: unknown): string {
17
+ return error instanceof Error ? error.message : String(error);
18
+ }
19
+
20
+ /**
21
+ * Project a terminal result payload into the slim shape that is safe to surface in
22
+ * tool_result details: run identity, per-child outcome, and the artifact trail.
23
+ * Output text is deliberately excluded — it already travels in the tool result
24
+ * content, and duplicating it in details would double the payload for every wait.
25
+ */
26
+ export function toWaitCompletion(data: Record<string, unknown>, runId: string): WaitCompletion {
27
+ const results = Array.isArray(data.results)
28
+ ? data.results.flatMap((entry): WaitCompletionChild[] => {
29
+ if (entry === null || typeof entry !== "object") return [];
30
+ const child = entry as Record<string, unknown>;
31
+ const outputState = child.outputState === "present" || child.outputState === "absent" || child.outputState === "unknown"
32
+ ? child.outputState
33
+ : undefined;
34
+ const artifactPaths = child.artifactPaths !== null && typeof child.artifactPaths === "object"
35
+ ? (child.artifactPaths as Partial<ArtifactPaths>)
36
+ : undefined;
37
+ const agent = asNonEmptyString(child.agent);
38
+ const childRunId = asNonEmptyString(child.runId);
39
+ const error = asNonEmptyString(child.error);
40
+ const model = asNonEmptyString(child.model);
41
+ return [{
42
+ ...(agent ? { agent } : {}),
43
+ ...(childRunId ? { runId: childRunId } : {}),
44
+ ...(typeof child.success === "boolean" ? { success: child.success } : {}),
45
+ ...(outputState ? { outputState } : {}),
46
+ ...(error ? { error } : {}),
47
+ ...(model ? { model } : {}),
48
+ ...(artifactPaths ? { artifactPaths } : {}),
49
+ }];
50
+ })
51
+ : undefined;
52
+ const agent = asNonEmptyString(data.agent);
53
+ const mode = asNonEmptyString(data.mode);
54
+ const state = asNonEmptyString(data.state);
55
+ return {
56
+ runId,
57
+ ...(agent ? { agent } : {}),
58
+ ...(mode ? { mode } : {}),
59
+ ...(state ? { state } : {}),
60
+ ...(typeof data.success === "boolean" ? { success: data.success } : {}),
61
+ ...(results && results.length > 0 ? { results } : {}),
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Record a consumed terminal payload for later surfacing by subagent_wait, pruning
67
+ * stale entries with the same TTL that dedupes completion notifications. The result
68
+ * file is deleted after delivery, so this record is the only in-process source once
69
+ * the watcher has consumed it.
70
+ */
71
+ export function recordWaitCompletion(state: SubagentState, runId: string, data: Record<string, unknown>, now: number, ttlMs: number): void {
72
+ const store = state.completedResults ??= new Map();
73
+ for (const [key, entry] of store) {
74
+ if (now - entry.seenAt > ttlMs) store.delete(key);
75
+ }
76
+ store.set(runId, { seenAt: now, completion: toWaitCompletion(data, runId) });
77
+ }
78
+
79
+ /**
80
+ * Terminal payloads for the runs a wait covered: the watcher's in-memory record
81
+ * first, then the not-yet-consumed result file. Result files are written atomically,
82
+ * so a direct read never observes a torn write; the read is deliberately read-only —
83
+ * the watcher owns notification and cleanup.
84
+ */
85
+ export function collectWaitCompletions(terminal: AsyncRunSummary[], state: SubagentState, resultsDir: string): WaitCompletion[] | undefined {
86
+ if (terminal.length === 0) return undefined;
87
+ const completions: WaitCompletion[] = [];
88
+ for (const run of terminal) {
89
+ const recorded = state.completedResults?.get(run.id);
90
+ if (recorded) {
91
+ completions.push(recorded.completion);
92
+ continue;
93
+ }
94
+ const resultPath = path.join(resultsDir, `${run.id}.json`);
95
+ try {
96
+ const raw = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as Record<string, unknown>;
97
+ completions.push(toWaitCompletion(raw, run.id));
98
+ } catch (error) {
99
+ if (errorCode(error) !== "ENOENT") {
100
+ throw new Error(`Failed to read subagent result '${resultPath}': ${errorMessage(error)}`, {
101
+ cause: error instanceof Error ? error : undefined,
102
+ });
103
+ }
104
+ // The watcher may have consumed the file between the store check and the
105
+ // read; its record is authoritative when present, otherwise the payload
106
+ // is gone and the text summary remains the only surface for this run.
107
+ const late = state.completedResults?.get(run.id);
108
+ if (late) completions.push(late.completion);
109
+ }
110
+ }
111
+ return completions.length > 0 ? completions : undefined;
112
+ }
@@ -173,7 +173,7 @@ export async function steerAsyncRun(input: {
173
173
  return { content: [{ type: "text", text: `Steering delivered for async run ${status.runId} (request ${requestId}).` }], details: { mode: "management", results: [], steering: preCommitResult } };
174
174
  }
175
175
  try {
176
- deliverInterruptRequest({ asyncDir, pid: latest?.pid ?? status.pid, kill: input.kill, source: "steering-recovery" });
176
+ deliverInterruptRequest({ asyncDir, source: "steering-recovery" });
177
177
  } catch (error) {
178
178
  fs.rmSync(markerPath, { force: true });
179
179
  fs.rmSync(claimPath, { force: true });
@@ -36,6 +36,7 @@ import {
36
36
  getStepAgents,
37
37
  isParallelStep,
38
38
  isDynamicParallelStep,
39
+ resolveChainPath,
39
40
  resolveStepBehavior,
40
41
  suppressProgressForReadOnlyTask,
41
42
  taskDisallowsFileUpdates,
@@ -256,6 +257,8 @@ export interface SubagentParamsLike {
256
257
  focus?: boolean;
257
258
  skill?: string | string[] | boolean;
258
259
  output?: string | boolean;
260
+ /** Internal-only; not part of the public tool schema. Wired for single-run reads (chain steps use their own field). */
261
+ reads?: string[] | false;
259
262
  outputMode?: "inline" | "file-only";
260
263
  outputSchema?: JsonSchemaObject;
261
264
  agentScope?: unknown;
@@ -865,8 +868,15 @@ function interruptAsyncRun(
865
868
  details: { mode: "management", results: [] },
866
869
  };
867
870
  }
871
+ if (status.mode === "workflow") {
872
+ return {
873
+ content: [{ type: "text", text: `Interrupt is unsupported for async workflow ${target.asyncId}; use stop instead.` }],
874
+ isError: true,
875
+ details: { mode: "management", results: [] },
876
+ };
877
+ }
868
878
  try {
869
- deliverInterruptRequest(omitUndefinedProperties({ asyncDir: target.asyncDir, pid: status.pid, kill, source: "interrupt-action" }));
879
+ deliverInterruptRequest({ asyncDir: target.asyncDir, source: "interrupt-action" });
870
880
  const tracked = state.asyncJobs.get(target.asyncId);
871
881
  if (tracked) {
872
882
  delete tracked.activityState;
@@ -1175,7 +1185,7 @@ function directNestedAsyncInterrupt(target: ResolvedSubagentRunId & { kind: "nes
1175
1185
  const pid = typeof status?.pid === "number" && status.pid > 0 ? status.pid : run.pid;
1176
1186
  if (!status || status.state !== "running" || typeof pid !== "number" || pid <= 0) return undefined;
1177
1187
  try {
1178
- deliverInterruptRequest({ asyncDir, pid, source: "nested-interrupt" });
1188
+ deliverInterruptRequest({ asyncDir, source: "nested-interrupt" });
1179
1189
  return { content: [{ type: "text", text: `Interrupt requested for nested async run ${run.id}.` }], details: { mode: "management", results: [] } };
1180
1190
  } catch (error) {
1181
1191
  const message = error instanceof Error ? error.message : String(error);
@@ -2562,6 +2572,7 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
2562
2572
  skills,
2563
2573
  output: effectiveOutput,
2564
2574
  outputMode: effectiveOutputMode,
2575
+ ...(params.reads !== undefined ? { reads: params.reads } : {}),
2565
2576
  outputBaseDir: resolveSingleRunOutputBaseDir(deps, artifactsDir, id),
2566
2577
  modelOverride,
2567
2578
  thinkingOverride: thinkingOverrideForTask(params.agent!, 0, modelOverride),
@@ -3590,6 +3601,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
3590
3601
  data.modelScope === undefined ? {} : { scope: data.modelScope },
3591
3602
  );
3592
3603
  let skillOverride: string[] | false | undefined = normalizeSkillInput(params.skill);
3604
+ let readsOverride: string[] | false | undefined = params.reads;
3593
3605
  const rawOutput = params.output !== undefined ? params.output : agentConfig.output;
3594
3606
  let effectiveOutput = normalizeSingleOutputOverride(rawOutput, agentConfig.output);
3595
3607
  const effectiveOutputMode = params.outputMode ?? "inline";
@@ -3627,6 +3639,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
3627
3639
  if (override?.model !== undefined) modelOverride = resolveEffectiveSubagentModel(override.model, agentConfig.model, parentModel, availableModels, currentProvider, data.modelScope === undefined ? {} : { scope: data.modelScope });
3628
3640
  if (override?.output !== undefined) effectiveOutput = normalizeSingleOutputOverride(override.output, agentConfig.output);
3629
3641
  if (override?.skills !== undefined) skillOverride = override.skills;
3642
+ if (override?.reads !== undefined) readsOverride = override.reads;
3630
3643
 
3631
3644
  if (result.runInBackground) {
3632
3645
  if (!isAsyncAvailable()) {
@@ -3666,6 +3679,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
3666
3679
  skills: skillOverride === false ? [] : skillOverride,
3667
3680
  output: effectiveOutput,
3668
3681
  outputMode: effectiveOutputMode,
3682
+ ...(readsOverride !== undefined ? { reads: readsOverride } : {}),
3669
3683
  outputBaseDir: resolveSingleRunOutputBaseDir(deps, artifactsDir, id),
3670
3684
  modelOverride,
3671
3685
  thinkingOverride: thinkingOverrideForTask(params.agent!, 0, modelOverride),
@@ -3702,6 +3716,13 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
3702
3716
  const structuredRuntime = params.outputSchema
3703
3717
  ? createStructuredOutputRuntime(params.outputSchema, artifactConfig.enabled ? path.join(artifactsDir, "structured-output", runId) : undefined)
3704
3718
  : undefined;
3719
+ // Reads: caller override > agent defaultReads > none. `~`/`~/` expand to home;
3720
+ // absolute paths pass through; relative paths resolve against the child cwd.
3721
+ const reads = readsOverride !== undefined ? readsOverride : agentConfig.defaultReads ?? false;
3722
+ const readsInstruction = Array.isArray(reads) && reads.length > 0
3723
+ ? `[Read from: ${reads.map((f) => resolveChainPath(f, effectiveCwd)).join(", ")}]\n\n`
3724
+ : "";
3725
+ task = readsInstruction + task;
3705
3726
  task = injectSingleOutputInstruction(task, outputPath, agentConfig);
3706
3727
 
3707
3728
  let effectiveSkills: string[] | undefined;
@@ -4016,6 +4037,7 @@ function prepareWorkflowChildParams(params: SubagentParamsLike): SubagentParamsL
4016
4037
  acceptance,
4017
4038
  agentContract,
4018
4039
  toolBudget,
4040
+ reads,
4019
4041
  ...runParams
4020
4042
  } = params;
4021
4043
  return {
@@ -4027,6 +4049,7 @@ function prepareWorkflowChildParams(params: SubagentParamsLike): SubagentParamsL
4027
4049
  ...(model !== undefined ? { model } : {}),
4028
4050
  ...(skill !== undefined ? { skill } : {}),
4029
4051
  ...(output !== undefined ? { output } : {}),
4052
+ ...(reads !== undefined ? { reads } : {}),
4030
4053
  ...(outputMode !== undefined ? { outputMode } : {}),
4031
4054
  ...(outputSchema !== undefined ? { outputSchema } : {}),
4032
4055
  ...(acceptance !== undefined ? { acceptance } : {}),
@@ -4238,6 +4261,14 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4238
4261
  if (job) {
4239
4262
  job.status = status.state;
4240
4263
  job.updatedAt = status.lastUpdate;
4264
+ job.activityState = status.activityState;
4265
+ job.lastActivityAt = status.lastActivityAt;
4266
+ job.currentTool = status.currentTool;
4267
+ job.currentToolStartedAt = status.currentToolStartedAt;
4268
+ job.currentPath = status.currentPath;
4269
+ job.turnCount = status.turnCount;
4270
+ job.toolCount = status.toolCount;
4271
+ job.currentStep = status.currentStep;
4241
4272
  if (status.steps) {
4242
4273
  job.steps = status.steps.map((step, index) => ({ ...step, index }));
4243
4274
  job.agents = status.steps.map((step) => step.agent);
@@ -4248,6 +4279,27 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4248
4279
  job.workflow = status.workflow;
4249
4280
  }
4250
4281
  };
4282
+ const projectWorkflowActivity = () => {
4283
+ const steps = status.steps ?? [];
4284
+ const runningSteps = steps.filter((step) => step.status === "running");
4285
+ const lastActivityAt = runningSteps.reduce<number | undefined>((latest, step) => step.lastActivityAt === undefined ? latest : Math.max(latest ?? step.lastActivityAt, step.lastActivityAt), undefined);
4286
+ const activeToolStep = runningSteps
4287
+ .filter((step) => step.currentTool)
4288
+ .sort((left, right) => (left.lastActivityAt ?? 0) - (right.lastActivityAt ?? 0))
4289
+ .at(-1);
4290
+ status.activityState = runningSteps.some((step) => step.activityState === "needs_attention")
4291
+ ? "needs_attention"
4292
+ : runningSteps.some((step) => step.activityState === "active_long_running") ? "active_long_running" : undefined;
4293
+ status.lastActivityAt = lastActivityAt;
4294
+ status.currentTool = activeToolStep?.currentTool;
4295
+ status.currentToolStartedAt = activeToolStep?.currentToolStartedAt;
4296
+ status.currentPath = activeToolStep?.currentPath;
4297
+ const turnCounts = steps.flatMap((step) => step.turnCount === undefined ? [] : [step.turnCount]);
4298
+ const toolCounts = steps.flatMap((step) => step.toolCount === undefined ? [] : [step.toolCount]);
4299
+ status.turnCount = turnCounts.length > 0 ? turnCounts.reduce((total, count) => total + count, 0) : undefined;
4300
+ status.toolCount = toolCounts.length > 0 ? toolCounts.reduce((total, count) => total + count, 0) : undefined;
4301
+ status.currentStep = runningSteps.length === 1 ? steps.indexOf(runningSteps[0]!) : undefined;
4302
+ };
4251
4303
  const workflowJob: AsyncJobState = { asyncId: workflowRunId, asyncDir, cwd: workflowCwd, status: "running", sessionId: currentSessionId ?? undefined, mode: "workflow", agents: [], steps: [], startedAt, updatedAt: startedAt, ...(timeout !== undefined ? { timeoutMs: timeout, deadlineAt: startedAt + timeout } : {}), workflow: status.workflow };
4252
4304
  deps.state.asyncJobs.set(workflowRunId, workflowJob);
4253
4305
  deps.state.fleetJobs ??= new Map();
@@ -4271,9 +4323,10 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4271
4323
  if (entry.durationMs === undefined) delete existing.durationMs;
4272
4324
  else existing.durationMs = entry.durationMs;
4273
4325
  } else {
4274
- status.steps?.push({ agent: entry.key, label: entry.key, workflowKey: entry.key, parentWorkflowRunId: workflowRunId, status: mapped });
4326
+ status.steps?.push({ agent: entry.key, label: entry.key, workflowKey: entry.key, parentWorkflowRunId: workflowRunId, status: mapped, startedAt: Date.now() });
4275
4327
  }
4276
4328
  }
4329
+ projectWorkflowActivity();
4277
4330
  persist();
4278
4331
  appendWorkflowEvent({ type: "subagent.workflow.trace", trace });
4279
4332
  };
@@ -4296,7 +4349,27 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4296
4349
  if (budgetState?.exhausted) return workflowChildResult(key, buildRequestedModeError(childParams as SubagentParamsLike, usageBudgetExceededMessage(budgetState)));
4297
4350
  patchMissionObjective(childParams.task);
4298
4351
  const childRequest = prepareWorkflowLaunchParams(workflowChildDefaults, childParams, workflowRunId, key, { missionDetached: detachWorkflowChildMissions });
4299
- const result = await execute(randomUUID(), childRequest, workflowSignal, undefined, ctx, preserveActiveSession);
4352
+ const result = await execute(randomUUID(), childRequest, workflowSignal, (update) => {
4353
+ const progress = update.details.progress?.[0];
4354
+ const step = status.steps?.find((candidate) => candidate.workflowKey === key);
4355
+ if (!progress || !step) return;
4356
+ step.status = progress.status === "completed" ? "completed" : progress.status === "failed" ? "failed" : "running";
4357
+ step.activityState = progress.activityState;
4358
+ step.lastActivityAt = progress.lastActivityAt;
4359
+ step.currentTool = progress.currentTool;
4360
+ step.currentToolArgs = progress.currentToolArgs;
4361
+ step.currentToolStartedAt = progress.currentToolStartedAt;
4362
+ step.currentPath = progress.currentPath;
4363
+ step.recentTools = progress.recentTools.map((tool) => ({ ...tool }));
4364
+ step.recentOutput = [...progress.recentOutput];
4365
+ step.turnCount = progress.turnCount;
4366
+ step.toolCount = progress.toolCount;
4367
+ step.model = progress.model;
4368
+ step.thinking = progress.thinking;
4369
+ step.error = progress.error;
4370
+ projectWorkflowActivity();
4371
+ persist();
4372
+ }, ctx, preserveActiveSession);
4300
4373
  workflowResults.push(...result.details.results);
4301
4374
  const child = workflowChildResult(key, result);
4302
4375
  if (result.details.asyncId) {
@@ -4312,16 +4385,16 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4312
4385
  const summary = `Workflow completed with ${workflow.children.length} child run(s). Return: ${returnPreview}${emitPreview} Trace: ${workflow.trace.length} event(s).`;
4313
4386
  const workflowUsage = sumResultsUsage(workflowResults);
4314
4387
  status = { ...status, state: "complete", endedAt: Date.now(), workflow: { value: workflow.value, trace: workflow.trace, emits: workflow.emits, console: workflow.console }, totalTokens: { input: workflowUsage.input, output: workflowUsage.output, total: workflowUsage.input + workflowUsage.output }, totalCost: sumResultsCost(workflowResults) };
4388
+ writeAtomicJson(resultPath, { id: workflowRunId, runId: workflowRunId, toolCallId, agent: "workflow", mode: "workflow", success: true, state: "complete", summary, output: summary, results: workflow.children.map((child) => ({ agent: child.key, ...(child.runId ? { runId: child.runId } : {}), output: child.output, outputState: child.output.trim() || child.structuredOutput !== undefined ? "present" : "absent", structuredOutput: child.structuredOutput, success: child.ok, ...(child.artifactPaths[0] ? { artifactPaths: { outputPath: child.artifactPaths[0] } } : {}) })), workflow: status.workflow, asyncDir, cwd: workflowCwd, sessionId: currentSessionId, timestamp: Date.now(), durationMs: Date.now() - startedAt });
4315
4389
  persist();
4316
4390
  appendWorkflowEvent({ type: "subagent.workflow.completed", state: "complete" });
4317
- writeAtomicJson(resultPath, { id: workflowRunId, runId: workflowRunId, toolCallId, agent: "workflow", mode: "workflow", success: true, state: "complete", summary, output: summary, results: workflow.children.map((child) => ({ agent: child.key, output: child.output, outputState: child.output.trim() || child.structuredOutput !== undefined ? "present" : "absent", structuredOutput: child.structuredOutput, success: child.ok, ...(child.artifactPaths[0] ? { artifactPaths: { outputPath: child.artifactPaths[0] } } : {}) })), workflow: status.workflow, asyncDir, cwd: workflowCwd, sessionId: currentSessionId, timestamp: Date.now(), durationMs: Date.now() - startedAt });
4318
4391
  } catch (error) {
4319
4392
  const partial = error instanceof WorkflowScriptError ? error.partial : { trace: [], emits: [], console: [], children: [] };
4320
4393
  const stopped = controller.signal.aborted;
4321
4394
  status = compactOptional<AsyncStatus>({ ...status, state: stopped ? "stopped" : "failed", stopped: stopped || undefined, error: error instanceof Error ? error.message : String(error), endedAt: Date.now(), workflow: { trace: partial.trace, emits: partial.emits, console: partial.console } });
4395
+ writeAtomicJson(resultPath, { id: workflowRunId, runId: workflowRunId, toolCallId, agent: "workflow", mode: "workflow", success: false, state: status.state, summary: status.error, error: status.error, stopped: status.stopped, results: partial.children.map((child) => ({ agent: child.key, ...(child.runId ? { runId: child.runId } : {}), output: child.output, outputState: child.output.trim() || child.structuredOutput !== undefined ? "present" : "absent", structuredOutput: child.structuredOutput, success: child.ok, ...(child.artifactPaths[0] ? { artifactPaths: { outputPath: child.artifactPaths[0] } } : {}) })), workflow: status.workflow, asyncDir, cwd: workflowCwd, sessionId: currentSessionId, timestamp: Date.now(), durationMs: Date.now() - startedAt });
4322
4396
  persist();
4323
4397
  appendWorkflowEvent({ type: "subagent.workflow.completed", state: status.state, error: status.error });
4324
- writeAtomicJson(resultPath, { id: workflowRunId, runId: workflowRunId, toolCallId, agent: "workflow", mode: "workflow", success: false, state: status.state, summary: status.error, error: status.error, stopped: status.stopped, results: partial.children.map((child) => ({ agent: child.key, output: child.output, outputState: child.output.trim() || child.structuredOutput !== undefined ? "present" : "absent", structuredOutput: child.structuredOutput, success: child.ok, ...(child.artifactPaths[0] ? { artifactPaths: { outputPath: child.artifactPaths[0] } } : {}) })), workflow: status.workflow, asyncDir, cwd: workflowCwd, sessionId: currentSessionId, timestamp: Date.now(), durationMs: Date.now() - startedAt });
4325
4398
  } finally {
4326
4399
  deps.state.workflowControllers?.delete(workflowRunId);
4327
4400
  }
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import * as fs from "node:fs";
6
+ import * as os from "node:os";
6
7
  import * as path from "node:path";
7
8
  import type { AgentConfig } from "../agents/agents.ts";
8
9
  import { normalizeSkillInput } from "../agents/skills.ts";
@@ -334,10 +335,22 @@ export function suppressProgressForReadOnlyTask(behavior: ResolvedStepBehavior,
334
335
  // =============================================================================
335
336
 
336
337
  /**
337
- * Resolve a file path: absolute paths pass through, relative paths get chainDir prepended.
338
+ * Expand a leading `~`/`~/` to the user's home directory. Other forms (relative,
339
+ * absolute, `~user/`) pass through unchanged.
338
340
  */
339
- function resolveChainPath(filePath: string, chainDir: string): string {
340
- return path.isAbsolute(filePath) ? filePath : path.join(chainDir, filePath);
341
+ export function expandHomePath(filePath: string): string {
342
+ if (filePath === "~") return os.homedir();
343
+ if (filePath.startsWith("~/")) return path.join(os.homedir(), filePath.slice(2));
344
+ return filePath;
345
+ }
346
+
347
+ /**
348
+ * Resolve a file path: `~`/`~/` expand to home first, then absolute paths pass
349
+ * through and relative paths get chainDir prepended.
350
+ */
351
+ export function resolveChainPath(filePath: string, chainDir: string): string {
352
+ const expanded = expandHomePath(filePath);
353
+ return path.isAbsolute(expanded) ? expanded : path.join(chainDir, expanded);
341
354
  }
342
355
 
343
356
  /**
@@ -947,6 +947,31 @@ export interface SpawnBudgetSnapshot {
947
947
  grantHistory: SpawnBudgetGrant[];
948
948
  }
949
949
 
950
+ /** Slim per-child projection of a terminal result payload, safe to surface in tool_result details. */
951
+ export interface WaitCompletionChild {
952
+ agent?: string;
953
+ /** Child run identity where the producer records one (workflow children); artifact files are keyed by it. */
954
+ runId?: string;
955
+ success?: boolean;
956
+ outputState?: SubagentOutputState;
957
+ error?: string;
958
+ model?: string;
959
+ artifactPaths?: Partial<ArtifactPaths>;
960
+ }
961
+
962
+ /**
963
+ * Terminal completion observed for a run a subagent_wait call covered. Carries run
964
+ * identity and the artifact trail; output text stays in the tool result content.
965
+ */
966
+ export interface WaitCompletion {
967
+ runId: string;
968
+ agent?: string;
969
+ mode?: string;
970
+ state?: string;
971
+ success?: boolean;
972
+ results?: WaitCompletionChild[];
973
+ }
974
+
950
975
  export interface Details {
951
976
  mode: SubagentResultMode | "management";
952
977
  runId?: string;
@@ -955,6 +980,13 @@ export interface Details {
955
980
  /** Run-level context summary. "mixed" when children resolved to different modes. */
956
981
  context?: "fresh" | "fork" | "mixed";
957
982
  results: SingleResult[];
983
+ /**
984
+ * Terminal completion payloads for runs this subagent_wait call observed
985
+ * finishing. Async completions travel as result files that are consumed and
986
+ * deleted after text delivery, so without this field their run and artifact
987
+ * identity never reaches tool_result details.
988
+ */
989
+ completions?: WaitCompletion[];
958
990
  controlEvents?: ControlEvent[];
959
991
  steering?: SteerActionResult;
960
992
  asyncId?: string;
@@ -1582,6 +1614,8 @@ export interface SubagentState {
1582
1614
  lastUiContext: ExtensionContext | null;
1583
1615
  poller: NodeJS.Timeout | null;
1584
1616
  completionSeen: Map<string, number>;
1617
+ /** Terminal result payloads observed by the result watcher, keyed by run id and pruned by the completion TTL. */
1618
+ completedResults?: Map<string, { seenAt: number; completion: WaitCompletion }>;
1585
1619
  watcher: FSWatcher | null;
1586
1620
  watcherRestartTimer: ReturnType<typeof setTimeout> | null;
1587
1621
  resultFileCoalescer: {
@@ -105,6 +105,17 @@ const capturedConsole = Object.freeze(Object.fromEntries(
105
105
  }]),
106
106
  ));
107
107
 
108
+ function formatWorkflowScriptSyntaxError(error) {
109
+ const details = error && error.stack ? error.stack : String(error);
110
+ return [
111
+ "workflowScript must be valid JavaScript.",
112
+ "If task text contains Markdown fences or backticks, use an array joined with \"\\n\" or escaped strings instead of a raw backtick template literal.",
113
+ "",
114
+ "Original SyntaxError:",
115
+ details,
116
+ ].join("\n");
117
+ }
118
+
108
119
  function assertJsonValue(value, path = "emit", seen = new Set()) {
109
120
  if (value === null || typeof value === "string" || typeof value === "boolean") return;
110
121
  if (typeof value === "number") {
@@ -143,7 +154,14 @@ parentPort.on("message", async (message) => {
143
154
  if (message.stateEnabled) sandbox.state = state;
144
155
  const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
145
156
  contextObjectPrototype = vm.runInContext("Object.prototype", context);
146
- const compiled = new vm.Script("(async () => {\n" + message.script + "\n})()", { filename: "workflow-script.js" });
157
+ let compiled;
158
+ try {
159
+ compiled = new vm.Script("(async () => {\n" + message.script + "\n})()", { filename: "workflow-script.js" });
160
+ } catch (error) {
161
+ if (!(error instanceof SyntaxError)) throw error;
162
+ parentPort.postMessage({ type: "error", error: formatWorkflowScriptSyntaxError(error) });
163
+ return;
164
+ }
147
165
  const value = await compiled.runInContext(context);
148
166
  const persistedValue = value === undefined ? null : value;
149
167
  assertJsonValue(persistedValue, "return");
@@ -385,8 +403,12 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
385
403
 
386
404
  const respond = (promise: Promise<unknown>) => {
387
405
  void promise.then(
388
- (value) => worker.postMessage({ type: "response", callId: message.callId, ok: true, value: omitUndefinedWorkflowValues(value) }),
389
- (error: unknown) => worker.postMessage({ type: "response", callId: message.callId, ok: false, error: error instanceof Error ? error.message : String(error) }),
406
+ (value) => {
407
+ if (!settled) worker.postMessage({ type: "response", callId: message.callId, ok: true, value: omitUndefinedWorkflowValues(value) });
408
+ },
409
+ (error: unknown) => {
410
+ if (!settled) worker.postMessage({ type: "response", callId: message.callId, ok: false, error: error instanceof Error ? error.message : String(error) });
411
+ },
390
412
  );
391
413
  };
392
414