pi-better-background-tasks 0.3.0 → 0.4.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.
@@ -1,5 +1,5 @@
1
1
  // Generated from packages/failure-observations/index.ts. Do not edit directly.
2
- import { appendFileSync, closeSync, existsSync, openSync, readFileSync, mkdirSync } from "node:fs";
2
+ import { appendFileSync, closeSync, existsSync, openSync, readFileSync, mkdirSync, statSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { createHash } from "node:crypto";
5
5
 
@@ -85,6 +85,66 @@ export const REJECTED_INTENT_CATEGORY = "rejected-intent";
85
85
  /** The same unresolved agent tool operation failing this many times is treated as stuck. */
86
86
  export const REPEATED_FAILURE_THRESHOLD = 3;
87
87
 
88
+ /** Caller-chosen intent identifiers: short, printable, no whitespace. */
89
+ export const INTENT_ID_PATTERN = "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,63}$";
90
+ const INTENT_ID = new RegExp(INTENT_ID_PATTERN);
91
+ export const MAX_EXPECTED_EXIT_CODES = 16;
92
+ /**
93
+ * Structured command intent (#315), declared by the caller before a command runs. Shared by the
94
+ * subagent task runtime's bash and background tasks (#325) so both validate identically.
95
+ */
96
+ export interface CommandIntent {
97
+ /** Stable across caller-declared modified retries of one logical operation. */
98
+ operationId?: string;
99
+ /** Names one concrete execution; evidence identity, never an operation match. */
100
+ attemptId?: string;
101
+ /** Non-zero exit codes declared intentional before execution. */
102
+ expectedExitCodes?: number[];
103
+ }
104
+ export type CommandIntentField = keyof CommandIntent;
105
+ const INTENT_FIELDS: readonly CommandIntentField[] = ["operationId", "attemptId", "expectedExitCodes"];
106
+ /**
107
+ * The one normalization of intent-bearing arguments: an explicit `null` intent field means
108
+ * "not declared", exactly like an omitted one. Models routinely send optional fields as null, and
109
+ * Pi's argument validation (0.87+) drops optional nulls before a tool runs, while the process log
110
+ * and session record keep the raw arguments. Every reader of intent, the executing tool and the
111
+ * replay alike, goes through this so both sides see the same declaration from the same input.
112
+ */
113
+ export function withoutAbsentIntent<T>(args: T): T {
114
+ if (!args || typeof args !== "object" || Array.isArray(args)) return args;
115
+ const input = args as Record<string, unknown>;
116
+ if (!INTENT_FIELDS.some((key) => key in input && input[key] == null)) return args;
117
+ const out = { ...input };
118
+ for (const key of INTENT_FIELDS) if (key in out && out[key] == null) delete out[key];
119
+ return out as T;
120
+ }
121
+ /**
122
+ * Validate structured intent fields. Absent fields (omitted, undefined, or null) are fine; malformed
123
+ * ones are an error, and the caller must not run the command. `names` renames fields in the error
124
+ * (e.g. snake_case parameters).
125
+ */
126
+ export function readCommandIntent(args: unknown, names: Partial<Record<CommandIntentField, string>> = {}): { intent: CommandIntent; error?: string } {
127
+ const input = withoutAbsentIntent((args && typeof args === "object" ? args : {}) as Record<string, unknown>);
128
+ const intent: CommandIntent = {};
129
+ for (const key of ["operationId", "attemptId"] as const) {
130
+ const value = input[key];
131
+ if (value === undefined) continue;
132
+ if (typeof value !== "string" || !INTENT_ID.test(value)) {
133
+ return { intent: {}, error: `${names[key] ?? key} must match ${INTENT_ID_PATTERN}` };
134
+ }
135
+ intent[key] = value;
136
+ }
137
+ const codes = input.expectedExitCodes;
138
+ if (codes !== undefined) {
139
+ if (!Array.isArray(codes) || codes.length === 0 || codes.length > MAX_EXPECTED_EXIT_CODES ||
140
+ !codes.every((code) => Number.isInteger(code) && code >= 1 && code <= 255) || new Set(codes).size !== codes.length) {
141
+ return { intent: {}, error: `${names.expectedExitCodes ?? "expectedExitCodes"} must be 1-${MAX_EXPECTED_EXIT_CODES} distinct integers from 1 to 255` };
142
+ }
143
+ intent.expectedExitCodes = [...codes] as number[];
144
+ }
145
+ return { intent };
146
+ }
147
+
88
148
  /** The one current incident with this id, if it is still in the reduced state. */
89
149
  export function findIncident(state: FailureState, id: string): FailureObservation | undefined {
90
150
  return Object.values(state.observations).find((item) => item.id === id);
@@ -109,6 +169,25 @@ export function failureLabel(x: FailureObservation): string {
109
169
  return requiresAction(x) ? "Action required" : "Unclassified failure observation";
110
170
  }
111
171
 
172
+ /**
173
+ * Whether an incident needs someone's action now: an unresolved `Action required` incident
174
+ * (non-tool failure, repeated tool failure, or disposed `open`) or an observation gap. Unclassified
175
+ * and expected failures are history: counted on every surface, listed only on explicit request.
176
+ */
177
+ export function needsAction(x: FailureObservation): boolean {
178
+ return x.status === "unresolved" && (x.category === "observation-incomplete" || requiresAction(x));
179
+ }
180
+
181
+ /**
182
+ * Which incidents a surface lists. `actionable` (the default everywhere) is what needs action;
183
+ * `all` is the explicit history view: every incident ever reduced, actionable first, then unclassified,
184
+ * expected, and closed (recovered/superseded) incidents.
185
+ */
186
+ export type IncidentScope = "actionable" | "all";
187
+ /** `compact` rows (the default) unwrap tool-result JSON, cap the excerpt, and shorten evidence paths; `full` keeps them. */
188
+ export type IncidentDetail = "compact" | "full";
189
+ export interface IncidentRowOptions { scope?: IncidentScope; detail?: IncidentDetail }
190
+
112
191
  /** Validate a disposition against the reduced state. Returns the rejection reason, or undefined. */
113
192
  export function validateDisposition(state: FailureState, event: FailureEvent): string | undefined {
114
193
  if (event.kind !== "disposition") return "Not a disposition event";
@@ -195,6 +274,20 @@ export function activeFailures(state: FailureState): FailureObservation[] {
195
274
  return Object.values(state.observations).filter((x) => x.status !== "resolved" && x.status !== "superseded")
196
275
  .sort((a, b) => priority(a) - priority(b) || (b.lastSequence ?? 0) - (a.lastSequence ?? 0) || b.lastObservedAt - a.lastObservedAt);
197
276
  }
277
+ /** Incidents that need action now, priority order. */
278
+ export function actionableFailures(state: FailureState): FailureObservation[] {
279
+ return activeFailures(state).filter(needsAction);
280
+ }
281
+ function closedOrder(a: FailureObservation, b: FailureObservation): number {
282
+ return (b.lastSequence ?? 0) - (a.lastSequence ?? 0) || b.lastObservedAt - a.lastObservedAt;
283
+ }
284
+ /** The incidents a scope lists, in the order rows render. */
285
+ export function scopedFailures(state: FailureState, scope: IncidentScope = "actionable"): FailureObservation[] {
286
+ if (scope !== "all") return actionableFailures(state);
287
+ const active = activeFailures(state);
288
+ const closed = failureHistory(state).filter((x) => x.status === "resolved" || x.status === "superseded").sort(closedOrder);
289
+ return [...active, ...closed];
290
+ }
198
291
  /** Every incident ever reduced, current and closed. Nothing is removed. */
199
292
  export function failureHistory(state: FailureState): FailureObservation[] {
200
293
  return [...Object.values(state.history ?? {}), ...Object.values(state.observations)];
@@ -220,18 +313,120 @@ export function failureCounts(state: FailureState): FailureCounts {
220
313
  }
221
314
  return counts;
222
315
  }
316
+ function plural(count: number, one: string, many = `${one}s`): string {
317
+ return `${count} ${count === 1 ? one : many}`;
318
+ }
319
+ /**
320
+ * Counts of incidents that do not need action, as one short fragment, or undefined when there are
321
+ * none: e.g. `8 unclassified tool errors · 2 expected · 1 recovered`.
322
+ */
323
+ export function historyCountsText(state: FailureState): string | undefined {
324
+ const counts = failureCounts(state);
325
+ const parts = [
326
+ counts.unclassified ? plural(counts.unclassified, "unclassified tool error") : "",
327
+ counts.expected ? `${counts.expected} expected` : "",
328
+ counts.recovered ? `${counts.recovered} recovered` : "",
329
+ counts.superseded ? `${counts.superseded} superseded` : "",
330
+ ].filter(Boolean);
331
+ return parts.length ? parts.join(" · ") : undefined;
332
+ }
333
+ /** One line for a run where nothing needs action but history exists. No cursor: history is explicit. */
334
+ export function quietFailureLine(state: FailureState): string | undefined {
335
+ if (actionableFailures(state).length) return undefined;
336
+ const counts = failureCounts(state);
337
+ // Closed incidents alone are not worth a line on every surface; they ride along with current ones.
338
+ if (!counts.unclassified && !counts.expected) return undefined;
339
+ const history = historyCountsText(state);
340
+ return history ? `No failures need action · ${history} (history)` : undefined;
341
+ }
342
+ /** Trailing history count after actionable rows, or undefined when there is none. */
343
+ export function historyTailLine(state: FailureState): string | undefined {
344
+ const history = historyCountsText(state);
345
+ return history ? `Also in history: ${history}` : undefined;
346
+ }
347
+
223
348
  const INCIDENT_CURSOR_PREFIX = "i1.";
224
349
  const encoder = new TextEncoder();
225
350
 
226
- function failureRow(x: FailureObservation): string {
351
+ /** Longest summary or disposition-reason excerpt a compact row shows, in UTF-8 bytes. */
352
+ export const COMPACT_EXCERPT_BYTES = 120;
353
+ const TOOL_RESULT_WRAPPER = /\{"content":\[\{"type":"text","text":"/;
354
+ const JSON_ESCAPES: Record<string, string> = { '"': '"', "\\": "\\", "/": "/", b: " ", f: " ", n: " ", r: " ", t: " " };
355
+
356
+ /** Remove unpaired UTF-16 surrogates, which would otherwise encode as U+FFFD. */
357
+ function dropLoneSurrogates(value: string): string {
358
+ return value.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "");
359
+ }
360
+
361
+ /**
362
+ * A tool error recorded as its raw result JSON (`{"content":[{"type":"text","text":"…"}]}`, often
363
+ * already cut mid-string) rendered as the text it carries. Decoding is tolerant of the cut; text
364
+ * before the wrapper (e.g. `read failed: `) is kept.
365
+ */
366
+ export function unwrapToolResultText(value: string): string {
367
+ const match = TOOL_RESULT_WRAPPER.exec(value);
368
+ if (!match) return value;
369
+ let out = "";
370
+ let i = match.index + match[0].length;
371
+ while (i < value.length) {
372
+ const ch = value[i]!;
373
+ if (ch === '"') break;
374
+ if (ch !== "\\") { out += ch; i += 1; continue; }
375
+ const next = value[i + 1];
376
+ if (next === "u") {
377
+ const hex = value.slice(i + 2, i + 6);
378
+ if (!/^[0-9a-fA-F]{4}$/.test(hex)) break;
379
+ out += String.fromCharCode(parseInt(hex, 16));
380
+ i += 6;
381
+ continue;
382
+ }
383
+ if (next === undefined || !(next in JSON_ESCAPES)) break;
384
+ out += JSON_ESCAPES[next];
385
+ i += 2;
386
+ }
387
+ // A wrapper cut inside a surrogate pair, or an escape of one half, leaves a lone surrogate
388
+ // (rendered as U+FFFD); decoded control characters (e.g. an escaped NUL) are not shown either.
389
+ const decoded = dropLoneSurrogates(out).replace(/[\x00-\x1f\x7f]/g, " ");
390
+ return `${value.slice(0, match.index)}${decoded}`.replace(/\s+/g, " ").trim();
391
+ }
392
+
393
+ /** At most `max` UTF-8 bytes of `value`, whole code points, ending in `…` when cut. */
394
+ export function capUtf8(value: string, max: number): string {
395
+ const bytes = encoder.encode(value);
396
+ if (bytes.length <= max) return value;
397
+ const ellipsis = "…";
398
+ const cut = utf8Prefix(bytes, Math.max(0, max - utf8Length(ellipsis)));
399
+ return `${Buffer.from(bytes.subarray(0, cut)).toString("utf8").trimEnd()}${ellipsis}`;
400
+ }
401
+
402
+ /**
403
+ * Evidence reference as a compact row shows it: an absolute path loses its directories
404
+ * (`/private/var/…/runs/<id>/output.log#byte=N` → `output.log#byte=N`); the run or task id already
405
+ * names the log. Full rows and the journal keep the whole reference.
406
+ */
407
+ export function shortEvidence(evidence: string): string {
408
+ const match = /^(?:[A-Za-z]:)?[\\/](?:[^#]*[\\/])?([^\\/#]+)(#.*)?$/.exec(evidence);
409
+ return match ? `${match[1]}${match[2] ?? ""}` : evidence;
410
+ }
411
+
412
+ function failureRow(x: FailureObservation, detail: IncidentDetail = "compact"): string {
227
413
  const time = x.at === undefined ? `observed ${new Date(x.firstObservedAt).toISOString()}` : new Date(x.at).toISOString();
228
- const disposition = x.disposition ? ` · ${x.disposition.disposition}: ${text(x.disposition.reason, "")}` : "";
229
- return `${failureLabel(x)} · ${time} · ${x.summary}${x.count > 1 ? ` (${x.count} occurrences)` : ""}${disposition}${x.evidence ? ` · evidence: ${text(x.evidence, "")}` : ""}`;
414
+ const compact = detail !== "full";
415
+ // Full rows (raw evidence) show the journal summary verbatim; compact rows unwrap and cap it.
416
+ const summary = compact ? capUtf8(dropLoneSurrogates(unwrapToolResultText(x.summary)).replace(/\s+/g, " ").trim(), COMPACT_EXCERPT_BYTES) : x.summary;
417
+ const reason = x.disposition ? text(x.disposition.reason, "") : "";
418
+ const disposition = x.disposition ? ` · ${x.disposition.disposition}: ${compact ? capUtf8(reason, COMPACT_EXCERPT_BYTES) : reason}` : "";
419
+ const evidence = x.evidence ? text(x.evidence, "") : "";
420
+ return `${failureLabel(x)} · ${time} · ${summary}${x.count > 1 ? ` (${x.count} occurrences)` : ""}${disposition}${evidence ? ` · evidence: ${compact ? shortEvidence(evidence) : evidence}` : ""}`;
230
421
  }
231
422
 
232
- /** Every active incident as a priority row. Consumers page these; they are not a lossy summary. */
233
- export function formatFailureLines(state: FailureState): string[] {
234
- return activeFailures(state).map(failureRow);
423
+ /**
424
+ * Incident rows in priority order. By default, only incidents that need action, as compact rows;
425
+ * `scope: "all"` lists history too and `detail: "full"` keeps whole excerpts and evidence paths.
426
+ * Consumers page these; they are not a lossy summary.
427
+ */
428
+ export function formatFailureLines(state: FailureState, options: IncidentRowOptions = {}): string[] {
429
+ return scopedFailures(state, options.scope).map((x) => failureRow(x, options.detail));
235
430
  }
236
431
 
237
432
  function closedHistoryLine(state: FailureState): string | undefined {
@@ -244,10 +439,10 @@ function closedHistoryLine(state: FailureState): string | undefined {
244
439
  /** Shared priority text, placed BEFORE assistant progress on every consumer surface. */
245
440
  export function formatFailureSummary(state: FailureState): string {
246
441
  const lines = formatFailureLines(state);
247
- if (!lines.length) return "";
442
+ if (!lines.length) return quietFailureLine(state) ?? "";
248
443
  const rows = lines.slice(0, 5);
249
444
  if (lines.length > 5) rows.push(`${lines.length - 5} additional active failure observations retained in the failure journal.`);
250
- const history = closedHistoryLine(state);
445
+ const history = historyTailLine(state);
251
446
  if (history) rows.push(history);
252
447
  return rows.join("\n");
253
448
  }
@@ -257,7 +452,7 @@ export function pendingAttentionRows(state: FailureState, incidents: readonly st
257
452
  const wanted = new Set(incidents);
258
453
  const rows = activeFailures(state).filter((x) => wanted.has(x.id));
259
454
  if (rows.length !== wanted.size) throw new Error("Failure incident evidence is unavailable; defer notification delivery");
260
- return rows.map(failureRow);
455
+ return rows.map((x) => failureRow(x));
261
456
  }
262
457
  /** Count of other active incidents, which a notification references but never re-lists. */
263
458
  export function pendingAttentionNote(state: FailureState, incidents: readonly string[]): string | undefined {
@@ -279,6 +474,8 @@ export function formatPendingAttention(state: FailureState, incidents: readonly
279
474
  return lines.join("\n");
280
475
  }
281
476
 
477
+ /** The terminal fact that lifecycle success is not evidence of correct work. Kept whenever it fits and never cut mid-sentence (#325). */
478
+ export const CORRECTNESS_NOTE: string = "Work correctness was not inferred from lifecycle alone.";
282
479
  export interface TerminalFailureParts {
283
480
  /** Actionable and observation-incomplete incidents among `incidents`, one row each. */
284
481
  rows: string[];
@@ -292,7 +489,7 @@ export interface TerminalFailureParts {
292
489
  export function terminalFailureParts(state: FailureState, incidents: readonly string[] = []): TerminalFailureParts {
293
490
  const wanted = new Set(incidents);
294
491
  const reportable = (x: FailureObservation) => requiresAction(x) || x.category === "observation-incomplete";
295
- const rows = activeFailures(state).filter((x) => wanted.has(x.id) && reportable(x)).map(failureRow);
492
+ const rows = activeFailures(state).filter((x) => wanted.has(x.id) && reportable(x)).map((x) => failureRow(x));
296
493
  const notes: string[] = [];
297
494
  const earlier = activeFailures(state).filter((x) => !wanted.has(x.id) && reportable(x)).length;
298
495
  if (earlier) notes.push(`${earlier} actionable incident${earlier === 1 ? " was" : "s were"} reported earlier; not repeated here.`);
@@ -301,7 +498,7 @@ export function terminalFailureParts(state: FailureState, incidents: readonly st
301
498
  if (counts.expected) notes.push(`${counts.expected} expected failure${counts.expected === 1 ? "" : "s"} recorded.`);
302
499
  const history = closedHistoryLine(state);
303
500
  if (history) notes.push(history);
304
- if (counts.unclassified || counts.actionRequired) notes.push("Work correctness was not inferred from lifecycle alone.");
501
+ if (counts.unclassified || counts.actionRequired) notes.push(CORRECTNESS_NOTE);
305
502
  return { rows, notes };
306
503
  }
307
504
  export function formatTerminalFailureFacts(state: FailureState, incidents: readonly string[] = []): string {
@@ -339,8 +536,9 @@ export function failureRevision(state: FailureState): string {
339
536
  .sort((a, b) => String(a[0]).localeCompare(String(b[0]))));
340
537
  }
341
538
 
342
- function incidentRevision(state: FailureState): string {
343
- return failureIdentity(activeFailures(state).map((item) => [item.id, item.status, item.count, item.summary, item.evidence ?? ""])).slice(0, 16);
539
+ /** Digest of the rows a cursor pages. Scope and detail are part of it: the same incidents render different bytes. */
540
+ function incidentRevision(state: FailureState, scope: IncidentScope = "actionable", detail: IncidentDetail = "compact"): string {
541
+ return failureIdentity(scope, detail, scopedFailures(state, scope).map((item) => [item.id, item.status, item.count, item.summary, item.evidence ?? ""])).slice(0, 16);
344
542
  }
345
543
 
346
544
  /** Cursors carry a digest of their resource/scope, not the scope text. */
@@ -348,20 +546,28 @@ function resourceTag(resource: string | undefined): string | undefined {
348
546
  return resource === undefined ? undefined : createHash("sha256").update(resource).digest("base64url").slice(0, 16);
349
547
  }
350
548
 
351
- interface IncidentCursor { o: number; b: number; v: string; n: number; r?: string }
549
+ /**
550
+ * Incident cursor format. `x` is the format version: cursors minted before scope and row detail
551
+ * existed (no `x`) carry byte offsets into full-detail rows of every active incident, so they reset
552
+ * as stale instead of resuming inside a compact row. `h`: the explicit history view; `f`: full rows.
553
+ */
554
+ const INCIDENT_CURSOR_FORMAT = 2;
555
+ interface IncidentCursor { o: number; b: number; v: string; n: number; r?: string; h?: 1; f?: 1 }
352
556
 
353
557
  function encodeIncidentCursor(cursor: IncidentCursor): string {
354
- return INCIDENT_CURSOR_PREFIX + Buffer.from(JSON.stringify({ k: "i", o: cursor.o, ...(cursor.b ? { b: cursor.b } : {}),
355
- v: cursor.v, n: cursor.n, ...(cursor.r !== undefined ? { r: cursor.r } : {}) }), "utf8").toString("base64url");
558
+ return INCIDENT_CURSOR_PREFIX + Buffer.from(JSON.stringify({ k: "i", x: INCIDENT_CURSOR_FORMAT, o: cursor.o, ...(cursor.b ? { b: cursor.b } : {}),
559
+ v: cursor.v, n: cursor.n, ...(cursor.r !== undefined ? { r: cursor.r } : {}), ...(cursor.h ? { h: 1 } : {}),
560
+ ...(cursor.f ? { f: 1 } : {}) }), "utf8").toString("base64url");
356
561
  }
357
562
 
358
563
  function decodeIncidentCursor(cursor: string | undefined): IncidentCursor | undefined {
359
564
  if (!cursor || !cursor.startsWith(INCIDENT_CURSOR_PREFIX)) return undefined;
360
565
  try {
361
- const parsed = JSON.parse(Buffer.from(cursor.slice(INCIDENT_CURSOR_PREFIX.length), "base64url").toString("utf8")) as { k?: string; o?: number; b?: number; v?: string; n?: number; r?: string };
362
- if (parsed?.k === "i" && typeof parsed.v === "string") {
566
+ const parsed = JSON.parse(Buffer.from(cursor.slice(INCIDENT_CURSOR_PREFIX.length), "base64url").toString("utf8")) as { k?: string; x?: number; o?: number; b?: number; v?: string; n?: number; r?: string; h?: number; f?: number };
567
+ if (parsed?.k === "i" && parsed.x === INCIDENT_CURSOR_FORMAT && typeof parsed.v === "string") {
363
568
  return { o: Math.max(0, Math.floor(parsed.o ?? 0)), b: Math.max(0, Math.floor(parsed.b ?? 0)), v: parsed.v,
364
- n: Math.max(0, Math.floor(parsed.n ?? 0)), ...(typeof parsed.r === "string" ? { r: parsed.r } : {}) };
569
+ n: Math.max(0, Math.floor(parsed.n ?? 0)), ...(typeof parsed.r === "string" ? { r: parsed.r } : {}),
570
+ ...(parsed.h === 1 ? { h: 1 as const } : {}), ...(parsed.f === 1 ? { f: 1 as const } : {}) };
365
571
  }
366
572
  } catch { /* stale */ }
367
573
  return undefined;
@@ -371,6 +577,12 @@ export function isIncidentCursor(cursor: string | undefined): boolean {
371
577
  return Boolean(cursor?.startsWith(INCIDENT_CURSOR_PREFIX));
372
578
  }
373
579
 
580
+ /** The scope an incident cursor pages, or undefined for anything that is not an incident cursor. */
581
+ export function incidentCursorScope(cursor: string | undefined): IncidentScope | undefined {
582
+ const parsed = decodeIncidentCursor(cursor);
583
+ return parsed ? (parsed.h ? "all" : "actionable") : undefined;
584
+ }
585
+
374
586
  function utf8Length(value: string): number {
375
587
  return encoder.encode(value).byteLength;
376
588
  }
@@ -384,7 +596,7 @@ function utf8Prefix(bytes: Uint8Array, room: number): number {
384
596
  return end;
385
597
  }
386
598
 
387
- export interface IncidentPageRequest {
599
+ export interface IncidentPageRequest extends IncidentRowOptions {
388
600
  cursor?: string;
389
601
  maxBytes?: number;
390
602
  /** Resource/scope bound into cursors; a cursor minted for another scope resets. */
@@ -398,23 +610,27 @@ export interface IncidentPageRequest {
398
610
  * that `endsPartial`). A page never exceeds `maxBytes`.
399
611
  */
400
612
  export function pageFailureIncidents(state: FailureState, request: IncidentPageRequest = {}): FailureIncidentPage {
401
- const lines = formatFailureLines(state);
402
- const revision = incidentRevision(state);
613
+ // A cursor carries its view: following a history cursor stays in history without the flag.
614
+ const parsed = request.cursor ? decodeIncidentCursor(request.cursor) : undefined;
615
+ const scope: IncidentScope = request.scope ?? (parsed?.h ? "all" : "actionable");
616
+ const detail: IncidentDetail = request.detail ?? (parsed?.f ? "full" : "compact");
617
+ const lines = formatFailureLines(state, { scope, detail });
618
+ const revision = incidentRevision(state, scope, detail);
403
619
  const total = lines.length;
404
620
  const resource = resourceTag(request.resource);
405
621
  let offset = 0;
406
622
  let byte = 0;
407
623
  let reset: FailureIncidentPage["reset"];
408
624
  if (request.cursor) {
409
- const parsed = decodeIncidentCursor(request.cursor);
410
- if (!parsed || parsed.r !== resource) reset = "stale-cursor";
625
+ if (!parsed || parsed.r !== resource || Boolean(parsed.h) !== (scope === "all") || Boolean(parsed.f) !== (detail === "full")) reset = "stale-cursor";
411
626
  else if (parsed.v !== revision) reset = "source-replaced";
412
627
  else { offset = Math.min(total, parsed.o); byte = offset < total ? parsed.b : 0; }
413
628
  }
414
629
  const maxBytes = Number.isFinite(request.maxBytes) && (request.maxBytes ?? -1) >= 0
415
630
  ? Math.floor(request.maxBytes as number)
416
631
  : 2 * 1024;
417
- const mint = (o: number, b: number) => encodeIncidentCursor({ o, b, v: revision, n: total, ...(resource !== undefined ? { r: resource } : {}) });
632
+ const mint = (o: number, b: number) => encodeIncidentCursor({ o, b, v: revision, n: total, ...(resource !== undefined ? { r: resource } : {}),
633
+ ...(scope === "all" ? { h: 1 as const } : {}), ...(detail === "full" ? { f: 1 as const } : {}) });
418
634
  const parts: string[] = [];
419
635
  let used = 0;
420
636
  let nextRow = offset;
@@ -460,11 +676,72 @@ export function pageFailureIncidents(state: FailureState, request: IncidentPageR
460
676
  };
461
677
  }
462
678
 
463
- export function incidentCursorAt(state: FailureState, offset: number, resource?: string): string {
464
- const lines = formatFailureLines(state);
679
+ /** Cursor resource for one task/run's incidents in one session scope. */
680
+ export function incidentResource(scopeKey: string, id: string): string {
681
+ return `incidents:${scopeKey}:${id}`;
682
+ }
683
+
684
+ /** Heading for an explicit incident page. */
685
+ export function incidentPageHeading(total: number, scope: IncidentScope = "actionable"): string {
686
+ return scope === "all"
687
+ ? `History page of ${total} failure observation${total === 1 ? "" : "s"} (all, including those that need no action).`
688
+ : `Incident page of ${total} active failure observation${total === 1 ? "" : "s"}.`;
689
+ }
690
+
691
+ /**
692
+ * One explicit incident page shaped as an envelope verbatim page (log-utils
693
+ * `VerbatimPage`): the shared body of every consumer's `incidentCursor`
694
+ * response. Omitted counts are rows, never bytes.
695
+ */
696
+ export function incidentVerbatimPage(state: FailureState, request: IncidentPageRequest = {}): {
697
+ text: string;
698
+ hasMore: boolean;
699
+ cursor: string;
700
+ nextCursor: string;
701
+ omittedBytes: number;
702
+ omittedRows: number;
703
+ reset?: "stale-cursor" | "source-replaced";
704
+ } {
705
+ const page = pageFailureIncidents(state, request);
706
+ const scope = request.scope ?? incidentCursorScope(request.cursor) ?? "actionable";
707
+ const empty = scope === "all" ? "No failure observations recorded."
708
+ : [quietFailureLine(state) ? "No failures need action." : "No active failure observations.", historyCountsText(state) ? "Pass history:true to list history." : ""].filter(Boolean).join(" ");
709
+ return {
710
+ text: page.text || (page.total === 0 ? empty : ""),
711
+ hasMore: page.hasMore,
712
+ cursor: page.cursor,
713
+ nextCursor: page.nextCursor,
714
+ omittedBytes: 0,
715
+ omittedRows: page.omitted,
716
+ ...(page.reset ? { reset: page.reset } : {}),
717
+ };
718
+ }
719
+
720
+ /**
721
+ * Cheap change fingerprint of a failure journal: the file's identity and size
722
+ * (or its read error), whether the durable existence marker is present, and
723
+ * how many records are still awaiting persistence in this process. Every
724
+ * journal append, truncation, deletion, or pending in-memory record changes
725
+ * it, so a cache keyed by it never serves stale incident counts.
726
+ */
727
+ export function failureJournalFingerprint(path: string): string {
728
+ let file: string;
729
+ try {
730
+ const stats = statSync(path, { bigint: true });
731
+ file = `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`;
732
+ } catch (error) {
733
+ file = `unreadable:${(error as NodeJS.ErrnoException).code ?? "error"}`;
734
+ }
735
+ return `${file}|${existsSync(`${path}.observed`) ? 1 : 0}|${pendingWrites.get(path)?.length ?? 0}`;
736
+ }
737
+
738
+ export function incidentCursorAt(state: FailureState, offset: number, resource?: string, options: IncidentRowOptions = {}): string {
739
+ const scope = options.scope ?? "actionable";
740
+ const detail = options.detail ?? "compact";
741
+ const lines = formatFailureLines(state, { scope, detail });
465
742
  const tag = resourceTag(resource);
466
- return encodeIncidentCursor({ o: Math.max(0, Math.floor(offset)), b: 0, v: incidentRevision(state), n: lines.length,
467
- ...(tag !== undefined ? { r: tag } : {}) });
743
+ return encodeIncidentCursor({ o: Math.max(0, Math.floor(offset)), b: 0, v: incidentRevision(state, scope, detail), n: lines.length,
744
+ ...(tag !== undefined ? { r: tag } : {}), ...(scope === "all" ? { h: 1 as const } : {}), ...(options.detail === "full" ? { f: 1 as const } : {}) });
468
745
  }
469
746
 
470
747
  const MIN_PARTIAL_ROW_BYTES = 96;
@@ -485,9 +762,28 @@ export interface IncidentSummary {
485
762
  * total, fully shown, omitted, and the incident cursor that resumes at the
486
763
  * first byte not shown. Counts survive any budget that fits the count line.
487
764
  */
488
- export function formatIncidentSummary(state: FailureState, options: { maxBytes: number; resource?: string; retrieval?: string }): IncidentSummary {
765
+ export interface IncidentSummaryOptions {
766
+ maxBytes: number;
767
+ resource?: string;
768
+ retrieval?: string;
769
+ /** Row detail; cursors minted here page the same detail. Default compact. */
770
+ detail?: IncidentDetail;
771
+ }
772
+ export function formatIncidentSummary(state: FailureState, options: IncidentSummaryOptions): IncidentSummary {
773
+ const maxBytes = Math.max(0, Math.floor(options.maxBytes));
774
+ const summary = actionableIncidentSummary(state, options);
775
+ // Nothing needs action: one short line, no cursor. History is listed only on request.
776
+ if (summary.total === 0) {
777
+ const quiet = quietFailureLine(state);
778
+ return { text: quiet && utf8Length(quiet) <= maxBytes ? quiet : "", total: 0, represented: 0, omitted: 0 };
779
+ }
780
+ const tail = historyTailLine(state);
781
+ if (tail && summary.text && utf8Length(summary.text) + 1 + utf8Length(tail) <= maxBytes) return { ...summary, text: `${summary.text}\n${tail}` };
782
+ return summary;
783
+ }
784
+ function actionableIncidentSummary(state: FailureState, options: IncidentSummaryOptions): IncidentSummary {
489
785
  const maxBytes = Math.max(0, Math.floor(options.maxBytes));
490
- const whole = pageFailureIncidents(state, { maxBytes, resource: options.resource });
786
+ const whole = pageFailureIncidents(state, { maxBytes, resource: options.resource, detail: options.detail });
491
787
  if (whole.total === 0) return { text: "", total: 0, represented: 0, omitted: 0 };
492
788
  if (!whole.hasMore) return { text: whole.text, total: whole.total, represented: whole.represented, omitted: 0 };
493
789
  const header = (page: FailureIncidentPage): string =>
@@ -495,11 +791,11 @@ export function formatIncidentSummary(state: FailureState, options: { maxBytes:
495
791
  ` · incidentCursor=${page.nextCursor}${options.retrieval ? ` (${options.retrieval})` : ""}`;
496
792
  let rowBudget = maxBytes - utf8Length(header(whole)) - 1;
497
793
  for (let attempt = 0; attempt < 6; attempt += 1) {
498
- let page = pageFailureIncidents(state, { maxBytes: Math.max(0, rowBudget), resource: options.resource });
794
+ let page = pageFailureIncidents(state, { maxBytes: Math.max(0, rowBudget), resource: options.resource, detail: options.detail });
499
795
  // A few bytes of a clipped row are noise; show the count line alone and
500
796
  // let the cursor start at that row.
501
797
  if (page.represented === 0 && page.endsPartial && utf8Length(page.text) < MIN_PARTIAL_ROW_BYTES) {
502
- page = pageFailureIncidents(state, { maxBytes: 0, resource: options.resource });
798
+ page = pageFailureIncidents(state, { maxBytes: 0, resource: options.resource, detail: options.detail });
503
799
  }
504
800
  const line = header(page);
505
801
  const text = page.text ? `${line}\n${page.text}` : line;
@@ -512,7 +808,7 @@ export function formatIncidentSummary(state: FailureState, options: { maxBytes:
512
808
  }
513
809
  // Not even the count line with its cursor fits. Never emit a clipped cursor:
514
810
  // keep the exact counts and drop the cursor token whole, saying so.
515
- const empty = pageFailureIncidents(state, { maxBytes: 0, resource: options.resource });
811
+ const empty = pageFailureIncidents(state, { maxBytes: 0, resource: options.resource, detail: options.detail });
516
812
  const withCursor = header(empty);
517
813
  if (utf8Length(withCursor) <= maxBytes) {
518
814
  return { text: withCursor, total: empty.total, represented: 0, omitted: empty.total, nextCursor: empty.nextCursor };
@@ -532,23 +828,58 @@ export function formatIncidentSummary(state: FailureState, options: { maxBytes:
532
828
  * observation-incomplete incidents (which lead the priority order) are shown as whole rows;
533
829
  * unclassified, expected, and closed incidents are counts. When any active row is not shown, a
534
830
  * count line gives the exact total, shown, omitted, and an incident cursor at the first unshown row.
831
+ *
832
+ * The result never exceeds `maxBytes` and is made of whole lines (#325). Under a tight budget it
833
+ * drops, in order: incident rows; the lower-priority notes (closed history, expected, earlier
834
+ * reported); the cursor's retrieval hint; the unclassified count; the cursor; the count line.
835
+ * The correctness note goes last, and only when it alone does not fit: it is never cut mid-sentence.
535
836
  */
536
- export function formatTerminalIncidentSummary(state: FailureState, options: { maxBytes: number; resource?: string; retrieval?: string }): IncidentSummary {
837
+ export function formatTerminalIncidentSummary(state: FailureState, options: IncidentSummaryOptions): IncidentSummary {
537
838
  const active = activeFailures(state);
538
- const total = active.length;
539
- const { notes } = terminalFailureParts(state, active.map((x) => x.id));
839
+ // Counts, rows, and the cursor cover only incidents that need action; the rest is history.
840
+ const lines = formatFailureLines(state, { detail: options.detail });
841
+ const total = lines.length;
842
+ const parts = terminalFailureParts(state, active.map((x) => x.id));
843
+ const quiet = quietFailureLine(state);
844
+ // Nothing needs action: one short history line (no cursor) and, when tool failures stay
845
+ // unclassified, the correctness note.
846
+ const notes = total === 0 && quiet ? [quiet, ...parts.notes.filter((x) => x === CORRECTNESS_NOTE)] : parts.notes;
540
847
  if (total === 0 && notes.length === 0) return { text: "", total: 0, represented: 0, omitted: 0 };
541
- const reportable = active.filter((x) => requiresAction(x) || x.category === "observation-incomplete").length;
542
- const lines = formatFailureLines(state).slice(0, reportable);
543
848
  const maxBytes = Math.max(0, Math.floor(options.maxBytes));
544
- const header = (shown: number): string | undefined => shown >= total ? undefined :
849
+ const fits = (parts: readonly (string | undefined)[]) => utf8Length(parts.filter(Boolean).join("\n")) <= maxBytes;
850
+ // "full": cursor and retrieval hint; "bare": cursor only; "none": exact counts, no cursor.
851
+ type Header = "full" | "bare" | "none";
852
+ const header = (shown: number, mode: Header): string | undefined => shown >= total ? undefined :
545
853
  `${total} active failure observation${total === 1 ? "" : "s"} · ${shown} shown · ${total - shown} omitted` +
546
- ` · incidentCursor=${incidentCursorAt(state, shown, options.resource)}${options.retrieval ? ` (${options.retrieval})` : ""}`;
547
- const render = (shown: number) => [header(shown), ...lines.slice(0, shown), ...notes].filter((x): x is string => Boolean(x)).join("\n");
548
- let shown = lines.length;
549
- while (shown > 0 && utf8Length(render(shown)) > maxBytes) shown -= 1;
550
- const omitted = total - shown;
551
- return { text: render(shown), total, represented: shown, omitted, ...(omitted > 0 ? { nextCursor: incidentCursorAt(state, shown, options.resource) } : {}) };
854
+ (mode === "none" ? " · incident cursor not shown (page too small)"
855
+ : ` · incidentCursor=${incidentCursorAt(state, shown, options.resource, { detail: options.detail })}${mode === "full" && options.retrieval ? ` (${options.retrieval})` : ""}`);
856
+ const done = (parts: readonly (string | undefined)[], shown: number, mode: Header): IncidentSummary => {
857
+ const omitted = total - shown;
858
+ return { text: parts.filter((x): x is string => Boolean(x)).join("\n"), total, represented: shown, omitted,
859
+ ...(omitted > 0 && mode !== "none" ? { nextCursor: incidentCursorAt(state, shown, options.resource, { detail: options.detail }) } : {}) };
860
+ };
861
+ // 1. Drop incident rows, lowest priority first, keeping every note and the count line with its cursor.
862
+ for (let shown = lines.length; shown >= 0; shown -= 1) {
863
+ const parts = [header(shown, "full"), ...lines.slice(0, shown), ...notes];
864
+ if (fits(parts)) return done(parts, shown, "full");
865
+ }
866
+ // 2. No rows. Then, in order: the lower-priority notes (history, expected, earlier reported), the
867
+ // cursor's retrieval hint, the unclassified count, the cursor, and the count line. The
868
+ // correctness note goes last, and only when it alone does not fit.
869
+ const correctness: string[] = notes.filter((x) => x === CORRECTNESS_NOTE);
870
+ const unclassified = notes.filter((x) => x !== CORRECTNESS_NOTE && /remains? unclassified\.$/.test(x));
871
+ const low = notes.filter((x) => !correctness.includes(x) && !unclassified.includes(x));
872
+ const ladder: Array<{ mode: Header | undefined; notes: string[] }> = [];
873
+ for (let keep = low.length - 1; keep >= 0; keep -= 1) ladder.push({ mode: "full", notes: [...low.slice(0, keep), ...unclassified, ...correctness] });
874
+ ladder.push({ mode: "bare", notes: [...unclassified, ...correctness] });
875
+ ladder.push({ mode: "bare", notes: correctness });
876
+ ladder.push({ mode: "none", notes: correctness });
877
+ ladder.push({ mode: undefined, notes: correctness });
878
+ for (const step of ladder) {
879
+ const parts = [step.mode ? header(0, step.mode) : undefined, ...step.notes];
880
+ if (parts.some(Boolean) && fits(parts)) return done(parts, 0, step.mode ?? "none");
881
+ }
882
+ return done([], 0, "none");
552
883
  }
553
884
  /**
554
885
  * Incidents due for a notification. Terminal: every current unresolved incident not yet delivered