@oh-my-pi/pi-coding-agent 15.10.4 → 15.10.6

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 (165) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/types/capability/rule-buckets.d.ts +1 -1
  3. package/dist/types/capability/rule.d.ts +6 -1
  4. package/dist/types/cli/update-cli.d.ts +11 -1
  5. package/dist/types/config/model-registry.d.ts +18 -1
  6. package/dist/types/discovery/at-imports.d.ts +15 -0
  7. package/dist/types/edit/diff.d.ts +3 -2
  8. package/dist/types/eval/__tests__/helpers-local-roots.test.d.ts +1 -0
  9. package/dist/types/eval/backend.d.ts +7 -0
  10. package/dist/types/eval/js/context-manager.d.ts +1 -0
  11. package/dist/types/eval/js/executor.d.ts +2 -0
  12. package/dist/types/eval/js/index.d.ts +1 -1
  13. package/dist/types/eval/js/shared/helpers.d.ts +6 -0
  14. package/dist/types/eval/js/shared/runtime.d.ts +5 -0
  15. package/dist/types/eval/js/worker-protocol.d.ts +6 -0
  16. package/dist/types/eval/py/executor.d.ts +7 -0
  17. package/dist/types/eval/py/index.d.ts +1 -1
  18. package/dist/types/exa/index.d.ts +1 -19
  19. package/dist/types/exa/mcp-client.d.ts +10 -3
  20. package/dist/types/exa/types.d.ts +0 -83
  21. package/dist/types/export/ttsr.d.ts +14 -0
  22. package/dist/types/extensibility/extensions/types.d.ts +8 -1
  23. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +1 -1
  24. package/dist/types/internal-urls/local-protocol.d.ts +10 -0
  25. package/dist/types/mcp/oauth-flow.d.ts +2 -2
  26. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  27. package/dist/types/modes/components/{status-line.d.ts → status-line/component.d.ts} +2 -32
  28. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  29. package/dist/types/modes/components/status-line/types.d.ts +31 -2
  30. package/dist/types/modes/controllers/mcp-command-controller.d.ts +8 -0
  31. package/dist/types/modes/image-references.d.ts +8 -3
  32. package/dist/types/modes/interactive-mode.d.ts +9 -1
  33. package/dist/types/modes/theme/theme.d.ts +2 -1
  34. package/dist/types/modes/types.d.ts +3 -1
  35. package/dist/types/modes/utils/ui-helpers.d.ts +2 -2
  36. package/dist/types/session/agent-session.d.ts +0 -2
  37. package/dist/types/task/render.d.ts +1 -0
  38. package/dist/types/tools/ask.d.ts +1 -0
  39. package/dist/types/tools/browser/tab-worker.d.ts +15 -0
  40. package/dist/types/tools/index.d.ts +17 -2
  41. package/dist/types/tools/render-utils.d.ts +1 -1
  42. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  43. package/dist/types/utils/block-context.d.ts +35 -0
  44. package/dist/types/utils/git.d.ts +6 -0
  45. package/dist/types/utils/image-loading.d.ts +12 -0
  46. package/package.json +29 -9
  47. package/src/capability/rule-buckets.ts +4 -2
  48. package/src/capability/rule.ts +10 -1
  49. package/src/cli/auth-broker-cli.ts +6 -7
  50. package/src/cli/auth-gateway-cli.ts +4 -3
  51. package/src/cli/list-models.ts +5 -0
  52. package/src/cli/update-cli.ts +138 -16
  53. package/src/commit/agentic/tools/split-commit.ts +8 -1
  54. package/src/config/model-provider-priority.ts +1 -0
  55. package/src/config/model-registry.ts +81 -2
  56. package/src/debug/index.ts +4 -8
  57. package/src/discovery/at-imports.ts +273 -0
  58. package/src/discovery/builtin-rules/index.ts +4 -0
  59. package/src/discovery/builtin-rules/ts-no-test-timers.md +55 -0
  60. package/src/discovery/builtin-rules/ts-redundant-clear-guard.md +75 -0
  61. package/src/discovery/helpers.ts +2 -1
  62. package/src/edit/diff.ts +114 -4
  63. package/src/edit/hashline/diff.ts +1 -1
  64. package/src/edit/hashline/execute.ts +1 -1
  65. package/src/edit/modes/patch.ts +6 -2
  66. package/src/edit/modes/replace.ts +1 -1
  67. package/src/edit/renderer.ts +12 -2
  68. package/src/eval/__tests__/helpers-local-roots.test.ts +58 -0
  69. package/src/eval/backend.ts +15 -0
  70. package/src/eval/js/context-manager.ts +4 -2
  71. package/src/eval/js/executor.ts +3 -0
  72. package/src/eval/js/index.ts +7 -1
  73. package/src/eval/js/shared/helpers.ts +53 -6
  74. package/src/eval/js/shared/runtime.ts +8 -0
  75. package/src/eval/js/worker-core.ts +1 -0
  76. package/src/eval/js/worker-protocol.ts +6 -0
  77. package/src/eval/py/executor.ts +12 -0
  78. package/src/eval/py/index.ts +7 -1
  79. package/src/eval/py/prelude.py +43 -4
  80. package/src/eval/py/runner.py +1 -0
  81. package/src/exa/index.ts +1 -26
  82. package/src/exa/mcp-client.ts +10 -10
  83. package/src/exa/types.ts +0 -97
  84. package/src/export/ttsr.ts +122 -1
  85. package/src/extensibility/extensions/types.ts +8 -1
  86. package/src/extensibility/legacy-pi-ai-shim.ts +1 -1
  87. package/src/extensibility/plugins/doctor.ts +1 -1
  88. package/src/extensibility/plugins/legacy-pi-compat.ts +6 -5
  89. package/src/goals/tools/goal-tool.ts +1 -1
  90. package/src/internal-urls/docs-index.generated.ts +7 -6
  91. package/src/internal-urls/local-protocol.ts +13 -0
  92. package/src/lsp/render.ts +8 -6
  93. package/src/mcp/oauth-flow.ts +3 -3
  94. package/src/mcp/render.ts +7 -1
  95. package/src/modes/components/agent-dashboard.ts +6 -4
  96. package/src/modes/components/custom-editor.ts +12 -6
  97. package/src/modes/components/login-dialog.ts +1 -1
  98. package/src/modes/components/oauth-selector.ts +4 -4
  99. package/src/modes/components/read-tool-group.ts +10 -3
  100. package/src/modes/components/{status-line.ts → status-line/component.ts} +18 -40
  101. package/src/modes/components/status-line/index.ts +1 -0
  102. package/src/modes/components/status-line/types.ts +23 -8
  103. package/src/modes/components/tool-execution.ts +1 -1
  104. package/src/modes/components/transcript-container.ts +17 -10
  105. package/src/modes/components/user-message.ts +6 -3
  106. package/src/modes/components/welcome.ts +1 -1
  107. package/src/modes/controllers/event-controller.ts +8 -0
  108. package/src/modes/controllers/extension-ui-controller.ts +143 -127
  109. package/src/modes/controllers/input-controller.ts +60 -11
  110. package/src/modes/controllers/mcp-command-controller.ts +52 -17
  111. package/src/modes/controllers/selector-controller.ts +4 -11
  112. package/src/modes/controllers/ssh-command-controller.ts +2 -2
  113. package/src/modes/image-references.ts +13 -7
  114. package/src/modes/interactive-mode.ts +35 -3
  115. package/src/modes/rpc/rpc-mode.ts +1 -1
  116. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -11
  117. package/src/modes/theme/theme.ts +95 -1
  118. package/src/modes/types.ts +3 -1
  119. package/src/modes/utils/ui-helpers.ts +14 -5
  120. package/src/prompts/tools/bash.md +1 -1
  121. package/src/prompts/tools/eval.md +4 -4
  122. package/src/sdk.ts +31 -14
  123. package/src/session/agent-session.ts +290 -196
  124. package/src/session/session-manager.ts +1 -1
  125. package/src/slash-commands/builtin-registry.ts +9 -1
  126. package/src/system-prompt.ts +15 -9
  127. package/src/task/index.ts +9 -1
  128. package/src/task/render.ts +36 -14
  129. package/src/tools/ask.ts +14 -5
  130. package/src/tools/bash-interactive.ts +1 -1
  131. package/src/tools/bash.ts +14 -2
  132. package/src/tools/browser/render.ts +5 -2
  133. package/src/tools/browser/tab-worker.ts +211 -91
  134. package/src/tools/debug.ts +5 -2
  135. package/src/tools/eval-render.ts +6 -3
  136. package/src/tools/eval.ts +1 -1
  137. package/src/tools/gh-renderer.ts +29 -15
  138. package/src/tools/index.ts +32 -4
  139. package/src/tools/inspect-image-renderer.ts +12 -5
  140. package/src/tools/job.ts +9 -6
  141. package/src/tools/memory-render.ts +19 -5
  142. package/src/tools/read.ts +165 -18
  143. package/src/tools/render-utils.ts +3 -1
  144. package/src/tools/resolve.ts +1 -1
  145. package/src/tools/review.ts +1 -1
  146. package/src/tools/ssh.ts +4 -1
  147. package/src/tools/todo.ts +8 -1
  148. package/src/tools/tool-timeouts.ts +1 -1
  149. package/src/tools/write.ts +1 -1
  150. package/src/tui/code-cell.ts +1 -1
  151. package/src/utils/block-context.ts +312 -0
  152. package/src/utils/git.ts +41 -0
  153. package/src/utils/image-loading.ts +31 -1
  154. package/src/web/search/providers/codex.ts +1 -1
  155. package/src/web/search/render.ts +14 -6
  156. package/dist/types/exa/factory.d.ts +0 -13
  157. package/dist/types/exa/render.d.ts +0 -19
  158. package/dist/types/exa/researcher.d.ts +0 -9
  159. package/dist/types/exa/search.d.ts +0 -9
  160. package/dist/types/exa/websets.d.ts +0 -9
  161. package/src/exa/factory.ts +0 -60
  162. package/src/exa/render.ts +0 -244
  163. package/src/exa/researcher.ts +0 -36
  164. package/src/exa/search.ts +0 -47
  165. package/src/exa/websets.ts +0 -248
