@oh-my-pi/pi-coding-agent 17.0.7 → 17.0.9

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 (153) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +5025 -4930
  3. package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
  4. package/dist/types/config/model-registry.d.ts +28 -1
  5. package/dist/types/config/model-resolver.d.ts +5 -1
  6. package/dist/types/config/settings-schema.d.ts +50 -3
  7. package/dist/types/dap/client.d.ts +19 -0
  8. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  9. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  10. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
  11. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
  12. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +1 -1
  13. package/dist/types/extensibility/typebox.d.ts +4 -0
  14. package/dist/types/hindsight/client.d.ts +10 -0
  15. package/dist/types/hindsight/config.d.ts +8 -0
  16. package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
  17. package/dist/types/lsp/client.d.ts +2 -0
  18. package/dist/types/mcp/manager.d.ts +6 -2
  19. package/dist/types/mcp/render.d.ts +2 -2
  20. package/dist/types/mcp/tool-bridge.d.ts +16 -3
  21. package/dist/types/mcp/types.d.ts +6 -0
  22. package/dist/types/mnemopi/state.d.ts +2 -0
  23. package/dist/types/modes/components/oauth-selector.d.ts +8 -0
  24. package/dist/types/modes/components/settings-defs.d.ts +1 -0
  25. package/dist/types/modes/components/user-message.d.ts +25 -1
  26. package/dist/types/modes/controllers/extension-ui-controller.d.ts +8 -0
  27. package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
  28. package/dist/types/modes/interactive-mode.d.ts +7 -1
  29. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  30. package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
  31. package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
  32. package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
  33. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  34. package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
  35. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  36. package/dist/types/modes/types.d.ts +14 -1
  37. package/dist/types/modes/utils/ui-helpers.d.ts +12 -8
  38. package/dist/types/sdk.d.ts +1 -0
  39. package/dist/types/session/agent-session-error-log.test.d.ts +1 -0
  40. package/dist/types/session/agent-session.d.ts +86 -3
  41. package/dist/types/session/session-loader.d.ts +6 -4
  42. package/dist/types/task/executor.d.ts +10 -0
  43. package/dist/types/task/index.d.ts +1 -0
  44. package/dist/types/task/prewalk.d.ts +3 -0
  45. package/dist/types/task/types.d.ts +14 -0
  46. package/dist/types/tools/ask.d.ts +10 -0
  47. package/dist/types/tools/hub/jobs.d.ts +1 -1
  48. package/dist/types/tools/index.d.ts +3 -0
  49. package/dist/types/tools/report-tool-issue.d.ts +24 -9
  50. package/dist/types/tools/tool-timeouts.d.ts +8 -2
  51. package/dist/types/utils/changelog.d.ts +4 -6
  52. package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
  53. package/dist/types/web/search/types.d.ts +1 -1
  54. package/package.json +13 -13
  55. package/scripts/legacy-pi-virtual-module.ts +1 -1
  56. package/src/cli/grievances-cli.ts +2 -2
  57. package/src/cli/models-cli.ts +37 -17
  58. package/src/cli/update-cli.ts +14 -1
  59. package/src/cli/usage-cli.ts +1 -1
  60. package/src/config/__tests__/model-registry.test.ts +147 -0
  61. package/src/config/model-registry.ts +87 -24
  62. package/src/config/model-resolver.ts +56 -32
  63. package/src/config/settings-schema.ts +46 -3
  64. package/src/config/settings.ts +68 -5
  65. package/src/dap/client.ts +86 -7
  66. package/src/edit/diff.ts +4 -4
  67. package/src/eval/py/prelude.py +5 -5
  68. package/src/export/html/template.js +23 -9
  69. package/src/extensibility/extensions/runner.ts +9 -4
  70. package/src/extensibility/extensions/types.ts +3 -0
  71. package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
  72. package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
  73. package/src/extensibility/plugins/legacy-pi-compat.ts +851 -310
  74. package/src/extensibility/typebox.ts +22 -9
  75. package/src/hindsight/client.test.ts +42 -0
  76. package/src/hindsight/client.ts +40 -3
  77. package/src/hindsight/config.ts +18 -0
  78. package/src/internal-urls/registry-helpers.ts +40 -0
  79. package/src/launch/broker.ts +31 -5
  80. package/src/lsp/client.ts +23 -0
  81. package/src/lsp/clients/biome-client.ts +3 -4
  82. package/src/lsp/index.ts +26 -8
  83. package/src/main.ts +16 -5
  84. package/src/mcp/manager.ts +39 -8
  85. package/src/mcp/render.ts +94 -35
  86. package/src/mcp/tool-bridge.ts +116 -11
  87. package/src/mcp/types.ts +7 -0
  88. package/src/mnemopi/state.ts +70 -3
  89. package/src/modes/components/agent-dashboard.ts +6 -2
  90. package/src/modes/components/agent-hub.ts +26 -9
  91. package/src/modes/components/chat-transcript-builder.ts +13 -2
  92. package/src/modes/components/diff.ts +2 -2
  93. package/src/modes/components/oauth-selector.ts +24 -7
  94. package/src/modes/components/plan-review-overlay.ts +20 -4
  95. package/src/modes/components/settings-defs.ts +5 -2
  96. package/src/modes/components/settings-selector.ts +9 -5
  97. package/src/modes/components/tool-execution.test.ts +63 -2
  98. package/src/modes/components/user-message.ts +100 -1
  99. package/src/modes/controllers/command-controller.ts +36 -12
  100. package/src/modes/controllers/event-controller.ts +4 -1
  101. package/src/modes/controllers/extension-ui-controller.ts +18 -0
  102. package/src/modes/controllers/input-controller.ts +47 -12
  103. package/src/modes/controllers/mcp-command-controller.ts +70 -40
  104. package/src/modes/controllers/selector-controller.ts +82 -6
  105. package/src/modes/interactive-mode.ts +39 -4
  106. package/src/modes/rpc/rpc-client.ts +94 -3
  107. package/src/modes/rpc/rpc-frame.ts +164 -4
  108. package/src/modes/rpc/rpc-messages.ts +127 -0
  109. package/src/modes/rpc/rpc-mode.ts +79 -7
  110. package/src/modes/rpc/rpc-types.ts +34 -2
  111. package/src/modes/setup-wizard/scenes/model.ts +5 -7
  112. package/src/modes/setup-wizard/scenes/providers.ts +3 -2
  113. package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
  114. package/src/modes/setup-wizard/scenes/theme.ts +13 -3
  115. package/src/modes/setup-wizard/scenes/types.ts +9 -1
  116. package/src/modes/setup-wizard/scenes/web-search.ts +6 -1
  117. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  118. package/src/modes/types.ts +16 -3
  119. package/src/modes/utils/ui-helpers.ts +40 -20
  120. package/src/prompts/goals/guided-goal-system.md +24 -3
  121. package/src/prompts/system/workflow-notice.md +1 -1
  122. package/src/prompts/tools/task.md +12 -2
  123. package/src/sdk.ts +179 -51
  124. package/src/session/agent-session-error-log.test.ts +59 -0
  125. package/src/session/agent-session.ts +351 -46
  126. package/src/session/session-context.test.ts +224 -1
  127. package/src/session/session-context.ts +41 -2
  128. package/src/session/session-loader.ts +10 -5
  129. package/src/slash-commands/helpers/usage-report.ts +3 -1
  130. package/src/system-prompt.ts +1 -2
  131. package/src/task/executor.ts +44 -27
  132. package/src/task/index.ts +63 -8
  133. package/src/task/prewalk.ts +6 -0
  134. package/src/task/structured-subagent.ts +3 -1
  135. package/src/task/types.ts +16 -0
  136. package/src/task/worktree.ts +25 -11
  137. package/src/tools/ask.ts +15 -0
  138. package/src/tools/bash.ts +14 -6
  139. package/src/tools/browser.ts +1 -1
  140. package/src/tools/debug.ts +1 -1
  141. package/src/tools/eval.ts +4 -5
  142. package/src/tools/fetch.ts +1 -1
  143. package/src/tools/hub/index.ts +1 -1
  144. package/src/tools/hub/jobs.ts +67 -8
  145. package/src/tools/hub/launch.ts +7 -0
  146. package/src/tools/index.ts +3 -0
  147. package/src/tools/report-tool-issue.ts +79 -28
  148. package/src/tools/tool-timeouts.ts +10 -3
  149. package/src/tools/write.ts +31 -0
  150. package/src/utils/changelog.ts +54 -58
  151. package/src/utils/git.ts +57 -49
  152. package/src/web/search/providers/firecrawl.ts +46 -13
  153. package/src/web/search/types.ts +5 -1
