@oh-my-pi/pi-coding-agent 17.2.12 → 17.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/{CHANGELOG-k9ghy5sn.md → CHANGELOG-d8xh7keh.md} +57 -0
  3. package/dist/cli.js +3069 -3047
  4. package/dist/types/advisor/delta-split.d.ts +24 -0
  5. package/dist/types/advisor/runtime.d.ts +2 -2
  6. package/dist/types/async/job-manager.d.ts +8 -1
  7. package/dist/types/cli/update-cli.d.ts +53 -1
  8. package/dist/types/config/keybindings.d.ts +10 -0
  9. package/dist/types/config/model-resolver.d.ts +15 -2
  10. package/dist/types/config/settings-schema.d.ts +4 -0
  11. package/dist/types/discovery/agents-md.d.ts +10 -1
  12. package/dist/types/eval/runner-cache.d.ts +12 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +12 -3
  14. package/dist/types/extensibility/extensions/types.d.ts +15 -4
  15. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +4 -1
  16. package/dist/types/lib/xai-http.d.ts +0 -1
  17. package/dist/types/mcp/tool-bridge.d.ts +8 -5
  18. package/dist/types/modes/components/agent-hub-renderer.d.ts +6 -1
  19. package/dist/types/modes/components/status-line/types.d.ts +4 -0
  20. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -4
  21. package/dist/types/modes/interactive-mode.d.ts +17 -8
  22. package/dist/types/modes/types.d.ts +7 -10
  23. package/dist/types/modes/utils/hotkeys-markdown.d.ts +1 -1
  24. package/dist/types/session/agent-session-types.d.ts +2 -0
  25. package/dist/types/session/agent-session.d.ts +22 -3
  26. package/dist/types/session/messages.d.ts +20 -0
  27. package/dist/types/session/retry-fallback-chains.d.ts +13 -0
  28. package/dist/types/session/session-advisors.d.ts +1 -1
  29. package/dist/types/session/session-history-format.d.ts +10 -0
  30. package/dist/types/session/session-maintenance.d.ts +1 -1
  31. package/dist/types/session/session-tools.d.ts +24 -5
  32. package/dist/types/session/turn-recovery.d.ts +34 -5
  33. package/dist/types/slash-commands/types.d.ts +5 -1
  34. package/dist/types/task/executor.d.ts +1 -1
  35. package/dist/types/tools/approval.d.ts +7 -0
  36. package/dist/types/tools/todo.d.ts +14 -15
  37. package/dist/types/tools/write.d.ts +2 -2
  38. package/dist/types/utils/local-date.d.ts +2 -0
  39. package/dist/types/vibe/runtime.d.ts +1 -1
  40. package/dist/types/web/parallel.d.ts +1 -0
  41. package/dist/types/web/search/providers/brave.d.ts +8 -3
  42. package/dist/types/web/search/providers/codex.d.ts +6 -0
  43. package/dist/types/web/search/providers/firecrawl.d.ts +3 -2
  44. package/dist/types/web/search/providers/jina.d.ts +3 -3
  45. package/dist/types/web/search/providers/parallel.d.ts +1 -0
  46. package/dist/types/web/search/providers/perplexity.d.ts +4 -0
  47. package/dist/types/web/search/providers/tinyfish.d.ts +2 -0
  48. package/package.json +13 -13
  49. package/src/advisor/delta-split.ts +98 -0
  50. package/src/advisor/runtime.ts +321 -69
  51. package/src/async/job-manager.ts +14 -3
  52. package/src/cli/plugin-cli.ts +30 -2
  53. package/src/cli/update-cli.ts +259 -24
  54. package/src/config/keybindings.ts +52 -9
  55. package/src/config/model-resolver.ts +19 -3
  56. package/src/config/settings-schema.ts +5 -0
  57. package/src/cursor.ts +10 -5
  58. package/src/discovery/agents-md.ts +61 -23
  59. package/src/eval/jl/kernel.ts +2 -20
  60. package/src/eval/py/kernel.ts +2 -20
  61. package/src/eval/rb/kernel.ts +2 -20
  62. package/src/eval/runner-cache.ts +41 -0
  63. package/src/exec/non-interactive-env.ts +14 -3
  64. package/src/extensibility/extensions/loader.ts +5 -2
  65. package/src/extensibility/extensions/runner.ts +184 -66
  66. package/src/extensibility/extensions/types.ts +26 -2
  67. package/src/extensibility/extensions/wrapper.ts +13 -7
  68. package/src/extensibility/plugins/marketplace/manager.ts +6 -2
  69. package/src/hindsight/client.ts +1 -1
  70. package/src/lib/xai-http.ts +0 -4
  71. package/src/lsp/client.ts +2 -0
  72. package/src/lsp/servers.ts +1 -1
  73. package/src/mcp/tool-bridge.ts +15 -6
  74. package/src/modes/components/agent-hub-renderer.ts +9 -3
  75. package/src/modes/components/agent-hub.ts +2 -1
  76. package/src/modes/components/status-line/component.ts +58 -6
  77. package/src/modes/components/status-line/segments.ts +12 -1
  78. package/src/modes/components/status-line/types.ts +1 -0
  79. package/src/modes/components/user-message.ts +20 -5
  80. package/src/modes/controllers/event-controller.ts +48 -0
  81. package/src/modes/controllers/extension-ui-controller.ts +14 -7
  82. package/src/modes/controllers/input-controller.ts +25 -6
  83. package/src/modes/interactive-mode.ts +315 -129
  84. package/src/modes/rpc/rpc-frame.ts +13 -5
  85. package/src/modes/theme/tui-adapters.ts +4 -5
  86. package/src/modes/types.ts +13 -7
  87. package/src/modes/utils/hotkeys-markdown.ts +10 -6
  88. package/src/prompts/system/system-prompt.md +1 -1
  89. package/src/registry/persisted-agents.ts +43 -8
  90. package/src/sdk.ts +139 -8
  91. package/src/session/agent-session-types.ts +2 -0
  92. package/src/session/agent-session.ts +66 -10
  93. package/src/session/messages.ts +98 -28
  94. package/src/session/retry-fallback-chains.ts +14 -0
  95. package/src/session/session-advisors.ts +32 -15
  96. package/src/session/session-history-format.ts +15 -1
  97. package/src/session/session-maintenance.ts +8 -8
  98. package/src/session/session-manager.ts +6 -2
  99. package/src/session/session-tools.ts +321 -184
  100. package/src/session/turn-recovery.ts +225 -47
  101. package/src/slash-commands/builtin-modes.ts +41 -12
  102. package/src/slash-commands/types.ts +5 -1
  103. package/src/task/executor.ts +92 -45
  104. package/src/task/structured-subagent.ts +5 -5
  105. package/src/tools/approval.ts +44 -10
  106. package/src/tools/fetch.ts +21 -2
  107. package/src/tools/image-gen.ts +6 -8
  108. package/src/tools/todo.ts +70 -26
  109. package/src/tools/tts.ts +3 -2
  110. package/src/tools/write.ts +7 -3
  111. package/src/utils/local-date.ts +13 -0
  112. package/src/utils/tools-manager.ts +2 -2
  113. package/src/vibe/runtime.ts +22 -14
  114. package/src/web/kagi.ts +91 -34
  115. package/src/web/parallel.ts +11 -2
  116. package/src/web/scrapers/crates-io.ts +2 -2
  117. package/src/web/scrapers/discogs.ts +2 -2
  118. package/src/web/scrapers/docs-rs.ts +2 -2
  119. package/src/web/scrapers/github.ts +2 -2
  120. package/src/web/scrapers/musicbrainz.ts +1 -2
  121. package/src/web/scrapers/pubmed.ts +2 -2
  122. package/src/web/scrapers/sec-edgar.ts +2 -2
  123. package/src/web/search/providers/brave.ts +121 -46
  124. package/src/web/search/providers/codex.ts +88 -12
  125. package/src/web/search/providers/exa.ts +45 -10
  126. package/src/web/search/providers/firecrawl.ts +53 -11
  127. package/src/web/search/providers/gemini.ts +139 -27
  128. package/src/web/search/providers/jina.ts +48 -25
  129. package/src/web/search/providers/parallel.ts +23 -9
  130. package/src/web/search/providers/perplexity.ts +24 -7
  131. package/src/web/search/providers/searxng.ts +77 -1
  132. package/src/web/search/providers/tavily.ts +23 -22
  133. package/src/web/search/providers/tinyfish.ts +44 -10
  134. package/src/web/search/providers/xai.ts +85 -14
