pi-better-subagents 0.5.1 → 0.7.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 (45) hide show
  1. package/README.md +13 -1
  2. package/agent-commands.ts +26 -7
  3. package/agent-inspection.ts +4 -1
  4. package/batch.mjs +7 -0
  5. package/callback-fields.ts +4 -1
  6. package/catalog-ui.ts +167 -0
  7. package/child-incidents.ts +20 -6
  8. package/child-steer.ts +102 -0
  9. package/cleanup.ts +18 -2
  10. package/config.json +1 -0
  11. package/config.ts +5 -1
  12. package/delegation.ts +22 -0
  13. package/docs/agent-catalog-operations.md +2 -0
  14. package/docs/agent-catalog.md +9 -0
  15. package/docs/failure-observations.md +18 -5
  16. package/failures.ts +83 -8
  17. package/incident-model.ts +73 -55
  18. package/index.ts +352 -25
  19. package/list.mjs +4 -1
  20. package/output-payload.ts +152 -58
  21. package/package.json +4 -4
  22. package/parse.ts +6 -0
  23. package/permission-policy.ts +15 -7
  24. package/registry.ts +79 -1
  25. package/roles/role.architect.md +3 -3
  26. package/roles/role.developer.md +3 -3
  27. package/roles/role.explorer.md +3 -4
  28. package/roles/role.product-manager.md +3 -4
  29. package/roles/role.researcher.md +3 -4
  30. package/roles/role.reviewer.md +3 -3
  31. package/shared-callback-batcher.ts +34 -3
  32. package/shared-failure-observations.ts +414 -47
  33. package/shared-log-utils.ts +108 -0
  34. package/shared-navigator.ts +180 -134
  35. package/shared-sandbox-core.ts +724 -17
  36. package/shared-task-apply-patch.ts +409 -0
  37. package/shared-task-files.ts +69 -7
  38. package/shared-task-sandbox.ts +21 -5
  39. package/shared-task-tools.ts +111 -0
  40. package/spawn.ts +3 -1
  41. package/subagent-tools.ts +95 -0
  42. package/task-guard.ts +42 -7
  43. package/task-policy.ts +45 -5
  44. package/timing.ts +425 -0
  45. package/tools.ts +52 -14
@@ -14,7 +14,7 @@ Each run retains an append-only `failures.jsonl` journal. Nothing is deleted fro
14
14
  | `Observation incomplete` | Missing, corrupt, truncated, or unreadable evidence. |
15
15
  | `Recovered` / `Superseded` | Closed. Counted in history, never shown as active. |
16
16
 
17
- A completed run reports lifecycle and incidents as separate facts, for example:
17
+ A completed run reports lifecycle and incidents as separate facts. When a page is small, incident rows are dropped before notes, and the correctness note is kept whole whenever it fits. For example:
18
18
 
19
19
  ```text
20
20
  status=completed
@@ -22,11 +22,22 @@ status=completed
22
22
  Work correctness was not inferred from lifecycle alone.
23
23
  ```
24
24
 
25
+ ## What the parent sees by default
26
+
27
+ Only incidents that need action are listed: `Action required` and `Observation incomplete`. Unclassified and expected failures are counted as history, never listed or paged by default. A completed run with nothing to act on says so in one line, with no incident cursor:
28
+
29
+ ```text
30
+ No failures need action · 8 unclassified tool errors · 2 expected (history)
31
+ Work correctness was not inferred from lifecycle alone.
32
+ ```
33
+
34
+ Pass `history: true` to `subagent_result` or `subagent_output` to page every incident, including recovered and superseded ones. Rows are compact: a tool's result JSON is shown as its text, the excerpt is capped at 120 bytes, and evidence reads `output.log#byte=N`. `mode: "raw"` keeps the whole excerpt and the full log path.
35
+
25
36
  ## When the parent is notified
26
37
 
27
38
  - **While the child runs**, a single tool error does not wake the parent; the child owns it. The parent is woken, after a 60-second grace period, only for non-tool failures, for an operation that failed three times with no recovery, and for incidents the child marked `open`. Each incident is delivered once. Observation gaps (for example an oversized log record) stay visible on every surface and are delivered with the completion or health callback, since the parent cannot act on them mid-run.
