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

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 (134) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/{CHANGELOG-k9ghy5sn.md → CHANGELOG-d8xh7keh.md} +57 -0
  3. package/dist/cli.js +3069 -3047
  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 +4 -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 +2 -0
  25. package/dist/types/session/agent-session.d.ts +22 -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 +24 -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/todo.d.ts +14 -15
  37. package/dist/types/tools/write.d.ts +2 -2
  38. package/dist/types/utils/local-date.d.ts +2 -0
  39. package/dist/types/vibe/runtime.d.ts +1 -1
  40. package/dist/types/web/parallel.d.ts +1 -0
  41. package/dist/types/web/search/providers/brave.d.ts +8 -3
  42. package/dist/types/web/search/providers/codex.d.ts +6 -0
  43. package/dist/types/web/search/providers/firecrawl.d.ts +3 -2
  44. package/dist/types/web/search/providers/jina.d.ts +3 -3
  45. package/dist/types/web/search/providers/parallel.d.ts +1 -0
  46. package/dist/types/web/search/providers/perplexity.d.ts +4 -0
  47. package/dist/types/web/search/providers/tinyfish.d.ts +2 -0
  48. package/package.json +13 -13
  49. package/src/advisor/delta-split.ts +98 -0
  50. package/src/advisor/runtime.ts +321 -69
  51. package/src/async/job-manager.ts +14 -3
  52. package/src/cli/plugin-cli.ts +30 -2
  53. package/src/cli/update-cli.ts +259 -24
  54. package/src/config/keybindings.ts +52 -9
  55. package/src/config/model-resolver.ts +19 -3
  56. package/src/config/settings-schema.ts +5 -0
  57. package/src/cursor.ts +10 -5
  58. package/src/discovery/agents-md.ts +61 -23
  59. package/src/eval/jl/kernel.ts +2 -20
  60. package/src/eval/py/kernel.ts +2 -20
  61. package/src/eval/rb/kernel.ts +2 -20
  62. package/src/eval/runner-cache.ts +41 -0
  63. package/src/exec/non-interactive-env.ts +14 -3
  64. package/src/extensibility/extensions/loader.ts +5 -2
  65. package/src/extensibility/extensions/runner.ts +184 -66
  66. package/src/extensibility/extensions/types.ts +26 -2
  67. package/src/extensibility/extensions/wrapper.ts +13 -7
  68. package/src/extensibility/plugins/marketplace/manager.ts +6 -2
  69. package/src/hindsight/client.ts +1 -1
  70. package/src/lib/xai-http.ts +0 -4
  71. package/src/lsp/client.ts +2 -0
  72. package/src/lsp/servers.ts +1 -1
  73. package/src/mcp/tool-bridge.ts +15 -6
  74. package/src/modes/components/agent-hub-renderer.ts +9 -3
  75. package/src/modes/components/agent-hub.ts +2 -1
  76. package/src/modes/components/status-line/component.ts +58 -6
  77. package/src/modes/components/status-line/segments.ts +12 -1
  78. package/src/modes/components/status-line/types.ts +1 -0
  79. package/src/modes/components/user-message.ts +20 -5
  80. package/src/modes/controllers/event-controller.ts +48 -0
  81. package/src/modes/controllers/extension-ui-controller.ts +14 -7
  82. package/src/modes/controllers/input-controller.ts +25 -6
  83. package/src/modes/interactive-mode.ts +315 -129
  84. package/src/modes/rpc/rpc-frame.ts +13 -5
  85. package/src/modes/theme/tui-adapters.ts +4 -5
  86. package/src/modes/types.ts +13 -7
  87. package/src/modes/utils/hotkeys-markdown.ts +10 -6
  88. package/src/prompts/system/system-prompt.md +1 -1
  89. package/src/registry/persisted-agents.ts +43 -8
  90. package/src/sdk.ts +139 -8
  91. package/src/session/agent-session-types.ts +2 -0
  92. package/src/session/agent-session.ts +66 -10
  93. package/src/session/messages.ts +98 -28
  94. package/src/session/retry-fallback-chains.ts +14 -0
  95. package/src/session/session-advisors.ts +32 -15
  96. package/src/session/session-history-format.ts +15 -1
  97. package/src/session/session-maintenance.ts +8 -8
  98. package/src/session/session-manager.ts +6 -2
  99. package/src/session/session-tools.ts +321 -184
  100. package/src/session/turn-recovery.ts +225 -47
  101. package/src/slash-commands/builtin-modes.ts +41 -12
  102. package/src/slash-commands/types.ts +5 -1
  103. package/src/task/executor.ts +92 -45
  104. package/src/task/structured-subagent.ts +5 -5
  105. package/src/tools/approval.ts +44 -10
  106. package/src/tools/fetch.ts +21 -2
  107. package/src/tools/image-gen.ts +6 -8
  108. package/src/tools/todo.ts +70 -26
  109. package/src/tools/tts.ts +3 -2
  110. package/src/tools/write.ts +7 -3
  111. package/src/utils/local-date.ts +13 -0
  112. package/src/utils/tools-manager.ts +2 -2
  113. package/src/vibe/runtime.ts +22 -14
  114. package/src/web/kagi.ts +91 -34
  115. package/src/web/parallel.ts +11 -2
  116. package/src/web/scrapers/crates-io.ts +2 -2
  117. package/src/web/scrapers/discogs.ts +2 -2
  118. package/src/web/scrapers/docs-rs.ts +2 -2
  119. package/src/web/scrapers/github.ts +2 -2
  120. package/src/web/scrapers/musicbrainz.ts +1 -2
  121. package/src/web/scrapers/pubmed.ts +2 -2
  122. package/src/web/scrapers/sec-edgar.ts +2 -2
  123. package/src/web/search/providers/brave.ts +121 -46
  124. package/src/web/search/providers/codex.ts +88 -12
  125. package/src/web/search/providers/exa.ts +45 -10
  126. package/src/web/search/providers/firecrawl.ts +53 -11
  127. package/src/web/search/providers/gemini.ts +139 -27
  128. package/src/web/search/providers/jina.ts +48 -25
  129. package/src/web/search/providers/parallel.ts +23 -9
  130. package/src/web/search/providers/perplexity.ts +24 -7
  131. package/src/web/search/providers/searxng.ts +77 -1
  132. package/src/web/search/providers/tavily.ts +23 -22
  133. package/src/web/search/providers/tinyfish.ts +44 -10
  134. 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
 
