pi-subagents 0.43.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,42 @@
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
+
20
+ ## [0.44.0] - 2026-08-08
21
+
22
+ ### Added
23
+ - Added one automatic enclosing mission and durable workflow state to plain `workflowScript` launches; child runs no longer create separate missions.
24
+ - Added `scheduledRuns.storeRoot` for durable schedules outside project repositories. Thanks to @ProCleiton for #891 and the prior #890 implementation.
25
+
26
+ ### Changed
27
+ - Clarified native supervisor messaging and optional external intercom result delivery in the docs and packaged skill.
28
+ - Identify status and transcript targets before the spawn-budget summary in collapsed tool-result cards.
29
+ - Point interactive async-launch guidance to `subagent_wait({ id, nonBlocking: true })` when an explicit wake is needed without blocking the current turn.
30
+
31
+ ### Fixed
32
+ - Report the explicit workflow execution cwd in async workflow status, job, and result records. Thanks to @nicobailon for #907.
33
+ - Ignore stale extension-context errors from advisory foreground control notifications after reload. Thanks to @alexei-led for #905.
34
+ - 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.
35
+ - Prevent boolean chain `output` values from crashing clarify rendering. Thanks to @ftoleedo for #901.
36
+ - Preserve `workflow` mode when asynchronous workflow mission runs complete.
37
+ - Serialize and merge each mission workflow-state write with the latest file so separate workflows do not drop unrelated keys.
38
+ - Preserve `workflowScript` worktree children that detach for supervisor coordination instead of cleaning a live managed worktree. Thanks to @astarktc for #896.
39
+ - Accept schema-valid structured output after a child recovers from an earlier tool error. Thanks to @white-hat for the report in #888.
40
+
5
41
  ## [0.43.0] - 2026-08-07
6
42
 
7
43
  ### Added
@@ -19,6 +55,7 @@
19
55
  - Require workflowScript-only persisted schedule targets. Removed legacy agent-target restore conversion.
20
56
 
21
57
  ### Fixed
58
+ - Use portable internal ids for async workflow directories and preserve host tool-call ids as correlation metadata. Thanks to @DrunkenDonkey80 for #889.
22
59
  - Represent gate normalization with explicit success and failure results, removing ambiguous internal states without changing gate behavior.
23
60
  - Preserve live composite child tool-call ids for APIs that normalize them, preventing context rewriting from breaking the next tool-loop turn.
24
61
  - 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.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:
@@ -367,7 +377,9 @@ Use `oracle` as a smart-friend escalation when the parent needs help with trajec
367
377
 
368
378
  ## Subagent + Intercom Coordination
369
379
 
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.
380
+ `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`.
381
+
382
+ 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
383
 
372
384
  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
385
 
@@ -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
 
@@ -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>> {
@@ -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
  }
@@ -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;
@@ -269,7 +270,8 @@ export function formatAsyncStartedMessage(headline: string, interactive: boolean
269
270
  ? [
270
271
  "The async run is detached and running in the background.",
271
272
  "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.",
273
+ "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.",
274
+ "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
275
  "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
276
  ]
275
277
  : [
@@ -1287,6 +1289,13 @@ export function executeAsyncSingle(
1287
1289
  const validationError = validateFileOnlyOutputMode(outputMode, outputPath, `Async single run (${agent})`);
1288
1290
  if (validationError) return formatAsyncStartError("single", validationError);
1289
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;
1290
1299
  const primaryModel = externalRunner ? undefined : resolveSubagentModelOverride(
1291
1300
  params.modelOverride ?? agentConfig.model,
1292
1301
  ctx.currentModel,
@@ -1414,7 +1423,7 @@ export function executeAsyncSingle(
1414
1423
  permissionRules,
1415
1424
  ...(capabilityCeiling ? { capabilityCeiling } : {}),
1416
1425
  agent,
1417
- task: taskWithOutputInstruction,
1426
+ task: taskText,
1418
1427
  ...(agentConfig.runner ? { runner: agentConfig.runner } : {}),
1419
1428
  ...(params.context ? { context: params.context } : {}),
1420
1429
  cwd: runnerCwd,
@@ -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;
@@ -206,7 +207,10 @@ function deriveAsyncActivityState(asyncDir: string, status: AsyncStatus): { acti
206
207
  const currentStep = typeof status.currentStep === "number" ? status.steps?.[status.currentStep] : undefined;
207
208
  return {
208
209
  activityState: status.activityState,
209
- 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),
210
214
  };
211
215
  }
212
216
 
@@ -288,6 +292,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
288
292
  return {
289
293
  id: status.runId || path.basename(asyncDir),
290
294
  asyncDir,
295
+ ...(status.toolCallId ? { toolCallId: status.toolCallId } : {}),
291
296
  ...(status.sessionId ? { sessionId: status.sessionId } : {}),
292
297
  state: status.state,
293
298
  ...(status.error ? { error: status.error } : {}),
@@ -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) {