pi-subagents 0.44.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.45.0] - 2026-08-09
6
+
7
+ ### Added
8
+ - 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.
9
+
10
+ ### Changed
11
+ - Clarified mission-use policy in the packaged `pi-subagents` skill.
12
+
13
+ ### Fixed
14
+ - Prefix quoted Herdr pane commands with PowerShell's call operator on Windows. Thanks to @qsgy-edge for #921.
15
+ - 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.
16
+ - Expand `reads` home paths and apply configured reads to single-run launches. Thanks to @Adjuvant (Thomas Deacon) for #916.
17
+ - Drop late workflow child responses after worker settlement. Thanks to @xz-dev (Xiangzhe) for #922.
18
+ - Stabilize steering recovery tests by invalidating cached status metadata after fast test rewrites.
19
+
5
20
  ## [0.44.0] - 2026-08-08
6
21
 
7
22
  ### 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.0",
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:
@@ -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
 
@@ -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) {
@@ -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
+ }
@@ -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;
@@ -2562,6 +2565,7 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
2562
2565
  skills,
2563
2566
  output: effectiveOutput,
2564
2567
  outputMode: effectiveOutputMode,
2568
+ ...(params.reads !== undefined ? { reads: params.reads } : {}),
2565
2569
  outputBaseDir: resolveSingleRunOutputBaseDir(deps, artifactsDir, id),
2566
2570
  modelOverride,
2567
2571
  thinkingOverride: thinkingOverrideForTask(params.agent!, 0, modelOverride),
@@ -3590,6 +3594,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
3590
3594
  data.modelScope === undefined ? {} : { scope: data.modelScope },
3591
3595
  );
3592
3596
  let skillOverride: string[] | false | undefined = normalizeSkillInput(params.skill);
3597
+ let readsOverride: string[] | false | undefined = params.reads;
3593
3598
  const rawOutput = params.output !== undefined ? params.output : agentConfig.output;
3594
3599
  let effectiveOutput = normalizeSingleOutputOverride(rawOutput, agentConfig.output);
3595
3600
  const effectiveOutputMode = params.outputMode ?? "inline";
@@ -3627,6 +3632,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
3627
3632
  if (override?.model !== undefined) modelOverride = resolveEffectiveSubagentModel(override.model, agentConfig.model, parentModel, availableModels, currentProvider, data.modelScope === undefined ? {} : { scope: data.modelScope });
3628
3633
  if (override?.output !== undefined) effectiveOutput = normalizeSingleOutputOverride(override.output, agentConfig.output);
3629
3634
  if (override?.skills !== undefined) skillOverride = override.skills;
3635
+ if (override?.reads !== undefined) readsOverride = override.reads;
3630
3636
 
