@oh-my-pi/pi-coding-agent 15.7.5 → 15.8.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.
Files changed (146) hide show
  1. package/CHANGELOG.md +159 -182
  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 +5 -0
  9. package/dist/types/config/settings-schema.d.ts +11 -2
  10. package/dist/types/config/settings.d.ts +13 -0
  11. package/dist/types/eval/__tests__/heartbeat.test.d.ts +1 -0
  12. package/dist/types/eval/concurrency-bridge.d.ts +26 -0
  13. package/dist/types/eval/heartbeat.d.ts +45 -0
  14. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  15. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  16. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  17. package/dist/types/extensibility/extensions/types.d.ts +24 -5
  18. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
  19. package/dist/types/lsp/diagnostics-ledger.d.ts +10 -0
  20. package/dist/types/lsp/index.d.ts +2 -0
  21. package/dist/types/lsp/utils.d.ts +4 -0
  22. package/dist/types/main.d.ts +5 -0
  23. package/dist/types/modes/acp/acp-agent.d.ts +1 -1
  24. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  25. package/dist/types/modes/components/custom-editor.d.ts +3 -2
  26. package/dist/types/modes/components/hook-selector.d.ts +10 -1
  27. package/dist/types/modes/components/session-selector.d.ts +32 -5
  28. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  29. package/dist/types/modes/controllers/extension-ui-controller.d.ts +3 -3
  30. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  31. package/dist/types/modes/interactive-mode.d.ts +10 -3
  32. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  33. package/dist/types/modes/theme/theme.d.ts +1 -1
  34. package/dist/types/modes/types.d.ts +5 -3
  35. package/dist/types/registry/agent-registry.d.ts +1 -1
  36. package/dist/types/sdk.d.ts +2 -2
  37. package/dist/types/session/agent-session.d.ts +12 -3
  38. package/dist/types/session/history-storage.d.ts +16 -1
  39. package/dist/types/session/session-manager.d.ts +4 -0
  40. package/dist/types/task/output-manager.d.ts +6 -15
  41. package/dist/types/tools/ask.d.ts +8 -6
  42. package/dist/types/tools/eval-backends.d.ts +12 -0
  43. package/dist/types/tools/eval-render.d.ts +52 -0
  44. package/dist/types/tools/eval.d.ts +2 -35
  45. package/dist/types/tools/find.d.ts +0 -9
  46. package/dist/types/tools/index.d.ts +5 -12
  47. package/dist/types/tools/path-utils.d.ts +16 -0
  48. package/dist/types/tools/sqlite-reader.d.ts +25 -8
  49. package/dist/types/tui/output-block.d.ts +4 -3
  50. package/dist/types/utils/clipboard.d.ts +4 -0
  51. package/dist/types/web/kagi.d.ts +76 -0
  52. package/dist/types/web/search/providers/exa.d.ts +7 -1
  53. package/dist/types/web/search/providers/kagi.d.ts +1 -0
  54. package/examples/extensions/README.md +1 -0
  55. package/examples/extensions/thinking-note.ts +13 -0
  56. package/package.json +9 -9
  57. package/src/async/job-manager.ts +3 -3
  58. package/src/cli/args.ts +6 -2
  59. package/src/cli/claude-trace-cli.ts +783 -0
  60. package/src/cli/session-picker.ts +36 -10
  61. package/src/cli/update-cli.ts +35 -2
  62. package/src/commands/launch.ts +3 -0
  63. package/src/config/keybindings.ts +6 -0
  64. package/src/config/model-registry.ts +33 -4
  65. package/src/config/settings-schema.ts +12 -2
  66. package/src/config/settings.ts +23 -0
  67. package/src/discovery/claude-plugins.ts +7 -9
  68. package/src/discovery/claude.ts +41 -22
  69. package/src/edit/index.ts +23 -3
  70. package/src/eval/__tests__/agent-bridge.test.ts +148 -4
  71. package/src/eval/__tests__/heartbeat.test.ts +84 -0
  72. package/src/eval/__tests__/llm-bridge.test.ts +30 -0
  73. package/src/eval/agent-bridge.ts +44 -38
  74. package/src/eval/concurrency-bridge.ts +34 -0
  75. package/src/eval/heartbeat.ts +74 -0
  76. package/src/eval/js/executor.ts +13 -9
  77. package/src/eval/js/shared/prelude.txt +20 -17
  78. package/src/eval/js/tool-bridge.ts +5 -0
  79. package/src/eval/llm-bridge.ts +20 -14
  80. package/src/eval/py/executor.ts +14 -18
  81. package/src/eval/py/prelude.py +23 -15
  82. package/src/exec/bash-executor.ts +31 -5
  83. package/src/extensibility/extensions/loader.ts +16 -18
  84. package/src/extensibility/extensions/runner.ts +22 -17
  85. package/src/extensibility/extensions/types.ts +39 -5
  86. package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
  87. package/src/extensibility/skills.ts +0 -1
  88. package/src/internal-urls/docs-index.generated.ts +14 -13
  89. package/src/lsp/diagnostics-ledger.ts +51 -0
  90. package/src/lsp/index.ts +9 -22
  91. package/src/lsp/utils.ts +21 -0
  92. package/src/main.ts +92 -24
  93. package/src/modes/acp/acp-agent.ts +8 -4
  94. package/src/modes/acp/acp-event-mapper.ts +54 -4
  95. package/src/modes/components/assistant-message.ts +28 -1
  96. package/src/modes/components/custom-editor.ts +19 -7
  97. package/src/modes/components/hook-selector.ts +229 -44
  98. package/src/modes/components/oauth-selector.ts +12 -6
  99. package/src/modes/components/session-selector.ts +179 -24
  100. package/src/modes/components/tool-execution.ts +36 -7
  101. package/src/modes/controllers/command-controller.ts +2 -11
  102. package/src/modes/controllers/event-controller.ts +5 -2
  103. package/src/modes/controllers/extension-ui-controller.ts +6 -4
  104. package/src/modes/controllers/input-controller.ts +19 -16
  105. package/src/modes/controllers/selector-controller.ts +61 -21
  106. package/src/modes/interactive-mode.ts +127 -16
  107. package/src/modes/rpc/rpc-mode.ts +17 -6
  108. package/src/modes/theme/theme-schema.json +30 -0
  109. package/src/modes/theme/theme.ts +39 -2
  110. package/src/modes/types.ts +7 -3
  111. package/src/modes/utils/ui-helpers.ts +5 -2
  112. package/src/prompts/system/orchestrate-notice.md +5 -3
  113. package/src/prompts/system/workflow-notice.md +2 -2
  114. package/src/prompts/tools/ask.md +2 -1
  115. package/src/prompts/tools/eval.md +6 -6
  116. package/src/prompts/tools/find.md +1 -1
  117. package/src/prompts/tools/irc.md +6 -6
  118. package/src/prompts/tools/search.md +1 -1
  119. package/src/prompts/tools/task.md +1 -1
  120. package/src/registry/agent-registry.ts +1 -1
  121. package/src/sdk.ts +85 -31
  122. package/src/session/agent-session.ts +127 -57
  123. package/src/session/history-storage.ts +56 -12
  124. package/src/session/session-manager.ts +34 -0
  125. package/src/task/output-manager.ts +40 -48
  126. package/src/task/render.ts +3 -8
  127. package/src/tools/ask.ts +74 -32
  128. package/src/tools/browser/tab-worker.ts +8 -5
  129. package/src/tools/eval-backends.ts +38 -0
  130. package/src/tools/eval-render.ts +750 -0
  131. package/src/tools/eval.ts +27 -754
  132. package/src/tools/find.ts +5 -29
  133. package/src/tools/index.ts +8 -38
  134. package/src/tools/path-utils.ts +144 -1
  135. package/src/tools/read.ts +47 -0
  136. package/src/tools/renderers.ts +1 -1
  137. package/src/tools/search.ts +2 -27
  138. package/src/tools/sqlite-reader.ts +92 -9
  139. package/src/tools/write.ts +9 -1
  140. package/src/tui/output-block.ts +5 -4
  141. package/src/utils/clipboard.ts +38 -1
  142. package/src/utils/open.ts +37 -2
  143. package/src/web/kagi.ts +168 -49
  144. package/src/web/search/providers/anthropic.ts +1 -1
  145. package/src/web/search/providers/exa.ts +20 -86
  146. package/src/web/search/providers/kagi.ts +4 -0
