pi-subagents 0.43.0 → 0.44.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,27 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.44.0] - 2026-08-08
6
+
7
+ ### Added
8
+ - Added one automatic enclosing mission and durable workflow state to plain `workflowScript` launches; child runs no longer create separate missions.
9
+ - Added `scheduledRuns.storeRoot` for durable schedules outside project repositories. Thanks to @ProCleiton for #891 and the prior #890 implementation.
10
+
11
+ ### Changed
12
+ - Clarified native supervisor messaging and optional external intercom result delivery in the docs and packaged skill.
13
+ - Identify status and transcript targets before the spawn-budget summary in collapsed tool-result cards.
14
+ - Point interactive async-launch guidance to `subagent_wait({ id, nonBlocking: true })` when an explicit wake is needed without blocking the current turn.
15
+
16
+ ### Fixed
17
+ - Report the explicit workflow execution cwd in async workflow status, job, and result records. Thanks to @nicobailon for #907.
18
+ - Ignore stale extension-context errors from advisory foreground control notifications after reload. Thanks to @alexei-led for #905.
19
+ - Bound inherited portable tool IDs to 64 characters for Codex-compatible child contexts while keeping tool calls and results paired. Thanks to @alexei-led for #903.
20
+ - Prevent boolean chain `output` values from crashing clarify rendering. Thanks to @ftoleedo for #901.
21
+ - Preserve `workflow` mode when asynchronous workflow mission runs complete.
22
+ - Serialize and merge each mission workflow-state write with the latest file so separate workflows do not drop unrelated keys.
23
+ - Preserve `workflowScript` worktree children that detach for supervisor coordination instead of cleaning a live managed worktree. Thanks to @astarktc for #896.
24
+ - Accept schema-valid structured output after a child recovers from an earlier tool error. Thanks to @white-hat for the report in #888.
25
+
5
26
  ## [0.43.0] - 2026-08-07
6
27
 
7
28
  ### Added
@@ -19,6 +40,7 @@
19
40
  - Require workflowScript-only persisted schedule targets. Removed legacy agent-target restore conversion.
20
41
 
21
42
  ### Fixed
43
+ - Use portable internal ids for async workflow directories and preserve host tool-call ids as correlation metadata. Thanks to @DrunkenDonkey80 for #889.
22
44
  - Represent gate normalization with explicit success and failure results, removing ambiguous internal states without changing gate behavior.
23
45
  - Preserve live composite child tool-call ids for APIs that normalize them, preventing context rewriting from breaking the next tool-loop turn.
24
46
  - Sanitize inherited child tool history ids so forked subagent context stays provider-portable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "description": "Pi extension for single-agent delegation and scripted multi-agent workflows",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
@@ -367,7 +367,9 @@ Use `oracle` as a smart-friend escalation when the parent needs help with trajec
367
367
 
368
368
  ## Subagent + Intercom Coordination
369
369
 
370
- `pi-subagents` includes native supervisor coordination. Child agents can use `contact_supervisor` to ask the exact parent session that spawned them; messages are scoped by parent session id and should not appear in other Pi sessions.
370
+ `pi-subagents` includes native supervisor coordination. Child agents can use `contact_supervisor` to ask the exact parent session that spawned them; messages are scoped by parent session id and should not appear in other Pi sessions. Parents inspect or reply with `subagent_supervisor`. This path does not require `pi-intercom`.
371
+
372
+ This is separate from optional external completion delivery. Set `intercomBridge.resultDelivery: true` only when an external listener consumes and acknowledges `subagent:result-intercom` grouped results. It does not deliver results by itself, and it does not change native supervisor asks or progress updates.
371
373
 
372
374
  Most agents should not call generic `intercom` directly unless bridge instructions provide a target and `contact_supervisor` is unavailable. Do not invent a target. Prefer the tool from the injected bridge instructions.
373
375
 
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import * as os from "node:os";
2
3
  import * as path from "node:path";
3
4
  import type { ArtifactDirPreference, ExtensionConfig } from "../shared/types.ts";
4
5
  import { validateMissionStoreConfig } from "../missions/store.ts";
@@ -8,6 +9,31 @@ import { validatePermissionConfig } from "../runs/shared/permissions.ts";
8
9
 
9
10
  const ARTIFACT_DIR_PREFERENCES = new Set<ArtifactDirPreference>(["project", "session", "temp"]);
10
11
 
12
+ export function resolveScheduledStoreRoot(value: string): string {
13
+ const expanded = value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
14
+ if (!path.isAbsolute(expanded)) throw new Error(`config.scheduledRuns.storeRoot must be an absolute path or "~/...", got ${JSON.stringify(value)}`);
15
+ return path.normalize(expanded);
16
+ }
17
+
18
+ function validateScheduledRunsConfig(value: unknown): void {
19
+ if (value === undefined) return;
20
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("config.scheduledRuns must be a JSON object");
21
+ const storeRoot = (value as Record<string, unknown>).storeRoot;
22
+ if (storeRoot === undefined) return;
23
+ if (typeof storeRoot !== "string" || !storeRoot.trim()) throw new Error("config.scheduledRuns.storeRoot must be a non-empty string");
24
+ resolveScheduledStoreRoot(storeRoot);
25
+ }
26
+
27
+ function validateConfig(config: Record<string, unknown>): void {
28
+ if (config.artifactDir !== undefined && !ARTIFACT_DIR_PREFERENCES.has(config.artifactDir as ArtifactDirPreference)) {
29
+ throw new Error(`config.artifactDir must be "project", "session", or "temp"`);
30
+ }
31
+ validateMissionStoreConfig(config.missions);
32
+ validateAuthorityPolicy(config.authorityPolicy);
33
+ validatePermissionConfig(config.permissions);
34
+ validateScheduledRunsConfig(config.scheduledRuns);
35
+ }
36
+
11
37
  export function getConfigPath(): string {
12
38
  return path.join(getAgentDir(), "extensions", "subagent", "config.json");
13
39
  }
@@ -18,13 +44,7 @@ function readConfigForUpdate(configPath = getConfigPath()): ExtensionConfig {
18
44
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
19
45
  throw new Error(`Subagent config at '${configPath}' must be a JSON object`);
20
46
  }
21
- const config = parsed as Record<string, unknown>;
22
- if (config.artifactDir !== undefined && !ARTIFACT_DIR_PREFERENCES.has(config.artifactDir as ArtifactDirPreference)) {
23
- throw new Error(`config.artifactDir must be "project", "session", or "temp"`);
24
- }
25
- validateMissionStoreConfig(config.missions);
26
- validateAuthorityPolicy(config.authorityPolicy);
27
- validatePermissionConfig(config.permissions);
47
+ validateConfig(parsed as Record<string, unknown>);
28
48
  return parsed as ExtensionConfig;
29
49
  }
30
50
 
