@oh-my-pi/pi-coding-agent 15.7.6 → 15.8.2

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 (117) hide show
  1. package/CHANGELOG.md +172 -198
  2. package/dist/types/async/job-manager.d.ts +3 -3
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli/claude-trace-cli.d.ts +54 -0
  5. package/dist/types/cli/session-picker.d.ts +10 -3
  6. package/dist/types/cli/update-cli.d.ts +17 -0
  7. package/dist/types/commands/launch.d.ts +3 -0
  8. package/dist/types/config/keybindings.d.ts +10 -1
  9. package/dist/types/config/settings-schema.d.ts +2 -2
  10. package/dist/types/config/settings.d.ts +13 -0
  11. package/dist/types/edit/index.d.ts +6 -0
  12. package/dist/types/edit/streaming.d.ts +8 -0
  13. package/dist/types/eval/concurrency-bridge.d.ts +26 -0
  14. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  15. package/dist/types/export/ttsr.d.ts +9 -0
  16. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
  17. package/dist/types/main.d.ts +5 -0
  18. package/dist/types/mcp/transports/stdio.d.ts +19 -0
  19. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  20. package/dist/types/modes/components/hook-selector.d.ts +3 -0
  21. package/dist/types/modes/components/session-selector.d.ts +32 -5
  22. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  23. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
  24. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  25. package/dist/types/modes/interactive-mode.d.ts +9 -2
  26. package/dist/types/modes/types.d.ts +4 -2
  27. package/dist/types/registry/agent-registry.d.ts +1 -1
  28. package/dist/types/sdk.d.ts +2 -2
  29. package/dist/types/session/agent-session.d.ts +4 -2
  30. package/dist/types/session/history-storage.d.ts +16 -1
  31. package/dist/types/session/session-manager.d.ts +4 -0
  32. package/dist/types/task/output-manager.d.ts +6 -15
  33. package/dist/types/tools/find.d.ts +0 -9
  34. package/dist/types/tools/index.d.ts +1 -1
  35. package/dist/types/tools/path-utils.d.ts +16 -0
  36. package/dist/types/tools/sqlite-reader.d.ts +25 -8
  37. package/dist/types/tools/write.d.ts +2 -0
  38. package/dist/types/utils/clipboard.d.ts +4 -0
  39. package/dist/types/utils/jj.d.ts +49 -0
  40. package/dist/types/web/kagi.d.ts +76 -0
  41. package/dist/types/web/search/providers/exa.d.ts +7 -1
  42. package/dist/types/web/search/providers/kagi.d.ts +1 -0
  43. package/package.json +9 -9
  44. package/src/async/job-manager.ts +3 -3
  45. package/src/cli/args.ts +6 -2
  46. package/src/cli/claude-trace-cli.ts +783 -0
  47. package/src/cli/session-picker.ts +36 -10
  48. package/src/cli/update-cli.ts +35 -2
  49. package/src/commands/launch.ts +3 -0
  50. package/src/config/keybindings.ts +14 -1
  51. package/src/config/model-registry.ts +18 -7
  52. package/src/config/settings-schema.ts +2 -2
  53. package/src/config/settings.ts +23 -0
  54. package/src/discovery/builtin-rules/index.ts +2 -0
  55. package/src/discovery/builtin-rules/ts-no-deprecated-leftovers.md +44 -0
  56. package/src/discovery/claude-plugins.ts +7 -9
  57. package/src/edit/index.ts +10 -0
  58. package/src/edit/streaming.ts +65 -0
  59. package/src/eval/__tests__/agent-bridge.test.ts +58 -4
  60. package/src/eval/concurrency-bridge.ts +34 -0
  61. package/src/eval/js/shared/prelude.txt +20 -17
  62. package/src/eval/js/tool-bridge.ts +5 -0
  63. package/src/eval/py/prelude.py +23 -15
  64. package/src/export/ttsr.ts +18 -1
  65. package/src/extensibility/custom-commands/bundled/review/index.ts +74 -45
  66. package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
  67. package/src/extensibility/skills.ts +0 -1
  68. package/src/internal-urls/docs-index.generated.ts +12 -11
  69. package/src/main.ts +92 -24
  70. package/src/mcp/transports/stdio.ts +55 -22
  71. package/src/modes/acp/acp-event-mapper.ts +54 -4
  72. package/src/modes/components/custom-editor.ts +10 -0
  73. package/src/modes/components/hook-selector.ts +89 -31
  74. package/src/modes/components/oauth-selector.ts +12 -6
  75. package/src/modes/components/session-selector.ts +179 -24
  76. package/src/modes/components/tool-execution.ts +16 -3
  77. package/src/modes/controllers/command-controller.ts +2 -11
  78. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  79. package/src/modes/controllers/input-controller.ts +19 -1
  80. package/src/modes/controllers/selector-controller.ts +61 -21
  81. package/src/modes/interactive-mode.ts +125 -15
  82. package/src/modes/types.ts +5 -2
  83. package/src/prompts/agents/reviewer.md +2 -2
  84. package/src/prompts/review-request.md +1 -1
  85. package/src/prompts/system/empty-stop-retry.md +6 -0
  86. package/src/prompts/system/orchestrate-notice.md +5 -3
  87. package/src/prompts/system/workflow-notice.md +2 -2
  88. package/src/prompts/tools/eval.md +5 -5
  89. package/src/prompts/tools/find.md +1 -1
  90. package/src/prompts/tools/irc.md +6 -6
  91. package/src/prompts/tools/search-tool-bm25.md +9 -2
  92. package/src/prompts/tools/search.md +1 -1
  93. package/src/prompts/tools/task.md +1 -1
  94. package/src/registry/agent-registry.ts +1 -1
  95. package/src/sdk.ts +85 -31
  96. package/src/session/agent-session.ts +209 -54
  97. package/src/session/history-storage.ts +56 -12
  98. package/src/session/session-manager.ts +34 -0
  99. package/src/task/output-manager.ts +40 -48
  100. package/src/task/render.ts +3 -8
  101. package/src/tools/browser/tab-worker.ts +8 -5
  102. package/src/tools/find.ts +5 -29
  103. package/src/tools/index.ts +1 -1
  104. package/src/tools/path-utils.ts +144 -1
  105. package/src/tools/read.ts +47 -0
  106. package/src/tools/search-tool-bm25.ts +7 -1
  107. package/src/tools/search.ts +2 -27
  108. package/src/tools/sqlite-reader.ts +92 -9
  109. package/src/tools/write.ts +6 -0
  110. package/src/utils/clipboard.ts +38 -1
  111. package/src/utils/git.ts +19 -23
  112. package/src/utils/jj.ts +225 -0
  113. package/src/utils/open.ts +37 -2
  114. package/src/web/kagi.ts +168 -49
  115. package/src/web/search/providers/anthropic.ts +1 -1
  116. package/src/web/search/providers/exa.ts +20 -86
  117. package/src/web/search/providers/kagi.ts +4 -0