3631
3637
  if (result.runInBackground) {
3632
3638
  if (!isAsyncAvailable()) {
@@ -3666,6 +3672,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
3666
3672
  skills: skillOverride === false ? [] : skillOverride,
3667
3673
  output: effectiveOutput,
3668
3674
  outputMode: effectiveOutputMode,
3675
+ ...(readsOverride !== undefined ? { reads: readsOverride } : {}),
3669
3676
  outputBaseDir: resolveSingleRunOutputBaseDir(deps, artifactsDir, id),
3670
3677
  modelOverride,
3671
3678
  thinkingOverride: thinkingOverrideForTask(params.agent!, 0, modelOverride),
@@ -3702,6 +3709,13 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
3702
3709
  const structuredRuntime = params.outputSchema
3703
3710
  ? createStructuredOutputRuntime(params.outputSchema, artifactConfig.enabled ? path.join(artifactsDir, "structured-output", runId) : undefined)
3704
3711
  : undefined;
3712
+ // Reads: caller override > agent defaultReads > none. `~`/`~/` expand to home;
3713
+ // absolute paths pass through; relative paths resolve against the child cwd.
3714
+ const reads = readsOverride !== undefined ? readsOverride : agentConfig.defaultReads ?? false;
3715
+ const readsInstruction = Array.isArray(reads) && reads.length > 0
3716
+ ? `[Read from: ${reads.map((f) => resolveChainPath(f, effectiveCwd)).join(", ")}]\n\n`
3717
+ : "";
3718
+ task = readsInstruction + task;
3705
3719
  task = injectSingleOutputInstruction(task, outputPath, agentConfig);
3706
3720
 
3707
3721
  let effectiveSkills: string[] | undefined;
@@ -4016,6 +4030,7 @@ function prepareWorkflowChildParams(params: SubagentParamsLike): SubagentParamsL
4016
4030
  acceptance,
4017
4031
  agentContract,
4018
4032
  toolBudget,
4033
+ reads,
4019
4034
  ...runParams
4020
4035
  } = params;
4021
4036
  return {
@@ -4027,6 +4042,7 @@ function prepareWorkflowChildParams(params: SubagentParamsLike): SubagentParamsL
4027
4042
  ...(model !== undefined ? { model } : {}),
4028
4043
  ...(skill !== undefined ? { skill } : {}),
4029
4044
  ...(output !== undefined ? { output } : {}),
4045
+ ...(reads !== undefined ? { reads } : {}),
4030
4046
  ...(outputMode !== undefined ? { outputMode } : {}),
4031
4047
  ...(outputSchema !== undefined ? { outputSchema } : {}),
4032
4048
  ...(acceptance !== undefined ? { acceptance } : {}),
@@ -4238,6 +4254,14 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4238
4254
  if (job) {
4239
4255
  job.status = status.state;
4240
4256
  job.updatedAt = status.lastUpdate;
4257
+ job.activityState = status.activityState;
4258
+ job.lastActivityAt = status.lastActivityAt;
4259
+ job.currentTool = status.currentTool;
4260
+ job.currentToolStartedAt = status.currentToolStartedAt;
4261
+ job.currentPath = status.currentPath;
4262
+ job.turnCount = status.turnCount;
4263
+ job.toolCount = status.toolCount;
4264
+ job.currentStep = status.currentStep;
4241
4265
  if (status.steps) {
4242
4266
  job.steps = status.steps.map((step, index) => ({ ...step, index }));
4243
4267
  job.agents = status.steps.map((step) => step.agent);
@@ -4248,6 +4272,26 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4248
4272
  job.workflow = status.workflow;
4249
4273
  }
4250
4274
  };
4275
+ const projectWorkflowActivity = () => {
4276
+ const runningSteps = (status.steps ?? []).filter((step) => step.status === "running");
4277
+ const lastActivityAt = runningSteps.reduce<number | undefined>((latest, step) => step.lastActivityAt === undefined ? latest : Math.max(latest ?? step.lastActivityAt, step.lastActivityAt), undefined);
4278
+ const activeToolStep = runningSteps
4279
+ .filter((step) => step.currentTool)
4280
+ .sort((left, right) => (left.lastActivityAt ?? 0) - (right.lastActivityAt ?? 0))
4281
+ .at(-1);
4282
+ status.activityState = runningSteps.some((step) => step.activityState === "needs_attention")
4283
+ ? "needs_attention"
4284
+ : runningSteps.some((step) => step.activityState === "active_long_running") ? "active_long_running" : undefined;
4285
+ status.lastActivityAt = lastActivityAt;
4286
+ status.currentTool = activeToolStep?.currentTool;
4287
+ status.currentToolStartedAt = activeToolStep?.currentToolStartedAt;
4288
+ status.currentPath = activeToolStep?.currentPath;
4289
+ const turnCounts = (status.steps ?? []).flatMap((step) => step.turnCount === undefined ? [] : [step.turnCount]);
4290
+ const toolCounts = (status.steps ?? []).flatMap((step) => step.toolCount === undefined ? [] : [step.toolCount]);
4291
+ status.turnCount = turnCounts.length > 0 ? turnCounts.reduce((total, count) => total + count, 0) : undefined;
4292
+ status.toolCount = toolCounts.length > 0 ? toolCounts.reduce((total, count) => total + count, 0) : undefined;
4293
+ status.currentStep = runningSteps.length === 1 ? status.steps?.indexOf(runningSteps[0]!) : undefined;
4294
+ };
4251
4295
  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
4296
  deps.state.asyncJobs.set(workflowRunId, workflowJob);
4253
4297
  deps.state.fleetJobs ??= new Map();
@@ -4271,9 +4315,10 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4271
4315
  if (entry.durationMs === undefined) delete existing.durationMs;
4272
4316
  else existing.durationMs = entry.durationMs;
4273
4317
  } else {
4274
- status.steps?.push({ agent: entry.key, label: entry.key, workflowKey: entry.key, parentWorkflowRunId: workflowRunId, status: mapped });
4318
+ status.steps?.push({ agent: entry.key, label: entry.key, workflowKey: entry.key, parentWorkflowRunId: workflowRunId, status: mapped, startedAt: Date.now() });
4275
4319
  }
4276
4320
  }
