@oh-my-pi/pi-coding-agent 15.5.6 → 15.5.8

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 (76) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
  3. package/dist/types/commands/auth-gateway.d.ts +3 -0
  4. package/dist/types/config/settings-schema.d.ts +60 -12
  5. package/dist/types/edit/file-snapshot-store.d.ts +9 -6
  6. package/dist/types/edit/hashline/diff.d.ts +4 -5
  7. package/dist/types/edit/streaming.d.ts +2 -1
  8. package/dist/types/eval/py/index.d.ts +1 -0
  9. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  10. package/dist/types/extensibility/shared-events.d.ts +1 -1
  11. package/dist/types/internal-urls/index.d.ts +1 -0
  12. package/dist/types/internal-urls/vault-protocol.d.ts +93 -0
  13. package/dist/types/lib/xai-http.d.ts +40 -0
  14. package/dist/types/mcp/transports/http.d.ts +9 -0
  15. package/dist/types/modes/components/tool-execution.d.ts +2 -1
  16. package/dist/types/session/agent-session.d.ts +4 -1
  17. package/dist/types/tools/fetch.d.ts +16 -0
  18. package/dist/types/tools/image-gen.d.ts +6 -2
  19. package/dist/types/tools/index.d.ts +1 -0
  20. package/dist/types/tools/match-line-format.d.ts +2 -2
  21. package/dist/types/tools/plan-mode-guard.d.ts +5 -6
  22. package/dist/types/tools/render-utils.d.ts +3 -1
  23. package/dist/types/tools/tts.d.ts +18 -0
  24. package/dist/types/tools/write.d.ts +2 -0
  25. package/dist/types/utils/file-mentions.d.ts +2 -0
  26. package/package.json +8 -8
  27. package/src/cli/args.ts +2 -0
  28. package/src/cli/auth-broker-cli.ts +2 -1
  29. package/src/cli/auth-gateway-cli.ts +210 -9
  30. package/src/commands/auth-gateway.ts +7 -1
  31. package/src/config/model-registry.ts +41 -9
  32. package/src/config/settings-schema.ts +55 -13
  33. package/src/edit/file-snapshot-store.ts +9 -6
  34. package/src/edit/hashline/diff.ts +26 -13
  35. package/src/edit/hashline/execute.ts +13 -9
  36. package/src/edit/renderer.ts +9 -9
  37. package/src/edit/streaming.ts +4 -6
  38. package/src/eval/py/index.ts +1 -1
  39. package/src/extensibility/custom-tools/types.ts +1 -1
  40. package/src/extensibility/shared-events.ts +1 -1
  41. package/src/internal-urls/docs-index.generated.ts +7 -7
  42. package/src/internal-urls/index.ts +1 -0
  43. package/src/internal-urls/router.ts +2 -0
  44. package/src/internal-urls/vault-protocol.ts +936 -0
  45. package/src/lib/xai-http.ts +124 -0
  46. package/src/main.ts +1 -2
  47. package/src/mcp/transports/http.ts +29 -2
  48. package/src/modes/components/tool-execution.ts +6 -4
  49. package/src/modes/controllers/event-controller.ts +10 -3
  50. package/src/modes/controllers/selector-controller.ts +7 -2
  51. package/src/modes/interactive-mode.ts +11 -3
  52. package/src/modes/utils/ui-helpers.ts +2 -1
  53. package/src/prompts/system/system-prompt.md +3 -0
  54. package/src/prompts/tools/ast-edit.md +1 -1
  55. package/src/prompts/tools/ast-grep.md +1 -1
  56. package/src/prompts/tools/read.md +3 -3
  57. package/src/prompts/tools/search.md +1 -1
  58. package/src/sdk.ts +41 -10
  59. package/src/session/agent-session.ts +112 -14
  60. package/src/system-prompt.ts +2 -0
  61. package/src/tools/ast-edit.ts +10 -7
  62. package/src/tools/ast-grep.ts +12 -11
  63. package/src/tools/eval.ts +28 -3
  64. package/src/tools/fetch.ts +52 -24
  65. package/src/tools/image-gen.ts +205 -7
  66. package/src/tools/index.ts +1 -0
  67. package/src/tools/match-line-format.ts +2 -2
  68. package/src/tools/path-utils.ts +2 -0
  69. package/src/tools/plan-mode-guard.ts +20 -7
  70. package/src/tools/read.ts +70 -55
  71. package/src/tools/render-utils.ts +15 -0
  72. package/src/tools/search.ts +14 -14
  73. package/src/tools/tts.ts +133 -0
  74. package/src/tools/write.ts +61 -6
  75. package/src/utils/file-mentions.ts +11 -5
  76. package/src/web/search/providers/codex.ts +2 -1
