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

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 (141) hide show
  1. package/CHANGELOG.md +52 -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/export/ttsr.d.ts +14 -0
  19. package/dist/types/extensibility/extensions/types.d.ts +8 -1
  20. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +1 -1
  21. package/dist/types/internal-urls/local-protocol.d.ts +10 -0
  22. package/dist/types/mcp/oauth-flow.d.ts +2 -2
  23. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  24. package/dist/types/modes/components/{status-line.d.ts → status-line/component.d.ts} +2 -32
  25. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  26. package/dist/types/modes/components/status-line/types.d.ts +31 -2
  27. package/dist/types/modes/image-references.d.ts +8 -3
  28. package/dist/types/modes/interactive-mode.d.ts +1 -1
  29. package/dist/types/modes/theme/theme.d.ts +2 -1
  30. package/dist/types/modes/types.d.ts +2 -1
  31. package/dist/types/modes/utils/ui-helpers.d.ts +2 -2
  32. package/dist/types/session/agent-session.d.ts +0 -2
  33. package/dist/types/tools/ask.d.ts +1 -0
  34. package/dist/types/tools/browser/tab-worker.d.ts +15 -0
  35. package/dist/types/tools/index.d.ts +17 -0
  36. package/dist/types/tools/render-utils.d.ts +1 -1
  37. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  38. package/dist/types/utils/block-context.d.ts +35 -0
  39. package/dist/types/utils/image-loading.d.ts +12 -0
  40. package/package.json +29 -9
  41. package/src/capability/rule-buckets.ts +4 -2
  42. package/src/capability/rule.ts +10 -1
  43. package/src/cli/auth-broker-cli.ts +6 -7
  44. package/src/cli/auth-gateway-cli.ts +1 -1
  45. package/src/cli/list-models.ts +5 -0
  46. package/src/cli/update-cli.ts +138 -16
  47. package/src/config/model-registry.ts +81 -2
  48. package/src/debug/index.ts +4 -8
  49. package/src/discovery/at-imports.ts +273 -0
  50. package/src/discovery/builtin-rules/index.ts +4 -0
  51. package/src/discovery/builtin-rules/ts-no-test-timers.md +55 -0
  52. package/src/discovery/builtin-rules/ts-redundant-clear-guard.md +75 -0
  53. package/src/discovery/helpers.ts +2 -1
  54. package/src/edit/diff.ts +114 -4
  55. package/src/edit/hashline/diff.ts +1 -1
  56. package/src/edit/hashline/execute.ts +1 -1
  57. package/src/edit/modes/patch.ts +6 -2
  58. package/src/edit/modes/replace.ts +1 -1
  59. package/src/edit/renderer.ts +12 -2
  60. package/src/eval/__tests__/helpers-local-roots.test.ts +58 -0
  61. package/src/eval/backend.ts +15 -0
  62. package/src/eval/js/context-manager.ts +4 -2
  63. package/src/eval/js/executor.ts +3 -0
  64. package/src/eval/js/index.ts +7 -1
  65. package/src/eval/js/shared/helpers.ts +53 -6
  66. package/src/eval/js/shared/runtime.ts +8 -0
  67. package/src/eval/js/worker-core.ts +1 -0
  68. package/src/eval/js/worker-protocol.ts +6 -0
  69. package/src/eval/py/executor.ts +12 -0
  70. package/src/eval/py/index.ts +7 -1
  71. package/src/eval/py/prelude.py +43 -4
  72. package/src/eval/py/runner.py +1 -0
  73. package/src/exa/render.ts +1 -1
  74. package/src/export/ttsr.ts +122 -1
  75. package/src/extensibility/extensions/types.ts +8 -1
  76. package/src/extensibility/legacy-pi-ai-shim.ts +1 -1
  77. package/src/extensibility/plugins/doctor.ts +1 -1
  78. package/src/extensibility/plugins/legacy-pi-compat.ts +6 -5
  79. package/src/goals/tools/goal-tool.ts +1 -1
  80. package/src/internal-urls/docs-index.generated.ts +6 -5
  81. package/src/internal-urls/local-protocol.ts +13 -0
  82. package/src/lsp/render.ts +8 -6
  83. package/src/mcp/oauth-flow.ts +3 -3
  84. package/src/mcp/render.ts +7 -1
  85. package/src/modes/components/custom-editor.ts +12 -6
  86. package/src/modes/components/login-dialog.ts +1 -1
  87. package/src/modes/components/oauth-selector.ts +4 -4
  88. package/src/modes/components/read-tool-group.ts +10 -3
  89. package/src/modes/components/{status-line.ts → status-line/component.ts} +18 -40
  90. package/src/modes/components/status-line/index.ts +1 -0
  91. package/src/modes/components/status-line/types.ts +23 -8
  92. package/src/modes/components/tool-execution.ts +1 -1
  93. package/src/modes/components/transcript-container.ts +17 -10
  94. package/src/modes/components/user-message.ts +6 -3
  95. package/src/modes/components/welcome.ts +1 -1
  96. package/src/modes/controllers/extension-ui-controller.ts +143 -127
  97. package/src/modes/controllers/input-controller.ts +36 -10
  98. package/src/modes/controllers/mcp-command-controller.ts +28 -12
  99. package/src/modes/controllers/selector-controller.ts +4 -11
  100. package/src/modes/controllers/ssh-command-controller.ts +2 -2
  101. package/src/modes/image-references.ts +13 -7
  102. package/src/modes/interactive-mode.ts +2 -2
  103. package/src/modes/rpc/rpc-mode.ts +1 -1
  104. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -11
  105. package/src/modes/theme/theme.ts +95 -1
  106. package/src/modes/types.ts +2 -1
  107. package/src/modes/utils/ui-helpers.ts +14 -5
  108. package/src/prompts/tools/bash.md +1 -1
  109. package/src/prompts/tools/eval.md +4 -4
  110. package/src/sdk.ts +31 -14
  111. package/src/session/agent-session.ts +213 -155
  112. package/src/session/session-manager.ts +1 -1
  113. package/src/slash-commands/builtin-registry.ts +1 -1
  114. package/src/system-prompt.ts +15 -9
  115. package/src/task/render.ts +20 -8
  116. package/src/tools/ask.ts +14 -5
  117. package/src/tools/bash-interactive.ts +1 -1
  118. package/src/tools/bash.ts +14 -2
  119. package/src/tools/browser/render.ts +5 -2
  120. package/src/tools/browser/tab-worker.ts +211 -91
  121. package/src/tools/debug.ts +5 -2
  122. package/src/tools/eval-render.ts +6 -3
  123. package/src/tools/eval.ts +1 -1
  124. package/src/tools/gh-renderer.ts +29 -15
  125. package/src/tools/index.ts +32 -0
  126. package/src/tools/inspect-image-renderer.ts +12 -5
  127. package/src/tools/job.ts +9 -6
  128. package/src/tools/memory-render.ts +19 -5
  129. package/src/tools/read.ts +165 -18
  130. package/src/tools/render-utils.ts +3 -1
  131. package/src/tools/resolve.ts +1 -1
  132. package/src/tools/review.ts +1 -1
  133. package/src/tools/ssh.ts +4 -1
  134. package/src/tools/todo.ts +8 -1
  135. package/src/tools/tool-timeouts.ts +1 -1
  136. package/src/tools/write.ts +1 -1
  137. package/src/tui/code-cell.ts +1 -1
  138. package/src/utils/block-context.ts +312 -0
  139. package/src/utils/image-loading.ts +31 -1
  140. package/src/web/search/providers/codex.ts +1 -1
  141. package/src/web/search/render.ts +14 -6