4321
+ projectWorkflowActivity();
4277
4322
  persist();
4278
4323
  appendWorkflowEvent({ type: "subagent.workflow.trace", trace });
4279
4324
  };
@@ -4296,7 +4341,27 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4296
4341
  if (budgetState?.exhausted) return workflowChildResult(key, buildRequestedModeError(childParams as SubagentParamsLike, usageBudgetExceededMessage(budgetState)));
4297
4342
  patchMissionObjective(childParams.task);
4298
4343
  const childRequest = prepareWorkflowLaunchParams(workflowChildDefaults, childParams, workflowRunId, key, { missionDetached: detachWorkflowChildMissions });
4299
- const result = await execute(randomUUID(), childRequest, workflowSignal, undefined, ctx, preserveActiveSession);
4344
+ const result = await execute(randomUUID(), childRequest, workflowSignal, (update) => {
4345
+ const progress = update.details.progress?.[0];
4346
+ const step = status.steps?.find((candidate) => candidate.workflowKey === key);
4347
+ if (!progress || !step) return;
4348
+ step.status = progress.status === "completed" ? "completed" : progress.status === "failed" ? "failed" : "running";
4349
+ step.activityState = progress.activityState;
4350
+ step.lastActivityAt = progress.lastActivityAt;
4351
+ step.currentTool = progress.currentTool;
4352
+ step.currentToolArgs = progress.currentToolArgs;
4353
+ step.currentToolStartedAt = progress.currentToolStartedAt;
4354
+ step.currentPath = progress.currentPath;
4355
+ step.recentTools = progress.recentTools.map((tool) => ({ ...tool }));
4356
+ step.recentOutput = [...progress.recentOutput];
4357
+ step.turnCount = progress.turnCount;
4358
+ step.toolCount = progress.toolCount;
4359
+ step.model = progress.model;
4360
+ step.thinking = progress.thinking;
4361
+ step.error = progress.error;
4362
+ projectWorkflowActivity();
4363
+ persist();
4364
+ }, ctx, preserveActiveSession);
4300
4365
  workflowResults.push(...result.details.results);
4301
4366
  const child = workflowChildResult(key, result);
4302
4367
  if (result.details.asyncId) {
@@ -4312,16 +4377,16 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4312
4377
  const summary = `Workflow completed with ${workflow.children.length} child run(s). Return: ${returnPreview}${emitPreview} Trace: ${workflow.trace.length} event(s).`;
4313
4378
  const workflowUsage = sumResultsUsage(workflowResults);
4314
4379
  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) };
4380
+ 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
4381
  persist();
4316
4382
  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
4383
  } catch (error) {
4319
4384
  const partial = error instanceof WorkflowScriptError ? error.partial : { trace: [], emits: [], console: [], children: [] };
4320
4385
  const stopped = controller.signal.aborted;
4321
4386
  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 } });
4387
+ 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
4388
  persist();
4323
4389
  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
4390
  } finally {
4326
4391
  deps.state.workflowControllers?.delete(workflowRunId);
4327
4392
  }
@@ -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: {
@@ -385,8 +385,12 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
385
385
 
386
386
  const respond = (promise: Promise<unknown>) => {
387
387
  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) }),
388
+ (value) => {
389
+ if (!settled) worker.postMessage({ type: "response", callId: message.callId, ok: true, value: omitUndefinedWorkflowValues(value) });
390
+ },
391
+ (error: unknown) => {
392
+ if (!settled) worker.postMessage({ type: "response", callId: message.callId, ok: false, error: error instanceof Error ? error.message : String(error) });
393
+ },
390
394
  );
391
395
  };
392
396