@oh-my-pi/pi-coding-agent 15.9.3 → 15.9.67

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 (142) hide show
  1. package/CHANGELOG.md +74 -1
  2. package/dist/types/cli/classify-install-target.d.ts +5 -1
  3. package/dist/types/config/keybindings.d.ts +4 -1
  4. package/dist/types/config/settings-schema.d.ts +24 -5
  5. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  6. package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +1 -0
  7. package/dist/types/eval/backend.d.ts +6 -6
  8. package/dist/types/eval/bridge-timeout.d.ts +27 -0
  9. package/dist/types/eval/idle-timeout.d.ts +16 -14
  10. package/dist/types/eval/js/executor.d.ts +3 -3
  11. package/dist/types/eval/py/executor.d.ts +2 -2
  12. package/dist/types/eval/py/spawn-options.d.ts +58 -0
  13. package/dist/types/modes/components/assistant-message.d.ts +16 -0
  14. package/dist/types/modes/components/copy-selector.d.ts +22 -0
  15. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  16. package/dist/types/modes/components/error-banner.d.ts +11 -0
  17. package/dist/types/modes/components/model-selector.d.ts +1 -0
  18. package/dist/types/modes/components/tool-execution.d.ts +15 -0
  19. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  20. package/dist/types/modes/components/user-message.d.ts +1 -1
  21. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  22. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  23. package/dist/types/modes/image-references.d.ts +17 -0
  24. package/dist/types/modes/interactive-mode.d.ts +8 -1
  25. package/dist/types/modes/types.d.ts +8 -1
  26. package/dist/types/modes/utils/copy-targets.d.ts +53 -0
  27. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  28. package/dist/types/session/blob-store.d.ts +12 -11
  29. package/dist/types/session/session-manager.d.ts +5 -3
  30. package/dist/types/system-prompt.d.ts +2 -0
  31. package/dist/types/tiny/title-client.d.ts +16 -1
  32. package/dist/types/tool-discovery/mode.d.ts +8 -0
  33. package/dist/types/tools/archive-reader.d.ts +5 -1
  34. package/dist/types/tools/eval-render.d.ts +8 -0
  35. package/dist/types/tools/render-utils.d.ts +25 -0
  36. package/dist/types/tui/code-cell.d.ts +6 -0
  37. package/dist/types/tui/hyperlink.d.ts +12 -0
  38. package/dist/types/tui/output-block.d.ts +11 -0
  39. package/dist/types/web/search/render.d.ts +1 -2
  40. package/package.json +9 -9
  41. package/src/autoresearch/dashboard.ts +11 -21
  42. package/src/cli/classify-install-target.ts +31 -5
  43. package/src/cli/claude-trace-cli.ts +13 -1
  44. package/src/cli/plugin-cli.ts +45 -0
  45. package/src/cli/web-search-cli.ts +0 -1
  46. package/src/config/keybindings.ts +58 -1
  47. package/src/config/model-registry.ts +54 -4
  48. package/src/config/settings-schema.ts +25 -5
  49. package/src/debug/raw-sse.ts +18 -4
  50. package/src/edit/file-snapshot-store.ts +1 -1
  51. package/src/edit/index.ts +1 -1
  52. package/src/edit/renderer.ts +7 -7
  53. package/src/edit/streaming.ts +1 -1
  54. package/src/eval/__tests__/agent-bridge.test.ts +100 -27
  55. package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
  56. package/src/eval/__tests__/idle-timeout.test.ts +26 -12
  57. package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
  58. package/src/eval/__tests__/llm-bridge.test.ts +10 -10
  59. package/src/eval/__tests__/shared-executors.test.ts +2 -2
  60. package/src/eval/agent-bridge.ts +4 -5
  61. package/src/eval/backend.ts +6 -6
  62. package/src/eval/bridge-timeout.ts +44 -0
  63. package/src/eval/idle-timeout.ts +33 -15
  64. package/src/eval/js/executor.ts +10 -10
  65. package/src/eval/llm-bridge.ts +4 -5
  66. package/src/eval/py/executor.ts +6 -6
  67. package/src/eval/py/kernel.ts +11 -1
  68. package/src/eval/py/spawn-options.ts +126 -0
  69. package/src/eval/py/tool-bridge.ts +43 -5
  70. package/src/export/ttsr.ts +9 -0
  71. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
  72. package/src/extensibility/extensions/runner.ts +2 -0
  73. package/src/internal-urls/docs-index.generated.ts +9 -8
  74. package/src/lsp/client.ts +80 -2
  75. package/src/lsp/index.ts +38 -4
  76. package/src/lsp/render.ts +3 -3
  77. package/src/main.ts +8 -2
  78. package/src/modes/components/agent-dashboard.ts +13 -4
  79. package/src/modes/components/assistant-message.ts +44 -1
  80. package/src/modes/components/copy-selector.ts +249 -0
  81. package/src/modes/components/custom-editor.ts +14 -2
  82. package/src/modes/components/error-banner.ts +33 -0
  83. package/src/modes/components/extensions/extension-list.ts +17 -8
  84. package/src/modes/components/history-search.ts +19 -11
  85. package/src/modes/components/model-selector.ts +125 -29
  86. package/src/modes/components/oauth-selector.ts +28 -12
  87. package/src/modes/components/session-observer-overlay.ts +13 -15
  88. package/src/modes/components/session-selector.ts +24 -13
  89. package/src/modes/components/tool-execution.ts +71 -13
  90. package/src/modes/components/transcript-container.ts +93 -32
  91. package/src/modes/components/tree-selector.ts +19 -7
  92. package/src/modes/components/user-message-selector.ts +25 -14
  93. package/src/modes/components/user-message.ts +9 -2
  94. package/src/modes/controllers/command-controller.ts +0 -116
  95. package/src/modes/controllers/event-controller.ts +67 -12
  96. package/src/modes/controllers/input-controller.ts +33 -1
  97. package/src/modes/controllers/selector-controller.ts +38 -1
  98. package/src/modes/image-references.ts +111 -0
  99. package/src/modes/interactive-mode.ts +52 -17
  100. package/src/modes/theme/theme.ts +46 -10
  101. package/src/modes/types.ts +11 -2
  102. package/src/modes/utils/copy-targets.ts +254 -0
  103. package/src/modes/utils/ui-helpers.ts +23 -2
  104. package/src/prompts/ci-green-request.md +5 -3
  105. package/src/prompts/system/project-prompt.md +1 -0
  106. package/src/prompts/tools/ast-edit.md +1 -1
  107. package/src/prompts/tools/ast-grep.md +1 -1
  108. package/src/prompts/tools/read.md +1 -1
  109. package/src/prompts/tools/search.md +1 -1
  110. package/src/sdk.ts +17 -9
  111. package/src/session/agent-session.ts +43 -14
  112. package/src/session/blob-store.ts +96 -9
  113. package/src/session/session-manager.ts +19 -10
  114. package/src/slash-commands/builtin-registry.ts +3 -11
  115. package/src/system-prompt.ts +4 -0
  116. package/src/task/render.ts +38 -11
  117. package/src/tiny/title-client.ts +7 -1
  118. package/src/tool-discovery/mode.ts +24 -0
  119. package/src/tools/archive-reader.ts +339 -31
  120. package/src/tools/bash.ts +18 -8
  121. package/src/tools/browser/render.ts +5 -4
  122. package/src/tools/debug.ts +3 -3
  123. package/src/tools/eval-render.ts +24 -9
  124. package/src/tools/eval.ts +14 -19
  125. package/src/tools/fetch.ts +34 -14
  126. package/src/tools/gh.ts +65 -11
  127. package/src/tools/index.ts +6 -8
  128. package/src/tools/read.ts +65 -19
  129. package/src/tools/render-utils.ts +46 -0
  130. package/src/tools/search-tool-bm25.ts +4 -6
  131. package/src/tools/search.ts +60 -11
  132. package/src/tools/ssh.ts +21 -8
  133. package/src/tools/write.ts +17 -8
  134. package/src/tui/code-cell.ts +19 -4
  135. package/src/tui/hyperlink.ts +42 -7
  136. package/src/tui/output-block.ts +14 -0
  137. package/src/web/search/index.ts +2 -2
  138. package/src/web/search/render.ts +23 -55
  139. package/dist/types/eval/heartbeat.d.ts +0 -45
  140. package/src/eval/__tests__/heartbeat.test.ts +0 -84
  141. package/src/eval/heartbeat.ts +0 -74
  142. /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