@@ -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";
@@ -531,15 +533,6 @@ function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): strin
531
533
  }
532
534
 
533
535
  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
536
 
544
537
  /**
545
538
  * Collapse degenerate IRC ephemeral replies before they hit the relay.
@@ -613,14 +606,10 @@ function buildSessionMetadata(
613
606
  const accountUuid = authStorage?.getOAuthAccountId("anthropic", sessionId);
614
607
  if (typeof accountUuid === "string" && accountUuid.length > 0) {
615
608
  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");
609
+ // Claude Code's `device_id` is a stable 64-hex account-scoped install
610
+ // identifier. Include both omp's persistent install id and the Claude
611
+ // account UUID so two accounts on the same install do not share a device.
612
+ userId.device_id = deriveClaudeDeviceId(getInstallId(), accountUuid);
624
613
  }
625
614
  }
626
615
  return { user_id: JSON.stringify(userId) };
@@ -1102,10 +1091,6 @@ export class AgentSession {
1102
1091
  this.#flushPendingAgentEnd();
1103
1092
  }
1104
1093
 
1105
- #syncToolCallBatchCap(model: Model | undefined = this.model): void {
1106
- this.agent.maxToolCallsPerTurn = resolveToolCallBatchCapForModel(model);
1107
- }
1108
-
1109
1094
  #flushPendingAgentEnd(): void {
1110
1095
  const pending = this.#pendingAgentEndEmit;
1111
1096
  if (!pending) return;
@@ -1224,7 +1209,6 @@ export class AgentSession {
1224
1209
  this.#agentId = config.agentId;
1225
1210
  this.#agentRegistry = config.agentRegistry;
1226
1211
  this.#providerSessionId = config.providerSessionId;
1227
- this.#syncToolCallBatchCap();
1228
1212
  this.agent.setAssistantMessageEventInterceptor((message, assistantMessageEvent) => {
1229
1213
  const event: AgentEvent = {
1230
1214
  type: "message_update",
@@ -1687,89 +1671,18 @@ export class AgentSession {
1687
1671
  }
1688
1672
 
1689
1673
  if (matchContext && "delta" in assistantEvent) {
1674
+ const targetMessageTimestamp = event.message.role === "assistant" ? event.message.timestamp : undefined;
1690
1675
  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
- }
1676
+ if (matches.length > 0 && this.#handleTtsrMatches(matches, matchContext, targetMessageTimestamp)) {
1677
+ return;
1678
+ }
1679
+ // ast-grep `astCondition` rules match against the reconstructed edit/write
1680
+ // snapshot, which only exists for tool argument streams. The native worker
1681
+ // call is async, so this path is awaited and self-throttled by the manager.
1682
+ if (matchContext.source === "tool" && this.#ttsrManager?.hasAstRules()) {
1683
+ const astMatches = await this.#checkTtsrAstStream(matchContext, streamingToolCall);
1684
+ if (astMatches.length > 0 && this.#handleTtsrMatches(astMatches, matchContext, targetMessageTimestamp)) {
1685
+ return;
1773
1686
  }
1774
1687
  }
1775
1688
  }
@@ -2441,19 +2354,134 @@ export class AgentSession {
2441
2354
  if (!manager) {
2442
2355
  return [];
2443
2356
  }
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
- }
2357
+ const digest = this.#resolveTtsrMatcherDigest(toolCall);
2358
+ if (digest !== undefined) {
2359
+ return manager.checkSnapshot(digest, matchContext);
2453
2360
  }
2454
2361
  return manager.checkDelta(delta, matchContext);
2455
2362
  }
2456
2363
 
2364
+ /** Reconstruct the tool's normalized source snapshot via its `matcherDigest`, if any. */
2365
+ #resolveTtsrMatcherDigest(toolCall: ToolCall | undefined): string | undefined {
2366
+ if (!toolCall) {
2367
+ return undefined;
2368
+ }
2369
+ const tools = this.agent.state.tools;
2370
+ const tool =
2371
+ tools.find(t => t.name === toolCall.name) ??
2372
+ tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name);
2373
+ return tool?.matcherDigest?.(toolCall.arguments ?? {});
2374
+ }
2375
+
2376
+ /**
2377
+ * Match ast-grep `astCondition` rules against the reconstructed tool snapshot.
2378
+ *
2379
+ * Only edit/write tool streams expose a `matcherDigest`, which is the real source
2380
+ * the call introduces; AST matching needs that (and a language inferred from the
2381
+ * path argument), so non-digest streams never produce AST matches.
2382
+ */
2383
+ async #checkTtsrAstStream(matchContext: TtsrMatchContext, toolCall: ToolCall | undefined): Promise<Rule[]> {
2384
+ const manager = this.#ttsrManager;
2385
+ if (!manager) {
2386
+ return [];
2387
+ }
2388
+ const digest = this.#resolveTtsrMatcherDigest(toolCall);
2389
+ if (digest === undefined) {
2390
+ return [];
2391
+ }
2392
+ return manager.checkAstSnapshot(digest, matchContext);
2393
+ }
2394
+
2395
+ /**
2396
+ * Route TTSR matches to either a per-tool injection or a stream-interrupting
2397
+ * retry. Returns true when the stream was aborted and the caller should stop
2398
+ * processing this event.
2399
+ */
2400
+ #handleTtsrMatches(
2401
+ matches: Rule[],
2402
+ matchContext: TtsrMatchContext,
2403
+ targetMessageTimestamp: number | undefined,
2404
+ ): boolean {
2405
+ // Decide first: a non-interrupting tool-source match attaches to the
2406
+ // specific tool call's result instead of driving a loop-wide follow-up.
2407
+ const shouldInterrupt = this.#shouldInterruptForTtsrMatch(matches, matchContext);
2408
+ const perToolId = shouldInterrupt ? undefined : this.#extractTtsrToolCallId(matchContext);
2409
+ if (perToolId) {
2410
+ this.#addPerToolTtsrInjections(perToolId, matches);
2411
+ this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
2412
+ return false;
2413
+ }
2414
+
2415
+ // Queue rules for injection; mark as injected only after successful enqueue.
2416
+ this.#addPendingTtsrInjections(matches);
2417
+ if (!shouldInterrupt) {
2418
+ return false;
2419
+ }
2420
+
2421
+ // Abort the stream immediately — do not gate on extension callbacks
2422
+ this.#ttsrAbortPending = true;
2423
+ this.#ensureTtsrResumePromise();
2424
+ this.agent.abort(this.#formatTtsrAbortReason(matches));
2425
+ // Notify extensions (fire-and-forget, does not block abort)
2426
+ this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
2427
+ // Schedule retry after a short delay
2428
+ const retryToken = ++this.#ttsrRetryToken;
2429
+ const generation = this.#promptGeneration;
2430
+ this.#schedulePostPromptTask(
2431
+ async () => {
2432
+ if (this.#ttsrRetryToken !== retryToken) {
2433
+ this.#resolveTtsrResume();
2434
+ return;
2435
+ }
2436
+
2437
+ const targetAssistantIndex = this.#findTtsrAssistantIndex(targetMessageTimestamp);
2438
+ if (!this.#ttsrAbortPending || this.#promptGeneration !== generation || targetAssistantIndex === -1) {
2439
+ this.#ttsrAbortPending = false;
2440
+ this.#pendingTtsrInjections = [];
2441
+ this.#perToolTtsrInjections.clear();
2442
+ this.#resolveTtsrResume();
2443
+ return;
2444
+ }
2445
+ this.#ttsrAbortPending = false;
2446
+ this.#perToolTtsrInjections.clear();
2447
+ const ttsrSettings = this.#ttsrManager?.getSettings();
2448
+ if (ttsrSettings?.contextMode === "discard") {
2449
+ // Remove the partial/aborted assistant turn from agent state
2450
+ this.agent.replaceMessages(this.agent.state.messages.slice(0, targetAssistantIndex));
2451
+ }
2452
+ // Inject TTSR rules as system reminder before retry
2453
+ const injection = this.#getTtsrInjectionContent();
2454
+ if (injection) {
2455
+ const details = { rules: injection.rules.map(rule => rule.name) };
2456
+ this.agent.appendMessage({
2457
+ role: "custom",
2458
+ customType: "ttsr-injection",
2459
+ content: injection.content,
2460
+ display: false,
2461
+ details,
2462
+ attribution: "agent",
2463
+ timestamp: Date.now(),
2464
+ });
2465
+ this.sessionManager.appendCustomMessageEntry(
2466
+ "ttsr-injection",
2467
+ injection.content,
2468
+ false,
2469
+ details,
2470
+ "agent",
2471
+ );
2472
+ this.#markTtsrInjected(details.rules);
2473
+ }
2474
+ try {
2475
+ await this.agent.continue();
2476
+ } catch {
2477
+ this.#resolveTtsrResume();
2478
+ }
2479
+ },
2480
+ { delayMs: 50 },
2481
+ );
2482
+ return true;
2483
+ }
2484
+
2457
2485
  /** Extract path-like arguments from tool call payload for TTSR glob matching. */
