pi-subagents 0.28.0 → 0.30.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 (47) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +26 -62
  3. package/package.json +1 -1
  4. package/skills/pi-subagents/SKILL.md +29 -35
  5. package/src/agents/agent-management.ts +29 -22
  6. package/src/agents/agent-selection.ts +2 -0
  7. package/src/agents/agent-serializer.ts +5 -10
  8. package/src/agents/agents.ts +339 -47
  9. package/src/agents/chain-serializer.ts +4 -9
  10. package/src/agents/proactive-skills.ts +191 -0
  11. package/src/extension/doctor.ts +4 -3
  12. package/src/extension/fanout-child.ts +1 -3
  13. package/src/extension/index.ts +6 -9
  14. package/src/extension/schemas.ts +63 -26
  15. package/src/intercom/intercom-bridge.ts +11 -1
  16. package/src/intercom/result-intercom.ts +0 -5
  17. package/src/runs/background/async-execution.ts +186 -74
  18. package/src/runs/background/async-resume.ts +53 -5
  19. package/src/runs/background/async-status.ts +4 -1
  20. package/src/runs/background/chain-append.ts +282 -0
  21. package/src/runs/background/chain-root-attachment.ts +161 -0
  22. package/src/runs/background/run-status.ts +2 -7
  23. package/src/runs/background/subagent-runner.ts +160 -219
  24. package/src/runs/foreground/chain-execution.ts +62 -58
  25. package/src/runs/foreground/execution.ts +39 -343
  26. package/src/runs/foreground/subagent-executor.ts +316 -111
  27. package/src/runs/shared/acceptance.ts +605 -22
  28. package/src/runs/shared/chain-outputs.ts +23 -8
  29. package/src/runs/shared/completion-guard.ts +3 -26
  30. package/src/runs/shared/dynamic-fanout.ts +1 -1
  31. package/src/runs/shared/model-fallback.ts +38 -0
  32. package/src/runs/shared/parallel-utils.ts +13 -10
  33. package/src/runs/shared/pi-args.ts +3 -2
  34. package/src/runs/shared/subagent-control.ts +8 -11
  35. package/src/runs/shared/subagent-prompt-runtime.ts +3 -2
  36. package/src/runs/shared/workflow-graph.ts +2 -6
  37. package/src/shared/atomic-json.ts +68 -11
  38. package/src/shared/settings.ts +1 -0
  39. package/src/shared/types.ts +20 -49
  40. package/src/shared/utils.ts +2 -8
  41. package/src/slash/slash-bridge.ts +3 -1
  42. package/src/slash/slash-commands.ts +1 -1
  43. package/src/tui/render.ts +14 -29
  44. package/src/runs/shared/acceptance-contract.ts +0 -318
  45. package/src/runs/shared/acceptance-evaluation.ts +0 -221
  46. package/src/runs/shared/acceptance-finalization.ts +0 -173
  47. package/src/runs/shared/acceptance-reports.ts +0 -127
@@ -8,6 +8,11 @@ const SAFE_OUTPUT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
8
8
 
9
9
  export class ChainOutputValidationError extends Error {}
10
10
 
