@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
@@ -5,6 +5,8 @@ const DELIVERY_RETRY_MAX_MS = 30_000;
5
5
  const DELIVERY_RETRY_JITTER_MS = 200;
6
6
  const DEFAULT_RETENTION_MS = 5 * 60 * 1000;
7
7
  const DEFAULT_MAX_RUNNING_JOBS = 15;
8
+ /** Abort reason used only when the owning session shuts down the entire manager. */
9
+ export const ASYNC_JOB_MANAGER_SHUTDOWN_REASON = Symbol("AsyncJobManager shutdown");
8
10
 
9
11
  /**
10
12
  * Adaptive ("smart") `hub` poll-wait ladder (ms). A tight poll loop climbs
@@ -414,11 +416,20 @@ export class AsyncJobManager {
414
416
  * Cancel running jobs. With `filter.ownerId` set, cancels only jobs the
415
417
  * matching agent registered; with no filter, cancels every running job
416
418
  * (used by `dispose()` to nuke the manager's state).
419
+ *
420
+ * `reason` is forwarded to each job's `AbortController.abort`, so a session
421
+ * teardown can tag its owned jobs with {@link ASYNC_JOB_MANAGER_SHUTDOWN_REASON}
422
+ * before `dispose()` runs — the task executor reads it to park (not
423
+ * tombstone) a subagent interrupted purely by process shutdown.
417
424
  */
418
- cancelAll(filter?: AsyncJobFilter): void {
425
+ cancelAll(filter?: AsyncJobFilter, reason?: unknown): void {
426
+ this.#cancelJobs(filter, reason);
427
+ }
428
+
429
+ #cancelJobs(filter?: AsyncJobFilter, reason?: unknown): void {
419
430
  for (const job of this.getRunningJobs(filter)) {
420
431
  job.status = "cancelled";
421
- job.abortController.abort();
432
+ job.abortController.abort(reason);
422
433
  this.#scheduleEviction(job.id);
423
434
  }
424
435
  }