@@ -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,
@@ -159,6 +161,7 @@ import { containsOrchestrate, ORCHESTRATE_NOTICE } from "../modes/orchestrate";
159
161
  import { getCurrentThemeName, theme } from "../modes/theme/theme";
160
162
  import { parseTurnBudget } from "../modes/turn-budget";
161
163
  import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink";
164
+ import { computeNonMessageTokens } from "../modes/utils/context-usage";
162
165
  import { containsWorkflow, WORKFLOW_NOTICE } from "../modes/workflow";
163
166
  import type { PlanModeState } from "../plan-mode/state";
164
167
  import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
@@ -226,7 +229,7 @@ import type {
226
229
  SessionContext,
227
230
  SessionManager,
228
231
  } from "./session-manager";
229
- import { getLatestCompactionEntry } from "./session-manager";
232
+ import { getLatestCompactionEntry, getRestorableSessionModels } from "./session-manager";
230
233
  import type { ShakeMode, ShakeResult } from "./shake-types";
231
234
  import { ToolChoiceQueue } from "./tool-choice-queue";
232
235
  import { YieldQueue } from "./yield-queue";
@@ -357,7 +360,7 @@ export interface AgentSessionConfig {
357
360
  * **MUST NOT** dispose it on their own teardown.
358
361
  */
359
362
  ownedAsyncJobManager?: AsyncJobManager;
360
- /** Agent identity (registry id like "0-Main" or "3-Alice") used for IRC routing. */
363
+ /** Agent identity (registry id like "Main" or "Alice") used for IRC routing. */
361
364
  agentId?: string;
362
365
  /** Shared agent registry (for forwarding IRC observations to the main session UI). */
363
366
  agentRegistry?: AgentRegistry;
@@ -580,15 +583,14 @@ function buildSessionMetadata(
580
583
  const accountUuid = authStorage?.getOAuthAccountId("anthropic", sessionId);
581
584
  if (typeof accountUuid === "string" && accountUuid.length > 0) {
582
585
  userId.account_uuid = accountUuid;
583
- // Derive device_id from account_uuid so the payload matches the real CC
584
- // getAPIMetadata shape without hardware fingerprinting. A SHA-256 of a
585
- // namespaced account UUID produces a stable 64-hex value that is
586
- // indistinguishable from a randomly generated device ID on the wire, is
587
- // deterministic per account (survives reinstalls), and is auditable: it
588
- // is derived solely from the OAuth UUID the user already consented to
589
- // share with Anthropic. Omitted when no OAuth credential is available
590
- // (API-key callers) to avoid sending a hash of an empty string.
591
- userId.device_id = crypto.createHash("sha256").update(`omp-device-id-v1:${accountUuid}`).digest("hex");
586
+ // Claude Code's `device_id` is a stable 64-hex install identifier. Use
587
+ // omp's persistent install id as the root instead of deriving it from
588
+ // `account_uuid`: logging into a different Claude account on the same
589
+ // install should not make the device look new.
590
+ userId.device_id = crypto
591
+ .createHash("sha256")
592
+ .update(`omp-claude-device-id-v1:${getInstallId()}`)
593
+ .digest("hex");
592
594
  }
593
595
  }
594
596
  return { user_id: JSON.stringify(userId) };
@@ -1103,10 +1105,12 @@ export class AgentSession {
1103
1105
  this.#onResponse = configuredOnResponse
1104
1106
  ? async (response, model) => {
1105
1107
  this.rawSseDebugBuffer.recordResponse(response, model);
1108
+ this.#ingestProviderUsageHeaders(response, model);
1106
1109
  await configuredOnResponse(response, model);
1107
1110
  }
1108
1111
  : (response, model) => {
1109
1112
  this.rawSseDebugBuffer.recordResponse(response, model);
1113
+ this.#ingestProviderUsageHeaders(response, model);
1110
1114
  };
1111
1115
  const configuredOnSseEvent = config.onSseEvent;
1112
1116
  this.#onSseEvent = configuredOnSseEvent
@@ -2826,13 +2830,14 @@ export class AgentSession {
2826
2830
 
2827
2831
  /**
2828
2832
  * Set agent.sessionId from the session manager and install a dynamic
2829
- * metadata resolver so every API request carries `metadata.user_id` shaped
2830
- * like real Claude Code's `getAPIMetadata` output: `{ session_id,
2831
- * account_uuid }` (the latter only when an Anthropic OAuth credential with
2832
- * a known account UUID is loaded). Resolving live keeps the value in sync
2833
- * with auth-state changes (login/logout, token refresh that surfaces a new
2834
- * account uuid) without needing to re-call `#syncAgentSessionId()` on every
2835
- * such event.
2833
+ * metadata resolver so every Anthropic API request carries
2834
+ * `metadata.user_id` shaped like real Claude Code's `getAPIMetadata` output:
2835
+ * `{ session_id, account_uuid, device_id }`. `account_uuid` is included only
2836
+ * when an Anthropic OAuth credential with a known account UUID is loaded;
2837
+ * `device_id` is derived from the persistent omp install id. Resolving live
2838
+ * keeps the value in sync with auth-state changes (login/logout, token
2839
+ * refresh that surfaces a new account uuid) without needing to re-call
2840
+ * `#syncAgentSessionId()` on every such event.
2836
2841
  */
2837
2842
  #syncAgentSessionId(sessionId?: string): void {
2838
2843
  const sid = this.#providerSessionId ?? sessionId ?? this.sessionManager.getSessionId();
@@ -3617,8 +3622,13 @@ export class AgentSession {
3617
3622
  /**
3618
3623
  * Replace MCP tools in the registry and recompute the visible MCP tool set immediately.
3619
3624
  * This allows /mcp add/remove/reauth to take effect without restarting the session.
3625
+ *
3626
+ * @param mcpTools The new MCP tools to register.
3627
+ * @param options.activateAll When true, force-activates every newly registered MCP tool
3628
+ * regardless of prior selection state. Used when an ACP client provisions MCP servers
3629
+ * for a session where MCP discovery is disabled.
3620
3630
  */
3621
- async refreshMCPTools(mcpTools: CustomTool[]): Promise<void> {
3631
+ async refreshMCPTools(mcpTools: CustomTool[], options?: { activateAll?: boolean }): Promise<void> {
3622
3632
  const previousSelectedMCPToolNames = this.getSelectedMCPToolNames();
3623
3633
  const existingNames = Array.from(this.#toolRegistry.keys());
3624
3634
  for (const name of existingNames) {
@@ -3659,6 +3669,18 @@ export class AgentSession {
3659
3669
  this.#getConfiguredDefaultSelectedMCPToolNames(),
3660
3670
  );
3661
3671
 
3672
+ if (options?.activateAll) {
3673
+ // Force-activate every newly registered MCP tool. This path is used
3674
+ // when an ACP client provisions MCP servers for a session where MCP
3675
+ // discovery is disabled — without it, getSelectedMCPToolNames()
3676
+ // returns only already-active tools (circular deadlock: tools can
3677
+ // only become active if they're already active).
3678
+ const newMcpNames = mcpTools.map(t => t.name);
3679
+ const nextActive = [...new Set([...this.#getActiveNonMCPToolNames(), ...newMcpNames])];
3680
+ await this.#applyActiveToolsByName(nextActive, { previousSelectedMCPToolNames });
3681
+ return;
3682
+ }
3683
+
3662
3684
  const nextActive = [...this.#getActiveNonMCPToolNames(), ...this.getSelectedMCPToolNames()];
3663
3685
  await this.#applyActiveToolsByName(nextActive, { previousSelectedMCPToolNames });
3664
3686
  }
@@ -4279,7 +4301,7 @@ export class AgentSession {
4279
4301
  // next microtask alongside the new turn.
4280
4302
  const lastAssistant = this.#findLastAssistantMessage();
4281
4303
  if (lastAssistant && !options?.skipCompactionCheck) {
4282
- await this.#checkCompaction(lastAssistant, false, false);
4304
+ await this.#checkCompaction(lastAssistant, false, false, false);
4283
4305
  }
4284
4306
 
4285
4307
  // Build messages array (session context, eager todo prelude, then active prompt message)
@@ -4381,6 +4403,11 @@ export class AgentSession {
4381
4403
  }
4382
4404
  }
4383
4405
 
4406
+ await this.#runPrePromptCompactionIfNeeded(messages);
4407
+ if (this.#promptGeneration !== generation) {
4408
+ return;
4409
+ }
4410
+
4384
4411
  const agentPromptOptions = options?.toolChoice ? { toolChoice: options.toolChoice } : undefined;
4385
4412
  await this.#promptAgentWithIdleRetry(messages, agentPromptOptions);
4386
4413
  if (!options?.skipPostPromptRecoveryWait) {
@@ -6091,6 +6118,34 @@ export class AgentSession {
6091
6118
  }
6092
6119
  }
6093
6120
 
6121
+ #estimatePendingPromptTokens(messages: AgentMessage[]): number {
6122
+ let tokens = computeNonMessageTokens(this);
6123
+ for (const message of this.messages) {
6124
+ tokens += estimateTokens(message);
6125
+ }
6126
+ for (const message of messages) {
6127
+ tokens += estimateTokens(message);
6128
+ }
6129
+ return tokens;
6130
+ }
6131
+
6132
+ async #runPrePromptCompactionIfNeeded(messages: AgentMessage[]): Promise<void> {
6133
+ const model = this.model;
6134
+ if (!model) return;
6135
+ const contextWindow = model.contextWindow ?? 0;
6136
+ if (contextWindow <= 0) return;
6137
+ const compactionSettings = this.settings.getGroup("compaction");
6138
+ const contextTokens = this.#estimatePendingPromptTokens(messages);
6139
+ if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return;
6140
+
6141
+ logger.debug("Pre-prompt context maintenance triggered by pending prompt size", {
6142
+ contextTokens,
6143
+ contextWindow,
6144
+ model: `${model.provider}/${model.id}`,
6145
+ });
6146
+ await this.#runAutoCompaction("threshold", false, false, false, { autoContinue: false });
6147
+ }
6148
+
6094
6149
  /**
6095
6150
  * Check if context maintenance or promotion is needed and run it.
6096
6151
  * Called after agent_end and before prompt submission.
@@ -6111,6 +6166,7 @@ export class AgentSession {
6111
6166
  * `agent_end` handler set this to true so `session.prompt()` resolves cleanly; callers
6112
6167
  * on the pre-prompt path (where the next agent turn is about to start) set it to false
6113
6168
  * to avoid racing the deferred handoff against the new turn.
6169
+ * @param autoContinue Whether maintenance may schedule the agent-authored continuation prompt.
6114
6170
  * @returns true when a deferred handoff was scheduled. Callers MUST then skip any
6115
6171
  * subsequent `#scheduleAgentContinue` / reminder appends for this turn — the
6116
6172
  * handoff will replace session state and a concurrent `agent.continue()` would
@@ -6120,6 +6176,7 @@ export class AgentSession {
6120
6176
  assistantMessage: AssistantMessage,
6121
6177
  skipAbortedCheck = true,
6122
6178
  allowDefer = true,
6179
+ autoContinue = true,
6123
6180
  ): Promise<boolean> {
6124
6181
  // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
6125
6182
  if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return false;
@@ -6157,7 +6214,7 @@ export class AgentSession {
6157
6214
  // No promotion target available fall through to compaction
6158
6215
  const compactionSettings = this.settings.getGroup("compaction");
6159
6216
  if (compactionSettings.enabled && compactionSettings.strategy !== "off") {
6160
- await this.#runAutoCompaction("overflow", true, false, allowDefer);
6217
+ await this.#runAutoCompaction("overflow", true, false, allowDefer, { autoContinue });
6161
6218
  }
6162
6219
  return false;
6163
6220
  }
@@ -6189,7 +6246,7 @@ export class AgentSession {
6189
6246
  model: `${assistantMessage.provider}/${assistantMessage.model}`,
6190
6247
  strategy: incompleteCompactionSettings.strategy,
6191
6248
  });
6192
- await this.#runAutoCompaction("incomplete", true, false, allowDefer);
6249
+ await this.#runAutoCompaction("incomplete", true, false, allowDefer, { autoContinue });
6193
6250
  } else {
6194
6251
  // Neither promotion nor compaction is available — surface the dead-end so
6195
6252
  // the user understands why the turn yielded with nothing.
@@ -6215,7 +6272,7 @@ export class AgentSession {
6215
6272
  // Try promotion first — if a larger model is available, switch instead of compacting
6216
6273
  const promoted = await this.#tryContextPromotion(assistantMessage);
6217
6274
  if (!promoted) {
6218
- return await this.#runAutoCompaction("threshold", false, false, allowDefer);
6275
+ return await this.#runAutoCompaction("threshold", false, false, allowDefer, { autoContinue });
6219
6276
  }
6220
6277
  }
6221
6278
  return false;
@@ -6939,17 +6996,18 @@ export class AgentSession {
6939
6996
  willRetry: boolean,
6940
6997
  deferred = false,
6941
6998
  allowDefer = true,
6999
+ options: { autoContinue?: boolean } = {},
6942
7000
  ): Promise<boolean> {
6943
7001
  const compactionSettings = this.settings.getGroup("compaction");
6944
7002
  if (compactionSettings.strategy === "off") return false;
6945
7003
  if (reason !== "idle" && !compactionSettings.enabled) return false;
6946
7004
  const generation = this.#promptGeneration;
6947
-
7005
+ const shouldAutoContinue = options.autoContinue !== false && compactionSettings.autoContinue !== false;
6948
7006
  // Shake runs inline (cheap, no remote LLM). On overflow recovery, if shake
6949
7007
  // reclaims nothing we fall through to the summary-compaction body below so
6950
7008
  // the oversized input still gets resolved.
6951
7009
  if (compactionSettings.strategy === "shake") {
6952
- const outcome = await this.#runAutoShake(reason, willRetry, generation);
7010
+ const outcome = await this.#runAutoShake(reason, willRetry, generation, shouldAutoContinue);
6953
7011
  if (outcome !== "fallback") return false;
6954
7012
  }
6955
7013
  // "overflow" and "incomplete" force inline execution because they are recovery
@@ -7018,7 +7076,7 @@ export class AgentSession {
7018
7076
  aborted: false,
7019
7077
  willRetry: false,
7020
7078
  });
7021
- if (!autoCompactionSignal.aborted && reason !== "idle" && compactionSettings.autoContinue !== false) {
7079
+ if (!autoCompactionSignal.aborted && reason !== "idle" && shouldAutoContinue) {
7022
7080
  this.#scheduleAutoContinuePrompt(generation);
7023
7081
  }
7024
7082
  return false;
@@ -7270,7 +7328,7 @@ export class AgentSession {
7270
7328
  };
7271
7329
  await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry });
7272
7330
 
7273
- if (!willRetry && reason !== "idle" && compactionSettings.autoContinue !== false) {
7331
+ if (!willRetry && reason !== "idle" && shouldAutoContinue) {
7274
7332
  this.#scheduleAutoContinuePrompt(generation);
7275
7333
  }
7276
7334
 
@@ -7348,6 +7406,7 @@ export class AgentSession {
7348
7406
  reason: "overflow" | "threshold" | "idle" | "incomplete",
7349
7407
  willRetry: boolean,
7350
7408
  generation: number,
7409
+ autoContinue: boolean,
7351
7410
  ): Promise<"handled" | "fallback"> {
7352
7411
  const action = "shake";
7353
7412
  await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
@@ -7355,7 +7414,6 @@ export class AgentSession {
7355
7414
  const controller = new AbortController();
7356
7415
  this.#autoCompactionAbortController = controller;
7357
7416
  const signal = controller.signal;
7358
- const compactionSettings = this.settings.getGroup("compaction");
7359
7417
  try {
7360
7418
  const result = await this.shake("elide", { config: DEFAULT_SHAKE_CONFIG, signal });
7361
7419
  if (signal.aborted) {
@@ -7391,7 +7449,7 @@ export class AgentSession {
7391
7449
  skipped: !reclaimed,
7392
7450
  });
7393
7451
 
7394
- if (!willRetry && reason !== "idle" && compactionSettings.autoContinue !== false) {
7452
+ if (!willRetry && reason !== "idle" && autoContinue) {
7395
7453
  this.#scheduleAutoContinuePrompt(generation);
7396
7454
  }
7397
7455
  if (willRetry) {
@@ -8669,28 +8727,34 @@ export class AgentSession {
8669
8727
  }
8670
8728
 
8671
8729
  // Restore model if saved
8672
- const defaultModelStr = sessionContext.models.default;
8673
- if (defaultModelStr) {
8674
- const slashIdx = defaultModelStr.indexOf("/");
8675
- if (slashIdx > 0) {
8676
- const provider = defaultModelStr.slice(0, slashIdx);
8677
- const modelId = defaultModelStr.slice(slashIdx + 1);
8678
- const availableModels = this.#modelRegistry.getAvailable();
8679
- const match = availableModels.find(m => m.provider === provider && m.id === modelId);
8680
- if (match) {
8681
- const currentModel = this.model;
8682
- const shouldResetProviderState =
8683
- switchingToDifferentSession ||
8684
- (currentModel !== undefined &&
8685
- (currentModel.provider !== match.provider ||
8686
- currentModel.id !== match.id ||
8687
- currentModel.api !== match.api));
8688
- if (shouldResetProviderState) {
8689
- this.#setModelWithProviderSessionReset(match);
8690
- } else {
8691
- this.agent.setModel(match);
8692
- this.#syncToolCallBatchCap(match);
8693
- }
8730
+ const targetModelStrings = getRestorableSessionModels(
8731
+ sessionContext.models,
8732
+ this.sessionManager.getLastModelChangeRole(),
8733
+ );
8734
+ if (targetModelStrings.length > 0) {
8735
+ const availableModels = this.#modelRegistry.getAvailable();
8736
+ let match: Model | undefined;
8737
+ for (const targetModelStr of targetModelStrings) {
8738
+ const slashIdx = targetModelStr.indexOf("/");
8739
+ if (slashIdx <= 0) continue;
8740
+ const provider = targetModelStr.slice(0, slashIdx);
8741
+ const modelId = targetModelStr.slice(slashIdx + 1);
8742
+ match = availableModels.find(m => m.provider === provider && m.id === modelId);
8743
+ if (match) break;
8744
+ }
8745
+ if (match) {
8746
+ const currentModel = this.model;
8747
+ const shouldResetProviderState =
8748
+ switchingToDifferentSession ||
8749
+ (currentModel !== undefined &&
8750
+ (currentModel.provider !== match.provider ||
8751
+ currentModel.id !== match.id ||
8752
+ currentModel.api !== match.api));
8753
+ if (shouldResetProviderState) {
8754
+ this.#setModelWithProviderSessionReset(match);
8755
+ } else {
8756
+ this.agent.setModel(match);
8757
+ this.#syncToolCallBatchCap(match);
8694
8758
  }
8695
8759
  }
8696
8760
  }
@@ -9148,12 +9212,10 @@ export class AgentSession {
9148
9212
  * Uses the last assistant message's usage data when available,
9149
9213
  * otherwise estimates tokens for all messages.
9150
9214
  */
9151
- getContextUsage(): ContextUsage | undefined {
9215
+ getContextUsage(options?: { contextWindow?: number }): ContextUsage | undefined {
9152
9216
  const model = this.model;
9153
- if (!model) return undefined;
9154
-
9155
- const contextWindow = model.contextWindow ?? 0;
9156
- if (contextWindow <= 0) return undefined;
9217
+ const contextWindow = options?.contextWindow ?? model?.contextWindow ?? 0;
9218
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0) return undefined;
9157
9219
 
9158
9220
  // After compaction, the last assistant usage reflects pre-compaction context size.
9159
9221
  // We can only trust usage from an assistant that responded after the latest compaction.
@@ -9194,6 +9256,14 @@ export class AgentSession {
9194
9256
  };
9195
9257
  }
9196
9258
 
9259
+ #ingestProviderUsageHeaders(response: ProviderResponseMetadata, model?: Model): void {
9260
+ if (model?.provider !== "anthropic") return;
9261
+ this.#modelRegistry.authStorage.ingestUsageHeaders("anthropic", response.headers, {
9262
+ sessionId: this.agent.sessionId,
9263
+ baseUrl: this.#modelRegistry.getProviderBaseUrl?.("anthropic"),
9264
+ });
9265
+ }
9266
+
9197
9267
  async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
9198
9268
  const authStorage = this.#modelRegistry.authStorage;
9199
9269
  if (!authStorage.fetchUsageReports) return null;
@@ -9211,7 +9281,7 @@ export class AgentSession {
9211
9281
  } {
9212
9282
  const messages = this.messages;
9213
9283
 
9214
- // Find last assistant message with usage
9284
+ // Find last assistant message with valid usage.
9215
9285
  let lastUsageIndex: number | null = null;
9216
9286
  let lastUsage: Usage | undefined;
9217
9287
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -8,6 +8,8 @@ export interface HistoryEntry {
8
8
  prompt: string;
9
9
  created_at: number;
10
10
  cwd?: string;
11
+ /** ID of the session the prompt was submitted from, if known. */
12
+ sessionId?: string;
11
13
  }
12
14
 
13
15
  type HistoryRow = {
@@ -15,6 +17,7 @@ type HistoryRow = {
15
17
  prompt: string;
16
18
  created_at: number;
17
19
  cwd: string | null;
20
+ session_id: string | null;
18
21
  };
19
22
 
20
23
  const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
@@ -62,7 +65,8 @@ class AsyncDrain<T> {
62
65
  export class HistoryStorage {
63
66
  #db: Database;
64
67
  static #instance?: HistoryStorage;
65
- #drain = new AsyncDrain<Pick<HistoryEntry, "prompt" | "cwd">>(100);
68
+ #drain = new AsyncDrain<Pick<HistoryEntry, "prompt" | "cwd" | "sessionId">>(100);
69
+ #sessionResolver?: () => string | undefined;
66
70
 
67
71
  // Prepared statements
68
72
  #insertRowStmt: Statement;
@@ -91,7 +95,8 @@ CREATE TABLE IF NOT EXISTS history (
91
95
  id INTEGER PRIMARY KEY AUTOINCREMENT,
92
96
  prompt TEXT NOT NULL,
93
97
  created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}),
94
- cwd TEXT
98
+ cwd TEXT,
99
+ session_id TEXT
95
100
  );
96
101
  CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC);
97
102
 
@@ -106,6 +111,10 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
106
111
  this.#migrateHistorySchema();
107
112
  }
108
113
 
114
+ if (!this.#historySchemaHasColumn("session_id")) {
115
+ this.#db.run("ALTER TABLE history ADD COLUMN session_id TEXT");
116
+ }
117
+
109
118
  if (!hasFts) {
110
119
  try {
111
120
  this.#db.run("INSERT INTO history_fts(history_fts) VALUES('rebuild')");
@@ -115,14 +124,14 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
115
124
  }
116
125
 
117
126
  this.#recentStmt = this.#db.prepare(
118
- "SELECT id, prompt, created_at, cwd FROM history ORDER BY created_at DESC, id DESC LIMIT ?",
127
+ "SELECT id, prompt, created_at, cwd, session_id FROM history ORDER BY created_at DESC, id DESC LIMIT ?",
119
128
  );
120
129
  this.#searchStmt = this.#db.prepare(
121
- "SELECT h.id, h.prompt, h.created_at, h.cwd FROM history_fts f JOIN history h ON h.id = f.rowid WHERE history_fts MATCH ? ORDER BY h.created_at DESC, h.id DESC LIMIT ?",
130
+ "SELECT h.id, h.prompt, h.created_at, h.cwd, h.session_id FROM history_fts f JOIN history h ON h.id = f.rowid WHERE history_fts MATCH ? ORDER BY h.created_at DESC, h.id DESC LIMIT ?",
122
131
  );
123
132
  this.#lastPromptStmt = this.#db.prepare("SELECT prompt FROM history ORDER BY id DESC LIMIT 1");
124
133
 
125
- this.#insertRowStmt = this.#db.prepare("INSERT INTO history (prompt, cwd) VALUES (?, ?)");
134
+ this.#insertRowStmt = this.#db.prepare("INSERT INTO history (prompt, cwd, session_id) VALUES (?, ?, ?)");
126
135
 
127
136
  const last = this.#lastPromptStmt.get() as { prompt?: string } | undefined;
128
137
  this.#lastPromptCache = last?.prompt ?? null;
@@ -140,20 +149,30 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
140
149
  HistoryStorage.#instance = undefined;
141
150
  }
142
151
 
143
- #insertBatch(rows: Array<Pick<HistoryEntry, "prompt" | "cwd">>): void {
144
- this.#db.transaction((rows: Array<Pick<HistoryEntry, "prompt" | "cwd">>) => {
152
+ #insertBatch(rows: Array<Pick<HistoryEntry, "prompt" | "cwd" | "sessionId">>): void {
153
+ this.#db.transaction((rows: Array<Pick<HistoryEntry, "prompt" | "cwd" | "sessionId">>) => {
145
154
  for (const row of rows) {
146
- this.#insertRowStmt.run(row.prompt, row.cwd ?? null);
155
+ this.#insertRowStmt.run(row.prompt, row.cwd ?? null, row.sessionId ?? null);
147
156
  }
148
157
  })(rows);
149
158
  }
150
159
 
151
- add(prompt: string, cwd?: string): Promise<void> {
160
+ /**
161
+ * Register a resolver that supplies the current session ID for prompts added
162
+ * without an explicit `sessionId`. Evaluated synchronously at `add()` time so
163
+ * batched writes capture the session active when the prompt was submitted.
164
+ */
165
+ setSessionResolver(resolver: () => string | undefined): void {
166
+ this.#sessionResolver = resolver;
167
+ }
168
+
169
+ add(prompt: string, cwd?: string, sessionId?: string): Promise<void> {
152
170
  const trimmed = prompt.trim();
153
171
  if (!trimmed) return Promise.resolve();
154
172
  if (this.#lastPromptCache === trimmed) return Promise.resolve();
155
173
  this.#lastPromptCache = trimmed;
156
- return this.#drain.push({ prompt: trimmed, cwd: cwd ?? undefined }, rows => {
174
+ const session = sessionId ?? this.#sessionResolver?.();
175
+ return this.#drain.push({ prompt: trimmed, cwd: cwd ?? undefined, sessionId: session || undefined }, rows => {
157
176
  this.#insertBatch(rows);
158
177
  });
159
178
  }
@@ -224,6 +243,24 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
224
243
  return merged;
225
244
  }