@@ -13,7 +13,6 @@
13
13
  * Modes use this class and add their own I/O layer on top.
14
14
  */
15
15
 
16
- import * as crypto from "node:crypto";
17
16
  import * as fs from "node:fs";
18
17
  import * as os from "node:os";
19
18
  import * as path from "node:path";
@@ -33,6 +32,7 @@ import {
33
32
  resolveTelemetry,
34
33
  ThinkingLevel,
35
34
  } from "@oh-my-pi/pi-agent-core";
35
+
36
36
  import {
37
37
  AGGRESSIVE_SHAKE_CONFIG,
38
38
  AUTO_HANDOFF_THRESHOLD_FOCUS,
@@ -77,6 +77,7 @@ import type {
77
77
  import {
78
78
  calculateRateLimitBackoffMs,
79
79
  clearAnthropicFastModeFallback,
80
+ deriveClaudeDeviceId,
80
81
  Effort,
81
82
  getSupportedEfforts,
82
83
  isContextOverflow,
@@ -215,6 +216,7 @@ import { parseCommandArgs } from "../utils/command-args";
215
216
  import { type EditMode, resolveEditMode } from "../utils/edit-mode";
216
217
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
217
218
  import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
219
+ import { normalizeModelContextImages } from "../utils/image-loading";
218
220
  import { buildNamedToolChoice } from "../utils/tool-choice";
219
221
  import type { AuthStorage } from "./auth-storage";
220
222
  import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
@@ -227,6 +229,7 @@ import {
227
229
  type PythonExecutionMessage,
228
230
  readPendingDisplayTag,
229
231
  SILENT_ABORT_MARKER,
232
+ SKILL_PROMPT_MESSAGE_TYPE,
230
233
  stripImagesFromMessage,
231
234
  } from "./messages";
232
235
  import { formatSessionDumpText } from "./session-dump-format";
@@ -531,15 +534,6 @@ function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): strin
531
534
  }
532
535
 
533
536
  const IRC_REPLY_MAX_BYTES = 4096;
534
- export const ANTHROPIC_TOOL_CALL_BATCH_CAP = 4;
535
- const CLAUDE_OPUS_4_8_MODEL_ID = /(?:^|[./_-])claude-opus-4[.-]8\b/i;
536
-
537
- export function resolveToolCallBatchCapForModel(model: Model | undefined): number | undefined {
538
- if (!model) return undefined;
539
- return model.provider === "anthropic" && CLAUDE_OPUS_4_8_MODEL_ID.test(model.id)
540
- ? ANTHROPIC_TOOL_CALL_BATCH_CAP
541
- : undefined;
542
- }
543
537
 
544
538
  /**
545
539
  * Collapse degenerate IRC ephemeral replies before they hit the relay.
@@ -613,14 +607,10 @@ function buildSessionMetadata(
613
607
  const accountUuid = authStorage?.getOAuthAccountId("anthropic", sessionId);
614
608
  if (typeof accountUuid === "string" && accountUuid.length > 0) {
615
609
  userId.account_uuid = accountUuid;
616
- // Claude Code's `device_id` is a stable 64-hex install identifier. Use
617
- // omp's persistent install id as the root instead of deriving it from
618
- // `account_uuid`: logging into a different Claude account on the same
619
- // install should not make the device look new.
620
- userId.device_id = crypto
621
- .createHash("sha256")
622
- .update(`omp-claude-device-id-v1:${getInstallId()}`)
623
- .digest("hex");
610
+ // Claude Code's `device_id` is a stable 64-hex account-scoped install
611
+ // identifier. Include both omp's persistent install id and the Claude
612
+ // account UUID so two accounts on the same install do not share a device.
613
+ userId.device_id = deriveClaudeDeviceId(getInstallId(), accountUuid);
624
614
  }
625
615
  }
626
616
  return { user_id: JSON.stringify(userId) };
@@ -1102,10 +1092,6 @@ export class AgentSession {
1102
1092
  this.#flushPendingAgentEnd();
1103
1093
  }
1104
1094
 
1105
- #syncToolCallBatchCap(model: Model | undefined = this.model): void {
1106
- this.agent.maxToolCallsPerTurn = resolveToolCallBatchCapForModel(model);
1107
- }
1108
-
1109
1095
  #flushPendingAgentEnd(): void {
1110
1096
  const pending = this.#pendingAgentEndEmit;
1111
1097
  if (!pending) return;
@@ -1224,7 +1210,6 @@ export class AgentSession {
1224
1210
  this.#agentId = config.agentId;
1225
1211
  this.#agentRegistry = config.agentRegistry;
1226
1212
  this.#providerSessionId = config.providerSessionId;
1227
- this.#syncToolCallBatchCap();
1228
1213
  this.agent.setAssistantMessageEventInterceptor((message, assistantMessageEvent) => {
1229
1214
  const event: AgentEvent = {
1230
1215
  type: "message_update",
@@ -1687,89 +1672,18 @@ export class AgentSession {
1687
1672
  }
1688
1673
 
1689
1674
  if (matchContext && "delta" in assistantEvent) {
1675
+ const targetMessageTimestamp = event.message.role === "assistant" ? event.message.timestamp : undefined;
1690
1676
  const matches = this.#checkTtsrStream(assistantEvent.delta, matchContext, streamingToolCall);
1691
- if (matches.length > 0) {
1692
- // Decide first: a non-interrupting tool-source match attaches to the
1693
- // specific tool call's result instead of driving a loop-wide follow-up.
1694
- const shouldInterrupt = this.#shouldInterruptForTtsrMatch(matches, matchContext);
1695
- const perToolId = shouldInterrupt ? undefined : this.#extractTtsrToolCallId(matchContext);
1696
- if (perToolId) {
1697
- this.#addPerToolTtsrInjections(perToolId, matches);
1698
- this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
1699
- } else {
1700
- // Queue rules for injection; mark as injected only after successful enqueue.
1701
- this.#addPendingTtsrInjections(matches);
1702
-
1703
- if (shouldInterrupt) {
1704
- // Abort the stream immediately — do not gate on extension callbacks
1705
- this.#ttsrAbortPending = true;
1706
- this.#ensureTtsrResumePromise();
1707
- this.agent.abort(this.#formatTtsrAbortReason(matches));
1708
- // Notify extensions (fire-and-forget, does not block abort)
1709
- this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
1710
- // Schedule retry after a short delay
1711
- const retryToken = ++this.#ttsrRetryToken;
1712
- const generation = this.#promptGeneration;
1713
- const targetMessageTimestamp =
1714
- event.message.role === "assistant" ? event.message.timestamp : undefined;
1715
- this.#schedulePostPromptTask(
1716
- async () => {
1717
- if (this.#ttsrRetryToken !== retryToken) {
1718
- this.#resolveTtsrResume();
1719
- return;
1720
- }
1721
-
1722
- const targetAssistantIndex = this.#findTtsrAssistantIndex(targetMessageTimestamp);
1723
- if (
1724
- !this.#ttsrAbortPending ||
1725
- this.#promptGeneration !== generation ||
1726
- targetAssistantIndex === -1
1727
- ) {
1728
- this.#ttsrAbortPending = false;
1729
- this.#pendingTtsrInjections = [];
1730
- this.#perToolTtsrInjections.clear();
1731
- this.#resolveTtsrResume();
1732
- return;
1733
- }
1734
- this.#ttsrAbortPending = false;
1735
- this.#perToolTtsrInjections.clear();
1736
- const ttsrSettings = this.#ttsrManager?.getSettings();
1737
- if (ttsrSettings?.contextMode === "discard") {
1738
- // Remove the partial/aborted assistant turn from agent state
1739
- this.agent.replaceMessages(this.agent.state.messages.slice(0, targetAssistantIndex));
1740
- }
1741
- // Inject TTSR rules as system reminder before retry
1742
- const injection = this.#getTtsrInjectionContent();
1743
- if (injection) {
1744
- const details = { rules: injection.rules.map(rule => rule.name) };
1745
- this.agent.appendMessage({
1746
- role: "custom",
1747
- customType: "ttsr-injection",
1748
- content: injection.content,
1749
- display: false,
1750
- details,
1751
- attribution: "agent",
1752
- timestamp: Date.now(),
1753
- });
1754
- this.sessionManager.appendCustomMessageEntry(
1755
- "ttsr-injection",
1756
- injection.content,
1757
- false,
1758
- details,
1759
- "agent",
1760
- );
1761
- this.#markTtsrInjected(details.rules);
1762
- }
1763
- try {
1764
- await this.agent.continue();
1765
- } catch {
1766
- this.#resolveTtsrResume();
1767
- }
1768
- },
1769
- { delayMs: 50 },
1770
- );
1771
- return;
1772
- }
1677
+ if (matches.length > 0 && this.#handleTtsrMatches(matches, matchContext, targetMessageTimestamp)) {
1678
+ return;
1679
+ }
1680
+ // ast-grep `astCondition` rules match against the reconstructed edit/write
1681
+ // snapshot, which only exists for tool argument streams. The native worker
1682
+ // call is async, so this path is awaited and self-throttled by the manager.
1683
+ if (matchContext.source === "tool" && this.#ttsrManager?.hasAstRules()) {
1684
+ const astMatches = await this.#checkTtsrAstStream(matchContext, streamingToolCall);
1685
+ if (astMatches.length > 0 && this.#handleTtsrMatches(astMatches, matchContext, targetMessageTimestamp)) {
1686
+ return;
1773
1687
  }
1774
1688
  }
1775
1689
  }
@@ -2441,19 +2355,134 @@ export class AgentSession {
2441
2355
  if (!manager) {
2442
2356
  return [];
2443
2357
  }
2444
- if (toolCall) {
2445
- const tools = this.agent.state.tools;
2446
- const tool =
2447
- tools.find(t => t.name === toolCall.name) ??
2448
- tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name);
2449
- const digest = tool?.matcherDigest?.(toolCall.arguments ?? {});
2450
- if (digest !== undefined) {
2451
- return manager.checkSnapshot(digest, matchContext);
2452
- }
2358
+ const digest = this.#resolveTtsrMatcherDigest(toolCall);
2359
+ if (digest !== undefined) {
2360
+ return manager.checkSnapshot(digest, matchContext);
2453
2361
  }
2454
2362
  return manager.checkDelta(delta, matchContext);
2455
2363
  }
2456
2364
 
2365
+ /** Reconstruct the tool's normalized source snapshot via its `matcherDigest`, if any. */
2366
+ #resolveTtsrMatcherDigest(toolCall: ToolCall | undefined): string | undefined {
2367
+ if (!toolCall) {
2368
+ return undefined;
2369
+ }
2370
+ const tools = this.agent.state.tools;
2371
+ const tool =
2372
+ tools.find(t => t.name === toolCall.name) ??
2373
+ tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name);
2374
+ return tool?.matcherDigest?.(toolCall.arguments ?? {});
2375
+ }
2376
+
2377
+ /**
2378
+ * Match ast-grep `astCondition` rules against the reconstructed tool snapshot.
2379
+ *
2380
+ * Only edit/write tool streams expose a `matcherDigest`, which is the real source
2381
+ * the call introduces; AST matching needs that (and a language inferred from the
2382
+ * path argument), so non-digest streams never produce AST matches.
2383
+ */
2384
+ async #checkTtsrAstStream(matchContext: TtsrMatchContext, toolCall: ToolCall | undefined): Promise<Rule[]> {
2385
+ const manager = this.#ttsrManager;
2386
+ if (!manager) {
2387
+ return [];
2388
+ }
2389
+ const digest = this.#resolveTtsrMatcherDigest(toolCall);
2390
+ if (digest === undefined) {
2391
+ return [];
2392
+ }
2393
+ return manager.checkAstSnapshot(digest, matchContext);
2394
+ }
2395
+
2396
+ /**
2397
+ * Route TTSR matches to either a per-tool injection or a stream-interrupting
2398
+ * retry. Returns true when the stream was aborted and the caller should stop
2399
+ * processing this event.
2400
+ */
2401
+ #handleTtsrMatches(
2402
+ matches: Rule[],
2403
+ matchContext: TtsrMatchContext,
2404
+ targetMessageTimestamp: number | undefined,
2405
+ ): boolean {
2406
+ // Decide first: a non-interrupting tool-source match attaches to the
2407
+ // specific tool call's result instead of driving a loop-wide follow-up.
2408
+ const shouldInterrupt = this.#shouldInterruptForTtsrMatch(matches, matchContext);
2409
+ const perToolId = shouldInterrupt ? undefined : this.#extractTtsrToolCallId(matchContext);
2410
+ if (perToolId) {
2411
+ this.#addPerToolTtsrInjections(perToolId, matches);
2412
+ this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
2413
+ return false;
2414
+ }
2415
+
2416
+ // Queue rules for injection; mark as injected only after successful enqueue.
2417
+ this.#addPendingTtsrInjections(matches);
2418
+ if (!shouldInterrupt) {
2419
+ return false;
2420
+ }
2421
+
2422
+ // Abort the stream immediately — do not gate on extension callbacks
2423
+ this.#ttsrAbortPending = true;
2424
+ this.#ensureTtsrResumePromise();
2425
+ this.agent.abort(this.#formatTtsrAbortReason(matches));
2426
+ // Notify extensions (fire-and-forget, does not block abort)
2427
+ this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
2428
+ // Schedule retry after a short delay
2429
+ const retryToken = ++this.#ttsrRetryToken;
2430
+ const generation = this.#promptGeneration;
2431
+ this.#schedulePostPromptTask(
2432
+ async () => {
2433
+ if (this.#ttsrRetryToken !== retryToken) {
2434
+ this.#resolveTtsrResume();
2435
+ return;
2436
+ }
2437
+
2438
+ const targetAssistantIndex = this.#findTtsrAssistantIndex(targetMessageTimestamp);
2439
+ if (!this.#ttsrAbortPending || this.#promptGeneration !== generation || targetAssistantIndex === -1) {
2440
+ this.#ttsrAbortPending = false;
2441
+ this.#pendingTtsrInjections = [];
2442
+ this.#perToolTtsrInjections.clear();
2443
+ this.#resolveTtsrResume();
2444
+ return;
2445
+ }
2446
+ this.#ttsrAbortPending = false;
2447
+ this.#perToolTtsrInjections.clear();
2448
+ const ttsrSettings = this.#ttsrManager?.getSettings();
2449
+ if (ttsrSettings?.contextMode === "discard") {
2450
+ // Remove the partial/aborted assistant turn from agent state
2451
+ this.agent.replaceMessages(this.agent.state.messages.slice(0, targetAssistantIndex));
2452
+ }
2453
+ // Inject TTSR rules as system reminder before retry
2454
+ const injection = this.#getTtsrInjectionContent();
2455
+ if (injection) {
2456
+ const details = { rules: injection.rules.map(rule => rule.name) };
2457
+ this.agent.appendMessage({
2458
+ role: "custom",
2459
+ customType: "ttsr-injection",
2460
+ content: injection.content,
2461
+ display: false,
2462
+ details,
2463
+ attribution: "agent",
2464
+ timestamp: Date.now(),
2465
+ });
2466
+ this.sessionManager.appendCustomMessageEntry(
2467
+ "ttsr-injection",
2468
+ injection.content,
2469
+ false,
2470
+ details,
2471
+ "agent",
2472
+ );
2473
+ this.#markTtsrInjected(details.rules);
2474
+ }
2475
+ try {
2476
+ await this.agent.continue();
2477
+ } catch {
2478
+ this.#resolveTtsrResume();
2479
+ }
2480
+ },
2481
+ { delayMs: 50 },
2482
+ );
2483
+ return true;
2484
+ }
2485
+
2457
2486
  /** Extract path-like arguments from tool call payload for TTSR glob matching. */