28
39
  - **At completion**, every still-unresolved incident is reported once in the ordinary completion callback: actionable incidents in full, unclassified tool failures as a count. Incidents a running notification already delivered are counted, not repeated.
29
- - **Orphaned and lost** health callbacks carry the same facts once.
40
+ - **Orphaned and lost** health callbacks carry the same facts once. After an orphaned or lost run's health callback, observation gaps found later are delivered promptly, once, since no further callback may follow.
30
41
 
31
42
  Every notification renders only its pending incidents. Delivery receipts are independent of recovery and are written after handoff; `callback:false` suppresses notifications without hiding inspection evidence.
32
43
 
@@ -34,11 +45,11 @@ Every notification renders only its pending incidents. Delivery receipts are ind
34
45
 
35
46
  Automatic recovery still requires an exact retry: the same tool name, arguments, and working directory, started after the failure. Parallel or unrelated successes cannot clear an incident, and neither can exit 0 of another command or anything an assistant writes.
36
47
 
37
- Sandboxed children (the task runtime) get two structured additions. The parent honours them only for runs whose metadata it wrote as launched on the task runtime; for any other run, intent-looking arguments are ordinary arguments and `failure_disposition` calls are ignored.
48
+ Sandboxed children (the task runtime) get two structured additions. The parent honours them only for runs it launched on the task runtime: `meta.taskRuntime` and a parent-authored provenance record written before the child starts, outside the run directory, where the confined child cannot write. For any other run, intent-looking arguments are ordinary arguments and `failure_disposition` calls are ignored. If the metadata cannot be read for a moment, the parent waits and reads it again rather than falling back to the exact rule for the rest of the run. The wait is bounded: when the run ends, or after 30 seconds, the log is scanned under the exact-retry rule so real failures stay visible, with an `Observation incomplete` note that the metadata could not be read.
38
49
 
39
50
  - `bash` accepts optional `operationId`, `attemptId`, and `expectedExitCodes`, validated before the command runs.
40
51
  - `operationId` names one logical operation across modified retries. A later success with the same `operationId` (a changed scope, timeout, or flag) recovers the earlier failure automatically. Without it, the exact rule applies. `attemptId` names one execution for use as evidence and never changes operation identity.
41
- - `expectedExitCodes` (distinct integers 1–255) declares intentional non-zero exits, such as `[1]` for an `rg`/`grep` no-match or a `git diff --exit-code` probe. Only the final shell exit code is classified, taken from the tool's structured result, never from output text. Timeouts, aborts, and undeclared codes stay ordinary failures.
52
+ - `expectedExitCodes` (distinct integers 1–255; a `0` is ignored, since exit 0 is already success) declares intentional non-zero exits, such as `[1]` for an `rg`/`grep` no-match or a `git diff --exit-code` probe. Only the final shell exit code is classified, taken from the tool's structured result, never from output text. Timeouts, aborts, and undeclared codes stay ordinary failures.
42
53
  - `failure_disposition({ disposition, targets, reason, evidence? })` records an explicit classification of the child's own incidents:
43
54
  - `recovered`: the same operation later passed. Evidence must be a successful attempt of that operation that started after the failure.
44
55
  - `superseded`: a different verification or remediation established the outcome, such as resolving a merge conflict and continuing. Evidence must be a successful attempt that started after the failure.
@@ -47,9 +58,11 @@ Sandboxed children (the task runtime) get two structured additions. The parent h
47
58
 
48
59
  A `bash` call whose intent is rejected (malformed fields, or a reused `attemptId`) never runs. It is recorded as its own unclassified observation, not as a failure of the command it named, so it cannot make that operation look stuck.
49
60
 