package/src/sdk.ts CHANGED
@@ -102,7 +102,7 @@ import { AgentSession } from "./session/agent-session";
102
102
  import { resolveAuthBrokerConfig } from "./session/auth-broker-config";
103
103
  import { AuthBrokerClient, AuthStorage, RemoteAuthCredentialStore } from "./session/auth-storage";
104
104
  import { type CustomMessage, convertToLlm } from "./session/messages";
105
- import { SessionManager } from "./session/session-manager";
105
+ import { getRestorableSessionModels, SessionManager } from "./session/session-manager";
106
106
  import { closeAllConnections } from "./ssh/connection-manager";
107
107
  import { unmountAll } from "./ssh/sshfs-mount";
108
108
  import {
@@ -323,13 +323,13 @@ export interface CreateAgentSessionOptions {
323
323
  parentHindsightSessionState?: HindsightSessionState;
324
324
  /** Parent Mnemopi state to alias for subagent memory tools. */
325
325
  parentMnemopiSessionState?: MnemopiSessionState;
326
- /** Pre-allocated agent identity for IRC routing. Default: "0-Main" for top-level, parentTaskPrefix-derived for sub. */
326
+ /** Pre-allocated agent identity for IRC routing. Default: "Main" for top-level, parentTaskPrefix-derived for sub. */
327
327
  agentId?: string;
328
328
  /** Display name for the agent in IRC. Default: "main" or "sub". */
329
329
  agentDisplayName?: string;
330
330
  /** Optional shared agent registry for IRC routing. Default: AgentRegistry.global(). */
331
331
  agentRegistry?: AgentRegistry;
332
- /** Parent task ID prefix for nested artifact naming (e.g., "6-Extensions") */
332
+ /** Parent task ID prefix for nested artifact naming (e.g., "Extensions") */
333
333
  parentTaskPrefix?: string;
334
334
  /** Inherited eval executor session id for subagents sharing parent eval state. */
335
335
  parentEvalSessionId?: string;
@@ -1008,20 +1008,37 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1008
1008
  );
1009
1009
  let model = options.model;
1010
1010
  let modelFallbackMessage: string | undefined;
1011
- // If session has data, try to restore model from it.
1012
- // Skip restore when an explicit model was requested.
1013
- const defaultModelStr = existingSession.models.default;
1014
- if (!hasExplicitModel && !model && hasExistingSession && defaultModelStr) {
1011
+ // Identify session model strings to restore in fallback order. We do an
1012
+ // initial pass here so model-dependent setup (thinking-level resolution,
1013
+ // host preconnect) can use the restored model; extension-registered
1014
+ // providers aren't visible yet, so we retry the preferred candidates once
1015
+ // extensions register below.
1016
+ const sessionModelStrings =
1017
+ !hasExplicitModel && hasExistingSession
1018
+ ? getRestorableSessionModels(existingSession.models, sessionManager.getLastModelChangeRole())
1019
+ : [];
1020
+ let restoredSessionModelIndex = -1;
1021
+ if (!hasExplicitModel && !model && sessionModelStrings.length > 0) {
1015
1022
  await logger.time("restoreSessionModel", async () => {
1016
- const parsedModel = parseModelString(defaultModelStr);
1017
- if (parsedModel) {
1023
+ let failedSessionModel: string | undefined;
1024
+ for (let i = 0; i < sessionModelStrings.length; i++) {
1025
+ const sessionModelStr = sessionModelStrings[i];
1026
+ const parsedModel = parseModelString(sessionModelStr);
1027
+ if (!parsedModel) {
1028
+ failedSessionModel ??= sessionModelStr;
1029
+ continue;
1030
+ }
1031
+
1018
1032
  const restoredModel = modelRegistry.find(parsedModel.provider, parsedModel.id);
1019
1033
  if (restoredModel && (await hasModelApiKey(restoredModel))) {
1020
1034
  model = restoredModel;
1035
+ restoredSessionModelIndex = i;
1036
+ break;
1021
1037
  }
1038
+ failedSessionModel ??= sessionModelStr;
1022
1039
  }
1023
- if (!model) {
1024
- modelFallbackMessage = `Could not restore model ${defaultModelStr}`;
1040
+ if (failedSessionModel) {
1041
+ modelFallbackMessage = `Could not restore model ${failedSessionModel}`;
1025
1042
  }
1026
1043
  });
1027
1044
  }
@@ -1039,26 +1056,29 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1039
1056
 
1040
1057
  const taskDepth = options.taskDepth ?? 0;
1041
1058
 
1042
- let thinkingLevel = options.thinkingLevel;
1043
-
1044
- // If session has data and includes a thinking entry, restore it
1045
- if (thinkingLevel === undefined && hasExistingSession && hasThinkingEntry) {
1046
- thinkingLevel = parseThinkingLevel(existingSession.thinkingLevel);
1047
- }
1048
-
1049
- if (thinkingLevel === undefined && !hasExplicitModel && !hasThinkingEntry && defaultRoleSpec.explicitThinkingLevel) {
1050
- thinkingLevel = defaultRoleSpec.thinkingLevel;
1051
- }
1052
-
1053
- // Prefer the selected model's configured defaultLevel, otherwise fall back
1054
- // to the global settings default.
1055
- if (thinkingLevel === undefined && model?.thinking?.defaultLevel !== undefined) {
1056
- thinkingLevel = model.thinking.defaultLevel;
1057
- }
1058
- if (thinkingLevel === undefined) {
1059
- thinkingLevel = settings.get("defaultThinkingLevel");
1060
- }
1061
- const autoThinking = thinkingLevel === AUTO_THINKING;
1059
+ // Resolves the session/agent thinking level using the same precedence we
1060
+ // apply at startup: explicit option → persisted session entry → default
1061
+ // role's explicit selector selected model's defaultLevel global
1062
+ // settings default. Run again after extension role reclaim so the final
1063
+ // model's own defaults aren't masked by an earlier fallback model's.
1064
+ const pickInitialThinkingLevel = (selectedModel: Model | undefined): ConfiguredThinkingLevel | undefined => {
1065
+ let level = options.thinkingLevel;
1066
+ if (level === undefined && hasExistingSession && hasThinkingEntry) {
1067
+ level = parseThinkingLevel(existingSession.thinkingLevel);
1068
+ }
1069
+ if (level === undefined && !hasExplicitModel && !hasThinkingEntry && defaultRoleSpec.explicitThinkingLevel) {
1070
+ level = defaultRoleSpec.thinkingLevel;
1071
+ }
1072
+ if (level === undefined && selectedModel?.thinking?.defaultLevel !== undefined) {
1073
+ level = selectedModel.thinking.defaultLevel;
1074
+ }
1075
+ if (level === undefined) {
1076
+ level = settings.get("defaultThinkingLevel");
1077
+ }
1078
+ return level;
1079
+ };
1080
+ let thinkingLevel = pickInitialThinkingLevel(model);
1081
+ let autoThinking = thinkingLevel === AUTO_THINKING;
1062
1082
  // Concrete level the agent/session start with. With `auto` this is the
1063
1083
  // provisional level shown until the first per-turn classification resolves;
1064
1084
  // `auto` itself stays a session-only concept handled by AgentSession.
@@ -1444,6 +1464,40 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1444
1464
  extensionsResult.runtime.pendingProviderRegistrations = [];
1445
1465
  }
1446
1466
 
1467
+ // Retry session-model candidates now that extension providers are
1468
+ // registered. The initial restore runs before extensions load, so a role
1469
+ // model supplied by an extension would have either fallen back to the
1470
+ // saved default (`restoredSessionModelIndex > 0`) or failed entirely
1471
+ // (`restoredSessionModelIndex === -1`, with the settings default or
1472
+ // downstream fallback filling `model`). Reclaim it here so resume
1473
+ // honors the last active role in either case.
1474
+ const sessionRetryLimit = restoredSessionModelIndex >= 0 ? restoredSessionModelIndex : sessionModelStrings.length;
1475
+ if (!hasExplicitModel && sessionRetryLimit > 0) {
1476
+ for (let i = 0; i < sessionRetryLimit; i++) {
1477
+ const sessionModelStr = sessionModelStrings[i];
1478
+ const parsedModel = parseModelString(sessionModelStr);
1479
+ if (!parsedModel) continue;
1480
+ const restoredModel = modelRegistry.find(parsedModel.provider, parsedModel.id);
1481
+ if (restoredModel && (await hasModelApiKey(restoredModel))) {
1482
+ model = restoredModel;
1483
+ modelFallbackMessage = undefined;
1484
+ restoredSessionModelIndex = i;
1485
+ // Recompute thinking-level from scratch against the reclaimed
1486
+ // model: any value derived from the earlier fallback model's
1487
+ // `thinking.defaultLevel` must not become sticky.
1488
+ thinkingLevel = pickInitialThinkingLevel(restoredModel);
1489
+ autoThinking = thinkingLevel === AUTO_THINKING;
1490
+ effectiveThinkingLevel = thinkingLevel === AUTO_THINKING ? undefined : thinkingLevel;
1491
+ effectiveThinkingLevel = logger.time("resolveThinkingLevelForModel", () =>
1492
+ autoThinking
1493
+ ? resolveProvisionalAutoLevel(restoredModel)
1494
+ : resolveThinkingLevelForModel(restoredModel, effectiveThinkingLevel),
1495
+ );
1496
+ preconnectModelHost(restoredModel.baseUrl);
1497
+ break;
1498
+ }
1499
+ }
1500
+ }
1447
1501
  // Resolve deferred --model pattern now that extension models are registered.
1448
1502
  if (!model && options.modelPattern) {
1449
1503
  const availableModels = modelRegistry.getAll();
@@ -62,6 +62,7 @@ import type {
62
62
  Message,
63
63
  MessageAttribution,
64
64
  Model,
65
+ ProviderResponseMetadata,
65
66
  ProviderSessionState,
66
67
  ServiceTier,
67
68
  SimpleStreamOptions,
@@ -87,6 +88,7 @@ import { countTokens, MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
87
88
  import {
88
89
  extractRetryHint,
89
90
  getAgentDbPath,
91
+ getInstallId,
90
92
  isEnoent,
91
93
  isUnexpectedSocketCloseMessage,
92
94
  logger,
@@ -164,6 +166,7 @@ import { containsWorkflow, WORKFLOW_NOTICE } from "../modes/workflow";
164
166
  import type { PlanModeState } from "../plan-mode/state";
165
167
  import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
166
168
  import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
169
+ import emptyStopRetryTemplate from "../prompts/system/empty-stop-retry.md" with { type: "text" };
167
170
  import ircIncomingTemplate from "../prompts/system/irc-incoming.md" with { type: "text" };
168
171
  import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
169
172
  import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
@@ -227,7 +230,7 @@ import type {
227
230
  SessionContext,
228
231
  SessionManager,
229
232
  } from "./session-manager";
230
- import { getLatestCompactionEntry } from "./session-manager";
233
+ import { getLatestCompactionEntry, getRestorableSessionModels } from "./session-manager";
231
234
  import type { ShakeMode, ShakeResult } from "./shake-types";
232
235
  import { ToolChoiceQueue } from "./tool-choice-queue";
233
236
  import { YieldQueue } from "./yield-queue";
@@ -273,6 +276,8 @@ export type AgentSessionEvent =
273
276
  export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
274
277
  export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
275
278
 
279
+ const EMPTY_STOP_MAX_RETRIES = 3;
280
+
276
281
  export interface AsyncJobSnapshot {
277
282
  running: AsyncJobSnapshotItem[];
278
283
  recent: AsyncJobSnapshotItem[];
@@ -358,7 +363,7 @@ export interface AgentSessionConfig {
358
363
  * **MUST NOT** dispose it on their own teardown.
359
364
  */
360
365
  ownedAsyncJobManager?: AsyncJobManager;
361
- /** Agent identity (registry id like "0-Main" or "3-Alice") used for IRC routing. */
366
+ /** Agent identity (registry id like "Main" or "Alice") used for IRC routing. */
362
367
  agentId?: string;
363
368
  /** Shared agent registry (for forwarding IRC observations to the main session UI). */
364
369
  agentRegistry?: AgentRegistry;
@@ -581,15 +586,14 @@ function buildSessionMetadata(
581
586
  const accountUuid = authStorage?.getOAuthAccountId("anthropic", sessionId);
582
587
  if (typeof accountUuid === "string" && accountUuid.length > 0) {
583
588
  userId.account_uuid = accountUuid;
584
- // Derive device_id from account_uuid so the payload matches the real CC
585
- // getAPIMetadata shape without hardware fingerprinting. A SHA-256 of a
586
- // namespaced account UUID produces a stable 64-hex value that is
587
- // indistinguishable from a randomly generated device ID on the wire, is
588
- // deterministic per account (survives reinstalls), and is auditable: it
589
- // is derived solely from the OAuth UUID the user already consented to
590
- // share with Anthropic. Omitted when no OAuth credential is available
591
- // (API-key callers) to avoid sending a hash of an empty string.
592
- userId.device_id = crypto.createHash("sha256").update(`omp-device-id-v1:${accountUuid}`).digest("hex");
589
+ // Claude Code's `device_id` is a stable 64-hex install identifier. Use
590
+ // omp's persistent install id as the root instead of deriving it from
591
+ // `account_uuid`: logging into a different Claude account on the same
592
+ // install should not make the device look new.
593
+ userId.device_id = crypto
594
+ .createHash("sha256")
595
+ .update(`omp-claude-device-id-v1:${getInstallId()}`)
596
+ .digest("hex");
593
597
  }
594
598
  }
595
599
  return { user_id: JSON.stringify(userId) };
@@ -994,6 +998,7 @@ export class AgentSession {
994
998
  #checkpointState: CheckpointState | undefined = undefined;
995
999
  #pendingRewindReport: string | undefined = undefined;
996
1000
  #lastSuccessfulYieldToolCallId: string | undefined = undefined;
1001
+ #emptyStopRetryCount = 0;
997
1002
  #promptGeneration = 0;
998
1003
  #providerSessionState = new Map<string, ProviderSessionState>();
999
1004
  #hindsightSessionState: HindsightSessionState | undefined = undefined;
@@ -1104,10 +1109,12 @@ export class AgentSession {
1104
1109
  this.#onResponse = configuredOnResponse
1105
1110
  ? async (response, model) => {
1106
1111
  this.rawSseDebugBuffer.recordResponse(response, model);
1112
+ this.#ingestProviderUsageHeaders(response, model);
1107
1113
  await configuredOnResponse(response, model);
1108
1114
  }
1109
1115
  : (response, model) => {
1110
1116
  this.rawSseDebugBuffer.recordResponse(response, model);
1117
+ this.#ingestProviderUsageHeaders(response, model);
1111
1118
  };
1112
1119
  const configuredOnSseEvent = config.onSseEvent;
1113
1120
  this.#onSseEvent = configuredOnSseEvent
@@ -1597,17 +1604,19 @@ export class AgentSession {
1597
1604
  if (event.type === "message_update" && this.#ttsrManager?.hasRules()) {
1598
1605
  const assistantEvent = event.assistantMessageEvent;
1599
1606
  let matchContext: TtsrMatchContext | undefined;
1607
+ let streamingToolCall: ToolCall | undefined;
1600
1608
 
1601
1609
  if (assistantEvent.type === "text_delta") {
1602
1610
  matchContext = { source: "text" };
1603
1611
  } else if (assistantEvent.type === "thinking_delta") {
1604
1612
  matchContext = { source: "thinking" };
1605
1613
  } else if (assistantEvent.type === "toolcall_delta") {
1606
- matchContext = this.#getTtsrToolMatchContext(event.message, assistantEvent.contentIndex);
1614
+ streamingToolCall = this.#getStreamingToolCallBlock(event.message, assistantEvent.contentIndex);
1615
+ matchContext = this.#getTtsrToolMatchContext(streamingToolCall, assistantEvent.contentIndex);
1607
1616
  }
1608
1617
 
1609
1618
  if (matchContext && "delta" in assistantEvent) {
1610
- const matches = this.#ttsrManager.checkDelta(assistantEvent.delta, matchContext);
1619
+ const matches = this.#checkTtsrStream(assistantEvent.delta, matchContext, streamingToolCall);
1611
1620
  if (matches.length > 0) {
1612
1621
  // Decide first: a non-interrupting tool-source match attaches to the
1613
1622
  // specific tool call's result instead of driving a loop-wide follow-up.
@@ -1766,6 +1775,7 @@ export class AgentSession {
1766
1775
  if (
1767
1776
  assistantMsg.stopReason !== "error" &&
1768
1777
  assistantMsg.stopReason !== "aborted" &&
1778
+ !this.#isEmptyAssistantStop(assistantMsg) &&
1769
1779
  this.#retryAttempt > 0
1770
1780
  ) {
1771
1781
  if (this.#activeRetryFallback && this.model) {
@@ -1880,6 +1890,10 @@ export class AgentSession {
1880
1890
  }
1881
1891
  this.#lastSuccessfulYieldToolCallId = undefined;
1882
1892
 
1893
+ if (await this.#handleEmptyAssistantStop(msg)) {
1894
+ return;
1895
+ }
1896
+
1883
1897
  // Check for retryable errors first (overloaded, rate limit, server errors)
1884
1898
  if (this.#isRetryableError(msg)) {
1885
1899
  const didRetry = await this.#handleRetryableError(msg);
@@ -2273,30 +2287,64 @@ export class AgentSession {
2273
2287
  });
2274
2288
  }
2275
2289
 
2276
- /** Build TTSR match context for tool call argument deltas. */
2277
- #getTtsrToolMatchContext(message: AgentMessage, contentIndex: number): TtsrMatchContext {
2278
- const context: TtsrMatchContext = { source: "tool" };
2290
+ /** Extract the tool-call block a toolcall_delta event refers to, if present. */
2291
+ #getStreamingToolCallBlock(message: AgentMessage, contentIndex: number): ToolCall | undefined {
2279
2292
  if (message.role !== "assistant") {
2280
- return context;
2293
+ return undefined;
2281
2294
  }
2282
2295
 
2283
2296
  const content = message.content;
2284
2297
  if (!Array.isArray(content) || contentIndex < 0 || contentIndex >= content.length) {
2285
- return context;
2298
+ return undefined;
2286
2299
  }
2287
2300
 
2288
2301
  const block = content[contentIndex];
2289
2302
  if (!block || typeof block !== "object" || block.type !== "toolCall") {
2303
+ return undefined;
2304
+ }
2305
+
2306
+ return block as ToolCall;
2307
+ }
2308
+
2309
+ /** Build TTSR match context for tool call argument deltas. */
2310
+ #getTtsrToolMatchContext(toolCall: ToolCall | undefined, contentIndex: number): TtsrMatchContext {
2311
+ const context: TtsrMatchContext = { source: "tool" };
2312
+ if (!toolCall) {
2290
2313
  return context;
2291
2314
  }
2292
2315
 
2293
- const toolCall = block as ToolCall;
2294
2316
  context.toolName = toolCall.name;
2295
2317
  context.streamKey = toolCall.id ? `toolcall:${toolCall.id}` : `tool:${toolCall.name}:${contentIndex}`;
2296
2318
  context.filePaths = this.#extractTtsrFilePathsFromArgs(toolCall.arguments);
2297
2319
  return context;
2298
2320
  }
2299
2321
 
2322
+ /**
2323
+ * Match a stream delta against TTSR rules.
2324
+ *
2325
+ * Tool argument streams prefer the tool's `matcherDigest` normalization — the
2326
+ * real content the call introduces — over the raw argument delta, so rule
2327
+ * conditions written against source text keep working regardless of the
2328
+ * tool's wire format (hashline patches, JSON-escaped strings, ...).
2329
+ */
2330
+ #checkTtsrStream(delta: string, matchContext: TtsrMatchContext, toolCall: ToolCall | undefined): Rule[] {
2331
+ const manager = this.#ttsrManager;
2332
+ if (!manager) {
2333
+ return [];
2334
+ }
2335
+ if (toolCall) {
2336
+ const tools = this.agent.state.tools;
2337
+ const tool =
2338
+ tools.find(t => t.name === toolCall.name) ??
2339
+ tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name);
2340
+ const digest = tool?.matcherDigest?.(toolCall.arguments ?? {});
2341
+ if (digest !== undefined) {
2342
+ return manager.checkSnapshot(digest, matchContext);
2343
+ }
2344
+ }
2345
+ return manager.checkDelta(delta, matchContext);
2346
+ }
2347
+
2300
2348
  /** Extract path-like arguments from tool call payload for TTSR glob matching. */
2301
2349
  #extractTtsrFilePathsFromArgs(args: unknown): string[] | undefined {
2302
2350
  if (!args || typeof args !== "object" || Array.isArray(args)) {
@@ -2827,13 +2875,14 @@ export class AgentSession {
2827
2875
 
2828
2876
  /**
2829
2877
  * Set agent.sessionId from the session manager and install a dynamic
2830
- * metadata resolver so every API request carries `metadata.user_id` shaped
2831
- * like real Claude Code's `getAPIMetadata` output: `{ session_id,
2832
- * account_uuid }` (the latter only when an Anthropic OAuth credential with
2833
- * a known account UUID is loaded). Resolving live keeps the value in sync
2834
- * with auth-state changes (login/logout, token refresh that surfaces a new
2835
- * account uuid) without needing to re-call `#syncAgentSessionId()` on every
2836
- * such event.
2878
+ * metadata resolver so every Anthropic API request carries
2879
+ * `metadata.user_id` shaped like real Claude Code's `getAPIMetadata` output:
2880
+ * `{ session_id, account_uuid, device_id }`. `account_uuid` is included only
2881
+ * when an Anthropic OAuth credential with a known account UUID is loaded;
2882
+ * `device_id` is derived from the persistent omp install id. Resolving live
2883
+ * keeps the value in sync with auth-state changes (login/logout, token
2884
+ * refresh that surfaces a new account uuid) without needing to re-call
2885
+ * `#syncAgentSessionId()` on every such event.
2837
2886
  */
2838
2887
  #syncAgentSessionId(sessionId?: string): void {
2839
2888
  const sid = this.#providerSessionId ?? sessionId ?? this.sessionManager.getSessionId();
@@ -4270,6 +4319,7 @@ export class AgentSession {
4270
4319
 
4271
4320
  // Reset todo reminder count on new user prompt
4272
4321
  this.#todoReminderCount = 0;
4322
+ this.#emptyStopRetryCount = 0;
4273
4323
 
4274
4324
  await this.#maybeRestoreRetryFallbackPrimary();
4275
4325
 
@@ -6283,6 +6333,99 @@ export class AgentSession {
6283
6333
  return lastToolCall?.name === "yield" && lastToolCall.id === toolCallId;
6284
6334
  }
6285
6335
 
6336
+ async #handleEmptyAssistantStop(assistantMessage: AssistantMessage): Promise<boolean> {
6337
+ if (!this.#isEmptyAssistantStop(assistantMessage)) {
6338
+ this.#emptyStopRetryCount = 0;
6339
+ return false;
6340
+ }
6341
+
6342
+ this.#emptyStopRetryCount++;
6343
+ if (this.#emptyStopRetryCount > EMPTY_STOP_MAX_RETRIES) {
6344
+ logger.warn("Assistant returned empty stop after retry cap", {
6345
+ attempts: this.#emptyStopRetryCount - 1,
6346
+ model: assistantMessage.model,
6347
+ provider: assistantMessage.provider,
6348
+ });
6349
+ if (this.#retryAttempt > 0) {
6350
+ await this.#emitSessionEvent({
6351
+ type: "auto_retry_end",
6352
+ success: false,
6353
+ attempt: this.#retryAttempt,
6354
+ finalError: "Assistant returned empty stop after retry cap",
6355
+ });
6356
+ this.#retryAttempt = 0;
6357
+ }
6358
+ this.#resolveRetry();
6359
+ return true;
6360
+ }
6361
+
6362
+ this.#removeEmptyStopFromActiveContext(assistantMessage);
6363
+ this.agent.appendMessage({
6364
+ role: "developer",
6365
+ content: [{ type: "text", text: this.#emptyStopRetryReminder() }],
6366
+ attribution: "agent",
6367
+ timestamp: Date.now(),
6368
+ });
6369
+ this.#scheduleAgentContinue({ generation: this.#promptGeneration });
6370
+ return true;
6371
+ }
6372
+
6373
+ #isEmptyAssistantStop(assistantMessage: AssistantMessage): boolean {
6374
+ if (assistantMessage.stopReason !== "stop") return false;
6375
+ return !assistantMessage.content.some(content => {
6376
+ if (content.type === "text") return content.text.trim().length > 0;
6377
+ if (content.type === "thinking") return content.thinking.trim().length > 0;
6378
+ return content.type === "toolCall";
6379
+ });
6380
+ }
6381
+
6382
+ #emptyStopRetryReminder(): string {
6383
+ return prompt.render(emptyStopRetryTemplate, {
6384
+ retryCount: this.#emptyStopRetryCount,
6385
+ maxRetries: EMPTY_STOP_MAX_RETRIES,
6386
+ });
6387
+ }
6388
+
6389
+ #removeEmptyStopFromActiveContext(assistantMessage: AssistantMessage): void {
6390
+ const messages = this.agent.state.messages;
6391
+ const lastMessage = messages[messages.length - 1];
6392
+ if (
6393
+ lastMessage?.role === "assistant" &&
6394
+ this.#isSameAssistantMessage(lastMessage as AssistantMessage, assistantMessage)
6395
+ ) {
6396
+ this.agent.replaceMessages(messages.slice(0, -1));
6397
+ }
6398
+
6399
+ const emptyStopEntry = this.sessionManager
6400
+ .getBranch()
6401
+ .slice()
6402
+ .reverse()
6403
+ .find(
6404
+ entry =>
6405
+ entry.type === "message" &&
6406
+ entry.message.role === "assistant" &&
6407
+ this.#isSameAssistantMessage(entry.message as AssistantMessage, assistantMessage),
6408
+ );
6409
+ if (!emptyStopEntry) {
6410
+ return;
6411
+ }
6412
+ if (emptyStopEntry.parentId === null) {
6413
+ this.sessionManager.resetLeaf();
6414
+ } else {
6415
+ this.sessionManager.branch(emptyStopEntry.parentId);
6416
+ }
6417
+ }
6418
+
6419
+ #isSameAssistantMessage(left: AssistantMessage, right: AssistantMessage): boolean {
6420
+ return (
6421
+ left === right ||
6422
+ (left.timestamp === right.timestamp &&
6423
+ left.provider === right.provider &&
6424
+ left.model === right.model &&
6425
+ left.stopReason === right.stopReason)
6426
+ );
6427
+ }
6428
+
6286
6429
  #enforceRewindBeforeYield(): boolean {
6287
6430
  if (!this.#checkpointState || this.#pendingRewindReport) {
6288
6431
  return false;
@@ -8723,28 +8866,34 @@ export class AgentSession {
8723
8866
  }
8724
8867
 
8725
8868
  // Restore model if saved
8726
- const defaultModelStr = sessionContext.models.default;
8727
- if (defaultModelStr) {
8728
- const slashIdx = defaultModelStr.indexOf("/");
8729
- if (slashIdx > 0) {
8730
- const provider = defaultModelStr.slice(0, slashIdx);
8731
- const modelId = defaultModelStr.slice(slashIdx + 1);
8732
- const availableModels = this.#modelRegistry.getAvailable();
8733
- const match = availableModels.find(m => m.provider === provider && m.id === modelId);
8734
- if (match) {
8735
- const currentModel = this.model;
8736
- const shouldResetProviderState =
8737
- switchingToDifferentSession ||
8738
- (currentModel !== undefined &&
8739
- (currentModel.provider !== match.provider ||
8740
- currentModel.id !== match.id ||
8741
- currentModel.api !== match.api));
8742
- if (shouldResetProviderState) {
8743
- this.#setModelWithProviderSessionReset(match);
8744
- } else {
8745
- this.agent.setModel(match);
8746
- this.#syncToolCallBatchCap(match);
8747
- }
8869
+ const targetModelStrings = getRestorableSessionModels(
8870
+ sessionContext.models,
8871
+ this.sessionManager.getLastModelChangeRole(),
8872
+ );
8873
+ if (targetModelStrings.length > 0) {
8874
+ const availableModels = this.#modelRegistry.getAvailable();
8875
+ let match: Model | undefined;
8876
+ for (const targetModelStr of targetModelStrings) {
8877
+ const slashIdx = targetModelStr.indexOf("/");
8878
+ if (slashIdx <= 0) continue;
8879
+ const provider = targetModelStr.slice(0, slashIdx);
8880
+ const modelId = targetModelStr.slice(slashIdx + 1);
8881
+ match = availableModels.find(m => m.provider === provider && m.id === modelId);
8882
+ if (match) break;
8883
+ }
8884
+ if (match) {
8885
+ const currentModel = this.model;
8886
+ const shouldResetProviderState =
8887
+ switchingToDifferentSession ||
8888
+ (currentModel !== undefined &&
8889
+ (currentModel.provider !== match.provider ||
8890
+ currentModel.id !== match.id ||
8891
+ currentModel.api !== match.api));
8892
+ if (shouldResetProviderState) {
8893
+ this.#setModelWithProviderSessionReset(match);
8894
+ } else {
8895
+ this.agent.setModel(match);
8896
+ this.#syncToolCallBatchCap(match);
8748
8897
  }
8749
8898
  }
8750
8899
  }
@@ -9202,12 +9351,10 @@ export class AgentSession {
9202
9351
  * Uses the last assistant message's usage data when available,
9203
9352
  * otherwise estimates tokens for all messages.
9204
9353
  */
9205
- getContextUsage(): ContextUsage | undefined {
9354
+ getContextUsage(options?: { contextWindow?: number }): ContextUsage | undefined {
9206
9355
  const model = this.model;
9207
- if (!model) return undefined;
9208
-
9209
- const contextWindow = model.contextWindow ?? 0;
9210
- if (contextWindow <= 0) return undefined;
9356
+ const contextWindow = options?.contextWindow ?? model?.contextWindow ?? 0;
9357
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0) return undefined;
9211
9358
 
9212
9359
  // After compaction, the last assistant usage reflects pre-compaction context size.
9213
9360
  // We can only trust usage from an assistant that responded after the latest compaction.
@@ -9248,6 +9395,14 @@ export class AgentSession {
9248
9395
  };
9249
9396
  }
9250
9397
 
9398
+ #ingestProviderUsageHeaders(response: ProviderResponseMetadata, model?: Model): void {
9399
+ if (model?.provider !== "anthropic") return;
9400
+ this.#modelRegistry.authStorage.ingestUsageHeaders("anthropic", response.headers, {
9401
+ sessionId: this.agent.sessionId,
9402
+ baseUrl: this.#modelRegistry.getProviderBaseUrl?.("anthropic"),
9403
+ });
9404
+ }
9405
+
9251
9406
  async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
9252
9407
  const authStorage = this.#modelRegistry.authStorage;
9253
9408
  if (!authStorage.fetchUsageReports) return null;
@@ -9265,7 +9420,7 @@ export class AgentSession {
9265
9420
  } {
9266
9421
  const messages = this.messages;
9267
9422
 
9268
- // Find last assistant message with usage
9423
+ // Find last assistant message with valid usage.
9269
9424
  let lastUsageIndex: number | null = null;
9270
9425
  let lastUsage: Usage | undefined;
9271
9426
  for (let i = messages.length - 1; i >= 0; i--) {