@@ -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,
package/src/tools/todo.ts CHANGED
@@ -236,6 +236,13 @@ export function todoMatchesAnyDescription(content: string, descriptions: readonl
236
236
  return false;
237
237
  }
238
238
 
239
+ /** Whether a todo is settled: completed or deliberately abandoned. Shared so
240
+ * the collapsed viewport, the HUD progress counters, and the HUD's closed-todo
241
+ * auto-clear can never disagree about what "done" hides. */
242
+ export function isClosedTodo<T extends { status: TodoStatus }>(task: T): boolean {
243
+ return task.status === "completed" || task.status === "abandoned";
244
+ }
245
+
239
246
  /**
240
247
  * A todo the collapsed viewport treats as current work: the literal
241
248
  * `in_progress` task or a pending task a live subagent is executing. Both
@@ -254,36 +261,33 @@ export interface CollapsedTodoSelection<T> {
254
261
  }
255
262
 
256
263
  /**
257
- * Walking-viewport selection for a phase's collapsed todo preview (#5873).
264
+ * Closed rows kept directly above the open window so finishing a task is
265
+ * visible as it happens. Without this the collapsed viewport only ever renders
266
+ * unchecked boxes while a phase has open work: every completion silently
267
+ * removes a row, so a plan mid-flight looks untouched, and the card's
268
+ * completion strike animation (`completedTasks` → {@link TODO_STRIKE_TOTAL_FRAMES})
269
+ * animated a row that was never rendered.
270
+ */
271
+ const COLLAPSED_CLOSED_CONTEXT = 1;
272
+
273
+ /**
274
+ * Rows to show for a display base already reduced to the relevant tasks.
258
275
  *
259
- * Policy, applied to `tasks` in todo order:
260
- * 1. While the phase has open work, completed/abandoned tasks are omitted. A
261
- * phase with no open tasks left falls back to its closed tasks so the sticky
262
- * HUD's closed-todo persistence still has something to render.
263
- * 2. Every active task (in-progress, or pending matched to a live subagent) is
276
+ * 1. Every active task (in-progress, or pending matched to a live subagent) is
264
277
  * placed at the head in stable todo order — never dropped for lying outside
265
278
  * an ordinary window.
266
- * 3. Remaining rows up to `cap` are filled with the pending tasks that follow
279
+ * 2. Remaining rows up to `cap` are filled with the pending tasks that follow
267
280
  * the first active one, in todo order (falling back to leading pending tasks
268
281
  * when no active task exists), so a freshly-promoted task leads the preview.
269
- * 4. When active tasks alone exceed `cap`, only the first `cap` active tasks are
282
+ * 3. When active tasks alone exceed `cap`, only the first `cap` active tasks are
270
283
  * shown and the summary counts the hidden *active* todos, never replacing
271
284
  * them with unrelated pending rows.
272
- *
273
- * The summary otherwise counts the remaining tasks in the display base. Returns
274
- * the whole base with an empty summary when it already fits.
275
285
  */