@@ -0,0 +1,124 @@
1
+ // Ported from NousResearch/hermes-agent (MIT) — tools/xai_http.py.
2
+
3
+ import { getBundledModels } from "@oh-my-pi/pi-ai";
4
+ import { $env } from "@oh-my-pi/pi-utils";
5
+ import type { ModelRegistry } from "../config/model-registry";
6
+
7
+ const DEFAULT_BASE_URL = "https://api.x.ai/v1";
8
+
9
+ interface XAICredentials {
10
+ provider: "xai-oauth" | "xai";
11
+ apiKey: string;
12
+ baseURL: string;
13
+ }
14
+
15
+ export function ohMyPiXAIUserAgent(): string {
16
+ return "oh-my-pi/xai";
17
+ }
18
+
19
+ type XAIProvider = "xai-oauth" | "xai";
20
+
21
+ /**
22
+ * Resolve the HTTP base URL for an xAI tool call.
23
+ *
24
+ * Precedence:
25
+ * 1. `model.baseUrl` from the registry IF the user pinned a per-model
26
+ * override — i.e. `merged.baseUrl` differs from the seeded/bundled
27
+ * default for the (provider, id) pair. Mirrors the chat path's per-model
28
+ * contract (`openai-responses.ts: model.baseUrl`).
29
+ * 2. `ModelRegistry.getProviderBaseUrl(provider)` — provider-level override
30
+ * (e.g. `providers.xai-oauth.baseUrl` from models.yml). Reached when the
31
+ * modelId does not appear in the registry under this provider, which
32
+ * happens for tool-only ids like `grok-imagine-image` that
33
+ * `applyXAIOAuthCuration` filters out via `XAI_NON_CHAT_PREFIXES`.
34
+ * Without this leg, a registry-configured proxy is silently bypassed for
35
+ * image/TTS traffic.
36
+ * 3. `XAI_BASE_URL` env var (legacy global override, preserved).
37
+ * 4. `DEFAULT_BASE_URL = "https://api.x.ai/v1"`.
38
+ *
39
+ * The override gate at step 1 uses `bundled?.baseUrl ?? DEFAULT_BASE_URL` as
40
+ * the canonical default sentinel. For xai (which has bundled entries) this
41
+ * compares against the bundled value; for xai-oauth (no bundled entries —
42
+ * models.json carries no xai-oauth records when the seed is absent, the
43
+ * picker is seeded statically from `xaiOAuthModelManagerOptions` with
44
+ * `baseUrl: DEFAULT_BASE_URL`) the sentinel falls back to DEFAULT_BASE_URL
45
+ * so the env leg remains reachable. Without that fallback, every xai-oauth
46
+ * model id forces `!bundled === true` and short-circuits XAI_BASE_URL
47
+ * silently. Lookup is scoped to (provider, id); matching by id alone would
48
+ * let xai-oauth entries hijack a xai tool call (or vice versa) when the
49
+ * same model id ships under both descriptors.
50
+ */
51
+ function resolveXAIBaseURL(modelRegistry: ModelRegistry, provider: XAIProvider, modelId: string | undefined): string {
52
+ if (modelId) {
53
+ const merged = modelRegistry.getAll().find(m => m.id === modelId && m.provider === provider);
54
+ if (merged?.baseUrl) {
55
+ const bundled = getBundledModels(provider as Parameters<typeof getBundledModels>[0]).find(
56
+ m => m.id === modelId,
57
+ );
58
+ const providerDefault = bundled?.baseUrl ?? DEFAULT_BASE_URL;
59
+ if (merged.baseUrl !== providerDefault) {
60
+ return merged.baseUrl.replace(/\/$/, "");
61
+ }
62
+ }
63
+ }
64
+ const providerBaseUrl = modelRegistry.getProviderBaseUrl(provider);
65
+ if (providerBaseUrl) {
66
+ const normalized = providerBaseUrl.replace(/\/$/, "");
67
+ if (normalized !== DEFAULT_BASE_URL) return normalized;
68
+ }
69
+ return ($env.XAI_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, "");
70
+ }
71
+
72
+ /**
73
+ * Resolve xAI credentials for HTTP tool calls.
74
+ *
75
+ * Credential priority:
76
+ * 1. xai-oauth — only when a *dedicated* xai-oauth source exists. Composed
77
+ * of two checks against the registry layer:
78
+ * a. `authStorage.hasNonEnvCredential("xai-oauth")` covers stored
79
+ * credentials (OAuth or api_key), runtime overrides (CLI
80
+ * `--api-key` for xai-oauth), config overrides (models.yml
81
+ * `providers.xai-oauth.apiKey`), and fallback resolvers.
82
+ * b. `$env.XAI_OAUTH_TOKEN` covers the xai-oauth-specific env var.
83
+ * `XAI_API_KEY` is intentionally NOT a signal here, even though the
84
+ * env-fallback map (`stream.ts: "xai-oauth"`) lets xai-oauth borrow it
85
+ * as a back-compat convenience: the borrow lets API-key-only setups
86
+ * satisfy the xai-oauth branch and then resolve baseUrl under
87
+ * xai-oauth instead of xai, silently bypassing `providers.xai.baseUrl`
88
+ * overrides for image/TTS traffic. The gate routes the borrow case to
89
+ * step 2 while preserving every dedicated xai-oauth path.
90
+ * 2. xai (plain API key). Delegates to ModelRegistry.getApiKeyForProvider
91
+ * which runs AuthStorage.getApiKey's full cascade: runtime override →
92
+ * models.yml config override → stored api_key credential → OAuth
93
+ * resolution → XAI_API_KEY env var → custom fallback resolver.
94
+ *
95
+ * baseURL: see `resolveXAIBaseURL` above. Resolved AFTER the credential
96
+ * decision so the scoped (provider, id) lookup is unambiguous. `modelId`
97
+ * is optional; probes / tool-availability checks pass `undefined` and fall
98
+ * through to env/default.
99
+ *
100
+ * Returns null when neither credential is available. Caller is responsible
101
+ * for surfacing an actionable error message in that case.
102
+ */
103
+ export async function resolveXAIHttpCredentials(
104
+ modelRegistry: ModelRegistry,
105
+ modelId?: string,
106
+ ): Promise<XAICredentials | null> {
107
+ const hasDedicatedXaiOAuth =
108
+ modelRegistry.authStorage.hasNonEnvCredential("xai-oauth") || Boolean($env.XAI_OAUTH_TOKEN);
109
+ if (hasDedicatedXaiOAuth) {
110
+ const oauthKey = await modelRegistry.getApiKeyForProvider("xai-oauth");
111
+ if (oauthKey) {
112
+ const baseURL = resolveXAIBaseURL(modelRegistry, "xai-oauth", modelId);
113
+ return { provider: "xai-oauth", apiKey: oauthKey, baseURL };
114
+ }
115
+ }
116
+
117
+ const apiKey = await modelRegistry.getApiKeyForProvider("xai");
118
+ if (apiKey) {
119
+ const baseURL = resolveXAIBaseURL(modelRegistry, "xai", modelId);
120
+ return { provider: "xai", apiKey, baseURL };
121
+ }
122
+
123
+ return null;
124
+ }
package/src/main.ts CHANGED
@@ -9,7 +9,6 @@ import * as fs from "node:fs/promises";
9
9
  import * as os from "node:os";