61
+ Reuse is checked against the child's whole session record, every branch, which is also what the parent scans (the whole log). A reuse the parent sees is always one the child refused, so a real failed run is never filed as a rejected intent.
62
+
50
63
  Targets may be incident ids, the `attemptId` or `operationId` the child declared, or tool call ids. Unknown, already-disposed, evidence-free, or partially invalid requests are rejected whole, with the open incidents listed, and nothing is written. The parent replays the same validation from the child's log before it appends the disposition to the journal, so dispositions survive reload and replay exactly once.
51
64
 
52
- Unconfined children keep the exact-retry rule and do not yet have these additions.
65
+ Unconfined children keep the exact-retry rule, and this is intentional: an unconfined child can rewrite its own log and metadata, so the parent cannot trust intent or dispositions it reports. Background tasks, which the parent agent launches itself, accept `operation_id` and `expected_exit_codes` directly (see the background-tasks README).
53
66
 
54
67
  ## Evidence health
55
68
 
package/failures.ts CHANGED
@@ -1,19 +1,30 @@
1
1
  import { closeSync, fstatSync, openSync, readSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { logPathFor, readMeta, runDir } from "./registry.ts";
4
- import { activeFailures, disposeIncidents, failureIdentity, formatFailureSummary, formatTerminalFailureFacts, markFailureAttentionDelivered,
3
+ import { logPathFor, runDir, taskRuntimeTrust } from "./registry.ts";
4
+ import { activeFailures, actionableFailures, disposeIncidents, failureIdentity, findIncident, formatFailureSummary, formatTerminalFailureFacts, markFailureAttentionDelivered,
5
5
  observeFailures, pendingFailureAttention, readFailureState, type FailureState } from "./shared-failure-observations.ts";
6
6
  import { evidenceText, foldToolEnd, foldToolStart, newIncidentModel, toolOperation, type IncidentModel, type IncidentSink } from "./incident-model.ts";
7
7
 
8
8
  export { formatFailureSummary, pendingFailureAttention, markFailureAttentionDelivered, toolOperation };
9
9
  export const failurePath = (id: string) => join(runDir(id), "failures.jsonl");
10
10
  export const readRunFailures = (id: string): FailureState => readFailureState(failurePath(id));
11
- interface Scan { offset: number; head: string; identity: string; model: IncidentModel; retry?: number }
11
+ interface Scan {
12
+ offset: number; head: string; identity: string; model: IncidentModel; retry?: number;
13
+ /**
14
+ * A "run metadata could not be read" gap this trusted rescan inherited, with the tool incidents
15
+ * that were unresolved when it began (the earlier exact-rule scan's). Re-checked after every scan.
16
+ */
17
+ gap?: { id: string; before: Set<string> };
18
+ }
12
19
  const scans = new Map<string, Scan>();
20
+ /** When each run's trust first could not be read, for the bounded wait (#325). */
21
+ const trustUnknownSince = new Map<string, number>();
22
+ /** How long a running scan waits for unreadable trust before scanning under the exact-retry rule. */
23
+ export const TRUST_WAIT_MS = 30_000;
13
24
  /** Drop an in-memory scan cursor (e.g. on reload); the journal remains authoritative. */
14
25
  export function resetFailureScanCursor(id?: string): void {
15
- if (id === undefined) scans.clear();
16
- else scans.delete(id);
26
+ if (id === undefined) { scans.clear(); trustUnknownSince.clear(); }
27
+ else { scans.delete(id); trustUnknownSince.delete(id); }
17
28
  }
18
29
  const CHUNK = 64 * 1024;
19
30
  const MAX_LINE = 2 * 1024 * 1024;
@@ -70,6 +81,27 @@ function fold(id: string, scan: Scan, row: any, offset: number, cwd: string): vo
70
81
  ++model.sequence;
71
82
  }
72
83
  }
