@oh-my-pi/pi-coding-agent 17.2.12 → 17.2.14

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 (143) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/{CHANGELOG-k9ghy5sn.md → CHANGELOG-za420td8.md} +63 -0
  3. package/dist/cli.js +3658 -3627
  4. package/dist/types/advisor/delta-split.d.ts +24 -0
  5. package/dist/types/advisor/runtime.d.ts +2 -2
  6. package/dist/types/async/job-manager.d.ts +8 -1
  7. package/dist/types/cli/update-cli.d.ts +53 -1
  8. package/dist/types/config/keybindings.d.ts +10 -0
  9. package/dist/types/config/model-resolver.d.ts +15 -2
  10. package/dist/types/config/settings-schema.d.ts +14 -0
  11. package/dist/types/discovery/agents-md.d.ts +10 -1
  12. package/dist/types/eval/runner-cache.d.ts +12 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +12 -3
  14. package/dist/types/extensibility/extensions/types.d.ts +15 -4
  15. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +4 -1
  16. package/dist/types/lib/xai-http.d.ts +0 -1
  17. package/dist/types/mcp/tool-bridge.d.ts +8 -5
  18. package/dist/types/modes/components/agent-hub-renderer.d.ts +6 -1
  19. package/dist/types/modes/components/status-line/types.d.ts +4 -0
  20. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -4
  21. package/dist/types/modes/interactive-mode.d.ts +17 -8
  22. package/dist/types/modes/types.d.ts +7 -10
  23. package/dist/types/modes/utils/hotkeys-markdown.d.ts +1 -1
  24. package/dist/types/session/agent-session-types.d.ts +4 -0
  25. package/dist/types/session/agent-session.d.ts +24 -3
  26. package/dist/types/session/messages.d.ts +20 -0
  27. package/dist/types/session/retry-fallback-chains.d.ts +13 -0
  28. package/dist/types/session/session-advisors.d.ts +1 -1
  29. package/dist/types/session/session-history-format.d.ts +10 -0
  30. package/dist/types/session/session-maintenance.d.ts +1 -1
  31. package/dist/types/session/session-tools.d.ts +35 -5
  32. package/dist/types/session/turn-recovery.d.ts +34 -5
  33. package/dist/types/slash-commands/types.d.ts +5 -1
  34. package/dist/types/task/executor.d.ts +1 -1
  35. package/dist/types/tools/approval.d.ts +7 -0
  36. package/dist/types/tools/builtin-names.d.ts +1 -2
  37. package/dist/types/tools/index.d.ts +1 -0
  38. package/dist/types/tools/think.d.ts +38 -0
  39. package/dist/types/tools/todo.d.ts +14 -15
  40. package/dist/types/tools/write.d.ts +2 -2
  41. package/dist/types/utils/local-date.d.ts +2 -0
  42. package/dist/types/vibe/runtime.d.ts +1 -1
  43. package/dist/types/web/parallel.d.ts +1 -0
  44. package/dist/types/web/search/providers/brave.d.ts +8 -3
  45. package/dist/types/web/search/providers/codex.d.ts +6 -0
  46. package/dist/types/web/search/providers/firecrawl.d.ts +3 -2
  47. package/dist/types/web/search/providers/jina.d.ts +3 -3
  48. package/dist/types/web/search/providers/parallel.d.ts +1 -0
  49. package/dist/types/web/search/providers/perplexity.d.ts +4 -0
  50. package/dist/types/web/search/providers/tinyfish.d.ts +2 -0
  51. package/package.json +13 -13
  52. package/src/advisor/delta-split.ts +98 -0
  53. package/src/advisor/runtime.ts +321 -69
  54. package/src/async/job-manager.ts +14 -3
  55. package/src/cli/gallery-fixtures/agentic.ts +16 -0
  56. package/src/cli/plugin-cli.ts +30 -2
  57. package/src/cli/update-cli.ts +259 -24
  58. package/src/config/keybindings.ts +52 -9
  59. package/src/config/model-resolver.ts +19 -3
  60. package/src/config/settings-schema.ts +16 -0
  61. package/src/cursor.ts +10 -5
  62. package/src/discovery/agents-md.ts +61 -23
  63. package/src/eval/jl/kernel.ts +2 -20
  64. package/src/eval/py/kernel.ts +2 -20
  65. package/src/eval/rb/kernel.ts +2 -20
  66. package/src/eval/runner-cache.ts +41 -0
  67. package/src/exec/non-interactive-env.ts +14 -3
  68. package/src/extensibility/extensions/loader.ts +5 -2
  69. package/src/extensibility/extensions/runner.ts +184 -66
  70. package/src/extensibility/extensions/types.ts +26 -2
  71. package/src/extensibility/extensions/wrapper.ts +13 -7
  72. package/src/extensibility/plugins/marketplace/manager.ts +6 -2
  73. package/src/hindsight/client.ts +1 -1
  74. package/src/lib/xai-http.ts +0 -4
  75. package/src/lsp/client.ts +2 -0
  76. package/src/lsp/servers.ts +1 -1
  77. package/src/mcp/tool-bridge.ts +15 -6
  78. package/src/modes/components/agent-hub-renderer.ts +9 -3
  79. package/src/modes/components/agent-hub.ts +2 -1
  80. package/src/modes/components/status-line/component.ts +58 -6
  81. package/src/modes/components/status-line/segments.ts +12 -1
  82. package/src/modes/components/status-line/types.ts +1 -0
  83. package/src/modes/components/user-message.ts +20 -5
  84. package/src/modes/controllers/event-controller.ts +48 -0
  85. package/src/modes/controllers/extension-ui-controller.ts +14 -7
  86. package/src/modes/controllers/input-controller.ts +25 -6
  87. package/src/modes/controllers/selector-controller.ts +5 -0
  88. package/src/modes/interactive-mode.ts +315 -129
  89. package/src/modes/rpc/rpc-frame.ts +13 -5
  90. package/src/modes/theme/tui-adapters.ts +4 -5
  91. package/src/modes/types.ts +13 -7
  92. package/src/modes/utils/hotkeys-markdown.ts +10 -6
  93. package/src/prompts/system/system-prompt.md +10 -1
  94. package/src/registry/persisted-agents.ts +43 -8
  95. package/src/sdk.ts +146 -9
  96. package/src/session/agent-session-types.ts +4 -0
  97. package/src/session/agent-session.ts +91 -10
  98. package/src/session/messages.ts +98 -28
  99. package/src/session/retry-fallback-chains.ts +14 -0
  100. package/src/session/session-advisors.ts +32 -15
  101. package/src/session/session-history-format.ts +15 -1
  102. package/src/session/session-maintenance.ts +8 -8
  103. package/src/session/session-manager.ts +6 -2
  104. package/src/session/session-tools.ts +355 -183
  105. package/src/session/turn-recovery.ts +225 -47
  106. package/src/slash-commands/builtin-modes.ts +41 -12
  107. package/src/slash-commands/types.ts +5 -1
  108. package/src/task/executor.ts +92 -45
  109. package/src/task/structured-subagent.ts +5 -5
  110. package/src/tools/approval.ts +44 -10
  111. package/src/tools/builtin-names.ts +1 -2
  112. package/src/tools/fetch.ts +21 -2
  113. package/src/tools/image-gen.ts +6 -8
  114. package/src/tools/index.ts +8 -0
  115. package/src/tools/renderers.ts +2 -0
  116. package/src/tools/think.ts +62 -0
  117. package/src/tools/todo.ts +70 -26
  118. package/src/tools/tts.ts +3 -2
  119. package/src/tools/write.ts +7 -3
  120. package/src/utils/local-date.ts +13 -0
  121. package/src/utils/tools-manager.ts +2 -2
  122. package/src/vibe/runtime.ts +22 -14
  123. package/src/web/kagi.ts +91 -34
  124. package/src/web/parallel.ts +11 -2
  125. package/src/web/scrapers/crates-io.ts +2 -2
  126. package/src/web/scrapers/discogs.ts +2 -2
  127. package/src/web/scrapers/docs-rs.ts +2 -2
  128. package/src/web/scrapers/github.ts +2 -2
  129. package/src/web/scrapers/musicbrainz.ts +1 -2
  130. package/src/web/scrapers/pubmed.ts +2 -2
  131. package/src/web/scrapers/sec-edgar.ts +2 -2
  132. package/src/web/search/providers/brave.ts +121 -46
  133. package/src/web/search/providers/codex.ts +88 -12
  134. package/src/web/search/providers/exa.ts +45 -10
  135. package/src/web/search/providers/firecrawl.ts +53 -11
  136. package/src/web/search/providers/gemini.ts +139 -27
  137. package/src/web/search/providers/jina.ts +48 -25
  138. package/src/web/search/providers/parallel.ts +23 -9
  139. package/src/web/search/providers/perplexity.ts +24 -7
  140. package/src/web/search/providers/searxng.ts +77 -1
  141. package/src/web/search/providers/tavily.ts +23 -22
  142. package/src/web/search/providers/tinyfish.ts +44 -10
  143. package/src/web/search/providers/xai.ts +85 -14