10
10
  import * as path from "node:path";
11
11
  import { createInterface } from "node:readline/promises";
12
- import { keepaliveWhile } from "@oh-my-pi/pi-agent-core";
13
12
  import type { ImageContent } from "@oh-my-pi/pi-ai";
14
13
  import {
15
14
  $env,
@@ -316,7 +315,7 @@ async function runInteractiveMode(
316
315
  }
317
316
 
318
317
  while (true) {
319
- const input = await keepaliveWhile(mode.getUserInput());
318
+ const input = await mode.getUserInput();
320
319
  await submitInteractiveInput(mode, session, input);
321
320
  }
322
321
  }
@@ -16,8 +16,23 @@ import type {
16
16
  MCPTransport,
17
17
  } from "../../mcp/types";
18
18
  import { toJsonRpcError } from "../../mcp/types";
19
- import { createMCPTimeout, getNeverAbortSignal, resolveMCPTimeoutMs } from "../timeout";
19
+ import { createMCPTimeout, getNeverAbortSignal, isMCPTimeoutEnabled, resolveMCPTimeoutMs } from "../timeout";
20
20
 
21
+ const HTTP_SSE_CONNECT_TIMEOUT_MS = 1_000;
22
+ /**
23
+ * Best-effort startup deadline for the optional Streamable HTTP GET SSE listener.
24
+ *
25
+ * Returns `0` (disabled) when the operator has explicitly disabled MCP client-side
26
+ * timeouts via `timeout: 0` or `OMP_MCP_TIMEOUT_MS=0`, mirroring the rest of the
27
+ * MCP timeout surface. Otherwise caps the wait at one second and scales below
28
+ * short request timeouts so connect-time never exceeds the request budget.
29
+ */
30
+ export function resolveSSEConnectTimeoutMs(configTimeout?: number): number {
31
+ const requestTimeout = resolveMCPTimeoutMs(configTimeout);
32
+ if (!isMCPTimeoutEnabled(requestTimeout)) return 0;
33
+ const boundedTimeout = Math.min(HTTP_SSE_CONNECT_TIMEOUT_MS, Math.floor(requestTimeout / 4));
34
+ return Math.max(1, boundedTimeout);
35
+ }
21
36
  /**
22
37
  * HTTP transport for MCP servers.
23
38
  * Uses POST for requests, supports SSE responses.
@@ -73,6 +88,15 @@ export class HttpTransport implements MCPTransport {
73
88
  }
74
89
 
75
90
  let response: Response;
91
+ let timedOut = false;
92
+ const startupTimeoutMs = resolveSSEConnectTimeoutMs(this.config.timeout);
93
+ const timeoutId =
94
+ startupTimeoutMs > 0
95
+ ? setTimeout(() => {
96
+ timedOut = true;
97
+ this.#sseConnection?.abort();
98
+ }, startupTimeoutMs)
99
+ : null;
76
100
  try {
77
101
  response = await fetch(this.config.url, {
78
102
  method: "GET",
@@ -81,13 +105,16 @@ export class HttpTransport implements MCPTransport {
81
105
  });
82
106
  } catch (error) {
83
107
  this.#sseConnection = null;
84
- if (error instanceof Error && error.name !== "AbortError") {
108
+ if (error instanceof Error && error.name !== "AbortError" && !timedOut) {
85
109
  this.onError?.(error);
86
110
  }
87
111
  return;
112
+ } finally {
113
+ if (timeoutId !== null) clearTimeout(timeoutId);
88
114
  }
89
115
 
90
116
  if (response.status === 405 || !response.ok || !response.body) {
117
+ await response.body?.cancel();
91
118
  this.#sseConnection = null;
92
119
  return;
93
120
  }
@@ -1,3 +1,4 @@
1
+ import type { SnapshotStore } from "@oh-my-pi/hashline";
1
2
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
3
  import {
3
4
  Box,
@@ -105,10 +106,10 @@ function resolveEditModeForTool(toolName: string, tool: AgentTool | undefined):
105
106
  }
106
107
 
107
108
  export interface ToolExecutionOptions {
109
+ snapshots?: SnapshotStore;
108
110
  showImages?: boolean; // default: true (only used if terminal supports images)
109
111
  editFuzzyThreshold?: number;
110
112
  editAllowFuzzy?: boolean;
111
- hashlineAutoDropPureInsertDuplicates?: boolean;
112
113
  }
113
114
 
114
115
  export interface ToolExecutionHandle {
@@ -142,7 +143,7 @@ export class ToolExecutionComponent extends Container {
142
143
  #showImages: boolean;
143
144
  #editFuzzyThreshold: number | undefined;
144
145
  #editAllowFuzzy: boolean | undefined;
145
- #hashlineAutoDropPureInsertDuplicates: boolean | undefined;
146
+ #snapshots?: SnapshotStore;
146
147
  #isPartial = true;
147
148
  #tool?: AgentTool;
148
149
  #ui: TUI;
@@ -189,7 +190,7 @@ export class ToolExecutionComponent extends Container {
189
190
  this.#showImages = options.showImages ?? true;
190
191
  this.#editFuzzyThreshold = options.editFuzzyThreshold;
191
192
  this.#editAllowFuzzy = options.editAllowFuzzy;
192
- this.#hashlineAutoDropPureInsertDuplicates = options.hashlineAutoDropPureInsertDuplicates;
193
+ this.#snapshots = options.snapshots;
193
194
  this.#tool = tool;
194
195
  this.#ui = ui;
195
196
  this.#cwd = cwd;
@@ -266,12 +267,13 @@ export class ToolExecutionComponent extends Container {
266
267
 
267
268
  try {
268
269
  const isStreaming = !this.#argsComplete;
270
+ if (editMode === "hashline" && !this.#snapshots) return;
269
271
  const previews = await strategy.computeDiffPreview(effectiveArgs, {
270
272
  cwd: this.#cwd,
271
273
  signal: controller.signal,
274
+ snapshots: this.#snapshots!,
272
275
  fuzzyThreshold: this.#editFuzzyThreshold,
273
276
  allowFuzzy: this.#editAllowFuzzy,
274
- hashlineAutoDropPureInsertDuplicates: this.#hashlineAutoDropPureInsertDuplicates,
275
277
  isStreaming,
276
278
  });
277
279
  if (controller.signal.aborted) return;
@@ -3,6 +3,7 @@ import { calculatePromptTokens } from "@oh-my-pi/pi-agent-core/compaction/compac
3
3
  import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
4
4
  import { type Component, Loader, TERMINAL, Text } from "@oh-my-pi/pi-tui";
5
5
  import { settings } from "../../config/settings";
6
+ import { getFileSnapshotStore } from "../../edit/file-snapshot-store";
6
7
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
7
8
  import {
8
9
  ReadToolGroupComponent,
@@ -329,10 +330,10 @@ export class EventController {
329
330
  content.name,
330
331
  renderArgs,
331
332
  {
333
+ snapshots: getFileSnapshotStore(this.ctx.session),
332
334
  showImages: settings.get("terminal.showImages"),
333
335
  editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
334
336
  editAllowFuzzy: settings.get("edit.fuzzyMatch"),
335
- hashlineAutoDropPureInsertDuplicates: settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
336
337
  },
337
338
  tool,
338
339
  this.ctx.ui,
@@ -444,10 +445,10 @@ export class EventController {
444
445
  event.toolName,
445
446
  event.args,
446
447
  {
448
+ snapshots: getFileSnapshotStore(this.ctx.session),
447
449
  showImages: settings.get("terminal.showImages"),
448
450
  editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
449
451
  editAllowFuzzy: settings.get("edit.fuzzyMatch"),
450
- hashlineAutoDropPureInsertDuplicates: settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
451
452
  },
452
453
  tool,
453
454
  this.ctx.ui,
@@ -598,7 +599,13 @@ export class EventController {
598
599
  };
599
600
  this.ctx.statusContainer.clear();
600
601
  const reasonText =
601
- event.reason === "overflow" ? "Context overflow detected, " : event.reason === "idle" ? "Idle " : "";
602
+ event.reason === "overflow"
603
+ ? "Context overflow detected, "
604
+ : event.reason === "incomplete"
605
+ ? "Response incomplete, "
606
+ : event.reason === "idle"
607
+ ? "Idle "
608
+ : "";
602
609
  const actionLabel = event.action === "handoff" ? "Auto-handoff" : "Auto context-full maintenance";
603
610
  this.ctx.autoCompactionLoader = new Loader(
604
611
  this.ctx.ui,
@@ -29,7 +29,12 @@ import {
29
29
  import type { InteractiveModeContext } from "../../modes/types";
30
30
  import { type SessionInfo, SessionManager } from "../../session/session-manager";
31
31
  import { FileSessionStorage } from "../../session/session-storage";
32
- import { isSearchProviderPreference, setPreferredImageProvider, setPreferredSearchProvider } from "../../tools";
32
+ import {
33
+ isImageProviderPreference,
34
+ isSearchProviderPreference,
35
+ setPreferredImageProvider,
36
+ setPreferredSearchProvider,
37
+ } from "../../tools";
33
38
  import { setSessionTerminalTitle } from "../../utils/title-generator";
34
39
  import { AgentDashboard } from "../components/agent-dashboard";
35
40
  import { AssistantMessageComponent } from "../components/assistant-message";
@@ -374,7 +379,7 @@ export class SelectorController {
374
379
  }
375
380
  break;
376
381
  case "providers.image":
377
- if (value === "auto" || value === "openai" || value === "gemini" || value === "openrouter") {
382
+ if (isImageProviderPreference(value)) {
378
383
  setPreferredImageProvider(value);
379
384
  }
380
385
  break;
@@ -4,7 +4,13 @@
4
4
  */
