@wichayutdew/pi-workflows 2.7.0 → 3.0.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/README.md CHANGED
@@ -33,8 +33,7 @@ dictating your language, framework, or delivery process.
33
33
  - Keep worktree-bound iterations safe, including follow-up enhancements.
34
34
  - Enforce declared resources and stop unsafe loops before they run away.
35
35
  - Assign a workflow-owned role prompt with `agent: planner`, `worker`,
36
- `reviewer`, or `scout`; customize profiles under your user workflow
37
- directory's `agents/` folder.
36
+ `reviewer`, or `scout`; customize profiles under `~/.agents/agents`.
38
37
 
39
38
  ## Herdr workflow status
40
39
 
package/dist/index.js CHANGED
@@ -1505,17 +1505,19 @@ var directWorkerCommand = (request) => [
1505
1505
  "--print",
1506
1506
  request.task
1507
1507
  ];
1508
- function directWorkerResponse(request, code, signal, stderr) {
1508
+ function directWorkerResponse(request, code, signal, stderr, diagnostic) {
1509
1509
  const status = code === 0 ? "completed" : signal ? "cancelled" : "failed";
1510
1510
  return {
1511
1511
  requestId: request.requestId,
1512
1512
  agent: request.agent,
1513
1513
  status,
1514
1514
  ...code === null ? {} : { exitCode: code },
1515
- ...status !== "completed" && stderr.trim() ? { error: stderr.trim().slice(-4000) } : {}
1515
+ ...status !== "completed" && stderr.trim() ? { error: stderr.trim().slice(-4000) } : {},
1516
+ ...diagnostic ? { diagnostic } : {}
1516
1517
  };
1517
1518
  }
1518
1519
  var MAX_PROGRESS_DETAIL_CHARS = 480;
1520
+ var MAX_DIAGNOSTIC_CALLS = 64;
1519
1521
  var SECRET_KEY = /authorization|cookie|password|secret|token|api[-_]?key/i;
1520
1522
  function redactProgressValue(value, key = "") {
1521
1523
  if (SECRET_KEY.test(key))
@@ -1537,6 +1539,52 @@ function formatToolCall(toolName, args) {
1537
1539
  const rendered = JSON.stringify(redactProgressValue(args));
1538
1540
  return `call ${toolName} ${rendered}`.slice(0, MAX_PROGRESS_DETAIL_CHARS);
1539
1541
  }
1542
+ var createDiagnostic2 = () => ({
1543
+ settled: false,
1544
+ truncated: false,
1545
+ calls: new Map
1546
+ });
1547
+ var diagnosticSnapshot = (diagnostic) => ({
1548
+ settled: diagnostic.settled,
1549
+ truncated: diagnostic.truncated,
1550
+ calls: [...diagnostic.calls.values()]
1551
+ });
1552
+ var recordWorkerDiagnostic = (line, diagnostic) => {
1553
+ let event;
1554
+ try {
1555
+ const parsed = JSON.parse(line);
1556
+ if (typeof parsed !== "object" || parsed === null)
1557
+ return;
1558
+ event = parsed;
1559
+ } catch {
1560
+ return;
1561
+ }
1562
+ if (event.type === "agent_settled") {
1563
+ diagnostic.settled = true;
1564
+ return;
1565
+ }
1566
+ if (event.type !== "tool_execution_start" && event.type !== "tool_execution_end" || typeof event.toolName !== "string" || typeof event.toolCallId !== "string") {
1567
+ return;
1568
+ }
1569
+ if (!diagnostic.calls.has(event.toolCallId)) {
1570
+ if (diagnostic.calls.size >= MAX_DIAGNOSTIC_CALLS) {
1571
+ diagnostic.truncated = true;
1572
+ return;
1573
+ }
1574
+ diagnostic.calls.set(event.toolCallId, {
1575
+ id: event.toolCallId,
1576
+ name: event.toolName,
1577
+ state: "started"
1578
+ });
1579
+ }
1580
+ if (event.type === "tool_execution_end") {
1581
+ diagnostic.calls.set(event.toolCallId, {
1582
+ id: event.toolCallId,
1583
+ name: event.toolName,
1584
+ state: event.isError === false ? "completed" : "failed"
1585
+ });
1586
+ }
1587
+ };
1540
1588
  function workerProgressFromJsonLine(line, requestId, toolCount, responseText = "") {
1541
1589
  let event;
1542
1590
  try {
@@ -1609,6 +1657,7 @@ function createSubagentDelegationClient(spawnWorker = spawn) {
1609
1657
  let stdoutBuffer = "";
1610
1658
  let toolCount = 0;
1611
1659
  let responseText = "";
1660
+ const diagnostic = createDiagnostic2();
1612
1661
  const stdoutDecoder = new StringDecoder("utf8");
1613
1662
  const consumeWorkerLines = () => {
1614
1663
  while (true) {
@@ -1618,6 +1667,7 @@ function createSubagentDelegationClient(spawnWorker = spawn) {
1618
1667
  return;
1619
1668
  const line = stdoutBuffer.slice(0, newline);
1620
1669
  stdoutBuffer = stdoutBuffer.slice(newline + 1);
1670
+ recordWorkerDiagnostic(line, diagnostic);
1621
1671
  const progress = workerProgressFromJsonLine(line, request.requestId, toolCount, responseText);
1622
1672
  toolCount = progress.toolCount;
1623
1673
  responseText = progress.responseText;
@@ -1648,7 +1698,7 @@ function createSubagentDelegationClient(spawnWorker = spawn) {
1648
1698
  if (active?.process === child)
1649
1699
  active = undefined;
1650
1700
  options.signal?.removeEventListener("abort", abort);
1651
- resolve3(directWorkerResponse(request, code, signal, stderr));
1701
+ resolve3(directWorkerResponse(request, code, signal, stderr, diagnosticSnapshot(diagnostic)));
1652
1702
  });
1653
1703
  });
1654
1704
  };
@@ -5461,6 +5511,7 @@ function buildMainWorkflowNotice(workflow, run, statusShortcutLabel = "Ctrl+Alt+
5461
5511
  }
5462
5512
  // src/agents/profile.ts
5463
5513
  import { existsSync as existsSync2, readFileSync } from "node:fs";
5514
+ import { homedir as homedir3 } from "node:os";
5464
5515
  import { join as join5 } from "node:path";
5465
5516
  import { fileURLToPath } from "node:url";
5466
5517
  import { parse } from "yaml";
@@ -5509,8 +5560,9 @@ function parseAgentProfile(source, name) {
5509
5560
  ...typeof thinking === "string" ? { thinking } : {}
5510
5561
  };
5511
5562
  }
5512
- function loadAgentProfile(name, userDirectory = defaultUserWorkflowDirectory2()) {
5513
- const userPath = join5(userDirectory, "agents", `${name}.md`);
5563
+ var DEFAULT_AGENT_PROFILE_DIRECTORY = join5(homedir3(), ".agents", "agents");
5564
+ function loadAgentProfile(name, agentProfileDirectory = DEFAULT_AGENT_PROFILE_DIRECTORY) {
5565
+ const userPath = join5(agentProfileDirectory, `${name}.md`);
5514
5566
  const bundledUrl = new URL(`../../examples/starter-kit/agents/${name}.md`, import.meta.url);
5515
5567
  const path = existsSync2(userPath) ? userPath : fileURLToPath(bundledUrl);
5516
5568
  try {
@@ -6563,6 +6615,26 @@ function createStepExecutionActions() {
6563
6615
  };
6564
6616
  }
6565
6617
 
6618
+ // src/integrations/subagents/diagnostics.ts
6619
+ var READ_ONLY_TOOLS = new Set([
6620
+ "read",
6621
+ "ls",
6622
+ "grep",
6623
+ "structured_output"
6624
+ ]);
6625
+ var classifyRecoverySafety = (diagnostic) => {
6626
+ if (!diagnostic || !diagnostic.settled || diagnostic.truncated) {
6627
+ return "incomplete";
6628
+ }
6629
+ if (diagnostic.calls.some((call) => call.state !== "completed" || !READ_ONLY_TOOLS.has(call.name))) {
6630
+ return "unsafe";
6631
+ }
6632
+ return "read-only";
6633
+ };
6634
+
6635
+ // src/harness/delegation-recovery.ts
6636
+ var shouldRetryMissingCompletion = (diagnostic, subagentAttemptCount) => subagentAttemptCount === 1 && classifyRecoverySafety(diagnostic) === "read-only";
6637
+
6566
6638
  // src/harness/delegation-response-actions.ts
6567
6639
  function hasErrorCode(error, code) {
6568
6640
  return error instanceof Error && "code" in error && error.code === code;
@@ -6638,7 +6710,15 @@ async function finishDelegation(active, response) {
6638
6710
  serializedResult = await this.dependencies.readDelegatedResult(active);
6639
6711
  } catch (error) {
6640
6712
  if (hasErrorCode(error, "ENOENT")) {
6641
- throw new Error(`Subagent "${active.agent}" completed without producing the required correlated structured_output result`, { cause: error });
6713
+ const subagentAttemptCount = this.run.currentStepAttempts?.filter((attempt) => attempt.kind === "subagent").length ?? 0;
6714
+ if (shouldRetryMissingCompletion(response.diagnostic, subagentAttemptCount)) {
6715
+ cleanupAttempted = true;
6716
+ await this.cleanupDelegation(active);
6717
+ this.launchCurrentStep(workflow);
6718
+ return;
6719
+ }
6720
+ const diagnosticState = response.diagnostic ? `settled=${response.diagnostic.settled}, truncated=${response.diagnostic.truncated}, calls=${response.diagnostic.calls.length}` : "unavailable";
6721
+ throw new Error(`Subagent "${active.agent}" completed without producing the required correlated structured_output result (request ${active.requestId}; diagnostic ${diagnosticState})`, { cause: error });
6642
6722
  }
6643
6723
  throw error;
6644
6724
  }
@@ -7521,6 +7601,24 @@ var DEFAULT_CHILD_RUNTIME_DEPENDENCIES = {
7521
7601
  tokensAreEqual
7522
7602
  };
7523
7603
 
7604
+ // src/integrations/subagents/child-runtime-repair.ts
7605
+ var COMPLETION_REPAIR_PROMPT = [
7606
+ "The delegated step settled without its required correlated result.",
7607
+ "Do not repeat completed work and do not execute work tools.",
7608
+ "Call `structured_output` exactly once, alone, with one configured outcome and the required summary, artifact, and workspace fields."
7609
+ ].join(`
7610
+ `);
7611
+ var needsCompletionRepair = ({
7612
+ policy,
7613
+ dependencies
7614
+ }) => {
7615
+ try {
7616
+ return !dependencies.fileSystem.exists(policy.resultPath);
7617
+ } catch {
7618
+ return false;
7619
+ }
7620
+ };
7621
+
7524
7622
  // src/integrations/subagents/child-runtime-files.ts
7525
7623
  var verifyChildWorkingDirectory = (policy, dependencies) => {
7526
7624
  let expected;
@@ -7618,7 +7716,8 @@ var INITIAL_STATE = {
7618
7716
  activePolicy: undefined,
7619
7717
  policyError: undefined,
7620
7718
  invalidCompletionCalls: new Set,
7621
- effectiveTools: new Set
7719
+ effectiveTools: new Set,
7720
+ repairRequested: false
7622
7721
  };
7623
7722
  var errorMessage2 = (error) => error instanceof Error ? error.message : String(error);
7624
7723
  var invalidPolicyInput = (pi, policyError, images) => {
@@ -7684,7 +7783,8 @@ var registerSubagentChildRuntime = (pi, options = {}) => {
7684
7783
  ...state,
7685
7784
  activePolicy: extracted.policy,
7686
7785
  policyError: undefined,
7687
- effectiveTools
7786
+ effectiveTools,
7787
+ repairRequested: false
7688
7788
  };
7689
7789
  } catch (error) {
7690
7790
  const policyError = errorMessage2(error);
@@ -7717,6 +7817,19 @@ ${childSystemPrompt(state.activePolicy)}`
7717
7817
  pi.on("turn_start", () => {
7718
7818
  state = { ...state, invalidCompletionCalls: new Set };
7719
7819
  });
7820
+ pi.on("agent_settled", () => {
7821
+ const policy = state.activePolicy;
7822
+ if (!policy || state.repairRequested || !needsCompletionRepair({ policy, dependencies })) {
7823
+ return;
7824
+ }
7825
+ state = {
7826
+ ...state,
7827
+ repairRequested: true,
7828
+ effectiveTools: new Set([CHILD_COMPLETION_TOOL])
7829
+ };
7830
+ pi.setActiveTools([CHILD_COMPLETION_TOOL]);
7831
+ pi.sendUserMessage(COMPLETION_REPAIR_PROMPT, { deliverAs: "followUp" });
7832
+ });
7720
7833
  pi.on("message_end", (event) => {
7721
7834
  if (!state.activePolicy)
7722
7835
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wichayutdew/pi-workflows",
3
- "version": "2.7.0",
3
+ "version": "3.0.0",
4
4
  "description": "A declarative, pauseable workflow harness for Pi",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,8 +1,8 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
2
3
  import { join } from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import { parse } from 'yaml';
5
- import { defaultUserWorkflowDirectory } from '../config/load.ts';
6
6
 
7
7
  export const THINKING_LEVELS = [
8
8
  'off',
@@ -74,12 +74,14 @@ export function parseAgentProfile(source: string, name: string): AgentProfile {
74
74
  };
75
75
  }
76
76
 
77
+ const DEFAULT_AGENT_PROFILE_DIRECTORY = join(homedir(), '.agents', 'agents');
78
+
77
79
  /** Loads a user-owned agent profile, with a bundled prompt-only fallback. */
78
80
  export function loadAgentProfile(
79
81
  name: string,
80
- userDirectory = defaultUserWorkflowDirectory(),
82
+ agentProfileDirectory = DEFAULT_AGENT_PROFILE_DIRECTORY,
81
83
  ): AgentProfile {
82
- const userPath = join(userDirectory, 'agents', `${name}.md`);
84
+ const userPath = join(agentProfileDirectory, `${name}.md`);
83
85
  const bundledUrl = new URL(
84
86
  `../../examples/starter-kit/agents/${name}.md`,
85
87
  import.meta.url,
@@ -0,0 +1,15 @@
1
+ import {
2
+ classifyRecoverySafety,
3
+ type DelegationDiagnostic,
4
+ } from '../integrations/subagents/diagnostics.ts';
5
+
6
+ /**
7
+ * Allows one fresh retry only after the same-child repair settled with complete
8
+ * read-only evidence. The caller owns preserving run identity and cleanup.
9
+ */
10
+ export const shouldRetryMissingCompletion = (
11
+ diagnostic: DelegationDiagnostic | undefined,
12
+ subagentAttemptCount: number,
13
+ ): boolean =>
14
+ subagentAttemptCount === 1 &&
15
+ classifyRecoverySafety(diagnostic) === 'read-only';
@@ -9,6 +9,7 @@ import type { WorkflowStepResult } from '../runtime/step-result.ts';
9
9
  import type { HarnessActionContext as FullHarnessActionContext } from './action-context.ts';
10
10
  import type { ActiveDelegation } from './types.ts';
11
11
  import { resolveStepEffects } from './step-effects.ts';
12
+ import { shouldRetryMissingCompletion } from './delegation-recovery.ts';
12
13
 
13
14
  type HarnessActionContext = Pick<
14
15
  FullHarnessActionContext,
@@ -19,6 +20,7 @@ type HarnessActionContext = Pick<
19
20
  | 'finishDelegation'
20
21
  | 'isSessionActive'
21
22
  | 'latestContext'
23
+ | 'launchCurrentStep'
22
24
  | 'mutationQueue'
23
25
  | 'pauseForDelegationFailure'
24
26
  | 'releaseMainAfterCancellation'
@@ -174,8 +176,26 @@ async function finishDelegation(
174
176
  serializedResult = await this.dependencies.readDelegatedResult(active);
175
177
  } catch (error) {
176
178
  if (hasErrorCode(error, 'ENOENT')) {
179
+ const subagentAttemptCount =
180
+ this.run.currentStepAttempts?.filter(
181
+ (attempt) => attempt.kind === 'subagent',
182
+ ).length ?? 0;
183
+ if (
184
+ shouldRetryMissingCompletion(
185
+ response.diagnostic,
186
+ subagentAttemptCount,
187
+ )
188
+ ) {
189
+ cleanupAttempted = true;
190
+ await this.cleanupDelegation(active);
191
+ this.launchCurrentStep(workflow);
192
+ return;
193
+ }
194
+ const diagnosticState = response.diagnostic
195
+ ? `settled=${response.diagnostic.settled}, truncated=${response.diagnostic.truncated}, calls=${response.diagnostic.calls.length}`
196
+ : 'unavailable';
177
197
  throw new Error(
178
- `Subagent "${active.agent}" completed without producing the required correlated structured_output result`,
198
+ `Subagent "${active.agent}" completed without producing the required correlated structured_output result (request ${active.requestId}; diagnostic ${diagnosticState})`,
179
199
  { cause: error },
180
200
  );
181
201
  }
@@ -0,0 +1,23 @@
1
+ import type { ChildStepPolicy } from './child-policy-types.ts';
2
+ import type { SubagentChildRuntimeDependencies } from './child-runtime-types.ts';
3
+
4
+ export const COMPLETION_REPAIR_PROMPT = [
5
+ 'The delegated step settled without its required correlated result.',
6
+ 'Do not repeat completed work and do not execute work tools.',
7
+ 'Call `structured_output` exactly once, alone, with one configured outcome and the required summary, artifact, and workspace fields.',
8
+ ].join('\n');
9
+
10
+ /** Returns whether a same-child completion repair may be requested safely. */
11
+ export const needsCompletionRepair = ({
12
+ policy,
13
+ dependencies,
14
+ }: {
15
+ readonly policy: ChildStepPolicy;
16
+ readonly dependencies: SubagentChildRuntimeDependencies;
17
+ }): boolean => {
18
+ try {
19
+ return !dependencies.fileSystem.exists(policy.resultPath);
20
+ } catch {
21
+ return false;
22
+ }
23
+ };
@@ -13,6 +13,10 @@ import {
13
13
  parseChildStructuredResult,
14
14
  } from './child-runtime-completion.ts';
15
15
  import { DEFAULT_CHILD_RUNTIME_DEPENDENCIES } from './child-runtime-dependencies.ts';
16
+ import {
17
+ COMPLETION_REPAIR_PROMPT,
18
+ needsCompletionRepair,
19
+ } from './child-runtime-repair.ts';
16
20
  import {
17
21
  verifyChildCapability,
18
22
  verifyChildWorkingDirectory,
@@ -39,6 +43,7 @@ type ChildRuntimeState = {
39
43
  readonly policyError: string | undefined;
40
44
  readonly invalidCompletionCalls: ReadonlySet<string>;
41
45
  readonly effectiveTools: ReadonlySet<string>;
46
+ readonly repairRequested: boolean;
42
47
  };
43
48
 
44
49
  const INITIAL_STATE: ChildRuntimeState = {
@@ -46,6 +51,7 @@ const INITIAL_STATE: ChildRuntimeState = {
46
51
  policyError: undefined,
47
52
  invalidCompletionCalls: new Set(),
48
53
  effectiveTools: new Set(),
54
+ repairRequested: false,
49
55
  };
50
56
 
51
57
  const errorMessage = (error: unknown): string =>
@@ -142,6 +148,7 @@ export const registerSubagentChildRuntime = (
142
148
  activePolicy: extracted.policy,
143
149
  policyError: undefined,
144
150
  effectiveTools,
151
+ repairRequested: false,
145
152
  };
146
153
  } catch (error) {
147
154
  const policyError = errorMessage(error);
@@ -175,6 +182,24 @@ export const registerSubagentChildRuntime = (
175
182
  state = { ...state, invalidCompletionCalls: new Set() };
176
183
  });
177
184
 
185
+ pi.on('agent_settled', () => {
186
+ const policy = state.activePolicy;
187
+ if (
188
+ !policy ||
189
+ state.repairRequested ||
190
+ !needsCompletionRepair({ policy, dependencies })
191
+ ) {
192
+ return;
193
+ }
194
+ state = {
195
+ ...state,
196
+ repairRequested: true,
197
+ effectiveTools: new Set([CHILD_COMPLETION_TOOL]),
198
+ };
199
+ pi.setActiveTools([CHILD_COMPLETION_TOOL]);
200
+ pi.sendUserMessage(COMPLETION_REPAIR_PROMPT, { deliverAs: 'followUp' });
201
+ });
202
+
178
203
  pi.on('message_end', (event) => {
179
204
  if (!state.activePolicy) return;
180
205
  const invalid = invalidCompletionCallIds(
@@ -5,6 +5,10 @@ import type {
5
5
  SubagentDelegationResponse,
6
6
  SubagentDelegationUpdate,
7
7
  } from './protocol-events.ts';
8
+ import type {
9
+ DelegationDiagnostic,
10
+ DelegationDiagnosticCall,
11
+ } from './diagnostics.ts';
8
12
 
9
13
  export type DelegateOptions = {
10
14
  readonly signal?: AbortSignal;
@@ -52,6 +56,7 @@ export function directWorkerResponse(
52
56
  code: number | null,
53
57
  signal: NodeJS.Signals | null,
54
58
  stderr: string,
59
+ diagnostic?: DelegationDiagnostic,
55
60
  ): SubagentDelegationResponse {
56
61
  const status = code === 0 ? 'completed' : signal ? 'cancelled' : 'failed';
57
62
  return {
@@ -62,12 +67,15 @@ export function directWorkerResponse(
62
67
  ...(status !== 'completed' && stderr.trim()
63
68
  ? { error: stderr.trim().slice(-4_000) }
64
69
  : {}),
70
+ ...(diagnostic ? { diagnostic } : {}),
65
71
  };
66
72
  }
67
73
 
68
74
  type WorkerJsonEvent = {
69
75
  readonly type?: unknown;
76
+ readonly toolCallId?: unknown;
70
77
  readonly toolName?: unknown;
78
+ readonly isError?: unknown;
71
79
  readonly args?: unknown;
72
80
  readonly message?: { readonly role?: unknown };
73
81
  readonly assistantMessageEvent?: {
@@ -83,6 +91,7 @@ type WorkerProgress = {
83
91
  };
84
92
 
85
93
  const MAX_PROGRESS_DETAIL_CHARS = 480;
94
+ const MAX_DIAGNOSTIC_CALLS = 64;
86
95
  const SECRET_KEY = /authorization|cookie|password|secret|token|api[-_]?key/i;
87
96
 
88
97
  function redactProgressValue(value: unknown, key = ''): unknown {
@@ -110,6 +119,70 @@ function formatToolCall(toolName: string, args: unknown): string {
110
119
  return `call ${toolName} ${rendered}`.slice(0, MAX_PROGRESS_DETAIL_CHARS);
111
120
  }
112
121
 
122
+ type MutableDiagnostic = {
123
+ settled: boolean;
124
+ truncated: boolean;
125
+ calls: Map<string, DelegationDiagnosticCall>;
126
+ };
127
+
128
+ const createDiagnostic = (): MutableDiagnostic => ({
129
+ settled: false,
130
+ truncated: false,
131
+ calls: new Map(),
132
+ });
133
+
134
+ const diagnosticSnapshot = (
135
+ diagnostic: MutableDiagnostic,
136
+ ): DelegationDiagnostic => ({
137
+ settled: diagnostic.settled,
138
+ truncated: diagnostic.truncated,
139
+ calls: [...diagnostic.calls.values()],
140
+ });
141
+
142
+ const recordWorkerDiagnostic = (
143
+ line: string,
144
+ diagnostic: MutableDiagnostic,
145
+ ): void => {
146
+ let event: WorkerJsonEvent;
147
+ try {
148
+ const parsed: unknown = JSON.parse(line);
149
+ if (typeof parsed !== 'object' || parsed === null) return;
150
+ event = parsed;
151
+ } catch {
152
+ return;
153
+ }
154
+ if (event.type === 'agent_settled') {
155
+ diagnostic.settled = true;
156
+ return;
157
+ }
158
+ if (
159
+ (event.type !== 'tool_execution_start' &&
160
+ event.type !== 'tool_execution_end') ||
161
+ typeof event.toolName !== 'string' ||
162
+ typeof event.toolCallId !== 'string'
163
+ ) {
164
+ return;
165
+ }
166
+ if (!diagnostic.calls.has(event.toolCallId)) {
167
+ if (diagnostic.calls.size >= MAX_DIAGNOSTIC_CALLS) {
168
+ diagnostic.truncated = true;
169
+ return;
170
+ }
171
+ diagnostic.calls.set(event.toolCallId, {
172
+ id: event.toolCallId,
173
+ name: event.toolName,
174
+ state: 'started',
175
+ });
176
+ }
177
+ if (event.type === 'tool_execution_end') {
178
+ diagnostic.calls.set(event.toolCallId, {
179
+ id: event.toolCallId,
180
+ name: event.toolName,
181
+ state: event.isError === false ? 'completed' : 'failed',
182
+ });
183
+ }
184
+ };
185
+
113
186
  /** Converts one Pi JSONL event into safe, operator-visible worker progress. */
114
187
  export function workerProgressFromJsonLine(
115
188
  line: string,
@@ -211,6 +284,7 @@ export function createSubagentDelegationClient(
211
284
  let stdoutBuffer = '';
212
285
  let toolCount = 0;
213
286
  let responseText = '';
287
+ const diagnostic = createDiagnostic();
214
288
  const stdoutDecoder = new StringDecoder('utf8');
215
289
  const consumeWorkerLines = (): void => {
216
290
  while (true) {
@@ -218,6 +292,7 @@ export function createSubagentDelegationClient(
218
292
  if (newline === -1) return;
219
293
  const line = stdoutBuffer.slice(0, newline);
220
294
  stdoutBuffer = stdoutBuffer.slice(newline + 1);
295
+ recordWorkerDiagnostic(line, diagnostic);
221
296
  const progress = workerProgressFromJsonLine(
222
297
  line,
223
298
  request.requestId,
@@ -250,7 +325,15 @@ export function createSubagentDelegationClient(
250
325
  consumeWorkerLines();
251
326
  if (active?.process === child) active = undefined;
252
327
  options.signal?.removeEventListener('abort', abort);
253
- resolve(directWorkerResponse(request, code, signal, stderr));
328
+ resolve(
329
+ directWorkerResponse(
330
+ request,
331
+ code,
332
+ signal,
333
+ stderr,
334
+ diagnosticSnapshot(diagnostic),
335
+ ),
336
+ );
254
337
  });
255
338
  });
256
339
  };
@@ -0,0 +1,43 @@
1
+ export type DiagnosticCallState = 'completed' | 'failed' | 'started';
2
+
3
+ export type DelegationDiagnosticCall = {
4
+ readonly id: string;
5
+ readonly name: string;
6
+ readonly state: DiagnosticCallState;
7
+ };
8
+
9
+ export type DelegationDiagnostic = {
10
+ readonly settled: boolean;
11
+ readonly truncated: boolean;
12
+ readonly calls: ReadonlyArray<DelegationDiagnosticCall>;
13
+ };
14
+
15
+ export type RecoverySafety = 'read-only' | 'unsafe' | 'incomplete';
16
+
17
+ const READ_ONLY_TOOLS: ReadonlySet<string> = new Set([
18
+ 'read',
19
+ 'ls',
20
+ 'grep',
21
+ 'structured_output',
22
+ ]);
23
+
24
+ /**
25
+ * Decides whether a fresh child may safely repeat a step after same-child
26
+ * completion repair failed. Unknown, partial, and mutation-capable evidence
27
+ * always fails closed.
28
+ */
29
+ export const classifyRecoverySafety = (
30
+ diagnostic: DelegationDiagnostic | undefined,
31
+ ): RecoverySafety => {
32
+ if (!diagnostic || !diagnostic.settled || diagnostic.truncated) {
33
+ return 'incomplete';
34
+ }
35
+ if (
36
+ diagnostic.calls.some(
37
+ (call) => call.state !== 'completed' || !READ_ONLY_TOOLS.has(call.name),
38
+ )
39
+ ) {
40
+ return 'unsafe';
41
+ }
42
+ return 'read-only';
43
+ };
@@ -1,3 +1,5 @@
1
+ import type { DelegationDiagnostic } from './diagnostics.ts';
2
+
1
3
  export const SUBAGENT_DELEGATION_PROTOCOL_VERSION = 1 as const;
2
4
  export const SUBAGENT_DELEGATION_REQUEST_EVENT =
3
5
  'prompt-template:subagent:request';
@@ -41,4 +43,5 @@ export type SubagentDelegationResponse = {
41
43
  readonly error?: string;
42
44
  readonly exitCode?: number;
43
45
  readonly warnings?: ReadonlyArray<string>;
46
+ readonly diagnostic?: DelegationDiagnostic;
44
47
  };