@@ -74,6 +74,9 @@ const KEY_INPUT: Record<string, string> = {
74
74
  LEFT: "\u001b[D",
75
75
  };
76
76
 
77
+ /** Terminal daemon lifecycle states — the process is no longer running. */
78
+ const TERMINAL_STATES: Partial<Record<DaemonState, true>> = { exited: true, failed: true };
79
+
77
80
  /** Structured launch state retained for compact TUI rendering. */
78
81
  export interface LaunchToolDetails {
79
82
  op: LaunchParams["op"];
@@ -229,6 +232,8 @@ function toolContent(result: DaemonRpcResult, params: LaunchParams): string {
229
232
  lines.push(
230
233
  `NOT ready — readiness timed out after ${params.ready?.timeout ?? 30}s${cause}. The process is still running (state: ${daemon.state}); follow its logs or stop it.`,
231
234
  );
235
+ } else if (params.ready && daemon.readyAt === undefined && TERMINAL_STATES[daemon.state]) {
236
+ lines.push("Process exited before readiness was observed.");
232
237
  }
233
238
  return lines.join("\n");
234
239
  }
@@ -437,6 +442,8 @@ export function launchRenderResult(
437
442
  : "Readiness timed out; the process is still running.",
438
443
  ),
439
444
  );
445
+ } else if (params.ready && daemon && daemon.readyAt === undefined && TERMINAL_STATES[daemon.state]) {
446
+ body.push(theme.fg("warning", "Process exited before readiness was observed."));
440
447
  }
441
448
  break;
442
449
  }
@@ -20,6 +20,7 @@ import { LspTool } from "../lsp";
20
20
  import type { MCPManager } from "../mcp";
21
21
  import type { MnemopiSessionState } from "../mnemopi/state";
22
22
  import type { PlanModeState } from "../plan-mode/state";
23
+ import type { AgentLifecycleManager } from "../registry/agent-lifecycle";
23
24
  import type { AgentRegistry } from "../registry/agent-registry";
24
25
  import type { ArtifactManager } from "../session/artifacts";
25
26
  import type { ClientBridge } from "../session/client-bridge";
