pi-subagents 0.58.0 → 0.60.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.
Files changed (116) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/docs/agents.md +5 -3
  3. package/docs/configuration.md +4 -4
  4. package/docs/extension-api.md +1 -1
  5. package/docs/models.md +24 -1
  6. package/docs/observability.md +1 -1
  7. package/docs/tool-reference.md +93 -5
  8. package/docs/workflows.md +94 -3
  9. package/package.json +3 -1
  10. package/prompts/review-loop.md +2 -2
  11. package/skills/council-mode/SKILL.md +48 -243
  12. package/skills/council-mode/references/pass-contracts.md +150 -0
  13. package/skills/pi-subagents/SKILL.md +87 -37
  14. package/skills/pi-subagents/references/constraints-and-recipes.md +29 -233
  15. package/skills/pi-subagents/references/execution-controls.md +52 -7
  16. package/skills/pi-subagents/references/management-authoring-rpc.md +3 -4
  17. package/skills/pi-subagents/references/multi-lane-orchestration.md +13 -1
  18. package/skills/pi-subagents/references/prompting-and-roles.md +54 -26
  19. package/skills/pi-subagents/references/review-and-validation.md +73 -0
  20. package/src/agents/agent-management.ts +159 -42
  21. package/src/agents/agent-serializer.ts +4 -2
  22. package/src/agents/agents.ts +67 -26
  23. package/src/agents/runtime-agent-registry.ts +9 -16
  24. package/src/api/background-work.ts +5 -1
  25. package/src/api/delegation.ts +0 -7
  26. package/src/api/preflight.ts +6 -9
  27. package/src/api/shared-types.ts +2 -0
  28. package/src/extension/fanout-child.ts +5 -3
  29. package/src/extension/index.ts +51 -23
  30. package/src/extension/public-execution.ts +16 -2
  31. package/src/extension/schemas.ts +36 -10
  32. package/src/extension/tool-description.ts +24 -5
  33. package/src/intercom/result-intercom.ts +2 -0
  34. package/src/profiles/profiles.ts +5 -6
  35. package/src/runs/background/active-async-capacity.ts +2 -2
  36. package/src/runs/background/async-execution.ts +52 -37
  37. package/src/runs/background/async-job-tracker.ts +17 -12
  38. package/src/runs/background/async-resume.ts +23 -25
  39. package/src/runs/background/async-status-snapshot.ts +23 -261
  40. package/src/runs/background/async-status.ts +110 -8
  41. package/src/runs/background/chain-append.ts +6 -3
  42. package/src/runs/background/chain-root-attachment.ts +60 -8
  43. package/src/runs/background/control-channel.ts +3 -2
  44. package/src/runs/background/fleet-view.ts +21 -11
  45. package/src/runs/background/notify.ts +158 -6
  46. package/src/runs/background/result-files.ts +2 -1
  47. package/src/runs/background/result-watcher.ts +2 -0
  48. package/src/runs/background/resume-guidance.ts +1 -1
  49. package/src/runs/background/retained-children.ts +1 -1
  50. package/src/runs/background/run-status.ts +30 -9
  51. package/src/runs/background/scheduled-runs.ts +86 -7
  52. package/src/runs/background/stale-run-reconciler.ts +10 -4
  53. package/src/runs/background/steering.ts +4 -14
  54. package/src/runs/background/subagent-runner.ts +351 -358
  55. package/src/runs/background/subagent-wait.ts +68 -12
  56. package/src/runs/background/terminal-run-index.ts +1 -1
  57. package/src/runs/background/wait-completions.ts +25 -1
  58. package/src/runs/background/wait-config.ts +23 -9
  59. package/src/runs/background/wait-tool.ts +9 -2
  60. package/src/runs/foreground/async-steering-action.ts +2 -2
  61. package/src/runs/foreground/execution.ts +125 -122
  62. package/src/runs/foreground/foreground-control.ts +3 -0
  63. package/src/runs/foreground/foreground-history.ts +1 -0
  64. package/src/runs/foreground/subagent-executor.ts +576 -240
  65. package/src/runs/foreground/workflow-detach-reconcile.ts +99 -200
  66. package/src/runs/shared/abort-recovery.ts +119 -0
  67. package/src/runs/shared/async-status-projection.ts +597 -0
  68. package/src/runs/shared/background-process-options.ts +9 -0
  69. package/src/runs/shared/child-identity.ts +19 -4
  70. package/src/runs/shared/child-launch-plan.ts +151 -0
  71. package/src/runs/shared/completion-evidence.ts +89 -0
  72. package/src/runs/shared/completion-guard.ts +1 -1
  73. package/src/runs/shared/dynamic-fanout.ts +2 -2
  74. package/src/runs/shared/host-step-status.ts +230 -0
  75. package/src/runs/shared/lane-metadata.ts +105 -0
  76. package/src/runs/shared/mcp-config-sources.ts +42 -6
  77. package/src/runs/shared/mcp-direct-tool-allowlist.ts +93 -143
  78. package/src/runs/shared/mcp-direct-tool-grant.ts +194 -0
  79. package/src/runs/shared/model-fallback.ts +9 -2
  80. package/src/runs/shared/mutation-evidence.ts +52 -3
  81. package/src/runs/shared/nested-events.ts +6 -2
  82. package/src/runs/shared/nested-render.ts +7 -3
  83. package/src/runs/shared/parallel-handoff.ts +419 -7
  84. package/src/runs/shared/parallel-utils.ts +7 -0
  85. package/src/runs/shared/pi-args.ts +67 -3
  86. package/src/runs/shared/single-output.ts +72 -22
  87. package/src/runs/shared/subagent-prompt-runtime.ts +33 -6
  88. package/src/runs/shared/workflow-graph.ts +15 -0
  89. package/src/runs/shared/worktree-cleanup-plan.ts +847 -0
  90. package/src/runs/shared/worktree.ts +18 -0
  91. package/src/shared/child-session-name.ts +46 -0
  92. package/src/shared/extension-context.ts +24 -0
  93. package/src/shared/formatters.ts +5 -2
  94. package/src/shared/launch-contract.ts +1 -1
  95. package/src/shared/settings.ts +9 -103
  96. package/src/shared/types.ts +232 -30
  97. package/src/shared/utils.ts +35 -55
  98. package/src/slash/delegation-adapters.ts +1 -8
  99. package/src/slash/delegation-request.ts +0 -4
  100. package/src/slash/slash-bridge.ts +1 -2
  101. package/src/slash/slash-commands.ts +369 -90
  102. package/src/slash/slash-live-state.ts +22 -11
  103. package/src/tui/fleet-status.ts +134 -75
  104. package/src/tui/fleet.ts +11 -5
  105. package/src/tui/render-helpers.ts +31 -0
  106. package/src/tui/render.ts +895 -121
  107. package/src/watchdog/change-signature.ts +40 -1
  108. package/src/watchdog/turn-delta.ts +1 -1
  109. package/src/workflows/chat-progress.ts +6 -3
  110. package/src/workflows/host-command.ts +235 -0
  111. package/src/workflows/scripted-workflow.ts +503 -38
  112. package/src/workflows/workflow-child-summary.ts +9 -5
  113. package/src/workflows/workflow-preflight.ts +270 -0
  114. package/src/workflows/workflow-receipt.ts +43 -4
  115. package/src/workflows/workflow-settlement.ts +246 -0
  116. package/src/runs/shared/turn-budget.ts +0 -98
