pi-better-subagents 0.4.1 → 0.5.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.
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Pure incident model shared by the parent's log scanner (failures.ts) and the child's
3
+ * `failure_disposition` tool (child-incidents.ts). Both fold the same structured tool
4
+ * records in the same order, so a disposition the child accepts is re-validated
5
+ * identically by the parent before it is journaled (#315).
6
+ *
7
+ * Nothing here reads prose. Operation identity is either an explicit caller-declared
8
+ * `operationId` or the exact tool/arguments/cwd hash; expected exits require a code
9
+ * declared on the attempt before it ran and a structured exit code from the tool.
10
+ */
11
+ import { failureIdentity, findIncident, validateDisposition, INCIDENT_DISPOSITIONS, REJECTED_INTENT_CATEGORY,
12
+ type FailureEvent, type FailureState, type IncidentDisposition } from "./shared-failure-observations.ts";
13
+
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
+ }
51
+
52
+ function stable(value: unknown): unknown {
53
+ if (Array.isArray(value)) return value.map(stable);
54
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => [k, stable(v)]));
55
+ return value;
56
+ }
57
+ /** Exact identity: tool name, full arguments, and working directory. */
58
+ export function toolOperation(name: string, args: unknown, cwd: string): string {
59
+ return failureIdentity("tool", name, stable(args ?? {}), cwd);
60
+ }
61
+ /** Declared identity when an operationId is present, otherwise the exact rule. attemptId never affects identity. */
62
+ export function attemptOperation(name: string, args: unknown, cwd: string, intent: CommandIntent): string {
63
+ if (intent.operationId) return failureIdentity("operation", name, intent.operationId, cwd);
64
+ if (args && typeof args === "object" && !Array.isArray(args) && "attemptId" in args) {
65
+ const { attemptId: _attemptId, ...rest } = args as Record<string, unknown>;
66
+ return toolOperation(name, rest, cwd);
67
+ }
68
+ return toolOperation(name, args, cwd);
69
+ }
70
+
71
+ export function evidenceText(value: unknown): string | undefined {
72
+ if (value == null) return undefined;
73
+ const raw = typeof value === "string" ? value : JSON.stringify(value);
74
+ return raw?.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 300);
75
+ }
76
+ 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
+ }
80
+ return undefined;
81
+ }
82
+ function time(row: any): number | undefined {
83
+ return typeof row.at === "number" && Number.isFinite(row.at) ? row.at :
84
+ typeof row.message?.timestamp === "number" && Number.isFinite(row.message.timestamp) ? row.message.timestamp : undefined;
85
+ }
86
+
87
+ interface Attempt {
88
+ toolCallId: string;
89
+ toolName: string;
90
+ operation: string;
91
+ intent: CommandIntent;
92
+ startSequence: number;
93
+ /** The confined intent bash refused this attempt before running anything. */
94
+ rejectedIntent?: string;
95
+ }
96
+ interface Finished extends Attempt {
97
+ ok: boolean;
98
+ endSequence: number;
99
+ evidence: string;
100
+ incident?: string;
101
+ }
102
+ export interface IncidentModel {
103
+ /** Honour operationId / attemptId / expectedExitCodes / dispositions (confined runtime only). */
104
+ structuredIntent: boolean;
105
+ sequence: number;
106
+ open: Map<string, Attempt>;
107
+ finished: Map<string, Finished>;
108
+ /** attemptId → toolCallId of the accepted declaration. */
109
+ attemptIds: Map<string, string>;
110
+ /** Every attemptId any bash start has named, accepted or rejected. */
111
+ namedAttempts: Set<string>;
112
+ /** operationId → operation identity. */
113
+ operationIds: Map<string, string>;
114
+ /** Current unresolved incident per operation, for exact retry recovery. */
115
+ failed: Map<string, { id: string; sequence: number }>;
116
+ /** Incident → last failure sequence, for ordering evidence. */
117
+ incidentSequence: Map<string, number>;
118
+ /** Disposition requests resolved at their start position (parent) or execute time (child). */
119
+ pendingDispositions: Map<string, { event?: FailureEvent; error?: string }>;
120
+ }
121
+ /**
122
+ * `structuredIntent` is true only for runs whose tools are the trusted task runtime's
123
+ * (intent bash + failure_disposition). Anywhere else the intent fields and disposition
124
+ * requests are ignored and the exact-retry identity rule applies.
125
+ */
126
+ export function newIncidentModel(structuredIntent = false): IncidentModel {
127
+ return { structuredIntent, sequence: 0, open: new Map(), finished: new Map(), attemptIds: new Map(), namedAttempts: new Set(), operationIds: new Map(),
128
+ failed: new Map(), incidentSequence: new Map(), pendingDispositions: new Map() };
129
+ }
130
+ export interface IncidentSink {
131
+ /** Reduce/journal ordinary observations; returns the resulting state. */
132
+ observe(events: FailureEvent[]): FailureState;
133
+ /** Journal an already validated disposition. */
134
+ dispose(event: FailureEvent): void;
135
+ /** Current state for validation. */
136
+ state(): FailureState;
137
+ }
138
+
139
+ export interface DispositionRequest {
140
+ disposition: IncidentDisposition;
141
+ targets: string[];
142
+ reason: string;
143
+ evidence?: string;
144
+ }
145
+ export function readDispositionRequest(args: unknown): { request?: DispositionRequest; error?: string } {
146
+ const input = (args && typeof args === "object" ? args : {}) as Record<string, unknown>;
147
+ const disposition = input.disposition;
148
+ if (typeof disposition !== "string" || !INCIDENT_DISPOSITIONS.includes(disposition as IncidentDisposition)) {
149
+ return { error: `disposition must be one of ${INCIDENT_DISPOSITIONS.join(", ")}` };
150
+ }
151
+ const targets = input.targets;
152
+ if (!Array.isArray(targets) || targets.length === 0 || targets.length > 20 || !targets.every((x) => typeof x === "string" && x.length > 0 && x.length <= 200)) {
153
+ return { error: "targets must name 1-20 incidents, attempt ids, or operation ids" };
154
+ }
155
+ if (typeof input.reason !== "string" || !input.reason.trim()) return { error: "reason is required" };
156
+ if (input.evidence !== undefined && (typeof input.evidence !== "string" || !input.evidence.trim())) return { error: "evidence must be an attempt id" };
157
+ return { request: { disposition: disposition as IncidentDisposition, targets: [...targets] as string[], reason: input.reason.trim(),
158
+ ...(typeof input.evidence === "string" ? { evidence: input.evidence.trim() } : {}) } };
159
+ }
160
+
161
+ function attemptRef(model: IncidentModel, ref: string): Finished | undefined {
162
+ const toolCallId = model.attemptIds.get(ref) ?? ref;
163
+ return model.finished.get(toolCallId);
164
+ }
165
+ function attemptLabel(attempt: Finished): string {
166
+ return attempt.intent.attemptId ? `attempt ${attempt.intent.attemptId}` : `tool call ${attempt.toolCallId}`;
167
+ }
168
+
169
+ /**
170
+ * Resolve a disposition request against the model and reduced state. Targets may be incident ids,
171
+ * failed attempt ids / tool call ids, or declared operation ids with a current incident. Recovery
172
+ * and supersession need evidence naming a successful attempt that started after the failures;
173
+ * recovery additionally needs the same operation. Everything else fails closed.
174
+ */
175
+ export function resolveDisposition(model: IncidentModel, state: FailureState, request: DispositionRequest, requestId: string): { event?: FailureEvent; error?: string } {
176
+ const incidents: string[] = [];
177
+ for (const target of request.targets) {
178
+ let id: string | undefined;
179
+ if (findIncident(state, target) || Object.hasOwn(state.history ?? {}, target)) id = target;
180
+ else {
181
+ const attempt = attemptRef(model, target);
182
+ if (attempt) {
183
+ if (!attempt.incident) return { error: `${attemptLabel(attempt)} did not fail; it has no incident` };
184
+ id = attempt.incident;
185
+ } else if (model.operationIds.has(target)) {
186
+ const current = state.observations[failureIdentity(model.operationIds.get(target)!)];
187
+ if (!current) return { error: `Operation ${target} has no incident` };
188
+ id = current.id;
189
+ }
190
+ }
191
+ if (!id) return { error: `Unknown incident, attempt, or operation "${target}"` };
192
+ if (!incidents.includes(id)) incidents.push(id);
193
+ }
194
+ let evidence: string | undefined;
195
+ if (request.disposition === "recovered" || request.disposition === "superseded") {
196
+ if (!request.evidence) return { error: `A ${request.disposition} disposition requires evidence: the attempt id (or tool call id) of a successful later attempt` };
197
+ const proof = attemptRef(model, request.evidence);
198
+ if (!proof) return { error: `Unknown evidence attempt "${request.evidence}"` };
199
+ if (!proof.ok) return { error: `Evidence ${attemptLabel(proof)} did not succeed` };
200
+ for (const id of incidents) {
201
+ const failedAt = model.incidentSequence.get(id);
202
+ if (failedAt === undefined || proof.startSequence <= failedAt) {
203
+ return { error: `Evidence ${attemptLabel(proof)} did not start after incident ${id} failed` };
204
+ }
205
+ if (request.disposition === "recovered") {
206
+ const incident = findIncident(state, id);
207
+ if (!incident || incident.operation !== proof.operation) {
208
+ return { error: `Evidence ${attemptLabel(proof)} is a different operation; use superseded for a different verification` };
209
+ }
210
+ }
211
+ }
212
+ evidence = `${attemptLabel(proof)} succeeded · ${proof.evidence}`;
213
+ } else if (request.evidence) {
214
+ const proof = attemptRef(model, request.evidence);
215
+ evidence = proof ? `${attemptLabel(proof)} · ${proof.evidence}` : request.evidence.slice(0, 200);
216
+ }
217
+ const event: FailureEvent = { id: `disposition:${requestId}`, operation: "incident-disposition", kind: "disposition",
218
+ disposition: request.disposition, incidents, reason: request.reason, ...(evidence ? { evidence } : {}) };
219
+ const error = validateDisposition(state, event);
220
+ return error ? { error } : { event };
221
+ }
222
+
223
+ /** Fold one tool execution start. Disposition requests are resolved here, at their start position. */
224
+ export function foldToolStart(model: IncidentModel, row: any, cwd: string, sink: IncidentSink): void {
225
+ const toolCallId = String(row.toolCallId);
226
+ const toolName = String(row.toolName ?? "unknown");
227
+ const seq = ++model.sequence;
228
+ if (!model.structuredIntent) {
229
+ // 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 });
231
+ return;
232
+ }
233
+ const parsed = toolName === "bash" ? readCommandIntent(row.args) : { intent: {} as CommandIntent };
234
+ const intent = parsed.intent;
235
+ // Mirrors the child's check: any earlier bash start that named this attemptId, valid or not.
236
+ const rawAttemptId = toolName === "bash" && typeof row.args?.attemptId === "string" ? row.args.attemptId as string : undefined;
237
+ const reused = rawAttemptId !== undefined && model.namedAttempts.has(rawAttemptId);
238
+ 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);
246
+ if (toolName === DISPOSITION_TOOL) {
247
+ const parsed = readDispositionRequest(row.args);
248
+ model.pendingDispositions.set(toolCallId, parsed.request
249
+ ? resolveDisposition(model, sink.state(), parsed.request, toolCallId)
250
+ : { error: parsed.error });
251
+ }
252
+ }
253
+
254
+ /** Fold one tool execution end: failures, declared expected exits, exact retry recovery, dispositions. */
255
+ export function foldToolEnd(model: IncidentModel, row: any, cwd: string, evidence: string, sink: IncidentSink): void {
256
+ const toolCallId = String(row.toolCallId);
257
+ const toolName = String(row.toolName ?? "unknown");
258
+ const seq = ++model.sequence;
259
+ const attempt = model.open.get(toolCallId);
260
+ if (attempt) model.open.delete(toolCallId);
261
+ const intent = attempt?.intent ?? {};
262
+ const operation = attempt?.operation ?? toolOperation(toolName, row.args, cwd);
263
+ const details = row.result?.details;
264
+ const declaredExit = row.isError === false && details?.expectedExit === true && typeof details.exitCode === "number" &&
265
+ (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);
267
+ const finished: Finished = { ...(attempt ?? { toolCallId, toolName, operation, intent, startSequence: seq }),
268
+ ok: !error && declaredExit === undefined, endSequence: seq, evidence };
269
+ model.finished.set(toolCallId, finished);
270
+ if (attempt?.rejectedIntent && (row.isError === true || error)) {
271
+ // Nothing ran: visible, but never grouped with (or escalating) the operation it named.
272
+ const failureId = `tool:${toolCallId}`;
273
+ 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})` }]);
275
+ finished.incident = failureId;
276
+ return;
277
+ }
278
+ if (toolName === DISPOSITION_TOOL && model.structuredIntent) {
279
+ const pending = model.pendingDispositions.get(toolCallId);
280
+ model.pendingDispositions.delete(toolCallId);
281
+ if (!error && pending?.event && !validateDisposition(sink.state(), pending.event)) sink.dispose(pending.event);
282
+ return;
283
+ }
284
+ if (declaredExit !== undefined || error) {
285
+ const failureId = `tool:${toolCallId}`;
286
+ const event: FailureEvent = declaredExit !== undefined
287
+ ? { id: failureId, operation, kind: "failure", at: time(row), category: "tool", expected: true, evidence,
288
+ summary: `${toolName} exited with declared expected code ${declaredExit}` }
289
+ : { id: failureId, operation, kind: "failure", at: time(row), category: "tool", evidence,
290
+ summary: `${toolName} failed: ${error!.slice(0, 180)}`, expected: row.expected === true || row.result?.expected === true };
291
+ const state = sink.observe([event]);
292
+ const incident = state.observations[failureIdentity(operation)]?.id ?? failureId;
293
+ finished.incident = incident;
294
+ model.incidentSequence.set(incident, seq);
295
+ model.failed.set(operation, { id: incident, sequence: seq });
296
+ return;
297
+ }
298
+ if (attempt) {
299
+ const failed = model.failed.get(operation);
300
+ if (failed && attempt.startSequence > failed.sequence) {
301
+ sink.observe([{ id: `recovered:${toolCallId}`, operation, kind: "recovered", at: time(row), incidents: [failed.id] }]);
302
+ model.failed.delete(operation);
303
+ }
304
+ }
305
+ }
306
+
307
+ /** Open incidents a child can name, for actionable rejection messages. */
308
+ export function describeOpenTargets(model: IncidentModel, state: FailureState, limit = 10): string[] {
309
+ const rows: string[] = [];
310
+ for (const attempt of model.finished.values()) {
311
+ if (!attempt.incident) continue;
312
+ const incident = findIncident(state, attempt.incident);
313
+ if (!incident || incident.status !== "unresolved" || incident.category === "observation-incomplete") continue;
314
+ const names = [incident.id, attempt.intent.attemptId, attempt.intent.operationId].filter(Boolean).join(" / ");
315
+ const row = `${names} · ${incident.summary.slice(0, 120)}`;
316
+ if (!rows.some((x) => x.startsWith(incident.id))) rows.push(row);
317
+ if (rows.length >= limit) break;
318
+ }
319
+ return rows;
320
+ }
package/index.ts CHANGED
@@ -95,7 +95,8 @@ import {
95
95
  } from "./capacity.mjs";
96
96
  import { buildHealthCallbackDelivery } from "./completion.ts";
97
97
  import { cancelCallbackBatch, getCallbackBatcher } from "./shared-callback-batcher.ts";
98
- import { collectRunFailures, failurePath, failureSummary, formatFailureSummary, markFailureAttentionDelivered, pendingFailureAttention, prependFailureSummary } from "./failures.ts";
98
+ import { completionCallbackFields, failureAttentionFields, healthCallbackFields } from "./callback-fields.ts";
99
+ import { collectRunFailures, failurePath, failureSummary, markFailureAttentionDelivered, pendingFailureAttention, prependFailureSummary } from "./failures.ts";
99
100
  import { failureAttentionHandled, observeFailures } from "./shared-failure-observations.ts";
100
101
  import {
101
102
  text,
@@ -262,6 +263,17 @@ function callbackOriginFromContext(ctx: ExtensionContext): RunCallbackOrigin {
262
263
  return { cwd: ctx.cwd, sessionId };
263
264
  }
264
265
 
266
+ /** Origin with a readable session id, or undefined when identity is unavailable. */
267
+ function verifiedOriginFromContext(ctx: ExtensionContext | undefined): RunCallbackOrigin | undefined {
268
+ if (!ctx?.sessionManager || typeof ctx.cwd !== "string") return undefined;
269
+ try {
270
+ const sessionId = ctx.sessionManager.getSessionId();
271
+ return sessionId ? { cwd: ctx.cwd, sessionId } : undefined;
272
+ } catch {
273
+ return undefined;
274
+ }
275
+ }
276
+
265
277
  function callbackSuppressionReason(meta: RunMeta, active: RunCallbackOrigin | undefined = activeCallbackOrigin): string | undefined {
266
278
  const origin = meta.callbackOrigin;
267
279
  if (origin) {
@@ -339,17 +351,11 @@ function enqueueCompletionCallback(pi: ExtensionAPI, id: string): void {
339
351
  || meta.completionCallbackPendingAt === undefined
340
352
  || meta.completionCallbackSentAt !== undefined
341
353
  || meta.completionCallbackSuppressedAt !== undefined) return;
342
- const label = meta.name ? `${meta.name} (${id})` : id;
343
- const observations = Object.values(collectRunFailures(id, meta.cwd, true).observations);
344
- const unresolved = observations.filter((observation) => observation.status === "unresolved");
345
- const observationStatus = unresolved.some((observation) => observation.category === "observation-incomplete")
346
- ? "observation incomplete" : unresolved.length ? "unresolved failure observations" : undefined;
354
+ const state = collectRunFailures(id, meta.cwd, true);
355
+ // Lifecycle, current actionability, and retained history are separate facts (#315).
356
+ const due = pendingFailureAttention(state, Date.now(), { terminal: true });
347
357
  getCallbackBatcher(pi).enqueue({
348
- source: "subagent",
349
- id,
350
- label,
351
- status: observationStatus ? `${meta.status}; ${observationStatus}` : meta.status,
352
- detailTool: "subagent_result",
358
+ ...completionCallbackFields(meta, state, due?.incidents ?? []),
353
359
  callback: true,
354
360
  isDelivered: () => {
355
361
  const current = readMeta(id);
@@ -386,13 +392,13 @@ function deliverFailureAttention(pi: ExtensionAPI | undefined, meta: RunMeta, no
386
392
  if (!pi || meta.callback === false || callbackSuppressionReason(meta)) return;
387
393
  if ((meta.status === "orphaned" || meta.status === "lost") && !isHealthCallbackHandled(meta, meta.status)) return;
388
394
  const state = collectRunFailures(meta.id, meta.cwd, meta.status !== "running" && meta.status !== "orphaned");
389
- const pending = pendingFailureAttention(state, now);
395
+ // Evidence gaps (oversized or malformed log records) are not something the parent can act on
396
+ // while the child runs; they ride the completion or health callback instead (#315).
397
+ const pending = pendingFailureAttention(state, now, { deferObservationGaps: true });
390
398
  if (!pending || (meta.status !== "running" && meta.status !== "orphaned" && meta.completionCallbackPendingAt !== undefined)) return;
391
- const label = meta.name ? `${meta.name} (${meta.id})` : meta.id;
399
+ // Only the pending incidents are rendered; earlier deliveries are counted, not repeated (#315).
392
400
  void getCallbackBatcher(pi).deliverUrgent({
393
- source: "subagent", id: meta.id, label, status: `failure:${pending.key}`,
394
- customType: "subagent-failure",
395
- content: `${formatFailureSummary(state)}\nInspect: subagent_output id=${JSON.stringify(meta.id)}`,
401
+ ...failureAttentionFields(meta, state, pending),
396
402
  isDelivered: () => failureAttentionHandled(collectRunFailures(meta.id, meta.cwd), pending.incidents),
397
403
  getSuppressionReason: () => {
398
404
  const current = readMeta(meta.id);
@@ -572,7 +578,6 @@ function deliverHealthCallback(pi: ExtensionAPI | undefined, meta: RunMeta, stat
572
578
  const callback = meta.callback !== false;
573
579
  const label = meta.name ? `${meta.name} (${meta.id})` : meta.id;
574
580
  const failureState = collectRunFailures(meta.id, meta.cwd, status === "lost");
575
- const failureText = formatFailureSummary(failureState);
576
581
  const attention = pendingFailureAttention(failureState, now, { terminal: status === "lost" });
577
582
  const delivery = buildHealthCallbackDelivery({ id: meta.id, label, status, callback });
578
583
  if (!delivery) {
@@ -584,12 +589,7 @@ function deliverHealthCallback(pi: ExtensionAPI | undefined, meta: RunMeta, stat
584
589
  return;
585
590
  }
586
591
  void getCallbackBatcher(pi).deliverUrgent({
587
- source: "subagent",
588
- id: meta.id,
589
- label,
590
- status,
591
- customType: "subagent-health",
592
- content: prependFailureSummary(delivery.content, failureText),
592
+ ...healthCallbackFields(meta, status, failureState, delivery.content, attention?.incidents ?? []),
593
593
  isDelivered: () => {
594
594
  const current = readMeta(meta.id);
595
595
  if (!current) throw new Error("Subagent metadata is unavailable; defer health notification");
@@ -1785,9 +1785,14 @@ export default function (pi: ExtensionAPI) {
1785
1785
  // the factories return, so tests invoke the same execute handlers the
1786
1786
  // model reaches (no drift-prone second copy). Stop's only UI side effect
1787
1787
  // (widget redraw after a kill) is injected as onStopped.
1788
- pi.registerTool(subagentListTool(Type));
1789
- pi.registerTool(subagentOutputTool(Type));
1790
- const acceptanceResultTool = subagentResultTool(Type);
1788
+ // The foreground origin from session_start; otherwise the calling context's
1789
+ // own session. An unreadable session id stays unavailable (never cwd-wide).
1790
+ const toolSession = {
1791
+ getActiveOrigin: (ctx?: unknown) => activeCallbackOrigin ?? verifiedOriginFromContext(ctx as ExtensionContext | undefined),
1792
+ };
1793
+ pi.registerTool(subagentListTool(Type, toolSession));
1794
+ pi.registerTool(subagentOutputTool(Type, toolSession));
1795
+ const acceptanceResultTool = subagentResultTool(Type, toolSession);
1791
1796
  pi.registerTool(acceptanceResultTool);
1792
1797
  publishAcceptanceHooks(acceptanceResultTool);
1793
1798
  const acceptanceStopTool = subagentStopTool(Type, { onStopped: renderWidget });
package/lifecycle.ts CHANGED
@@ -299,7 +299,8 @@ export interface FormatSubagentResultInput {
299
299
  status: string;
300
300
  exitCode: string | number;
301
301
  statSeg: string;
302
- toolsSeg: string;
302
+ /** @deprecated Ordinary tool-name sequences are omitted from result heads (#312). */
303
+ toolsSeg?: string;
303
304
  run: ParsedRun;
304
305
  rawLogTail: string;
305
306
  lifecycle: LifecycleValidation;
@@ -312,8 +313,9 @@ export interface FormatSubagentResultInput {
312
313
  * consistent with the registered tool path.
313
314
  */
314
315
  export function formatSubagentResult(input: FormatSubagentResultInput): string {
315
- const { id, status, exitCode, statSeg, toolsSeg, run, rawLogTail, lifecycle } = input;
316
- const head = `[${id} · ${status} · exit ${exitCode}${statSeg}${toolsSeg} · lifecycle ${lifecycle.classification}]`;
316
+ const { id, status, exitCode, statSeg, run, rawLogTail, lifecycle } = input;
317
+ // Ordinary ordered tool histories are omitted from the head (#312).
318
+ const head = `[${id} · ${status} · exit ${exitCode}${statSeg} · lifecycle ${lifecycle.classification}]`;
317
319
  if (lifecycle.incomplete) {
318
320
  return `${head}\n${formatIncompleteResult(run, rawLogTail, lifecycle)}`;
319
321
  }
@@ -325,9 +327,8 @@ export function formatSubagentResult(input: FormatSubagentResultInput): string {
325
327
  || (run.lastActivity
326
328
  ? `(no final answer parsed; latest activity)\n${run.lastActivity}`
327
329
  : `(no final answer parsed)\n\n--- raw log tail ---\n${rawLogTail}`);
328
- return [
329
- head,
330
- formatLifecycleDiagnostics(lifecycle),
331
- body,
332
- ].join("\n");
330
+ const exceptional = lifecycle.classification !== "complete" || lifecycle.incomplete;
331
+ return exceptional
332
+ ? [head, formatLifecycleDiagnostics(lifecycle), body].join("\n")
333
+ : [head, body].join("\n");
333
334
  }
package/list.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { fmtElapsed, fmtSpend } from "./widget.mjs";
1
+ import { fmtElapsed } from "./widget.mjs";
2
2
  import { formatListHealthSuffix } from "./health-surface.mjs";
3
3
 
4
- export const SUBAGENT_LIST_DEFAULT_LIMIT = 20;
4
+ export const SUBAGENT_LIST_DEFAULT_LIMIT = 10;
5
5
  export const SUBAGENT_LIST_MAX_LIMIT = 100;
6
6
  /** Effective + durable supervision statuses accepted by subagent_list filters. */
7
7
  export const SUBAGENT_LIST_STATUSES = [
@@ -65,60 +65,83 @@ export function normalizeSubagentListOptions(params = {}) {
65
65
  };
66
66
  }
67
67
 
68
+ function bounded(value, max) {
69
+ const single = String(value ?? "").replace(/\s+/g, " ").trim();
70
+ return single.length <= max ? single : `${single.slice(0, Math.max(0, max - 1))}…`;
71
+ }
72
+
73
+ /**
74
+ * One compact row. Free text (name, model, batch) is bounded so the id and
75
+ * status always sit within the row's first ~80 characters and survive clipping.
76
+ */
68
77
  export function formatSubagentListRow(meta, p) {
69
78
  const status = p.status;
70
79
  const now = p.now ?? Date.now();
71
- const usage = p.usage;
72
80
  const elapsed = fmtElapsed((meta.endedAt ?? now) - meta.startedAt);
73
- const spend = fmtSpend(usage);
74
- const name = meta.name ? `${meta.name} ` : "";
75
- const stat = `${elapsed}${spend ? ` · ${spend}` : ""}`;
81
+ const name = meta.name ? `${bounded(meta.name, 60)} ` : "";
76
82
  const health = formatListHealthSuffix(p.health);
77
- const failure = p.failure ? `\n ${p.failure.replace(/\n/g, "\n ")}` : "";
83
+ const failure = p.failure ? ` · ${bounded(p.failure, 60)}` : "";
78
84
  const batch = meta.batchId
79
- ? ` [batch: ${meta.batchName ? `${meta.batchName} ` : ""}${meta.batchId}]`
85
+ ? ` [batch: ${meta.batchName ? `${bounded(meta.batchName, 40)} ` : ""}${bounded(meta.batchId, 60)}]`
80
86
  : "";
81
- return `• ${name}${meta.id} [${status}] ${meta.model ?? "?"} ${stat}${health}${batch}${failure}\n ${promptPreview(meta)}`;
87
+ return `• ${name}${meta.id} [${status}] ${bounded(meta.model ?? "?", 60)} ${elapsed}${health}${batch}${failure}\n ${promptPreview(meta)}`;
82
88
  }
83
89
 
84
- export function buildSubagentList(p) {
90
+ export function collectSubagentList(p) {
85
91
  const options = normalizeSubagentListOptions(p.params ?? {});
86
92
  const now = p.now ?? Date.now();
87
93
  const parentPid = p.parentPid ?? process.pid;
88
94
  const statusOf = p.statusOf ?? ((meta) => meta.status);
89
- const usageById = p.usageById ?? (() => undefined);
90
95
  const healthById = p.healthById ?? (() => undefined);
91
96
  const failureById = p.failureById ?? (() => "");
97
+ const inScope = p.inScope ?? ((meta) => options.all || meta.spawnPid === parentPid);
98
+ const offset = Math.max(0, Math.floor(Number(p.offset) || 0));
92
99
 
93
100
  const scoped = (p.metas ?? [])
94
- .filter((meta) => options.all || meta.spawnPid === parentPid)
101
+ .filter((meta) => inScope(meta, options))
95
102
  .sort((a, b) => b.startedAt - a.startedAt)
96
103
  .map((meta) => ({ meta, status: statusOf(meta) }));
97
104
 
98
- const matching = options.statuses === null
105
+ const items = options.statuses === null
99
106
  ? scoped
100
107
  : scoped.filter((row) => options.statuses.has(row.status));
101
108
 
102
- const displayed = matching.slice(0, options.limit);
103
- const lines = [...options.warnings];
109
+ const displayed = items.slice(offset, offset + options.limit);
110
+ const rows = displayed.map((row) => formatSubagentListRow(row.meta, {
111
+ status: row.status,
112
+ now,
113
+ health: healthById(row.meta.id),
114
+ failure: failureById(row.meta.id),
115
+ }));
104
116
 
105
- if (matching.length === 0) {
117
+ return {
118
+ warnings: options.warnings,
119
+ items,
120
+ rows,
121
+ matching: items.length,
122
+ displayed: displayed.length,
123
+ offset,
124
+ limit: options.limit,
125
+ all: options.all,
126
+ empty: items.length === 0,
127
+ };
128
+ }
129
+
130
+ export function buildSubagentList(p) {
131
+ const collected = collectSubagentList(p);
132
+ const lines = [...collected.warnings];
133
+
134
+ if (collected.empty) {
106
135
  lines.push("No subagent runs match filters.");
107
136
  return lines.join("\n");
108
137
  }
109
138
 
110
- lines.push(...displayed.map((row) => formatSubagentListRow(row.meta, {
111
- status: row.status,
112
- now,
113
- usage: usageById(row.meta.id),
114
- health: healthById(row.meta.id),
115
- failure: failureById(row.meta.id),
116
- })));
139
+ lines.push(...collected.rows);
117
140
 
118
- if (matching.length > displayed.length) {
141
+ if (collected.matching > collected.displayed + collected.offset) {
119
142
  lines.push(
120
- `Showing ${displayed.length} of ${matching.length} matching subagent runs ` +
121
- `(limit ${options.limit}). Increase limit up to ${SUBAGENT_LIST_MAX_LIMIT} to see more.`,
143
+ `Showing ${collected.displayed} of ${collected.matching} matching subagent runs ` +
144
+ `(limit ${collected.limit}). Increase limit up to ${SUBAGENT_LIST_MAX_LIMIT} to see more.`,
122
145
  );
123
146
  }
124
147
 
package/list.ts CHANGED
@@ -4,6 +4,7 @@ export {
4
4
  SUBAGENT_LIST_STATUSES,
5
5
  normalizeSubagentListOptions,
6
6
  formatSubagentListRow,
7
+ collectSubagentList,
7
8
  buildSubagentList,
8
9
  } from "./list.mjs";
9
10