@@ -18,7 +18,7 @@ Performs structural code search using AST matching via native ast-grep.
18
18
 
19
19
  <output>
20
20
  - Grouped matches with file path, byte range, line/column ranges, metavariable captures
21
- - Match lines are numbered under a file snapshot tag header in hashline mode: src/foo.ts#0a`, `*42:content` for the matched line, ` 43:content` for context
21
+ - Match lines are numbered under a file snapshot tag header in hashline mode: `[src/foo.ts#1A2B]`, `*42:content` for the matched line, ` 43:content` for context
22
22
  - Summary counts (`totalMatches`, `filesWithMatches`, `filesSearched`) and parse issues when present
23
23
  </output>
24
24
 
@@ -28,7 +28,7 @@ Append `:<sel>` to `path`. The bare path falls back to the default mode.
28
28
 
29
29
  - Reading a directory path returns a depth-limited dirent listing.
30
30
  {{#if IS_HL_MODE}}
31
- - Reading a file with an explicit selector emits a file snapshot tag header and numbered lines: src/foo.ts#0a` then `41:def alpha():`. Copy the PATH#TAG` header for anchored edits; ops use bare line numbers. NEVER fabricate the tag.
31
+ - Reading a file with an explicit selector emits a file snapshot tag header and numbered lines: `[src/foo.ts#1A2B]` then `41:def alpha():`. Copy the `[PATH#TAG]` header for anchored edits; ops use bare line numbers. NEVER fabricate the tag.
32
32
  {{else}}
33
33
  {{#if IS_LINE_NUMBER_MODE}}
34
34
  - Reading a file with an explicit selector returns lines prefixed with line numbers: `41|def alpha():`.
@@ -9,7 +9,7 @@ Searches files using powerful regex matching.
9
9
 
10
10
  <output>
11
11
  {{#if IS_HL_MODE}}
12
- - Text output emits a file snapshot tag header per matched file plus numbered lines: src/login.ts#1f`, `*42:if (user.id) {` (match), ` 43:return user;` (context). Copy the header for anchored edits; ops use bare line numbers.
12
+ - Text output emits a file snapshot tag header per matched file plus numbered lines: `[src/login.ts#1A2B]`, `*42:if (user.id) {` (match), ` 43:return user;` (context). Copy the header for anchored edits; ops use bare line numbers.
13
13
  {{else}}
14
14
  {{#if IS_LINE_NUMBER_MODE}}
15
15
  - Text output is line-number-prefixed
package/src/sdk.ts CHANGED
@@ -130,6 +130,7 @@ import {
130
130
  resolveThinkingLevelForModel,
131
131
  toReasoningEffort,
132
132
  } from "./thinking";
133
+ import { countToolsForAutoDiscovery, resolveEffectiveToolDiscoveryMode } from "./tool-discovery/mode";
133
134
  import {
134
135
  collectDiscoverableTools,
135
136
  type DiscoverableTool,
@@ -157,6 +158,7 @@ import {
157
158
  ResolveTool,
158
159
  renderSearchToolBm25Description,
159
160
  SearchTool,
161
+ SearchToolBm25Tool,
160
162
  setPreferredImageProvider,
161
163
  setPreferredSearchProvider,
162
164
  type Tool,
@@ -1687,6 +1689,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1687
1689
  }
1688
1690
  }
1689
1691
 
1692
+ const effectiveDiscoveryMode = resolveEffectiveToolDiscoveryMode(
1693
+ settings,
1694
+ countToolsForAutoDiscovery(toolRegistry.keys()),
1695
+ );
1696
+ if (effectiveDiscoveryMode !== "off" && !toolRegistry.has("search_tool_bm25")) {
1697
+ const searchTool: Tool = new SearchToolBm25Tool(toolSession);
1698
+ toolRegistry.set(
1699
+ searchTool.name,
1700
+ new ExtensionToolWrapper(wrapToolWithMetaNotice(searchTool), extensionRunner) as Tool,
1701
+ );
1702
+ }
1703
+ const mcpDiscoveryEnabled = effectiveDiscoveryMode !== "off"; // back-compat: true when any discovery active
1704
+
1690
1705
  const reloadSshTool = async (): Promise<AgentTool | null> => {
1691
1706
  if (!requestedToolNameSet.has("ssh")) return null;
1692
1707
  const sshTool = (await loadSshTool({
@@ -1773,6 +1788,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1773
1788
  secretsEnabled,
1774
1789
  workspaceTree: workspaceTreePromise,
1775
1790
  memoryRootEnabled: memoryBackend.id === "local",
1791
+ model: settings.get("includeModelInPrompt") ? getActiveModelString() : undefined,
1776
1792
  });
1777
1793
 
1778
1794
  if (options.systemPrompt === undefined) {
@@ -1805,15 +1821,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1805
1821
  const requestedToolNames = explicitlyRequestedToolNames ?? toolNamesFromRegistry;
1806
1822
  const normalizedRequested = requestedToolNames.filter(name => toolRegistry.has(name));
1807
1823
  const requestedToolNameSet = new Set(normalizedRequested);
1808
- // Effective discovery mode: tools.discoveryMode takes precedence; mcp.discoveryMode is back-compat alias.
1809
- const toolsDiscoveryModeSetting = settings.get("tools.discoveryMode");
1810
- const effectiveDiscoveryMode: "off" | "mcp-only" | "all" =
1811
- toolsDiscoveryModeSetting !== "off"
1812
- ? (toolsDiscoveryModeSetting as "off" | "mcp-only" | "all")
1813
- : settings.get("mcp.discoveryMode")
1814
- ? "mcp-only"
1815
- : "off";
1816
- const mcpDiscoveryEnabled = effectiveDiscoveryMode !== "off"; // back-compat: true when any discovery active
1824
+ // Effective discovery mode is resolved after the full registry exists so auto mode can count MCP/extension tools.
1817
1825
  const defaultInactiveToolNames = new Set(
1818
1826
  registeredTools.filter(tool => tool.definition.defaultInactive).map(tool => tool.definition.name),
1819
1827
  );
@@ -91,6 +91,7 @@ import {
91
91
  extractRetryHint,
92
92
  getAgentDbPath,
93
93
  getInstallId,
94
+ isBunTestRuntime,
94
95
  isEnoent,
95
96
  isUnexpectedSocketCloseMessage,
96
97
  logger,
@@ -192,6 +193,7 @@ import {
192
193
  toReasoningEffort,
193
194
  } from "../thinking";
194
195
  import { shutdownTinyTitleClient } from "../tiny/title-client";
196
+ import { countToolsForAutoDiscovery, resolveEffectiveToolDiscoveryMode } from "../tool-discovery/mode";
195
197
  import {
196
198
  buildDiscoverableToolSearchIndex,
197
199
  collectDiscoverableTools,
@@ -960,6 +962,13 @@ export class AgentSession {
960
962
  * the dominant cause of prompt-cache invalidation in long sessions.
961
963
  */
962
964
  #lastAppliedToolSignature: string | undefined;
965
+ /**
966
+ * Model identifier (`provider/id`) currently rendered into `#baseSystemPrompt`.
967
+ * The prompt surfaces the active model to the agent, so a model switch must
968
+ * trigger a rebuild. Compared against the live model after every model change
969
+ * to decide whether the cached prompt is stale.
970
+ */
971
+ #promptModelKey: string | undefined;
963
972
  #mcpDiscoveryEnabled = false;
964
973
  #discoverableMCPTools = new Map<string, DiscoverableTool>();
965
974
  #selectedMCPToolNames = new Set<string>();
@@ -1028,6 +1037,7 @@ export class AgentSession {
1028
1037
 
1029
1038
  #acquirePowerAssertion(): void {
1030
1039
  if (process.platform !== "darwin") return;
1040
+ if (isBunTestRuntime()) return;
1031
1041
  if (this.#powerAssertion) return;
1032
1042
  const idle = this.settings.get("power.preventIdleSleep");
1033
1043
  const system = this.settings.get("power.preventSystemSleep");
@@ -1173,6 +1183,7 @@ export class AgentSession {
1173
1183
  this.#getMcpServerInstructions = config.getMcpServerInstructions;
1174
1184
  this.#reloadSshTool = config.reloadSshTool;
1175
1185
  this.#baseSystemPrompt = this.agent.state.systemPrompt;
1186
+ this.#promptModelKey = this.#currentPromptModelKey();
1176
1187
  this.#mcpDiscoveryEnabled = config.mcpDiscoveryEnabled ?? false;
1177
1188
  this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry());
1178
1189
  this.#selectedMCPToolNames = new Set(config.initialSelectedMCPToolNames ?? []);
@@ -3264,9 +3275,21 @@ export class AgentSession {
3264
3275
  return resolveEditMode(this.#getEditModeSession());
3265
3276
  }
3266
3277
 
3267
- async #syncEditToolModeAfterModelChange(previousEditMode: EditMode): Promise<void> {
3278
+ /**
3279
+ * Model key (`provider/id`) currently surfaced in the system prompt, or
3280
+ * undefined when the model is unset or `includeModelInPrompt` is disabled.
3281
+ */
3282
+ #currentPromptModelKey(): string | undefined {
3283
+ if (!this.settings.get("includeModelInPrompt")) return undefined;
3284
+ return this.model ? formatModelString(this.model) : undefined;
3285
+ }
3286
+
3287
+ async #syncAfterModelChange(previousEditMode: EditMode): Promise<void> {
3268
3288
  const currentEditMode = this.#resolveActiveEditMode();
3269
- if (previousEditMode !== currentEditMode && this.getActiveToolNames().includes("edit")) {
3289
+ const editModeChanged = previousEditMode !== currentEditMode && this.getActiveToolNames().includes("edit");
3290
+ // The system prompt may surface the active model; a switch makes the cached prompt stale.
3291
+ const modelChanged = this.#currentPromptModelKey() !== this.#promptModelKey;
3292
+ if (editModeChanged || modelChanged) {
3270
3293
  await this.refreshBaseSystemPrompt();
3271
3294
  }
3272
3295
  }
@@ -3305,12 +3328,14 @@ export class AgentSession {
3305
3328
 
3306
3329
  // ── Generic tool discovery (covers built-in + MCP + extension) ────────────
3307
3330
 
3308
- /** Resolve effective discovery mode: tools.discoveryMode wins; mcp.discoveryMode is back-compat alias. */
3331
+ /** Resolve effective discovery mode from the current registry size. */
3309
3332
  #resolveEffectiveDiscoveryMode(): "off" | "mcp-only" | "all" {
3310
- const toolsMode = this.settings.get("tools.discoveryMode");
3311
- if (toolsMode !== "off") return toolsMode as "off" | "mcp-only" | "all";
3312
- if (this.settings.get("mcp.discoveryMode")) return "mcp-only";
3313
- return "off";
3333
+ const mode = resolveEffectiveToolDiscoveryMode(
3334
+ this.settings,
3335
+ countToolsForAutoDiscovery(this.#toolRegistry.keys()),
3336
+ );
3337
+ if (mode !== "off") return mode;
3338
+ return this.#mcpDiscoveryEnabled ? "mcp-only" : "off";
3314
3339
  }
3315
3340
 
3316
3341
  isToolDiscoveryEnabled(): boolean {
@@ -3551,6 +3576,7 @@ export class AgentSession {
3551
3576
  this.#baseSystemPrompt = built.systemPrompt;
3552
3577
  this.agent.setSystemPrompt(this.#baseSystemPrompt);
3553
3578
  this.#lastAppliedToolSignature = signature;
3579
+ this.#promptModelKey = this.#currentPromptModelKey();
3554
3580
  }
3555
3581
  }
3556
3582
  if (options?.persistMCPSelection !== false) {
@@ -3633,6 +3659,7 @@ export class AgentSession {
3633
3659
  const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry);
3634
3660
  this.#baseSystemPrompt = built.systemPrompt;
3635
3661
  this.agent.setSystemPrompt(this.#baseSystemPrompt);
3662
+ this.#promptModelKey = this.#currentPromptModelKey();
3636
3663
  // Refresh the cached signature so a subsequent `#applyActiveToolsByName` with
3637
3664
  // the same tool set does not re-rebuild on top of the explicit refresh we
3638
3665
  // just performed (and conversely, a different set forces a fresh rebuild).
@@ -3692,7 +3719,7 @@ export class AgentSession {
3692
3719
  * closure-captured ones cannot change at runtime regardless of skip behavior.
3693
3720
  * For everything else, callers must explicitly call `refreshBaseSystemPrompt()`
3694
3721
  * after side-effecting changes; see e.g. the memory hooks and
3695
- * `#syncEditToolModeAfterModelChange`.
3722
+ * `#syncAfterModelChange`.
3696
3723
  *
3697
3724
  * The current calendar date IS covered (appended as a segment) because
3698
3725
  * `buildSystemPrompt` injects it into the prompt body (`Today is '{{date}}'`).
@@ -5284,7 +5311,7 @@ export class AgentSession {
5284
5311
  // Re-apply thinking for the newly selected model. Prefer the model's
5285
5312
  // configured defaultLevel; otherwise preserve the current level (or auto).
5286
5313
  this.#reapplyThinkingLevel(model.thinking?.defaultLevel);
5287
- await this.#syncEditToolModeAfterModelChange(previousEditMode);
5314
+ await this.#syncAfterModelChange(previousEditMode);
5288
5315
  }
5289
5316
 
5290
5317
  /**
@@ -5318,7 +5345,7 @@ export class AgentSession {
5318
5345
  } else {
5319
5346
  this.#reapplyThinkingLevel(model.thinking?.defaultLevel);
5320
5347
  }
5321
- await this.#syncEditToolModeAfterModelChange(previousEditMode);
5348
+ await this.#syncAfterModelChange(previousEditMode);
5322
5349
  }
5323
5350
 
5324
5351
  /**
@@ -5462,7 +5489,7 @@ export class AgentSession {
5462
5489
 
5463
5490
  // Apply the scoped model's configured thinking level, preserving auto.
5464
5491
  this.setThinkingLevel(this.#autoThinking ? AUTO_THINKING : next.thinkingLevel);
5465
- await this.#syncEditToolModeAfterModelChange(previousEditMode);
5492
+ await this.#syncAfterModelChange(previousEditMode);
5466
5493
 
5467
5494
  return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };
5468
5495
  }
@@ -5491,7 +5518,7 @@ export class AgentSession {
5491
5518
  this.settings.getStorage()?.recordModelUsage(`${nextModel.provider}/${nextModel.id}`);
5492
5519
  // Re-apply the current thinking level (or auto) for the newly selected model
5493
5520
  this.#reapplyThinkingLevel();
5494
- await this.#syncEditToolModeAfterModelChange(previousEditMode);
5521
+ await this.#syncAfterModelChange(previousEditMode);
5495
5522
 
5496
5523
  return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false };
5497
5524
  }
@@ -8111,8 +8138,10 @@ export class AgentSession {
8111
8138
 
8112
8139
  const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined;
8113
8140
  if (!switchedCredential && currentSelector) {
8114
- this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
8115
- switchedModel = await this.#tryRetryModelFallback(currentSelector);
8141
+ if (retrySettings.modelFallback) {
8142
+ this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
8143
+ switchedModel = await this.#tryRetryModelFallback(currentSelector);
8144
+ }
8116
8145
  if (switchedModel) {
8117
8146
  delayMs = 0;
8118
8147
  } else if (parsedRetryAfterMs && parsedRetryAfterMs > delayMs) {
@@ -5,19 +5,90 @@ import { isEnoent, logger } from "@oh-my-pi/pi-utils";
5
5
 
6
6
  const BLOB_PREFIX = "blob:sha256:";
7
7
 
8
+ export interface BlobPutOptions {
9
+ /** Optional file extension for a sidecar hardlink/copy that OS openers can type-detect. */
10
+ extension?: string;
11
+ }
12
+
8
13
  export interface BlobPutResult {
9
14
  hash: string;
15
+ /** Canonical content-addressed path, always `<dir>/<sha256-hex>`. */
10
16
  path: string;
17
+ /** Path with the requested extension when supplied, otherwise the canonical path. */
18
+ displayPath: string;
11
19
  get ref(): string;
12
20
  }
13
21
 
14
22
  /**
15
23
  * Content-addressed blob store for externalizing large binary data (images) from session JSONL files.
16
24
  *
17
- * Files are stored at `<dir>/<sha256-hex>` with no extension. The SHA-256 hash is computed
18
- * over the raw binary data (not base64). Content-addressing makes writes idempotent and
19
- * provides automatic deduplication across sessions.
25
+ * Files are stored canonically at `<dir>/<sha256-hex>`. Callers may also request
26
+ * a typed sidecar path (`<dir>/<sha256-hex>.<ext>`) for `file://` links and OS
27
+ * image viewers; blob refs and reads still address the extensionless hash path.
28
+ * The SHA-256 hash is computed over the raw binary data (not base64).
29
+ * Content-addressing makes writes idempotent and provides automatic deduplication
30
+ * across sessions.
20
31
  */
32
+
33
+ const IMAGE_EXTENSION_BY_MIME: Record<string, string> = {
34
+ "image/png": "png",
35
+ "image/jpeg": "jpg",
36
+ "image/jpg": "jpg",
37
+ "image/gif": "gif",
38
+ "image/webp": "webp",
39
+ "image/svg+xml": "svg",
40
+ };
41
+
42
+ function normalizeBlobExtension(extension: string | undefined): string | undefined {
43
+ if (!extension) return undefined;
44
+ const normalized = extension.startsWith(".") ? extension.slice(1) : extension;
45
+ if (normalized.length === 0 || normalized.length > 32) return undefined;
46
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(normalized)) return undefined;
47
+ return normalized.toLowerCase();
48
+ }
49
+
50
+ async function ensureDisplayPath(blobPath: string, displayPath: string, data: Buffer): Promise<void> {
51
+ if (displayPath === blobPath) return;
52
+ try {
53
+ await fsp.link(blobPath, displayPath);
54
+ return;
55
+ } catch (err) {
56
+ if (typeof err === "object" && err !== null && "code" in err && err.code === "EEXIST") return;
57
+ logger.debug("Blob display hardlink failed; falling back to copy", {
58
+ blobPath,
59
+ displayPath,
60
+ error: err instanceof Error ? err.message : String(err),
61
+ });
62
+ }
63
+ await Bun.write(displayPath, data);
64
+ }
65
+
66
+ function ensureDisplayPathSync(blobPath: string, displayPath: string, data: Buffer): void {
67
+ if (displayPath === blobPath) return;
68
+ try {
69
+ fs.linkSync(blobPath, displayPath);
70
+ return;
71
+ } catch (err) {
72
+ if (typeof err === "object" && err !== null && "code" in err && err.code === "EEXIST") return;
73
+ logger.debug("Blob display hardlink failed; falling back to copy", {
74
+ blobPath,
75
+ displayPath,
76
+ error: err instanceof Error ? err.message : String(err),
77
+ });
78
+ }
79
+ fs.writeFileSync(displayPath, data);
80
+ }
81
+
82
+ export function blobExtensionForImageMimeType(mimeType: string | undefined): string | undefined {
83
+ if (!mimeType) return undefined;
84
+ const lower = mimeType.toLowerCase();
85
+ const known = IMAGE_EXTENSION_BY_MIME[lower];
86
+ if (known) return known;
87
+ if (!lower.startsWith("image/")) return undefined;
88
+ const subtype = lower.slice("image/".length).split(";")[0]?.split("+")[0];
89
+ return normalizeBlobExtension(subtype);
90
+ }
91
+
21
92
  export class BlobStore {
22
93
  constructor(readonly dir: string) {}
23
94
 
@@ -25,18 +96,22 @@ export class BlobStore {
25
96
  * Write binary data to the blob store.
26
97
  * @returns SHA-256 hex hash of the data
27
98
  */
28
- async put(data: Buffer): Promise<BlobPutResult> {
99
+ async put(data: Buffer, options?: BlobPutOptions): Promise<BlobPutResult> {
29
100
  const hash = new Bun.SHA256().update(data).digest("hex");
30
101
  const blobPath = path.join(this.dir, hash);
102
+ const extension = normalizeBlobExtension(options?.extension);
103
+ const displayPath = extension ? `${blobPath}.${extension}` : blobPath;
31
104
  const result = {
32
105
  hash,
33
106
  path: blobPath,
107
+ displayPath,
34
108
  get ref() {
35
109
  return `${BLOB_PREFIX}${hash}`;
36
110
  },
37
111
  };
38
112
 
39
113
  await Bun.write(blobPath, data);
114
+ await ensureDisplayPath(blobPath, displayPath, data);
40
115
  return result;
41
116
  }
42
117
 
@@ -45,18 +120,22 @@ export class BlobStore {
45
120
  * cannot afford the microtask hops of the async version (e.g. OOM-safe session writes).
46
121
  * Returns once the bytes are in the kernel page cache.
47
122
  */
48
- putSync(data: Buffer): BlobPutResult {
123
+ putSync(data: Buffer, options?: BlobPutOptions): BlobPutResult {
49
124
  const hash = new Bun.SHA256().update(data).digest("hex");
50
125
  const blobPath = path.join(this.dir, hash);
126
+ const extension = normalizeBlobExtension(options?.extension);
127
+ const displayPath = extension ? `${blobPath}.${extension}` : blobPath;
51
128
  const result = {
52
129
  hash,
53
130
  path: blobPath,
131
+ displayPath,
54
132
  get ref() {
55
133
  return `${BLOB_PREFIX}${hash}`;
56
134
  },
57
135
  };
58
136
  fs.mkdirSync(this.dir, { recursive: true });
59
137
  fs.writeFileSync(blobPath, data);
138
+ ensureDisplayPathSync(blobPath, displayPath, data);
60
139
  return result;
61
140
  }
62
141
 
@@ -120,17 +199,25 @@ export function externalizeImageDataUrlSync(blobStore: BlobStore, dataUrl: strin
120
199
  * Externalize an image's base64 data to the blob store, returning a blob reference.
121
200
  * If the data is already a blob reference, returns it unchanged.
122
201
  */
123
- export async function externalizeImageData(blobStore: BlobStore, base64Data: string): Promise<string> {
202
+ export async function externalizeImageData(
203
+ blobStore: BlobStore,
204
+ base64Data: string,
205
+ mimeType?: string,
206
+ ): Promise<string> {
124
207
  if (isBlobRef(base64Data)) return base64Data;
125
208
  const buffer = Buffer.from(base64Data, "base64");
126
- const { ref } = await blobStore.put(buffer);
209
+ const { ref } = await blobStore.put(buffer, {
210
+ extension: blobExtensionForImageMimeType(mimeType),
211
+ });
127
212
  return ref;
128
213
  }
129
214
 
130
215
  /** Synchronous variant of {@link externalizeImageData}. */
131
- export function externalizeImageDataSync(blobStore: BlobStore, base64Data: string): string {
216
+ export function externalizeImageDataSync(blobStore: BlobStore, base64Data: string, mimeType?: string): string {
132
217
  if (isBlobRef(base64Data)) return base64Data;
133
- return blobStore.putSync(Buffer.from(base64Data, "base64")).ref;
218
+ return blobStore.putSync(Buffer.from(base64Data, "base64"), {
219
+ extension: blobExtensionForImageMimeType(mimeType),
220
+ }).ref;
134
221
  }
135
222
 
136
223
  /**
@@ -29,6 +29,7 @@ import {
29
29
  } from "@oh-my-pi/pi-utils";
30
30
  import { ArtifactManager } from "./artifacts";
31
31
  import {
32
+ type BlobPutOptions,
32
33
  type BlobPutResult,
33
34
  BlobStore,
34
35
  externalizeImageData,
@@ -336,6 +337,7 @@ export type ReadonlySessionManager = Pick<
336
337
  | "getTree"
337
338
  | "getUsageStatistics"
338
339
  | "putBlob"
340
+ | "putBlobSync"
339
341
  >;
340
342
 
341
343
  function createSessionId(): string {
@@ -1219,7 +1221,7 @@ async function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?:
1219
1221
  if (key === TEXT_CONTENT_KEY && isImageBlock(item)) {
1220
1222
  if (!isBlobRef(item.data) && item.data.length >= BLOB_EXTERNALIZE_THRESHOLD) {
1221
1223
  changed = true;
1222
- const blobRef = await externalizeImageData(blobStore, item.data);
1224
+ const blobRef = await externalizeImageData(blobStore, item.data, item.mimeType);
1223
1225
  return { ...item, data: blobRef };
1224
1226
  }
1225
1227
  }
@@ -1313,13 +1315,15 @@ function truncateForPersistenceSync(obj: unknown, blobStore: BlobStore, key?: st
1313
1315
  const result: unknown[] = new Array(obj.length);
1314
1316
  for (let i = 0; i < obj.length; i++) {
1315
1317
  const item = obj[i];
1316
- if (key === TEXT_CONTENT_KEY && isImageBlock(item)) {
1317
- if (!isBlobRef(item.data) && item.data.length >= BLOB_EXTERNALIZE_THRESHOLD) {
1318
- changed = true;
1319
- const blobRef = externalizeImageDataSync(blobStore, item.data);
1320
- result[i] = { ...item, data: blobRef };
1321
- continue;
1322
- }
1318
+ if (
1319
+ key === TEXT_CONTENT_KEY &&
1320
+ isImageBlock(item) &&
1321
+ !isBlobRef(item.data) &&
1322
+ item.data.length >= BLOB_EXTERNALIZE_THRESHOLD
1323
+ ) {
1324
+ changed = true;
1325
+ result[i] = { ...item, data: externalizeImageDataSync(blobStore, item.data, item.mimeType) };
1326
+ continue;
1323
1327
  }
1324
1328
  const newItem = truncateForPersistenceSync(item, blobStore, key);
1325
1329
  if (newItem !== item) changed = true;
@@ -1978,8 +1982,13 @@ export class SessionManager {
1978
1982
  }
1979
1983
 
1980
1984
  /** Puts a binary blob into the blob store and returns the blob reference */
1981
- async putBlob(data: Buffer): Promise<BlobPutResult> {
1982
- return this.#blobStore.put(data);
1985
+ async putBlob(data: Buffer, options?: BlobPutOptions): Promise<BlobPutResult> {
1986
+ return this.#blobStore.put(data, options);
1987
+ }
1988
+
1989
+ /** Synchronous variant of {@link putBlob} for rebuild-only render paths. */
1990
+ putBlobSync(data: Buffer, options?: BlobPutOptions): BlobPutResult {
1991
+ return this.#blobStore.putSync(data, options);
1983
1992
  }
1984
1993
 
1985
1994
  captureState(): SessionManagerStateSnapshot {
@@ -392,17 +392,9 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
392
392
  },
393
393
  {
394
394
  name: "copy",
395
- description: "Copy last agent message to clipboard",
396
- subcommands: [
397
- { name: "last", description: "Copy full last agent message" },
398
- { name: "code", description: "Copy last code block" },
399
- { name: "all", description: "Copy all code blocks from last message" },
400
- { name: "cmd", description: "Copy last bash/python command" },
401
- ],
402
- allowArgs: true,
403
- handleTui: async (command, runtime) => {
404
- const sub = command.args.trim().toLowerCase() || undefined;
405
- await runtime.ctx.handleCopyCommand(sub);
395
+ description: "Pick text or code from the conversation to copy",
396
+ handleTui: (_command, runtime) => {
397
+ runtime.ctx.showCopySelector();
406
398
  runtime.ctx.editor.setText("");
407
399
  },
408
400
  },
@@ -363,6 +363,8 @@ export interface BuildSystemPromptOptions {
363
363
  workspaceTree?: WorkspaceTree | Promise<WorkspaceTree>;
364
364
  /** Whether the local memory://root summary is active. */
365
365
  memoryRootEnabled?: boolean;
366
+ /** Active model identifier (e.g. "anthropic/claude-opus-4") surfaced to the agent. */
367
+ model?: string;
366
368
  }
367
369
 
368
370
  /** Result of building provider-facing system prompt messages. */
@@ -396,6 +398,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
396
398
  secretsEnabled = false,
397
399
  workspaceTree: providedWorkspaceTree,
398
400
  memoryRootEnabled = false,
401
+ model,
399
402
  } = options;
400
403
  const resolvedCwd = cwd ?? getProjectDir();
401
404
 
@@ -566,6 +569,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
566
569
  date,
567
570
  dateTime,
568
571
  cwd: promptCwd,
572
+ model: model ?? "",
569
573
  intentTracing: !!intentField,
570
574
  intentField: intentField ?? "",
571
575
  mcpDiscoveryMode,
@@ -13,6 +13,7 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
13
13
  import { formatContextUsage } from "../modes/components/status-line/context-thresholds";
14
14
  import type { Theme } from "../modes/theme/theme";
15
15
  import {
16
+ capPreviewLines,
16
17
  formatBadge,
17
18
  formatDuration,
18
19
  formatMoreItems,
@@ -117,6 +118,26 @@ function normalizeReportFindings(value: unknown): ReportFindingDetails[] {
117
118
  return findings;
118
119
  }
119
120
 
121
+ /**
122
+ * Normalize the `yield` slot of `extractedToolData` into an array of
123
+ * yield-detail records. The subprocess executor always populates this slot as
124
+ * `unknown[]` (see `executor.ts` `extractData` handler), but the renderer
125
+ * MUST also tolerate a stray single object — optional chaining short-circuits
126
+ * on `null`/`undefined` only, so calling `.map` on a plain object would throw
127
+ * `TypeError: completeData?.map is not a function` and crash the TUI.
128
+ * A single object is wrapped as a 1-element array so the review verdict still
129
+ * renders; non-object primitives drop out.
130
+ */
131
+ function normalizeYieldData(value: unknown): Array<{ data: unknown }> {
132
+ if (Array.isArray(value)) {
133
+ return value.filter((item): item is { data: unknown } => item !== null && typeof item === "object");
134
+ }
135
+ if (value !== null && typeof value === "object") {
136
+ return [value as { data: unknown }];
137
+ }
138
+ return [];
139
+ }
140
+
120
141
  function formatJsonScalar(value: unknown, _theme: Theme): string {
121
142
  if (value === null) return "null";
122
143
  if (typeof value === "string") {
@@ -541,10 +562,11 @@ export function renderCall(
541
562
 
542
563
  if (hasContext) {
543
564
  lines.push(` ${branch} ${theme.fg("dim", "Context")}`);
544
- for (const line of context.split("\n")) {
565
+ const contextLines = context.split("\n").map(line => {
545
566
  const content = line ? theme.fg("muted", replaceTabs(line)) : "";
546
- lines.push(` ${vertical} ${content}`);
547
- }
567
+ return ` ${vertical} ${content}`;
568
+ });
569
+ lines.push(...capPreviewLines(contextLines, theme, { expanded: options.expanded, prefix: ` ${vertical} ` }));
548
570
  }
549
571
 
550
572
  // `Tasks` is the last child unless the isolation flag follows it.
@@ -671,12 +693,12 @@ function renderAgentProgress(
671
693
  if (progress.extractedToolData) {
672
694
  // For completed tasks, check for review verdict from yield tool
673
695
  if (progress.status === "completed") {
674
- const completeData = progress.extractedToolData.yield as Array<{ data: unknown }> | undefined;
696
+ const completeData = normalizeYieldData(progress.extractedToolData.yield);
675
697
  const reportFindingData = normalizeReportFindings(progress.extractedToolData.report_finding);
676
698
  const reviewData = completeData
677
- ?.map(c => c.data as SubmitReviewDetails)
699
+ .map(c => c.data as SubmitReviewDetails)
678
700
  .filter(d => d && typeof d === "object" && "overall_correctness" in d);
679
- if (reviewData && reviewData.length > 0) {
701
+ if (reviewData.length > 0) {
680
702
  const summary = reviewData[reviewData.length - 1];
681
703
  const findings = reportFindingData;
682
704
  lines.push(...renderReviewResult(summary, findings, continuePrefix, expanded, theme));
@@ -912,16 +934,21 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
912
934
  );
913
935
  }
914
936
  // Check for review result (yield with review schema + report_finding)
915
- const completeData = result.extractedToolData?.yield as Array<{ data: unknown }> | undefined;
937
+ // Check for review result (yield with review schema + report_finding).
938
+ // `normalizeYieldData` guards against a stray non-array `yield` slot —
939
+ // optional chaining on `.map` only short-circuits on null/undefined and
940
+ // would otherwise crash the renderer with `TypeError: completeData?.map
941
+ // is not a function` when the slot is a plain object (see issue #1987).
942
+ const completeData = normalizeYieldData(result.extractedToolData?.yield);
916
943
  const reportFindingData = normalizeReportFindings(result.extractedToolData?.report_finding);
917
944
 
918
945
  // Extract review verdict from yield tool's data field if it matches SubmitReviewDetails
919
946
  const reviewData = completeData
920
- ?.map(c => c.data as SubmitReviewDetails)
947
+ .map(c => c.data as SubmitReviewDetails)
921
948
  .filter(d => d && typeof d === "object" && "overall_correctness" in d);
922
- const submitReviewData = reviewData && reviewData.length > 0 ? reviewData : undefined;
949
+ const submitReviewData = reviewData.length > 0 ? reviewData : undefined;
923
950
 
924
- if (submitReviewData && submitReviewData.length > 0) {
951
+ if (submitReviewData) {
925
952
  // Use combined review renderer
926
953
  const summary = submitReviewData[submitReviewData.length - 1];
927
954
  const findings = reportFindingData;
@@ -929,7 +956,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
929
956
  return lines;
930
957
  }
931
958
  if (reportFindingData.length > 0) {
932
- const hasCompleteData = completeData && completeData.length > 0;
959
+ const hasCompleteData = completeData.length > 0;
933
960
  const message = hasCompleteData
934
961
  ? "Review verdict missing expected fields"
935
962
  : "Review incomplete (yield not called)";
@@ -261,6 +261,11 @@ export class TinyTitleClient {
261
261
  #pending = new Map<string, PendingRequest>();
262
262
  #progressListeners = new Set<(event: TinyTitleProgressEvent) => void>();
263
263
  #nextRequestId = 0;
264
+ #spawnWorker: () => WorkerHandle;
265
+
266
+ constructor(spawnWorker: () => WorkerHandle = spawnTinyTitleWorker) {
267
+ this.#spawnWorker = spawnWorker;
268
+ }
264
269
 
265
270
  onProgress(listener: (event: TinyTitleProgressEvent) => void): () => void {
266
271
  this.#progressListeners.add(listener);
@@ -392,7 +397,7 @@ export class TinyTitleClient {
392
397
 
393
398
  #ensureWorker(): WorkerHandle {
394
399
  if (this.#worker) return this.#worker;
395
- const worker = spawnTinyTitleWorker();
400
+ const worker = this.#spawnWorker();
396
401
  this.#worker = worker;
397
402
  this.#unsubscribeMessage = worker.onMessage(message => this.#handleMessage(message));
398
403
  this.#unsubscribeError = worker.onError(error => this.#handleWorkerError(error));
@@ -429,6 +434,7 @@ export class TinyTitleClient {
429
434
  this.#emitProgress({ modelKey: pending.modelKey, status: "error" });
430
435
  if (pending.kind === "generate" || pending.kind === "complete") pending.resolve(null);
431
436
  else pending.resolve(false);
437
+ void this.terminate();
432
438
  }
433
439
 
434
440
  #emitProgress(event: TinyTitleProgressEvent): void {