@@ -58,12 +58,15 @@ import {
58
58
  type Details,
59
59
  type ForegroundResumeRun,
60
60
  type SubagentState,
61
+ type Usage,
61
62
  type WaitCompletion,
62
63
  } from "../../shared/types.ts";
63
64
  import { formatDuration, shortenPath } from "../../shared/formatters.ts";
65
+ import { toAgentToolUsage } from "../../shared/utils.ts";
64
66
  import { collectWaitCompletions } from "./wait-completions.ts";
65
67
  import { formatResumeFirstFailedRunsNote } from "./resume-guidance.ts";
66
- export { WAIT_TOOL_ENABLED_ENV, resolveWaitToolConfig, type ResolvedWaitToolConfig } from "./wait-config.ts";
68
+ import { formatTimeoutRecoveryLines } from "../shared/mutation-evidence.ts";
69
+ export { WAIT_TOOL_DEFAULT_TIMEOUT_MS_ENV, WAIT_TOOL_ENABLED_ENV, resolveWaitToolConfig, type ResolvedWaitToolConfig } from "./wait-config.ts";
67
70
 
68
71
  /** States that mean a run is still in flight (not yet resolved). */
69
72
  const ACTIVE_STATES: ReadonlyArray<AsyncRunSummary["state"]> = ["queued", "running"];