@@ -584,7 +595,7 @@ export class AsyncJobManager {
584
595
  async dispose(options?: { timeoutMs?: number }): Promise<boolean> {
585
596
  this.#disposed = true;
586
597
  this.#clearEvictionTimers();
587
- this.cancelAll();
598
+ this.#cancelJobs(undefined, ASYNC_JOB_MANAGER_SHUTDOWN_REASON);
588
599
  const timeoutMs = Math.max(options?.timeoutMs ?? 3_000, 0);
589
600
  const deadline = Date.now() + timeoutMs;
590
601
  const jobsSettled = await this.#waitForAllUntil(deadline);
@@ -361,6 +361,22 @@ export const agenticFixtures: Record<string, GalleryFixture> = {
361
361
  },
362
362
  },
363
363
 
364
+ think: {
365
+ label: "Think",
366
+ // Streaming: scratchpad text still arriving.
367
+ streamingArgs: {
368
+ thoughts: "The retry loop re-reads the config after every failure — that explains the doubled latency.",
369
+ },
370
+ args: {
371
+ thoughts:
372
+ "The retry loop re-reads the config after every failure — that explains the doubled latency. Cache the parsed config outside the loop, then re-check the invalidation path before answering.",
373
+ },
374
+ result: {
375
+ content: [{ type: "text", text: "------" }],
376
+ details: { recorded: true },
377
+ },
378
+ },
379
+
364
380
  hub_jobs: {
365
381
  label: "Hub jobs",
366
382
  renderer: "hub",
@@ -464,7 +464,7 @@ async function handleInstall(
464
464
  async function handleUninstall(
465
465
  manager: PluginManager,
466
466
  packages: string[],
467
- flags: { json?: boolean; scope?: "user" | "project" },
467
+ flags: { json?: boolean; dryRun?: boolean; scope?: "user" | "project" },
468
468
  ): Promise<void> {
469
469
  if (packages.length === 0) {
470
470
  console.error(chalk.red(`Usage: ${APP_NAME} plugin uninstall <package> ...`));
@@ -477,7 +477,35 @@ async function handleUninstall(
477
477
  const installedPlugins = new Set((await mktMgr.listInstalledPlugins()).map(p => p.id));
478
478
 
479
479
  for (const name of packages) {
480
- if (installedPlugins.has(name)) {
480
+ const viaMarketplace = installedPlugins.has(name);
481
+
482
+ if (flags.dryRun) {
483
+ if (viaMarketplace) {
484
+ try {
485
+ await mktMgr.uninstallPlugin(name, flags.scope, { dryRun: true });
486
+ } catch (err) {
487
+ console.error(chalk.red(`${theme.status.error} Failed to uninstall ${name}: ${err}`));
488
+ process.exit(1);
489
+ }
490
+ }
491
+
492
+ // Marketplace dry-runs validate the requested scope before reporting.
493
+ if (flags.json) {
494
+ console.log(
495
+ JSON.stringify({
496
+ dryRun: true,
497
+ action: "uninstall",
498
+ plugin: name,
499
+ source: viaMarketplace ? "marketplace" : "npm",
500
+ }),
501
+ );
502
+ } else {
503
+ console.log(chalk.dim(`[dry-run] Would uninstall ${name}`));
504
+ }
505
+ continue;
506
+ }
507
+
508
+ if (viaMarketplace) {
481
509
  // Exact match against installed marketplace plugin IDs (name@marketplace)
482
510
  try {
483
511
  await mktMgr.uninstallPlugin(name, flags.scope);
@@ -63,9 +63,14 @@ function currentNativeTag(): string {
63
63
  return `${process.platform}-${process.arch}`;
64
64
  }
65
65
 
66
+ /** Distribution channel advertised by a release's published npm manifest. */
67
+ export type ReleaseDist = "npm" | "binary";
68
+
66
69
  interface ReleaseInfo {
67
70
  tag: string;
68
71
  version: string;
72
+ /** Parsed `omp.dist` from the registry manifest; undefined when absent. */
73
+ dist?: ReleaseDist;
69
74
  }
70
75
 
71
76
  export interface ReleaseBinaryAsset {
@@ -80,6 +85,47 @@ function isRecord(value: unknown): value is Record<string, unknown> {
80
85
  return typeof value === "object" && value !== null;
81
86
  }
82
87
 
88
+ /**
89
+ * Parse the `omp.dist` field from a published package manifest.
90
+ *
91
+ * Forward-compatibility contract with future releases: a release that is not
92
+ * installable as an npm package (e.g. a native rewrite) publishes
93
+ * `"omp": { "dist": "binary" }` in its package.json. Any value other than
94
+ * "npm" — including values this updater does not know yet — maps to "binary"
95
+ * so already-deployed updaters never run a package-manager install against a
96
+ * release that no longer supports it.
97
+ */
98
+ export function resolveReleaseDist(manifest: unknown): ReleaseDist | undefined {
99
+ if (!isRecord(manifest) || !isRecord(manifest.omp)) return undefined;
100
+ const dist = manifest.omp.dist;
101
+ if (dist === undefined) return undefined;
102
+ return dist === "npm" ? "npm" : "binary";
103
+ }
104
+
105
+ function majorVersion(version: string): number {
106
+ const major = Number.parseInt(version, 10);
107
+ return Number.isNaN(major) ? 0 : major;
108
+ }
109
+
110
+ /**
111
+ * Whether the update must bypass bun/npm and install the release binary.
112
+ *
113
+ * An explicit `omp.dist` wins in both directions. Without one, a release with
114
+ * a higher major than the running build is assumed not npm-installable: the
115
+ * runtime may have changed out from under the package layout, and the pinned
116
+ * `@oh-my-pi/pi-natives*` companions ({@link buildBunInstallArgs}) may not
117
+ * exist at that version, which would strand bun/npm-managed installs behind a
118
+ * hard install failure. Homebrew and mise installs are unaffected — both
119
+ * already pull GitHub release binaries.
120
+ */
121
+ export function shouldForceBinaryUpdate(
122
+ release: { version: string; dist?: ReleaseDist },
123
+ currentVersion: string = VERSION,
124
+ ): boolean {
125
+ if (release.dist !== undefined) return release.dist === "binary";
126
+ return majorVersion(release.version) > majorVersion(currentVersion);
127
+ }
128
+
83
129
  /**
84
130
  * Select and validate the binary asset from GitHub release metadata.
85
131
  */
@@ -394,9 +440,9 @@ interface UpdateMethodResolutionOptions {
394
440
  type UpdateTarget =
395
441
  | { method: "brew" }
396
442
  | { method: "mise" }
397
- | { method: "bun" }
398
- | { method: "npm" }
399
- | { method: "binary"; path: string };
443
+ | { method: "bun"; path?: string }
444
+ | { method: "npm"; path?: string }
445
+ | { method: "binary"; path: string; replacesSymlink: boolean };
400
446
 
401
447
  function resolveUpdateMethod(
402
448
  ompPath: string,
@@ -433,9 +479,18 @@ export function resolveUpdateMethodForTest(
433
479
  ): UpdateMethod {
434
480
  return resolveUpdateMethod(ompPath, bunBinDir, options);
435
481
  }
436
- async function resolveUpdateTarget(): Promise<UpdateTarget> {
437
- const bunBinDir = await getBunGlobalBinDir();
438
- const npmBinDir = await getNpmGlobalBinDir();
482
+ /**
483
+ * Resolve how the running install should be updated.
484
+ *
485
+ * `allowPackageManagers: false` skips the `bun pm bin -g` / `npm prefix -g`
486
+ * probes entirely — used for binary-only releases, where routing through a
487
+ * package manager is never valid and the probes would be wasted subprocesses.
488
+ * Homebrew/mise detection always runs: both managers install GitHub release
489
+ * binaries and stay valid regardless of how the release is distributed.
490
+ */
491
+ async function resolveUpdateTarget(options: { allowPackageManagers: boolean }): Promise<UpdateTarget> {
492
+ const bunBinDir = options.allowPackageManagers ? await getBunGlobalBinDir() : undefined;
493
+ const npmBinDir = options.allowPackageManagers ? await getNpmGlobalBinDir() : undefined;
439
494
  const homebrewPrefix = await getHomebrewFormulaPrefix();
440
495
  const miseAvailable = $which("mise") !== undefined;
441
496
  const miseBinDirs = miseAvailable ? await getMiseBinDirs() : [];
@@ -448,9 +503,11 @@ async function resolveUpdateTarget(): Promise<UpdateTarget> {
448
503
  // overlaps the installer's default (~/.local/bin), that file type — not
449
504
  // directory containment — distinguishes a binary install from npm/bun.
450
505
  let ompIsRegularFile = false;
506
+ let ompIsSymlink = false;
451
507
  try {
452
508
  const stat = fs.lstatSync(ompPath);
453
509
  ompIsRegularFile = stat.isFile() && !stat.isSymbolicLink();
510
+ ompIsSymlink = stat.isSymbolicLink();
454
511
  } catch {}
455
512
  const method = resolveUpdateMethod(ompPath, bunBinDir, {
456
513
  homebrewPrefix,
@@ -459,7 +516,8 @@ async function resolveUpdateTarget(): Promise<UpdateTarget> {
459
516
  npmBinDir,
460
517
  ompIsRegularFile,
461
518
  });
462
- if (method === "binary") return { method, path: ompPath };
519
+ if (method === "binary") return { method, path: ompPath, replacesSymlink: ompIsSymlink };
520
+ if (method === "bun" || method === "npm") return { method, path: ompPath };
463
521
  return { method };
464
522
  }
465
523
 
@@ -488,13 +546,16 @@ async function getLatestRelease(): Promise<ReleaseInfo> {
488
546
  throw new Error(`Failed to fetch release info: ${response.statusText}`);
489
547
  }
490
548
 
491
- const data = (await response.json()) as { version: string };
549
+ const data: unknown = await response.json();
550
+ if (!isRecord(data) || typeof data.version !== "string") {
551
+ throw new Error("Malformed npm registry response: missing version");
552
+ }
492
553
  const version = data.version;
493
- const tag = `v${version}`;
494
554
 
495
555
  return {
496
- tag,
556
+ tag: `v${version}`,
497
557
  version,
558
+ dist: resolveReleaseDist(data),
498
559
  };
499
560
  }
500
561
 
@@ -788,24 +849,31 @@ function resolveOmpPath(): string | undefined {
788
849
  }
789
850
 
790
851
  /**
791
- * Run the resolved omp binary and check if it reports the expected version.
852
+ * Run a specific binary and check if it reports the expected version.
792
853
  */
793
- async function verifyInstalledVersion(expectedVersion: string): Promise<InstalledVersionVerification> {
794
- const ompPath = resolveOmpPath();
795
- if (!ompPath) return { ok: false };
854
+ async function verifyBinaryAtPath(binaryPath: string, expectedVersion: string): Promise<InstalledVersionVerification> {
796
855
  try {
797
- const result = await $`${ompPath} --version`.quiet().nothrow();
798
- if (result.exitCode !== 0) return { ok: false, path: ompPath };
856
+ const result = await $`${binaryPath} --version`.quiet().nothrow();
857
+ if (result.exitCode !== 0) return { ok: false, path: binaryPath };
799
858
  const output = result.text().trim();
800
859
  // Output format: "omp/X.Y.Z"
801
860
  const match = output.match(/\/(\d+\.\d+\.\d+)/);
802
861
  const actual = match?.[1];
803
- return { ok: actual === expectedVersion, actual, path: ompPath };
862
+ return { ok: actual === expectedVersion, actual, path: binaryPath };
804
863
  } catch {
805
- return { ok: false, path: ompPath };
864
+ return { ok: false, path: binaryPath };
806
865
  }
807
866
  }
808
867
 
868
+ /**
869
+ * Run the PATH-resolved omp binary and check if it reports the expected version.
870
+ */
871
+ async function verifyInstalledVersion(expectedVersion: string): Promise<InstalledVersionVerification> {
872
+ const ompPath = resolveOmpPath();
873
+ if (!ompPath) return { ok: false };
874
+ return await verifyBinaryAtPath(ompPath, expectedVersion);
875
+ }
876
+
809
877
  function printVerifiedVersion(expectedVersion: string): void {
810
878
  console.log(chalk.green(`\n${theme.status.success} Updated to ${expectedVersion}`));
811
879
  }
@@ -1108,6 +1176,145 @@ export async function updateViaBinaryAt(
1108
1176
  console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`));
1109
1177
  }
1110
1178
 
1179
+ /**
1180
+ * In-place forwarder bodies, by shim extension, for launchers that cannot be
1181
+ * renamed aside during a script-shim takeover; each execs the sibling
1182
+ * `omp.exe`. Rewriting matters for the shims that outrank `.exe` at command
1183
+ * resolution: PowerShell prefers `.ps1` and Git Bash resolves the
1184
+ * extensionless sh shim first, so leaving the old body behind would keep
1185
+ * launching the replaced install.
1186
+ */
1187
+ const SHIM_FORWARDERS: Record<string, string> = {
1188
+ "": `#!/bin/sh\nexec "$(dirname "$0")/${APP_NAME}.exe" "$@"\n`,
1189
+ ".cmd": `@"%~dp0${APP_NAME}.exe" %*\r\n`,
1190
+ ".bat": `@"%~dp0${APP_NAME}.exe" %*\r\n`,
1191
+ ".ps1": `& "$PSScriptRoot\\${APP_NAME}.exe" @args\nexit $LASTEXITCODE\n`,
1192
+ };
1193
+
1194
+ /**
1195
+ * Take over a Windows script-launcher install for a binary-only release.
1196
+ *
1197
+ * npm-managed Windows installs are launched through script shims
1198
+ * (`omp`/`omp.cmd`/`omp.ps1`) that cannot be overwritten with a native
1199
+ * executable. The release binary is installed as `omp.exe` beside them and
1200
+ * the shims are then renamed aside: cmd.exe would already prefer `.exe` via
1201
+ * PATHEXT, but PowerShell resolves `.ps1` first, so the takeover only sticks
1202
+ * once the shims are out of the way. A working launcher exists at every
1203
+ * step — the exe lands before any shim moves, a shim that refuses to move
1204
+ * (a running `.cmd` can be renamed but may be held open some other way) is
1205
+ * rewritten in place as a forwarder to the exe, and a failed version
1206
+ * verification moves everything back.
1207
+ */
1208
+ export async function updateViaShimTakeover(
1209
+ shimPath: string,
1210
+ expectedVersion: string,
1211
+ options: {
1212
+ binaryName?: string;
1213
+ fetchImpl?: Fetch;
1214
+ githubToken?: string;
1215
+ verifyBinary?: typeof verifyBinaryAtPath;
1216
+ } = {},
1217
+ ): Promise<void> {
1218
+ const binaryName = options.binaryName ?? getBinaryName();
1219
+ const launcherDir = path.dirname(shimPath);
1220
+ const exePath = path.join(launcherDir, `${APP_NAME}.exe`);
1221
+ const tempPath = `${exePath}.new`;
1222
+ const asset = await getReleaseBinaryAsset(expectedVersion, binaryName, options.fetchImpl, options.githubToken);
1223
+ console.log(chalk.dim(`Downloading ${binaryName}…`));
1224
+ await downloadVerifiedBinary({
1225
+ url: asset.url,
1226
+ targetPath: tempPath,
1227
+ expectedSize: asset.size,
1228
+ expectedDigest: asset.digest,
1229
+ fetchImpl: options.fetchImpl,
1230
+ });
1231
+ console.log(chalk.dim(`Verified ${asset.digest}`));
1232
+
1233
+ console.log(chalk.dim(`Installing ${APP_NAME}.exe beside the script launcher...`));
1234
+ await fs.promises.rename(tempPath, exePath);
1235
+ // Retire the shims so PATH resolution lands on the new exe. Renamed, not
1236
+ // deleted: restorable on verification failure, and Windows permits
1237
+ // renaming a batch file that is still executing. A shim that cannot be
1238
+ // renamed (held open without delete sharing) is rewritten in place as a
1239
+ // forwarder to the exe — write and rename take different Windows locks,
1240
+ // so one can succeed where the other fails.
1241
+ const backupSuffix = `${Date.now()}.${process.pid}.bak`;
1242
+ const retired: Array<{ launcher: string; backup: string }> = [];
1243
+ const forwarded: Array<{ launcher: string; original: string }> = [];
1244
+ const stuck: string[] = [];
1245
+ for (const ext of ["", ".cmd", ".ps1", ".bat"]) {
1246
+ const launcher = path.join(launcherDir, `${APP_NAME}${ext}`);
1247
+ const backup = `${launcher}.${backupSuffix}`;
1248
+ try {
1249
+ await fs.promises.rename(launcher, backup);
1250
+ retired.push({ launcher, backup });
1251
+ } catch (err) {
1252
+ if (isEnoent(err)) continue;
1253
+ try {
1254
+ const original = await Bun.file(launcher).text();
1255
+ await Bun.write(launcher, SHIM_FORWARDERS[ext]);
1256
+ forwarded.push({ launcher, original });
1257
+ } catch {
1258
+ stuck.push(launcher);
1259
+ }
1260
+ }
1261
+ }
1262
+
1263
+ // Verify the exe by its explicit path: $which cached the shim path when
1264
+ // the update target was resolved, and the shim was just renamed away, so
1265
+ // a PATH re-resolution here would test a file that no longer exists.
1266
+ const verify = options.verifyBinary ?? verifyBinaryAtPath;
1267
+ const verification = await verify(exePath, expectedVersion);
1268
+ if (!verification.ok) {
1269
+ for (const { launcher, backup } of retired) {
1270
+ try {
1271
+ await fs.promises.rename(backup, launcher);
1272
+ } catch {}
1273
+ }
1274
+ for (const { launcher, original } of forwarded) {
1275
+ try {
1276
+ await Bun.write(launcher, original);
1277
+ } catch {}
1278
+ }
1279
+ await unlinkIfExists(exePath);
1280
+ throw new Error(
1281
+ `${formatVerificationFailure(verification, expectedVersion)}; restored previous ${APP_NAME} launcher`,
1282
+ );
1283
+ }
1284
+ for (const { backup } of retired) {
1285
+ await removeBackupBestEffort(backup);
1286
+ }
1287
+ // Reclaim exe backups and retired-shim leftovers from earlier attempts.
1288
+ for (const ext of [".exe", "", ".cmd", ".ps1", ".bat"]) {
1289
+ await sweepStaleBackups(path.join(launcherDir, `${APP_NAME}${ext}`));
1290
+ }
1291
+ for (const { launcher } of forwarded) {
1292
+ console.log(chalk.dim(`Converted ${launcher} to a forwarder (it could not be removed).`));
1293
+ }
1294
+ for (const launcher of stuck) {
1295
+ console.log(
1296
+ chalk.yellow(
1297
+ `Could not retire ${launcher}; shells that prefer it may keep launching the old version until it is deleted manually.`,
1298
+ ),
1299
+ );
1300
+ }
1301
+ printVerifiedVersion(expectedVersion);
1302
+ console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`));
1303
+ }
1304
+
1305
+ /**
1306
+ * Platform-appropriate installer one-liner for recovery instructions.
1307
+ *
1308
+ * Forces the installer's binary mode (`--binary` / `-Binary`): the default
1309
+ * mode prefers a bun-based install whenever bun is present, which would send
1310
+ * a user recovering from a binary-only release straight back through bun.
1311
+ */
1312
+ function installerHint(): string {
1313
+ return process.platform === "win32"
1314
+ ? "& ([scriptblock]::Create((irm https://omp.sh/install.ps1))) -Binary"
1315
+ : "curl -fsSL https://omp.sh/install | sh -s -- --binary";
1316
+ }
1317
+
1111
1318
  /**
1112
1319
  * Run the update command.
1113
1320
  */
@@ -1141,19 +1348,47 @@ export async function runUpdateCommand(opts: { force: boolean; check: boolean })
1141
1348
  return;
1142
1349
  }
1143
1350
 
1144
- // Choose update method based on the prioritized omp binary in PATH
1351
+ // Choose update method based on the prioritized omp binary in PATH. For
1352
+ // binary-only releases the package managers are never consulted: a bun/npm
1353
+ // symlink resolves to method "binary" and is replaced in place, keeping the
1354
+ // same PATH entry live.
1145
1355
  try {
1146
- const target = await resolveUpdateTarget();
1356
+ const forceBinary = shouldForceBinaryUpdate(release);
1357
+ const target = await resolveUpdateTarget({ allowPackageManagers: !forceBinary });
1147
1358
  if (target.method === "brew") {
1148
1359
  await updateViaHomebrew(release.version, opts.force);
1149
1360
  } else if (target.method === "mise") {
1150
1361
  await updateViaMise(release.version, opts.force);
1151
- } else if (target.method === "bun") {
1152
- await updateViaBun(release.version);
1153
- } else if (target.method === "npm") {
1154
- await updateViaNpm(release.version);
1362
+ } else if (target.method === "bun" || target.method === "npm") {
1363
+ if (forceBinary) {
1364
+ // Reachable in forced mode only through a Windows script
1365
+ // launcher resolved from PATH (the bun/npm bin-dir probes are
1366
+ // skipped), so the launcher path is always known.
1367
+ if (!target.path) throw new Error(`Could not resolve ${APP_NAME} launcher path in PATH`);
1368
+ console.log(chalk.dim("This release ships as a standalone binary; replacing the script launcher."));
1369
+ await updateViaShimTakeover(target.path, release.version);
1370
+ console.log(
1371
+ chalk.yellow(
1372
+ `This install is no longer managed by ${target.method}. Removing the old global package may delete this launcher; if it does, reinstall with: ${installerHint()}`,
1373
+ ),
1374
+ );
1375
+ } else if (target.method === "bun") {
1376
+ await updateViaBun(release.version);
1377
+ } else {
1378
+ await updateViaNpm(release.version);
1379
+ }
1155
1380
  } else {
1381
+ if (forceBinary && target.replacesSymlink) {
1382
+ console.log(chalk.dim("Replacing the package-manager launcher with the standalone binary."));
1383
+ }
1156
1384
  await updateViaBinaryAt(target.path, release.version);
1385
+ if (forceBinary && target.replacesSymlink) {
1386
+ console.log(
1387
+ chalk.yellow(
1388
+ `This install is no longer managed by bun/npm. Removing the old global package may delete this launcher; if it does, reinstall with: ${installerHint()}`,
1389
+ ),
1390
+ );
1391
+ }
1157
1392
  }
1158
1393
  } catch (err) {
1159
1394
  console.error(chalk.red(`Update failed: ${err}`));
@@ -654,12 +654,52 @@ export class KeybindingsManager extends TuiKeybindingsManager {
654
654
 
655
655
  /**
656
656
  * Key hint formatting utilities for UI labels.
657
+ *
658
+ * Modifier labels are platform-aware: macOS names the physical keys `Option`
659
+ * (`alt`) and `Cmd` (`super`), so rendering `Alt`/`Super` there would name keys
660
+ * absent from a Mac keyboard. Every other platform keeps `Alt`/`Super`.
657
661
  */
658
- const MODIFIER_LABELS: Record<string, string> = {
659
- ctrl: "Ctrl",
660
- shift: "Shift",
661
- alt: "Alt",
662
- };
662
+
663
+ /**
664
+ * Platform override for key-hint rendering; `undefined` resolves to the host
665
+ * `process.platform`. Mirrors `setKittyProtocolActive` in the TUI keys module:
666
+ * a single seam that keeps hint output deterministic in tests without mutating
667
+ * the global `process.platform`.
668
+ */
669
+ let keyHintPlatformOverride: NodeJS.Platform | undefined;
670
+
671
+ /** Pin the platform used to render modifier labels (test seam). */
672
+ export function setKeyHintPlatform(platform: NodeJS.Platform | undefined): void {
673
+ keyHintPlatformOverride = platform;
674
+ }
675
+
676
+ /** Platform currently used for key-hint rendering. */
677
+ export function keyHintPlatform(): NodeJS.Platform {
678
+ return keyHintPlatformOverride ?? process.platform;
679
+ }
680
+
681
+ type Modifier = "ctrl" | "shift" | "alt" | "super";
682
+
683
+ function isModifier(part: string): part is Modifier {
684
+ return part === "ctrl" || part === "shift" || part === "alt" || part === "super";
685
+ }
686
+
687
+ /**
688
+ * Human label for a modifier, using each platform's own key names. `ctrl` and
689
+ * `shift` are the same everywhere; `alt`/`super` become `Option`/`Cmd` on macOS.
690
+ */
691
+ export function modifierLabel(mod: Modifier, platform: NodeJS.Platform = keyHintPlatform()): string {
692
+ switch (mod) {
693
+ case "ctrl":
694
+ return "Ctrl";
695
+ case "shift":
696
+ return "Shift";
697
+ case "alt":
698
+ return platform === "darwin" ? "Option" : "Alt";
699
+ case "super":
700
+ return platform === "darwin" ? "Cmd" : "Super";
701
+ }
702
+ }
663
703
 
664
704
  const KEY_LABELS: Record<string, string> = {
665
705
  esc: "Esc",
@@ -680,10 +720,9 @@ const KEY_LABELS: Record<string, string> = {
680
720
  right: "Right",
681
721
  };
682
722
 
683
- function formatKeyPart(part: string): string {
723
+ function formatKeyPart(part: string, platform: NodeJS.Platform): string {
684
724
  const lower = part.toLowerCase();
685
- const modifier = MODIFIER_LABELS[lower];
686
- if (modifier) return modifier;
725
+ if (isModifier(lower)) return modifierLabel(lower, platform);
687
726
  const label = KEY_LABELS[lower];
688
727
  if (label) return label;
689
728
  if (part.length === 1) return part.toUpperCase();
@@ -691,7 +730,11 @@ function formatKeyPart(part: string): string {
691
730
  }
692
731
 
693
732
  export function formatKeyHint(key: KeyId): string {
694
- return key.split("+").map(formatKeyPart).join("+");
733
+ const platform = keyHintPlatform();
734
+ return key
735
+ .split("+")
736
+ .map(part => formatKeyPart(part, platform))
737
+ .join("+");
695
738
  }
696
739
 
697
740
  export function formatKeyHints(keys: KeyId | KeyId[]): string {
@@ -1138,14 +1138,30 @@ function resolveEffectiveAgentModelSelection(
1138
1138
  return { patterns: resolveConfiguredModelPatterns(fallback, settings) };
1139
1139
  }
1140
1140
 
1141
- /** Return the raw selector source that supplies the effective agent patterns. */
1142
- export function resolveAgentModelSource(options: AgentModelPatternResolutionOptions): string | string[] | undefined {
1143
- return resolveEffectiveAgentModelSelection(options).source;
1141
+ /** Effective agent model patterns paired with the pre-expansion role alias behind them. */
1142
+ export interface AgentModelSelection {
1143
+ /** Expanded model patterns to spawn with. */
1144
+ patterns: string[];
1145
+ /** Role alias the patterns came from (`@task` -> `task`), when the source named one. */
1146
+ role: string | undefined;
1147
+ }
1148
+
1149
+ /**
1150
+ * Resolve an agent's model patterns together with the role identity they were
1151
+ * expanded from. Spawn paths MUST take both from this single call: the child's
1152
+ * inherited retry-fallback chain is keyed off the role, which the expansion
1153
+ * discards, and deriving the two halves separately is how they drift apart.
1154
+ */
1155
+ export function resolveAgentModelSelection(options: AgentModelPatternResolutionOptions): AgentModelSelection {
1156
+ const { source, patterns } = resolveEffectiveAgentModelSelection(options);
1157
+ return { patterns, role: resolveExplicitModelRole(source, options.settings) };
1144
1158
  }
1145
1159
 
1160
+ /** Effective agent model patterns alone, for callers with no interest in role identity. */
1146
1161
  export function resolveAgentModelPatterns(options: AgentModelPatternResolutionOptions): string[] {
1147
1162
  return resolveEffectiveAgentModelSelection(options).patterns;
1148
1163
  }
1164
+
1149
1165
  /** Default prewalk hand-off target when no explicit target is configured. */
1150
1166
  export const DEFAULT_PREWALK_TARGET = "@smol";
1151
1167
 
@@ -1139,6 +1139,17 @@ export const SETTINGS_SCHEMA = {
1139
1139
  },
1140
1140
  },
1141
1141
 
1142
+ externalThinking: {
1143
+ type: "boolean",
1144
+ default: false,
1145
+ ui: {
1146
+ tab: "model",
1147
+ group: "Thinking",
1148
+ label: "External Thinking",
1149
+ description: "Use a private think tool and send reasoning effort off to GPT Responses models",
1150
+ },
1151
+ },
1152
+
1142
1153
  "model.loopGuard.enabled": {
1143
1154
  type: "boolean",
1144
1155
  default: true,
@@ -5471,6 +5482,11 @@ export const SETTINGS_SCHEMA = {
5471
5482
  default: undefined,
5472
5483
  },
5473
5484
 
5485
+ "searxng.safesearch": {
5486
+ type: "number",
5487
+ default: undefined,
5488
+ },
5489
+
5474
5490
  "commit.mapReduceEnabled": { type: "boolean", default: true },
5475
5491
 
5476
5492
  "commit.mapReduceMinFiles": { type: "number", default: 4 },