226
245
 
246
+ /**
247
+ * IDs of the sessions whose stored prompts match `query`, ordered by match
248
+ * relevance (most relevant/recent first) and de-duplicated. Prompts with no
249
+ * recorded session are skipped. Used to augment session ranking in the
250
+ * resume picker with prompts that the 4KB session-list prefix never sees.
251
+ */
252
+ matchingSessionIds(query: string, limit = 500): string[] {
253
+ const seen = new Set<string>();
254
+ const ids: string[] = [];
255
+ for (const entry of this.search(query, limit)) {
256
+ const id = entry.sessionId;
257
+ if (!id || seen.has(id)) continue;
258
+ seen.add(id);
259
+ ids.push(id);
260
+ }
261
+ return ids;
262
+ }
263
+
227
264
  #ensureDir(dbPath: string): void {
228
265
  const dir = path.dirname(dbPath);
229
266
  fs.mkdirSync(dir, { recursive: true });
@@ -236,6 +273,11 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
236
273
  return row?.sql?.includes("unixepoch(") ?? false;
237
274
  }
238
275
 
276
+ #historySchemaHasColumn(column: string): boolean {
277
+ const columns = this.#db.prepare("PRAGMA table_info(history)").all() as Array<{ name: string }>;
278
+ return columns.some(col => col.name === column);
279
+ }
280
+
239
281
  #migrateHistorySchema(): void {