84
+ /**
85
+ * Close an inherited metadata gap unless an exact-rule leftover still needs it. A leftover counts only
86
+ * when its attempt declared intent (operationId, attemptId, or expectedExitCodes): its event id is
87
+ * already consumed under the exact rule, so this trusted scan cannot re-key it, and a declared retry
88
+ * or expected exit cannot resolve it. A plain failure reads the same under either rule and never
89
+ * holds the gap. Once the leftover is resolved (an exact retry or a disposition), the gap closes.
90
+ */
91
+ function settleMetadataGap(id: string, scan: Scan): void {
92
+ const gap = scan.gap!;
93
+ const state = readRunFailures(id);
94
+ const current = state.observations[failureIdentity("run-metadata")];
95
+ if (!current || current.id !== gap.id || current.status !== "unresolved") { scan.gap = undefined; return; }
96
+ const intentBearing = new Set<string>();
97
+ for (const attempt of scan.model.finished.values()) {
98
+ if (attempt.incident && gap.before.has(attempt.incident) && Object.keys(attempt.intent).length) intentBearing.add(attempt.incident);
99
+ }
100
+ const held = [...intentBearing].some((incident) => findIncident(state, incident)?.status === "unresolved");
101
+ if (held) return;
102
+ observeFailures(failurePath(id), [{ id: `run-metadata-readable:${gap.id}`, operation: "run-metadata", kind: "recovered", incidents: [gap.id] }]);
103
+ scan.gap = undefined;
104
+ }
73
105
  /** Scan complete source records, independent of the finite progress/transcript tail. */
74
106
  export function collectRunFailures(id: string, cwd: string, terminal = false): FailureState {
75
107
  const path = failurePath(id);
@@ -97,8 +129,41 @@ export function collectRunFailures(id: string, cwd: string, terminal = false): F
97
129
  scan = undefined;
98
130
  }
99
131
  // Structured intent and dispositions are honoured only for runs launched on the trusted task
100
- // runtime, whose bash and failure_disposition tools are the guarded ones (parent-written metadata).
101
- if (!scan) scan = { offset: 0, head, identity, model: newIncidentModel(readMeta(id)?.taskRuntime === true) };
132
+ // runtime, whose bash and failure_disposition tools are the guarded ones. Trust needs the
133
+ // parent-authored provenance record as well as `meta.taskRuntime` (#325). It is decided once
134
+ // per scan model, and only from a definite answer: while metadata or provenance cannot be read
135
+ // the scan waits (nothing is folded, so a brief read failure pins nothing to the exact rule).
136
+ // The wait is bounded: at a terminal read, or once trust has been unreadable for
137
+ // TRUST_WAIT_MS, the log is scanned under the exact-retry rule (untrusted) so real failures
138
+ // stay visible, and an observation gap says the metadata could not be read.
139
+ if (!scan) {
140
+ const trust = taskRuntimeTrust(id);
141
+ if (trust === "unknown") {
142
+ const now = Date.now();
143
+ const since = trustUnknownSince.get(id) ?? now;
144
+ trustUnknownSince.set(id, since);
145
+ while (trustUnknownSince.size > 256) trustUnknownSince.delete(trustUnknownSince.keys().next().value!);
146
+ const prior = readRunFailures(id);
147
+ const gap = prior.observations[failureIdentity("run-metadata")];
148
+ const waited = terminal || now - since >= TRUST_WAIT_MS;
149
+ if (waited && !(gap && gap.status === "unresolved")) {
150
+ observeFailures(path, [{ id: failureIdentity("run-metadata-unreadable", gap?.id ?? "initial"), operation: "run-metadata", kind: "incomplete",
151
+ summary: "Run metadata could not be read; tool failures are scanned under the exact-retry rule" }]);
152
+ }
153
+ if (!waited) return prior;
154
+ scan = { offset: 0, head, identity, model: newIncidentModel(false) };
155
+ } else {
156
+ trustUnknownSince.delete(id);
157
+ scan = { offset: 0, head, identity, model: newIncidentModel(trust === "trusted") };
158
+ // The gap is settled after this scan has folded the log (settleMetadataGap), and again after
159
+ // every later scan, once no leftover of the exact-rule scan that trust would have read
160
+ // differently is still unresolved (#332).
161
+ const prior = readRunFailures(id);
162
+ const deferred = prior.observations[failureIdentity("run-metadata")];
163
+ if (deferred && deferred.status === "unresolved") scan.gap = { id: deferred.id,
164
+ before: new Set(Object.values(prior.observations).filter((x) => x.category === "tool" && x.status === "unresolved").map((x) => x.id)) };
165
+ }
166
+ }
102
167
  scan.head = head;