2458
2486
  #extractTtsrFilePathsFromArgs(args: unknown): string[] | undefined {
2459
2487
  if (!args || typeof args !== "object" || Array.isArray(args)) {
@@ -2992,10 +3020,10 @@ export class AgentSession {
2992
3020
  * `metadata.user_id` shaped like real Claude Code's `getAPIMetadata` output:
2993
3021
  * `{ session_id, account_uuid, device_id }`. `account_uuid` is included only
2994
3022
  * 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.
3023
+ * `device_id` is derived from both the persistent omp install id and that
3024
+ * account UUID. Resolving live keeps the value in sync with auth-state changes
3025
+ * (login/logout, token refresh that surfaces a new account UUID) without
3026
+ * needing to re-call `#syncAgentSessionId()` on every such event.
2999
3027
  */
3000
3028
  #syncAgentSessionId(sessionId?: string): void {
3001
3029
  const sid = this.#activeProviderSessionId(sessionId);
@@ -4286,6 +4314,27 @@ export class AgentSession {
4286
4314
  };
4287
4315
  }
4288
4316
 
4317
+ async #normalizeMessageContentImages(
4318
+ content: string | (TextContent | ImageContent)[],
4319
+ ): Promise<string | (TextContent | ImageContent)[]> {
4320
+ if (typeof content === "string") return content;
4321
+ const images = content.filter((part): part is ImageContent => part.type === "image");
4322
+ if (images.length === 0) return content;
4323
+ const normalizedImages = await normalizeModelContextImages(images);
4324
+ if (!normalizedImages) return content;
4325
+ let imageIndex = 0;
4326
+ return content.map(part => (part.type === "image" ? normalizedImages[imageIndex++]! : part));
4327
+ }
4328
+
4329
+ async #normalizeAgentMessageImages<T extends AgentMessage>(message: T): Promise<T> {
4330
+ if (!("content" in message)) return message;
4331
+ const content = message.content;
4332
+ if (typeof content !== "string" && !Array.isArray(content)) return message;
4333
+ const normalized = await this.#normalizeMessageContentImages(content as string | (TextContent | ImageContent)[]);
4334
+ if (normalized === content) return message;
4335
+ return { ...message, content: normalized } as T;
4336
+ }
4337
+
4289
4338
  /**
4290
4339
  * Send a prompt to the agent.
4291
4340
  * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming
@@ -4385,10 +4434,11 @@ export class AgentSession {
4385
4434
  const hasPendingUserDirective = this.#toolChoiceQueue.inspect().includes("user-force");
4386
4435
  const eagerTodoPrelude =
4387
4436
  !options?.synthetic && !hasPendingUserDirective ? this.#createEagerTodoPrelude(expandedText) : undefined;
4437
+ const normalizedImages = await normalizeModelContextImages(options?.images);
4388
4438
 
4389
4439
  const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
4390
- if (options?.images) {
4391
- userContent.push(...options.images);
4440
+ if (normalizedImages) {
4441
+ userContent.push(...normalizedImages);
4392
4442
  }
4393
4443
 
4394
4444
  const promptAttribution = options?.attribution ?? (options?.synthetic ? "agent" : "user");
@@ -4405,6 +4455,7 @@ export class AgentSession {
4405
4455
  try {
4406
4456
  await this.#promptWithMessage(message, expandedText, {
4407
4457
  ...options,
4458
+ images: normalizedImages,
4408
4459
  prependMessages: eagerTodoPrelude ? [eagerTodoPrelude.message] : undefined,
4409
4460
  appendMessages: keywordNotices.length > 0 ? keywordNotices : undefined,
4410
4461
  });
@@ -4547,7 +4598,9 @@ export class AgentSession {
4547
4598
  useHashLines: resolveFileDisplayMode(this).hashLines,
4548
4599
  snapshotStore: getFileSnapshotStore(this),
4549
4600
  });
4550
- messages.push(...fileMentionMessages);
4601
+ for (const fileMentionMessage of fileMentionMessages) {
4602
+ messages.push(await this.#normalizeAgentMessageImages(fileMentionMessage));
4603
+ }
4551
4604
  }
4552
4605
 
4553
4606
  const beforeAgentStartSystemPrompt = await this.#buildSystemPromptForAgentStart(expandedText);
@@ -4563,15 +4616,18 @@ export class AgentSession {
4563
4616
  const promptAttribution: "user" | "agent" | undefined =
4564
4617
  "attribution" in message ? message.attribution : undefined;
4565
4618
  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
- });
4619
+ messages.push(
4620
+ await this.#normalizeAgentMessageImages({
4621
+ role: "custom",
4622
+ customType: msg.customType,
4623
+ content: msg.content,
4624
+ display: msg.display,
4625
+ details: msg.details,
4626
+ attribution:
4627
+ msg.attribution ?? promptAttribution ?? (message.role === "user" ? "user" : "agent"),
4628
+ timestamp: Date.now(),
4629
+ }),
4630
+ );
4575
4631
  }
4576
4632
  }
4577
4633
 
@@ -4779,11 +4835,12 @@ export class AgentSession {
4779
4835
  * Internal: Queue a steering message (already expanded, no extension command check).
4780
4836
  */