240
282
  const migrate = this.#db.transaction(() => {
241
283
  this.#db.run("ALTER TABLE history RENAME TO history_legacy");
@@ -247,7 +289,8 @@ CREATE TABLE history (
247
289
  id INTEGER PRIMARY KEY AUTOINCREMENT,
248
290
  prompt TEXT NOT NULL,
249
291
  created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}),
250
- cwd TEXT
292
+ cwd TEXT,
293
+ session_id TEXT
251
294
  );
252
295
  CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC);
253
296
  INSERT INTO history (id, prompt, created_at, cwd)
@@ -294,7 +337,7 @@ END;
294
337
  if (stmt) return stmt;
295
338
  const whereClause = Array(tokenCount).fill("prompt LIKE ? ESCAPE '\\' COLLATE NOCASE").join(" AND ");
296
339
  stmt = this.#db.prepare(
297
- `SELECT id, prompt, created_at, cwd FROM history WHERE ${whereClause} ORDER BY created_at DESC, id DESC LIMIT ?`,
340
+ `SELECT id, prompt, created_at, cwd, session_id FROM history WHERE ${whereClause} ORDER BY created_at DESC, id DESC LIMIT ?`,
298
341
  );
299
342
  this.#substringStmts.set(tokenCount, stmt);
300
343
  return stmt;
