@oh-my-pi/pi-coding-agent 15.5.4 → 15.5.7

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 (43) hide show
  1. package/CHANGELOG.md +48 -2
  2. package/dist/types/config/settings-schema.d.ts +50 -2
  3. package/dist/types/edit/hashline/diff.d.ts +6 -1
  4. package/dist/types/edit/hashline/execute.d.ts +1 -2
  5. package/dist/types/edit/hashline/params.d.ts +4 -5
  6. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +23 -0
  7. package/dist/types/lib/xai-http.d.ts +40 -0
  8. package/dist/types/session/agent-session.d.ts +1 -0
  9. package/dist/types/tools/fetch.d.ts +19 -0
  10. package/dist/types/tools/find.d.ts +7 -0
  11. package/dist/types/tools/image-gen.d.ts +6 -2
  12. package/dist/types/tools/index.d.ts +1 -0
  13. package/dist/types/tools/plan-mode-guard.d.ts +5 -6
  14. package/dist/types/tools/tts.d.ts +18 -0
  15. package/package.json +8 -8
  16. package/scripts/build-binary.ts +11 -0
  17. package/src/config/model-registry.ts +41 -9
  18. package/src/config/settings-schema.ts +43 -2
  19. package/src/edit/diff.ts +5 -3
  20. package/src/edit/hashline/diff.ts +11 -4
  21. package/src/edit/hashline/execute.ts +3 -10
  22. package/src/edit/hashline/params.ts +10 -3
  23. package/src/edit/index.ts +9 -12
  24. package/src/edit/renderer.ts +14 -7
  25. package/src/edit/streaming.ts +15 -128
  26. package/src/extensibility/legacy-pi-ai-shim.ts +24 -0
  27. package/src/extensibility/plugins/legacy-pi-compat.ts +47 -3
  28. package/src/lib/xai-http.ts +124 -0
  29. package/src/main.ts +2 -1
  30. package/src/modes/controllers/selector-controller.ts +7 -2
  31. package/src/modes/interactive-mode.ts +1 -1
  32. package/src/modes/rpc/rpc-client.ts +3 -1
  33. package/src/prompts/tools/find.md +3 -2
  34. package/src/sdk.ts +15 -9
  35. package/src/session/agent-session.ts +48 -5
  36. package/src/tools/fetch.ts +145 -74
  37. package/src/tools/find.ts +38 -6
  38. package/src/tools/image-gen.ts +205 -7
  39. package/src/tools/index.ts +1 -0
  40. package/src/tools/plan-mode-guard.ts +14 -6
  41. package/src/tools/read.ts +57 -3
  42. package/src/tools/search.ts +2 -2
  43. package/src/tools/tts.ts +133 -0
@@ -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,6 +9,7 @@ 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";
12
13
  import type { ImageContent } from "@oh-my-pi/pi-ai";
13
14
  import {
14
15
  $env,
@@ -315,7 +316,7 @@ async function runInteractiveMode(
315
316
  }
316
317
 
317
318
  while (true) {
318
- const input = await mode.getUserInput();
319
+ const input = await keepaliveWhile(mode.getUserInput());
319
320
  await submitInteractiveInput(mode, session, input);
320
321
  }
321
322
  }
@@ -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;
@@ -1012,7 +1012,7 @@ export class InteractiveMode implements InteractiveModeContext {
1012
1012
  }
1013
1013
 
1014
1014
  async #getPlanFilePath(): Promise<string> {
1015
- return "local://PLAN.md";
1015
+ return this.session.getPlanReferencePath() || "local://PLAN.md";
1016
1016
  }
1017
1017
 
1018
1018
  #resolvePlanFilePath(planFilePath: string): string {
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * Spawns the agent in RPC mode and provides a typed API for all operations.
5
5
  */