103
168
  let position = scan.offset;
104
169
  let start = position;
@@ -145,6 +210,7 @@ export function collectRunFailures(id: string, cwd: string, terminal = false): F
145
210
  position += count;
146
211
  }
147
212
  scan.offset = start;
213
+ if (scan.gap) settleMetadataGap(id, scan);
148
214
  scans.delete(id);
149
215
  scans.set(id, scan);
150
216
  while (scans.size > 64) scans.delete(scans.keys().next().value!);
@@ -167,7 +233,16 @@ export function runFailureFacts(state: FailureState, terminal: boolean): string
167
233
  return formatTerminalFailureFacts(state, activeFailures(state).map((x) => x.id));
168
234
  }
169
235
  export function failureSummary(id: string, cwd: string, terminal = false): string {
170
- return runFailureFacts(collectRunFailures(id, cwd, terminal), terminal);
236
+ return failureView(id, cwd, terminal).text;
237
+ }
238
+ /**
239
+ * The failure facts plus whether anything needs action. A navigator row leads with failure text only
240
+ * when something does; a quiet "No failures need action · … (history)" line never displaces the
241
+ * row's model, tool, and spend columns (#332).
242
+ */
243
+ export function failureView(id: string, cwd: string, terminal = false): { text: string; actionable: boolean } {
244
+ const state = collectRunFailures(id, cwd, terminal);
245
+ return { text: runFailureFacts(state, terminal), actionable: actionableFailures(state).length > 0 };
171
246
  }