2458
2487
  #extractTtsrFilePathsFromArgs(args: unknown): string[] | undefined {
2459
2488
  if (!args || typeof args !== "object" || Array.isArray(args)) {
@@ -2992,10 +3021,10 @@ export class AgentSession {
2992
3021
  * `metadata.user_id` shaped like real Claude Code's `getAPIMetadata` output:
2993
3022
  * `{ session_id, account_uuid, device_id }`. `account_uuid` is included only
2994
3023
  * when an Anthropic OAuth credential with a known account UUID is loaded;
2995
- * `device_id` is derived from the persistent omp install id. Resolving live
2996
- * keeps the value in sync with auth-state changes (login/logout, token
2997
- * refresh that surfaces a new account uuid) without needing to re-call
2998
- * `#syncAgentSessionId()` on every such event.
3024
+ * `device_id` is derived from both the persistent omp install id and that
3025
+ * account UUID. Resolving live keeps the value in sync with auth-state changes
3026
+ * (login/logout, token refresh that surfaces a new account UUID) without
3027
+ * needing to re-call `#syncAgentSessionId()` on every such event.
2999
3028
  */
3000
3029
  #syncAgentSessionId(sessionId?: string): void {
3001
3030
  const sid = this.#activeProviderSessionId(sessionId);
@@ -4286,6 +4315,65 @@ export class AgentSession {
4286
4315
  };
4287
4316
  }
4288
4317
 
4318
+ async #normalizeMessageContentImages(
4319
+ content: string | (TextContent | ImageContent)[],
4320
+ ): Promise<string | (TextContent | ImageContent)[]> {
4321
+ if (typeof content === "string") return content;
4322
+ const images = content.filter((part): part is ImageContent => part.type === "image");
4323
+ if (images.length === 0) return content;
4324
+ const normalizedImages = await normalizeModelContextImages(images);
4325
+ if (!normalizedImages) return content;
4326
+ let imageIndex = 0;
4327
+ return content.map(part => (part.type === "image" ? normalizedImages[imageIndex++]! : part));
4328
+ }
4329
+
4330
+ async #normalizeAgentMessageImages<T extends AgentMessage>(message: T): Promise<T> {
4331
+ if (!("content" in message)) return message;
4332
+ const content = message.content;
4333
+ if (typeof content !== "string" && !Array.isArray(content)) return message;
4334
+ const normalized = await this.#normalizeMessageContentImages(content as string | (TextContent | ImageContent)[]);
4335
+ if (normalized === content) return message;
4336
+ return { ...message, content: normalized } as T;
4337
+ }
4338
+
4339
+ #createMagicKeywordNotices(text: string): CustomMessage[] {
4340
+ const timestamp = Date.now();
4341
+ const turnBudget = parseTurnBudget(text);
4342
+ this.sessionManager.beginTurnBudget(turnBudget?.total ?? null, turnBudget?.hard ?? false);
4343
+ const keywordNotices: CustomMessage[] = [];
4344
+ if (containsUltrathink(text)) {
4345
+ keywordNotices.push({
4346
+ role: "custom",
4347
+ customType: "ultrathink-notice",
4348
+ content: ULTRATHINK_NOTICE,
4349
+ display: false,
4350
+ attribution: "user",
4351
+ timestamp,
4352
+ });
4353
+ }
4354
+ if (containsOrchestrate(text)) {
4355
+ keywordNotices.push({
4356
+ role: "custom",
4357
+ customType: "orchestrate-notice",
4358
+ content: ORCHESTRATE_NOTICE,
4359
+ display: false,
4360
+ attribution: "user",
4361
+ timestamp,
4362
+ });
4363
+ }
4364
+ if (containsWorkflow(text)) {
4365
+ keywordNotices.push({
4366
+ role: "custom",
4367
+ customType: "workflow-notice",
4368
+ content: WORKFLOW_NOTICE,
4369
+ display: false,
4370
+ attribution: "user",
4371
+ timestamp,
4372
+ });
4373
+ }
4374
+ return keywordNotices;
4375
+ }
4376
+
4289
4377
  /**
4290
4378
  * Send a prompt to the agent.
4291
4379
  * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming
@@ -4327,42 +4415,7 @@ export class AgentSession {
4327
4415
  // Magic keywords ("ultrathink", "orchestrate"): append hidden system notices after the
4328
4416
  // user's message that steer this turn. User-authored prompts only — synthetic /
4329
4417
  // agent-initiated turns never trigger them.
4330
- const keywordNotices: CustomMessage[] = [];
4331
- if (!options?.synthetic) {
4332
- const timestamp = Date.now();
4333
- const turnBudget = parseTurnBudget(expandedText);
4334
- this.sessionManager.beginTurnBudget(turnBudget?.total ?? null, turnBudget?.hard ?? false);
4335
- if (containsUltrathink(expandedText)) {
4336
- keywordNotices.push({
4337
- role: "custom",
4338
- customType: "ultrathink-notice",
4339
- content: ULTRATHINK_NOTICE,
4340
- display: false,
4341
- attribution: "user",
4342
- timestamp,
4343
- });
4344
- }
4345
- if (containsOrchestrate(expandedText)) {
4346
- keywordNotices.push({
4347
- role: "custom",
4348
- customType: "orchestrate-notice",
4349
- content: ORCHESTRATE_NOTICE,
4350
- display: false,
4351
- attribution: "user",
4352
- timestamp,
4353
- });
4354
- }
4355
- if (containsWorkflow(expandedText)) {
4356
- keywordNotices.push({
4357
- role: "custom",
4358
- customType: "workflow-notice",
4359
- content: WORKFLOW_NOTICE,
4360
- display: false,
4361
- attribution: "user",
4362
- timestamp,
4363
- });
4364
- }
4365
- }
4418
+ const keywordNotices = options?.synthetic ? [] : this.#createMagicKeywordNotices(expandedText);
4366
4419
 
4367
4420
  // If streaming, queue via steer() or followUp() based on option
4368
4421
  if (this.isStreaming) {
@@ -4385,10 +4438,11 @@ export class AgentSession {
4385
4438
  const hasPendingUserDirective = this.#toolChoiceQueue.inspect().includes("user-force");
4386
4439
  const eagerTodoPrelude =
4387
4440
  !options?.synthetic && !hasPendingUserDirective ? this.#createEagerTodoPrelude(expandedText) : undefined;
4441
+ const normalizedImages = await normalizeModelContextImages(options?.images);
4388
4442
 
4389
4443
  const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
4390
- if (options?.images) {
4391
- userContent.push(...options.images);
4444
+ if (normalizedImages) {
4445
+ userContent.push(...normalizedImages);
4392
4446
  }
4393
4447
 
4394
4448
  const promptAttribution = options?.attribution ?? (options?.synthetic ? "agent" : "user");
@@ -4405,6 +4459,7 @@ export class AgentSession {
4405
4459
  try {
4406
4460
  await this.#promptWithMessage(message, expandedText, {
4407
4461
  ...options,
4462
+ images: normalizedImages,
4408
4463
  prependMessages: eagerTodoPrelude ? [eagerTodoPrelude.message] : undefined,
4409
4464
  appendMessages: keywordNotices.length > 0 ? keywordNotices : undefined,
4410
4465
  });
@@ -4430,11 +4485,24 @@ export class AgentSession {
4430
4485
  .map(content => content.text)
4431
4486
  .join("");
4432
4487
 
4488
+ let keywordNotices: CustomMessage[] = [];
4489
+ if (message.customType === SKILL_PROMPT_MESSAGE_TYPE && message.attribution === "user") {
4490
+ const details = message.details;
4491
+ let skillArgs = "";
4492
+ if (details && typeof details === "object" && "args" in details && typeof details.args === "string") {
4493
+ skillArgs = details.args;
4494
+ }
4495
+ keywordNotices = this.#createMagicKeywordNotices(skillArgs);
4496
+ }
4497
+
4433
4498
  if (this.isStreaming) {
4434
4499
  if (!options?.streamingBehavior) {
4435
4500
  throw new AgentBusyError();
4436
4501
  }
4437
4502
  await this.sendCustomMessage(message, { deliverAs: options.streamingBehavior });
4503
+ for (const notice of keywordNotices) {
4504
+ await this.sendCustomMessage(notice, { deliverAs: options.streamingBehavior });
4505
+ }
4438
4506
  return;
4439
4507
  }
4440
4508
 
@@ -4448,7 +4516,10 @@ export class AgentSession {
4448
4516
  timestamp: Date.now(),
4449
4517
  };
4450
4518
 
4451
- await this.#promptWithMessage(customMessage, textContent, options);
4519
+ await this.#promptWithMessage(customMessage, textContent, {
4520
+ ...options,
4521
+ appendMessages: keywordNotices.length > 0 ? keywordNotices : undefined,
4522
+ });
4452
4523
  }
4453
4524
 
4454
4525
  async #promptWithMessage(
@@ -4547,7 +4618,9 @@ export class AgentSession {
4547
4618
  useHashLines: resolveFileDisplayMode(this).hashLines,
4548
4619
  snapshotStore: getFileSnapshotStore(this),
4549
4620
  });
4550
- messages.push(...fileMentionMessages);
4621
+ for (const fileMentionMessage of fileMentionMessages) {
4622
+ messages.push(await this.#normalizeAgentMessageImages(fileMentionMessage));
4623
+ }
4551
4624
  }
4552
4625
 
4553
4626
  const beforeAgentStartSystemPrompt = await this.#buildSystemPromptForAgentStart(expandedText);
@@ -4563,15 +4636,18 @@ export class AgentSession {
4563
4636
  const promptAttribution: "user" | "agent" | undefined =
4564
4637
  "attribution" in message ? message.attribution : undefined;
4565
4638
  for (const msg of result.messages) {
4566
- messages.push({
4567
- role: "custom",
4568
- customType: msg.customType,
4569
- content: msg.content,
4570
- display: msg.display,
4571
- details: msg.details,
4572
- attribution: msg.attribution ?? promptAttribution ?? (message.role === "user" ? "user" : "agent"),
4573
- timestamp: Date.now(),
4574
- });
4639
+ messages.push(
4640
+ await this.#normalizeAgentMessageImages({
4641
+ role: "custom",
4642
+ customType: msg.customType,
4643
+ content: msg.content,
4644
+ display: msg.display,
4645
+ details: msg.details,
4646
+ attribution:
4647
+ msg.attribution ?? promptAttribution ?? (message.role === "user" ? "user" : "agent"),
4648
+ timestamp: Date.now(),
4649
+ }),
4650
+ );
4575
4651
  }
4576
4652
  }
4577
4653
 
@@ -4779,11 +4855,12 @@ export class AgentSession {
4779
4855
  * Internal: Queue a steering message (already expanded, no extension command check).
4780
4856
  */