4781
4837
  async #queueSteer(text: string, images?: ImageContent[]): Promise<void> {
4838
+ const normalizedImages = await normalizeModelContextImages(images);
4782
4839
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
4783
4840
  this.#steeringMessages.push({ text: displayText });
4784
4841
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
4785
- if (images && images.length > 0) {
4786
- content.push(...images);
4842
+ if (normalizedImages && normalizedImages.length > 0) {
4843
+ content.push(...normalizedImages);
4787
4844
  }
4788
4845
  this.agent.steer({
4789
4846
  role: "user",
@@ -4798,11 +4855,12 @@ export class AgentSession {
4798
4855
  * Internal: Queue a follow-up message (already expanded, no extension command check).
4799
4856
  */
4800
4857
  async #queueFollowUp(text: string, images?: ImageContent[]): Promise<void> {
4858
+ const normalizedImages = await normalizeModelContextImages(images);
4801
4859
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
4802
4860
  this.#followUpMessages.push({ text: displayText });
4803
4861
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
4804
- if (images && images.length > 0) {
4805
- content.push(...images);
4862
+ if (normalizedImages && normalizedImages.length > 0) {
4863
+ content.push(...normalizedImages);
4806
4864
  }
4807
4865
  this.agent.followUp({
4808
4866
  role: "user",
@@ -4946,16 +5004,17 @@ export class AgentSession {
4946
5004
  attribution: message.attribution ?? "agent",
4947
5005
  timestamp: Date.now(),
4948
5006
  };
5007
+ const normalizedAppMessage = await this.#normalizeAgentMessageImages(appMessage);
4949
5008
  if (this.isStreaming) {
4950
5009
  if (options?.deliverAs === "nextTurn") {
4951
- this.#queueHiddenNextTurnMessage(appMessage, options?.triggerTurn ?? false);
5010
+ this.#queueHiddenNextTurnMessage(normalizedAppMessage, options?.triggerTurn ?? false);
4952
5011
  return;
4953
5012
  }
4954
5013
 
4955
5014
  if (options?.deliverAs === "followUp") {
4956
- this.agent.followUp(appMessage);
5015
+ this.agent.followUp(normalizedAppMessage);
4957
5016
  } else {
4958
- this.agent.steer(appMessage);
5017
+ this.agent.steer(normalizedAppMessage);
4959
5018
  }
4960
5019
  return;
4961
5020
  }
@@ -4963,16 +5022,16 @@ export class AgentSession {
4963
5022
  if (options?.deliverAs === "nextTurn") {
4964
5023
  if (options?.triggerTurn) {
4965
5024
  if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) {
4966
- this.#queueHiddenNextTurnMessage(appMessage, false);
5025
+ this.#queueHiddenNextTurnMessage(normalizedAppMessage, false);
4967
5026
  return;
4968
5027
  }
4969
- await this.agent.prompt(appMessage);
5028
+ await this.agent.prompt(normalizedAppMessage);
4970
5029
  return;
4971
5030
  }
4972
- this.agent.appendMessage(appMessage);
5031
+ this.agent.appendMessage(normalizedAppMessage);
4973
5032
  this.sessionManager.appendCustomMessageEntry(
4974
- message.customType,
4975
- message.content,
5033
+ normalizedAppMessage.customType,
5034
+ normalizedAppMessage.content,
4976
5035
  message.display,
4977
5036
  message.details,
4978
5037
  message.attribution ?? "agent",
@@ -4982,17 +5041,17 @@ export class AgentSession {
4982
5041
 
4983
5042
  if (options?.triggerTurn) {
4984
5043
  if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) {
4985
- this.#queueHiddenNextTurnMessage(appMessage, false);
5044
+ this.#queueHiddenNextTurnMessage(normalizedAppMessage, false);
4986
5045
  return;
4987
5046
  }
4988
- await this.agent.prompt(appMessage);
5047
+ await this.agent.prompt(normalizedAppMessage);
4989
5048
  return;
4990
5049
  }
4991
5050
 
4992
- this.agent.appendMessage(appMessage);
5051
+ this.agent.appendMessage(normalizedAppMessage);
4993
5052
  this.sessionManager.appendCustomMessageEntry(
4994
- message.customType,
4995
- message.content,
5053
+ normalizedAppMessage.customType,
5054
+ normalizedAppMessage.content,
4996
5055
  message.display,
4997
5056
  message.details,
4998
5057
  message.attribution ?? "agent",
@@ -6749,9 +6808,13 @@ export class AgentSession {
6749
6808
  return undefined;
6750
6809
  }
6751
6810
 
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),
6811
+ // Must check the active tool set, not just the registry: tool discovery
6812
+ // (tools.discoveryMode === "all") can register `todo` while hiding it from
6813
+ // the exposed tools. Forcing a named tool_choice for an inactive tool makes
6814
+ // the provider reject the request (HTTP 400).
6815
+ if (!this.getActiveToolNames().includes("todo")) {
6816
+ logger.warn("Eager todo enforcement skipped because todo is not active", {
6817
+ activeToolNames: this.getActiveToolNames(),
6755
6818
  });
6756
6819
  return undefined;
6757
6820
  }
@@ -6913,7 +6976,6 @@ export class AgentSession {
6913
6976
  this.#closeProviderSessionsForModelSwitch(currentModel, model);
6914
6977
  }
6915
6978
  this.agent.setModel(model);
6916
- this.#syncToolCallBatchCap(model);
6917
6979
 
6918
6980
  // Re-evaluate append-only context mode — provider or setting may have changed
6919
6981
  this.#syncAppendOnlyContext(model);
@@ -9109,7 +9171,6 @@ export class AgentSession {
9109
9171
  this.#setModelWithProviderSessionReset(match);
9110
9172
  } else {
9111
9173
  this.agent.setModel(match);
9112
- this.#syncToolCallBatchCap(match);
9113
9174
  }
9114
9175
  }
9115
9176
  }
@@ -9192,9 +9253,6 @@ export class AgentSession {
9192
9253
  this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
9193
9254
  if (previousModel) {
9194
9255
  this.agent.setModel(previousModel);
9195
- this.#syncToolCallBatchCap(previousModel);
9196
- } else {
9197
- this.#syncToolCallBatchCap(undefined);
9198
9256
  }
9199
9257
  this.#thinkingLevel = previousThinkingLevel;
9200
9258
  this.#autoThinking = previousAutoThinking;
@@ -3558,7 +3558,7 @@ export class SessionManager {
3558
3558
  }
3559
3559
  const relocated = sourceCwdGone && (mostRecent === null || (mostRecentIsBreadcrumb && !hasCurrentCwdSession));
3560
3560
  if (relocated) {
3561
- process.stderr.write(`Re-rooting moved session from ${resolvedBreadcrumbCwd} to ${resolvedCwd}.\n`);
3561
+ logger.info("Re-rooting moved session", { from: resolvedBreadcrumbCwd, to: resolvedCwd });
3562
3562
  const manager = await SessionManager.open(breadcrumb.sessionFile, undefined, storage);
3563
3563
  await manager.moveTo(cwd, sessionDir);
3564
3564
  return manager;
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
4
+ import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
5
5
  import { Snowflake, setProjectDir } from "@oh-my-pi/pi-utils";
6
6
  import { $ } from "bun";
7
7
  import type { SettingPath, SettingValue } from "../config/settings";
@@ -10,6 +10,7 @@ import { contextFileCapability } from "./capability/context-file";
10
10
  import { systemPromptCapability } from "./capability/system-prompt";
11
11
  import type { SkillsSettings } from "./config/settings";
12
12
  import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile } from "./discovery";
13
+ import { expandAtImports } from "./discovery/at-imports";
13
14
  import { loadSkills, type Skill } from "./extensibility/skills";
14
15
  import { hasObsidian } from "./internal-urls/vault-protocol";
15
16
  import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
@@ -254,15 +255,20 @@ export async function loadProjectContextFiles(
254
255
 
255
256
  const result = await loadCapability(contextFileCapability.id, { cwd: resolvedCwd });
256
257
 
257
- // Convert ContextFile items and preserve depth info
258
- const files = result.items.map(item => {
259
- const contextFile = item as ContextFile;
260
- return {
261
- path: contextFile.path,
262
- content: contextFile.content,
263
- depth: contextFile.depth,
264
- };
265
- });
258
+ // Materialize ContextFile items, expanding any `@path/to/file` includes
259
+ // in their content. The expansion uses the file's own directory as the
260
+ // resolution base so relative imports work the same way Claude Code,
261
+ // Goose, and other tools document.
262
+ const files = await Promise.all(
263
+ result.items.map(async item => {
264
+ const contextFile = item as ContextFile;
265
+ return {
266
+ path: contextFile.path,
267
+ content: await expandAtImports(contextFile.content, contextFile.path),
268
+ depth: contextFile.depth,
269
+ };
270
+ }),
271
+ );
266
272
 
267
273
  // Sort by depth (descending): higher depth (farther from cwd) comes first,
268
274
  // so files closer to cwd appear later and are more prominent