@@ -306,6 +349,7 @@ END;
306
349
  prompt: row.prompt,
307
350
  created_at: row.created_at,
308
351
  cwd: row.cwd ?? undefined,
352
+ sessionId: row.session_id ?? undefined,
309
353
  };
310
354
  }
311
355
  }
@@ -254,6 +254,22 @@ export interface SessionContext {
254
254
  modeData?: Record<string, unknown>;
255
255
  }
256
256
 
257
+ /** Lists session model strings to try when restoring, in fallback order. */
258
+ export function getRestorableSessionModels(
259
+ models: Readonly<Record<string, string>>,
260
+ lastModelChangeRole: string | undefined,
261
+ ): string[] {
262
+ const defaultModel = models.default;
263
+ if (!lastModelChangeRole || lastModelChangeRole === "default" || lastModelChangeRole === "temporary") {
264
+ return defaultModel ? [defaultModel] : [];
265
+ }
266
+
267
+ const roleModel = models[lastModelChangeRole];
268
+ if (!roleModel) return defaultModel ? [defaultModel] : [];
269
+ if (!defaultModel || roleModel === defaultModel) return [roleModel];
270
+ return [roleModel, defaultModel];
271
+ }
272
+
257
273
  export interface SessionInfo {
258
274
  path: string;
259
275
  id: string;
@@ -1808,6 +1824,8 @@ export async function resolveResumableSession(
1808
1824
  return { session: globalMatch, scope: "global" };
1809
1825
  }
1810
1826
  interface SessionManagerStateSnapshot {
1827
+ cwd: string;
1828
+ sessionDir: string;
1811
1829
  sessionId: string;
1812
1830
  sessionName: string | undefined;
1813
1831
  titleSource: "auto" | "user" | undefined;
@@ -1880,6 +1898,8 @@ export class SessionManager {
1880
1898
 
1881
1899
  captureState(): SessionManagerStateSnapshot {
1882
1900
  return {
1901
+ cwd: this.cwd,
1902
+ sessionDir: this.sessionDir,
1883
1903
  sessionId: this.#sessionId,
1884
1904
  sessionName: this.#sessionName,
1885
1905
  titleSource: this.#titleSource,
@@ -1893,6 +1913,8 @@ export class SessionManager {
1893
1913
  }
1894
1914
 
1895
1915
  restoreState(snapshot: SessionManagerStateSnapshot): void {
1916
+ this.cwd = snapshot.cwd;
1917
+ this.sessionDir = snapshot.sessionDir;
1896
1918
  this.#sessionId = snapshot.sessionId;
1897
1919
  this.#sessionName = snapshot.sessionName;
1898
1920
  this.#titleSource = snapshot.titleSource;
@@ -1938,6 +1960,18 @@ export class SessionManager {
1938
1960
  this.#sessionName = header?.title;
1939
1961
  this.#titleSource = header?.titleSource;
1940
1962
 
1963
+ // Adopt the loaded session's own working directory. Sessions are stored in
1964
+ // a directory keyed by their cwd, so resuming a session from another
1965
+ // project (e.g. global review in the picker) must re-point cwd/sessionDir
1966
+ // at that project. Same-cwd resumes and in-place reloads are a no-op; old
1967
+ // sessions with no recorded cwd keep the current cwd.
1968
+ const headerCwd = header?.cwd ? path.resolve(header.cwd) : undefined;
1969
+ if (headerCwd && headerCwd !== this.cwd) {
1970
+ this.cwd = headerCwd;
1971
+ this.sessionDir = path.resolve(this.#sessionFile, "..");
1972
+ writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
1973
+ }
1974
+
1941
1975
  this.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(this.#fileEntries);
1942
1976
 
1943
1977
  await resolveBlobRefsInEntries(this.#fileEntries, this.#blobStore);