@@ -9,7 +9,7 @@ import type { AgentEvent, AgentIdentity, AgentMessage, AgentTelemetryConfig } fr
9
9
  import { recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
10
10
  import type { Api, Model, ServiceTierByFamily, Usage } from "@oh-my-pi/pi-ai";
11
11
  import { logger, popLoopPhase, prompt, pushLoopPhase, untilAborted } from "@oh-my-pi/pi-utils";
12
- import { AsyncJobManager } from "../async";
12
+ import { ASYNC_JOB_MANAGER_SHUTDOWN_REASON, AsyncJobManager } from "../async";
13
13
  import type { Rule } from "../capability/rule";
14
14
  import { ModelRegistry } from "../config/model-registry";
15
15
  import {
@@ -189,19 +189,24 @@ function resolveSubagentRetryFallbackCandidates(
189
189
  * Chain a single-model subagent inherits when its own model patterns supply no
190
190
  * fallbacks of their own. The child is pinned to a `subagent:<id>` role whose
191
191
  * chain shadows every configured role chain (see
192
- * {@link installSubagentRetryFallbackChain}), so a role-alias request (`@smol`)
193
- * MUST inherit that role's chain — otherwise the pin silently re-routes the
194
- * child onto the `default` role's chain. Explicit model selectors keep
195
- * inheriting `default`: they carry no role identity, and a role that happens to
196
- * be assigned the same model must not capture the child's fallback routing.
192
+ * {@link installSubagentRetryFallbackChain}), so a role-alias request (`@smol`,
193
+ * the bundled `task` agent's `@task`) MUST inherit that role's chain —
194
+ * otherwise the pin silently re-routes the child onto the `default` role's
195
+ * chain. Explicit model selectors keep inheriting `default`: they carry no role
196
+ * identity, and a role that happens to be assigned the same model must not
197
+ * capture the child's fallback routing.
198
+ *
199
+ * Spawn paths preserve the pre-expansion alias as `modelRole` because their
200
+ * model patterns are already expanded. Direct callers may still supply an
201
+ * unexpanded alias through `modelOverride` or `agent.model`; retain that
202
+ * existing path by deriving the role only when no preserved role was supplied.
197
203
  */
198
204
  function resolveSubagentInheritedRetryFallbackChain(
199
205
  settings: Settings,
200
206
  modelRegistry: ModelRegistry,
201
- modelPatterns: string[],
207
+ role: string | undefined,
202
208
  ): string[] | undefined {
203
209
  const configuredChains = settings.get("retry.fallbackChains");
204
- const role = resolveExplicitModelRole(modelPatterns, settings);
205
210
  // An explicitly emptied role chain means "no fallbacks", not "inherit
206
211
  // default" — mirrors expandDefaultRetryFallbackChains.
207
212
  const fallbackChain = (role !== undefined ? configuredChains?.[role] : undefined) ?? configuredChains?.default;
@@ -895,7 +900,7 @@ export function createSubagentSettings(
895
900
  );
896
901
  }
897
902
 
898
- export type AbortReason = "signal" | "terminate" | "timeout" | "budget";
903
+ export type AbortReason = "signal" | "shutdown" | "terminate" | "timeout" | "budget";
899
904
 
900
905
  const MAX_YIELD_TOOL_ERRORS = 6;
901
906
 
@@ -1095,7 +1100,21 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1095
1100
  budgetLimitExceeded = true;
1096
1101
  }
1097
1102
  if (abortSent) {
1098
- if (reason === "signal" && abortReason !== "signal" && abortReason !== "timeout") {
1103
+ // Shutdown is a superseding external abort: a process teardown that
1104
+ // races a self-inflicted budget hard-abort must still follow the
1105
+ // shutdown release path (dispose + unregister) instead of the
1106
+ // budget-resumable path, which would leave the subagent adopted and
1107
+ // alive past AgentLifecycleManager.dispose(). Genuine kills
1108
+ // (signal/timeout/terminate) already dispose terminally, and shutdown
1109
+ // is never downgraded back to signal.
1110
+ if (reason === "shutdown" && abortReason === "budget") {
1111
+ abortReason = "shutdown";
1112
+ } else if (
1113
+ reason === "signal" &&
1114
+ abortReason !== "signal" &&
1115
+ abortReason !== "timeout" &&
1116
+ abortReason !== "shutdown"
1117
+ ) {
1099
1118
  abortReason = "signal";
1100
1119
  }
1101
1120
  return;
@@ -1158,7 +1177,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1158
1177
  signal.addEventListener(
1159
1178
  "abort",
1160
1179
  () => {
1161
- if (!resolved) requestAbort("signal");
1180
+ if (!resolved) requestAbort(signal.reason === ASYNC_JOB_MANAGER_SHUTDOWN_REASON ? "shutdown" : "signal");
1162
1181
  },
1163
1182
  { once: true, signal: listenerSignal },
1164
1183
  );
@@ -1183,6 +1202,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1183
1202
  }
1184
1203
 
1185
1204
  const resolveSignalAbortReason = (): string => {
1205
+ if (signal?.reason === ASYNC_JOB_MANAGER_SHUTDOWN_REASON) return "Async job manager shutdown";
1186
1206
  const reason = signal?.reason;
1187
1207
  if (reason instanceof Error) {
1188
1208
  const message = reason.message.trim();
@@ -1659,15 +1679,28 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1659
1679
  };
1660
1680
 
1661
1681
  const attach = (session: AgentSession): (() => void) => {
1662
- let activeModel = session.model ? formatModelStringWithRouting(session.model) : undefined;
1682
+ // The session owns attribution: it knows which model produced its output
1683
+ // and withholds an armed-but-unproven fallback. Re-deriving that here from
1684
+ // the event stream got it wrong twice over — the stream also carries
1685
+ // advisor turns running on a different model, and a routing switch was
1686
+ // read as evidence the target had served.
1687
+ const publishServingModel = (): void => {
1688
+ const serving = session.servingModel;
1689
+ if (!serving) return;
1690
+ const isFallback = serving.isFallback;
1691
+ if (
1692
+ serving.selector === progress.resolvedModel &&
1693
+ (progress.resolvedModelIsFallback ?? false) === isFallback
1694
+ ) {
1695
+ return;
1696
+ }
1697
+ progress.resolvedModel = serving.selector;
1698
+ progress.resolvedModelIsFallback = isFallback;
1699
+ scheduleProgress(true);
1700
+ };
1663
1701
  return session.subscribe(event => {
1664
1702
  emitSubagentEvent(event);
1665
- const nextModel = session.model ? formatModelStringWithRouting(session.model) : undefined;
1666
- if (nextModel && nextModel !== activeModel) {
1667
- activeModel = nextModel;
1668
- progress.resolvedModel = nextModel;
1669
- scheduleProgress(true);
1670
- }
1703
+ publishServingModel();
1671
1704
  if (event.type === "auto_retry_start") {
1672
1705
  progress.retryState = {
1673
1706
  attempt: event.attempt,
@@ -1707,18 +1740,6 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1707
1740
  popLoopPhase();
1708
1741
  }
1709
1742
  }
1710
- if (event.type === "retry_fallback_applied") {
1711
- progress.resolvedModel = event.to;
1712
- progress.resolvedModelIsFallback = true;
1713
- scheduleProgress(true);
1714
- return;
1715
- }
1716
- if (event.type === "retry_fallback_succeeded") {
1717
- progress.resolvedModel = event.model;
1718
- progress.resolvedModelIsFallback = true;
1719
- scheduleProgress(true);
1720
- return;
1721
- }
1722
1743
  });
1723
1744
  };
1724
1745
 
@@ -1751,7 +1772,11 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1751
1772
  runtimeLimitExceeded: () => runtimeLimitExceeded,
1752
1773
  terminalError: () => terminalError,
1753
1774
  hasExplicitAbortReason: () =>
1754
- abortReason === "signal" || runtimeLimitExceeded || budgetLimitExceeded || budgetStopRequested,
1775
+ abortReason === "signal" ||
1776
+ abortReason === "shutdown" ||
1777
+ runtimeLimitExceeded ||
1778
+ budgetLimitExceeded ||
1779
+ budgetStopRequested,
1755
1780
  budgetStopRequested: () => budgetStopRequested,
1756
1781
  waitForBudgetStop: () => budgetStopAbortPromise ?? Promise.resolve(),
1757
1782
  yieldInvalidatedByAsync: () => yieldInvalidatedByAsync,
@@ -1777,7 +1802,11 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1777
1802
  // the lifecycle can park the agent as resumable instead of killing it.
1778
1803
  abortKind: () => abortReason ?? (budgetStopRequested ? "budget" : undefined),
1779
1804
  isAbortedRun: () =>
1780
- abortReason === "signal" || runtimeLimitExceeded || budgetLimitExceeded || abortReason === undefined,
1805
+ abortReason === "signal" ||
1806
+ abortReason === "shutdown" ||
1807
+ runtimeLimitExceeded ||
1808
+ budgetLimitExceeded ||
1809
+ abortReason === undefined,
1781
1810
  requestAbort,
1782
1811
  failWithError,
1783
1812
  abortActiveSession,
@@ -2416,23 +2445,37 @@ export async function finalizeSubagentLifecycle(args: {
2416
2445
  }
2417
2446
  };
2418
2447
 
2419
- // A budget abort leaves a consistent session with its transcript on disk;
2420
- // caller signals, wall-clock timeouts (possible stream hang), and internal
2448
+ // A budget abort leaves a consistent session with its transcript on disk.
2449
+ // Manager shutdown also preserves the transcript, but disposes and unregisters
2450
+ // the process-local session. Caller signals, wall-clock timeouts, and internal
2421
2451
  // terminations are genuine kills and stay terminal.
2422
2452
  const resumableAbort =
2423
2453
  args.abortKind === "budget" && args.keepAlive && !args.isolated && args.reviveSession !== null;
2424
2454
  if (args.aborted && !resumableAbort) {
2425
2455
  if (ref && ownsRef) {
2426
- // Route hard kills through the lifecycle owner so the terminal
2427
- // decision is durable and a restart cannot rediscover the transcript
2428
- // as a revivable parked agent.
2429
- try {
2430
- await AgentLifecycleManager.global().release(args.id, ref, { tombstone: true });
2431
- } catch (error) {
2432
- logger.warn("runSubagent: failed to persist kill tombstone", { id: args.id, error: String(error) });
2433
- registry.setStatus(args.id, "aborted", ref);
2434
- registry.detachSession(args.id, ref);
2435
- await disposeSession();
2456
+ if (args.abortKind === "shutdown") {
2457
+ try {
2458
+ await AgentLifecycleManager.global().release(args.id, ref);
2459
+ } catch (error) {
2460
+ logger.warn("runSubagent: failed to release session during manager shutdown", {
2461
+ id: args.id,
2462
+ error: String(error),
2463
+ });
2464
+ await disposeSession();
2465
+ registry.unregister(args.id, ref);
2466
+ }
2467
+ } else {
2468
+ // Route hard kills through the lifecycle owner so the terminal
2469
+ // decision is durable and a restart cannot rediscover the transcript
2470
+ // as a revivable parked agent.
2471
+ try {
2472
+ await AgentLifecycleManager.global().release(args.id, ref, { tombstone: true });
2473
+ } catch (error) {
2474
+ logger.warn("runSubagent: failed to persist kill tombstone", { id: args.id, error: String(error) });
2475
+ registry.setStatus(args.id, "aborted", ref);
2476
+ registry.detachSession(args.id, ref);
2477
+ await disposeSession();
2478
+ }
2436
2479
  }
2437
2480
  } else {
2438
2481
  await disposeSession();
@@ -2838,7 +2881,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2838
2881
  const configuredModelPatterns = resolveConfiguredModelPatterns(modelPatterns, settings);
2839
2882
  const inheritedRetryFallbackChain =
2840
2883
  configuredModelPatterns.length === 1
2841
- ? resolveSubagentInheritedRetryFallbackChain(subagentSettings, modelRegistry, modelPatterns)
2884
+ ? resolveSubagentInheritedRetryFallbackChain(
2885
+ subagentSettings,
2886
+ modelRegistry,
2887
+ modelRole ?? resolveExplicitModelRole(modelPatterns, subagentSettings),
2888
+ )
2842
2889
  : undefined;
2843
2890
  const {
2844
2891
  model,
@@ -8,7 +8,7 @@ import * as fs from "node:fs/promises";
8
8
  import * as os from "node:os";
9
9
  import path from "node:path";
10
10
  import { $env, prompt, Snowflake } from "@oh-my-pi/pi-utils";
11
- import { resolveAgentModelPatterns, resolveAgentModelSource, resolveExplicitModelRole } from "../config/model-resolver";
11
+ import { resolveAgentModelSelection } from "../config/model-resolver";
12
12
  import type { LocalProtocolOptions } from "../internal-urls";
13
13
  import { registerArtifactsDir } from "../internal-urls/registry-helpers";
14
14
  import { MCPManager } from "../mcp/manager";
@@ -288,10 +288,10 @@ export async function resolveEffectiveSubagentPolicy(
288
288
  activeModelPattern: parentActiveModelPattern,
289
289
  fallbackModelPattern: request.session.getModelString?.(),
290
290
  };
291
- // Keep role identity from the same effective non-empty source that supplies
292
- // model selection: caller request, settings override, then agent definition.
293
- const modelRole = resolveExplicitModelRole(resolveAgentModelSource(modelResolution), request.session.settings);
294
- const modelOverride = resolveAgentModelPatterns(modelResolution);
291
+ // Role identity and patterns come from one call so they cannot be derived
292
+ // from different sources: the expansion below discards the alias, and the
293
+ // child's inherited retry-fallback chain is keyed off the role.
294
+ const { patterns: modelOverride, role: modelRole } = resolveAgentModelSelection(modelResolution);
295
295
  const isolationMode = request.session.settings.get("task.isolation.mode");
296
296
  const isIsolated = request.isolation?.requested === true;
297
297
  if (isIsolated && isolationMode === "none") {
@@ -21,6 +21,8 @@ export interface ResolvedApproval {
21
21
  reason?: string;
22
22
  override: boolean;
23
23
  source?: "tool" | "user" | "mode";
24
+ /** User-policy key that produced `source: "user"` (defaults to the tool name). */
25
+ policyKey?: string;
24
26
  }
25
27
 
26
28
  const POLICY_VALUES: ReadonlySet<ApprovalPolicy> = new Set(["allow", "deny", "prompt"]);
@@ -61,11 +63,14 @@ function normalizeDecision(value: unknown): Omit<ResolvedApproval, "policy"> & {
61
63
  const tier = isToolTier(record.tier) ? record.tier : "exec";
62
64
  const reason = typeof record.reason === "string" && record.reason.length > 0 ? record.reason : undefined;
63
65
  const policy = normalizePolicy(record.policy);
66
+ const policyKey =
67
+ typeof record.policyKey === "string" && record.policyKey.length > 0 ? record.policyKey : undefined;
64
68
  return {
65
69
  tier,
66
70
  override: record.override === true,
67
71
  ...(policy ? { policy } : {}),
68
72
  ...(reason ? { reason } : {}),
73
+ ...(policyKey ? { policyKey } : {}),
69
74
  };
70
75
  }
71
76
 
@@ -101,6 +106,11 @@ function modeApprovesTier(mode: ApprovalMode, tier: ToolTier): boolean {
101
106
  *
102
107
  * Resolution order:
103
108
  * 1. Tool `approval(args)` decision, defaulting to tier "exec" when omitted.
109
+ * A decision may carry a `policyKey` — `tools.approval.<policyKey>` is then
110
+ * the user override consulted instead of `tools.approval.<tool.name>`, with
111
+ * the invoking tool's own policy as the fallback when the user set none for
112
+ * the keyed sub-tool (e.g. an `xd://` device dispatch without a device
113
+ * policy still honors `tools.approval.write`).
104
114
  * 2. User per-tool override, if set and valid.
105
115
  * 3. Active mode tier comparison.
106
116
  *
@@ -114,7 +124,14 @@ export function resolveApproval(
114
124
  userConfig: Record<string, unknown> = {},
115
125
  ): ResolvedApproval {
116
126
  const decision = getToolDecision(tool, args);
117
- const userPolicy = Object.hasOwn(userConfig, tool.name) ? normalizePolicy(userConfig[tool.name]) : undefined;
127
+ const policyKey = decision.policyKey ?? tool.name;
128
+ const userPolicy = Object.hasOwn(userConfig, policyKey) ? normalizePolicy(userConfig[policyKey]) : undefined;
129
+ const fallbackPolicy =
130
+ policyKey !== tool.name && userPolicy === undefined && Object.hasOwn(userConfig, tool.name)
131
+ ? normalizePolicy(userConfig[tool.name])
132
+ : undefined;
133
+ const effectiveUserPolicy = userPolicy ?? fallbackPolicy;
134
+ const userPolicyKey = userPolicy !== undefined ? policyKey : tool.name;
118
135
 
119
136
  if (decision.policy === "deny") {
120
137
  return {
@@ -122,11 +139,18 @@ export function resolveApproval(
122
139
  tier: decision.tier,
123
140
  override: decision.override,
124
141
  source: "tool",
142
+ ...(decision.policyKey ? { policyKey: decision.policyKey } : {}),
125
143
  ...(decision.reason ? { reason: decision.reason } : {}),
126
144
  };
127
145
  }
128
- if (userPolicy === "deny") {
129
- return { policy: "deny", tier: decision.tier, override: decision.override, source: "user" };
146
+ if (effectiveUserPolicy === "deny") {
147
+ return {
148
+ policy: "deny",
149
+ tier: decision.tier,
150
+ override: decision.override,
151
+ source: "user",
152
+ policyKey: userPolicyKey,
153
+ };
130
154
  }
131
155
 
132
156
  if (mode === "yolo") {
@@ -136,14 +160,16 @@ export function resolveApproval(
136
160
  tier: decision.tier,
137
161
  override: false,
138
162
  source: "tool",
163
+ ...(decision.policyKey ? { policyKey: decision.policyKey } : {}),
139
164
  ...(decision.reason ? { reason: decision.reason } : {}),
140
165
  };
141
166
  }
142
167
  return {
143
- policy: userPolicy ?? "allow",
168
+ policy: effectiveUserPolicy ?? "allow",
144
169
  tier: decision.tier,
145
170
  override: false,
146
- source: userPolicy ? "user" : "mode",
171
+ source: effectiveUserPolicy ? "user" : "mode",
172
+ ...(effectiveUserPolicy ? { policyKey: userPolicyKey } : {}),
147
173
  };
148
174
  }
149
175
 
@@ -153,6 +179,7 @@ export function resolveApproval(
153
179
  tier: decision.tier,
154
180
  override: true,
155
181
  source: "tool",
182
+ ...(decision.policyKey ? { policyKey: decision.policyKey } : {}),
156
183
  ...(decision.reason ? { reason: decision.reason } : {}),
157
184
  };
158
185
  }
@@ -163,12 +190,19 @@ export function resolveApproval(
163
190
  tier: decision.tier,
164
191
  override: false,
165
192
  source: "tool",
193
+ ...(decision.policyKey ? { policyKey: decision.policyKey } : {}),
166
194
  ...(decision.reason ? { reason: decision.reason } : {}),
167
195
  };
168
196
  }
169
197
 
170
- if (userPolicy) {
171
- return { policy: userPolicy, tier: decision.tier, override: false, source: "user" };
198
+ if (effectiveUserPolicy) {
199
+ return {
200
+ policy: effectiveUserPolicy,
201
+ tier: decision.tier,
202
+ override: false,
203
+ source: "user",
204
+ policyKey: userPolicyKey,
205
+ };
172
206
  }
173
207
 
174
208
  if (modeApprovesTier(mode, decision.tier)) {
@@ -196,15 +230,15 @@ export function requiresApproval(
196
230
  mode: ApprovalMode,
197
231
  userConfig: Record<string, unknown> = {},
198
232
  ): { required: boolean; reason?: string } {
199
- const { policy, reason, source } = resolveApproval(tool, args, mode, userConfig);
233
+ const { policy, reason, source, policyKey } = resolveApproval(tool, args, mode, userConfig);
200
234
 
201
235
  if (policy === "deny") {
202
236
  if (source === "tool") {
203
237
  throw new Error(`Tool "${tool.name}" is blocked by tool policy.${reason ? `\nReason: ${reason}` : ""}`);
204
238
  }
205
239
  throw new Error(
206
- `Tool "${tool.name}" is blocked by user policy.\n` +
207
- `To allow: remove "tools.approval.${tool.name}: deny" from config.`,
240
+ `Tool "${policyKey ?? tool.name}" is blocked by user policy.\n` +
241
+ `To allow: remove "tools.approval.${policyKey ?? tool.name}: deny" from config.`,
208
242
  );
209
243
  }
210
244
 
@@ -32,8 +32,7 @@ export const BUILTIN_TOOL_NAMES = [
32
32
 
33
33
  export type BuiltinToolName = (typeof BUILTIN_TOOL_NAMES)[number];
34
34
 
35
- /** Hidden built-ins: constructible and `--tools`-addressable, but never part of the default active set. */
36
- export const HIDDEN_TOOL_NAMES = ["yield", "goal"] as const;
35
+ export const HIDDEN_TOOL_NAMES = ["yield", "goal", "think"] as const;
37
36
 
38
37
  export type HiddenToolName = (typeof HIDDEN_TOOL_NAMES)[number];
39
38
 
@@ -576,6 +576,19 @@ async function parseFeedToMarkdown(content: string, maxItems = 10): Promise<stri
576
576
  * local fallback renderers (trafilatura, lynx, native). See #1449.
577
577
  */
578
578
  const REMOTE_READER_MAX_MS = 10_000;
579
+ const JINA_MARKDOWN_MARKER = "Markdown Content:";
580
+ const JINA_READER_MAX_BYTES = 2 * 1024 * 1024;
581
+
582
+ function parseJinaReaderContent(responseBody: string): string | null {
583
+ const markerStart = responseBody.indexOf(JINA_MARKDOWN_MARKER);
584
+ if (markerStart < 0) return null;
585
+
586
+ const content = responseBody.slice(markerStart + JINA_MARKDOWN_MARKER.length).trim();
587
+ if (content.length < 100 || content.startsWith("Loading...") || content.startsWith("Please enable JavaScript")) {
588
+ return null;
589
+ }
590
+ return content;
591
+ }
579
592
 
580
593
  /** Reader backends for {@link renderHtmlToText}, in default priority order. */
581
594
  export type FetchProvider = "native" | "trafilatura" | "lynx" | "parallel" | "jina";
@@ -653,10 +666,16 @@ export async function renderHtmlToText(
653
666
  },
654
667
  jina: async () => {
655
668
  const response = await fetchImpl(`https://r.jina.ai/${url}`, {
656
- headers: { Accept: "text/markdown" },
669
+ headers: {
670
+ Accept: "text/markdown",
671
+ "X-No-Cache": "true",
672
+ },
657
673
  signal: remoteSignal(),
658
674
  });
659
- return response.ok ? await response.text() : null;
675
+ if (!response.ok) return null;
676
+ const contentLength = Number(response.headers.get("content-length"));
677
+ if (Number.isFinite(contentLength) && contentLength > JINA_READER_MAX_BYTES) return null;
678
+ return parseJinaReaderContent(await response.text());
660
679
  },
661
680
  };
662
681
 
@@ -1,7 +1,7 @@
1
1
  import * as os from "node:os";
2
2
  import * as path from "node:path";
3
3
  import { type } from "@oh-my-pi/omptype";
4
- import { type ApiKey, type FetchImpl, getEnvApiKey, type Model, withAuth } from "@oh-my-pi/pi-ai";
4
+ import { type ApiKey, type FetchImpl, getEnvApiKey, getOpenRouterHeaders, type Model, withAuth } from "@oh-my-pi/pi-ai";
5
5
  import { ProviderHttpError } from "@oh-my-pi/pi-ai/error";
6
6
  import {
7
7
  CODEX_BASE_URL,
@@ -19,13 +19,13 @@ import {
19
19
  ptree,
20
20
  readSseJson,
21
21
  Snowflake,
22
+ USER_AGENT,
22
23
  untilAborted,
23
24
  } from "@oh-my-pi/pi-utils";
24
- import packageJson from "../../package.json" with { type: "json" };
25
25
  import { isAuthenticated, type ModelRegistry } from "../config/model-registry";
26
26
  import { settings } from "../config/settings";
27
27
  import type { CustomTool } from "../extensibility/custom-tools/types";
28
- import { ohMyPiXAIUserAgent, resolveXAIHttpCredentials } from "../lib/xai-http";
28
+ import { resolveXAIHttpCredentials } from "../lib/xai-http";
29
29
  import imageGenDescription from "../prompts/tools/image-gen.md" with { type: "text" };
30
30
  import { AUTO_IMAGE_PROVIDER_ORDER, type ImageProvider, isImageProviderId } from "./image-providers";
31
31
  import { resolveReadPath } from "./path-utils";
@@ -897,7 +897,7 @@ function buildOpenAIImageHeaders(model: Model, apiKey: string, sessionId: string
897
897
  }
898
898
  headers.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES);
899
899
  headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
900
- headers.set("User-Agent", `pi/${packageJson.version} (${os.platform()} ${os.release()}; ${os.arch()})`);
900
+ headers.set("User-Agent", USER_AGENT);
901
901
  if (sessionId) {
902
902
  headers.set(OPENAI_HEADERS.CONVERSATION_ID, sessionId);
903
903
  headers.set(OPENAI_HEADERS.SESSION_ID, sessionId);
@@ -1389,7 +1389,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1389
1389
  headers: {
1390
1390
  Authorization: `Bearer ${key}`,
1391
1391
  "Content-Type": "application/json",
1392
- "User-Agent": ohMyPiXAIUserAgent(),
1392
+ "User-Agent": USER_AGENT,
1393
1393
  },
1394
1394
  body: JSON.stringify(xaiBody),
1395
1395
  signal: requestSignal,
@@ -1479,9 +1479,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1479
1479
  headers: {
1480
1480
  "Content-Type": "application/json",
1481
1481
  Authorization: `Bearer ${key}`,
1482
- "HTTP-Referer": "https://omp.sh/",
1483
- "X-OpenRouter-Title": "Oh-My-Pi",
1484
- "X-OpenRouter-Categories": "cli-agent",
1482
+ ...getOpenRouterHeaders(),
1485
1483
  },
1486
1484
  body: JSON.stringify(requestBody),
1487
1485
  signal: requestSignal,
@@ -62,6 +62,7 @@ import { wrapToolWithMetaNotice } from "./output-meta";
62
62
  import { ReadTool } from "./read";
63
63
  import type { PlanProposalHandler } from "./resolve";
64
64
  import { SecurityScanTool } from "./security-scan";
65
+ import { ThinkTool } from "./think";
65
66
  import { type TodoPhase, TodoTool } from "./todo";
66
67
  import { WriteTool } from "./write";
67
68
  import { isMountableUnderXdev, type XdevState } from "./xdev";
@@ -102,6 +103,7 @@ export * from "./report-tool-issue";
102
103
  export * from "./resolve";
103
104
  export * from "./review";
104
105
  export * from "./security-scan";
106
+ export * from "./think";
105
107
  export * from "./todo";
106
108
  export * from "./tts";
107
109
  export * from "./vibe";
@@ -444,6 +446,7 @@ export const BUILTIN_TOOLS: Record<BuiltinToolName, ToolFactory> = {
444
446
  };
445
447
 
446
448
  export const HIDDEN_TOOLS: Record<HiddenToolName, ToolFactory> = {
449
+ think: () => new ThinkTool(),
447
450
  yield: s => new YieldTool(s),
448
451
  goal: s => new GoalTool(s),
449
452
  };
@@ -562,6 +565,9 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
562
565
  if (session.settings.get("memory.backend") === "mnemopi" && !requestedTools.includes("memory_edit")) {
563
566
  requestedTools.push("memory_edit");
564
567
  }
568
+ if (session.settings.get("externalThinking") && !requestedTools.includes("think")) {
569
+ requestedTools.push("think");
570
+ }
565
571
  // Auto-learn tools are gated by `autolearn.enabled` but, like the memory
566
572
  // tools above, must also be force-included into an explicit requestedTools
567
573
  // list so a restricted top-level session whose controller/guidance is
@@ -604,6 +610,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
604
610
  if (name === "inspect_image") return isInspectImageToolActive(session);
605
611
  if (name === "web_search") return session.settings.get("web_search.enabled");
606
612
  if (name === "security_scan") return session.settings.get("security.enabled");
613
+ if (name === "think") return session.settings.get("externalThinking");
607
614
  if (name === "ask") return session.settings.get("ask.enabled");
608
615
  if (name === "browser") return session.settings.get("browser.enabled");
609
616
  if (name === "computer") return session.settings.get("computer.enabled");
@@ -650,6 +657,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
650
657
  ...Object.entries(BUILTIN_TOOLS)
651
658
  .filter(([name]) => isToolAllowed(name))
652
659
  .map(([name, factory]) => [name, factory] as const),
660
+ ...(session.settings.get("externalThinking") ? ([["think", HIDDEN_TOOLS.think]] as const) : []),
653
661
  ...(includeYield ? ([["yield", HIDDEN_TOOLS.yield]] as const) : []),
654
662
  ...(goalModeActive ? ([["goal", HIDDEN_TOOLS.goal]] as const) : []),
655
663
  ];
@@ -27,6 +27,7 @@ import { inspectImageToolRenderer } from "./inspect-image-renderer";
27
27
  import { recallToolRenderer, reflectToolRenderer, retainToolRenderer } from "./memory-render";
28
28
  import { readToolRenderer } from "./read";
29
29
  import { resolveRenderer } from "./resolve";
30
+ import { thinkToolRenderer } from "./think";
30
31
  import { todoToolRenderer } from "./todo";
31
32
  import { createVibeToolRenderer } from "./vibe";
32
33
  import { writeToolRenderer } from "./write";
@@ -115,6 +116,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
115
116
  get task(): ToolRenderer {
116
117
  return taskToolRenderer as ToolRenderer;
117
118
  },
119
+ think: thinkToolRenderer as ToolRenderer,
118
120
  todo: todoToolRenderer as ToolRenderer,
119
121
  github: githubToolRenderer as ToolRenderer,
120
122
  goal: goalToolRenderer as ToolRenderer,
@@ -0,0 +1,62 @@
1
+ import { type } from "@oh-my-pi/omptype";
2
+ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
3
+ import { type Component, Markdown } from "@oh-my-pi/pi-tui";
4
+ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
5
+ import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
6
+
7
+ const thinkSchema = type({
8
+ thoughts: type("string").describe("private scratchpad reasoning to retain before the next response"),
9
+ "+": "reject",
10
+ }).describe("record private intermediate reasoning before answering");
11
+
12
+ type ThinkParams = typeof thinkSchema.infer;
13
+
14
+ export type ThinkRenderArgs = {
15
+ thoughts?: string;
16
+ };
17
+
18
+ export const thinkToolRenderer = {
19
+ inline: true,
20
+ renderCall(args: ThinkRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
21
+ const thoughts =
22
+ typeof args === "object" && args !== null && "thoughts" in args && typeof args.thoughts === "string"
23
+ ? args.thoughts
24
+ : "";
25
+ return new Markdown(thoughts, 1, 0, getMarkdownTheme(), {
26
+ color: (text: string) => uiTheme.fg("thinkingText", text),
27
+ italic: true,
28
+ });
29
+ },
30
+ renderResult(): Component {
31
+ return undefined as unknown as Component;
32
+ },
33
+ };
34
+
35
+ interface ThinkToolDetails {
36
+ recorded: true;
37
+ }
38
+
39
+ /** Records private intermediate reasoning while native GPT reasoning is disabled. */
40
+ export class ThinkTool implements AgentTool<typeof thinkSchema, ThinkToolDetails> {
41
+ readonly name = "think";
42
+ readonly approval = "read" as const;
43
+ readonly label = "Think";
44
+ readonly summary = "Record private intermediate reasoning before answering";
45
+ readonly description =
46
+ "Use this private scratchpad to plan, derive, or check work before answering. Record only materially new reasoning. The user does not see this tool activity.";
47
+ readonly parameters = thinkSchema;
48
+ readonly strict = true;
49
+ readonly intent = "omit" as const;
50
+
51
+ async execute(_toolCallId: string, _params: ThinkParams): Promise<AgentToolResult<ThinkToolDetails>> {
52
+ return {
53
+ content: [
54
+ {
55
+ type: "text",
56
+ text: "------",
57
+ },
58
+ ],
59
+ details: { recorded: true },
60
+ };
61
+ }
62
+ }