276
- export function selectCollapsedTodos<T extends { status: TodoStatus }>(
277
- tasks: T[],
286
+ function selectWithinCap<T extends { status: TodoStatus }>(
287
+ base: T[],
278
288
  isMatched: (task: T) => boolean,
279
289
  cap: number,
280
290
  ): CollapsedTodoSelection<T> {
281
- const open = tasks.filter(
282
- task => task.status === "pending" || task.status === "in_progress" || task.status === "blocked",
283
- );
284
- // No open work: fall back to the closed tasks so a settled phase still
285
- // renders (HUD closed-todo persistence). Closed tasks are never active.
286
- const base = open.length > 0 ? open : tasks;
287
291
  if (base.length <= cap) return { items: base, summary: "" };
288
292
 
289
293
  const active = base.filter(task => isActiveTodo(task, isMatched));
@@ -312,6 +316,33 @@ export function selectCollapsedTodos<T extends { status: TodoStatus }>(
312
316
  return { items, summary: hidden > 0 ? formatMoreItems(hidden, "todo") : "" };
313
317
  }
314
318
 
319
+ /**
320
+ * Walking-viewport selection for a phase's collapsed todo preview (#5873).
321
+ *
322
+ * Applied to `tasks` in todo order: the open tasks run through
323
+ * {@link selectWithinCap}, led by the last {@link COLLAPSED_CLOSED_CONTEXT}
324
+ * closed tasks in todo order so a checked row remains visible even when callers
325
+ * complete work out of sequence. The lead is additive — it never costs an open
326
+ * row — and a phase with no open work left falls back to its closed tasks so the
327
+ * sticky HUD's closed-todo persistence still has something to render.
328
+ *
329
+ * `summary` counts the open tasks that did not fit; the closed lead is context,
330
+ * not part of the budget.
331
+ */
332
+ export function selectCollapsedTodos<T extends { status: TodoStatus }>(
333
+ tasks: T[],
334
+ isMatched: (task: T) => boolean,
335
+ cap: number,
336
+ ): CollapsedTodoSelection<T> {
337
+ const open = tasks.filter(task => !isClosedTodo(task));
338
+ // Closed tasks are never active, so a settled phase selects over itself.
339
+ if (open.length === 0) return selectWithinCap(tasks, isMatched, cap);
340
+ // `done` accepts any named task, so closed tasks are not necessarily a prefix.
341
+ const lead = tasks.filter(isClosedTodo).slice(-COLLAPSED_CLOSED_CONTEXT);
342
+ const selected = selectWithinCap(open, isMatched, cap);
343
+ return { items: [...lead, ...selected.items], summary: selected.summary };
344
+ }
345
+
315
346
  function resolveTaskOrError(
316
347
  phases: TodoPhase[],
317
348
  content: string | undefined,
@@ -1055,12 +1086,20 @@ function computeTouchedPhases(
1055
1086
  return touched.size > 0 ? touched : null;
1056
1087
  }
1057
1088
 
1089
+ /**
1090
+ * Dim `closed/total` suffix for a phase header. Counts closed tasks, not just
1091
+ * completed ones: the collapsed viewport hides both, so an abandoned task has to
1092
+ * move the counter or its phase reads as permanently stuck.
1093
+ */
1094
+ function formatPhaseProgress(phase: TodoPhase, uiTheme: Theme): string {
1095
+ const done = phase.tasks.filter(isClosedTodo).length;
1096
+ return uiTheme.fg("dim", ` ${done}/${phase.tasks.length}`);
1097
+ }
1098
+
1058
1099
  /** One-line summary for a collapsed (untouched) phase: dim header + progress. */
1059
1100
  function formatPhaseSummary(phase: TodoPhase, oneBasedIndex: number, uiTheme: Theme): string {
1060
- const total = phase.tasks.length;
1061
- const done = phase.tasks.filter(task => task.status === "completed").length;
1062
1101
  const name = uiTheme.fg("dim", chalk.bold(formatPhaseDisplayName(phase.name, oneBasedIndex)));
1063
- return `${name}${uiTheme.fg("dim", ` ${done}/${total}`)}`;
1102
+ return `${name}${formatPhaseProgress(phase, uiTheme)}`;
1064
1103
  }
1065
1104
 
1066
1105
  /**
@@ -1178,12 +1217,17 @@ export const todoToolRenderer = {
1178
1217
  continue;
1179
1218
  }
1180
1219
  if (multiPhase) {
1181
- bodyLines.push(uiTheme.fg("accent", chalk.bold(formatPhaseDisplayName(phase.name, p + 1))));
1220
+ // Progress belongs on the expanded header too: the collapsed
1221
+ // viewport below hides closed rows, so without it the phase the
1222
+ // agent is actually working in is the one phase with no visible
1223
+ // completion signal at all.
1224
+ const name = uiTheme.fg("accent", chalk.bold(formatPhaseDisplayName(phase.name, p + 1)));
1225
+ bodyLines.push(`${name}${formatPhaseProgress(phase, uiTheme)}`);
1182
1226
  }
1183
1227
  const completionKeys = completionKeysByPhase.get(phase.name) ?? EMPTY_COMPLETION_KEYS;
1184
- // Collapsed: walking viewport — completed/abandoned omitted, active
1185
- // work (in-progress / subagent-matched) pulled to the head, then
1186
- // following pending tasks (#5873). Expanded: every task in order.
1228
+ // Collapsed: walking viewport — the last closed task leads, then
1229
+ // active work (in-progress / subagent-matched), then following
1230
+ // pending tasks (#5873). Expanded: every task in order.
1187
1231
  const treeLines = expanded
1188
1232
  ? renderTreeList(
1189
1233
  {
package/src/tools/tts.ts CHANGED
@@ -7,9 +7,10 @@ import { type } from "@oh-my-pi/omptype";
7
7
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
8
8
  import { type ApiKey, withAuth } from "@oh-my-pi/pi-ai";
9
9
  import { ProviderHttpError } from "@oh-my-pi/pi-ai/error";
10
+ import { USER_AGENT } from "@oh-my-pi/pi-utils";
10
11
  import { settings } from "../config/settings";
11
12
  import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types";
12
- import { ohMyPiXAIUserAgent, resolveXAIHttpCredentials } from "../lib/xai-http";
13
+ import { resolveXAIHttpCredentials } from "../lib/xai-http";
13
14
  import { DEFAULT_TTS_LOCAL_MODEL_KEY, DEFAULT_TTS_VOICE, isTtsLocalModelKey, KOKORO_VOICES } from "../tts/models";
14
15
  import { ttsClient } from "../tts/tts-client";
15
16
  import { encodeWav } from "../tts/wav";
@@ -150,7 +151,7 @@ async function synthesizeXai(
150
151
  headers: {
151
152
  Authorization: `Bearer ${key}`,
152
153
  "Content-Type": "application/json",
153
- "User-Agent": ohMyPiXAIUserAgent(),
154
+ "User-Agent": USER_AGENT,
154
155
  },
155
156
  body: JSON.stringify(payload),
156
157
  signal: combinedSignal,