5
5
  import * as fs from "node:fs/promises";
6
6
  import * as path from "node:path";
7
- import { type Agent, type AgentMessage, type AgentToolResult, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
7
+ import {
8
+ type Agent,
9
+ type AgentMessage,
10
+ type AgentToolResult,
11
+ EventLoopKeepalive,
12
+ ThinkingLevel,
13
+ } from "@oh-my-pi/pi-agent-core";
8
14
  import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction";
9
15
  import {
10
16
  type AssistantMessage,
@@ -619,7 +625,9 @@ export class InteractiveMode implements InteractiveModeContext {
619
625
  };
620
626
  this.#scheduleLoopAutoSubmit();
621
627
  this.#scheduleGoalContinuation();
622
- return promise;
628
+
629
+ using _ = new EventLoopKeepalive();
630
+ return await promise;
623
631
  }
624
632
 
625
633
  #scheduleLoopAutoSubmit(): void {
@@ -1012,7 +1020,7 @@ export class InteractiveMode implements InteractiveModeContext {
1012
1020
  }
1013
1021
 
1014
1022
  async #getPlanFilePath(): Promise<string> {
1015
- return "local://PLAN.md";
1023
+ return this.session.getPlanReferencePath() || "local://PLAN.md";
1016
1024
  }
1017
1025
 