4781
4857
  async #queueSteer(text: string, images?: ImageContent[]): Promise<void> {
4858
+ const normalizedImages = await normalizeModelContextImages(images);
4782
4859
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
4783
4860
  this.#steeringMessages.push({ text: displayText });
4784
4861
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
4785
- if (images && images.length > 0) {
4786
- content.push(...images);
4862
+ if (normalizedImages && normalizedImages.length > 0) {
4863
+ content.push(...normalizedImages);
4787
4864
  }
4788
4865
  this.agent.steer({
4789
4866
  role: "user",
@@ -4798,11 +4875,12 @@ export class AgentSession {
4798
4875
  * Internal: Queue a follow-up message (already expanded, no extension command check).
4799
4876
  */
4800
4877
  async #queueFollowUp(text: string, images?: ImageContent[]): Promise<void> {
4878
+ const normalizedImages = await normalizeModelContextImages(images);
4801
4879
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
4802
4880
  this.#followUpMessages.push({ text: displayText });
4803
4881
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
4804
- if (images && images.length > 0) {
4805
- content.push(...images);
4882
+ if (normalizedImages && normalizedImages.length > 0) {
4883
+ content.push(...normalizedImages);
4806
4884
  }
4807
4885
  this.agent.followUp({
4808
4886
  role: "user",
@@ -4946,16 +5024,17 @@ export class AgentSession {
4946
5024
  attribution: message.attribution ?? "agent",
4947
5025
  timestamp: Date.now(),
4948
5026
  };
5027
+ const normalizedAppMessage = await this.#normalizeAgentMessageImages(appMessage);
4949
5028
  if (this.isStreaming) {
4950
5029
  if (options?.deliverAs === "nextTurn") {
4951
- this.#queueHiddenNextTurnMessage(appMessage, options?.triggerTurn ?? false);
5030
+ this.#queueHiddenNextTurnMessage(normalizedAppMessage, options?.triggerTurn ?? false);
4952
5031
  return;
4953
5032
  }
4954
5033
 
4955
5034
  if (options?.deliverAs === "followUp") {
4956
- this.agent.followUp(appMessage);
5035
+ this.agent.followUp(normalizedAppMessage);
4957
5036
  } else {
4958
- this.agent.steer(appMessage);
5037
+ this.agent.steer(normalizedAppMessage);
4959
5038
  }
4960
5039
  return;
4961
5040
  }
@@ -4963,16 +5042,16 @@ export class AgentSession {
4963
5042
  if (options?.deliverAs === "nextTurn") {
4964
5043
  if (options?.triggerTurn) {
4965
5044
  if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) {
4966
- this.#queueHiddenNextTurnMessage(appMessage, false);
5045
+ this.#queueHiddenNextTurnMessage(normalizedAppMessage, false);
4967
5046
  return;
4968
5047
  }
4969
- await this.agent.prompt(appMessage);
5048
+ await this.agent.prompt(normalizedAppMessage);
4970
5049
  return;
4971
5050
  }
4972
- this.agent.appendMessage(appMessage);
5051
+ this.agent.appendMessage(normalizedAppMessage);
4973
5052
  this.sessionManager.appendCustomMessageEntry(
4974
- message.customType,
4975
- message.content,
5053
+ normalizedAppMessage.customType,
5054
+ normalizedAppMessage.content,
4976
5055
  message.display,
4977
5056
  message.details,
4978
5057
  message.attribution ?? "agent",
@@ -4982,17 +5061,17 @@ export class AgentSession {
4982
5061
 
4983
5062
  if (options?.triggerTurn) {
4984
5063
  if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) {
4985
- this.#queueHiddenNextTurnMessage(appMessage, false);
5064
+ this.#queueHiddenNextTurnMessage(normalizedAppMessage, false);
4986
5065
  return;
4987
5066
  }
4988
- await this.agent.prompt(appMessage);
5067
+ await this.agent.prompt(normalizedAppMessage);
4989
5068
  return;
4990
5069
  }
4991
5070
 
4992
- this.agent.appendMessage(appMessage);
5071
+ this.agent.appendMessage(normalizedAppMessage);
4993
5072
  this.sessionManager.appendCustomMessageEntry(
4994
- message.customType,
4995
- message.content,
5073
+ normalizedAppMessage.customType,
5074
+ normalizedAppMessage.content,
4996
5075
  message.display,
4997
5076
  message.details,
4998
5077
  message.attribution ?? "agent",
@@ -6749,9 +6828,13 @@ export class AgentSession {
6749
6828
  return undefined;
6750
6829
  }
6751
6830
 
6752
- if (!this.#toolRegistry.has("todo")) {
6753
- logger.warn("Eager todo enforcement skipped because todo is unavailable", {
6754
- activeToolNames: this.agent.state.tools.map(tool => tool.name),
6831
+ // Must check the active tool set, not just the registry: tool discovery
6832
+ // (tools.discoveryMode === "all") can register `todo` while hiding it from
6833
+ // the exposed tools. Forcing a named tool_choice for an inactive tool makes
6834
+ // the provider reject the request (HTTP 400).
6835
+ if (!this.getActiveToolNames().includes("todo")) {
6836
+ logger.warn("Eager todo enforcement skipped because todo is not active", {
6837
+ activeToolNames: this.getActiveToolNames(),
6755
6838
  });
6756
6839
  return undefined;
6757
6840
  }
@@ -6913,7 +6996,6 @@ export class AgentSession {
6913
6996
  this.#closeProviderSessionsForModelSwitch(currentModel, model);
6914
6997
  }
6915
6998
  this.agent.setModel(model);
6916
- this.#syncToolCallBatchCap(model);
6917
6999
 
6918
7000
  // Re-evaluate append-only context mode — provider or setting may have changed
6919
7001
  this.#syncAppendOnlyContext(model);
@@ -7775,16 +7857,32 @@ export class AgentSession {
7775
7857
  return "handled";
7776
7858
  }
7777
7859
  const reclaimed = result.toolResultsDropped + result.blocksDropped > 0;
7778
- // Overflow needs the input to actually shrink before the retry; if shake
7779
- // reclaimed nothing, summarization is the only remaining recovery.
7780
- if (reason === "overflow" && !reclaimed) {
7860
+ // Detect the dead-loop reported in issue #2119: the threshold check fires,
7861
+ // shake runs, but the resulting context is still above the configured
7862
+ // threshold. The next agent_end would re-trigger shake, which has nothing
7863
+ // new to drop on the second pass, so the loop spins until the user kills it.
7864
+ // Same hazard for "incomplete" (the retry would re-hit the length cap) and
7865
+ // for the existing "overflow + nothing reclaimed" case. In every recovery
7866
+ // reason we hand off to the summarization-driven context-full path so the
7867
+ // situation actually resolves; "idle" is exempt because its 60s+ timer
7868
+ // re-checks usage before re-firing and cannot dead-loop on its own.
7869
+ const contextWindow = this.model?.contextWindow ?? 0;
7870
+ const compactionSettings = this.settings.getGroup("compaction");
7871
+ const postShakeTokens = contextWindow > 0 ? this.#estimatePendingPromptTokens([]) : 0;
7872
+ const stillOverThreshold = shouldCompact(postShakeTokens, contextWindow, compactionSettings);
7873
+ const shouldFallBack = reason !== "idle" && ((reason === "overflow" && !reclaimed) || stillOverThreshold);
7874
+ if (shouldFallBack) {
7875
+ const errorMessage = reclaimed
7876
+ ? `Auto-shake reclaimed ~${result.tokensFreed} tokens but context is still above the threshold; falling back to context-full compaction.`
7877
+ : "Auto-shake found nothing eligible to drop; falling back to context-full compaction.";
7781
7878
  await this.#emitSessionEvent({
7782
7879
  type: "auto_compaction_end",
7783
7880
  action,
7784
7881
  result: undefined,
7785
7882
  aborted: false,
7786
7883
  willRetry: false,
7787
- skipped: true,
7884
+ skipped: !reclaimed,
7885
+ errorMessage,
7788
7886
  });
7789
7887
  return "fallback";
7790
7888
  }
@@ -9109,7 +9207,6 @@ export class AgentSession {
9109
9207
  this.#setModelWithProviderSessionReset(match);
9110
9208
  } else {
9111
9209
  this.agent.setModel(match);
9112
- this.#syncToolCallBatchCap(match);
9113
9210
  }
9114
9211
  }
9115
9212
  }
@@ -9192,9 +9289,6 @@ export class AgentSession {
9192
9289
  this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
9193
9290
  if (previousModel) {
9194
9291
  this.agent.setModel(previousModel);
9195
- this.#syncToolCallBatchCap(previousModel);
9196
- } else {
9197
- this.#syncToolCallBatchCap(undefined);
9198
9292
  }
9199
9293
  this.#thinkingLevel = previousThinkingLevel;
9200
9294
  this.#autoThinking = previousAutoThinking;