pi-better-subagents 0.6.0 → 0.7.1

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.
@@ -75,7 +75,8 @@ export function failureIdentity(...parts: unknown[]): string {
75
75
  return createHash("sha256").update(JSON.stringify(parts)).digest("hex").slice(0, 32);
76
76
  }
77
77
  function text(value: string | undefined, fallback: string): string {
78
- return (value || fallback).replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 400);
78
+ // The 400-unit cut can split a surrogate pair; a lone half would render as U+FFFD (#332).
79
+ return dropLoneSurrogates((value || fallback).replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 400));
79
80
  }
80
81
 
81
82
  /** Agent tool attempts: the agent handles its own tool errors, so one failure is not yet actionable. */
@@ -104,24 +105,60 @@ export interface CommandIntent {
104
105
  export type CommandIntentField = keyof CommandIntent;
105
106
  const INTENT_FIELDS: readonly CommandIntentField[] = ["operationId", "attemptId", "expectedExitCodes"];
106
107
  /**
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.
108
+ * Pi's own argument coercion for the intent fields (pi-ai `validateToolArguments` against the confined
109
+ * bash schema, where each field is `anyOf: [field schema, null]`). Pi applies it before execute, but the
110
+ * process log and session record keep the raw arguments; mirroring it lets the parent's replay read
111
+ * the declaration the child executed with (#332).
112
+ * - ids: a number or boolean becomes its string; `""` fails the pattern and falls through to null.
113
+ * - exit codes: array items that read as integers become integers (null -> 0, true -> 1);
114
+ * a scalar `0`, `""`, or `false` falls through to null.
115
+ */
116
+ function coerceInteger(value: unknown): unknown {
117
+ if (value === null) return 0;
118
+ if (typeof value === "string" && value.trim() !== "" && Number.isInteger(Number(value))) return Number(value);
119
+ if (typeof value === "boolean") return value ? 1 : 0;
120
+ return value;
121
+ }
122
+ function normalizeIntentField(key: CommandIntentField, value: unknown): unknown {
123
+ if (value == null) return undefined;
124
+ if (key === "expectedExitCodes") {
125
+ if (value === 0 || value === "" || value === false) return undefined;
126
+ if (!Array.isArray(value)) return value;
127
+ // 0 is a no-op declaration: exit 0 is already success. A list of only zeros declares nothing.
128
+ const codes = value.map(coerceInteger).filter((code) => code !== 0);
129
+ if (codes.length === 0 && value.length > 0) return undefined;
130
+ return codes.length === value.length && codes.every((code, index) => code === value[index]) ? value : codes;
131
+ }
132
+ if (value === "") return undefined;
133
+ return typeof value === "number" || typeof value === "boolean" ? String(value) : value;
134
+ }
135
+ /**
136
+ * The one normalization of intent-bearing arguments. An explicit `null` intent field means "not
137
+ * declared", exactly like an omitted one: models routinely send optional fields as null, and Pi's
138
+ * argument validation drops or nulls them before a tool runs, while the process log and session
139
+ * record keep the raw arguments. Values Pi would coerce are coerced the same way, and `0` is dropped
140
+ * from `expectedExitCodes`. Every reader of intent, the executing tool and the replay alike, and the
141
+ * exact operation identity go through this, so all see the same declaration from the same input.
142
+ * Malformed values are kept as they are for the validator to refuse.
112
143
  */
113
144
  export function withoutAbsentIntent<T>(args: T): T {
114
145
  if (!args || typeof args !== "object" || Array.isArray(args)) return args;
115
146
  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;
147
+ let out: Record<string, unknown> | undefined;
148
+ for (const key of INTENT_FIELDS) {
149
+ if (!(key in input)) continue;
150
+ const value = normalizeIntentField(key, input[key]);
151
+ if (value === input[key]) continue;
152
+ out ??= { ...input };
153
+ if (value === undefined) delete out[key];
154
+ else out[key] = value;
155
+ }
156
+ return (out ?? args) as T;
120
157
  }
121
158
  /**
122
- * Validate structured intent fields. Absent fields (omitted, undefined, or null) are fine; malformed
159
+ * Validate structured intent fields after `withoutAbsentIntent`. Absent fields are fine; malformed
123
160
  * ones are an error, and the caller must not run the command. `names` renames fields in the error
124
- * (e.g. snake_case parameters).
161
+ * (e.g. snake_case parameters). `0` in `expectedExitCodes` is accepted and dropped (#332).
125
162
  */
126
163
  export function readCommandIntent(args: unknown, names: Partial<Record<CommandIntentField, string>> = {}): { intent: CommandIntent; error?: string } {
127
164
  const input = withoutAbsentIntent((args && typeof args === "object" ? args : {}) as Record<string, unknown>);
@@ -138,7 +175,7 @@ export function readCommandIntent(args: unknown, names: Partial<Record<CommandIn
138
175
  if (codes !== undefined) {
139
176
  if (!Array.isArray(codes) || codes.length === 0 || codes.length > MAX_EXPECTED_EXIT_CODES ||
140
177
  !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` };
178
+ return { intent: {}, error: `${names.expectedExitCodes ?? "expectedExitCodes"} must be 1-${MAX_EXPECTED_EXIT_CODES} distinct integers from 1 to 255 (0 is allowed and ignored)` };
142
179
  }
143
180
  intent.expectedExitCodes = [...codes] as number[];
144
181
  }
@@ -481,6 +518,8 @@ export interface TerminalFailureParts {
481
518
  rows: string[];
482
519
  /** Counts and separate facts: earlier-reported, unclassified, expected, closed history, correctness. */
483
520
  notes: string[];
521
+ /** The unclassified-count note, when it is among `notes`: a byte budget keeps it longer than the others. */
522
+ unclassifiedNote?: string;
484
523
  }
485
524
  /**
486
525
  * Terminal/completion facts: actionable and incomplete incidents from `incidents` as rows,
@@ -494,12 +533,14 @@ export function terminalFailureParts(state: FailureState, incidents: readonly st
494
533
  const earlier = activeFailures(state).filter((x) => !wanted.has(x.id) && reportable(x)).length;
495
534
  if (earlier) notes.push(`${earlier} actionable incident${earlier === 1 ? " was" : "s were"} reported earlier; not repeated here.`);
496
535
  const counts = failureCounts(state);
497
- if (counts.unclassified) notes.push(`${counts.unclassified} earlier tool failure${counts.unclassified === 1 ? "" : "s"} remain${counts.unclassified === 1 ? "s" : ""} unclassified.`);
536
+ const unclassifiedNote = counts.unclassified
537
+ ? `${counts.unclassified} earlier tool failure${counts.unclassified === 1 ? "" : "s"} remain${counts.unclassified === 1 ? "s" : ""} unclassified.` : undefined;
538
+ if (unclassifiedNote) notes.push(unclassifiedNote);
498
539
  if (counts.expected) notes.push(`${counts.expected} expected failure${counts.expected === 1 ? "" : "s"} recorded.`);
499
540
  const history = closedHistoryLine(state);
500
541
  if (history) notes.push(history);
501
542
  if (counts.unclassified || counts.actionRequired) notes.push(CORRECTNESS_NOTE);
502
- return { rows, notes };
543
+ return { rows, notes, ...(unclassifiedNote ? { unclassifiedNote } : {}) };
503
544
  }
504
545
  export function formatTerminalFailureFacts(state: FailureState, incidents: readonly string[] = []): string {
505
546
  const { rows, notes } = terminalFailureParts(state, incidents);
@@ -607,7 +648,10 @@ export interface IncidentPageRequest extends IncidentRowOptions {
607
648
  * Caller-owned incident pages. Whole rows are preferred; a row larger than the
608
649
  * page is split at a code-point boundary and resumes at that byte, so pages
609
650
  * reconstruct formatFailureLines() exactly (join with "\n" except after a page
610
- * that `endsPartial`). A page never exceeds `maxBytes`.
651
+ * that `endsPartial`). A page never exceeds `maxBytes`, so one smaller than the
652
+ * next code point (up to 4 bytes) returns no text, `hasMore`, and a `nextCursor`
653
+ * equal to `cursor`: the caller retries with a larger page, as with
654
+ * pageVerbatimText. Consumers never ask for fewer than 4 bytes.
611
655
  */
612
656
  export function pageFailureIncidents(state: FailureState, request: IncidentPageRequest = {}): FailureIncidentPage {
613
657
  // A cursor carries its view: following a history cursor stays in history without the flag.
@@ -725,14 +769,19 @@ export function incidentVerbatimPage(state: FailureState, request: IncidentPageR
725
769
  * it, so a cache keyed by it never serves stale incident counts.
726
770
  */
727
771
  export function failureJournalFingerprint(path: string): string {
728
- let file: string;
772
+ return `${fileChangeIdentity(path)}|${existsSync(`${path}.observed`) ? 1 : 0}|${pendingWrites.get(path)?.length ?? 0}`;
773
+ }
774
+ /**
775
+ * A file's identity and change stamp (device, inode, size, and nanosecond mtime/ctime), or its read
776
+ * error. Any append, rewrite, replacement, or deletion changes it; for cache keys, never for trust.
777
+ */
778
+ export function fileChangeIdentity(path: string): string {
729
779
  try {
730
780
  const stats = statSync(path, { bigint: true });
731
- file = `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`;
781
+ return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`;
732
782
  } catch (error) {
733
- file = `unreadable:${(error as NodeJS.ErrnoException).code ?? "error"}`;
783
+ return `unreadable:${(error as NodeJS.ErrnoException).code ?? "error"}`;
734
784
  }
735
- return `${file}|${existsSync(`${path}.observed`) ? 1 : 0}|${pendingWrites.get(path)?.length ?? 0}`;
736
785
  }
737
786
 
738
787
  export function incidentCursorAt(state: FailureState, offset: number, resource?: string, options: IncidentRowOptions = {}): string {
@@ -867,7 +916,7 @@ export function formatTerminalIncidentSummary(state: FailureState, options: Inci
867
916
  // cursor's retrieval hint, the unclassified count, the cursor, and the count line. The
868
917
  // correctness note goes last, and only when it alone does not fit.
869
918
  const correctness: string[] = notes.filter((x) => x === CORRECTNESS_NOTE);
870
- const unclassified = notes.filter((x) => x !== CORRECTNESS_NOTE && /remains? unclassified\.$/.test(x));
919
+ const unclassified = notes.filter((x) => x === parts.unclassifiedNote);
871
920
  const low = notes.filter((x) => !correctness.includes(x) && !unclassified.includes(x));
872
921
  const ladder: Array<{ mode: Header | undefined; notes: string[] }> = [];
873
922
  for (let keep = low.length - 1; keep >= 0; keep -= 1) ladder.push({ mode: "full", notes: [...low.slice(0, keep), ...unclassified, ...correctness] });
@@ -819,29 +819,22 @@ export interface OutputControls {
819
819
  maxBytes?: unknown;
820
820
  /** Raw requested line count (canonical `lines`, else deprecated `tail_lines`). */
821
821
  lines?: unknown;
822
- /** Deprecated alias names the caller used (whether or not the canonical name won). */
823
- deprecated: string[];
824
822
  }
825
823
 
826
824
  /**
827
825
  * Resolve output-control parameters. Both spellings work; when both are given
828
- * the canonical name wins. Values are returned unvalidated so each surface
829
- * keeps its own defaults and hard caps.
826
+ * the canonical name wins. An explicit `null` is "not given", so a null
827
+ * canonical value never hides an alias value (#332). Values are returned
828
+ * unvalidated so each surface keeps its own defaults and hard caps.
830
829
  */
831
830
  export function readOutputControls(params: unknown): OutputControls {
832
831
  const p = (params && typeof params === "object" ? params : {}) as Record<string, unknown>;
833
- const deprecated: string[] = [];
834
- const pick = (canonical: keyof typeof OUTPUT_CONTROL_ALIASES): unknown => {
835
- const alias = OUTPUT_CONTROL_ALIASES[canonical];
836
- if (p[alias] !== undefined) deprecated.push(alias);
837
- return p[canonical] !== undefined ? p[canonical] : p[alias];
838
- };
832
+ const pick = (canonical: keyof typeof OUTPUT_CONTROL_ALIASES): unknown => p[canonical] ?? p[OUTPUT_CONTROL_ALIASES[canonical]] ?? undefined;
839
833
  const maxBytes = pick("max_bytes");
840
834
  const lines = pick("lines");
841
835
  return {
842
836
  ...(maxBytes !== undefined ? { maxBytes } : {}),
843
837
  ...(lines !== undefined ? { lines } : {}),
844
- deprecated,
845
838
  };
846
839
  }
847
840
 
@@ -851,7 +844,8 @@ export type OutputInclude = (typeof OUTPUT_INCLUDE_VALUES)[number];
851
844
 
852
845
  /** Parse an `include` request into known values and unknown ones (both deduplicated). */
853
846
  export function readOutputInclude(value: unknown): { include: Set<OutputInclude>; unknown: string[] } {
854
- const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
847
+ // Any other non-null value (a number, boolean, or object) is one unknown entry, never silently nothing (#332).
848
+ const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : value == null ? [] : [value];
855
849
  const include = new Set<OutputInclude>();
856
850
  const unknown: string[] = [];
857
851
  for (const entry of raw) {