@@ -84,7 +87,7 @@ export interface SubagentWaitParams {
84
87
  * targets a single run.
85
88
  */
86
89
  all?: boolean;
87
- /** Give up after this many milliseconds. Defaults to 30 minutes. */
90
+ /** Give up after this many milliseconds. Defaults to waitTool.defaultTimeoutMs, then 30 minutes. */
88
91
  timeoutMs?: number;
89
92
  /** False keeps a blocking wait open through idle attention; supervisor/contact requests still stop the wait. */
90
93
  stopOnAttention?: boolean;
@@ -106,6 +109,8 @@ export interface SubagentWaitDeps {
106
109
  pollIntervalMs?: number;
107
110
  /** False makes the tool return immediately without blocking active async runs. */
108
111
  enabled?: boolean;
112
+ /** Configured blocking window used when the call omits timeoutMs. */
113
+ defaultTimeoutMs?: number;
109
114
  /** Injectable sleep for tests. */
110
115
  sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
111
116
  /** Internal auto-drain mode waits through needs-attention states. */
@@ -312,10 +317,31 @@ function summarizeTerminalRuns(runs: AsyncRunSummary[], providerFinishedCount =
312
317
  return parts.join(", ");
313
318
  }
314
319
 
320
+ function completionUsage(completions: WaitCompletion[] | undefined): Usage | undefined {
321
+ if (!completions?.length) return undefined;
322
+ const usage: Usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
323
+ let projected = false;
324
+ for (const completion of completions) {
325
+ for (const child of completion.results ?? []) {
326
+ if (!child.usage || (child.usage.input === 0 && child.usage.output === 0 && child.usage.cacheRead === 0 && child.usage.cacheWrite === 0 && child.usage.cost === 0 && child.usage.turns === 0)) continue;
327
+ projected = true;
328
+ usage.input += child.usage.input;
329
+ usage.output += child.usage.output;
330
+ usage.cacheRead += child.usage.cacheRead;
331
+ usage.cacheWrite += child.usage.cacheWrite;
332
+ usage.cost += child.usage.cost;
333
+ usage.turns += child.usage.turns;
334
+ }
335
+ }
336
+ return projected ? usage : undefined;
337
+ }
338
+
315
339
  function result(text: string, isError = false, completions?: WaitCompletion[]): AgentToolResult<Details> {
340
+ const usage = completionUsage(completions);
316
341
  return {
317
342
  content: [{ type: "text", text }],
318
343
  ...(isError ? { isError: true } : {}),
344
+ ...(usage ? { usage: toAgentToolUsage(usage) } : {}),
319
345
  details: {
320
346
  mode: "management",
321
347
  results: [],
@@ -324,6 +350,32 @@ function result(text: string, isError = false, completions?: WaitCompletion[]):
324
350
  };
325
351
  }
326
352
 
353
+ function formatCompletionRecovery(completions: WaitCompletion[] | undefined): string {
354
+ const lines = (completions ?? []).flatMap((completion) =>
355
+ (completion.results ?? []).flatMap((child) => formatTimeoutRecoveryLines(child.timeoutRecovery)));
356
+ return lines.length > 0 ? `\n${lines.join("\n")}` : "";
357
+ }
358
+
359
+ function windowElapsedResult(
360
+ text: string,
361
+ activeRunIds: string[],
362
+ activeProviderItems: readonly RegisteredBackgroundWorkItem[] = [],
363
+ ): AgentToolResult<Details> {
364
+ return {
365
+ content: [{ type: "text", text }],
366
+ details: {
367
+ mode: "management",
368
+ results: [],
369
+ wait: {
370
+ reason: "window_elapsed",
371
+ timedOut: true,
372
+ activeRunIds,
373
+ activeProviderItems: activeProviderItems.map(({ provider, id }) => ({ provider, id })),
374
+ },
375
+ },
376
+ };
377
+ }
378
+
327
379
  /** Build the live status shown while async work keeps subagent_wait blocked. */
328
380
  function asyncWaitUpdate(runs: AsyncRunSummary[], providerCount: number, elapsedMs: number): AgentToolResult<Details> {
329
381
  const activity = runs.flatMap((run) => {
@@ -471,9 +523,9 @@ async function waitForDetachedForegroundRun(
471
523
  return result(`Wait aborted after ${formatDuration(now() - startedAt)}. Remembered foreground run "${run.runId}" remains detached. Reply to any pending supervisor request before resuming or launching a replacement.`, true);
472
524
  }
473
525
  if (now() - startedAt >= timeoutMs) {
474
- return result(
475
- `Wait timed out after ${formatDuration(timeoutMs)} with remembered foreground run "${run.runId}" still detached. Reply to any pending supervisor request, then call subagent_wait({ id: "${run.runId}" }) again or inspect status; do not resume or launch a replacement while it remains detached.`,
476
- true,
526
+ return windowElapsedResult(
527
+ `Wait window elapsed after ${formatDuration(timeoutMs)} with remembered foreground run "${run.runId}" still detached. Reply to any pending supervisor request, then call subagent_wait({ id: "${run.runId}" }) again or inspect status; do not resume or launch a replacement while it remains detached.`,
528
+ [run.runId],
477
529
  );
478
530
  }
479
531
  await waitForWake(pollIntervalMs, signal, deps);
@@ -499,7 +551,9 @@ export async function waitForSubagents(
499
551
 
500
552
  const now = deps.now ?? Date.now;
501
553
  const pollIntervalMs = Math.max(MIN_POLL_INTERVAL_MS, deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
502
- const timeoutMs = params.timeoutMs !== undefined && params.timeoutMs > 0 ? params.timeoutMs : DEFAULT_TIMEOUT_MS;
554
+ const timeoutMs = params.timeoutMs !== undefined && params.timeoutMs > 0
555
+ ? params.timeoutMs
556
+ : deps.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
503
557
  const startedAt = now();
504
558
  const waitForAll = params.id ? true : params.all === true;
505
559
  if (params.nonBlocking && !params.id) {
@@ -586,9 +640,10 @@ export async function waitForSubagents(
586
640
  return result(`Wait aborted after ${formatDuration(now() - startedAt)}. Still active: ${stillActive}.`, true);
587
641
  }
588
642
  if (now() - startedAt >= timeoutMs) {
589
- return result(
590
- `Wait timed out after ${formatDuration(timeoutMs)} with ${activeInitialRuns.length} async run(s) and ${activeInitialProviderItems.length} provider item(s) still active: ${stillActive}. The work keeps going; call subagent_wait again or inspect subagent status.`,
591
- true,
643
+ return windowElapsedResult(
644
+ `Wait window elapsed after ${formatDuration(timeoutMs)} with ${activeInitialRuns.length} async run(s) and ${activeInitialProviderItems.length} provider item(s) still active: ${stillActive}. The work keeps going; call subagent_wait again or inspect subagent status.`,
645
+ activeInitialRuns.map((run) => run.id),
646
+ activeInitialProviderItems,
592
647
  );
593
648
  }
594
649
  try {
@@ -618,7 +673,7 @@ export async function waitForSubagents(
618
673
  const allNow = runsForIds(initialAsyncIds, deps);
619
674
  const terminal = allNow.filter((run) => !ACTIVE_STATES.includes(run.state) && initialAsyncIds.has(run.id));
620
675
  finishedAsyncCount = terminal.length;
621
- failedAsyncCount = terminal.filter((run) => run.state === "failed").length;
676
+ failedAsyncCount = terminal.filter((run) => run.state === "failed" || run.state === "partial").length;
622
677
  terminalSummary = summarizeTerminalRuns(terminal, providerFinishedCount);
623
678
  resumeGuidance = formatResumeFirstFailedRunsNote(terminal);
624
679
  completions = collectWaitCompletions(terminal, deps.state, deps.resultsDir ?? DIRS.results);
@@ -637,6 +692,7 @@ export async function waitForSubagents(
637
692
  + providerActive.filter((item) => initialProviderIds.has(backgroundWorkIdentity(item))).length;
638
693
  const elapsed = formatDuration(now() - startedAt);
639
694
  const outcome = terminalSummary ? ` Outcome: ${terminalSummary}.` : "";
695
+ const recoveryNote = formatCompletionRecovery(completions);
640
696
 
641
697
  if (waitForAll) {
642
698
  const scope = params.id
@@ -646,7 +702,7 @@ export async function waitForSubagents(
646
702
  : `${initialAsyncIds.size} async run(s) and ${initialProviderIds.size} provider item(s)`;
647
703
  const status = relevantAttention.length > 0 ? "attention required" : "done";
648
704
  return result(
649
- `Waited ${elapsed} for ${scope}; ${status}.${outcome}${resumeGuidance}${attentionNote} Completion/control events have been observed; inspect status if a notification is not visible yet.`,
705
+ `Waited ${elapsed} for ${scope}; ${status}.${outcome}${recoveryNote}${resumeGuidance}${attentionNote} Completion/control events have been observed; inspect status if a notification is not visible yet.`,
650
706
  (deps.failOnFailedRuns === true && failedAsyncCount > 0) || (deps.failOnAttention === true && relevantAttention.length > 0),
651
707
  completions,
652
708
  );
@@ -663,7 +719,7 @@ export async function waitForSubagents(
663
719
  ? `${relevantAttention.length} of ${initialCount} ${subject} need attention`
664
720
  : `${finishedCount} of ${initialCount} ${subject} finished`;
665
721
  return result(
666
- `Waited ${elapsed}; ${progress}.${outcome}${resumeGuidance}${attentionNote}${remainder} Relevant completion/control events have been observed; inspect status if a notification is not visible yet.`,
722
+ `Waited ${elapsed}; ${progress}.${outcome}${recoveryNote}${resumeGuidance}${attentionNote}${remainder} Relevant completion/control events have been observed; inspect status if a notification is not visible yet.`,
667
723
  (deps.failOnFailedRuns === true && failedAsyncCount > 0) || (deps.failOnAttention === true && relevantAttention.length > 0),
668
724
  completions,
669
725
  );
@@ -18,7 +18,7 @@ interface TerminalRunIndexEntry {
18
18
  }
19
19
 
20
20
  function isTerminalState(state: AsyncStatus["state"]): boolean {
21
- return state === "complete" || state === "failed" || state === "paused" || state === "stopped" || state === "rejected";
21
+ return state === "complete" || state === "failed" || state === "partial" || state === "paused" || state === "stopped" || state === "rejected";
22
22
  }
23
23
 
24
24
  function indexRoot(asyncDirRoot: string): string {
@@ -1,14 +1,32 @@
1
1
  import * as fs from "node:fs";
2
- import type { ArtifactPaths, SubagentState, WaitCompletion, WaitCompletionChild } from "../../shared/types.ts";
2
+ import type { ArtifactPaths, SubagentState, Usage, WaitCompletion, WaitCompletionChild } from "../../shared/types.ts";
3
3
  import type { AsyncRunSummary } from "./async-status.ts";
4
4
  import { readCompletionReplay, writeCompletionReplay } from "./completion-replay.ts";
5
5
  import { fallbackResultPayloadPathForSessionRun, resultFilePath, resultPayloadPathForSessionRun } from "./result-files.ts";
6
6
  import { parseWorkflowChildSummary } from "../../workflows/workflow-child-summary.ts";
7
+ import { projectTimeoutRecovery } from "../shared/mutation-evidence.ts";
7
8
 
8
9
  function asNonEmptyString(value: unknown): string | undefined {
9
10
  return typeof value === "string" && value ? value : undefined;
10
11
  }
11
12
 
13
+ function nonNegativeNumber(value: unknown): number | undefined {
14
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
15
+ }
16
+
17
+ function projectedUsage(value: unknown): Usage | undefined {
18
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
19
+ const record = value as Record<string, unknown>;
20
+ const input = nonNegativeNumber(record.input);
21
+ const output = nonNegativeNumber(record.output);
22
+ const cacheRead = nonNegativeNumber(record.cacheRead);
23
+ const cacheWrite = nonNegativeNumber(record.cacheWrite);
24
+ const cost = nonNegativeNumber(record.cost);
25
+ const turns = nonNegativeNumber(record.turns);
26
+ if (input === undefined || output === undefined || cacheRead === undefined || cacheWrite === undefined || cost === undefined || turns === undefined || !Number.isSafeInteger(turns)) return undefined;
27
+ return { input, output, cacheRead, cacheWrite, cost, turns };
28
+ }
29
+
12
30
  function errorCode(error: unknown): string | undefined {
13
31
  return typeof error === "object" && error !== null && "code" in error
14
32
  ? (error as NodeJS.ErrnoException).code
@@ -43,18 +61,24 @@ export function toWaitCompletion(data: Record<string, unknown>, runId: string):
43
61
  : undefined;
44
62
  const agent = asNonEmptyString(child.agent);
45
63
  const childRunId = asNonEmptyString(child.runId);
64
+ const usage = projectedUsage(child.usage);
65
+ const sessionFile = asNonEmptyString(child.sessionFile);
46
66
  const error = asNonEmptyString(child.error);
47
67
  const model = asNonEmptyString(child.model);
48
68
  const contextOverflow = child.contextOverflow === true;
69
+ const timeoutRecovery = projectTimeoutRecovery(child.timeoutRecovery);
49
70
  return [{
50
71
  ...(agent ? { agent } : {}),
51
72
  ...(childRunId ? { runId: childRunId } : {}),
73
+ ...(usage ? { usage } : {}),
74
+ ...(sessionFile ? { sessionFile } : {}),
52
75
  ...(typeof child.success === "boolean" ? { success: child.success } : {}),
53
76
  ...(outputState ? { outputState } : {}),
54
77
  ...(error ? { error } : {}),
55
78
  ...(model ? { model } : {}),
56
79
  ...(contextOverflow ? { contextOverflow: true } : {}),
57
80
  ...(artifactPaths ? { artifactPaths } : {}),
81
+ ...(timeoutRecovery ? { timeoutRecovery } : {}),
58
82
  }];
59
83
  })
60
84
  : undefined;
@@ -1,9 +1,11 @@
1
1
  import type { WaitToolConfig } from "../../shared/types.ts";
2
2
 
3
3
  export const WAIT_TOOL_ENABLED_ENV = "PI_SUBAGENT_WAIT_TOOL_ENABLED";
4
+ export const WAIT_TOOL_DEFAULT_TIMEOUT_MS_ENV = "PI_SUBAGENT_WAIT_TOOL_DEFAULT_TIMEOUT_MS";
4
5
 
5
6
  export interface ResolvedWaitToolConfig {
6
7
  enabled: boolean;
8
+ defaultTimeoutMs?: number;
7
9
  }
8
10
 
9
11
  const TRUE_VALUES = new Set(["1", "true", "yes", "on", "enabled"]);
@@ -17,20 +19,32 @@ function environmentValue(value: string | undefined): boolean | undefined {
17
19
  throw new Error(`${WAIT_TOOL_ENABLED_ENV} must be one of true/false, 1/0, yes/no, on/off, or enabled/disabled.`);
18
20
  }
19
21
 
20
- function configuredValue(config: unknown): boolean | undefined {
21
- if (config === undefined) return undefined;
22
- if (typeof config === "boolean") return config;
22
+ function environmentDefaultTimeoutMs(value: string | undefined): number | undefined {
23
+ if (value === undefined) return undefined;
24
+ const parsed = Number(value);
25
+ if (!Number.isInteger(parsed) || parsed < 1) throw new Error(`${WAIT_TOOL_DEFAULT_TIMEOUT_MS_ENV} must be a positive integer.`);
26
+ return parsed;
27
+ }
28
+
29
+ function configuredValue(config: unknown): { enabled?: boolean; defaultTimeoutMs?: number } {
30
+ if (config === undefined) return {};
31
+ if (typeof config === "boolean") return { enabled: config };
23
32
  if (!config || typeof config !== "object" || Array.isArray(config)) {
24
- throw new Error("config.waitTool must be a boolean or an object with optional enabled boolean.");
33
+ throw new Error("config.waitTool must be a boolean or an object with optional enabled and defaultTimeoutMs values.");
34
+ }
35
+ const { enabled, defaultTimeoutMs } = config as { enabled?: unknown; defaultTimeoutMs?: unknown };
36
+ if (enabled !== undefined && typeof enabled !== "boolean") throw new Error("config.waitTool.enabled must be a boolean.");
37
+ if (defaultTimeoutMs !== undefined && (!Number.isInteger(defaultTimeoutMs) || (defaultTimeoutMs as number) < 1)) {
38
+ throw new Error("config.waitTool.defaultTimeoutMs must be a positive integer.");
25
39
  }
26
- const enabled = (config as { enabled?: unknown }).enabled;
27
- if (enabled === undefined) return undefined;
28
- if (typeof enabled !== "boolean") throw new Error("config.waitTool.enabled must be a boolean.");
29
- return enabled;
40
+ return { enabled, ...(defaultTimeoutMs !== undefined ? { defaultTimeoutMs: defaultTimeoutMs as number } : {}) };
30
41
  }
31
42
 
32
43
  export function resolveWaitToolConfig(config?: WaitToolConfig, env: Record<string, string | undefined> = process.env): ResolvedWaitToolConfig {
44
+ const configured = configuredValue(config);
45
+ const envDefaultTimeoutMs = environmentDefaultTimeoutMs(env[WAIT_TOOL_DEFAULT_TIMEOUT_MS_ENV]);
33
46
  return {
34
- enabled: environmentValue(env[WAIT_TOOL_ENABLED_ENV]) ?? configuredValue(config) ?? true,
47
+ enabled: environmentValue(env[WAIT_TOOL_ENABLED_ENV]) ?? configured.enabled ?? true,
48
+ ...(envDefaultTimeoutMs ?? configured.defaultTimeoutMs !== undefined ? { defaultTimeoutMs: envDefaultTimeoutMs ?? configured.defaultTimeoutMs } : {}),
35
49
  };
36
50
  }
@@ -5,7 +5,13 @@ import { resolveWaitToolConfig, waitForSubagents } from "./subagent-wait.ts";
5
5
  import type { WaitSubscriptionManager } from "./wait-subscriptions.ts";
6
6
  import { finalizeToolResult } from "../../extension/tool-result.ts";
7
7
 
8
- export function registerWaitTool(pi: ExtensionAPI, state: SubagentState, enabled = resolveWaitToolConfig().enabled, subscriptions?: Pick<WaitSubscriptionManager, "arm">): void {
8
+ export function registerWaitTool(
9
+ pi: ExtensionAPI,
10
+ state: SubagentState,
11
+ enabled = resolveWaitToolConfig().enabled,
12
+ subscriptions?: Pick<WaitSubscriptionManager, "arm">,
13
+ defaultTimeoutMs?: number,
14
+ ): void {
9
15
  const tool: ToolDefinition<typeof SubagentWaitParams, Details> = {
10
16
  name: "subagent_wait",
11
17
  label: "Subagent Wait",
@@ -18,7 +24,7 @@ In an interactive chat, do not call this merely to wait: return control to the u
18
24
  • { id: "..." } — wait for one async or remembered detached foreground subagent run (id or prefix).
19
25
  • { id: "...", nonBlocking: true } — resolve the prefix once, persist an exact-run wake subscription, and return immediately. The originating interactive session wakes on completion, failure, attention, reconciliation failure, or timeout.
20
26
  • { stopOnAttention: false } — for blocking waits only, keep waiting through idle or long-thinking attention; supervisor/contact requests still stop the wait.
21
- • { timeoutMs: 600000 } — stop waiting after N ms; active work keeps running.
27
+ • { timeoutMs: 600000 } — stop waiting after N ms; active work keeps running. Omitted values use waitTool.defaultTimeoutMs, then 30 minutes. Window expiry returns a non-error window_elapsed result with active work identities.
22
28
 
23
29
  Non-blocking subscriptions are visible in subagent status and differ from disabling waitTool: waitTool.enabled=false returns immediately without registering any future wake. Provider jobs are session-scoped and identified exactly, so replacing one job with another cannot hide a completion. Provider extensions must be explicitly loaded in this process. In a child agent, keep \`subagent_wait\` in the child tool allowlist and load each provider through the agent's extensions or subagentOnlyExtensions; this tool never loads providers or grants tools itself.${enabled ? "" : "\n\nConfigured behavior: subagent_wait is disabled by config.waitTool or PI_SUBAGENT_WAIT_TOOL_ENABLED and returns immediately without blocking."}`,
24
30
  parameters: SubagentWaitParams,
@@ -27,6 +33,7 @@ Non-blocking subscriptions are visible in subagent status and differ from disabl
27
33
  state,
28
34
  events: pi.events,
29
35
  enabled,
36
+ ...(defaultTimeoutMs !== undefined ? { defaultTimeoutMs } : {}),
30
37
  onUpdate,
31
38
  ...(subscriptions && ctx?.hasUI ? { subscribe: (input) => subscriptions.arm(input) } : {}),
32
39
  }));
@@ -3,7 +3,7 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
5
5
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
6
- import type { AsyncStatus, Details, SubagentState, ToolBudgetConfig, TurnBudgetConfig } from "../../shared/types.ts";
6
+ import type { AsyncStatus, Details, SubagentState, ToolBudgetConfig } from "../../shared/types.ts";
7
7
  import { readStatus } from "../../shared/utils.ts";
8
8
  import { consumeSteerAcks, deliverInterruptRequest, queueRevivalBrief, requestAsyncSteer, type SteerDeliveryMode, type SteerRequest } from "../background/control-channel.ts";
9
9
  import { resolveAsyncResumeTarget } from "../background/async-resume.ts";
@@ -34,7 +34,7 @@ export async function steerAsyncRun(input: {
34
34
  onRequestQueued?: (requestPath: string) => void;
35
35
  onBeforeRecoveryClaim?: (requestId: string, committedAt: number) => void;
36
36
  onRecoveryCommitted?: (requestId: string, committedAt: number) => void;
37
- recover?: (limits: { timeoutMs?: number; absoluteDeadlineAt?: number; turnBudget?: TurnBudgetConfig; toolBudget?: ToolBudgetConfig }) => Promise<AgentToolResult<Details>>;
37
+ recover?: (limits: { timeoutMs?: number; absoluteDeadlineAt?: number; toolBudget?: ToolBudgetConfig }) => Promise<AgentToolResult<Details>>;
38
38
  }): Promise<AgentToolResult<Details>> {
39
39
  if (!input.location.asyncDir) {
40
40
  return {