@cr1ms0n/pi-subagent 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/output.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import type { SubagentConfig } from "./config.js";
3
+ import { earlierAttemptOutputNote } from "./model-failover.js";
3
4
  import type { OutputMode, RunSnapshot, TaskResult } from "./types.js";
4
5
 
5
6
  export interface CappedDelivery {
@@ -9,15 +10,16 @@ export interface CappedDelivery {
9
10
  totalLines: number;
10
11
  }
11
12
 
12
- function finalAssistantText(messages: any[]): string | undefined {
13
+ function finalAssistantText(messages: any[], latestOnly = false): string | undefined {
13
14
  for (let i = messages.length - 1; i >= 0; i--) {
14
15
  const message = messages[i];
15
- if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
16
+ if (message?.role !== "assistant") continue;
17
+ if (!Array.isArray(message.content)) { if (latestOnly) return undefined; continue; }
16
18
  const text = message.content
17
19
  .filter((part: any) => part?.type === "text" && typeof part.text === "string")
18
20
  .map((part: any) => part.text)
19
21
  .join("");
20
- if (text) return text;
22
+ if (text || latestOnly) return text || undefined;
21
23
  }
22
24
  return undefined;
23
25
  }
@@ -87,7 +89,10 @@ export class OutputManager {
87
89
  // the narrative preamble around the fenced block.
88
90
  raw = JSON.stringify(result.structuredOutput, null, 2);
89
91
  } else {
90
- raw = result.finalOutput || finalAssistantText(result.messages || []) || result.liveText || result.errorMessage || result.stderr || "(no output)";
92
+ const primary = result.finalOutput || finalAssistantText(result.messages || [], !!result.routing?.rankedModels) || result.liveText;
93
+ raw = primary || result.errorMessage || result.stderr || "(no output)";
94
+ const earlier = !primary ? earlierAttemptOutputNote(result) : undefined;
95
+ if (earlier) raw += `\n\n${earlier}`;
91
96
  }
92
97
  const artifact = result.outputFile ? ` Full output: ${result.outputFile}` : " Full output is in the child session transcript.";
93
98
  const marker = `[Truncated.${artifact}]`;
@@ -1,7 +1,14 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import type { SubagentConfig } from "./config.js";
3
- import { MAX_ROUTING_TOOL_QUESTIONS, type RoutingReceipt } from "./routing-types.js";
4
- import type { ChildProcessIdentity, RunMode, RunSnapshot, RunState, TaskProfile, TaskRouting, TaskSpec, TimeoutPhase, UsageStats } from "./types.js";
3
+ import {
4
+ MAX_ATTEMPT_PREVIEW_BYTES,
5
+ MAX_MODEL_ATTEMPT_RECORDS,
6
+ trimAttemptPreviews,
7
+ utf8SafePrefix,
8
+ validateModelRanking,
9
+ } from "./model-failover.js";
10
+ import { MAX_ROUTING_MODEL_ID_LENGTH, MAX_ROUTING_TOOL_QUESTIONS, type RankedModelOption, type RoutingReceipt } from "./routing-types.js";
11
+ import type { ChildProcessIdentity, ModelAttemptRecord, ModelFailureCategory, RunMode, RunSnapshot, RunState, TaskProfile, TaskRouting, TaskSpec, TimeoutPhase, ToolActivity, UsageStats } from "./types.js";
5
12
  import { emptyUsage } from "./types.js";
6
13
  import { isThinkingLevel } from "./thinking.js";
7
14
 
@@ -46,6 +53,20 @@ const MAX_ROUTING_VERSION_LENGTH = 128;
46
53
  const MAX_ROUTING_MODEL_LENGTH = 256;
47
54
  const MAX_ROUTING_LIST = 256;
48
55
 
56
+ const TOOL_ACTIVITY_STATES = ["none", "started", "unknown"] as const;
57
+ const MODEL_FAILURE_CATEGORIES: readonly ModelFailureCategory[] = [
58
+ "model_unavailable",
59
+ "rate_limited",
60
+ "service_overload",
61
+ "transport",
62
+ "auth",
63
+ "quota",
64
+ "invalid_request",
65
+ "context_overflow",
66
+ "refusal",
67
+ "unknown",
68
+ ];
69
+
49
70
  function routingString(value: unknown, max = MAX_ROUTING_ID_LENGTH): string | undefined {
50
71
  if (typeof value !== "string") return undefined;
51
72
  const trimmed = value.trim();
@@ -138,6 +159,27 @@ export function normalizeTaskRouting(value: unknown): TaskRouting | undefined {
138
159
  const selectorVersions = routingStringArray(r.selectorVersions, MAX_ROUTING_TOOL_QUESTIONS + 1);
139
160
  const receiptIds = routingStringArray(r.receiptIds, MAX_ROUTING_TOOL_QUESTIONS + 1);
140
161
  const latencyMs = routingNonNegativeNumber(r.latencyMs);
162
+ // Probability ranking is display/history metadata on reload. Any malformed or
163
+ // oversized entry drops the WHOLE field: a partially decoded ranking must
164
+ // never exist downstream, and persisted rankings are never re-finalized into
165
+ // an executable attempt plan regardless.
166
+ let rankedModels: readonly RankedModelOption[] | undefined;
167
+ if (Array.isArray(r.rankedModels) && r.rankedModels.length > 0 && r.rankedModels.length <= MAX_MODEL_ATTEMPT_RECORDS) {
168
+ const entries: RankedModelOption[] = [];
169
+ let valid = true;
170
+ for (const raw of r.rankedModels) {
171
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) { valid = false; break; }
172
+ const entry = raw as Record<string, unknown>;
173
+ const model = routingString(entry.model, MAX_ROUTING_MODEL_ID_LENGTH);
174
+ const probability = entry.probability;
175
+ if (!model || typeof probability !== "number" || !Number.isFinite(probability) || probability < 0 || probability > 1) { valid = false; break; }
176
+ entries.push(Object.freeze({ model, probability }));
177
+ }
178
+ if (valid && (r.rankedTotal === undefined || r.rankedTotal === entries.length)
179
+ && validateModelRanking(entries, entries.map((entry) => entry.model), selectedModel) === undefined) {
180
+ rankedModels = Object.freeze(entries);
181
+ }
182
+ }
141
183
 
142
184
  return Object.freeze({
143
185
  decisionId,
@@ -146,6 +188,7 @@ export function normalizeTaskRouting(value: unknown): TaskRouting | undefined {
146
188
  selectedModel,
147
189
  selectedTools,
148
190
  ...(confidence === undefined ? {} : { confidence }),
191
+ ...(rankedModels === undefined ? {} : { rankedModels }),
149
192
  selectorModel,
150
193
  ...(selectorVersion === undefined ? {} : { selectorVersion }),
151
194
  selectorVersions: selectorVersions ?? (selectorVersion ? Object.freeze([selectorVersion]) : Object.freeze([])),
@@ -343,6 +386,10 @@ export interface PersistedResult {
343
386
  attempts?: number;
344
387
  /** Models tried across attempts, in order. */
345
388
  attemptedModels?: string[];
389
+ /** Sticky tool-activity boundary state across the task's attempts. */
390
+ toolActivity?: ToolActivity;
391
+ /** Bounded ranked attempt history; previews capped, never executable. */
392
+ modelAttempts?: ModelAttemptRecord[];
346
393
  /** Parsed structured result when output_schema validated. */
347
394
  structuredOutput?: unknown;
348
395
  /** Validation errors when output_schema was requested but failed. */
@@ -429,6 +476,72 @@ function utf8Prefix(value: string, maxBytes: number): string {
429
476
  return buffer.subarray(0, end).toString("utf8");
430
477
  }
431
478
 
479
+ /**
480
+ * Bounded decode of the ranked attempt history. Any structurally invalid or
481
+ * over-cap record drops the whole optional field (never a partial ranking),
482
+ * and retained previews beyond the task total lose OLDEST text first while
483
+ * keeping metadata/session pointers. Decoded records are descriptive only:
484
+ * they never authorize execution.
485
+ */
486
+ export function normalizeModelAttempts(value: unknown): ModelAttemptRecord[] | undefined {
487
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_MODEL_ATTEMPT_RECORDS) return undefined;
488
+ const records: ModelAttemptRecord[] = [];
489
+ for (const raw of value) {
490
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
491
+ const entry = raw as Record<string, unknown>;
492
+ const attempt = routingNonNegativeInt(entry.attempt);
493
+ const rank = routingNonNegativeInt(entry.rank);
494
+ const model = routingString(entry.model, MAX_ROUTING_MODEL_ID_LENGTH);
495
+ const probability = entry.probability;
496
+ const outcome = isRunState(entry.outcome) ? entry.outcome : undefined;
497
+ if (attempt === undefined || attempt !== records.length + 1 || attempt > MAX_MODEL_ATTEMPT_RECORDS
498
+ || rank === undefined || rank >= MAX_MODEL_ATTEMPT_RECORDS || !model
499
+ || typeof probability !== "number" || !Number.isFinite(probability) || probability < 0 || probability > 1
500
+ || outcome === undefined) return undefined;
501
+ const stopReason = entry.stopReason === undefined ? undefined : routingString(entry.stopReason, 128);
502
+ if (entry.stopReason !== undefined && stopReason === undefined) return undefined;
503
+ const failureCategory = entry.failureCategory === undefined
504
+ ? undefined
505
+ : (MODEL_FAILURE_CATEGORIES as readonly string[]).includes(String(entry.failureCategory))
506
+ ? (entry.failureCategory as ModelFailureCategory)
507
+ : undefined;
508
+ const toolActivity = (TOOL_ACTIVITY_STATES as readonly string[]).includes(String(entry.toolActivity))
509
+ ? (entry.toolActivity as ToolActivity)
510
+ : undefined;
511
+ const sessionId = entry.sessionId === undefined ? undefined : routingString(entry.sessionId, MAX_ROUTING_ID_LENGTH);
512
+ if (entry.sessionId !== undefined && sessionId === undefined) return undefined;
513
+ const outputPreview = typeof entry.outputPreview === "string"
514
+ ? utf8SafePrefix(entry.outputPreview, MAX_ATTEMPT_PREVIEW_BYTES) || undefined
515
+ : undefined;
516
+ records.push({
517
+ attempt,
518
+ rank,
519
+ model,
520
+ probability,
521
+ outcome,
522
+ ...(stopReason === undefined ? {} : { stopReason }),
523
+ ...(failureCategory === undefined ? {} : { failureCategory }),
524
+ ...(toolActivity === undefined ? {} : { toolActivity }),
525
+ ...(sessionId === undefined ? {} : { sessionId }),
526
+ ...(outputPreview === undefined ? {} : { outputPreview }),
527
+ });
528
+ }
529
+ trimAttemptPreviews(records);
530
+ return records;
531
+ }
532
+
533
+ /** Bounded compatibility model chain; repeated IDs are valid for infrastructure retries. */
534
+ export function normalizeAttemptedModels(value: unknown): string[] | undefined {
535
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_MODEL_ATTEMPT_RECORDS) return undefined;
536
+ const models: string[] = [];
537
+ for (const entry of value) {
538
+ const model = routingString(entry, MAX_ROUTING_MODEL_ID_LENGTH);
539
+ if (!model) return undefined;
540
+ models.push(model);
541
+ }
542
+ return models;
543
+ }
544
+
432
545
  function normalizeResult(value: unknown): PersistedResult | undefined {
433
546
  if (!value || typeof value !== "object") return undefined;
434
547
  const r = value as Partial<PersistedResult>;
@@ -464,9 +577,11 @@ function normalizeResult(value: unknown): PersistedResult | undefined {
464
577
  wrappedUp: r.wrappedUp === true ? true : undefined,
465
578
  stalledSince: typeof r.stalledSince === "number" && Number.isFinite(r.stalledSince) ? r.stalledSince : undefined,
466
579
  attempts: typeof r.attempts === "number" && Number.isInteger(r.attempts) && r.attempts > 1 ? r.attempts : undefined,
467
- attemptedModels: Array.isArray(r.attemptedModels)
468
- ? r.attemptedModels.filter((m): m is string => typeof m === "string").slice(0, 10)
580
+ attemptedModels: normalizeAttemptedModels(r.attemptedModels),
581
+ toolActivity: (TOOL_ACTIVITY_STATES as readonly string[]).includes(String((r as { toolActivity?: unknown }).toolActivity))
582
+ ? (r as { toolActivity?: ToolActivity }).toolActivity
469
583
  : undefined,
584
+ modelAttempts: normalizeModelAttempts((r as { modelAttempts?: unknown }).modelAttempts),
470
585
  structuredOutput: r.structuredOutput !== undefined && Buffer.byteLength(JSON.stringify(r.structuredOutput) ?? "", "utf8") <= 32_768
471
586
  ? r.structuredOutput
472
587
  : undefined,
@@ -619,8 +734,15 @@ export class PersistenceLayer {
619
734
  if (typeof event.data.childSessionId === "string") ids.add(event.data.childSessionId);
620
735
  if (Array.isArray(event.data.results)) {
621
736
  for (const result of event.data.results) {
622
- const sessionId = (result as Partial<PersistedResult>)?.sessionId;
737
+ const partial = result as Partial<PersistedResult>;
738
+ const sessionId = partial?.sessionId;
623
739
  if (typeof sessionId === "string" && sessionId) ids.add(sessionId);
740
+ // Earlier ranked attempts stay referenced while their result record
741
+ // survives on the active branch, so lifecycle distillation cannot
742
+ // delete still-discoverable paid partial output.
743
+ for (const record of normalizeModelAttempts(partial?.modelAttempts) ?? []) {
744
+ if (record.sessionId) ids.add(record.sessionId);
745
+ }
624
746
  }
625
747
  }
626
748
  }
package/src/policy.ts CHANGED
@@ -3,10 +3,11 @@ import type { AgentDefinition } from "./agents.js";
3
3
  import { resolveAgent } from "./agents.js";
4
4
  import { isPlausibleSchema, repairDoubleEncodedText } from "./structured.js";
5
5
  import { defaultConfig, type TaskDefaults, type TaskDefaultsByProfile } from "./config.js";
6
- import type { OutputMode, TaskProfile, TaskSpec } from "./types.js";
6
+ import type { ModelAttemptSpec, OutputMode, TaskProfile, TaskSpec } from "./types.js";
7
7
  import type { ParallelTaskInput, SubagentParams } from "./schema.js";
8
8
  import { BACKEND_NAMES, checkCapabilities, type BackendName } from "./backend.js";
9
9
  import { resolveBackend } from "./backends/index.js";
10
+ import { validateModelRanking } from "./model-failover.js";
10
11
  import type { JevRoutingConfig, RoutingDecision, RoutingModelCandidate } from "./routing-types.js";
11
12
  import { isThinkingLevel } from "./thinking.js";
12
13
 
@@ -548,11 +549,33 @@ export function finalizeRoutedTasks(
548
549
  const tools = [...new Set([...decision.selectedTools, ...item.mandatoryTools])];
549
550
  const canWrite = tools.some((tool) => !NON_WRITING_TOOLS.has(tool));
550
551
  if (item.profile !== "general" && canWrite) return { ok: false, error: `Task ${index + 1}: writable selector choice violates ${item.profile}.` };
552
+ // The probability ranking is mandatory for every new route: without it there
553
+ // is no failover plan, and a persisted/legacy decision shape must never be
554
+ // silently re-promoted into one.
555
+ const ranked = decision.rankedModels;
556
+ const rankingProblem = validateModelRanking(ranked, models.map((entry) => entry.model), decision.selectedModel);
557
+ if (rankingProblem) return { ok: false, error: `Task ${index + 1}: ${rankingProblem}.` };
551
558
  const { candidateTools: _candidates, mandatoryTools, requestedThinking, parentThinking, ...spec } = item;
552
- const thinking = requestedThinking ?? candidate.thinking ?? parentThinking;
559
+ // One frozen attempt plan per ranked candidate: same shared tools and route,
560
+ // per-candidate thinking under explicit > agent > profile > candidate > parent.
561
+ const modelAttemptPlan: ModelAttemptSpec[] = [];
562
+ for (const entry of ranked!) {
563
+ const candidateEntry = models.find((model) => model.model === entry.model)!;
564
+ const thinking = requestedThinking ?? candidateEntry.thinking ?? parentThinking;
565
+ if (thinking !== undefined && !isThinkingLevel(thinking)) {
566
+ return { ok: false, error: `Task ${index + 1}: candidate ${JSON.stringify(entry.model)} has an invalid Pi thinking default.` };
567
+ }
568
+ modelAttemptPlan.push(Object.freeze({
569
+ model: entry.model,
570
+ probability: entry.probability,
571
+ ...(thinking === undefined ? {} : { thinking }),
572
+ }));
573
+ }
574
+ const first = modelAttemptPlan[0]!;
553
575
  tasks.push({
554
- ...spec, model: candidate.model, thinking, tools, effectiveTools: tools, canWrite,
576
+ ...spec, model: candidate.model, thinking: first.thinking, tools, effectiveTools: tools, canWrite,
555
577
  fallbackModels: [],
578
+ modelAttemptPlan: Object.freeze(modelAttemptPlan),
556
579
  routing: { ...decision, mandatoryTools: [...mandatoryTools], outcome: "success" },
557
580
  resolutionNotes: [...item.resolutionNotes.filter((note) => !note.startsWith("routing=")), "routing=jev", `access=${canWrite ? "RW" : "RO"}`],
558
581
  });
@@ -4,6 +4,7 @@ import * as fsp from "node:fs/promises";
4
4
  import * as os from "node:os";
5
5
  import * as path from "node:path";
6
6
  import { defaultConfig } from "./config.js";
7
+ import { MAX_ROUTING_MODELS } from "./routing-types.js";
7
8
 
8
9
  /**
9
10
  * Durable, crash-surviving coordination primitives under `~/.pi/subagent-locks/`.
@@ -48,6 +49,8 @@ export interface RunProcessRecord {
48
49
  runId: string;
49
50
  parentSessionKey: string;
50
51
  childSessionId?: string;
52
+ /** Ranked task's attempt sessions, protected together until final terminalization. */
53
+ childSessionIds?: string[];
51
54
  /** Live worktree checkout of this run; shields it from machine-wide GC sweeps. */
52
55
  worktreeCwd?: string;
53
56
  process: ProcessIdentity;
@@ -57,6 +60,12 @@ export interface RunProcessRecord {
57
60
  updatedAt: number;
58
61
  }
59
62
 
63
+ /** Bounded machine-wide session protection; old single-session records still work. */
64
+ export function runRecordSessionIds(record: RunProcessRecord): string[] {
65
+ const values: unknown[] = [record.childSessionId, ...(Array.isArray(record.childSessionIds) ? record.childSessionIds.slice(0, MAX_ROUTING_MODELS) : [])];
66
+ return [...new Set(values.filter((value): value is string => typeof value === "string" && value.trim().length > 0 && value.length <= 256))].slice(0, MAX_ROUTING_MODELS);
67
+ }
68
+
60
69
  export interface SlotToken {
61
70
  slotId: string;
62
71
  path: string;
@@ -582,6 +591,13 @@ export class ProcessLockManager {
582
591
  // ---- Run process records (orphan reconcile) ------------------------------
583
592
 
584
593
  writeRunRecord(record: RunProcessRecord): void {
594
+ const previous = this.readRunRecord(record.runId);
595
+ if (record.childSessionIds !== undefined || (previous?.state === "running" && previous.childSessionIds !== undefined)) {
596
+ record = { ...record, childSessionIds: [...new Set([
597
+ ...runRecordSessionIds(record),
598
+ ...(previous?.state === "running" ? runRecordSessionIds(previous) : []),
599
+ ])].slice(0, MAX_ROUTING_MODELS) };
600
+ }
585
601
  writeJsonAtomicSync(runRecordPath(this.root, record.runId), record);
586
602
  }
587
603
 
package/src/protocol.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { Message } from "@earendil-works/pi-ai";
2
- import type { TaskResult, UsageStats } from "./types.js";
2
+ import type { TaskResult, ToolActivity, UsageStats } from "./types.js";
3
3
  import { emptyUsage } from "./types.js";
4
4
  import { addUsage, hasBilledUsage, usageFromMessage, usageFromToolResultMessage } from "./usage.js";
5
+ import { extractProviderError, mergeToolActivity } from "./model-failover.js";
5
6
  import { PREFLIGHT_ACK_TYPE } from "./startup-check.js";
6
7
 
7
8
  export type ProtocolUpdate =
@@ -33,6 +34,7 @@ export class ProtocolParser {
33
34
  private messages: Message[] = [];
34
35
  private usage = emptyUsage();
35
36
  private liveText = "";
37
+ private assistantText?: string;
36
38
  private parseErrors = 0;
37
39
  private validEvents = 0;
38
40
  private headerSeen = false;
@@ -43,6 +45,25 @@ export class ProtocolParser {
43
45
  private model?: string;
44
46
  private stopReason?: string;
45
47
  private errorMessage?: string;
48
+ /**
49
+ * Sticky current-invocation tool activity for the pre-tool switch boundary. Latches `started` on
50
+ * tool_execution_start, a completed assistant toolCall or a toolResult; any
51
+ * malformed/dropped protocol evidence raises an otherwise-clean `none` to
52
+ * `unknown`. A later complete event never erases started/unknown and events
53
+ * from resumed/forked history never reach this parser at all (only this
54
+ * child's live event stream does), so historical tool records cannot create
55
+ * false live activity.
56
+ */
57
+ private toolActivity: ToolActivity = "none";
58
+ /** stopReason of the latest completed assistant message (settle diagnosis). */
59
+ private lastAssistantStopReason?: string;
60
+ /**
61
+ * Bounded errorMessage and primitive diagnostics.error.code from the latest
62
+ * completed assistant error, the only availability-classification input.
63
+ * Replaced on every completed assistant message; never mixed with normal
64
+ * content, arbitrary diagnostics or runner/RPC fallback text.
65
+ */
66
+ private providerError?: string;
46
67
  private transcriptLines: string[] = [];
47
68
  private transcriptBytes = 0;
48
69
  private transcriptTruncated = false;
@@ -53,6 +74,49 @@ export class ProtocolParser {
53
74
  private static readonly MAX_LINE_BYTES = 4 * 1024 * 1024;
54
75
  private static readonly MAX_BUFFER_BYTES = 8 * 1024 * 1024;
55
76
 
77
+ /** Sticky failover-latch merge; never lowers an existing started/unknown mark. */
78
+ private noteToolActivity(observed: ToolActivity): void {
79
+ this.toolActivity = mergeToolActivity(this.toolActivity, observed);
80
+ }
81
+
82
+ /**
83
+ * Conservative streamed assistant delta observations: a started tool call is
84
+ * completed-call evidence, its end too; a delta without any start proves the
85
+ * model was emitting a call and the outcome may have been lost — never a
86
+ * conclusive `none`.
87
+ */
88
+ private observeAssistantDelta(deltaEvent: unknown): void {
89
+ const event = deltaEvent as Record<string, unknown> | undefined;
90
+ if (!event || typeof event !== "object" || typeof event.type !== "string") {
91
+ this.noteMalformedEvidence();
92
+ return;
93
+ }
94
+ if (event.type === "toolcall_start" || event.type === "toolcall_end") {
95
+ // Compact RPC retains contentIndex for both start/end; Pi enriches
96
+ // start with id/toolName, while end carries a nested toolCall. Accept
97
+ // the indexed form as well as identity-bearing legacy events.
98
+ const modern = typeof event.contentIndex === "number" && Number.isInteger(event.contentIndex) && event.contentIndex >= 0;
99
+ const legacy = typeof event.id === "string" && !!event.id && typeof event.toolName === "string" && !!event.toolName;
100
+ if (modern || legacy) this.noteToolActivity("started");
101
+ else this.noteMalformedEvidence();
102
+ return;
103
+ }
104
+ if (event.type === "toolcall_delta") {
105
+ if (typeof event.contentIndex === "number" && Number.isInteger(event.contentIndex) && event.contentIndex >= 0) {
106
+ this.noteToolActivity("unknown");
107
+ } else {
108
+ this.noteMalformedEvidence();
109
+ }
110
+ }
111
+ }
112
+
113
+ /** Malformed/dropped evidence: counts a parse error and blocks failover. */
114
+ private noteMalformedEvidence(): void {
115
+ this.parseErrors++;
116
+ // A later complete event cannot erase the uncertainty this creates.
117
+ this.noteToolActivity("unknown");
118
+ }
119
+
56
120
  /** Append transcript lines incrementally; never re-flattens the full message list. */
57
121
  private appendTranscript(lines: string[]): void {
58
122
  for (const line of lines) {
@@ -92,7 +156,7 @@ export class ProtocolParser {
92
156
  this.buffer += data.toString();
93
157
  // If a single line grows past the hard limit without a newline, drop it as a parse error.
94
158
  if (Buffer.byteLength(this.buffer, "utf8") > ProtocolParser.MAX_BUFFER_BYTES) {
95
- this.parseErrors++;
159
+ this.noteMalformedEvidence();
96
160
  this.buffer = "";
97
161
  return [];
98
162
  }
@@ -113,25 +177,25 @@ export class ProtocolParser {
113
177
  const trimmed = line.trim();
114
178
  if (!trimmed) return [];
115
179
  if (Buffer.byteLength(trimmed, "utf8") > ProtocolParser.MAX_LINE_BYTES) {
116
- this.parseErrors++;
180
+ this.noteMalformedEvidence();
117
181
  return [];
118
182
  }
119
183
  let event: any;
120
184
  try {
121
185
  event = JSON.parse(trimmed);
122
186
  } catch {
123
- this.parseErrors++;
187
+ this.noteMalformedEvidence();
124
188
  return [];
125
189
  }
126
190
  if (!event || typeof event !== "object" || typeof event.type !== "string") {
127
- this.parseErrors++;
191
+ this.noteMalformedEvidence();
128
192
  return [];
129
193
  }
130
194
  this.validEvents++;
131
195
 
132
196
  if (event.type === "session") {
133
197
  if (typeof event.id !== "string" || !event.id) {
134
- this.parseErrors++;
198
+ this.noteMalformedEvidence();
135
199
  return [];
136
200
  }
137
201
  this.sessionId = event.id;
@@ -174,10 +238,63 @@ export class ProtocolParser {
174
238
  return dialog && typeof event.id === "string" ? [{ type: "ui-request", id: event.id }] : [];
175
239
  }
176
240
 
177
- if (event.type === "message_update" && event.message?.role === "assistant") {
241
+ // Tool execution begin is the pre-tool switch boundary. Pi emits it before tool
242
+ // preparation (including some rejected/truncated calls); that conservative
243
+ // start is still the no-restart line. Only shape-valid events latch
244
+ // `started`; a malformed start event means a tool may have begun anyway, so
245
+ // it raises `unknown`, which blocks failover just like a proven start.
246
+ if (event.type === "tool_execution_start") {
247
+ if (typeof event.toolCallId === "string" && event.toolCallId
248
+ && typeof event.toolName === "string" && event.toolName) {
249
+ this.noteToolActivity("started");
250
+ } else {
251
+ this.noteMalformedEvidence();
252
+ }
253
+ return [];
254
+ }
255
+
256
+ // Update/end events prove execution progressed even when the start event
257
+ // was lost or the stream was truncated; neither may leave a `none` latch.
258
+ if (event.type === "tool_execution_update" || event.type === "tool_execution_end") {
259
+ if (typeof event.toolCallId === "string" && event.toolCallId
260
+ && typeof event.toolName === "string" && event.toolName) {
261
+ this.noteToolActivity("started");
262
+ } else {
263
+ this.noteMalformedEvidence();
264
+ }
265
+ return [];
266
+ }
267
+
268
+ // Modern RPC message_update events are compact: {usage, assistantMessageEvent}
269
+ // without the full message. Every message_update must carry a readable
270
+ // assistant envelope or a typed delta event; one without either is dropped
271
+ // evidence that could hide a tool call or the terminal error.
272
+ if (event.type === "message_update") {
273
+ const hasMessage = !!event.message && typeof event.message === "object" && !Array.isArray(event.message);
274
+ const hasDeltaEvent = !!event.assistantMessageEvent && typeof event.assistantMessageEvent === "object" && !Array.isArray(event.assistantMessageEvent);
275
+ if (!hasMessage && !hasDeltaEvent) {
276
+ this.noteMalformedEvidence();
277
+ return [];
278
+ }
279
+ if (hasDeltaEvent) this.observeAssistantDelta(event.assistantMessageEvent);
280
+ if (!hasMessage || event.message.role !== "assistant") {
281
+ // Compact streamed turn (no full message), or an unexpected envelope:
282
+ // the delta observation above already recorded whatever is provable.
283
+ return [];
284
+ }
285
+ // An assistant stream envelope whose content array carries unreadable
286
+ // parts could hide a tool call: uncertain, never a conclusive none.
287
+ if (event.message.content !== undefined
288
+ && (!Array.isArray(event.message.content)
289
+ || (event.message.content as unknown[]).some((part: any) => !part || typeof part !== "object" || Array.isArray(part)))) {
290
+ this.noteMalformedEvidence();
291
+ }
292
+ if (Array.isArray(event.message.content) && event.message.content.some((part: any) => part?.type === "toolCall")) {
293
+ this.noteToolActivity("started");
294
+ }
178
295
  const delta =
179
- typeof event.assistantMessageEvent?.delta === "string"
180
- ? event.assistantMessageEvent.delta
296
+ typeof (event.assistantMessageEvent as any)?.delta === "string"
297
+ ? (event.assistantMessageEvent as any).delta
181
298
  : this.textParts(event.message).join("");
182
299
  if (!delta) return [];
183
300
  // Cap live text growth: keep the last 64KB of visible text so long runs stay bounded.
@@ -185,24 +302,68 @@ export class ProtocolParser {
185
302
  return [{ type: "live-text", delta, liveText: this.liveText }];
186
303
  }
187
304
 
188
- if (event.type === "message_end" && event.message) {
305
+ if (event.type === "message_end") {
306
+ // A message_end without a readable message object could have carried the
307
+ // final assistant error or a tool call; that uncertainty must not leave a
308
+ // conclusive `none` for the failover gate.
309
+ if (!event.message || typeof event.message !== "object" || Array.isArray(event.message)) {
310
+ this.noteMalformedEvidence();
311
+ return [];
312
+ }
189
313
  const message = event.message as Message & { customType?: unknown };
190
314
  // Startup-handshake acknowledgement: report it, but never fold verification
191
315
  // traffic into messages, transcript, live text or usage accounting.
192
316
  if (message.customType === PREFLIGHT_ACK_TYPE) {
193
317
  return [{ type: "preflight-ack", content: typeof (message as any).content === "string" ? (message as any).content : "" }];
194
318
  }
319
+ if (typeof message.role !== "string" || !message.role) {
320
+ // Roleless message envelope: nothing about it is trustworthy.
321
+ this.noteMalformedEvidence();
322
+ return [];
323
+ }
195
324
  this.messages.push(message);
196
325
  this.appendTranscript(this.transcriptFromMessage(message));
197
326
  if (message.role === "assistant") {
327
+ // Every new completed message needs a fresh terminal watermark, even
328
+ // when an older host omits agent_start between task and repair turns.
329
+ this.agentEndSeen = false;
330
+ this.agentSettledSeen = false;
198
331
  this.assistantEndSeen = true;
199
332
  this.usage = addUsage(this.usage, usageFromMessage(message));
200
333
  this.model ||= message.model;
201
334
  this.stopReason = message.stopReason;
202
335
  this.errorMessage = message.errorMessage;
336
+ const partsValid = Array.isArray(message.content) && (message.content as unknown[]).every((part: any) => !!part && typeof part === "object" && !Array.isArray(part));
337
+ const validShape = partsValid && typeof message.stopReason === "string";
338
+ this.lastAssistantStopReason = validShape ? message.stopReason : undefined;
339
+ if (!validShape) {
340
+ // Unreadable assistant content parts, non-array content, or a
341
+ // missing/non-string stopReason: a tool call or terminal error could
342
+ // be hidden in the unreadable shape, so activity becomes uncertain
343
+ // and no error evidence is kept.
344
+ this.noteMalformedEvidence();
345
+ this.providerError = undefined;
346
+ } else {
347
+ const toolCallParts = (message.content as any[]).some((part: any) => part?.type === "toolCall");
348
+ if (toolCallParts) {
349
+ // A completed assistant tool call is conservative execution evidence:
350
+ // a missing tool_execution_start/result must not authorize a restart.
351
+ this.noteToolActivity("started");
352
+ } else if (message.stopReason === "toolUse" || message.stopReason === "refusal") {
353
+ // Claims turn continuation on tools without a readable tool call,
354
+ // or a refusal shape we cannot interpret: uncertain by contract.
355
+ this.noteMalformedEvidence();
356
+ }
357
+ this.providerError = message.stopReason === "error"
358
+ ? extractProviderError(message.errorMessage, message.diagnostics)
359
+ : undefined;
360
+ }
203
361
  const text = this.textParts(message).join("");
362
+ this.assistantText = text; // Empty latest messages must replace earlier failed text.
204
363
  if (text) this.liveText = text;
205
364
  } else if (message.role === "toolResult") {
365
+ // Any completed tool result proves execution happened in this invocation.
366
+ this.noteToolActivity("started");
206
367
  // Pi ≥ #6671: tool results may carry nested LLM usage (e.g. a
207
368
  // grandchild subagent). Fold it into the run's cumulative spend so
208
369
  // budgets and parent ledgers see true subtree cost.
@@ -214,6 +375,13 @@ export class ProtocolParser {
214
375
 
215
376
  if (event.type === "agent_end") {
216
377
  this.agentEndSeen = true;
378
+ // `willRetry` must be a real boolean when present: a string "true" or any
379
+ // other shape means the settle boundary is unreadable, which is uncertain
380
+ // execution evidence and must block failover, never imply "no retry".
381
+ if (event.willRetry !== undefined && typeof event.willRetry !== "boolean") {
382
+ this.noteMalformedEvidence();
383
+ return [];
384
+ }
217
385
  // Pi may retry after agent_end (willRetry: true). That is NOT terminal;
218
386
  // only agent_settled marks a fully settled run.
219
387
  const willRetry = event.willRetry === true;
@@ -221,6 +389,18 @@ export class ProtocolParser {
221
389
  return [{ type: "agent-end", willRetry }];
222
390
  }
223
391
 
392
+ // A new live agent turn invalidates any earlier settle watermark: the run
393
+ // is running again, and only a NEW agent_end-without-retry or
394
+ // agent_settled may complete it. Without this reset, an old settle plus a
395
+ // later provider retry could fake a complete protocol around the newest
396
+ // terminal error.
397
+ if (event.type === "agent_start") {
398
+ this.agentEndSeen = false;
399
+ this.agentSettledSeen = false;
400
+ this.pendingRetry = false;
401
+ return [];
402
+ }
403
+
224
404
  if (event.type === "agent_settled") {
225
405
  this.agentSettledSeen = true;
226
406
  this.pendingRetry = false;
@@ -251,7 +431,7 @@ export class ProtocolParser {
251
431
  // Prefer agent_settled as the true terminal watermark. Fall back to
252
432
  // agent_end without a pending retry for older Pi builds that never emitted
253
433
  // agent_settled (json print historically closed after agent_end).
254
- const settled = this.agentSettledSeen || (this.agentEndSeen && !this.pendingRetry);
434
+ const settled = !this.pendingRetry && (this.agentSettledSeen || this.agentEndSeen);
255
435
  const completeProtocol = this.headerSeen && this.assistantEndSeen && settled;
256
436
  const assistantFailed = this.stopReason === "error" || this.stopReason === "aborted";
257
437
  const successfulExit = exitCode === 0 && !signal && !assistantFailed;
@@ -299,6 +479,8 @@ export class ProtocolParser {
299
479
  : undefined),
300
480
  liveText: this.liveText || undefined,
301
481
  transcript: this.getTranscript(),
482
+ toolActivity: this.toolActivity,
483
+ providerError: this.providerError,
302
484
  protocol,
303
485
  sessionId: this.sessionId,
304
486
  };
@@ -317,4 +499,19 @@ export class ProtocolParser {
317
499
  getMessages(): Message[] {
318
500
  return [...this.messages];
319
501
  }
502
+
503
+ /** Sticky current-invocation tool activity observed by this parser. */
504
+ getToolActivity(): ToolActivity {
505
+ return this.toolActivity;
506
+ }
507
+
508
+ /** stopReason of the latest completed assistant message, if any. */
509
+ getAssistantStopReason(): string | undefined {
510
+ return this.lastAssistantStopReason;
511
+ }
512
+
513
+ /** Latest completed assistant text, including an explicitly empty message. */
514
+ getAssistantText(): string | undefined {
515
+ return this.assistantText;
516
+ }
320
517
  }