11
+ export interface ChainOutputValidationContext {
12
+ priorOutputNames?: Iterable<string>;
13
+ startStepIndex?: number;
14
+ }
15
+
11
16
  function outputNamesForStep(step: ChainStep): string[] {
12
17
  if (isParallelStep(step)) return step.parallel.map((task) => task.as).filter((name): name is string => Boolean(name));
13
18
  if (isDynamicParallelStep(step)) return [step.collect.as];
@@ -22,27 +27,37 @@ function taskTemplatesForStep(step: ChainStep): string[] {
22
27
  }
23
28
 
24
29
  export function validateChainOutputBindings(steps: ChainStep[], dynamicFanoutConfig: DynamicFanoutConfig = {}): void {
25
- const available = new Set<string>();
26
- const seen = new Set<string>();
30
+ validateChainOutputBindingsWithContext(steps, dynamicFanoutConfig);
31
+ }
32
+
33
+ export function validateChainOutputBindingsWithContext(
34
+ steps: ChainStep[],
35
+ dynamicFanoutConfig: DynamicFanoutConfig = {},
36
+ context: ChainOutputValidationContext = {},
37
+ ): void {
38
+ const priorOutputNames = [...(context.priorOutputNames ?? [])];
39
+ const available = new Set<string>(priorOutputNames);
40
+ const seen = new Set<string>(priorOutputNames);
27
41
  for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
42
+ const displayStepIndex = (context.startStepIndex ?? 0) + stepIndex + 1;
28
43
  const step = steps[stepIndex]!;
29
44
  if (hasDynamicFanoutFields(step)) {
30
45
  if (!isDynamicParallelStep(step)) {
31
- throw new ChainOutputValidationError(`Dynamic chain step ${stepIndex + 1} requires expand, a single parallel template object, and collect; dynamic expand/collect cannot be mixed with static parallel arrays.`);
46
+ throw new ChainOutputValidationError(`Dynamic chain step ${displayStepIndex} requires expand, a single parallel template object, and collect; dynamic expand/collect cannot be mixed with static parallel arrays.`);
32
47
  }
33
48
  try {
34
- validateDynamicStepShape(step, stepIndex, dynamicFanoutConfig);
49
+ validateDynamicStepShape(step, displayStepIndex - 1, dynamicFanoutConfig);
35
50
  } catch (error) {
36
51
  if (error instanceof DynamicFanoutError) throw new ChainOutputValidationError(error.message);
37
52
  throw error;
38
53
  }
39
54
  if (!available.has(step.expand.from.output)) {
40
- throw new ChainOutputValidationError(`Dynamic chain step ${stepIndex + 1} references unknown output '${step.expand.from.output}'. Named outputs are only available after producing step/group completes.`);
55
+ throw new ChainOutputValidationError(`Dynamic chain step ${displayStepIndex} references unknown output '${step.expand.from.output}'. Named outputs are only available after producing step/group completes.`);
41
56
  }
42
57
  }
43
58
  for (const name of outputNamesForStep(step)) {
44
59
  if (!SAFE_OUTPUT_NAME_PATTERN.test(name)) {
45
- throw new ChainOutputValidationError(`Invalid chain output name '${name}' at step ${stepIndex + 1}. Use /^[A-Za-z_][A-Za-z0-9_]*$/.`);
60
+ throw new ChainOutputValidationError(`Invalid chain output name '${name}' at step ${displayStepIndex}. Use /^[A-Za-z_][A-Za-z0-9_]*$/.`);
46
61
  }
47
62
  if (seen.has(name)) {
48
63
  throw new ChainOutputValidationError(`Duplicate chain output name '${name}'. Each as name must be unique.`);
@@ -54,10 +69,10 @@ export function validateChainOutputBindings(steps: ChainStep[], dynamicFanoutCon
54
69
  const rawReference = match[0];
55
70
  const name = match[1]!;
56
71
  if (!SAFE_OUTPUT_NAME_PATTERN.test(name)) {
57
- throw new ChainOutputValidationError(`Invalid chain output reference '${rawReference}' at step ${stepIndex + 1}. Use {outputs.name} with /^[A-Za-z_][A-Za-z0-9_]*$/ names.`);
72
+ throw new ChainOutputValidationError(`Invalid chain output reference '${rawReference}' at step ${displayStepIndex}. Use {outputs.name} with /^[A-Za-z_][A-Za-z0-9_]*$/ names.`);
58
73
  }
59
74
  if (!available.has(name)) {
60
- throw new ChainOutputValidationError(`Unknown chain output reference '${rawReference}' at step ${stepIndex + 1}. Named outputs are only available after producing step/group completes.`);
75
+ throw new ChainOutputValidationError(`Unknown chain output reference '${rawReference}' at step ${displayStepIndex}. Named outputs are only available after producing step/group completes.`);
61
76
  }
62
77
  }
63
78
  }
@@ -66,17 +66,6 @@ const READ_ONLY_BUILTIN_TOOLS = new Set([
66
66
  "contact_supervisor",
67
67
  ]);
68
68
 
69
- export type CompletionPolicy = "none" | "mutation-guard" | "acceptance-contract";
70
-
71
- interface CompletionPolicyInput {
72
- agent: string;
73
- task: string;
74
- completionGuardEnabled: boolean;
75
- usesAcceptanceContract: boolean;
76
- tools?: string[];
77
- mcpDirectTools?: string[];
78
- }
79
-
80
69
  interface CompletionMutationGuardInput {
81
70
  agent: string;
82
71
  task: string;
@@ -145,22 +134,10 @@ export function hasMutationToolCall(messages: Message[]): boolean {
145
134
  return false;
146
135
  }
147
136
 
148
- export function resolveCompletionPolicy(input: CompletionPolicyInput): CompletionPolicy {
149
- if (input.usesAcceptanceContract) return "acceptance-contract";
150
- if (!input.completionGuardEnabled) return "none";
151
- if (declaresOnlyReadOnlyTools(input.tools, input.mcpDirectTools)) return "none";
152
- return expectsImplementationMutation(input.agent, input.task) ? "mutation-guard" : "none";
153
- }
154
-
155
137
  export function evaluateCompletionMutationGuard(input: CompletionMutationGuardInput): CompletionMutationGuardResult {
156
- const expectedMutation = resolveCompletionPolicy({
157
- agent: input.agent,
158
- task: input.task,
159
- completionGuardEnabled: true,
160
- usesAcceptanceContract: false,
161
- tools: input.tools,
162
- mcpDirectTools: input.mcpDirectTools,
163
- }) === "mutation-guard";
138
+ const expectedMutation = declaresOnlyReadOnlyTools(input.tools, input.mcpDirectTools)
139
+ ? false
140
+ : expectsImplementationMutation(input.agent, input.task);
164
141
  const attemptedMutation = hasMutationToolCall(input.messages);
165
142
  return {
166
143
  expectedMutation,
@@ -48,7 +48,7 @@ const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outpu
48
48
  const RUNNER_DYNAMIC_PARALLEL_KEYS = new Set([
49
49
  ...DYNAMIC_PARALLEL_KEYS,
50
50
  "outputName", "structured", "inheritProjectContext", "inheritSkills", "skills", "outputPath", "maxSubagentDepth",
51
- "structuredOutput", "structuredOutputSchema", "tools", "extensions", "mcpDirectTools", "completionGuard", "systemPrompt",
51
+ "structuredOutput", "structuredOutputSchema", "tools", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "completionGuard", "systemPrompt",
52
52
  "systemPromptMode", "thinking", "modelCandidates", "sessionFile", "effectiveAcceptance",
53
53
  ]);
54
54
  const DYNAMIC_COLLECT_KEYS = new Set(["as", "outputSchema"]);
@@ -20,6 +20,44 @@ export function splitThinkingSuffix(model: string): { baseModel: string; thinkin
20
20
  };
21
21
  }
22
22
 
23
+ /** Sentinel model value requesting that a subagent inherit the parent session's model. */
24
+ export const INHERIT_MODEL = "inherit";
25
+
26
+ /** Minimal shape of the parent session's in-memory model (`ctx.model`). */
27
+ export interface ParentModel {
28
+ provider: string;
29
+ id: string;
30
+ }
31
+
32
+ /**
33
+ * Resolve the `--model` override passed to a spawned subagent.
34
+ *
35
+ * When no model is requested (`undefined`, `false`, empty, or the `"inherit"`
36
+ * sentinel), the child must inherit the parent session's *in-memory* model
37
+ * (`provider/id`) instead of being left to resolve its own model. Without an
38
+ * explicit `provider/id`, the child falls back to the global
39
+ * `~/.pi/agent/settings.json` default, which is shared across every open PI
40
+ * session — so a different session that last changed its model in the TUI would
41
+ * silently contaminate this session's subagents (see issue #266). Passing an
42
+ * explicit `provider/id` keeps each session's children isolated to that
43
+ * session's model.
44
+ *
45
+ * An explicitly requested model string is resolved via {@link resolveModelCandidate}.
46
+ */
47
+ export function resolveSubagentModelOverride(
48
+ requestedModel: string | boolean | undefined,
49
+ parentModel: ParentModel | undefined,
50
+ availableModels: AvailableModelInfo[] | undefined,
51
+ preferredProvider?: string,
52
+ ): string | undefined {
53
+ const trimmed = typeof requestedModel === "string" ? requestedModel.trim() : "";
54
+ const explicit = trimmed && trimmed !== INHERIT_MODEL ? trimmed : undefined;
55
+ if (explicit === undefined) {
56
+ return parentModel ? `${parentModel.provider}/${parentModel.id}` : undefined;
57
+ }
58
+ return resolveModelCandidate(explicit, availableModels, preferredProvider);
59
+ }
60
+
23
61
  export function resolveModelCandidate(
24
62
  model: string | undefined,
25
63
  availableModels: AvailableModelInfo[] | undefined,
@@ -1,9 +1,12 @@
1
- import type { DynamicCollectSpec, DynamicExpandSpec } from "../../shared/settings.ts";
2
- import type { JsonSchemaObject, ResolvedAcceptanceConfig } from "../../shared/types.ts";
3
-
4
1
  export interface RunnerSubagentStep {
5
2
  agent: string;
6
3
  task: string;
4
+ importAsyncRoot?: {
5
+ runId: string;
6
+ asyncDir: string;
7
+ resultPath: string;
8
+ index: number;
9
+ };
7
10
  phase?: string;
8
11
  label?: string;
9
12
  outputName?: string;
@@ -14,6 +17,7 @@ export interface RunnerSubagentStep {
14
17
  modelCandidates?: string[];
15
18
  tools?: string[];
16
19
  extensions?: string[];
20
+ subagentOnlyExtensions?: string[];
17
21
  mcpDirectTools?: string[];
18
22
  completionGuard?: boolean;
19
23
  systemPrompt?: string | null;
@@ -25,15 +29,13 @@ export interface RunnerSubagentStep {
25
29
  outputMode?: "inline" | "file-only";
26
30
  sessionFile?: string;
27
31
  maxSubagentDepth?: number;
28
- maxExecutionTimeMs?: number;
29
- maxTokens?: number;
30
32
  structuredOutput?: {
31
- schema: JsonSchemaObject;
33
+ schema: import("../../shared/types.ts").JsonSchemaObject;
32
34
  schemaPath: string;
33
35
  outputPath: string;
34
36
  };
35
- structuredOutputSchema?: JsonSchemaObject;
36
- effectiveAcceptance?: ResolvedAcceptanceConfig;
37
+ structuredOutputSchema?: import("../../shared/types.ts").JsonSchemaObject;
38
+ effectiveAcceptance?: import("../../shared/types.ts").ResolvedAcceptanceConfig;
37
39
  }
38
40
 
39
41
  export interface ParallelStepGroup {
@@ -44,13 +46,14 @@ export interface ParallelStepGroup {
44
46
  }
45
47
 
46
48
  export interface DynamicRunnerGroup {
47
- expand: DynamicExpandSpec;
49
+ expand: import("../../shared/settings.ts").DynamicExpandSpec;
48
50
  parallel: RunnerSubagentStep;
49
- collect: DynamicCollectSpec;
51
+ collect: import("../../shared/settings.ts").DynamicCollectSpec;
50
52
  concurrency?: number;
51
53
  failFast?: boolean;
52
54
  phase?: string;
53
55
  label?: string;
56
+ effectiveAcceptance?: import("../../shared/types.ts").ResolvedAcceptanceConfig;
54
57
  }
55
58
 
56
59
  export type RunnerStep = RunnerSubagentStep | ParallelStepGroup | DynamicRunnerGroup;
@@ -39,6 +39,7 @@ interface BuildPiArgsInput {
39
39
  inheritSkills: boolean;
40
40
  tools?: string[];
41
41
  extensions?: string[];
42
+ subagentOnlyExtensions?: string[];
42
43
  systemPrompt?: string | null;
43
44
  mcpDirectTools?: string[];
44
45
  cwd?: string;
@@ -120,11 +121,11 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
120
121
  : [PROMPT_RUNTIME_EXTENSION_PATH];
121
122
  if (input.extensions !== undefined) {
122
123
  args.push("--no-extensions");
123
- for (const extPath of [...new Set([...runtimeExtensions, ...toolExtensionPaths, ...input.extensions])]) {
124
+ for (const extPath of [...new Set([...runtimeExtensions, ...toolExtensionPaths, ...input.extensions, ...(input.subagentOnlyExtensions ?? [])])]) {
124
125
  args.push("--extension", extPath);
125
126
  }
126
127
  } else {
127
- for (const extPath of [...new Set([...runtimeExtensions, ...toolExtensionPaths])]) {
128
+ for (const extPath of [...new Set([...runtimeExtensions, ...toolExtensionPaths, ...(input.subagentOnlyExtensions ?? [])])]) {
128
129
  args.push("--extension", extPath);
129
130
  }
130
131
  }
@@ -173,9 +173,8 @@ export function formatControlNoticeMessage(event: ControlEvent, childIntercomTar
173
173
  ].filter((line): line is string => Boolean(line)).join("\n");
174
174
  }
175
175
 
176
- const nudgeCommand = childIntercomTarget
177
- ? `intercom({ action: "send", to: "${childIntercomTarget}", message: "What are you blocked on? Reply with the smallest next step or ask for a decision." })`
178
- : undefined;
176
+ const nudgeMessage = "What are you blocked on? Reply with the smallest next step or ask for a decision.";
177
+ const nudgeCommand = `subagent({ action: "resume", id: "${runTarget}", ${event.index !== undefined ? `index: ${event.index}, ` : ""}message: "${nudgeMessage}" })`;
179
178
  if (event.type === "active_long_running") {
180
179
  const facts = formatLongRunningFacts(event);
181
180
  return [
@@ -183,10 +182,9 @@ export function formatControlNoticeMessage(event: ControlEvent, childIntercomTar
183
182
  `Run: ${runTarget}${event.index !== undefined ? ` step ${event.index + 1}` : ""}`,
184
183
  `Signal: ${event.message}`,
185
184
  facts ? `Facts: ${facts}` : undefined,
186
- "Hint: Inspect status, then nudge if the work seems stuck.",
187
- childIntercomTarget
188
- ? `Nudge: ${nudgeCommand}`
189
- : "Nudge: no child message route registered",
185
+ "Hint: Inspect status, then nudge if the work seems stuck. Live async nudges interrupt the child before sending the follow-up.",
186
+ `Nudge: ${nudgeCommand}`,
187
+ childIntercomTarget ? `Direct intercom target: ${childIntercomTarget}` : undefined,
190
188
  `Status: subagent({ action: "status", id: "${runTarget}" })`,
191
189
  `Interrupt: subagent({ action: "interrupt", id: "${runTarget}" })`,
192
190
  ].filter((line): line is string => Boolean(line)).join("\n");
@@ -197,10 +195,9 @@ export function formatControlNoticeMessage(event: ControlEvent, childIntercomTar
197
195
  `Run: ${runTarget}${event.index !== undefined ? ` step ${event.index + 1}` : ""}`,
198
196
  `Signal: ${event.message}`,
199
197
  event.recentFailureSummary ? `Recent failures: ${event.recentFailureSummary}` : undefined,
200
- "Hint: Inspect status first unless the run is clearly blocked.",
201
- childIntercomTarget
202
- ? `Nudge: ${nudgeCommand}`
203
- : "Nudge: no child message route registered",
198
+ "Hint: Inspect status first unless the run is clearly blocked. Live async nudges interrupt the child before sending the follow-up.",
199
+ `Nudge: ${nudgeCommand}`,
200
+ childIntercomTarget ? `Direct intercom target: ${childIntercomTarget}` : undefined,
204
201
  `Status: subagent({ action: "status", id: "${runTarget}" })`,
205
202
  `Interrupt: subagent({ action: "interrupt", id: "${runTarget}" })`,
206
203
  ].filter((line): line is string => Boolean(line)).join("\n");
@@ -135,14 +135,15 @@ function stripAssistantSubagentToolCallBlocks(message: unknown): unknown | undef
135
135
  }
136
136
 
137
137
  export function stripParentOnlySubagentMessages(messages: unknown[]): unknown[] {
138
+ const preserveCurrentFanoutToolHistory = process.env[SUBAGENT_FANOUT_CHILD_ENV] === "1";
138
139
  let changed = false;
139
140
  const filtered: unknown[] = [];
140
141
  for (const message of messages) {
141
- if (isParentOnlySubagentMessage(message) || isSubagentToolResultMessage(message)) {
142
+ if (isParentOnlySubagentMessage(message) || (!preserveCurrentFanoutToolHistory && isSubagentToolResultMessage(message))) {
142
143
  changed = true;
143
144
  continue;
144
145
  }
145
- const stripped = stripAssistantSubagentToolCallBlocks(message);
146
+ const stripped = preserveCurrentFanoutToolHistory ? message : stripAssistantSubagentToolCallBlocks(message);
146
147
  if (stripped === undefined) {
147
148
  changed = true;
148
149
  continue;
@@ -5,7 +5,7 @@ export interface WorkflowGraphBuildInput {
5
5
  runId: string;
6
6
  mode?: SubagentRunMode;
7
7
  steps: ChainStep[];
8
- results?: Array<Pick<SingleResult, "exitCode" | "detached" | "interrupted" | "timedOut" | "error" | "acceptance">>;
8
+ results?: Array<Pick<SingleResult, "exitCode" | "detached" | "interrupted" | "error" | "acceptance">>;
9
9
  currentFlatIndex?: number;
10
10
  currentStepIndex?: number;
11
11
  stepStatuses?: Array<{ status?: string; error?: string }>;
@@ -26,8 +26,6 @@ function normalizeStatus(status: string | undefined): WorkflowNodeStatus | undef
26
26
  return "paused";
27
27
  case "detached":
28
28
  return "detached";
29
- case "timed-out":
30
- return "timed-out";
31
29
  case "pending":
32
30
  return "pending";
33
31
  default:
@@ -35,10 +33,9 @@ function normalizeStatus(status: string | undefined): WorkflowNodeStatus | undef
35
33
  }
36
34
  }
37
35
 
38
- function resultStatus(result: Pick<SingleResult, "exitCode" | "detached" | "interrupted" | "timedOut"> | undefined): WorkflowNodeStatus | undefined {
36
+ function resultStatus(result: Pick<SingleResult, "exitCode" | "detached" | "interrupted"> | undefined): WorkflowNodeStatus | undefined {
39
37
  if (!result) return undefined;
40
38
  if (result.detached) return "detached";
41
- if (result.timedOut) return "timed-out";
42
39
  if (result.interrupted) return "paused";
43
40
  return result.exitCode === 0 ? "completed" : "failed";
44
41
  }
@@ -66,7 +63,6 @@ function seqLabel(step: SequentialStep, stepIndex: number): string {
66
63
  function summarizeParallelStatuses(statuses: WorkflowNodeStatus[]): WorkflowNodeStatus {
67
64
  if (statuses.some((status) => status === "running")) return "running";
68
65
  if (statuses.some((status) => status === "failed")) return "failed";
69
- if (statuses.some((status) => status === "timed-out")) return "timed-out";
70
66
  if (statuses.some((status) => status === "paused")) return "paused";
71
67
  if (statuses.some((status) => status === "detached")) return "detached";
72
68
  if (statuses.length > 0 && statuses.every((status) => status === "completed")) return "completed";
@@ -1,16 +1,73 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
 
4
- export function writeAtomicJson(filePath: string, payload: object): void {
5
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
6
- const tempPath = path.join(
7
- path.dirname(filePath),
8
- `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`,
9
- );
10
- try {
11
- fs.writeFileSync(tempPath, JSON.stringify(payload, null, 2), "utf-8");
12
- fs.renameSync(tempPath, filePath);
13
- } finally {
14
- fs.rmSync(tempPath, { force: true });
4
+ type AtomicJsonFs = Pick<typeof fs, "mkdirSync" | "writeFileSync" | "renameSync" | "rmSync">;
5
+
6
+ type AtomicJsonWriterOptions = {
7
+ fs?: AtomicJsonFs;
8
+ now?: () => number;
9
+ pid?: number;
10
+ random?: () => number;
11
+ retryRenameErrors?: boolean;
12
+ retryDelaysMs?: readonly number[];
13
+ wait?: (delayMs: number) => void;
14
+ };
15
+
16
+ const DEFAULT_RENAME_RETRY_DELAYS_MS = [10, 25, 50, 100, 200] as const;
17
+ const RETRYABLE_RENAME_ERROR_CODES = new Set(["EACCES", "EBUSY", "EPERM"]);
18
+
19
+ function waitSync(delayMs: number): void {
20
+ const end = Date.now() + delayMs;
21
+ while (Date.now() < end) {
22
+ // writeAtomicJson is synchronous because callers often update status from sync callbacks.
15
23
  }
16
24
  }
25
+
26
+ function isRetryableRenameError(error: unknown): boolean {
27
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
28
+ return typeof code === "string" && RETRYABLE_RENAME_ERROR_CODES.has(code);
29
+ }
30
+
31
+ function renameWithRetry(
32
+ fsImpl: AtomicJsonFs,
33
+ sourcePath: string,
34
+ targetPath: string,
35
+ retryDelaysMs: readonly number[],
36
+ wait: (delayMs: number) => void,
37
+ ): void {
38
+ for (let attempt = 0; ; attempt++) {
39
+ try {
40
+ fsImpl.renameSync(sourcePath, targetPath);
41
+ return;
42
+ } catch (error) {
43
+ const delayMs = retryDelaysMs[attempt];
44
+ if (delayMs === undefined || !isRetryableRenameError(error)) throw error;
45
+ wait(delayMs);
46
+ }
47
+ }
48
+ }
49
+
50
+ export function createAtomicJsonWriter(options: AtomicJsonWriterOptions = {}): (filePath: string, payload: object) => void {
51
+ const fsImpl = options.fs ?? fs;
52
+ const now = options.now ?? Date.now;
53
+ const pid = options.pid ?? process.pid;
54
+ const random = options.random ?? Math.random;
55
+ const retryRenameErrors = options.retryRenameErrors ?? process.platform === "win32";
56
+ const retryDelaysMs = retryRenameErrors ? options.retryDelaysMs ?? DEFAULT_RENAME_RETRY_DELAYS_MS : [];
57
+ const wait = options.wait ?? waitSync;
58
+ return (filePath: string, payload: object): void => {
59
+ fsImpl.mkdirSync(path.dirname(filePath), { recursive: true });
60
+ const tempPath = path.join(
61
+ path.dirname(filePath),
62
+ `.${path.basename(filePath)}.${pid}.${now()}.${random().toString(36).slice(2)}.tmp`,
63
+ );
64
+ try {
65
+ fsImpl.writeFileSync(tempPath, JSON.stringify(payload, null, 2), "utf-8");
66
+ renameWithRetry(fsImpl, tempPath, filePath, retryDelaysMs, wait);
67
+ } finally {
68
+ fsImpl.rmSync(tempPath, { force: true });
69
+ }
70
+ };
71
+ }
72
+
73
+ export const writeAtomicJson = createAtomicJsonWriter();
@@ -103,6 +103,7 @@ export interface DynamicParallelStep {
103
103
  failFast?: boolean;
104
104
  phase?: string;
105
105
  label?: string;
106
+ acceptance?: AcceptanceInput;
106
107
  }
107
108
 
108
109
  /** Parallel step: multiple agents running concurrently */
@@ -30,7 +30,7 @@ export interface ChainOutputMapEntry {
30
30
 
31
31
  export type ChainOutputMap = Record<string, ChainOutputMapEntry>;
32
32
 
33
- export type WorkflowNodeStatus = "pending" | "running" | "completed" | "failed" | "paused" | "detached" | "timed-out";
33
+ export type WorkflowNodeStatus = "pending" | "running" | "completed" | "failed" | "paused" | "detached";
34
34
 
35
35
  export interface WorkflowGraphNode {
36
36
  id: string;
@@ -142,7 +142,7 @@ export interface ControlEvent {
142
142
  recentFailureSummary?: string;
143
143
  }
144
144
 
145
- export type SubagentResultStatus = "completed" | "failed" | "paused" | "detached" | "timed-out";
145
+ export type SubagentResultStatus = "completed" | "failed" | "paused" | "detached";
146
146
  export type SubagentRunMode = "single" | "parallel" | "chain";
147
147
 
148
148
  export type PublicNestedStepSummary = Pick<
@@ -239,7 +239,7 @@ export interface ModelAttempt {
239
239
  usage?: Usage;
240
240
  }
241
241
 
242
- export type AcceptanceProvenanceLevel = "none" | "attested" | "checked" | "verified" | "reviewed";
242
+ export type AcceptanceLevel = "auto" | "none" | "attested" | "checked" | "verified" | "reviewed";
243
243
 
244
244
  export type AcceptanceEvidenceKind =
245
245
  | "changed-files"
@@ -275,15 +275,16 @@ export interface AcceptanceReviewGate {
275
275
  }
276
276
 
277
277
  export interface AcceptanceConfig {
278
+ level?: AcceptanceLevel;
278
279
  criteria?: Array<string | AcceptanceGate>;
279
280
  evidence?: AcceptanceEvidenceKind[];
280
281
  verify?: AcceptanceVerifyCommand[];
281
- review?: AcceptanceReviewGate;
282
+ review?: AcceptanceReviewGate | false;
282
283
  stopRules?: string[];
283
- maxFinalizationTurns?: number;
284
+ reason?: string;
284
285
  }
285
286
 
286
- export type AcceptanceInput = AcceptanceConfig;
287
+ export type AcceptanceInput = AcceptanceLevel | false | AcceptanceConfig;
287
288
 
288
289
  export interface ResolvedAcceptanceGate extends AcceptanceGate {
289
290
  id: string;
@@ -293,18 +294,15 @@ export interface ResolvedAcceptanceGate extends AcceptanceGate {
293
294
  }
294
295
 
295
296
  export interface ResolvedAcceptanceConfig {
296
- level: AcceptanceProvenanceLevel;
297
+ level: Exclude<AcceptanceLevel, "auto">;
297
298
  explicit: boolean;
298
299
  inferredReason: string[];
299
300
  criteria: ResolvedAcceptanceGate[];
300
301
  evidence: AcceptanceEvidenceKind[];
301
302
  verify: AcceptanceVerifyCommand[];
302
- review?: AcceptanceReviewGate;
303
+ review?: AcceptanceReviewGate | false;
303
304
  stopRules: string[];
304
- finalization: {
305
- mode: "none" | "self-review-loop";
306
- maxTurns: number;
307
- };
305
+ reason?: string;
308
306
  }
309
307
 
310
308
  export interface AcceptanceReport {
@@ -368,25 +366,6 @@ export type AcceptanceLedgerStatus =
368
366
  | "accepted"
369
367
  | "rejected";
370
368
 
371
- export interface AcceptanceFinalizationTurn {
372
- turn: number;
373
- prompt: string;
374
- status: AcceptanceLedgerStatus;
375
- rawOutput?: string;
376
- report?: AcceptanceReport;
377
- parseError?: string;
378
- runtimeChecks: AcceptanceRuntimeCheck[];
379
- verifyRuns: AcceptanceVerifyResult[];
380
- failureMessage?: string;
381
- }
382
-
383
- export interface AcceptanceFinalizationLedger {
384
- mode: "self-review-loop";
385
- status: "not-run" | "completed" | "failed";
386
- maxTurns: number;
387
- turns: AcceptanceFinalizationTurn[];
388
- }
389
-
390
369
  export interface AcceptanceLedger {
391
370
  status: AcceptanceLedgerStatus;
392
371
  explicit: boolean;
@@ -395,12 +374,9 @@ export interface AcceptanceLedger {
395
374
  criteria: ResolvedAcceptanceGate[];
396
375
  childReport?: AcceptanceReport;
397
376
  childReportParseError?: string;
398
- initialChildReport?: AcceptanceReport;
399
- initialChildReportParseError?: string;
400
377
  runtimeChecks: AcceptanceRuntimeCheck[];
401
378
  verifyRuns: AcceptanceVerifyResult[];
402
379
  reviewResult?: AcceptanceReviewResult;
403
- finalization?: AcceptanceFinalizationLedger;
404
380
  parentDecision?: {
405
381
  status: "accepted" | "rejected";
406
382
  at: string;
@@ -408,13 +384,6 @@ export interface AcceptanceLedger {
408
384
  };
409
385
  }
410
386
 
411
- export interface ResourceLimitExceeded {
412
- kind: "maxExecutionTimeMs" | "maxTokens";
413
- limit: number;
414
- observed?: number;
415
- message: string;
416
- }
417
-
418
387
  export interface SingleResult {
419
388
  agent: string;
420
389
  task: string;
@@ -422,8 +391,6 @@ export interface SingleResult {
422
391
  detached?: boolean;
423
392
  detachedReason?: string;
424
393
  interrupted?: boolean;
425
- timedOut?: boolean;
426
- resourceLimitExceeded?: ResourceLimitExceeded;
427
394
  messages?: Message[];
428
395
  usage: Usage;
429
396
  model?: string;
@@ -612,6 +579,7 @@ export interface AsyncStatus {
612
579
  cwd?: string;
613
580
  currentStep?: number;
614
581
  chainStepCount?: number;
582
+ pendingAppends?: number;
615
583
  parallelGroups?: AsyncParallelGroupStatus[];
616
584
  workflowGraph?: WorkflowGraphSnapshot;
617
585
  steps?: Array<{
@@ -648,7 +616,6 @@ export interface AsyncStatus {
648
616
  structuredOutputPath?: string;
649
617
  structuredOutputSchemaPath?: string;
650
618
  acceptance?: AcceptanceLedger;
651
- resourceLimitExceeded?: ResourceLimitExceeded;
652
619
  }>;
653
620
  sessionDir?: string;
654
621
  outputFile?: string;
@@ -790,8 +757,6 @@ export interface RunSyncOptions {
790
757
  cwd?: string;
791
758
  signal?: AbortSignal;
792
759
  interruptSignal?: AbortSignal;
793
- timeoutMs?: number;
794
- timeoutAt?: number;
795
760
  allowIntercomDetach?: boolean;
796
761
  intercomEvents?: IntercomEventBus;
797
762
  onUpdate?: (r: import("@earendil-works/pi-agent-core").AgentToolResult<Details>) => void;
@@ -810,8 +775,6 @@ export interface RunSyncOptions {
810
775
  outputPath?: string;
811
776
  outputMode?: OutputMode;
812
777
  maxSubagentDepth?: number;
813
- maxExecutionTimeMs?: number;
814
- maxTokens?: number;
815
778
  nestedRoute?: NestedRouteInfo;
816
779
  /** Override the agent's default model (format: "provider/id" or just "id") */
817
780
  modelOverride?: string;
@@ -853,6 +816,13 @@ interface ExtensionChainConfig {
853
816
  };
854
817
  }
855
818
 
819
+ export interface ProactiveSkillSubagentsConfig {
820
+ enabled?: boolean;
821
+ minReferences?: number;
822
+ maxRecommendations?: number;
823
+ preferredAgent?: string;
824
+ }
825
+
856
826
  export interface ExtensionConfig {
857
827
  asyncByDefault?: boolean;
858
828
  forceTopLevelAsync?: boolean;
@@ -864,6 +834,7 @@ export interface ExtensionConfig {
864
834
  worktreeSetupHook?: string;
865
835
  worktreeSetupHookTimeoutMs?: number;
866
836
  intercomBridge?: IntercomBridgeConfig;
837
+ proactiveSkillSubagents?: ProactiveSkillSubagentsConfig | false;
867
838
  }
868
839
 
869
840
  // ============================================================================
@@ -954,7 +925,7 @@ export const SLASH_SUBAGENT_CANCEL_EVENT = "subagent:slash:cancel";
954
925
  export const POLL_INTERVAL_MS = 250;
955
926
  export const MAX_WIDGET_JOBS = 4;
956
927
  export const DEFAULT_SUBAGENT_MAX_DEPTH = 2;
957
- export const SUBAGENT_ACTIONS = ["list", "get", "create", "update", "delete", "status", "interrupt", "resume", "doctor"] as const;
928
+ export const SUBAGENT_ACTIONS = ["list", "get", "create", "update", "delete", "status", "interrupt", "resume", "append-step", "doctor"] as const;
958
929
 
959
930
  export const DEFAULT_FORK_PREAMBLE =
960
931
  "You are a delegated subagent running from a fork of the parent session. " +
@@ -192,7 +192,8 @@ export function getFinalOutput(messages: Message[]): string {
192
192
  const hasAssistantError = ("errorMessage" in msg && typeof msg.errorMessage === "string" && msg.errorMessage.length > 0)
193
193
  || ("stopReason" in msg && msg.stopReason === "error");
194
194
  if (hasAssistantError) continue;
195
- for (const part of msg.content) {
195
+ for (let j = msg.content.length - 1; j >= 0; j--) {
196
+ const part = msg.content[j];
196
197
  if (part.type === "text" && part.text.trim().length > 0) return part.text;
197
198
  }
198
199
  }
@@ -204,13 +205,6 @@ export function getSingleResultOutput(result: Pick<SingleResult, "finalOutput" |
204
205
  return result.finalOutput ?? getFinalOutput(result.messages ?? []);
205
206
  }
206
207
 
207
- export function formatResourceLimitExceeded(input: { agent: string; kind: "maxExecutionTimeMs" | "maxTokens"; limit: number; observed?: number }): string {
208
- if (input.kind === "maxExecutionTimeMs") {
209
- return `Resource limit exceeded for ${input.agent}: maxExecutionTimeMs ${input.limit}ms.`;
210
- }
211
- return `Resource limit exceeded for ${input.agent}: maxTokens ${input.limit}${input.observed !== undefined ? ` (observed ${input.observed})` : ""}.`;
212
- }
213
-
214
208
  /**
215
209
  * Extract display items (text and tool calls) from messages
216
210
  */