@@ -36,6 +56,7 @@ export function saveConfig(config: ExtensionConfig, configPath = getConfigPath()
36
56
  export function updateConfig(updater: (config: ExtensionConfig) => ExtensionConfig): ExtensionConfig {
37
57
  const configPath = getConfigPath();
38
58
  const next = updater(readConfigForUpdate(configPath));
59
+ validateConfig(next as Record<string, unknown>);
39
60
  saveConfig(next, configPath);
40
61
  return next;
41
62
  }
@@ -50,7 +50,7 @@ import { formatSteeringNotice, handleSubagentSteeringNotice, SUBAGENT_STEERING_M
50
50
  import { SUBAGENT_CHILD_ENV, SUBAGENT_PARENT_SESSION_ENV } from "../runs/shared/pi-args.ts";
51
51
  import { resolveCurrentSubagentCapabilityCeiling } from "../runs/shared/capability-ceiling.ts";
52
52
  import { formatDuration, shortenPath } from "../shared/formatters.ts";
53
- import { loadConfig, resolveAsyncByDefault } from "./config.ts";
53
+ import { loadConfig, resolveAsyncByDefault, resolveScheduledStoreRoot } from "./config.ts";
54
54
  import { buildSubagentToolDescription } from "./tool-description.ts";
55
55
  import { collectGoalContinuationNotices } from "../missions/goal-driver.ts";
56
56
  import { syncMissionFromAsyncCompletion } from "../missions/lifecycle.ts";
@@ -383,8 +383,10 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
383
383
  : undefined;
384
384
  let executorScheduled: ((id: string, params: SubagentParamsLike, signal: AbortSignal, ctx: ExtensionContext) => Promise<AgentToolResult<Details>>) | undefined;
385
385
  let goalTurnId = 0;
386
+ const scheduledStoreRoot = config.scheduledRuns?.storeRoot === undefined ? undefined : resolveScheduledStoreRoot(config.scheduledRuns.storeRoot);
386
387
  const scheduledRunManager = createScheduledRunManager({
387
388
  config,
389
+ storeRoot: scheduledStoreRoot,
388
390
  launch: (params, ctx, signal) => {
389
391
  if (!executorScheduled) {
390
392
  return Promise.resolve({
@@ -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. Mission-attached workflows also get async state.get(key) and state.set(key, JSONValue); 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. 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. Mission-attached workflows also get async state.get/state.set for durable JSON state; 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. 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
 
@@ -339,7 +339,7 @@ export function syncMissionFromAsyncCompletion(value: unknown): MissionRecord |
339
339
  : undefined);
340
340
  return updateMission(binding.location, binding.missionId, {
341
341
  status: missionStatusForRun(current, runId, runStatus),
342
- addRuns: [{ runId, mode: typeof event.mode === "string" && ["single", "parallel", "chain"].includes(event.mode) ? event.mode as SubagentRunMode : "external", asyncDir: event.asyncDir, status: runStatus, completedAt, ...(usage && usage.tokens > 0 ? { usage } : {}) }],
342
+ addRuns: [{ runId, mode: typeof event.mode === "string" && ["single", "parallel", "chain", "workflow"].includes(event.mode) ? event.mode as SubagentRunMode : "external", asyncDir: event.asyncDir, status: runStatus, completedAt, ...(usage && usage.tokens > 0 ? { usage } : {}) }],
343
343
  addArtifacts: artifacts,
344
344
  ...(summary ? { summary } : {}),
345
345
  });
@@ -1,11 +1,15 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
1
3
  import * as fs from "node:fs";
2
4
  import * as path from "node:path";
3
5
  import { writePrivateAtomicJson } from "../shared/atomic-json.ts";
6
+ import { DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS, waitForFileSystemRetry } from "../shared/file-system-retry.ts";
4
7
  import { assertWorkflowJsonValue } from "../workflows/scripted-workflow.ts";
5
8
  import type { MissionStoreLocation } from "./types.ts";
6
9
  import { validateMissionId } from "./store.ts";
7
10
 
8
11
  const STATE_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
12
+ const STATE_LOCK_STALE_MS = 60_000;
9
13
  export const MISSION_STATE_MAX_BYTES = 256 * 1024;
10
14
 
11
15
  export interface MissionWorkflowState {
@@ -18,6 +22,172 @@ export function missionStatePath(location: MissionStoreLocation, missionId: stri
18
22
  return path.join(location.missionDir, validateMissionId(missionId), "state.json");
19
23
  }
20
24
 
25
+ function isProcessAlive(pid: number): boolean {
26
+ try {
27
+ process.kill(pid, 0);
28
+ return true;
29
+ } catch (error) {
30
+ return (error as NodeJS.ErrnoException).code === "EPERM";
31
+ }
32
+ }
33
+
34
+ interface StateLockOwner {
35
+ pid: number;
36
+ token: string;
37
+ createdAt: number;
38
+ processKey?: string;
39
+ }
40
+
41
+ function linuxProcessStartKey(pid: number): string | undefined {
42
+ try {
43
+ const raw = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
44
+ const tail = raw.slice(raw.lastIndexOf(")") + 2).trim().split(/\s+/);
45
+ return tail[19] ? `linux:${tail[19]}` : undefined;
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ }
50
+
51
+ function psProcessStartKey(pid: number): string | undefined {
52
+ try {
53
+ const raw = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1000 }).trim();
54
+ return raw ? `ps:${raw}` : undefined;
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ function windowsProcessStartKey(pid: number): string | undefined {
61
+ try {
62
+ const raw = execFileSync("powershell.exe", ["-NoProfile", "-Command", `(Get-CimInstance Win32_Process -Filter \"ProcessId=${pid}\").CreationDate`], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1000 }).trim();
63
+ return raw ? `win:${raw}` : undefined;
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ }
68
+
69
+ function processStartKey(pid: number): string | undefined {
70
+ if (process.platform === "linux") return linuxProcessStartKey(pid) ?? psProcessStartKey(pid);
71
+ if (process.platform === "win32") return windowsProcessStartKey(pid);
72
+ return psProcessStartKey(pid);
73
+ }
74
+
75
+ const CURRENT_PROCESS_KEY = processStartKey(process.pid);
76
+
77
+ function readStateLockOwner(lockPath: string): StateLockOwner | undefined {
78
+ try {
79
+ const owner = JSON.parse(fs.readFileSync(path.join(lockPath, "owner.json"), "utf-8")) as { pid?: unknown; token?: unknown; createdAt?: unknown; processKey?: unknown };
80
+ if (Number.isSafeInteger(owner.pid) && (owner.pid as number) > 0 && typeof owner.token === "string" && owner.token && Number.isSafeInteger(owner.createdAt)) {
81
+ return {
82
+ pid: owner.pid as number,
83
+ token: owner.token,
84
+ createdAt: owner.createdAt as number,
85
+ ...(typeof owner.processKey === "string" && owner.processKey ? { processKey: owner.processKey } : {}),
86
+ };
87
+ }
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ function stateLockIsStale(lockPath: string, now = Date.now()): boolean {
95
+ const owner = readStateLockOwner(lockPath);
96
+ if (owner) {
97
+ if (!isProcessAlive(owner.pid)) return true;
98
+ const currentProcessKey = processStartKey(owner.pid);
99
+ return Boolean(owner.processKey && currentProcessKey && owner.processKey !== currentProcessKey);
100
+ }
101
+ try {
102
+ return now - fs.statSync(lockPath).mtimeMs > STATE_LOCK_STALE_MS;
103
+ } catch (error) {
104
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
105
+ throw error;
106
+ }
107
+ }
108
+
109
+ function removeOwnedStateLock(lockPath: string, owner: StateLockOwner): void {
110
+ const current = readStateLockOwner(lockPath);
111
+ if (current?.token !== owner.token) return;
112
+ fs.rmSync(lockPath, { recursive: true, force: true });
113
+ }
114
+
115
+ function staleDirectoryExists(dirPath: string, now = Date.now()): boolean {
116
+ try {
117
+ return now - fs.statSync(dirPath).mtimeMs > STATE_LOCK_STALE_MS;
118
+ } catch (error) {
119
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
120
+ throw error;
121
+ }
122
+ }
123
+
124
+ function tryMakeDirectory(dirPath: string, mode: number): boolean {
125
+ try {
126
+ fs.mkdirSync(dirPath, { mode });
127
+ return true;
128
+ } catch (error) {
129
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
130
+ throw error;
131
+ }
132
+ }
133
+
134
+ function waitForStateLock(delayMs: number | undefined, lockPath: string): void {
135
+ if (delayMs === undefined) throw new Error(`Timed out acquiring mission state lock '${lockPath}'.`);
136
+ waitForFileSystemRetry(delayMs);
137
+ }
138
+
139
+ function reclaimStaleStateLock(lockPath: string, reclaimPath: string): boolean {
140
+ if (!stateLockIsStale(lockPath)) return false;
141
+ if (!tryMakeDirectory(reclaimPath, 0o700)) return false;
142
+ try {
143
+ if (!stateLockIsStale(lockPath)) return false;
144
+ fs.rmSync(lockPath, { recursive: true, force: true });
145
+ return true;
146
+ } finally {
147
+ fs.rmSync(reclaimPath, { recursive: true, force: true });
148
+ }
149
+ }
150
+
151
+ function withStateFileLock<T>(filePath: string, operation: () => T): T {
152
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
153
+ const lockPath = `${filePath}.lock`;
154
+ const reclaimPath = `${lockPath}.reclaim`;
155
+ let owner: StateLockOwner | undefined;
156
+ for (let attempt = 0; ; attempt++) {
157
+ if (fs.existsSync(reclaimPath)) {
158
+ if (staleDirectoryExists(reclaimPath)) {
159
+ fs.rmSync(reclaimPath, { recursive: true, force: true });
160
+ continue;
161
+ }
162
+ waitForStateLock(DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt], lockPath);
163
+ continue;
164
+ }
165
+ try {
166
+ fs.mkdirSync(lockPath, { mode: 0o700 });
167
+ owner = { pid: process.pid, token: randomUUID(), createdAt: Date.now(), ...(CURRENT_PROCESS_KEY ? { processKey: CURRENT_PROCESS_KEY } : {}) };
168
+ try {
169
+ fs.writeFileSync(path.join(lockPath, "owner.json"), JSON.stringify(owner), { encoding: "utf-8", mode: 0o600 });
170
+ } catch (error) {
171
+ removeOwnedStateLock(lockPath, owner);
172
+ owner = undefined;
173
+ throw error;
174
+ }
175
+ break;
176
+ } catch (error) {
177
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
178
+ throw new Error(`Failed to acquire mission state lock '${lockPath}': ${error instanceof Error ? error.message : String(error)}`);
179
+ }
180
+ if (reclaimStaleStateLock(lockPath, reclaimPath)) continue;
181
+ waitForStateLock(DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt], lockPath);
182
+ }
183
+ }
184
+ try {
185
+ return operation();
186
+ } finally {
187
+ if (owner) removeOwnedStateLock(lockPath, owner);
188
+ }
189
+ }
190
+
21
191
  function validateStateKey(value: unknown): string {
22
192
  if (typeof value !== "string" || !STATE_KEY_PATTERN.test(value)) {
23
193
  throw new Error("state key must be 1-128 characters using letters, numbers, '.', '_' or '-', and start with a letter or number.");
@@ -30,16 +200,12 @@ export function createMissionWorkflowState(location: MissionStoreLocation, missi
30
200
  let loaded = false;
31
201
  let values: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
32
202
 
33
- const load = (): Record<string, unknown> => {
34
- if (loaded) return values;
203
+ const readStateFile = (): Record<string, unknown> => {
35
204
  let raw: string;
36
205
  try {
37
206
  raw = fs.readFileSync(filePath, "utf-8");
38
207
  } catch (error) {
39
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
40
- loaded = true;
41
- return values;
42
- }
208
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return Object.create(null) as Record<string, unknown>;
43
209
  throw new Error(`Failed to read mission state '${filePath}': ${error instanceof Error ? error.message : String(error)}`);
44
210
  }
45
211
  const bytes = Buffer.byteLength(raw);
@@ -48,14 +214,19 @@ export function createMissionWorkflowState(location: MissionStoreLocation, missi
48
214
  const parsed: unknown = JSON.parse(raw);
49
215
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("root must be a JSON object");
50
216
  assertWorkflowJsonValue(parsed, "mission state");
51
- values = Object.assign(Object.create(null) as Record<string, unknown>, parsed);
52
- loaded = true;
53
- return values;
217
+ return Object.assign(Object.create(null) as Record<string, unknown>, parsed);
54
218
  } catch (error) {
55
219
  throw new Error(`Invalid mission state file '${filePath}': ${error instanceof Error ? error.message : String(error)}`);
56
220
  }
57
221
  };
58
222
 
223
+ const load = (): Record<string, unknown> => {
224
+ if (loaded) return values;
225
+ values = readStateFile();
226
+ loaded = true;
227
+ return values;
228
+ };
229
+
59
230
  return {
60
231
  path: filePath,
61
232
  get(key) {
@@ -66,12 +237,14 @@ export function createMissionWorkflowState(location: MissionStoreLocation, missi
66
237
  set(key, value) {
67
238
  const validKey = validateStateKey(key);
68
239
  assertWorkflowJsonValue(value, `state.set('${validKey}') value`);
69
- const next = Object.assign(Object.create(null) as Record<string, unknown>, load(), { [validKey]: value });
70
- const bytes = Buffer.byteLength(JSON.stringify(next, null, 2));
71
- if (bytes > MISSION_STATE_MAX_BYTES) throw new Error(`Mission state exceeds the 256 KiB limit (${bytes} bytes; maximum ${MISSION_STATE_MAX_BYTES} bytes).`);
72
- writePrivateAtomicJson(filePath, next);
73
- values = next;
74
- loaded = true;
240
+ withStateFileLock(filePath, () => {
241
+ const next = Object.assign(Object.create(null) as Record<string, unknown>, readStateFile(), { [validKey]: value });
242
+ const bytes = Buffer.byteLength(JSON.stringify(next, null, 2));
243
+ if (bytes > MISSION_STATE_MAX_BYTES) throw new Error(`Mission state exceeds the 256 KiB limit (${bytes} bytes; maximum ${MISSION_STATE_MAX_BYTES} bytes).`);
244
+ writePrivateAtomicJson(filePath, next);
245
+ values = next;
246
+ loaded = true;
247
+ });
75
248
  },
76
249
  };
77
250
  }
@@ -269,7 +269,8 @@ export function formatAsyncStartedMessage(headline: string, interactive: boolean
269
269
  ? [
270
270
  "The async run is detached and running in the background.",
271
271
  "You are in an interactive session. By default, return control to the user now; Pi will wake you on completion when the run finishes or needs attention. Do NOT call subagent_wait() merely to wait, and do not run sleep/polling loops to wait for it.",
272
- "Override that default and call subagent_wait() before ending the turn only when the current request is run-to-completion — for example, the user asked you to report results back here before continuing, or a skill must finish in one turn. In that case, call subagent_wait() to block until the run completes so its results are delivered in this turn instead of deferred.",
272
+ "When you need an explicit wake for one known run but do not need same-turn results, call subagent_wait({ id: \"...\", nonBlocking: true }) to arm a subscription and return immediately.",
273
+ "Override the default and call blocking subagent_wait() before ending the turn only when the current request is run-to-completion — for example, the user asked you to report results back here before continuing, or a skill must finish in one turn. In that case, call subagent_wait() to block until the run completes so its results are delivered in this turn instead of deferred.",
273
274
  "Otherwise, continue any independent work or return control to the user. Use subagent({ action: \"status\", id: \"...\" }) for a one-shot status/result or to inspect a blocked/stale run, never as a wait loop.",
274
275
  ]
275
276
  : [
@@ -65,6 +65,7 @@ interface AsyncRunStepSummary {
65
65
  export interface AsyncRunSummary {
66
66
  id: string;
67
67
  asyncDir: string;
68
+ toolCallId?: string;
68
69
  sessionId?: string;
69
70
  state: "queued" | "running" | "complete" | "failed" | "paused" | "stopped" | "rejected";
70
71
  error?: string;
@@ -288,6 +289,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
288
289
  return {
289
290
  id: status.runId || path.basename(asyncDir),
290
291
  asyncDir,
292
+ ...(status.toolCallId ? { toolCallId: status.toolCallId } : {}),
291
293
  ...(status.sessionId ? { sessionId: status.sessionId } : {}),
292
294
  state: status.state,
293
295
  ...(status.error ? { error: status.error } : {}),
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { DIRS, type SubagentState } from "../../shared/types.ts";
4
+ import { readStatus } from "../../shared/utils.ts";
4
5
  import { findAsyncRunPrefixMatches, type AsyncRunLocation } from "./async-resume.ts";
5
6
  import { assertSafeNestedId, findNestedRunMatchesById, type NestedRoute, type NestedRunMatch, type NestedRunResolutionScope } from "../shared/nested-events.ts";
6
7
 
@@ -27,6 +28,70 @@ function exactAsyncLocation(id: string, asyncDirRoot: string, resultsDir: string
27
28
  };
28
29
  }
29
30
 
31
+ type AsyncRunMatch = { id: string; location: AsyncRunLocation };
32
+
33
+ type WorkflowResultIdentity = {
34
+ id?: string;
35
+ runId?: string;
36
+ toolCallId?: string;
37
+ };
38
+
39
+ function readWorkflowResultIdentity(resultPath: string): WorkflowResultIdentity | undefined {
40
+ let parsed: unknown;
41
+ try {
42
+ parsed = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as unknown;
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
47
+ const record = parsed as Record<string, unknown>;
48
+ return {
49
+ ...(typeof record.id === "string" ? { id: record.id } : {}),
50
+ ...(typeof record.runId === "string" ? { runId: record.runId } : {}),
51
+ ...(typeof record.toolCallId === "string" ? { toolCallId: record.toolCallId } : {}),
52
+ };
53
+ }
54
+
55
+ function directoryEntries(root: string): string[] {
56
+ try {
57
+ return fs.readdirSync(root);
58
+ } catch (error) {
59
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
60
+ throw error;
61
+ }
62
+ }
63
+
64
+ function resultPathFor(resultsDir: string, runId: string): string | null {
65
+ const resultPath = path.join(resultsDir, `${runId}.json`);
66
+ return fs.existsSync(resultPath) ? resultPath : null;
67
+ }
68
+
69
+ function toolCallIdMatches(value: string | undefined, query: string, options: { prefix?: boolean }): boolean {
70
+ if (value === undefined) return false;
71
+ return options.prefix === true ? value.startsWith(query) : value === query;
72
+ }
73
+
74
+ function toolCallIdAsyncLocations(toolCallId: string, asyncDirRoot: string, resultsDir: string, options: { prefix?: boolean } = {}): AsyncRunMatch[] {
75
+ const byId = new Map<string, AsyncRunLocation>();
76
+ for (const entry of directoryEntries(asyncDirRoot)) {
77
+ const asyncDir = path.join(asyncDirRoot, entry);
78
+ const status = readStatus(asyncDir);
79
+ if (!status || !toolCallIdMatches(status.toolCallId, toolCallId, options)) continue;
80
+ const runId = status.runId || entry;
81
+ byId.set(runId, { asyncDir, resultPath: resultPathFor(resultsDir, runId), resolvedId: runId });
82
+ }
83
+ for (const entry of directoryEntries(resultsDir)) {
84
+ if (!entry.endsWith(".json")) continue;
85
+ const resultPath = path.join(resultsDir, entry);
86
+ const identity = readWorkflowResultIdentity(resultPath);
87
+ if (!identity || !toolCallIdMatches(identity.toolCallId, toolCallId, options)) continue;
88
+ const runId = identity.runId ?? identity.id ?? entry.slice(0, -".json".length);
89
+ const asyncDir = path.join(asyncDirRoot, runId);
90
+ byId.set(runId, { asyncDir: fs.existsSync(asyncDir) ? asyncDir : null, resultPath, resolvedId: runId });
91
+ }
92
+ return [...byId.entries()].map(([id, location]) => ({ id, location }));
93
+ }
94
+
30
95
  function foregroundIds(state: SubagentState | undefined): string[] {
31
96
  if (!state) return [];
32
97
  const remembered = state.currentSessionId
@@ -73,6 +138,9 @@ export function resolveSubagentRunId(id: string, deps: ResolveSubagentRunIdDeps
73
138
  if (hasExactForegroundId(deps.state, id)) return { kind: "foreground", id };
74
139
  const exactAsync = exactAsyncLocation(id, asyncDirRoot, resultsDir);
75
140
  if (exactAsync) return { kind: "async", id, location: exactAsync };
141
+ const exactToolCallIdMatches = toolCallIdAsyncLocations(id, asyncDirRoot, resultsDir);
142
+ if (exactToolCallIdMatches.length > 1) throw new Error(`Subagent tool-call id '${id}' is ambiguous across async runs. Use the returned asyncId instead.`);
143
+ if (exactToolCallIdMatches[0]) return { kind: "async", id: exactToolCallIdMatches[0].id, location: exactToolCallIdMatches[0].location };
76
144
  const exactNested = findNestedRunMatchesById(id, nestedScope ? { scope: nestedScope } : {});
77
145
  if (exactNested.length > 1) throw new Error(`Nested run id '${id}' is ambiguous across authorized registries. Provide the full id after stale registries are cleaned up.`);
78
146
  if (exactNested[0]) return { kind: "nested", id, match: exactNested[0] };
@@ -84,6 +152,9 @@ export function resolveSubagentRunId(id: string, deps: ResolveSubagentRunIdDeps
84
152
  for (const match of asyncPrefixMatches(id, asyncDirRoot, resultsDir)) {
85
153
  matches.push({ kind: "async", id: match.id, location: match.location });
86
154
  }
155
+ for (const match of toolCallIdAsyncLocations(id, asyncDirRoot, resultsDir, { prefix: true })) {
156
+ matches.push({ kind: "async", id: match.id, location: match.location });
157
+ }
87
158
  for (const match of findNestedRunMatchesById(id, nestedScope ? { prefix: true, scope: nestedScope } : { prefix: true })) {
88
159
  matches.push({ kind: "nested", id: match.run.id, match });
89
160
  }
@@ -382,6 +382,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
382
382
  const workflowEmitPreview = status.workflow?.emits.length ? formatWorkflowJsonPreview(status.workflow.emits.at(-1), 240) : undefined;
383
383
  const lines = [
384
384
  `Run: ${status.runId}`,
385
+ status.toolCallId ? `Tool call: ${status.toolCallId}` : undefined,
385
386
  missionId ? `Mission: ${missionId}` : undefined,
386
387
  `State: ${status.state}`,
387
388
  processTerminal ? `Process terminal: ${processTerminal.state}${processTerminal.reason ? ` (${processTerminal.reason})` : ""}` : undefined,
@@ -454,7 +455,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
454
455
  if (resultPath) {
455
456
  try {
456
457
  const raw = fs.readFileSync(resultPath, "utf-8");
457
- const data = JSON.parse(raw) as { id?: string; runId?: string; agent?: string; success?: boolean; summary?: string; output?: string; exitCode?: number; state?: string; stopped?: boolean; timedOut?: boolean; turnBudgetExceeded?: boolean; processSignal?: string | null; sessionFile?: string; parallelHandoff?: { path?: string }; results?: Array<{ agent?: string; output?: string; summary?: string; sessionFile?: string; state?: string; success?: boolean; exitCode?: number | null; stopped?: boolean; timedOut?: boolean; turnBudgetExceeded?: boolean; interrupted?: boolean; processSignal?: string | null }> };
458
+ const data = JSON.parse(raw) as { id?: string; runId?: string; toolCallId?: string; agent?: string; success?: boolean; summary?: string; output?: string; exitCode?: number; state?: string; stopped?: boolean; timedOut?: boolean; turnBudgetExceeded?: boolean; processSignal?: string | null; sessionFile?: string; parallelHandoff?: { path?: string }; results?: Array<{ agent?: string; output?: string; summary?: string; sessionFile?: string; state?: string; success?: boolean; exitCode?: number | null; stopped?: boolean; timedOut?: boolean; turnBudgetExceeded?: boolean; interrupted?: boolean; processSignal?: string | null }> };
458
459
  if (params.view === "transcript") {
459
460
  try {
460
461
  return { content: [{ type: "text", text: formatAsyncResultTranscript(data, resultPath, { index: params.index, lines: params.lines }) }], details: { mode: "single", results: [] } };
@@ -479,7 +480,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
479
480
  ? "stopped"
480
481
  : data.success ? "complete" : data.state === "paused" || data.exitCode === 0 ? "paused" : "failed";
481
482
  const runId = data.runId ?? data.id ?? resolvedId;
482
- const lines = [`Run: ${runId}`, `State: ${status}`, `Result: ${resultPath}`];
483
+ const lines = [`Run: ${runId}`, data.toolCallId ? `Tool call: ${data.toolCallId}` : undefined, `State: ${status}`, `Result: ${resultPath}`].filter((line): line is string => Boolean(line));
483
484
  if (data.parallelHandoff?.path) lines.push(`Parallel handoff: ${data.parallelHandoff.path}`);
484
485
  const children = Array.isArray(data.results) ? data.results : data.agent ? [{ agent: data.agent, sessionFile: data.sessionFile }] : [];
485
486
  lines.push(formatResumeGuidance(runId, children, data.sessionFile, { stopped: status === "stopped" }));
@@ -69,7 +69,7 @@ import {
69
69
  import { applyThinkingSuffix, buildPiArgs, cleanupTempDir, projectLaunchResolvedChildExtensions, resolvePiLaunchToolPlan } from "../shared/pi-args.ts";
70
70
  import { readRuntimeAcknowledgedExtensions } from "../shared/runtime-acknowledged-extensions.ts";
71
71
  import { outputEntryFromAsyncResult, resolveOutputReferences } from "../shared/chain-outputs.ts";
72
- import { createStructuredOutputRuntime, readStructuredOutput } from "../shared/structured-output.ts";
72
+ import { createStructuredOutputRuntime, MISSING_STRUCTURED_OUTPUT_CALL_ERROR, readStructuredOutput } from "../shared/structured-output.ts";
73
73
  import { formatProcessSignalError, isUnexplainedProcessSignal } from "../shared/process-signal.ts";
74
74
  import { readChildToolDiagnosticError } from "../shared/tool-availability.ts";
75
75
  import { collectDynamicResults, DynamicFanoutError, materializeDynamicParallelStep, validateDynamicCollection } from "../shared/dynamic-fanout.ts";
@@ -503,6 +503,8 @@ interface RunPiStreamingResult {
503
503
  toolBudget?: ToolBudgetState;
504
504
  toolBudgetBlocked?: boolean;
505
505
  observedMutationAttempt?: boolean;
506
+ structuredOutputToolInvoked?: boolean;
507
+ structuredOutputMessageStartIndex?: number;
506
508
  watchdog?: ChildWatchdogStateSnapshot;
507
509
  runtimeAcknowledgedExtensions?: RuntimeAcknowledgedChildExtensionsV1;
508
510
  processInstanceId: string;
@@ -568,6 +570,8 @@ function runPiStreaming(
568
570
  let turnBudgetMessage: string | undefined;
569
571
  let turnBudget: TurnBudgetState | undefined;
570
572
  let observedMutationAttempt = false;
573
+ let structuredOutputToolInvoked = false;
574
+ let structuredOutputMessageStartIndex: number | undefined;
571
575
  let toolCount = 0;
572
576
  const childWatchdogConfig = decodeChildWatchdogConfig(env?.[CHILD_WATCHDOG_CONFIG_ENV]);
573
577
  let childWatchdogState: ChildWatchdogStateSnapshot | undefined;
@@ -658,6 +662,10 @@ function runPiStreaming(
658
662
 
659
663
  if (event.type === "tool_execution_start" && event.toolName) {
660
664
  toolCount += 1;
665
+ if (event.toolName === "structured_output") {
666
+ structuredOutputToolInvoked = true;
667
+ structuredOutputMessageStartIndex = messages.length;
668
+ }
661
669
  observedMutationAttempt = observedMutationAttempt || isMutatingTool(event.toolName, event.args);
662
670
  const toolArgs = extractToolArgsPreview(event.args ?? {});
663
671
  writeOutputLine(toolArgs ? `${event.toolName}: ${toolArgs}` : event.toolName);
@@ -923,6 +931,8 @@ function runPiStreaming(
923
931
  turnBudgetExceeded,
924
932
  wrapUpRequested: turnBudget?.outcome === "wrap-up-requested" || turnBudget?.outcome === "termination-deferred" || turnBudgetExceeded || undefined,
925
933
  observedMutationAttempt,
934
+ structuredOutputToolInvoked,
935
+ structuredOutputMessageStartIndex,
926
936
  watchdog: childWatchdogState,
927
937
  processInstanceId,
928
938
  processCloseObservedAt,
@@ -949,7 +959,7 @@ function runPiStreaming(
949
959
  const stderr = stderrTail.text();
950
960
  const finalOutput = getFinalOutput(messages) || rawStdoutTail.text().trim();
951
961
  const spawnErrorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
952
- resolve(omitUndefinedProperties({ stderr, exitCode: 1, messages, usage, toolCount, durationMs: Date.now() - startedAt, model, error: stopped ? (stopMessage ?? "Subagent stopped by user.") : timedOut ? (timeoutMessage ?? "Subagent timed out.") : turnBudgetExceeded ? turnBudgetMessage : error ?? assistantError ?? spawnErrorMessage, protocolError, finalOutput: (timedOut || stopped) && !finalOutput.trim() ? (stopped ? stopMessage ?? "Subagent stopped by user." : timeoutMessage ?? "Subagent timed out.") : finalOutput, outputState: finalOutput.trim() ? "present" : "absent", timedOut, stopped, turnBudget, turnBudgetExceeded, wrapUpRequested: turnBudget?.outcome === "wrap-up-requested" || turnBudget?.outcome === "termination-deferred" || turnBudgetExceeded || undefined, observedMutationAttempt, watchdog: childWatchdogState, processInstanceId }));
962
+ resolve(omitUndefinedProperties({ stderr, exitCode: 1, messages, usage, toolCount, durationMs: Date.now() - startedAt, model, error: stopped ? (stopMessage ?? "Subagent stopped by user.") : timedOut ? (timeoutMessage ?? "Subagent timed out.") : turnBudgetExceeded ? turnBudgetMessage : error ?? assistantError ?? spawnErrorMessage, protocolError, finalOutput: (timedOut || stopped) && !finalOutput.trim() ? (stopped ? stopMessage ?? "Subagent stopped by user." : timeoutMessage ?? "Subagent timed out.") : finalOutput, outputState: finalOutput.trim() ? "present" : "absent", timedOut, stopped, turnBudget, turnBudgetExceeded, wrapUpRequested: turnBudget?.outcome === "wrap-up-requested" || turnBudget?.outcome === "termination-deferred" || turnBudgetExceeded || undefined, observedMutationAttempt, structuredOutputToolInvoked, structuredOutputMessageStartIndex, watchdog: childWatchdogState, processInstanceId }));
953
963
  });
954
964
  });
955
965
  }
@@ -1444,31 +1454,42 @@ async function runSingleStep(
1444
1454
  const runtimeAcknowledgedExtensions = readRuntimeAcknowledgedExtensions(runtimeAcknowledgedExtensionsPath);
1445
1455
  cleanupTempDir(tempDir);
1446
1456
 
1447
- const hiddenError = run.exitCode === 0 && !run.error && !toolAvailabilityError ? detectSubagentError(run.messages) : null;
1448
- const missingStructuredOutput = effectiveStructuredOutput
1449
- ? !fs.existsSync(effectiveStructuredOutput.outputPath)
1450
- : false;
1457
+ let structuredOutput: unknown;
1458
+ let structuredError: string | undefined;
1459
+ let validatedStructuredOutput = false;
1460
+ if (effectiveStructuredOutput && run.exitCode === 0 && !run.error && !toolAvailabilityError) {
1461
+ if (!run.structuredOutputToolInvoked) {
1462
+ structuredError = MISSING_STRUCTURED_OUTPUT_CALL_ERROR;
1463
+ } else {
1464
+ const structured = await readStructuredOutput({
1465
+ schema: effectiveStructuredOutput.schema,
1466
+ schemaPath: effectiveStructuredOutput.schemaPath,
1467
+ outputPath: effectiveStructuredOutput.outputPath,
1468
+ });
1469
+ if (structured.error) structuredError = structured.error;
1470
+ else {
1471
+ structuredOutput = structured.value;
1472
+ validatedStructuredOutput = true;
1473
+ }
1474
+ }
1475
+ }
1476
+ const errorMessages = validatedStructuredOutput
1477
+ ? run.messages.slice(run.structuredOutputMessageStartIndex ?? run.messages.length)
1478
+ : run.messages;
1479
+ const hiddenError = run.exitCode === 0 && !run.error && !toolAvailabilityError && !structuredError
1480
+ ? detectSubagentError(errorMessages)
1481
+ : null;
1451
1482
  const emptyOutputError = run.exitCode === 0
1452
1483
  && !run.error
1453
1484
  && !toolAvailabilityError
1485
+ && !structuredError
1454
1486
  && !run.finalOutput.trim()
1455
- && (!effectiveStructuredOutput || missingStructuredOutput)
1487
+ && !validatedStructuredOutput
1456
1488
  && (!hiddenError?.hasError || hasEmptyTerminalAssistantResponse(run.messages))
1457
1489
  ? "Subagent produced no output (possible model cold-start or empty response)."
1458
1490
  : undefined;
1459
- let structuredOutput: unknown;
1460
- let structuredError: string | undefined;
1461
- if (effectiveStructuredOutput && run.exitCode === 0 && !run.error && !toolAvailabilityError && !hiddenError?.hasError && !emptyOutputError) {
1462
- const structured = await readStructuredOutput({
1463
- schema: effectiveStructuredOutput.schema,
1464
- schemaPath: effectiveStructuredOutput.schemaPath,
1465
- outputPath: effectiveStructuredOutput.outputPath,
1466
- });
1467
- if (structured.error) structuredError = structured.error;
1468
- else structuredOutput = structured.value;
1469
- }
1470
1491
  const completionGuardEnabled = isAgentContractV1(step.agentContract) ? step.completionGuard === true : step.completionGuard !== false;
1471
- const completionGuard = run.exitCode === 0 && !run.error && !toolAvailabilityError && !hiddenError?.hasError && !emptyOutputError && completionGuardEnabled
1492
+ const completionGuard = run.exitCode === 0 && !run.error && !toolAvailabilityError && !structuredError && !hiddenError?.hasError && !emptyOutputError && completionGuardEnabled
1472
1493
  ? evaluateCompletionMutationGuard(omitUndefinedProperties({
1473
1494
  agent: step.agent,
1474
1495
  task: taskForCompletionGuard,
@@ -532,7 +532,7 @@ export class ChainClarifyComponent implements Component {
532
532
  buffer = template.split("\n")[0] ?? "";
533
533
  } else if (mode === "output") {
534
534
  const behavior = this.getEffectiveBehavior(this.selectedStep);
535
- buffer = behavior.output === false ? "" : (behavior.output || "");
535
+ buffer = typeof behavior.output === "string" ? behavior.output : "";
536
536
  } else if (mode === "reads") {
537
537
  const behavior = this.getEffectiveBehavior(this.selectedStep);
538
538
  buffer = behavior.reads === false ? "" : (behavior.reads?.join(", ") || "");
@@ -1165,7 +1165,9 @@ export class ChainClarifyComponent implements Component {
1165
1165
 
1166
1166
  const writesValue = behavior.output === false
1167
1167
  ? th.fg("dim", "(disabled)")
1168
- : (behavior.output || th.fg("dim", "(none)"));
1168
+ : (typeof behavior.output === "string" && behavior.output
1169
+ ? behavior.output
1170
+ : th.fg("dim", "(none)"));
1169
1171
  const writesLabel = th.fg("dim", "writes: ");
1170
1172
  lines.push(this.row(` ${writesLabel}${truncateToWidth(writesValue, innerW - 14)}`));
1171
1173
 
@@ -1296,7 +1298,9 @@ export class ChainClarifyComponent implements Component {
1296
1298
 
1297
1299
  const writesValue = behavior.output === false
1298
1300
  ? th.fg("dim", "(disabled)")
1299
- : (behavior.output || th.fg("dim", "(none)"));
1301
+ : (typeof behavior.output === "string" && behavior.output
1302
+ ? behavior.output
1303
+ : th.fg("dim", "(none)"));
1300
1304
  const writesLabel = th.fg("dim", "writes: ");
1301
1305
  lines.push(this.row(` ${writesLabel}${truncateToWidth(writesValue, innerW - 14)}`));
1302
1306
 
@@ -459,6 +459,7 @@ async function runSingleAttempt(
459
459
  const spawnEnv = { ...process.env, ...sharedEnv, ...getSubagentDepthEnv(options.maxSubagentDepth) };
460
460
  let observedMutationAttempt = false;
461
461
  let structuredOutputToolInvoked = false;
462
+ let structuredOutputMessageStartIndex: number | undefined;
462
463
 
463
464
  const exitCode = await new Promise<number>((resolve) => {
464
465
  const spawnSpec = getPiSpawnCommand(args);
@@ -874,7 +875,10 @@ async function runSingleAttempt(
874
875
  const toolArgs = evt.args && typeof evt.args === "object" && !Array.isArray(evt.args)
875
876
  ? evt.args as Record<string, unknown>
876
877
  : {};
877
- if (options.structuredOutput && evt.toolName === "structured_output") structuredOutputToolInvoked = true;
878
+ if (options.structuredOutput && evt.toolName === "structured_output") {
879
+ structuredOutputToolInvoked = true;
880
+ structuredOutputMessageStartIndex = result.messages?.length ?? 0;
881
+ }
878
882
  if (options.allowIntercomDetach && (evt.toolName === "intercom" || evt.toolName === "contact_supervisor")) {
879
883
  intercomStarted = true;
880
884
  }
@@ -1183,24 +1187,7 @@ async function runSingleAttempt(
1183
1187
  if (result.error && result.exitCode === 0) {
1184
1188
  result.exitCode = 1;
1185
1189
  }
1186
- if (result.exitCode === 0 && !result.error) {
1187
- const messages = result.messages ?? [];
1188
- const finalText = getFinalOutput(messages);
1189
- const missingStructuredOutput = options.structuredOutput
1190
- ? !existsSync(options.structuredOutput.outputPath)
1191
- : false;
1192
- const errInfo = detectSubagentError(messages);
1193
- const missingOutput = !finalText?.trim() && (!options.structuredOutput || missingStructuredOutput);
1194
- if (missingOutput && (!errInfo.hasError || hasEmptyTerminalAssistantResponse(messages))) {
1195
- result.exitCode = 1;
1196
- result.error = "Subagent produced no output (possible model cold-start or empty response).";
1197
- } else if (errInfo.hasError) {
1198
- result.exitCode = errInfo.exitCode ?? 1;
1199
- result.error = errInfo.details
1200
- ? `${errInfo.errorType} failed (exit ${errInfo.exitCode}): ${errInfo.details}`
1201
- : `${errInfo.errorType} failed with exit code ${errInfo.exitCode}`;
1202
- }
1203
- }
1190
+ let validatedStructuredOutput = false;
1204
1191
  if (options.structuredOutput && result.exitCode === 0 && !result.error) {
1205
1192
  result.structuredOutputSchemaPath = options.structuredOutput.schemaPath;
1206
1193
  result.structuredOutputPath = options.structuredOutput.outputPath;
@@ -1220,9 +1207,28 @@ async function runSingleAttempt(
1220
1207
  result.structuredOutputFailed = true;
1221
1208
  } else {
1222
1209
  result.structuredOutput = structured.value;
1210
+ validatedStructuredOutput = true;
1223
1211
  }
1224
1212
  }
1225
1213
  }
1214
+ if (result.exitCode === 0 && !result.error) {
1215
+ const messages = result.messages ?? [];
1216
+ const finalText = getFinalOutput(messages);
1217
+ const errorMessages = validatedStructuredOutput
1218
+ ? messages.slice(structuredOutputMessageStartIndex ?? messages.length)
1219
+ : messages;
1220
+ const errInfo = detectSubagentError(errorMessages);
1221
+ const missingOutput = !finalText?.trim() && !validatedStructuredOutput;
1222
+ if (missingOutput && (!errInfo.hasError || hasEmptyTerminalAssistantResponse(messages))) {
1223
+ result.exitCode = 1;
1224
+ result.error = "Subagent produced no output (possible model cold-start or empty response).";
1225
+ } else if (errInfo.hasError) {
1226
+ result.exitCode = errInfo.exitCode ?? 1;
1227
+ result.error = errInfo.details
1228
+ ? `${errInfo.errorType} failed (exit ${errInfo.exitCode}): ${errInfo.details}`
1229
+ : `${errInfo.errorType} failed with exit code ${errInfo.exitCode}`;
1230
+ }
1231
+ }
1226
1232
 
1227
1233
  progress.status = result.exitCode === 0 ? "completed" : "failed";
1228
1234
  progress.durationMs = Date.now() - startTime;
@@ -91,11 +91,12 @@ import { inspectSubagentStatus } from "../background/run-status.ts";
91
91
  import { applyForceTopLevelAsyncOverride } from "../background/top-level-async.ts";
92
92
  import { handleMissionAction, MISSION_ACTIONS } from "../../missions/actions.ts";
93
93
  import { attachMissionToLaunchResult, prepareMissionLaunch, type MissionLaunchBinding } from "../../missions/lifecycle.ts";
94
+ import { updateMission } from "../../missions/store.ts";
94
95
  import { createMissionWorkflowState } from "../../missions/workflow-state.ts";
95
96
  import { resolveAuthorityDecision } from "../../policy/authority.ts";
96
97
  import { handleHerdrInspectorAction, HERDR_INSPECTOR_ACTIONS } from "../../inspectors/herdr/actions.ts";
97
98
  import { handleHerdrProjectPaneAction, HERDR_PROJECT_PANE_ACTIONS } from "../../inspectors/herdr/project-panes.ts";
98
- import { runWorkflowScript, WorkflowScriptError, type WorkflowScriptChildResult } from "../../workflows/scripted-workflow.ts";
99
+ import { previewSimpleWorkflowRun, runWorkflowScript, WorkflowScriptError, type WorkflowScriptChildResult } from "../../workflows/scripted-workflow.ts";
99
100
  import { resolveWorkflowChatProgress, type WorkflowChatProgressProjection } from "../../workflows/chat-progress.ts";
100
101
  import {
101
102
  cleanupWorktrees,
@@ -798,6 +799,20 @@ function getAsyncInterruptTarget(
798
799
  return newest ? { asyncId: newest.asyncId, asyncDir: newest.asyncDir } : undefined;
799
800
  }
800
801
 
802
+ function isStaleExtensionContextError(error: unknown): boolean {
803
+ if (!(error instanceof Error)) return false;
804
+ return /extension ctx is stale|stale after session replacement or reload/i.test(error.message);
805
+ }
806
+
807
+ function emitAdvisoryControlEvent(pi: ExtensionAPI, channel: string, payload: unknown): void {
808
+ try {
809
+ pi.events.emit(channel, payload);
810
+ } catch (error) {
811
+ if (isStaleExtensionContextError(error)) return;
812
+ throw error;
813
+ }
814
+ }
815
+
801
816
  function emitControlNotification(input: {
802
817
  pi: ExtensionAPI;
803
818
  controlConfig: ResolvedControlConfig;
@@ -815,10 +830,10 @@ function emitControlNotification(input: {
815
830
  noticeText: formatControlNoticeMessage(input.event, childIntercomTarget),
816
831
  };
817
832
  if (input.controlConfig.notifyChannels.includes("event")) {
818
- input.pi.events.emit(SUBAGENT_CONTROL_EVENT, payload);
833
+ emitAdvisoryControlEvent(input.pi, SUBAGENT_CONTROL_EVENT, payload);
819
834
  }
820
835
  if (input.event.type !== "active_long_running" && input.controlConfig.notifyChannels.includes("intercom") && input.intercomBridge.active && input.intercomBridge.orchestratorTarget) {
821
- input.pi.events.emit(SUBAGENT_CONTROL_INTERCOM_EVENT, {
836
+ emitAdvisoryControlEvent(input.pi, SUBAGENT_CONTROL_INTERCOM_EVENT, {
822
837
  ...payload,
823
838
  to: input.intercomBridge.orchestratorTarget,
824
839
  message: formatControlIntercomMessage(input.event, childIntercomTarget),
@@ -1865,6 +1880,19 @@ function getRequestedModeLabel(params: SubagentParamsLike): Details["mode"] {
1865
1880
  return "single";
1866
1881
  }
1867
1882
 
1883
+ function formatStatusTargetLabel(params: Pick<SubagentParamsLike, "dir" | "index" | "view">, targetRunId: string | undefined): string {
1884
+ let target: string;
1885
+ if (targetRunId) {
1886
+ target = `run ${targetRunId}`;
1887
+ } else if (params.dir) {
1888
+ target = `dir ${params.dir}`;
1889
+ } else {
1890
+ target = params.view === "transcript" ? "active run" : "active runs";
1891
+ }
1892
+ if (params.view !== "transcript") return `Status target: ${target}`;
1893
+ return `Transcript target: ${target}${params.index !== undefined ? ` · child ${params.index}` : ""}`;
1894
+ }
1895
+
1868
1896
  interface AgentDefaultContextPolicy {
1869
1897
  params: SubagentParamsLike;
1870
1898
  contextForAgent(agentName: string): ContextMode;
@@ -3323,10 +3351,11 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
3323
3351
  );
3324
3352
  if (errorResult) return errorResult;
3325
3353
 
3326
- let worktreeFinalized = false;
3354
+ let worktreeCleanupHandled = false;
3355
+ let pendingHandoff: Details["parallelHandoff"];
3327
3356
  try {
3328
3357
  if (worktreeSetup) {
3329
- writePendingParallelHandoff({
3358
+ pendingHandoff = writePendingParallelHandoff({
3330
3359
  manifestPath: parallelHandoffPath(artifactsDir, runId),
3331
3360
  runId,
3332
3361
  mode: "parallel",
@@ -3429,10 +3458,13 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
3429
3458
  updateForegroundNestedProjection(foregroundControl);
3430
3459
  attachRootChildrenToSteps(runId, results, foregroundControl.nestedChildren);
3431
3460
  }
3461
+ const detached = results.find((result) => result.detached);
3432
3462
  let handoff: ReturnType<typeof finalizeParallelWorktreeHandoff> | undefined;
3433
3463
  if (worktreeSetup) {
3434
- worktreeFinalized = true;
3435
- handoff = finalizeParallelWorktreeHandoff({ worktreeSetup, artifactsDir, runId, cwd: effectiveCwd, tasks, results });
3464
+ worktreeCleanupHandled = true;
3465
+ handoff = detached
3466
+ ? { suffix: pendingHandoff ? formatParallelHandoffReference(pendingHandoff) : "", reference: pendingHandoff }
3467
+ : finalizeParallelWorktreeHandoff({ worktreeSetup, artifactsDir, runId, cwd: effectiveCwd, tasks, results });
3436
3468
  }
3437
3469
  const interrupted = results.find((result) => result.interrupted);
3438
3470
  const totalCost = sumResultsCost(results);
@@ -3455,11 +3487,10 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
3455
3487
  details,
3456
3488
  };
3457
3489
  }
3458
- const detachedIndex = results.findIndex((result) => result.detached);
3459
- const detached = detachedIndex >= 0 ? results[detachedIndex] : undefined;
3460
3490
  if (detached) {
3491
+ const handoffSuffix = handoff?.suffix ? `\n\n${handoff.suffix}` : "";
3461
3492
  return {
3462
- content: [{ type: "text", text: `Parallel run detached for intercom coordination (${detached.agent}). Reply to the supervisor request first, then wait with subagent_wait({ id: "${runId}" }). Use subagent({ action: "status", id: "${runId}" }) to recover the result; do not resume or launch a replacement while it remains detached.` }],
3493
+ content: [{ type: "text", text: `Parallel run detached for intercom coordination (${detached.agent}). Reply to the supervisor request first, then wait with subagent_wait({ id: "${runId}" }). Use subagent({ action: "status", id: "${runId}" }) to recover the result; do not resume or launch a replacement while it remains detached.${handoffSuffix}` }],
3463
3494
  details,
3464
3495
  };
3465
3496
  }
@@ -3508,7 +3539,7 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
3508
3539
  details,
3509
3540
  };
3510
3541
  } finally {
3511
- if (worktreeSetup && !worktreeFinalized) cleanupWorktrees(worktreeSetup);
3542
+ if (worktreeSetup && !worktreeCleanupHandled) cleanupWorktrees(worktreeSetup);
3512
3543
  }
3513
3544
  }
3514
3545
 
@@ -3895,6 +3926,8 @@ function workflowChildResult(key: string, result: AgentToolResult<Details>): Wor
3895
3926
  const output = result.details.results.length === 1 && result.details.results[0]?.finalOutput !== undefined
3896
3927
  ? result.details.results[0].finalOutput
3897
3928
  : receiptOutput;
3929
+ const detached = result.details.results.some((child) => child.detached);
3930
+ const ok = result.isError !== true && !detached;
3898
3931
  const artifactPaths = new Set<string>();
3899
3932
  if (result.details.asyncDir) artifactPaths.add(result.details.asyncDir);
3900
3933
  if (result.details.parallelHandoff?.path) artifactPaths.add(result.details.parallelHandoff.path);
@@ -3906,10 +3939,10 @@ function workflowChildResult(key: string, result: AgentToolResult<Details>): Wor
3906
3939
  const structured = result.details.results.map((child) => child.structuredOutput).filter((value) => value !== undefined);
3907
3940
  return {
3908
3941
  key,
3909
- ok: result.isError !== true,
3942
+ ok,
3910
3943
  ...(result.details.runId || result.details.asyncId ? { runId: result.details.runId ?? result.details.asyncId } : {}),
3911
3944
  output,
3912
- ...(result.isError === true ? { error: receiptOutput || output || "Child run failed." } : {}),
3945
+ ...(!ok ? { error: receiptOutput || output || "Child run failed." } : {}),
3913
3946
  ...(structured.length === 1 ? { structuredOutput: structured[0] } : structured.length > 1 ? { structuredOutput: structured } : {}),
3914
3947
  artifactPaths: [...artifactPaths],
3915
3948
  results: result.details.results,
@@ -4123,11 +4156,18 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4123
4156
  if (chatProgressResult.error) return { content: [{ type: "text", text: chatProgressResult.error }], isError: true, details: { mode: "workflow", results: [] } };
4124
4157
  const chatProgress = chatProgressResult.projection!;
4125
4158
  const explicitMission = requestParams.missionId !== undefined || requestParams.mission !== undefined;
4159
+ const autoMission = !explicitMission;
4160
+ const workflowPreview = autoMission ? previewSimpleWorkflowRun(requestParams.workflowScript) : undefined;
4161
+ const previewTask = workflowPreview?.task?.trim() || undefined;
4162
+ const previewAgent = workflowPreview?.agent?.trim() || undefined;
4163
+ const scriptFirstLine = requestParams.workflowScript.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || "Workflow";
4164
+ const boundedScriptPreview = scriptFirstLine.length > 100 ? `${scriptFirstLine.slice(0, 97)}...` : scriptFirstLine;
4165
+ const derivedObjective = previewTask || (previewAgent ? `Workflow: ${previewAgent}` : boundedScriptPreview);
4126
4166
  let missionBinding: MissionLaunchBinding | undefined;
4127
4167
  let missionWarning: string | undefined;
4128
4168
  try {
4129
4169
  missionBinding = prepareMissionLaunch({
4130
- params: requestParams,
4170
+ params: autoMission ? { ...requestParams, task: derivedObjective } : requestParams,
4131
4171
  projectRoot: workflowCwd,
4132
4172
  ...(deps.config.missions ? { config: deps.config.missions } : {}),
4133
4173
  ownerSessionId: resolveCurrentSessionId(ctx.sessionManager),
@@ -4136,6 +4176,20 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4136
4176
  if (explicitMission) return { content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], isError: true, details: { mode: "workflow", results: [] } };
4137
4177
  missionWarning = `Mission tracking unavailable: ${error instanceof Error ? error.message : String(error)}`;
4138
4178
  }
4179
+ let shouldPatchMissionObjective = autoMission && previewTask === undefined && missionBinding !== undefined;
4180
+ const patchMissionObjective = (task: unknown): void => {
4181
+ if (!shouldPatchMissionObjective || !missionBinding || typeof task !== "string" || !task.trim()) return;
4182
+ shouldPatchMissionObjective = false;
4183
+ const objective = task.trim();
4184
+ const firstLine = objective.split(/\r?\n/, 1)[0]?.trim() || objective;
4185
+ const title = firstLine.length > 100 ? `${firstLine.slice(0, 97)}...` : firstLine;
4186
+ try {
4187
+ updateMission(missionBinding.location, missionBinding.missionId, { title, objective });
4188
+ } catch (error) {
4189
+ console.warn(`[pi-subagents] Failed to update automatic mission objective: ${error instanceof Error ? error.message : String(error)}`);
4190
+ }
4191
+ };
4192
+ const detachWorkflowChildMissions = autoMission || missionBinding !== undefined || requestParams.mission === false;
4139
4193
  const workflowState = missionBinding ? createMissionWorkflowState(missionBinding.location, missionBinding.missionId) : undefined;
4140
4194
  const attachWorkflowMission = (result: AgentToolResult<Details>): AgentToolResult<Details> => {
4141
4195
  if (!missionBinding) return missionWarning ? { ...result, details: { ...result.details, missionWarning } } : result;
@@ -4149,7 +4203,8 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4149
4203
  }
4150
4204
  };
4151
4205
  if (requestParams.async !== false) {
4152
- const workflowRunId = _id;
4206
+ const toolCallId = _id;
4207
+ const workflowRunId = randomUUID();
4153
4208
  const asyncDir = path.join(DIRS.async, workflowRunId);
4154
4209
  const resultPath = path.join(DIRS.results, `${workflowRunId}.json`);
4155
4210
  const statusPath = path.join(asyncDir, "status.json");
@@ -4163,13 +4218,14 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4163
4218
  deps.state.workflowControllers.set(workflowRunId, controller);
4164
4219
  let status: AsyncStatus = {
4165
4220
  runId: workflowRunId,
4221
+ toolCallId,
4166
4222
  sessionId: currentSessionId ?? undefined,
4167
4223
  mode: "workflow",
4168
4224
  state: "running",
4169
4225
  startedAt,
4170
4226
  lastUpdate: startedAt,
4171
4227
  ...(timeout !== undefined ? { deadlineAt: startedAt + timeout, timeoutMs: timeout } : {}),
4172
- cwd: parentCwd,
4228
+ cwd: workflowCwd,
4173
4229
  pid: process.pid,
4174
4230
  steps: [],
4175
4231
  workflow: { trace: [], emits: [], console: [] },
@@ -4192,7 +4248,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4192
4248
  job.workflow = status.workflow;
4193
4249
  }
4194
4250
  };
4195
- const workflowJob: AsyncJobState = { asyncId: workflowRunId, asyncDir, cwd: parentCwd, status: "running", sessionId: currentSessionId ?? undefined, mode: "workflow", agents: [], steps: [], startedAt, updatedAt: startedAt, ...(timeout !== undefined ? { timeoutMs: timeout, deadlineAt: startedAt + timeout } : {}), workflow: status.workflow };
4251
+ 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 };
4196
4252
  deps.state.asyncJobs.set(workflowRunId, workflowJob);
4197
4253
  deps.state.fleetJobs ??= new Map();
4198
4254
  deps.state.fleetJobs.set(workflowRunId, workflowJob);
@@ -4238,7 +4294,8 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4238
4294
  if (workflowUsageBudget.budget && childParams.async === true) return workflowChildResult(key, buildRequestedModeError(childParams as SubagentParamsLike, "workflow usageBudget does not support async runs.run launches."));
4239
4295
  const budgetState = usageBudgetState(workflowUsageBudget.budget, sumResultsCost(workflowResults));
4240
4296
  if (budgetState?.exhausted) return workflowChildResult(key, buildRequestedModeError(childParams as SubagentParamsLike, usageBudgetExceededMessage(budgetState)));
4241
- const childRequest = prepareWorkflowLaunchParams(workflowChildDefaults, childParams, workflowRunId, key, { missionDetached: missionBinding !== undefined || requestParams.mission === false });
4297
+ patchMissionObjective(childParams.task);
4298
+ const childRequest = prepareWorkflowLaunchParams(workflowChildDefaults, childParams, workflowRunId, key, { missionDetached: detachWorkflowChildMissions });
4242
4299
  const result = await execute(randomUUID(), childRequest, workflowSignal, undefined, ctx, preserveActiveSession);
4243
4300
  workflowResults.push(...result.details.results);
4244
4301
  const child = workflowChildResult(key, result);
@@ -4257,21 +4314,21 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4257
4314
  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) };
4258
4315
  persist();
4259
4316
  appendWorkflowEvent({ type: "subagent.workflow.completed", state: "complete" });
4260
- writeAtomicJson(resultPath, { id: workflowRunId, runId: workflowRunId, 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: parentCwd, sessionId: currentSessionId, timestamp: Date.now(), durationMs: Date.now() - startedAt });
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 });
4261
4318
  } catch (error) {
4262
4319
  const partial = error instanceof WorkflowScriptError ? error.partial : { trace: [], emits: [], console: [], children: [] };
4263
4320
  const stopped = controller.signal.aborted;
4264
4321
  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 } });
4265
4322
  persist();
4266
4323
  appendWorkflowEvent({ type: "subagent.workflow.completed", state: status.state, error: status.error });
4267
- writeAtomicJson(resultPath, { id: workflowRunId, runId: workflowRunId, 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: parentCwd, sessionId: currentSessionId, timestamp: Date.now(), durationMs: Date.now() - startedAt });
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 });
4268
4325
  } finally {
4269
4326
  deps.state.workflowControllers?.delete(workflowRunId);
4270
4327
  }
4271
4328
  });
4272
4329
  return attachWorkflowMission({
4273
4330
  content: [{ type: "text", text: formatAsyncStartedMessage(`Async workflow [${workflowRunId}]`, ctx.hasUI === true) }],
4274
- details: { mode: "workflow", runId: workflowRunId, asyncId: workflowRunId, asyncDir, results: [], chatProgress },
4331
+ details: { mode: "workflow", runId: workflowRunId, toolCallId, asyncId: workflowRunId, asyncDir, results: [], chatProgress },
4275
4332
  });
4276
4333
  }
4277
4334
  const { workflowScript: _workflowScript, action: _action, agent: _agent, task: _task, resume: _resume, tasks: _tasks, chain: _chain, concurrency: _concurrency, async: _async, foregroundOnly: _foregroundOnly, clarify: _clarify, timeoutMs: _timeoutMs, maxRuntimeMs: _maxRuntimeMs, usageBudget: _usageBudget, chatProgress: _chatProgress, missionId: _missionId, mission: _mission, ...workflowChildDefaults } = requestParams;
@@ -4299,7 +4356,8 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4299
4356
  if (workflowUsageBudget.budget && childParams.async === true) return workflowChildResult(key, buildRequestedModeError(childParams as SubagentParamsLike, "workflow usageBudget does not support async runs.run launches."));
4300
4357
  const budgetState = usageBudgetState(workflowUsageBudget.budget, sumResultsCost(workflowResults));
4301
4358
  if (budgetState?.exhausted) return workflowChildResult(key, buildRequestedModeError(childParams as SubagentParamsLike, usageBudgetExceededMessage(budgetState)));
4302
- const childRequest = prepareWorkflowLaunchParams(workflowChildDefaults, childParams, _id, key, { missionDetached: missionBinding !== undefined || requestParams.mission === false, suppressRoutineResultIntercom: chatProgress.mode === "live-card" });
4359
+ patchMissionObjective(childParams.task);
4360
+ const childRequest = prepareWorkflowLaunchParams(workflowChildDefaults, childParams, _id, key, { missionDetached: detachWorkflowChildMissions, suppressRoutineResultIntercom: chatProgress.mode === "live-card" });
4303
4361
  const result = await execute(randomUUID(), childRequest, workflowSignal, undefined, ctx, preserveActiveSession);
4304
4362
  workflowResults.push(...result.details.results);
4305
4363
  return workflowChildResult(key, result);
@@ -4572,13 +4630,18 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4572
4630
  }
4573
4631
  if (action === "status") {
4574
4632
  if (!preserveActiveSession) deps.state.currentSessionId = resolveCurrentSessionId(ctx.sessionManager);
4575
- const withBudget = (result: AgentToolResult<Details>) => withSpawnBudgetStatus(
4576
- result,
4577
- deps.state,
4578
- deps.config,
4579
- deps.state.currentSessionId,
4580
- );
4581
4633
  const targetRunId = paramsWithResolvedCwd.id ?? paramsWithResolvedCwd.runId;
4634
+ const hasDirectoryTarget = Boolean(paramsWithResolvedCwd.dir);
4635
+ const targetLabel = formatStatusTargetLabel(paramsWithResolvedCwd, targetRunId);
4636
+ const withBudget = (result: AgentToolResult<Details>) => {
4637
+ const budgeted = withSpawnBudgetStatus(result, deps.state, deps.config, deps.state.currentSessionId);
4638
+ return {
4639
+ ...budgeted,
4640
+ content: budgeted.content.map((item, index) => index === 0 && item.type === "text"
4641
+ ? { ...item, text: `${targetLabel}\n${item.text}` }
4642
+ : item),
4643
+ };
4644
+ };
4582
4645
  const nestedScope = nestedResolutionScopeForExecutor(deps);
4583
4646
  const sessionRoots = trustedSessionRootsForStatus(ctx, deps);
4584
4647
  if (paramsWithResolvedCwd.view === "fleet") {
@@ -4603,7 +4666,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4603
4666
  const message = error instanceof Error ? error.message : String(error);
4604
4667
  return withBudget({ content: [{ type: "text", text: message }], isError: true, details: { mode: "management", results: [] } });
4605
4668
  }
4606
- } else {
4669
+ } else if (!hasDirectoryTarget) {
4607
4670
  const foreground = getForegroundControl(deps.state, undefined);
4608
4671
  if (foreground && paramsWithResolvedCwd.view !== "transcript") return withBudget(foregroundStatusResult(foreground));
4609
4672
  if (foreground && paramsWithResolvedCwd.view === "transcript") {
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -206,16 +207,18 @@ function isSubagentToolCallBlock(block: unknown): boolean {
206
207
  }
207
208
 
208
209
  const PORTABLE_TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
210
+ const MAX_PORTABLE_TOOL_ID_LENGTH = 64;
209
211
  const COMPOSITE_TOOL_ID_APIS = new Set([
210
212
  "azure-openai-responses",
211
- "openai-codex-responses",
212
213
  "openai-completions",
213
214
  "openai-responses",
214
215
  ]);
215
216
 
216
217
  function portableToolId(id: string): string {
217
- if (PORTABLE_TOOL_ID_PATTERN.test(id)) return id;
218
- return `tool_${Buffer.from(id).toString("base64url") || "empty"}`;
218
+ if (PORTABLE_TOOL_ID_PATTERN.test(id) && id.length <= MAX_PORTABLE_TOOL_ID_LENGTH) return id;
219
+ const encoded = `tool_${Buffer.from(id).toString("base64url") || "empty"}`;
220
+ if (encoded.length <= MAX_PORTABLE_TOOL_ID_LENGTH) return encoded;
221
+ return `tool_${createHash("sha256").update(id).digest("base64url")}`;
219
222
  }
220
223
 
221
224
  function sanitizeToolHistoryMessage(message: unknown): unknown {
@@ -23,8 +23,10 @@ export interface ResolvedStepBehavior {
23
23
  model?: string;
24
24
  }
25
25
 
26
+ export type OutputOverrideInput = string | boolean;
27
+
26
28
  export interface StepOverrides {
27
- output?: string | false;
29
+ output?: OutputOverrideInput;
28
30
  outputMode?: OutputMode;
29
31
  reads?: string[] | false;
30
32
  progress?: boolean;
@@ -32,8 +34,10 @@ export interface StepOverrides {
32
34
  model?: string;
33
35
  }
34
36
 
35
- function normalizeOutputOverride(output: string | false | undefined): string | false | undefined {
36
- return output === "false" ? false : output;
37
+ function normalizeOutputOverride(output: unknown): string | false | undefined {
38
+ if (output === false || output === "false") return false;
39
+ if (output === true || output === "true") return undefined;
40
+ return typeof output === "string" && output.length > 0 ? output : undefined;
37
41
  }
38
42
 
39
43
  // =============================================================================
@@ -49,7 +53,7 @@ export interface SequentialStep {
49
53
  as?: string;
50
54
  outputSchema?: JsonSchemaObject;
51
55
  cwd?: string;
52
- output?: string | false;
56
+ output?: OutputOverrideInput;
53
57
  outputMode?: OutputMode;
54
58
  reads?: string[] | false;
55
59
  progress?: boolean;
@@ -71,7 +75,7 @@ export interface ParallelTaskItem {
71
75
  outputSchema?: JsonSchemaObject;
72
76
  cwd?: string;
73
77
  count?: number;
74
- output?: string | false;
78
+ output?: OutputOverrideInput;
75
79
  outputMode?: OutputMode;
76
80
  reads?: string[] | false;
77
81
  progress?: boolean;
@@ -123,7 +127,7 @@ export interface CheckpointStep {
123
127
  agent?: string;
124
128
  task?: string;
125
129
  as?: string;
126
- output?: string | false;
130
+ output?: OutputOverrideInput;
127
131
  outputMode?: OutputMode;
128
132
  reads?: string[] | false;
129
133
  progress?: boolean;
@@ -950,6 +950,8 @@ export interface SpawnBudgetSnapshot {
950
950
  export interface Details {
951
951
  mode: SubagentResultMode | "management";
952
952
  runId?: string;
953
+ /** Host tool-call id retained when it differs from the internal run id. */
954
+ toolCallId?: string;
953
955
  /** Run-level context summary. "mixed" when children resolved to different modes. */
954
956
  context?: "fresh" | "fork" | "mixed";
955
957
  results: SingleResult[];
@@ -1246,6 +1248,8 @@ export interface ExternalProcessStatus {
1246
1248
  export interface AsyncStatus {
1247
1249
  lifecycleArtifactVersion?: SubagentLifecycleArtifactVersion;
1248
1250
  runId: string;
1251
+ /** Host tool-call id retained when it differs from the internal run id. */
1252
+ toolCallId?: string;
1249
1253
  sessionId?: string;
1250
1254
  mode: SubagentRunMode;
1251
1255
  context?: "fresh" | "fork" | "mixed";
@@ -1736,6 +1740,8 @@ export type InlineToolDisplay = "rich" | "summary";
1736
1740
  export interface ScheduledRunsConfig {
1737
1741
  enabled?: boolean;
1738
1742
  maxPending?: number;
1743
+ /** Absolute or `~/` root for per-project durable schedules. */
1744
+ storeRoot?: string;
1739
1745
  }
1740
1746
 
1741
1747
  export type FleetViewPlacement = "aboveEditor" | "belowEditor";