1018
1026
  #resolvePlanFilePath(planFilePath: string): string {
@@ -2,6 +2,7 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
2
  import type { AssistantMessage, ImageContent, Message } from "@oh-my-pi/pi-ai";
3
3
  import { type Component, Spacer, Text, TruncatedText } from "@oh-my-pi/pi-tui";
4
4
  import { settings } from "../../config/settings";
5
+ import { getFileSnapshotStore } from "../../edit/file-snapshot-store";
5
6
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
6
7
  import { BashExecutionComponent } from "../../modes/components/bash-execution";
7
8
  import { BranchSummaryMessageComponent } from "../../modes/components/branch-summary-message";
@@ -377,10 +378,10 @@ export class UiHelpers {
377
378
  content.name,
378
379
  renderArgs,
379
380
  {
381
+ snapshots: getFileSnapshotStore(this.ctx.session),
380
382
  showImages: settings.get("terminal.showImages"),
381
383
  editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
382
384
  editAllowFuzzy: settings.get("edit.fuzzyMatch"),
383
- hashlineAutoDropPureInsertDuplicates: settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
384
385
  },
385
386
  tool,
386
387
  this.ctx.ui,
@@ -59,6 +59,9 @@ With most FS/bash-like tools, static references to them will automatically resol
59
59
  - `/<path>`: JSON field extraction
60
60
  - `artifact://<id>`: Artifact content
61
61
  - `local://<name>.md`: Plan artifacts and shared content with subagents
62
+ {{#if hasObsidian}}
63
+ - `vault://<vault>/<path>`: Obsidian vault content (read/edit). `vault://` lists vaults; `vault://_/…` targets the active vault. File-scoped `?op=outline|backlinks|links|tags|properties|tasks|base|…`; vault-scoped `?op=search&q=…|daily|tasks|orphans|unresolved|bases|…`.
64
+ {{/if}}
62
65
  - `mcp://<uri>`: MCP resource
63
66
  - `issue://<N>` (or `issue://<owner>/<repo>/<N>`): GitHub issue view; cached on disk so re-reads are free. Bare `issue://` (or `issue://<owner>/<repo>`) lists recent issues; supports `?state=open|closed|all&limit=&author=&label=`.
64
67
  - `pr://<N>` (or `pr://<owner>/<repo>/<N>`): GitHub PR view; same cache. Append `?comments=0` to drop the comments section. Bare `pr://` (or `pr://<owner>/<repo>`) lists recent PRs; supports `?state=open|closed|merged|all&limit=&author=&label=`.
@@ -14,7 +14,7 @@ Performs structural AST-aware rewrites via native ast-grep.
14
14
  </instruction>
15
15
 
16
16
  <output>
17
- - Replacement summary, per-file replacement counts, and change diffs as `¶src/foo.ts#1a2b`, `-12:before`, `+12:after` lines in hashline mode
17
+ - Replacement summary, per-file replacement counts, and change diffs as `¶src/foo.ts#0a`, `-12:before`, `+12:after` lines in hashline mode
18
18
  - Parse issues when files cannot be processed
19
19
  </output>
20
20
 
@@ -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-hash header in hashline mode: `¶src/foo.ts#1a2b`, `*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#0a`, `*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
 
@@ -8,7 +8,7 @@ Read files, directories, archives, SQLite databases, images, documents, internal
8
8
 
9
9
  ## Parameters
10
10
 
11
- - `path` — required. Local path, internal URI (`skill://`, `agent://`, `artifact://`, `memory://`, `rule://`, `local://`, `mcp://`), or URL. Append `:<sel>` for line ranges, raw mode, or special modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).
11
+ - `path` — required. Local path, internal URI (`skill://`, `agent://`, `artifact://`, `memory://`, `rule://`, `local://`, `vault://`, `mcp://`), or URL. Append `:<sel>` for line ranges, raw mode, or special modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).
12
12
 
13
13
  ## Selectors
14
14
 
@@ -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-hash header and numbered lines: `¶src/foo.ts#1a2b` then `41:def alpha():`. Copy the `¶PATH#HASH` header for anchored edits; ops use bare line numbers. NEVER fabricate the hash.
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.
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():`.
@@ -70,7 +70,7 @@ For `.sqlite`, `.sqlite3`, `.db`, `.db3`:
70
70
 
71
71
  # Internal URIs
72
72
 
73
- `skill://<name>`, `agent://<id>`, `artifact://<id>`, `memory://root`, `rule://<name>`, `local://<name>.md`, `mcp://<uri>` resolve transparently and accept the same line selectors as filesystem paths. Use `artifact://<id>` to recover full output that a previous bash/eval/tool result spilled or truncated.
73
+ `skill://<name>`, `agent://<id>`, `artifact://<id>`, `memory://root`, `rule://<name>`, `local://<name>.md`, `vault://<vault>/<path>`, `mcp://<uri>` resolve transparently and accept the same line selectors as filesystem paths. Use `artifact://<id>` to recover full output that a previous bash/eval/tool result spilled or truncated.
74
74
 
75
75
  <critical>
76
76
  - You MUST use `read` for every file, directory, archive, and URL inspection. `cat`, `head`, `tail`, `less`, `more`, `ls`, `tar`, `unzip`, `curl`, `wget` are FORBIDDEN — any such bash call is a bug, regardless of how short or convenient it looks.
@@ -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-hash header per matched file plus numbered lines: `¶src/login.ts#3c4d`, `*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#1f`, `*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
@@ -10,6 +10,7 @@ import {
10
10
  } from "@oh-my-pi/pi-agent-core";
11
11
  import {
12
12
  type CredentialDisabledEvent,
13
+ isUsageLimitError,
13
14
  type Message,
14
15
  type Model,
15
16
  type SimpleStreamOptions,
@@ -23,6 +24,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
23
24
  import {
24
25
  $env,
25
26
  $flag,
27
+ extractRetryHint,
26
28
  getAgentDbPath,
27
29
  getAgentDir,
28
30
  getProjectDir,
@@ -129,6 +131,7 @@ import {
129
131
  FindTool,
130
132
  getSearchTools,
131
133
  HIDDEN_TOOLS,
134
+ isImageProviderPreference,
132
135
  isSearchProviderPreference,
133
136
  type LspStartupServerInfo,
134
137
  loadSshTool,
@@ -148,6 +151,7 @@ import { ToolContextStore } from "./tools/context";
148
151
  import { getImageGenTools } from "./tools/image-gen";
149
152
  import { wrapToolWithMetaNotice } from "./tools/output-meta";
150
153
  import { queueResolveHandler } from "./tools/resolve";
154
+ import { ttsTool } from "./tools/tts";
151
155
  import { EventBus } from "./utils/event-bus";
152
156
  import { buildNamedToolChoice } from "./utils/tool-choice";
153
157
  import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
@@ -893,12 +897,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
893
897
  }
894
898
 
895
899
  const imageProvider = settings.get("providers.image");
896
- if (
897
- imageProvider === "auto" ||
898
- imageProvider === "openai" ||
899
- imageProvider === "gemini" ||
900
- imageProvider === "openrouter"
901
- ) {
900
+ if (isImageProviderPreference(imageProvider)) {
902
901
  setPreferredImageProvider(imageProvider);
903
902
  }
904
903
 
@@ -1319,6 +1318,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1319
1318
  customTools.push(...(imageGenTools as unknown as CustomTool[]));
1320
1319
  }
1321
1320
 
1321
+ if (settings.get("tts.enabled")) {
1322
+ customTools.push(ttsTool as unknown as CustomTool);
1323
+ }
1324
+
1322
1325
  // Add web search tools
1323
1326
  if (options.toolNames?.includes("web_search")) {
1324
1327
  customTools.push(...getSearchTools());
@@ -1876,21 +1879,49 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1876
1879
  }
1877
1880
  return key;
1878
1881
  },
1879
- streamFn: (streamModel, context, streamOptions) =>
1880
- streamSimple(streamModel, context, {
1882
+ streamFn: (streamModel, context, streamOptions) => {
1883
+ const openrouterRoutingPreset = settings.get("providers.openrouterVariant");
1884
+ const openrouterVariant =
1885
+ openrouterRoutingPreset && openrouterRoutingPreset !== "default" ? openrouterRoutingPreset : undefined;
1886
+ return streamSimple(streamModel, context, {
1881
1887
  ...streamOptions,
1888
+ openrouterVariant: streamOptions?.openrouterVariant ?? openrouterVariant,
1882
1889
  onAuthError: async (provider, oldKey, error) => {
1890
+ const message = error instanceof Error ? error.message : String(error);
1891
+ // streamSimple invokes this for both 401 auth failures AND
1892
+ // rotatable usage-limit errors (Codex usage_limit_reached,
1893
+ // Anthropic usage_limit_reached, etc.). The two need
1894
+ // different storage actions: a real 401 means the credential
1895
+ // is bad and should be marked suspect; a usage limit just
1896
+ // means this account is parked until reset and should be
1897
+ // temporarily blocked so a sibling can pick the request up.
1898
+ if (isUsageLimitError(message)) {
1899
+ const retryAfterMs = extractRetryHint(undefined, message);
1900
+ const switched = await modelRegistry.authStorage.markUsageLimitReached(provider, agent.sessionId, {
1901
+ retryAfterMs,
1902
+ signal: streamOptions?.signal,
1903
+ });
1904
+ logger.debug("Retrying provider request after usage-limit block", {
1905
+ provider,
1906
+ switched,
1907
+ retryAfterMs,
1908
+ error: message,
1909
+ });
1910
+ if (!switched) return undefined;
1911
+ return modelRegistry.getApiKeyForProvider(provider, agent.sessionId);
1912
+ }
1883
1913
  await modelRegistry.authStorage.invalidateCredentialMatching(provider, oldKey, {
1884
1914
  signal: streamOptions?.signal,
1885
1915
  sessionId: agent.sessionId,
1886
1916
  });
1887
1917
  logger.debug("Retrying provider request after credential invalidation", {
1888
1918
  provider,
1889
- error: error instanceof Error ? error.message : String(error),
1919
+ error: message,
1890
1920
  });
1891
1921
  return modelRegistry.getApiKeyForProvider(provider, agent.sessionId);
1892
1922
  },
1893
- }),
1923
+ });
1924
+ },
1894
1925
  cursorExecHandlers,
1895
1926
  transformToolCallArguments: (args, _toolName) => {
1896
1927
  let result = args;