172
247
  export function prependFailureSummary(body: string, summary: string): string {
173
248
  if (!summary) return body;
package/incident-model.ts CHANGED
@@ -8,46 +8,12 @@
8
8
  * `operationId` or the exact tool/arguments/cwd hash; expected exits require a code
9
9
  * declared on the attempt before it ran and a structured exit code from the tool.
10
10
  */
11
- import { failureIdentity, findIncident, validateDisposition, INCIDENT_DISPOSITIONS, REJECTED_INTENT_CATEGORY,
12
- type FailureEvent, type FailureState, type IncidentDisposition } from "./shared-failure-observations.ts";
11
+ import { failureIdentity, findIncident, validateDisposition, INCIDENT_DISPOSITIONS, INTENT_ID_PATTERN, MAX_EXPECTED_EXIT_CODES,
12
+ REJECTED_INTENT_CATEGORY, readCommandIntent, withoutAbsentIntent, type CommandIntent, type FailureEvent, type FailureState, type IncidentDisposition } from "./shared-failure-observations.ts";
13
13
 
14
14
  export const DISPOSITION_TOOL = "failure_disposition";
15
- /** Caller-chosen identifiers: short, printable, no whitespace. */
16
- export const INTENT_ID_PATTERN = "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,63}$";
17
- const INTENT_ID = new RegExp(INTENT_ID_PATTERN);
18
- export const MAX_EXPECTED_EXIT_CODES = 16;
19
-
20
- export interface CommandIntent {
21
- /** Stable across caller-declared modified retries of one logical operation. */
22
- operationId?: string;
23
- /** Names one concrete execution; evidence identity, never an operation match. */
24
- attemptId?: string;
25
- /** Non-zero exit codes declared intentional before execution. */
26
- expectedExitCodes?: number[];
27
- }
28
-
29
- /** Validate structured intent fields. Absent fields are fine; malformed ones are an error. */
30
- export function readCommandIntent(args: unknown): { intent: CommandIntent; error?: string } {
31
- const input = (args && typeof args === "object" ? args : {}) as Record<string, unknown>;
32
- const intent: CommandIntent = {};
33
- for (const key of ["operationId", "attemptId"] as const) {
34
- const value = input[key];
35
- if (value === undefined) continue;
36
- if (typeof value !== "string" || !INTENT_ID.test(value)) {
37
- return { intent: {}, error: `${key} must match ${INTENT_ID_PATTERN}` };
38
- }
39
- intent[key] = value;
40
- }
41
- const codes = input.expectedExitCodes;
42
- if (codes !== undefined) {
43
- if (!Array.isArray(codes) || codes.length === 0 || codes.length > MAX_EXPECTED_EXIT_CODES ||
44
- !codes.every((code) => Number.isInteger(code) && code >= 1 && code <= 255) || new Set(codes).size !== codes.length) {
45
- return { intent: {}, error: `expectedExitCodes must be 1-${MAX_EXPECTED_EXIT_CODES} distinct integers from 1 to 255` };
46
- }
47
- intent.expectedExitCodes = [...codes] as number[];
48
- }
49
- return { intent };
50
- }
15
+ // The intent validator is shared with background tasks through the vendored failure-observations module (#325).
16
+ export { INTENT_ID_PATTERN, MAX_EXPECTED_EXIT_CODES, readCommandIntent, withoutAbsentIntent, type CommandIntent };
51
17
 
52
18
  function stable(value: unknown): unknown {
53
19
  if (Array.isArray(value)) return value.map(stable);
@@ -61,6 +27,8 @@ export function toolOperation(name: string, args: unknown, cwd: string): string
61
27
  /** Declared identity when an operationId is present, otherwise the exact rule. attemptId never affects identity. */
62
28
  export function attemptOperation(name: string, args: unknown, cwd: string, intent: CommandIntent): string {
63
29
  if (intent.operationId) return failureIdentity("operation", name, intent.operationId, cwd);
30
+ // An explicit-null intent field is an undeclared one: `{expectedExitCodes: null}` and `{}` are the same command.
31
+ args = withoutAbsentIntent(args);
64
32
  if (args && typeof args === "object" && !Array.isArray(args) && "attemptId" in args) {
65
33
  const { attemptId: _attemptId, ...rest } = args as Record<string, unknown>;
66
34
  return toolOperation(name, rest, cwd);
@@ -73,10 +41,12 @@ export function evidenceText(value: unknown): string | undefined {
73
41
  const raw = typeof value === "string" ? value : JSON.stringify(value);
74
42
  return raw?.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 300);
75
43
  }
44
+ /** The text a tool result carries (its first text part, stderr, or error), not its JSON wrapper. */
45
+ function resultText(result: any): string | undefined {
46
+ return evidenceText(result?.content?.find?.((x: any) => x?.type === "text")?.text ?? result?.stderr ?? result?.error ?? result);
47
+ }
76
48
  function resultError(result: any): string | undefined {
77
- if (result?.isError === true || (typeof result?.exitCode === "number" && result.exitCode !== 0)) {
78
- return evidenceText(result?.content?.find?.((x: any) => x?.type === "text")?.text ?? result?.stderr ?? result?.error ?? result);
79
- }
49
+ if (result?.isError === true || (typeof result?.exitCode === "number" && result.exitCode !== 0)) return resultText(result);
80
50
  return undefined;
81
51
  }
82
52
  function time(row: any): number | undefined {
@@ -90,8 +60,13 @@ interface Attempt {
90
60
  operation: string;
91
61
  intent: CommandIntent;
92
62
  startSequence: number;
93
- /** The confined intent bash refused this attempt before running anything. */
94
- rejectedIntent?: string;
63
+ /**
64
+ * Why the confined intent bash is expected to refuse this attempt. A prediction only: the
65
+ * attempt is filed as a rejected intent when its end row carries the child's own refusal.
66
+ */
67
+ predictedRejection?: string;
68
+ /** Intent names this start registered, undone if the child refused it after all. */
69
+ registered?: { attemptId?: string; operationId?: string };
95
70
  }
96
71
  interface Finished extends Attempt {
97
72
  ok: boolean;
@@ -227,7 +202,9 @@ export function foldToolStart(model: IncidentModel, row: any, cwd: string, sink:
227
202
  const seq = ++model.sequence;
228
203
  if (!model.structuredIntent) {
229
204
  // Unconfined: intent-looking fields are ordinary arguments; exact identity only.
230
- model.open.set(toolCallId, { toolCallId, toolName, operation: toolOperation(toolName, row.args, cwd), intent: {}, startSequence: seq });
205
+ // An explicit-null intent-named field is still normalized away, as on the confined path, so
206
+ // `{command, expectedExitCodes: null}` and `{command}` are one command (#332).
207
+ model.open.set(toolCallId, { toolCallId, toolName, operation: toolOperation(toolName, withoutAbsentIntent(row.args), cwd), intent: {}, startSequence: seq });
231
208
  return;
232
209
  }
233
210
  const parsed = toolName === "bash" ? readCommandIntent(row.args) : { intent: {} as CommandIntent };
@@ -236,13 +213,23 @@ export function foldToolStart(model: IncidentModel, row: any, cwd: string, sink:
236
213
  const rawAttemptId = toolName === "bash" && typeof row.args?.attemptId === "string" ? row.args.attemptId as string : undefined;
237
214
  const reused = rawAttemptId !== undefined && model.namedAttempts.has(rawAttemptId);
238
215
  if (rawAttemptId !== undefined) model.namedAttempts.add(rawAttemptId);
239
- const rejectedIntent = parsed.error ?? (reused ? `attemptId ${intent.attemptId} was already used` : undefined);
240
- const operation = attemptOperation(toolName, row.args, cwd, intent);
241
- model.open.set(toolCallId, { toolCallId, toolName, operation, intent: rejectedIntent ? {} : intent, startSequence: seq,
242
- ...(rejectedIntent ? { rejectedIntent } : {}) });
243
- if (rejectedIntent) return;
244
- if (intent.attemptId) model.attemptIds.set(intent.attemptId, toolCallId);
245
- if (intent.operationId && !model.operationIds.has(intent.operationId)) model.operationIds.set(intent.operationId, operation);
216
+ const predictedRejection = parsed.error ?? (reused ? `attemptId ${intent.attemptId} was already used` : undefined);
217
+ // A predicted refusal falls back to exact identity: if the child ran the command after all, it is
218
+ // an ordinary failure of exactly that command, never a failure of the operation it named.
219
+ const effective = predictedRejection ? {} : intent;
220
+ const operation = attemptOperation(toolName, row.args, cwd, effective);
221
+ const attempt: Attempt = { toolCallId, toolName, operation, intent: effective, startSequence: seq,
222
+ ...(predictedRejection ? { predictedRejection } : {}) };
223
+ model.open.set(toolCallId, attempt);
224
+ if (predictedRejection) return;
225
+ if (intent.attemptId) {
226
+ model.attemptIds.set(intent.attemptId, toolCallId);
227
+ attempt.registered = { attemptId: intent.attemptId };
228
+ }
229
+ if (intent.operationId && !model.operationIds.has(intent.operationId)) {
230
+ model.operationIds.set(intent.operationId, operation);
231
+ attempt.registered = { ...attempt.registered, operationId: intent.operationId };
232
+ }
246
233
  if (toolName === DISPOSITION_TOOL) {
247
234
  const parsed = readDispositionRequest(row.args);
248
235
  model.pendingDispositions.set(toolCallId, parsed.request
@@ -251,6 +238,33 @@ export function foldToolStart(model: IncidentModel, row: any, cwd: string, sink:
251
238
  }
252
239
  }
253
240
 
241
+ /** The confined intent bash's own pre-run refusal (child-incidents.ts), matched as the whole result text. */
242
+ const INTENT_REFUSAL = /^Invalid command intent: ([\s\S]+)\. The command was not run\.$/;
243
+ /**
244
+ * Pi's pre-execution schema rejection of the bash call (pi-ai `validateToolArguments`), matched as its
245
+ * whole shape: the header, one or more ` - path: message` lines, and the received arguments as
246
+ * pretty-printed JSON ending the text, so a command that ran (and ends "Command exited with code N")
247
+ * can never match. Nothing ran.
248
+ */
249
+ const SCHEMA_REFUSAL = /^Validation failed for tool "bash":\n(?: - [^\n]*\n)+\nReceived arguments:\n(?:\{\}|\{(?:\n {2,}[^\n]*)*\n\})$/;
250
+
251
+ /**
252
+ * Why the child refused this bash call before running it, read from the end row the child
253
+ * produced, never re-derived from the arguments. A command that ran and failed returns undefined.
254
+ */
255
+ function preRunRefusal(model: IncidentModel, attempt: Attempt | undefined, toolName: string, row: any): string | undefined {
256
+ if (!model.structuredIntent || toolName !== "bash" || row.isError !== true) return undefined;
257
+ const content = row.result?.content;
258
+ const text = Array.isArray(content) && content.length === 1 && typeof content[0]?.text === "string" ? content[0].text.trim() : undefined;
259
+ if (text === undefined) return undefined;
260
+ const refused = INTENT_REFUSAL.exec(text);
261
+ if (refused) return refused[1];
262
+ // Pi validates arguments against the intent schema before execute; that refusal is ours only
263
+ // when the intent fields themselves are what the shared validator rejects.
264
+ if (attempt?.predictedRejection && !attempt.predictedRejection.includes("already used") && SCHEMA_REFUSAL.test(text)) return attempt.predictedRejection;
265
+ return undefined;
266
+ }
267
+
254
268
  /** Fold one tool execution end: failures, declared expected exits, exact retry recovery, dispositions. */
255
269
  export function foldToolEnd(model: IncidentModel, row: any, cwd: string, evidence: string, sink: IncidentSink): void {
256
270
  const toolCallId = String(row.toolCallId);
@@ -259,19 +273,23 @@ export function foldToolEnd(model: IncidentModel, row: any, cwd: string, evidenc
259
273
  const attempt = model.open.get(toolCallId);
260
274
  if (attempt) model.open.delete(toolCallId);
261
275
  const intent = attempt?.intent ?? {};
262
- const operation = attempt?.operation ?? toolOperation(toolName, row.args, cwd);
276
+ const operation = attempt?.operation ?? toolOperation(toolName, withoutAbsentIntent(row.args), cwd);
263
277
  const details = row.result?.details;
264
278
  const declaredExit = row.isError === false && details?.expectedExit === true && typeof details.exitCode === "number" &&
265
279
  (intent.expectedExitCodes ?? []).includes(details.exitCode) ? details.exitCode as number : undefined;
266
- const error = row.isError === true ? evidenceText(row.result) ?? "Tool returned an error" : resultError(row.result);
280
+ const error = row.isError === true ? resultText(row.result) ?? "Tool returned an error" : resultError(row.result);
267
281
  const finished: Finished = { ...(attempt ?? { toolCallId, toolName, operation, intent, startSequence: seq }),
268
282
  ok: !error && declaredExit === undefined, endSequence: seq, evidence };
269
283
  model.finished.set(toolCallId, finished);
270
- if (attempt?.rejectedIntent && (row.isError === true || error)) {
284
+ const refusal = preRunRefusal(model, attempt, toolName, row);
285
+ if (refusal !== undefined) {
271
286
  // Nothing ran: visible, but never grouped with (or escalating) the operation it named.
287
+ if (attempt?.registered?.attemptId && model.attemptIds.get(attempt.registered.attemptId) === toolCallId) model.attemptIds.delete(attempt.registered.attemptId);
288
+ if (attempt?.registered?.operationId) model.operationIds.delete(attempt.registered.operationId);
289
+ finished.intent = {};
272
290
  const failureId = `tool:${toolCallId}`;
273
291
  sink.observe([{ id: failureId, operation: failureIdentity("rejected-intent", toolCallId), kind: "failure", at: time(row),
274
- category: REJECTED_INTENT_CATEGORY, evidence, summary: `${toolName} not run: invalid command intent (${attempt.rejectedIntent})` }]);
292
+ category: REJECTED_INTENT_CATEGORY, evidence, summary: `${toolName} not run: invalid command intent (${refusal})` }]);
275
293
  finished.incident = failureId;
276
294
  return;
277
295
  }