6
+
7
+ import { isPromise } from "node:util/types";
6
8
  import type { AgentEvent, AgentMessage, AgentToolResult, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
7
9
  import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction";
8
10
  import type { ImageContent, Model } from "@oh-my-pi/pi-ai";
@@ -790,7 +792,7 @@ export class RpcClient {
790
792
  const stdin = this.#process.stdin as import("bun").FileSink;
791
793
  stdin.write(`${JSON.stringify(frame)}\n`);
792
794
  const flushResult = stdin.flush();
793
- if (flushResult instanceof Promise) {
795
+ if (isPromise(flushResult)) {
794
796
  flushResult.catch((err: Error) => {
795
797
  onError?.(err);
796
798
  });
@@ -5,17 +5,18 @@ Finds files and directories using fast pattern matching that works with any code
5
5
  - Pass multiple targets as **separate array elements** (`paths: ["a", "b"]`), NEVER as a single comma-joined string (`paths: ["a,b"]` is rejected)
6
6
  - `gitignore` defaults to `true` and hides files matched by `.gitignore`. Set `gitignore: false` to find `.env*`, `*.log`, freshly-created build outputs, or anything else your repo ignores
7
7
  - `hidden` defaults to `true`; combine with `gitignore: false` to surface dotfiles that are also gitignored
8
+ - `limit` is clamped to 1-200 (default 200). Narrow the pattern instead of raising the limit
8
9
  - `timeout` is in seconds (default 5, clamped to 0.5–60). On timeout, find returns whatever partial matches it has collected with `truncated: true` and a notice — increase `timeout` or narrow the pattern instead of retrying blindly
9
10
  - You SHOULD perform multiple searches in parallel when potentially useful
10
11
  </instruction>
11
12
 
12
13
  <output>
13
- Matching file and directory paths sorted by modification time (most recent first). Directories are suffixed with `/`. Truncated at 1000 entries or 50KB (configurable via `limit`).
14
+ Matching file and directory paths sorted by modification time (most recent first), grouped by directory to reduce token usage. Each group starts with `# <dir>/` followed by basenames (one per line); directory entries get a trailing `/`. Root-level entries have no header. Truncated at 200 entries or 50KB.
14
15
  </output>
15
16
 
16
17
  <examples>
17
18
  # Find files
18
- `{"paths": ["src/**/*.ts"], "limit": 1000}`
19
+ `{"paths": ["src/**/*.ts"]}`
19
20
  # Multiple targets — separate array elements
20
21
  `{"paths": ["src/**/*.ts", "test/**/*.ts"]}`
21
22
  # Find gitignored files like .env
package/src/sdk.ts CHANGED
@@ -129,6 +129,7 @@ import {
129
129
  FindTool,
130
130
  getSearchTools,
131
131
  HIDDEN_TOOLS,
132
+ isImageProviderPreference,
132
133
  isSearchProviderPreference,
133
134
  type LspStartupServerInfo,
134
135
  loadSshTool,
@@ -148,6 +149,7 @@ import { ToolContextStore } from "./tools/context";
148
149
  import { getImageGenTools } from "./tools/image-gen";
149
150
  import { wrapToolWithMetaNotice } from "./tools/output-meta";
150
151
  import { queueResolveHandler } from "./tools/resolve";
152
+ import { ttsTool } from "./tools/tts";
151
153
  import { EventBus } from "./utils/event-bus";
152
154
  import { buildNamedToolChoice } from "./utils/tool-choice";
153
155
  import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
@@ -893,12 +895,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
893
895
  }
894
896
 
895
897
  const imageProvider = settings.get("providers.image");
896
- if (
897
- imageProvider === "auto" ||
898
- imageProvider === "openai" ||
899
- imageProvider === "gemini" ||
900
- imageProvider === "openrouter"
901
- ) {
898
+ if (isImageProviderPreference(imageProvider)) {
902
899
  setPreferredImageProvider(imageProvider);
903
900
  }
904
901
 
@@ -1319,6 +1316,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1319
1316
  customTools.push(...(imageGenTools as unknown as CustomTool[]));
1320
1317
  }
1321
1318
 
1319
+ if (settings.get("tts.enabled")) {
1320
+ customTools.push(ttsTool as unknown as CustomTool);
1321
+ }
1322
+
1322
1323
  // Add web search tools
1323
1324
  if (options.toolNames?.includes("web_search")) {
1324
1325
  customTools.push(...getSearchTools());
@@ -1876,9 +1877,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1876
1877
  }
1877
1878
  return key;
1878
1879
  },
1879
- streamFn: (streamModel, context, streamOptions) =>
1880
- streamSimple(streamModel, context, {
1880
+ streamFn: (streamModel, context, streamOptions) => {
1881
+ const openrouterRoutingPreset = settings.get("providers.openrouterVariant");
1882
+ const openrouterVariant =
1883
+ openrouterRoutingPreset && openrouterRoutingPreset !== "default" ? openrouterRoutingPreset : undefined;
1884
+ return streamSimple(streamModel, context, {
1881
1885
  ...streamOptions,
1886
+ openrouterVariant: streamOptions?.openrouterVariant ?? openrouterVariant,
1882
1887
  onAuthError: async (provider, oldKey, error) => {
1883
1888
  await modelRegistry.authStorage.invalidateCredentialMatching(provider, oldKey, {
1884
1889
  signal: streamOptions?.signal,
@@ -1890,7 +1895,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1890
1895
  });
1891
1896
  return modelRegistry.getApiKeyForProvider(provider, agent.sessionId);
1892
1897
  },
1893
- }),
1898
+ });
1899
+ },
1894
1900
  cursorExecHandlers,
1895
1901
  transformToolCallArguments: (args, _toolName) => {
1896
1902
  let result = args;
@@ -17,6 +17,7 @@ import * as crypto from "node:crypto";
17
17
  import * as fs from "node:fs";
18
18
  import * as path from "node:path";
19
19
  import { scheduler } from "node:timers/promises";
20
+ import { isPromise } from "node:util/types";
20
21
  import {
21
22
  type AfterToolCallContext,
22
23
  type AfterToolCallResult,
@@ -1307,10 +1308,25 @@ export class AgentSession {
1307
1308
 
1308
1309
  /** Emit an event to all listeners */
1309
1310
  #emit(event: AgentSessionEvent): void {
1310
- // Copy array before iteration to avoid mutation during iteration
1311
+ // Copy array before iteration to avoid mutation during iteration.
1311
1312
  const listeners = [...this.#eventListeners];
1312
1313
  for (const l of listeners) {
1313
- l(event);
1314
+ try {
1315
+ const result = l(event) as unknown;
1316
+ // Listener may be an async function whose returned Promise we don't await;
1317
+ // attach a catch so a rejection does not become an unhandled rejection.
1318
+ if (isPromise(result)) {
1319
+ result.catch(err => {
1320
+ logger.warn("AgentSession listener rejected", {
1321
+ error: err instanceof Error ? err.message : String(err),
1322
+ });
1323
+ });
1324
+ }
1325
+ } catch (err) {
1326
+ logger.warn("AgentSession listener threw", {
1327
+ error: err instanceof Error ? err.message : String(err),
1328
+ });
1329
+ }
1314
1330
  }
1315
1331
  }
1316
1332
 
@@ -3615,9 +3631,17 @@ export class AgentSession {
3615
3631
  const sessionOnResponse = this.#onResponse;
3616
3632
  const sessionMetadata = this.agent.metadataForProvider(provider);
3617
3633
  const sessionOnSseEvent = this.#onSseEvent;
3618
- if (!sessionOnPayload && !sessionOnResponse && !sessionMetadata && !sessionOnSseEvent) return options;
3619
-
3620
- const preparedOptions: SimpleStreamOptions = { ...options };
3634
+ const openrouterRoutingPreset =
3635
+ provider === "openrouter" ? this.settings.get("providers.openrouterVariant") : "default";
3636
+ const openrouterVariant =
3637
+ openrouterRoutingPreset !== "default" && options.openrouterVariant === undefined
3638
+ ? openrouterRoutingPreset
3639
+ : undefined;
3640
+ if (!sessionOnPayload && !sessionOnResponse && !sessionMetadata && !sessionOnSseEvent && !openrouterVariant)
3641
+ return options;
3642
+
3643
+ const preparedOptions: SimpleStreamOptions =
3644
+ openrouterVariant === undefined ? { ...options } : { ...options, openrouterVariant };
3621
3645
 
3622
3646
  // Stamp session metadata (e.g. user_id={session_id}) onto direct-call requests so
3623
3647
  // they share the same session bucket as Agent.prompt-routed requests on Anthropic
@@ -3742,6 +3766,10 @@ export class AgentSession {
3742
3766
  this.#planReferencePath = path;
3743
3767
  }
3744
3768
 
3769
+ getPlanReferencePath(): string {
3770
+ return this.#planReferencePath;
3771
+ }
3772
+
3745
3773
  get clientBridge(): ClientBridge | undefined {
3746
3774
  return this.#clientBridge;
3747
3775
  }
@@ -5559,6 +5587,11 @@ export class AgentSession {
5559
5587
  initiatorOverride: "agent",
5560
5588
  metadata: this.agent.metadataForProvider(model.provider),
5561
5589
  telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
5590
+ // Honor the user's /model thinking selection on the handoff
5591
+ // path. Clamped per-model inside generateHandoff via
5592
+ // resolveCompactionEffort so unsupported-effort models don't
5593
+ // trip requireSupportedEffort.
5594
+ thinkingLevel: this.thinkingLevel,
5562
5595
  },
5563
5596
  handoffSignal,
5564
5597
  );
@@ -6329,6 +6362,11 @@ export class AgentSession {
6329
6362
  metadata: this.agent.metadataForProvider(candidate.provider),
6330
6363
  convertToLlm,
6331
6364
  telemetry,
6365
+ // Honor the user's /model thinking selection (incl. `off`) on
6366
+ // the manual `/compact` path. Clamped per-model inside compact()
6367
+ // via resolveCompactionEffort so unsupported-effort models
6368
+ // (xai-oauth/grok-build) don't trip requireSupportedEffort.
6369
+ thinkingLevel: this.thinkingLevel,
6332
6370
  });
6333
6371
  } catch (error) {
6334
6372
  if (!this.#isCompactionAuthFailure(error)) {
@@ -6601,6 +6639,11 @@ export class AgentSession {
6601
6639
  initiatorOverride: "agent",
6602
6640
  convertToLlm,
6603
6641
  telemetry,
6642
+ // Honor the user's /model thinking selection on the
6643
+ // auto-compaction path — the most-fired compaction
6644
+ // site. Clamped per-model inside compact() via
6645
+ // resolveCompactionEffort.
6646
+ thinkingLevel: this.thinkingLevel,
6604
6647
  });
6605
6648
  break;
6606
6649
  } catch (error) {