@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
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,
@@ -9,7 +9,7 @@ import type {
9
9
  AgentToolContext,
10
10
  AgentToolResult,
11
11
  AgentToolUpdateCallback,
12
- ToolTier,
12
+ ToolApprovalDecision,
13
13
  } from "@oh-my-pi/pi-agent-core";
14
14
  import { type Component, Text } from "@oh-my-pi/pi-tui";
15
15
  import { isEnoent, isRecord, prompt, untilAborted } from "@oh-my-pi/pi-utils";
@@ -500,7 +500,7 @@ function parseSqliteWriteTarget(subPath: string, queryString: string): { table:
500
500
  */
501
501
  export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails> {
502
502
  readonly name = "write";
503
- readonly approval = (args: unknown): ToolTier => {
503
+ readonly approval = (args: unknown): ToolApprovalDecision => {
504
504
  const rawPath = (args as Partial<WriteParams>).path;
505
505
  if (typeof rawPath !== "string") return "write";
506
506
  // Unwrap a hashline `[path#TAG]` wrapper first (parity with execute) so a
@@ -532,7 +532,11 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
532
532
  }
533
533
  if (!isRecord(parsed)) return "exec";
534
534
  try {
535
- return resolveToolTier(inst, parsed);
535
+ // The tier is the mounted tool's own (argument-dependent) approval; the
536
+ // policyKey makes the outer gate consult `tools.approval.<device>` for
537
+ // this dispatch before falling back to `tools.approval.write`, so users
538
+ // can scope allow/deny/prompt to a single device (issue #7923).
539
+ return { tier: resolveToolTier(inst, parsed), policyKey: xdevTarget.name! };
536
540
  } catch {
537
541
  return "exec";
538
542
  }
@@ -5,3 +5,16 @@ export function formatLocalCalendarDate(date: Date = new Date()): string {
5
5
  const day = String(date.getDate()).padStart(2, "0");
6
6
  return `${year}-${month}-${day}`;
7
7
  }
8
+
9
+ /** Format a local date and minute with a compact numeric UTC offset. */
10
+ export function formatLocalDateTimeWithOffset(date: Date): string {
11
+ const offsetMinutes = date.getTimezoneOffset();
12
+ const offsetSign = offsetMinutes <= 0 ? "+" : "-";
13
+ const absoluteOffset = Math.abs(offsetMinutes);
14
+ const offsetHours = Math.floor(absoluteOffset / 60);
15
+ const offsetRemainderMinutes = absoluteOffset % 60;
16
+ const pad2 = (value: number): string => String(value).padStart(2, "0");
17
+ return `${formatLocalCalendarDate(date)} ${pad2(date.getHours())}:${pad2(date.getMinutes())} ${offsetSign}${pad2(
18
+ offsetHours,
19
+ )}:${pad2(offsetRemainderMinutes)}`;
20
+ }
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import { $which, APP_NAME, getToolsDir, logger, ptree, TempDir } from "@oh-my-pi/pi-utils";
4
+ import { $which, getToolsDir, logger, ptree, TempDir, USER_AGENT } from "@oh-my-pi/pi-utils";
5
5
  import { extractArchive } from "./zip";
6
6
 
7
7
  const TOOLS_DIR = getToolsDir();
@@ -174,7 +174,7 @@ async function getLatestVersion(repo: string, signal?: AbortSignal): Promise<str
174
174
  let response: Response;
175
175
  try {
176
176
  response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
177
- headers: { "User-Agent": `${APP_NAME}-coding-agent` },
177
+ headers: { "User-Agent": USER_AGENT },
178
178
  signal: ptree.combineSignals(signal, TOOL_METADATA_TIMEOUT_MS),
179
179
  });
180
180
  } catch (err) {
@@ -18,7 +18,7 @@ import * as os from "node:os";
18
18
  import * as path from "node:path";
19
19
  import { logger, prompt, Snowflake } from "@oh-my-pi/pi-utils";
20
20
  import type { AsyncJob, AsyncJobManager } from "../async/job-manager";
21
- import { resolveAgentModelPatterns } from "../config/model-resolver";
21
+ import { resolveAgentModelSelection } from "../config/model-resolver";
22
22
  import type { LocalProtocolOptions } from "../internal-urls";
23
23
  import { registerArtifactsDir } from "../internal-urls/registry-helpers";
24
24
  import { MCPManager } from "../mcp/manager";
@@ -43,7 +43,7 @@ export type VibeCli = "fast" | "good";
43
43
  * CLI flavor → bundled agent type. This IS the model-tier mapping: `sonic`
44
44
  * carries `model: "@smol"` (the configured fast/low-latency role) and `task`
45
45
  * carries `model: "@task"` (inherits the session's strong model).
46
- * Resolution goes through {@link resolveAgentModelPatterns} exactly like a
46
+ * Resolution goes through {@link resolveAgentModelSelection} exactly like a
47
47
  * `task` spawn, so `task.agentModelOverrides` and model-role settings apply.
48
48
  */
49
49
  export const VIBE_CLI_AGENT: Record<VibeCli, string> = {
@@ -144,6 +144,8 @@ interface VibeRestoreCandidate {
144
144
  interface ResolvedVibeWorker {
145
145
  agent: AgentDefinition;
146
146
  modelOverride?: string | string[];
147
+ /** Pre-expansion role alias behind {@link modelOverride}, when the worker agent named one. */
148
+ modelRole?: string;
147
149
  }
148
150
 
149
151
  interface VibeTurn {
@@ -165,6 +167,8 @@ interface VibeRecord {
165
167
  childSessionFile?: string;
166
168
  agent: AgentDefinition;
167
169
  modelOverride?: string | string[];
170
+ /** Pre-expansion role alias behind {@link modelOverride}, when the worker agent named one. */
171
+ modelRole?: string;
168
172
  state: VibeSessionState;
169
173
  createdAt: number;
170
174
  lastActivityAt: number;
@@ -490,16 +494,17 @@ export class VibeSessionRegistry {
490
494
  throw new ToolError(`Bundled agent "${agentName}" for vibe cli "${cli}" is unavailable.`);
491
495
  }
492
496
  const agentModelOverrides = session.settings.get("task.agentModelOverrides");
493
- return {
494
- agent,
495
- modelOverride: resolveAgentModelPatterns({
496
- settingsOverride: agentModelOverrides[agentName],
497
- agentModel: agent.model,
498
- settings: session.settings,
499
- activeModelPattern: session.getActiveModelString?.(),
500
- fallbackModelPattern: session.getModelString?.(),
501
- }),
502
- };
497
+ // Same contract as the task spawn path: the expansion discards the role
498
+ // alias (`@task`, `@smol`), so patterns and role identity come from one
499
+ // call — the child's inherited retry-fallback chain is keyed off the role.
500
+ const { patterns, role } = resolveAgentModelSelection({
501
+ settingsOverride: agentModelOverrides[agentName],
502
+ agentModel: agent.model,
503
+ settings: session.settings,
504
+ activeModelPattern: session.getActiveModelString?.(),
505
+ fallbackModelPattern: session.getModelString?.(),
506
+ });
507
+ return { agent, modelOverride: patterns, modelRole: role };
503
508
  }
504
509
 
505
510
  async #appendLifecycleEvent(
@@ -890,7 +895,7 @@ export class VibeSessionRegistry {
890
895
  existing.sessionFile === childSessionFile &&
891
896
  (existing.status === "idle" || existing.status === "parked");
892
897
  const blockedByCollision = Boolean(existing && !existingIsResumable);
893
- const { agent, modelOverride } = this.#resolveWorker(session, spawn.cli);
898
+ const { agent, modelOverride, modelRole } = this.#resolveWorker(session, spawn.cli);
894
899
  if (!existing) {
895
900
  AgentRegistry.global().register({
896
901
  id: spawn.id,
@@ -911,6 +916,7 @@ export class VibeSessionRegistry {
911
916
  childSessionFile,
912
917
  agent,
913
918
  modelOverride,
919
+ modelRole,
914
920
  state: "idle",
915
921
  createdAt: spawn.createdAt,
916
922
  lastActivityAt: candidate.lastActivityAt,
@@ -945,7 +951,7 @@ export class VibeSessionRegistry {
945
951
  throw new ToolError("Vibe mode has exited; enter Vibe mode again before spawning a worker.");
946
952
  }
947
953
  const manager = this.#manager(session);
948
- const { agent, modelOverride } = this.#resolveWorker(session, args.cli);
954
+ const { agent, modelOverride, modelRole } = this.#resolveWorker(session, args.cli);
949
955
  if (!session.agentOutputManager) {
950
956
  session.agentOutputManager = new AgentOutputManager(session.getArtifactsDir ?? (() => null));
951
957
  }
@@ -969,6 +975,7 @@ export class VibeSessionRegistry {
969
975
  childSessionFile,
970
976
  agent,
971
977
  modelOverride,
978
+ modelRole,
972
979
  state: "starting",
973
980
  createdAt,
974
981
  lastActivityAt: createdAt,
@@ -1418,6 +1425,7 @@ export class VibeSessionRegistry {
1418
1425
  taskDepth: session.taskDepth ?? 0,
1419
1426
  detached: true,
1420
1427
  modelOverride: record.modelOverride,
1428
+ modelRole: record.modelRole,
1421
1429
  parentActiveModelPattern: session.getActiveModelString?.(),
1422
1430
  thinkingLevel: record.agent.thinkingLevel,
1423
1431
  sessionFile,
package/src/web/kagi.ts CHANGED
@@ -98,7 +98,7 @@ export class KagiApiError extends Error {
98
98
  }
99
99
 
100
100
  function extractKagiErrorMessage(payload: unknown): string | null {
101
- if (!payload || typeof payload !== "object") return null;
101
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
102
102
  const record = payload as Record<string, unknown>;
103
103
 
104
104
  for (const value of [record.message, record.detail]) {
@@ -107,17 +107,20 @@ function extractKagiErrorMessage(payload: unknown): string | null {
107
107
  }
108
108
  }
109
109
 
110
- if (typeof record.error === "string" && record.error.trim().length > 0) {
111
- return record.error.trim();
112
- }
113
-
114
- if (Array.isArray(record.error)) {
115
- for (const entry of record.error) {
110
+ for (const errors of [record.error, record.errors]) {
111
+ if (typeof errors === "string" && errors.trim().length > 0) {
112
+ return errors.trim();
113
+ }
114
+ if (!Array.isArray(errors)) continue;
115
+ for (const entry of errors) {
116
116
  if (!entry || typeof entry !== "object") continue;
117
117
  const e = entry as Record<string, unknown>;
118
- for (const value of [e.message, e.msg]) {
119
- if (typeof value === "string" && value.trim().length > 0) {
120
- return value.trim();
118
+ for (const value of [e.message, e.msg, e.code]) {
119
+ if (
120
+ (typeof value === "string" && value.trim().length > 0) ||
121
+ (typeof value === "number" && Number.isFinite(value))
122
+ ) {
123
+ return String(value).trim();
121
124
  }
122
125
  }
123
126
  }
@@ -147,6 +150,34 @@ function parseKagiErrorResponse(statusCode: number, responseText: string): KagiA
147
150
  }
148
151
  }
149
152
 
153
+ function parseKagiSuccessResponse(statusCode: number, responseText: string): KagiSearchResponse {
154
+ let payload: unknown;
155
+ try {
156
+ payload = JSON.parse(responseText);
157
+ } catch {
158
+ throw new KagiApiError("Kagi API returned an invalid response: invalid JSON", statusCode);
159
+ }
160
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
161
+ throw new KagiApiError("Kagi API returned an invalid response: expected an object envelope", statusCode);
162
+ }
163
+
164
+ const record = payload as Record<string, unknown>;
165
+ const errorMessage = extractKagiErrorMessage(payload);
166
+ if (errorMessage && (record.error !== undefined || record.errors !== undefined)) {
167
+ const errors = Array.isArray(record.error) ? record.error : Array.isArray(record.errors) ? record.errors : [];
168
+ const first = errors[0];
169
+ const code =
170
+ first && typeof first === "object" && typeof (first as Record<string, unknown>).code === "number"
171
+ ? ((first as Record<string, unknown>).code as number)
172
+ : statusCode;
173
+ throw createKagiApiError(code, errorMessage);
174
+ }
175
+ if (record.data !== undefined && (!record.data || typeof record.data !== "object" || Array.isArray(record.data))) {
176
+ throw new KagiApiError("Kagi API returned an invalid response: expected data to be an object", statusCode);
177
+ }
178
+ return payload as KagiSearchResponse;
179
+ }
180
+
150
181
  // ---------------------------------------------------------------------------
151
182
  // Public API
152
183
  // ---------------------------------------------------------------------------
@@ -216,23 +247,40 @@ function buildRequestBody(query: string, options: KagiSearchOptions): KagiSearch
216
247
  return req;
217
248
  }
218
249
 
219
- /** Push every item in a result bucket as a source, with an optional title tag. */
220
- function collectSources(sources: KagiSearchSource[], items: KagiSearchResultItem[] | undefined, tag?: string): void {
221
- if (!items) return;
222
- for (const item of items) {
250
+ function firstNonEmptyString(...values: unknown[]): string | undefined {
251
+ for (const value of values) {
252
+ if (typeof value === "string" && value.trim().length > 0) return value.trim();
253
+ }
254
+ return undefined;
255
+ }
256
+
257
+ /** Push every valid item in a result bucket as a source, with an optional title tag. */
258
+ function collectSources(sources: KagiSearchSource[], items: unknown, tag?: string): void {
259
+ if (!Array.isArray(items)) return;
260
+ for (const value of items) {
261
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
262
+ const item = value as Record<string, unknown>;
263
+ const url = firstNonEmptyString(item.url, item.href, item.link);
264
+ if (!url) continue;
265
+ const title = firstNonEmptyString(item.title, item.name) ?? url;
223
266
  sources.push({
224
- title: tag ? `${tag} ${item.title}` : item.title,
225
- url: item.url,
226
- snippet: item.snippet,
227
- publishedDate: item.time,
267
+ title: tag ? `${tag} ${title}` : title,
268
+ url,
269
+ snippet: firstNonEmptyString(item.snippet, item.description, item.summary),
270
+ publishedDate: firstNonEmptyString(item.time),
228
271
  });
229
272
  }
230
273
  }
231
274
 
232
275
  /** Pull a related/adjacent question from an item's props or fall back to title. */
233
- function questionOf(item: KagiSearchResultItem): string | undefined {
234
- const q = item.props?.question ?? item.props?.query ?? item.title;
235
- return typeof q === "string" && q.length > 0 ? q : undefined;
276
+ function questionOf(value: unknown): string | undefined {
277
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
278
+ const item = value as Record<string, unknown>;
279
+ const props =
280
+ item.props && typeof item.props === "object" && !Array.isArray(item.props)
281
+ ? (item.props as Record<string, unknown>)
282
+ : undefined;
283
+ return firstNonEmptyString(props?.question, props?.query, item.title);
236
284
  }
237
285
 
238
286
  export async function searchWithKagi(
@@ -269,11 +317,7 @@ export async function searchWithKagi(
269
317
  },
270
318
  );
271
319
 
272
- const payload = (await response.json()) as KagiSearchResponse;
273
- if (payload.error && payload.error.length > 0) {
274
- const first = payload.error[0];
275
- throw createKagiApiError(first.code ?? response.status, extractKagiErrorMessage(payload) ?? first.message);
276
- }
320
+ const payload = parseKagiSuccessResponse(response.status, await response.text());
277
321
 
278
322
  const data = payload.data;
279
323
  const sources: KagiSearchSource[] = [];
@@ -284,17 +328,30 @@ export async function searchWithKagi(
284
328
  collectSources(sources, data?.news, "[News]");
285
329
  collectSources(sources, data?.infobox, "[Info]");
286
330
 
287
- for (const item of data?.adjacent_question ?? []) {
288
- const q = questionOf(item);
289
- if (q) relatedQuestions.push(q);
331
+ const adjacentQuestions: unknown = data?.adjacent_question;
332
+ if (Array.isArray(adjacentQuestions)) {
333
+ for (const item of adjacentQuestions) {
334
+ const question = questionOf(item);
335
+ if (question) relatedQuestions.push(question);
336
+ }
290
337
  }
291
- for (const item of data?.related_search ?? []) {
292
- const q = questionOf(item);
293
- if (q) relatedQuestions.push(q);
338
+ const relatedSearches: unknown = data?.related_search;
339
+ if (Array.isArray(relatedSearches)) {
340
+ for (const item of relatedSearches) {
341
+ const question = questionOf(item);
342
+ if (question) relatedQuestions.push(question);
343
+ }
294
344
  }
295
345
 
296
- const directAnswer = data?.direct_answer?.[0];
297
- const answer = directAnswer ? (directAnswer.snippet ?? directAnswer.title) : undefined;
346
+ const directAnswers: unknown = data?.direct_answer;
347
+ const directAnswer = Array.isArray(directAnswers) ? directAnswers[0] : undefined;
348
+ const answer =
349
+ directAnswer && typeof directAnswer === "object" && !Array.isArray(directAnswer)
350
+ ? firstNonEmptyString(
351
+ (directAnswer as Record<string, unknown>).snippet,
352
+ (directAnswer as Record<string, unknown>).title,
353
+ )
354
+ : undefined;
298
355
 
299
356
  return {
300
357
  requestId: payload.meta?.trace ?? payload.meta?.id ?? "",
@@ -146,6 +146,15 @@ export function parseParallelErrorResponse(statusCode: number, responseText: str
146
146
  }
147
147
  }
148
148
 
149
+ export async function parseParallelJsonResponse(response: Response, operation: "search" | "extract"): Promise<unknown> {
150
+ try {
151
+ return await response.json();
152
+ } catch (err) {
153
+ const detail = err instanceof Error ? err.message : String(err);
154
+ throw new ParallelApiError(`Parallel ${operation} returned invalid JSON: ${detail}`);
155
+ }
156
+ }
157
+
149
158
  function getAuthHeaders(apiKey: string): {
150
159
  Accept: string;
151
160
  "Content-Type": string;
@@ -316,7 +325,7 @@ export async function searchWithParallel(
316
325
  throw parseParallelErrorResponse(response.status, await response.text());
317
326
  }
318
327
 
319
- const payload: unknown = await response.json();
328
+ const payload = await parseParallelJsonResponse(response, "search");
320
329
  return parseParallelSearchPayload(payload);
321
330
  }
322
331
 
@@ -349,6 +358,6 @@ export async function extractWithParallel(
349
358
  throw parseParallelErrorResponse(response.status, await response.text());
350
359
  }
351
360
 
352
- const payload: unknown = await response.json();
361
+ const payload = await parseParallelJsonResponse(response, "extract");
353
362
  return parseExtractPayload(payload);
354
363
  }
@@ -1,4 +1,4 @@
1
- import { tryParseJson } from "@oh-my-pi/pi-utils";
1
+ import { tryParseJson, USER_AGENT } from "@oh-my-pi/pi-utils";
2
2
  import type { RenderResult, SpecialHandler } from "./types";
3
3
  import { buildResult, formatNumber, loadPage, looksLikeHtml } from "./types";
4
4
 
@@ -26,7 +26,7 @@ export const handleCratesIo: SpecialHandler = async (
26
26
  const result = await loadPage(apiUrl, {
27
27
  timeout,
28
28
  signal,
29
- headers: { "User-Agent": "omp-web-fetch/1.0 (https://github.com/anthropics)" },
29
+ headers: { "User-Agent": USER_AGENT },
30
30
  });
31
31
 
32
32
  if (!result.ok) return null;
@@ -5,7 +5,7 @@
5
5
  * API docs: https://www.discogs.com/developers
6
6
  */
7
7
 
8
- import { tryParseJson } from "@oh-my-pi/pi-utils";
8
+ import { tryParseJson, USER_AGENT } from "@oh-my-pi/pi-utils";
9
9
  import type { RenderResult, SpecialHandler } from "./types";
10
10
  import { buildResult, loadPage } from "./types";
11
11
 
@@ -277,7 +277,7 @@ export const handleDiscogs: SpecialHandler = async (
277
277
  signal,
278
278
  headers: {
279
279
  Accept: "application/json",
280
- "User-Agent": "CodingAgent/1.0 +https://github.com/can1357/oh-my-pi",
280
+ "User-Agent": USER_AGENT,
281
281
  },
282
282
  });
283
283
 
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { gunzipSync } from "node:zlib";
4
- import { getDocsRsCacheDir, isEnoent, logger, ptree, tryParseJson } from "@oh-my-pi/pi-utils";
4
+ import { getDocsRsCacheDir, isEnoent, logger, ptree, tryParseJson, USER_AGENT } from "@oh-my-pi/pi-utils";
5
5
  import { ToolAbortError } from "../../tools/tool-errors";
6
6
  import type { RenderResult, SpecialHandler } from "./types";
7
7
  import { buildResult, MAX_BYTES } from "./types";
@@ -388,7 +388,7 @@ export const handleDocsRs: SpecialHandler = async (
388
388
  const requestSignal = ptree.combineSignals(signal, timeout * 1000);
389
389
  const response = await fetch(jsonUrl, {
390
390
  signal: requestSignal,
391
- headers: { "User-Agent": "omp-web-fetch/1.0", Accept: "application/gzip" },
391
+ headers: { "User-Agent": USER_AGENT, Accept: "application/gzip" },
392
392
  redirect: "follow",
393
393
  });
394
394
  if (!response.ok) return null;
@@ -1,4 +1,4 @@
1
- import { $env, ptree } from "@oh-my-pi/pi-utils";
1
+ import { $env, ptree, USER_AGENT } from "@oh-my-pi/pi-utils";
2
2
  import type { RenderResult, SpecialHandler } from "./types";
3
3
  import { buildResult, formatMediaDuration, loadPage } from "./types";
4
4
 
@@ -121,7 +121,7 @@ export async function fetchGitHubApi(
121
121
 
122
122
  const headers: Record<string, string> = {
123
123
  Accept: "application/vnd.github.v3+json",
124
- "User-Agent": "omp-web-fetch/1.0",
124
+ "User-Agent": USER_AGENT,
125
125
  };
126
126
 
127
127
  // Use GITHUB_TOKEN if available
@@ -2,7 +2,7 @@
2
2
  * MusicBrainz URL handler for artists, releases, and recordings
3
3
  */
4
4
 
5
- import { tryParseJson } from "@oh-my-pi/pi-utils";
5
+ import { tryParseJson, USER_AGENT } from "@oh-my-pi/pi-utils";
6
6
  import type { RenderResult, SpecialHandler } from "./types";
7
7
  import { buildResult, formatMediaDuration, loadPage } from "./types";
8
8
 
@@ -64,7 +64,6 @@ interface MusicBrainzRelease {
64
64
  }
65
65
 
66
66
  const MUSICBRAINZ_HOSTS = new Set(["musicbrainz.org", "www.musicbrainz.org"]);
67
- const USER_AGENT = "omp-web-fetch/1.0 (https://github.com/anthropics)";
68
67
  const MAX_TRACKS = 50;
69
68
 
70
69
  function parseEntity(url: URL): { entity: MusicBrainzEntity; mbid: string } | null {
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * PubMed handler for web-fetch
3
3
  */
4
- import { tryParseJson } from "@oh-my-pi/pi-utils";
4
+ import { tryParseJson, USER_AGENT } from "@oh-my-pi/pi-utils";
5
5
  import { buildResult, loadPage, type RenderResult, type SpecialHandler } from "./types";
6
6
 
7
7
  const NCBI_HEADERS = {
8
8
  Accept: "application/json, text/plain;q=0.9, */*;q=0.8",
9
- "User-Agent": "CodingAgent/1.0 (web scraper)",
9
+ "User-Agent": USER_AGENT,
10
10
  };
11
11
 
12
12
  /**