@@ -98,7 +98,7 @@ import {
98
98
  withTimeout,
99
99
  } from "@oh-my-pi/pi-utils";
100
100
  import { type AdvisorConfig, type AdvisorRuntimeStatus, loadAdvisorTranscriptCosts } from "../advisor";
101
- import { type AsyncJob, AsyncJobManager } from "../async";
101
+ import { ASYNC_JOB_MANAGER_SHUTDOWN_REASON, type AsyncJob, AsyncJobManager } from "../async";
102
102
  import { shouldEnableAppendOnlyContext } from "../config/append-only-context-mode";
103
103
  import type { ModelRegistry } from "../config/model-registry";
104
104
  import type { ResolvedModelRoleValue } from "../config/model-resolver";
@@ -313,6 +313,7 @@ import {
313
313
  queueChipText,
314
314
  toRestoredQueuedMessage,
315
315
  } from "./queued-messages";
316
+ import type { ServingModel } from "./retry-fallback-chains";
316
317
  import { type AdvisorStats, SessionAdvisors, type SessionAdvisorsHost } from "./session-advisors";
317
318
  import type { BuildSessionContextOptions, SessionContext } from "./session-context";
318
319
  import { getRestorableSessionModels } from "./session-context";
@@ -1242,6 +1243,7 @@ export class AgentSession {
1242
1243
  createComputerTool: config.createComputerTool,
1243
1244
  createInspectImageTool: config.createInspectImageTool,
1244
1245
  builtInToolNames: config.builtInToolNames,
1246
+ mcpManagerToolNames: config.mcpManagerToolNames,
1245
1247
  presentationPinnedToolNames: config.presentationPinnedToolNames,
1246
1248
  ensureWriteRegistered: config.ensureWriteRegistered,
1247
1249
  rebuildSystemPrompt: config.rebuildSystemPrompt,
@@ -1488,7 +1490,7 @@ export class AgentSession {
1488
1490
  this.#planReferenceSent = false;
1489
1491
  },
1490
1492
  syncTodoPhasesFromBranch: () => this.#todo.syncFromBranch(),
1491
- resetAdvisorRuntimes: () => this.#advisors.resetAllRuntimes(),
1493
+ resetAdvisorRuntimes: (reason?: string) => this.#advisors.resetAllRuntimes(reason),
1492
1494
  rebaseAfterCompaction: () => this.#stats.rebaseAfterCompaction(),
1493
1495
  recordAnchoredHistoryRewrite: tokensRemoved => this.#stats.recordAnchoredHistoryRewrite(tokensRemoved),
1494
1496
  getContextBreakdown: options => this.getContextBreakdown(options),
@@ -1783,10 +1785,10 @@ export class AgentSession {
1783
1785
  *
1784
1786
  * No-op when no manager is reachable or this session has no agent id.
1785
1787
  */
1786
- #cancelOwnAsyncJobs(): void {
1788
+ #cancelOwnAsyncJobs(reason?: unknown): void {
1787
1789
  if (!this.#agentId) return;
1788
1790
  const manager = this.#asyncJobManager;
1789
- manager?.cancelAll({ ownerId: this.#agentId });
1791
+ manager?.cancelAll({ ownerId: this.#agentId }, reason);
1790
1792
  manager?.evictCompletedJobs({ ownerId: this.#agentId });
1791
1793
  // Invalidate this owner's in-flight/drained deliveries against the new
1792
1794
  // generation, then drop any async-result follow-up already queued, so a
@@ -3743,8 +3745,14 @@ export class AgentSession {
3743
3745
  // dead-letter rather than enqueue a follow-up into a disposing session.
3744
3746
  this.#unregisterAsyncDeliverySink?.();
3745
3747
  this.#unregisterAsyncDeliverySink = undefined;
3746
- this.#cancelOwnAsyncJobs();
3747
3748
  const manager = this.#ownedAsyncJobManager;
3749
+ // The shutdown reason is reserved for the top-level session that OWNS the
3750
+ // manager — the genuine process/handled-shutdown path — so the task
3751
+ // executor parks (rather than tombstones) interrupted subagents. A
3752
+ // subagent session dispose (e.g. `release({ tombstone: true })` during an
3753
+ // explicit hard kill) leaves `#ownedAsyncJobManager` undefined and must
3754
+ // propagate a generic cancellation so its nested children stay terminal.
3755
+ this.#cancelOwnAsyncJobs(manager ? ASYNC_JOB_MANAGER_SHUTDOWN_REASON : undefined);
3748
3756
  if (!manager) return;
3749
3757
 
3750
3758
  try {
@@ -4097,9 +4105,13 @@ export class AgentSession {
4097
4105
  return this.agent.state.model;
4098
4106
  }
4099
4107
 
4100
- /** Resolved selector while retry routing is using a fallback model. */
4101
- get retryFallbackModel(): string | undefined {
4102
- return this.#recovery.retryFallbackModel;
4108
+ /**
4109
+ * Model this session's produced work is attributed to. Holds the last model
4110
+ * that actually served while a fallback is armed but unproven, so observers
4111
+ * never credit a run to a candidate that produced nothing.
4112
+ */
4113
+ get servingModel(): ServingModel | undefined {
4114
+ return this.#recovery.servingModel;
4103
4115
  }
4104
4116
 
4105
4117
  /** Install the interactive decision surface for reserve-triggered model changes. */
@@ -4296,6 +4308,41 @@ export class AgentSession {
4296
4308
  return this.#tools.hasBuiltInTool(name);
4297
4309
  }
4298
4310
 
4311
+ /** Updates source provenance when a live registry entry is replaced or restored. */
4312
+ setToolBuiltIn(name: string, builtIn: boolean): void {
4313
+ this.#tools.setToolBuiltIn(name, builtIn);
4314
+ }
4315
+
4316
+ /** Whether the live registry entry is owned by the RPC host. */
4317
+ hasRpcHostTool(name: string): boolean {
4318
+ return this.#tools.hasRpcHostTool(name);
4319
+ }
4320
+
4321
+ /** Whether the current MCP entry came from the manager snapshot. */
4322
+ hasMCPManagerTool(name: string): boolean {
4323
+ return this.#tools.hasMCPManagerTool(name);
4324
+ }
4325
+
4326
+ /** Restores manager ownership after a lifecycle registration rollback. */
4327
+ setMCPManagerTool(name: string, managerOwned: boolean): void {
4328
+ this.#tools.setMCPManagerTool(name, managerOwned);
4329
+ }
4330
+
4331
+ /** Current extension-owned MCP entry retained across manager refreshes. */
4332
+ getExtensionMCPTool(name: string): AgentTool | undefined {
4333
+ return this.#tools.getExtensionMCPTool(name);
4334
+ }
4335
+
4336
+ /** Updates extension MCP ownership after a lifecycle registration commit or rollback. */
4337
+ setExtensionMCPTool(name: string, tool: AgentTool | undefined): void {
4338
+ this.#tools.setExtensionMCPTool(name, tool);
4339
+ }
4340
+
4341
+ /** Runs a registry/presentation mutation in this session's shared queue. */
4342
+ runToolRegistryMutation<T>(mutation: () => Promise<T>, signal?: AbortSignal): Promise<T> {
4343
+ return this.#tools.runToolRegistryMutation(mutation, signal);
4344
+ }
4345
+
4299
4346
  /** Names of every registered tool. */
4300
4347
  getAllToolNames(): string[] {
4301
4348
  return this.#tools.getAllToolNames();
@@ -4349,8 +4396,13 @@ export class AgentSession {
4349
4396
  }
4350
4397
 
4351
4398
  /** Restores an exact top-level versus `xd://` tool partition. */
4352
- setActiveToolPresentation(toolNames: string[], mountedToolNames: string[]): Promise<void> {
4353
- return this.#tools.setActiveToolPresentation(toolNames, mountedToolNames);
4399
+ setActiveToolPresentation(
4400
+ toolNames: string[],
4401
+ mountedToolNames: string[],
4402
+ forcePromptRefresh = false,
4403
+ signal?: AbortSignal,
4404
+ ): Promise<void> {
4405
+ return this.#tools.setActiveToolPresentation(toolNames, mountedToolNames, forcePromptRefresh, signal);
4354
4406
  }
4355
4407
 
4356
4408
  /**
@@ -6497,6 +6549,7 @@ export class AgentSession {
6497
6549
  async fork(): Promise<boolean> {
6498
6550
  this.#assertVibeSessionTransitionAllowed("fork the session");
6499
6551
  const previousSessionFile = this.sessionFile;
6552
+ const previousSessionId = this.sessionManager.getSessionId();
6500
6553
 
6501
6554
  // Emit session_before_switch event with reason "fork" (can be cancelled)
6502
6555
  if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
@@ -6535,6 +6588,9 @@ export class AgentSession {
6535
6588
  }
6536
6589
  this.#bash.markSessionTransition(bashTransition);
6537
6590
  this.#bash.finishSessionTransition(bashTransition, true);
6591
+ // The fork clones the transcript and keeps this recovery state running
6592
+ // under a fresh id, so the work already produced is still this session's.
6593
+ this.#recovery.reanchorServedAttribution(previousSessionId);
6538
6594
 
6539
6595
  // Copy artifacts directory if it exists
6540
6596
  const oldArtifactDir = forkResult.oldSessionFile.slice(0, -6);
@@ -498,6 +498,76 @@ export function isEmptyErrorTurn(message: Pick<AssistantMessage, "stopReason" |
498
498
  });
499
499
  }
500
500
 
501
+ /** Non-whitespace text. Tolerates malformed blocks: transcripts replayed off
502
+ * disk predate current shapes, and a missing field must not throw. */
503
+ function hasText(content: { text?: unknown }): boolean {
504
+ return typeof content.text === "string" && content.text.trim().length > 0;
505
+ }
506
+
507
+ /**
508
+ * A block that is real output from the model.
509
+ *
510
+ * Everything the assistant can emit counts except two: unsigned thinking, which
511
+ * is not provider-authenticated and not actionable, and Anthropic's `fallback`
512
+ * marker, which records that the request was routed elsewhere rather than
513
+ * carrying any output. A native image response often arrives with no text and
514
+ * no tool call at all, so recognising only those would call it nothing.
515
+ */
516
+ function isActionableContent(content: AssistantMessage["content"][number] | undefined): boolean {
517
+ switch (content?.type) {
518
+ case "toolCall":
519
+ case "image":
520
+ case "redactedThinking":
521
+ case "anthropicServerTool":
522
+ return true;
523
+ case "text":
524
+ return hasText(content);
525
+ case "thinking":
526
+ return typeof content.thinkingSignature === "string" && content.thinkingSignature.trim().length > 0;
527
+ default:
528
+ return false;
529
+ }
530
+ }
531
+
532
+ /** A `stop`/`toolUse` turn that produced nothing actionable. Any other stop
533
+ * reason is not an "empty stop": an `error`/`aborted` turn is a failure rather
534
+ * than an empty completion, and a `length` stop was cut off mid-output. */
535
+ export function isEmptyAssistantStop(message: Pick<AssistantMessage, "stopReason" | "content">): boolean {
536
+ switch (message.stopReason) {
537
+ case "stop":
538
+ return !message.content.some(isActionableContent);
539
+ case "toolUse":
540
+ // An orphaned toolUse stop (no tool_use block) corrupts Anthropic history:
541
+ // a later tool_result has nothing to anchor to. Thinking alone cannot anchor
542
+ // a tool_result, so it does not rescue a toolUse stop here.
543
+ return !message.content.some(
544
+ content => content?.type === "toolCall" || (content?.type === "text" && hasText(content)),
545
+ );
546
+ default:
547
+ return false;
548
+ }
549
+ }
550
+
551
+ /**
552
+ * True when this assistant turn actually produced output, making its model the
553
+ * one that served the run.
554
+ *
555
+ * Attribution asks this from two places that MUST reach the same verdict: the
556
+ * live session, which flips a fallback to "served", and the offline walk
557
+ * replaying a transcript. `error` and `aborted` are both failures — a stalled or
558
+ * dropped stream is finalized as `aborted` with its partial block still
559
+ * attached, so a stop reason alone is not proof.
560
+ *
561
+ * Actionable content is required on top of the empty-stop rule, which only
562
+ * inspects `stop`/`toolUse`. A `length` stop burns the whole output budget
563
+ * without necessarily emitting anything usable, and every other stop reason
564
+ * bypasses that rule entirely.
565
+ */
566
+ export function assistantTurnProducedOutput(message: Pick<AssistantMessage, "stopReason" | "content">): boolean {
567
+ if (message.stopReason === "error" || message.stopReason === "aborted") return false;
568
+ return !isEmptyAssistantStop(message) && message.content.some(isActionableContent);
569
+ }
570
+
501
571
  /** Sentinel `errorMessage` the agent stamps on any abort that carried no custom
502
572
  * reason (bare `abort()`). Renderers treat it as "no specific reason given". */
503
573
  export const GENERIC_ABORT_SENTINEL = "Request was aborted";
@@ -1081,17 +1151,16 @@ const convertCache = new WeakMap<AgentMessage, ConvertMemoEntry>();
1081
1151
  // The tail-identity guard on exact-repeat catches the streaming snapshot swap
1082
1152
  // (partial → trailing is a fresh identity), so a settled tail is never served
1083
1153
  // from a stale mid-stream fragment.
1154
+ interface ConvertArrayMemo {
1155
+ generation: number;
1156
+ length: number;
1157
+ output: Message[];
1158
+ tail: AgentMessage | undefined;
1159
+ prefixOutputLen: number;
1160
+ }
1161
+
1084
1162
  let convertGeneration = 0;
1085
- let lastConvertInput: AgentMessage[] | undefined;
1086
- let lastConvertLength = 0;
1087
- let lastConvertOutput: Message[] | undefined;
1088
- let lastConvertGeneration = -1;
1089
- let lastConvertTail: AgentMessage | undefined;
1090
- // Output-message count contributed by messages[0 .. lastConvertLength-1), i.e.
1091
- // every message except the last. The last message is neighbor-sensitive (its LLM
1092
- // view drops the trailing thinking run only while an interrupted-thinking marker
1093
- // follows), so growth reconverts it rather than reusing its old fragment.
1094
- let lastConvertPrefixOutputLen = 0;
1163
+ const convertArrayCache = new WeakMap<AgentMessage[], ConvertArrayMemo>();
1095
1164
 
1096
1165
  registerMessageCacheInvalidator(message => {
1097
1166
  convertCache.delete(message);
@@ -1246,15 +1315,16 @@ function convertOneCached(m: AgentMessage, interruptedNext: boolean): Message[]
1246
1315
  */
1247
1316
  export function convertToLlm(messages: AgentMessage[]): Message[] {
1248
1317
  const len = messages.length;
1249
- const sameArray = messages === lastConvertInput && lastConvertGeneration === convertGeneration;
1318
+ const memo = convertArrayCache.get(messages);
1319
+ const sameGeneration = memo !== undefined && memo.generation === convertGeneration;
1250
1320
  const tail = len > 0 ? messages[len - 1] : undefined;
1251
1321
 
1252
1322
  // Exact-repeat: same array, same length, same trailing identity → reuse the
1253
1323
  // outer array. The tail-identity check rejects the streaming snapshot swap
1254
1324
  // (partial → settled trailing keeps array identity/length but mints a fresh
1255
1325
  // tail), so a settled tail never reads a stale mid-stream fragment.
1256
- if (sameArray && lastConvertOutput !== undefined && len === lastConvertLength && tail === lastConvertTail) {
1257
- return lastConvertOutput;
1326
+ if (sameGeneration && memo.length === len && tail === memo.tail) {
1327
+ return memo.output;
1258
1328
  }
1259
1329
 
1260
1330
  // Slice-on-growth: same array grew by append. Every interior message is
@@ -1267,15 +1337,14 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
1267
1337
  let out: Message[];
1268
1338
  let start: number;
1269
1339
  if (
1270
- sameArray &&
1271
- lastConvertOutput !== undefined &&
1272
- len > lastConvertLength &&
1273
- lastConvertLength > 0 &&
1274
- messages[lastConvertLength - 1] === lastConvertTail &&
1275
- lastConvertPrefixOutputLen <= lastConvertOutput.length
1340
+ sameGeneration &&
1341
+ len > memo.length &&
1342
+ memo.length > 0 &&
1343
+ messages[memo.length - 1] === memo.tail &&
1344
+ memo.prefixOutputLen <= memo.output.length
1276
1345
  ) {
1277
- out = lastConvertOutput.slice(0, lastConvertPrefixOutputLen);
1278
- start = lastConvertLength - 1;
1346
+ out = memo.output.slice(0, memo.prefixOutputLen);
1347
+ start = memo.length - 1;
1279
1348
  } else {
1280
1349
  out = [];
1281
1350
  start = 0;
@@ -1294,12 +1363,13 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
1294
1363
  if (len === 0) prefixOutputLen = 0;
1295
1364
 
1296
1365
  // Record for the next call's shortcuts. `out` is a fresh array (slice or new),
1297
- // so a prior caller holding the previous `lastConvertOutput` never sees it grow.
1298
- lastConvertInput = messages;
1299
- lastConvertLength = len;
1300
- lastConvertOutput = out;
1301
- lastConvertGeneration = convertGeneration;
1302
- lastConvertTail = tail;
1303
- lastConvertPrefixOutputLen = prefixOutputLen;
1366
+ // so a prior caller holding the previous memo output never sees it grow.
1367
+ convertArrayCache.set(messages, {
1368
+ generation: convertGeneration,
1369
+ length: len,
1370
+ output: out,
1371
+ tail,
1372
+ prefixOutputLen,
1373
+ });
1304
1374
  return out;
1305
1375
  }
@@ -50,6 +50,20 @@ export interface ActiveRetryFallbackState {
50
50
  originalThinkingLevel: ConfiguredThinkingLevel | undefined;
51
51
  lastAppliedFallbackThinkingLevel: ConfiguredThinkingLevel | undefined;
52
52
  pinned: boolean;
53
+ /**
54
+ * Set once a turn on the fallback target settles successfully. Until then the
55
+ * switch is only a routing decision — nothing has been produced by the new
56
+ * model, so no observer may report the run as having used it.
57
+ */
58
+ served?: boolean;
59
+ }
60
+
61
+ /** Model a session's produced work is attributed to. */
62
+ export interface ServingModel {
63
+ /** Full selector including routing and thinking level. */
64
+ selector: string;
65
+ /** Whether fallback routing, rather than the configured primary, owns it. */
66
+ isFallback: boolean;
53
67
  }
54
68
 
55
69
  const RETRY_BACKOFF_MAX_DELAY_MS = 8_000;
@@ -418,8 +418,8 @@ export class SessionAdvisors {
418
418
  }
419
419
 
420
420
  /** Re-primes advisor transcript views after an in-conversation history rewrite. */
421
- resetAllRuntimes(): void {
422
- this.#resetAllAdvisorRuntimes();
421
+ resetAllRuntimes(reason?: string): void {
422
+ this.#resetAllAdvisorRuntimes(reason);
423
423
  }
424
424
 
425
425
  /** Whether live runtimes still match the resolved advisor configuration. */
@@ -535,7 +535,7 @@ export class SessionAdvisors {
535
535
  for (const a of this.#advisors) {
536
536
  a.agentUnsubscribe?.();
537
537
  a.agentUnsubscribe = undefined;
538
- a.runtime.reset();
538
+ a.runtime.reset("conversation-boundary");
539
539
  a.adviseTool.resetDeliveredNotes();
540
540
  a.emissionGuard.reset();
541
541
  this.#attachAdvisorRecorderFeed(a);
@@ -785,14 +785,22 @@ export class SessionAdvisors {
785
785
  mcpResources: this.#advisorMcpResources,
786
786
  });
787
787
  const baseAdvisorStreamFn = this.#advisorStreamFn ?? streamSimple;
788
- const advisorStreamFn: StreamFn = (requestModel, context, options) =>
789
- baseAdvisorStreamFn(
790
- requestModel,
791
- context,
792
- requestModel.api === "openai-codex-responses"
793
- ? { ...options, codexSseMaxAttempts: ADVISOR_CODEX_SSE_MAX_ATTEMPTS }
794
- : options,
795
- );
788
+ const advisorStreamFn: StreamFn = (requestModel, context, options) => {
789
+ if (requestModel.api === "openai-codex-responses") {
790
+ return baseAdvisorStreamFn(requestModel, context, {
791
+ ...options,
792
+ codexSseMaxAttempts: ADVISOR_CODEX_SSE_MAX_ATTEMPTS,
793
+ });
794
+ }
795
+ if (
796
+ requestModel.api === "google-generative-ai" ||
797
+ requestModel.api === "google-gemini-cli" ||
798
+ requestModel.api === "google-vertex"
799
+ ) {
800
+ return baseAdvisorStreamFn(requestModel, context, { ...options, acceptEmptyResponse: true });
801
+ }
802
+ return baseAdvisorStreamFn(requestModel, context, options);
803
+ };
796
804
  const advisorAgent = new Agent({
797
805
  initialState: {
798
806
  systemPrompt,
@@ -832,8 +840,17 @@ export class SessionAdvisors {
832
840
  let quarantined: string | undefined;
833
841
  try {
834
842
  quarantinedAdvisorOutput = undefined;
835
- currentAdvisorInput = input;
836
- await advisorAgent.prompt(input);
843
+ // Multi-message input (candidate 4) must serialize deterministically
844
+ // for quarantine source text; reuse the session history formatter
845
+ // rather than ad-hoc joins so all message kinds (text/tool/
846
+ // custom/structured) are preserved exactly as rendered.
847
+ currentAdvisorInput = Array.isArray(input)
848
+ ? formatSessionHistoryMarkdown(input, { watchedRoles: true })
849
+ : input;
850
+ // Agent.prompt's overloads accept string OR AgentMessage[] but not
851
+ // the union, so narrow first; both branches intentionally identical.
852
+ if (Array.isArray(input)) await advisorAgent.prompt(input);
853
+ else await advisorAgent.prompt(input);
837
854
  quarantined = quarantinedAdvisorOutput;
838
855
  } finally {
839
856
  quarantinedAdvisorOutput = undefined;
@@ -1057,8 +1074,8 @@ export class SessionAdvisors {
1057
1074
  }
1058
1075
 
1059
1076
  /** Re-prime every advisor's transcript view after an in-conversation history rewrite. */
1060
- #resetAllAdvisorRuntimes(): void {
1061
- for (const a of this.#advisors) a.runtime.reset();
1077
+ #resetAllAdvisorRuntimes(reason?: string): void {
1078
+ for (const a of this.#advisors) a.runtime.reset(reason);
1062
1079
  }
1063
1080
 
1064
1081
  #stopAdvisorRuntime(): void {
@@ -55,6 +55,14 @@ export interface HistoryFormatOptions {
55
55
  */
56
56
  toolResultIndex?: ReadonlyMap<string, ToolResultMessage>;
57
57
  consumedToolCallIds?: Set<string>;
58
+ /**
59
+ * Chunked rendering state: a mutable holder for the watched-role label
60
+ * (`**user**:` / `**agent**:`) that ended the previous chunk. Lets a caller
61
+ * formatting one logical transcript across several calls (advisor
62
+ * multi-message split) keep consecutive same-role collapsing byte-identical
63
+ * to the single-block render: pass one object across all chunk calls.
64
+ */
65
+ watchedRoleState?: { lastLabel: string | undefined };
58
66
  }
59
67
 
60
68
  /** Max length of the primary-arg summary inside `→ tool(...)` lines. */
@@ -313,7 +321,9 @@ export function formatSessionHistoryMarkdown(messages: unknown[], opts?: History
313
321
  // (the watched agent emits one assistant message per tool call, so otherwise
314
322
  // every call repeats `**agent**:`). Cleared whenever a
315
323
  // non-role-labeled line is emitted so the next turn re-labels.
316
- let lastWatchedLabel: string | undefined;
324
+ // Chunked callers seed the previous chunk's trailing label so collapsing
325
+ // stays byte-identical to the single-block render.
326
+ let lastWatchedLabel: string | undefined = opts?.watchedRoleState?.lastLabel;
317
327
  // Emit a watched-mode role label, collapsing consecutive same-role turns
318
328
  // under one label (matching the user/assistant paths). Used for the
319
329
  // user-attributed `!`/`$` execution lines so the advisor never reads them
@@ -455,5 +465,9 @@ export function formatSessionHistoryMarkdown(messages: unknown[], opts?: History
455
465
  }
456
466
  }
457
467
 
468
+ if (opts?.watchedRoleState) {
469
+ opts.watchedRoleState.lastLabel = lastWatchedLabel;
470
+ }
471
+
458
472
  return `${lines.join("\n").trim()}\n`;
459
473
  }
@@ -235,7 +235,7 @@ export interface SessionMaintenanceHost {
235
235
  resetCodexProviderAfterCompaction(compaction: CodexCompactionContext): void;
236
236
  resetPlanReference(): void;
237
237
  syncTodoPhasesFromBranch(): void;
238
- resetAdvisorRuntimes(): void;
238
+ resetAdvisorRuntimes(reason?: string): void;
239
239
  rebaseAfterCompaction(): void;
240
240
  recordAnchoredHistoryRewrite(tokensRemoved: number): void;
241
241
  getContextBreakdown(options?: {
@@ -345,7 +345,7 @@ export class SessionMaintenance {
345
345
  await this.#host.sessionManager.rewriteEntries();
346
346
  const sessionContext = this.#host.buildDisplaySessionContext();
347
347
  this.#host.agent.replaceMessages(sessionContext.messages);
348
- this.#host.resetAdvisorRuntimes();
348
+ this.#host.resetAdvisorRuntimes("prune-tool-outputs");
349
349
  this.#host.syncTodoPhasesFromBranch();
350
350
  this.#host.closeCodexProviderSessionsForHistoryRewrite();
351
351
  return result;
@@ -388,7 +388,7 @@ export class SessionMaintenance {
388
388
  await this.#host.sessionManager.rewriteEntries();
389
389
  const sessionContext = this.#host.buildDisplaySessionContext();
390
390
  this.#host.agent.replaceMessages(sessionContext.messages);
391
- this.#host.resetAdvisorRuntimes();
391
+ this.#host.resetAdvisorRuntimes("prune-stale-tool-results");
392
392
  this.#host.syncTodoPhasesFromBranch();
393
393
  this.#host.closeCodexProviderSessionsForHistoryRewrite();
394
394
  return result;
@@ -439,7 +439,7 @@ export class SessionMaintenance {
439
439
  await this.#host.sessionManager.rewriteEntries();
440
440
  const sessionContext = this.#host.buildDisplaySessionContext();
441
441
  this.#host.agent.replaceMessages(sessionContext.messages);
442
- this.#host.resetAdvisorRuntimes();
442
+ this.#host.resetAdvisorRuntimes("drop-images");
443
443
  this.#host.closeCodexProviderSessionsForHistoryRewrite();
444
444
  return { removed };
445
445
  }
@@ -527,7 +527,7 @@ export class SessionMaintenance {
527
527
  await this.#host.sessionManager.rewriteEntries();
528
528
  const sessionContext = this.#host.buildDisplaySessionContext();
529
529
  this.#host.agent.replaceMessages(sessionContext.messages);
530
- this.#host.resetAdvisorRuntimes();
530
+ this.#host.resetAdvisorRuntimes("shake");
531
531
  this.#host.closeCodexProviderSessionsForHistoryRewrite();
532
532
 
533
533
  return {
@@ -879,7 +879,7 @@ export class SessionMaintenance {
879
879
  // plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads
880
880
  // the plan from disk and re-injects it on the next turn (issue #1246).
881
881
  this.#host.resetPlanReference();
882
- this.#host.resetAdvisorRuntimes();
882
+ this.#host.resetAdvisorRuntimes("compact");
883
883
  this.#host.syncTodoPhasesFromBranch();
884
884
  if (codexCompaction) {
885
885
  this.#host.resetCodexProviderAfterCompaction(codexCompaction);
@@ -2094,7 +2094,7 @@ export class SessionMaintenance {
2094
2094
  // and advisor cursors / todo phases were derived from the replaced
2095
2095
  // history.
2096
2096
  this.#host.resetPlanReference();
2097
- this.#host.resetAdvisorRuntimes();
2097
+ this.#host.resetAdvisorRuntimes("compaction-rescue");
2098
2098
  this.#host.syncTodoPhasesFromBranch();
2099
2099
  this.#host.closeCodexProviderSessionsForHistoryRewrite();
2100
2100
  // Extensions must see the entry that is now active, not (only) the one
@@ -2763,7 +2763,7 @@ export class SessionMaintenance {
2763
2763
  // plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads
2764
2764
  // the plan from disk and re-injects it on the next turn (issue #1246).
2765
2765
  this.#host.resetPlanReference();
2766
- this.#host.resetAdvisorRuntimes();
2766
+ this.#host.resetAdvisorRuntimes("auto-compaction");
2767
2767
  this.#host.syncTodoPhasesFromBranch();
2768
2768
  if (codexCompaction) {
2769
2769
  this.#host.resetCodexProviderAfterCompaction(codexCompaction);
@@ -1285,6 +1285,10 @@ export class SessionManager {
1285
1285
 
1286
1286
  /** Switch to a different session file (resume / branch). */
1287
1287
  async setSessionFile(sessionFile: string): Promise<void> {
1288
+ await this.#setSessionFile(sessionFile);
1289
+ }
1290
+
1291
+ async #setSessionFile(sessionFile: string, loadedEntries?: FileEntry[]): Promise<void> {
1288
1292
  await this.#drainAndCloseWriter();
1289
1293
  this.#clearDiskError();
1290
1294
  this.#draftOnlySessionCleanupArmed = false;
@@ -1294,7 +1298,7 @@ export class SessionManager {
1294
1298
  this.#rememberBreadcrumb(this.#cwd, resolvedSessionFile);
1295
1299
 
1296
1300
  const titleSlot = await readTitleSlotFromFile(resolvedSessionFile, this.#storage);
1297
- const fileEntries = await loadEntriesFromFile(resolvedSessionFile, this.#storage);
1301
+ const fileEntries = loadedEntries ?? (await loadEntriesFromFile(resolvedSessionFile, this.#storage));
1298
1302
  if (fileEntries.length === 0) {
1299
1303
  // Explicit but empty/missing path (e.g. --session flag): start fresh but
1300
1304
  // keep the requested path and materialize the header immediately.
@@ -2601,7 +2605,7 @@ export class SessionManager {
2601
2605
  : path.dirname(path.resolve(filePath)));
2602
2606
  const manager = new SessionManager(cwd, dir, true, storage);
2603
2607
  manager.#suppressBreadcrumb = options?.suppressBreadcrumb === true;
2604
- await manager.setSessionFile(filePath);
2608
+ await manager.#setSessionFile(filePath, loaded);
2605
2609
  return manager;
2606
2610
  }
2607
2611