@@ -238,6 +239,8 @@ export interface ToolSession {
238
239
  xdevRegistry?: XdevRegistry;
239
240
  /** Agent registry for IRC routing across live sessions. */
240
241
  agentRegistry?: AgentRegistry;
242
+ /** Idle→parked→revive lifecycle owner; lets the hub kill a non-job-backed agent registration. Default: AgentLifecycleManager.global(). */
243
+ agentLifecycle?: () => AgentLifecycleManager;
241
244
  /** Get artifacts directory for artifact:// URLs */
242
245
  getArtifactsDir?: () => string | null;
243
246
  /** Get the ArtifactManager backing this session (shared across parent + subagents). */
@@ -5,16 +5,21 @@
5
5
  * `xd://report_issue`, and the system prompt tells the model to write
6
6
  * `<tool>: <concise description>` there when auto-QA is enabled.
7
7
  *
8
- * Enabled by default; gated behind PI_AUTO_QA=1 / `dev.autoqa` so a user who
9
- * flips the setting off short-circuits injection entirely.
8
+ * Enabled by default (`dev.autoqa` defaults to true); `PI_AUTO_QA=0` or an
9
+ * explicit `dev.autoqa: false` short-circuits injection entirely. When the
10
+ * user is only enabled by default (never configured `dev.autoqa` themselves),
11
+ * a persisted `dev.autoqaConsent: "denied"` also disables injection so a "No"
12
+ * in the consent dialog fully turns the feature off.
10
13
  * Records grievances to a local SQLite database; never throws from the device
11
14
  * dispatch path.
12
15
  *
13
- * Before the first record lands, the user's consent is checked. If they've
14
- * never been asked (`dev.autoqaConsent === "unset"`) the process-global
15
- * consent handler — wired by `InteractiveMode` to a Yes/No popup — is invoked
16
- * exactly once and the decision is persisted. Subsequent calls (including from
17
- * subagents) read the cached decision without prompting.
16
+ * Nothing is written until consent resolves. If the user has never been asked
17
+ * (`dev.autoqaConsent === "unset"`) the process-global consent handler —
18
+ * wired by `InteractiveMode` to a Yes/No popup — is invoked exactly once and
19
+ * the decision is persisted; a denial (or dismissal) drops the pending report
20
+ * without touching the database. Subsequent calls (including from subagents)
21
+ * read the cached decision without prompting. `PI_AUTO_QA_PUSH=1` bypasses
22
+ * the dialog for headless environments.
18
23
  *
19
24
  * When the user grants consent, push is automatically active against the
20
25
  * bundled endpoint (`dev.autoqaPush.endpoint`, default `qa.omp.sh`). Each
@@ -24,11 +29,13 @@
24
29
  * the network and never throws.
25
30
  */
26
31
  import { Database } from "bun:sqlite";
32
+ import * as fs from "node:fs";
33
+ import * as path from "node:path";
27
34
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
28
35
  import type { FetchImpl } from "@oh-my-pi/pi-ai";
29
36
  import type { Component } from "@oh-my-pi/pi-tui";
30
37
  import { Text } from "@oh-my-pi/pi-tui";
31
- import { $env, $flag, getAutoQaDbDir, getInstallId, logger, VERSION } from "@oh-my-pi/pi-utils";
38
+ import { $env, $flag, getAutoQaDbPath, getInstallId, logger, VERSION } from "@oh-my-pi/pi-utils";
32
39
  import type { Settings } from "..";
33
40
  import type { Theme } from "../modes/theme/theme";
34
41
  import { renderStatusLine, truncateToWidth } from "../tui";
@@ -88,8 +95,23 @@ function parseReportIssueBody(text: string): { tool: string; report: string } {
88
95
  throw new ToolError(`Invalid report format. ${reportIssueDeviceUsage()}`);
89
96
  }
90
97
 
98
+ /**
99
+ * Whether Auto-QA is active for this session.
100
+ *
101
+ * Precedence: `PI_AUTO_QA` env flag > explicit `dev.autoqa` setting >
102
+ * default-on unless the user previously denied consent. The denial veto only
103
+ * applies to the default: explicitly configuring `dev.autoqa: true` re-enables
104
+ * injection (recording still no-ops until consent is granted).
105
+ */
91
106
  export function isAutoQaEnabled(settings?: Settings): boolean {
92
- return $flag("PI_AUTO_QA", !!settings?.get("dev.autoqa"));
107
+ let fallback = false;
108
+ if (settings) {
109
+ const enabled = !!settings.get("dev.autoqa");
110
+ fallback = settings.isConfigured("dev.autoqa")
111
+ ? enabled
112
+ : enabled && settings.get("dev.autoqaConsent") !== "denied";
113
+ }
114
+ return $flag("PI_AUTO_QA", fallback);
93
115
  }
94
116
 
95
117
  // ───────────────────────────────────────────────────────────────────────────
@@ -233,15 +255,18 @@ let cachedDb: Database | null = null;
233
255
 
234
256
  /**
235
257
  * Open (or return the cached handle for) the auto-QA SQLite database at
236
- * `~/.omp/agent/autoqa.db`, creating the schema lazily. Returns `null` when
237
- * the agent data dir cannot be resolved.
258
+ * `~/.omp/autoqa.db` (XDG: `$XDG_DATA_HOME/omp/autoqa.db`), creating the
259
+ * schema lazily. Returns `null` when the path cannot be resolved or opened.
238
260
  */
239
261
  export function openAutoQaDb(): Database | null {
240
262
  if (cachedDb) return cachedDb;
241
- const dir = getAutoQaDbDir();
242
- if (!dir) return null;
263
+ const dbPath = getAutoQaDbPath();
264
+ if (!dbPath) return null;
243
265
  try {
244
- const db = new Database(`${dir}/autoqa.db`, { create: true });
266
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
267
+ const db = new Database(dbPath, { create: true });
268
+ // Install the busy handler BEFORE any lock-taking statement. See #2421.
269
+ db.run("PRAGMA busy_timeout = 5000");
245
270
  db.exec(`
246
271
  CREATE TABLE IF NOT EXISTS grievances (
247
272
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -252,6 +277,17 @@ export function openAutoQaDb(): Database | null {
252
277
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
253
278
  pushed INTEGER NOT NULL DEFAULT 0
254
279
  );
280
+ `);
281
+ // Legacy DBs (May 2026) predate `created_at`. ALTER TABLE only accepts
282
+ // constant defaults, so add it empty and backfill before the index below.
283
+ const hasCreatedAt = db.prepare("SELECT 1 FROM pragma_table_info('grievances') WHERE name = 'created_at'").get();
284
+ if (!hasCreatedAt) {
285
+ db.exec(`
286
+ ALTER TABLE grievances ADD COLUMN created_at TEXT NOT NULL DEFAULT '';
287
+ UPDATE grievances SET created_at = CURRENT_TIMESTAMP WHERE created_at = '';
288
+ `);
289
+ }
290
+ db.exec(`
255
291
  CREATE INDEX IF NOT EXISTS grievances_pushed_created_at_idx
256
292
  ON grievances (pushed, created_at, id);
257
293
  `);
@@ -471,23 +507,38 @@ export async function flushGrievances(
471
507
  }
472
508
  }
473
509
 
474
- /** Record a grievance row and trigger the background consent/flush pipeline. */
475
- async function recordToolIssue(session: ToolSession, tool: string, report: string): Promise<void> {
510
+ /**
511
+ * Most recently scheduled record pipeline. Never rejects (the pipeline
512
+ * swallows its own errors); retained so tests can await the fire-and-forget
513
+ * work deterministically via {@link __awaitAutoQaRecordPipelineForTests}.
514
+ */
515
+ let lastRecordPipeline: Promise<void> = Promise.resolve();
516
+
517
+ /** Test-only: await the last consent → insert → flush pipeline. */
518
+ export function __awaitAutoQaRecordPipelineForTests(): Promise<void> {
519
+ return lastRecordPipeline;
520
+ }
521
+
522
+ /**
523
+ * Queue a grievance for recording. The consent → insert → flush pipeline is
524
+ * fire-and-forget: nothing is written until the user grants consent (or
525
+ * `PI_AUTO_QA_PUSH=1` forces headless recording), and the device result
526
+ * returns immediately so the model never waits on the dialog or the network.
527
+ */
528
+ function recordToolIssue(session: ToolSession, tool: string, report: string): void {
476
529
  const canonicalTool = tool.startsWith("proxy_") ? tool.slice("proxy_".length) : tool;
477
- const db = openAutoQaDb();
478
- if (!db) return;
479
- db.prepare("INSERT INTO grievances (model, version, tool, report) VALUES (?, ?, ?, ?)").run(
480
- session.getActiveModelString?.() ?? "unknown",
481
- VERSION,
482
- canonicalTool,
483
- report,
484
- );
485
- void (async () => {
530
+ const model = session.getActiveModelString?.() ?? "unknown";
531
+ lastRecordPipeline = (async () => {
486
532
  try {
487
- await resolveAutoQaConsent(session.settings);
533
+ if (!$flag("PI_AUTO_QA_PUSH") && !(await resolveAutoQaConsent(session.settings))) return;
534
+ const db = openAutoQaDb();
535
+ if (!db) return;
536
+ db.prepare(
537
+ "INSERT INTO grievances (model, version, tool, report, created_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)",
538
+ ).run(model, VERSION, canonicalTool, report);
488
539
  await flushGrievances(db, session.settings);
489
540
  } catch (error) {
490
- logger.debug("autoqa post-insert pipeline failed", { error: String(error) });
541
+ logger.debug("autoqa consent pipeline failed", { error: String(error) });
491
542
  }
492
543
  })();
493
544
  }
@@ -504,7 +555,7 @@ export async function dispatchReportIssueDevice(
504
555
  try {
505
556
  if (isAutoQaEnabled(session.settings)) {
506
557
  const { tool, report } = parseReportIssueBody(text);
507
- await recordToolIssue(session, tool, report);
558
+ recordToolIssue(session, tool, report);
508
559
  }
509
560
  } catch (error) {
510
561
  if (error instanceof ToolError) throw error;
@@ -21,10 +21,17 @@ export type ToolWithTimeout = keyof typeof TOOL_TIMEOUTS;
21
21
 
22
22
  /**
23
23
  * Clamp a raw timeout to the allowed range for a tool.
24
- * If rawTimeout is undefined, returns the tool's default.
24
+ *
25
+ * When `rawTimeout` is undefined the tool's `default` is used. A positive
26
+ * `maxTimeout` (the `tools.maxTimeout` global ceiling) caps the *resolved*
27
+ * value — including the default-fallback path — before the per-tool `min`/`max`
28
+ * floor and ceiling apply, so a configured global cap governs calls where the
29
+ * agent omits `timeout`, not only explicitly-passed values. `maxTimeout <= 0`
30
+ * means no global cap.
25
31
  */
26
- export function clampTimeout(tool: ToolWithTimeout, rawTimeout?: number): number {
32
+ export function clampTimeout(tool: ToolWithTimeout, rawTimeout?: number, maxTimeout?: number): number {
27
33
  const config = TOOL_TIMEOUTS[tool];
28
34
  const timeout = rawTimeout ?? config.default;
29
- return Math.max(config.min, Math.min(config.max, timeout));
35
+ const capped = maxTimeout !== undefined && maxTimeout > 0 ? Math.min(timeout, maxTimeout) : timeout;
36
+ return Math.max(config.min, Math.min(config.max, capped));
30
37
  }
@@ -85,6 +85,36 @@ import { renderXdevCall, renderXdevResult, type XdevDispatch } from "./xdev";
85
85
 
86
86
  const LOOSE_HASHLINE_HEADER_RE = /^\s*\[[^#\r\n]+#[^ \t\r\n]*\]\s*$/;
87
87
  const EXECUTABLE_NOTICE = "[Notice: Made executable via chmod +x]";
88
+ const URI_LIKE_WRITE_PATH_RE = /^([a-z][a-z0-9+.-]*):\/{1,2}(.*)$/i;
89
+ const XD_MISSING_DELIMITER_RE = /^xd\/+(.*)$/i;
90
+ const XD_SCHEME_NEAR_MISSES: Record<string, true> = { dx: true, xdd: true, xdt: true };
91
+
92
+ function assertWriteTargetAddressable(target: string, router: InternalUrlRouter): void {
93
+ const trimmed = target.trim();
94
+ if (path.win32.isAbsolute(trimmed) || router.canHandle(trimmed)) return;
95
+
96
+ const missingDelimiter = trimmed.match(XD_MISSING_DELIMITER_RE);
97
+ if (missingDelimiter) {
98
+ throw new ToolError(
99
+ `Unknown URI-like write target '${trimmed}'. Did you mean 'xd://${missingDelimiter[1]}'? Prefix the path with './' to write it as a filesystem path.`,
100
+ );
101
+ }
102
+
103
+ const uriLike = trimmed.match(URI_LIKE_WRITE_PATH_RE);
104
+ if (!uriLike) return;
105
+
106
+ const scheme = uriLike[1]!.toLowerCase();
107
+ // conflict:// has no router handler but is spliced downstream by
108
+ // parseConflictUri (which emits its own precise id/scope errors); let it pass.
109
+ if (scheme === "conflict") return;
110
+ const canonicalScheme = router.getHandler(scheme) ? scheme : XD_SCHEME_NEAR_MISSES[scheme] ? "xd" : undefined;
111
+ const suggestion = canonicalScheme
112
+ ? ` Did you mean '${canonicalScheme}://${uriLike[2]}'?`
113
+ : " Tool devices use 'xd://<tool>'.";
114
+ throw new ToolError(
115
+ `Unknown URI-like write target '${trimmed}'.${suggestion} Prefix the path with './' to write it as a filesystem path.`,
116
+ );
117
+ }
88
118
 
89
119
  const BULK_DIRECTIVE_RE = /^#?(\d+)\s*[:=]\s*(@ours|@theirs|@base|@both)$/;
90
120
  /**
@@ -987,6 +1017,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
987
1017
  // Strip hashline display prefixes ([PATH#HASH] + LINE:) if the model copied them from read output
988
1018
  const { text: cleanContent, stripped } = stripWriteContent(this.session, content);
989
1019
  const internalRouter = InternalUrlRouter.instance();
1020
+ assertWriteTargetAddressable(path, internalRouter);
990
1021
  if (internalRouter.canHandle(path)) {
991
1022
  const parsed = parseInternalUrl(path);
992
1023
  const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
@@ -1,4 +1,5 @@
1
1
  import { getLastChangelogVersionPath, isEnoent, logger } from "@oh-my-pi/pi-utils";
2
+ import bundledChangelog from "../../CHANGELOG.md" with { type: "text" };
2
3
 
3
4
  export interface ChangelogEntry {
4
5
  major: number;
@@ -29,73 +30,68 @@ export interface StartupChangelogSelection {
29
30
  }
30
31
 
31
32
  /**
32
- * Parse changelog entries from the file at `changelogPath`. Scans for `## [x.y.z]`
33
- * headings and collects each block until the next heading or EOF.
33
+ * Parse changelog entries from omp's package asset when available, falling back
34
+ * to the copy embedded in compiled binaries.
34
35
  *
35
- * Returns `[]` when `changelogPath` is `undefined` (package directory not
36
- * resolvable see `getChangelogPath`) or the file is missing. Callers MUST NOT
37
- * synthesize a fallback path from the host project's cwd; doing so caused issue
38
- * #1423 (the host project's `CHANGELOG.md` was rendered as omp's).
36
+ * The embedded fallback keeps standalone binaries self-contained without
37
+ * resolving relative to the host project's cwd, which caused issue #1423.
39
38
  */
40
39
  export async function parseChangelog(changelogPath: string | undefined): Promise<ChangelogEntry[]> {
41
- if (!changelogPath) {
42
- return [];
43
- }
44
- try {
45
- const content = await Bun.file(changelogPath).text();
46
- const lines = content.split("\n");
47
- const entries: ChangelogEntry[] = [];
48
-
49
- let currentLines: string[] = [];
50
- let currentVersion: { major: number; minor: number; patch: number } | null = null;
51
-
52
- for (const line of lines) {
53
- // Check if this is a version header (## [x.y.z] ...)
54
- if (line.startsWith("## ")) {
55
- // Save previous entry if exists
56
- if (currentVersion && currentLines.length > 0) {
57
- entries.push({
58
- ...currentVersion,
59
- content: currentLines.join("\n").trim(),
60
- });
61
- }
62
-
63
- // Try to parse version from this line
64
- const versionMatch = line.match(/##\s+\[?(\d+)\.(\d+)\.(\d+)\]?/);
65
- if (versionMatch) {
66
- currentVersion = {
67
- major: Number.parseInt(versionMatch[1], 10),
68
- minor: Number.parseInt(versionMatch[2], 10),
69
- patch: Number.parseInt(versionMatch[3], 10),
70
- };
71
- currentLines = [line];
72
- } else {
73
- // Reset if we can't parse version
74
- currentVersion = null;
75
- currentLines = [];
76
- }
77
- } else if (currentVersion) {
78
- // Collect lines for current version
79
- currentLines.push(line);
40
+ let content = bundledChangelog;
41
+ if (changelogPath) {
42
+ try {
43
+ content = await Bun.file(changelogPath).text();
44
+ } catch (error) {
45
+ if (!isEnoent(error)) {
46
+ logger.error(`Warning: Could not parse changelog: ${error}`);
80
47
  }
81
48
  }
49
+ }
82
50
 
83
- // Save last entry
84
- if (currentVersion && currentLines.length > 0) {
85
- entries.push({
86
- ...currentVersion,
87
- content: currentLines.join("\n").trim(),
88
- });
89
- }
51
+ return parseChangelogContent(content);
52
+ }
90
53
 
91
- return entries;
92
- } catch (error) {
93
- if (isEnoent(error)) {
94
- return [];
54
+ function parseChangelogContent(content: string): ChangelogEntry[] {
55
+ const lines = content.split("\n");
56
+ const entries: ChangelogEntry[] = [];
57
+
58
+ let currentLines: string[] = [];
59
+ let currentVersion: { major: number; minor: number; patch: number } | null = null;
60
+
61
+ for (const line of lines) {
62
+ if (line.startsWith("## ")) {
63
+ if (currentVersion && currentLines.length > 0) {
64
+ entries.push({
65
+ ...currentVersion,
66
+ content: currentLines.join("\n").trim(),
67
+ });
68
+ }
69
+
70
+ const versionMatch = line.match(/##\s+\[?(\d+)\.(\d+)\.(\d+)\]?/);
71
+ if (versionMatch) {
72
+ currentVersion = {
73
+ major: Number.parseInt(versionMatch[1], 10),
74
+ minor: Number.parseInt(versionMatch[2], 10),
75
+ patch: Number.parseInt(versionMatch[3], 10),
76
+ };
77
+ currentLines = [line];
78
+ } else {
79
+ currentVersion = null;
80
+ currentLines = [];
81
+ }
82
+ } else if (currentVersion) {
83
+ currentLines.push(line);
95
84
  }
96
- logger.error(`Warning: Could not parse changelog: ${error}`);
97
- return [];
98
85
  }
86
+
87
+ if (currentVersion && currentLines.length > 0) {
88
+ entries.push({
89
+ ...currentVersion,
90
+ content: currentLines.join("\n").trim(),
91
+ });
92
+ }
93
+
94
+ return entries;
99
95
  }
100
96
 
101
97
  /**
package/src/utils/git.ts CHANGED
@@ -217,6 +217,12 @@ export const GIT_NETWORK_TIMEOUT_MS = 30 * 60 * 1000;
217
217
  export const GIT_COMMAND_OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024;
218
218
 
219
219
  const GIT_COMMAND_TIMEOUT_EXIT_CODE = 124;
220
+ // Exit code returned when the `git` binary cannot be launched at all (spawn
221
+ // ENOENT). Mirrors the POSIX "command not found" code so read-only callers that
222
+ // degrade on any non-zero exit treat a missing git the same as a failed
223
+ // invocation instead of letting the raw spawn ENOENT escape as an unhandled
224
+ // rejection.
225
+ const GIT_SPAWN_ENOENT_EXIT_CODE = 127;
220
226
  const GIT_OUTPUT_TRUNCATED_MARKER = "\n[git subprocess output truncated after 8 MiB]\n";
221
227
  const GIT_COMMAND_TERMINATE_GRACE_MS = 5_000;
222
228
 
@@ -388,6 +394,29 @@ function ensureAvailable(): void {
388
394
  }
389
395
  }
390
396
 
397
+ /**
398
+ * Launch a `git` plumbing command synchronously and decode stdout. Returns the
399
+ * exit code plus trimmed stdout; a missing `git` binary (spawn ENOENT) is
400
+ * reported as {@link GIT_SPAWN_ENOENT_EXIT_CODE} so sync read-only callers
401
+ * degrade to `null` instead of throwing an uncaught error during rendering.
402
+ */
403
+ function gitSpawnSyncText(cwd: string, args: readonly string[]): { exitCode: number; stdout: string } {
404
+ const commandArgs = withShortLivedGitConfig(withNoOptionalLocks(args));
405
+ try {
406
+ const result = Bun.spawnSync(["git", ...commandArgs], {
407
+ cwd,
408
+ env: buildGitEnv(),
409
+ stdout: "pipe",
410
+ stderr: "pipe",
411
+ windowsHide: true,
412
+ });
413
+ return { exitCode: result.exitCode ?? 0, stdout: new TextDecoder().decode(result.stdout).trim() };
414
+ } catch (err) {
415
+ if (isEnoent(err)) return { exitCode: GIT_SPAWN_ENOENT_EXIT_CODE, stdout: "" };
416
+ throw err;
417
+ }
418
+ }
419
+
391
420
  function formatCommandFailure(
392
421
  args: readonly string[],
393
422
  result: Pick<GitCommandResult, "exitCode" | "stdout" | "stderr">,
@@ -401,15 +430,26 @@ function formatCommandFailure(
401
430
 
402
431
  async function git(cwd: string, args: readonly string[], options: CommandOptions = {}): Promise<GitCommandResult> {
403
432
  const commandArgs = withShortLivedGitConfig(options.readOnly ? withNoOptionalLocks(args) : [...args]);
404
- const child = Bun.spawn(["git", ...commandArgs], {
405
- cwd,
406
- env: buildGitEnv(options.env),
407
- signal: options.signal,
408
- stdin: normalizeStdin(options.stdin),
409
- stdout: "pipe",
410
- stderr: "pipe",
411
- windowsHide: true,
412
- });
433
+ let child: Subprocess;
434
+ try {
435
+ child = Bun.spawn(["git", ...commandArgs], {
436
+ cwd,
437
+ env: buildGitEnv(options.env),
438
+ signal: options.signal,
439
+ stdin: normalizeStdin(options.stdin),
440
+ stdout: "pipe",
441
+ stderr: "pipe",
442
+ windowsHide: true,
443
+ });
444
+ } catch (err) {
445
+ if (isEnoent(err)) {
446
+ // A deleted/nonexistent cwd also surfaces as a spawn ENOENT; only blame
447
+ // the binary when the working directory actually exists.
448
+ const stderr = fs.existsSync(cwd) ? "git is not installed." : `working directory does not exist: ${cwd}`;
449
+ return { exitCode: GIT_SPAWN_ENOENT_EXIT_CODE, stdout: "", stderr };
450
+ }
451
+ throw err;
452
+ }
413
453
 
414
454
  return await collectSubprocessResult("git", commandArgs, child, options);
415
455
  }
@@ -890,28 +930,12 @@ async function resolveHeadStateReftable(repository: GitRepository, signal?: Abor
890
930
  }
891
931
 
892
932
  function resolveHeadStateReftableSync(repository: GitRepository): GitHeadState | null {
893
- ensureAvailable();
894
- const symArgs = withShortLivedGitConfig(withNoOptionalLocks(["symbolic-ref", "HEAD"]));
895
- const symResult = Bun.spawnSync(["git", ...symArgs], {
896
- cwd: repository.repoRoot,
897
- env: buildGitEnv(),
898
- stdout: "pipe",
899
- stderr: "pipe",
900
- windowsHide: true,
901
- });
902
-
903
- const revArgs = withShortLivedGitConfig(withNoOptionalLocks(["rev-parse", "--verify", "HEAD"]));
904
- const revResult = Bun.spawnSync(["git", ...revArgs], {
905
- cwd: repository.repoRoot,
906
- env: buildGitEnv(),
907
- stdout: "pipe",
908
- stderr: "pipe",
909
- windowsHide: true,
910
- });
911
- const commit = revResult.exitCode === 0 ? new TextDecoder().decode(revResult.stdout).trim() || null : null;
933
+ const symResult = gitSpawnSyncText(repository.repoRoot, ["symbolic-ref", "HEAD"]);
934
+ const revResult = gitSpawnSyncText(repository.repoRoot, ["rev-parse", "--verify", "HEAD"]);
935
+ const commit = revResult.exitCode === 0 ? revResult.stdout || null : null;
912
936
 
913
937
  if (symResult.exitCode === 0) {
914
- const ref = new TextDecoder().decode(symResult.stdout).trim();
938
+ const ref = symResult.stdout;
915
939
  const branchName = ref.startsWith(LOCAL_BRANCH_PREFIX) ? ref.slice(LOCAL_BRANCH_PREFIX.length) : null;
916
940
  return {
917
941
  ...repository,
@@ -933,29 +957,13 @@ function resolveHeadStateReftableSync(repository: GitRepository): GitHeadState |
933
957
 
934
958
  function readRefSync(repository: GitRepository, targetRef: string): string | null {
935
959
  if (isReftableRepoSync(repository)) {
936
- ensureAvailable();
937
- const symArgs = withShortLivedGitConfig(withNoOptionalLocks(["symbolic-ref", targetRef]));
938
- const symResult = Bun.spawnSync(["git", ...symArgs], {
939
- cwd: repository.repoRoot,
940
- env: buildGitEnv(),
941
- stdout: "pipe",
942
- stderr: "pipe",
943
- windowsHide: true,
944
- });
960
+ const symResult = gitSpawnSyncText(repository.repoRoot, ["symbolic-ref", targetRef]);
945
961
  if (symResult.exitCode === 0) {
946
- const stdoutText = new TextDecoder().decode(symResult.stdout).trim();
947
- return `${HEAD_REF_PREFIX} ${stdoutText}`;
962
+ return `${HEAD_REF_PREFIX} ${symResult.stdout}`;
948
963
  }
949
- const revArgs = withShortLivedGitConfig(withNoOptionalLocks(["rev-parse", "--verify", targetRef]));
950
- const revResult = Bun.spawnSync(["git", ...revArgs], {
951
- cwd: repository.repoRoot,
952
- env: buildGitEnv(),
953
- stdout: "pipe",
954
- stderr: "pipe",
955
- windowsHide: true,
956
- });
964
+ const revResult = gitSpawnSyncText(repository.repoRoot, ["rev-parse", "--verify", targetRef]);
957
965
  if (revResult.exitCode === 0) {
958
- return new TextDecoder().decode(revResult.stdout).trim() || null;
966
+ return revResult.stdout || null;
959
967
  }
960
968
  return null;
961
969
  }
@@ -4,7 +4,14 @@
4
4
  * Calls Firecrawl's search API and maps web results into the unified
5
5
  * SearchResponse shape used by the web search tool.
6
6
  */
7
- import { type ApiKey, type AuthStorage, type FetchImpl, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
7
+ import {
8
+ type AuthStorage,
9
+ type FetchImpl,
10
+ getEnvApiKey,
11
+ resolveApiKeyOnce,
12
+ seedApiKeyResolver,
13
+ withAuth,
14
+ } from "@oh-my-pi/pi-ai";
8
15
  import type { SearchResponse, SearchSource } from "../../../web/search/types";
9
16
  import { SearchProviderError } from "../../../web/search/types";
10
17
  import { clampNumResults } from "../utils";
@@ -66,13 +73,19 @@ function buildRequestBody(params: FirecrawlSearchParams): Record<string, unknown
66
73
  return body;
67
74
  }
68
75
 
69
- async function callFirecrawlSearch(apiKey: string, params: FirecrawlSearchParams): Promise<FirecrawlSearchResponse> {
76
+ async function callFirecrawlSearch(
77
+ apiKey: string | undefined,
78
+ params: FirecrawlSearchParams,
79
+ ): Promise<FirecrawlSearchResponse> {
80
+ const headers: Record<string, string> = {
81
+ "Content-Type": "application/json",
82
+ };
83
+ if (apiKey) {
84
+ headers.Authorization = `Bearer ${apiKey}`;
85
+ }
70
86
  const response = await (params.fetch ?? fetch)(FIRECRAWL_SEARCH_URL, {
71
87
  method: "POST",
72
- headers: {
73
- "Content-Type": "application/json",
74
- Authorization: `Bearer ${apiKey}`,
75
- },
88
+ headers,
76
89
  body: JSON.stringify(buildRequestBody(params)),
77
90
  signal: withHardTimeout(params.signal),
78
91
  });
@@ -100,16 +113,24 @@ export async function searchFirecrawl(params: SearchParams): Promise<SearchRespo
100
113
  signal: params.signal,
101
114
  fetch: params.fetch,
102
115
  };
103
- const keyOrResolver: ApiKey = params.authStorage.resolver("firecrawl", {
116
+ const keyResolver = params.authStorage.resolver("firecrawl", {
104
117
  sessionId: params.sessionId,
105
118
  });
106
119
  const numResults = clampNumResults(firecrawlParams.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
107
120
 
108
- const data = await withAuth(keyOrResolver, key => callFirecrawlSearch(key, firecrawlParams), {
109
- signal: params.signal,
110
- missingKeyMessage:
111
- 'Firecrawl credentials not found. Set FIRECRAWL_API_KEY or configure an API key for provider "firecrawl".',
112
- });
121
+ const resolvedKey = await resolveApiKeyOnce(keyResolver, params.signal);
122
+ let data: FirecrawlSearchResponse;
123
+ if (resolvedKey) {
124
+ // Reuse the preflight credential for the initial authenticated attempt.
125
+ const seededResolver = seedApiKeyResolver(resolvedKey, keyResolver);
126
+ data = await withAuth(seededResolver, key => callFirecrawlSearch(key, firecrawlParams), {
127
+ signal: params.signal,
128
+ });
129
+ } else {
130
+ // Keyless mode — omit Authorization header
131
+ data = await callFirecrawlSearch(undefined, firecrawlParams);
132
+ }
133
+
113
134
  const sources: SearchSource[] = [];
114
135
 
115
136
  for (const result of data.data?.web ?? []) {
@@ -125,7 +146,7 @@ export async function searchFirecrawl(params: SearchParams): Promise<SearchRespo
125
146
  provider: "firecrawl",
126
147
  sources: sources.slice(0, numResults),
127
148
  requestId: data.id ?? undefined,
128
- authMode: "api_key",
149
+ authMode: resolvedKey ? "api_key" : "keyless",
129
150
  };
130
151
  }
131
152
 
@@ -134,10 +155,22 @@ export class FirecrawlProvider extends SearchProvider {
134
155
  readonly id = "firecrawl";
135
156
  readonly label = "Firecrawl";
136
157
 
158
+ /**
159
+ * Auto-chain admission: requires a credential so an unconfigured Firecrawl
160
+ * doesn't displace other providers that the user has set up with API keys.
161
+ */
137
162
  isAvailable(authStorage: AuthStorage): boolean {
138
163
  return authStorage.hasAuth("firecrawl") || !!getEnvApiKey("firecrawl");
139
164
  }
140
165
 
166
+ /**
167
+ * Firecrawl supports keyless mode, so an explicit user selection
168
+ * (`webSearch: firecrawl`) works without any credential configured.
169
+ */
170
+ isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
171
+ return true;
172
+ }
173
+
141
174
  search(params: SearchParams): Promise<SearchResponse> {
142
175
  return searchFirecrawl(params);
143
176
  }