@oh-my-pi/pi-coding-agent 17.3.5 → 17.3.8

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 (129) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/{CHANGELOG-tt9k4jpr.md → CHANGELOG-vr9cckb4.md} +80 -0
  3. package/dist/cli.js +2993 -3001
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/{tool-views.generated-jdfmzwmn.js → tool-views.generated-dd2km5r2.js} +19 -19
  6. package/dist/types/advisor/advise-tool.d.ts +4 -2
  7. package/dist/types/cli/auth-broker-cli.d.ts +15 -0
  8. package/dist/types/cli/stats-cli.d.ts +1 -6
  9. package/dist/types/cli/update-cli.d.ts +8 -0
  10. package/dist/types/cli-commands.d.ts +10 -2
  11. package/dist/types/commands/stats.d.ts +4 -0
  12. package/dist/types/config/settings-schema.d.ts +28 -0
  13. package/dist/types/config/settings.d.ts +9 -0
  14. package/dist/types/extensibility/extensions/runner.d.ts +23 -4
  15. package/dist/types/extensibility/extensions/types.d.ts +58 -0
  16. package/dist/types/launch/presence.d.ts +4 -1
  17. package/dist/types/mcp/oauth-credentials.d.ts +23 -0
  18. package/dist/types/mcp/oauth-flow.d.ts +11 -0
  19. package/dist/types/mnemopi/backend.d.ts +12 -0
  20. package/dist/types/modes/components/tool-execution.d.ts +12 -0
  21. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  22. package/dist/types/modes/interactive-mode.d.ts +25 -3
  23. package/dist/types/modes/types.d.ts +10 -0
  24. package/dist/types/session/agent-session.d.ts +3 -0
  25. package/dist/types/session/prewalk.d.ts +4 -0
  26. package/dist/types/session/session-entries.d.ts +0 -1
  27. package/dist/types/session/session-manager.d.ts +12 -0
  28. package/dist/types/session/session-stats.d.ts +13 -1
  29. package/dist/types/session/skill-title-input.d.ts +13 -0
  30. package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +1 -0
  31. package/dist/types/subprocess/worker-client.d.ts +7 -4
  32. package/dist/types/task/label.d.ts +2 -0
  33. package/dist/types/task/render.d.ts +2 -0
  34. package/dist/types/tiny/completion-prompt.d.ts +2 -0
  35. package/dist/types/tiny/title-client.d.ts +6 -4
  36. package/dist/types/tiny/title-protocol.d.ts +1 -0
  37. package/dist/types/tiny/worker.d.ts +27 -0
  38. package/dist/types/tools/bash.d.ts +1 -1
  39. package/dist/types/tools/file-write-fallback.d.ts +124 -0
  40. package/dist/types/tools/index.d.ts +1 -0
  41. package/dist/types/tools/path-utils.d.ts +23 -0
  42. package/dist/types/tools/read-format.d.ts +6 -0
  43. package/dist/types/tools/read-summary.d.ts +7 -1
  44. package/dist/types/utils/block-context.d.ts +14 -0
  45. package/dist/types/utils/fetch-timeout.d.ts +15 -0
  46. package/dist/types/utils/git.d.ts +25 -1
  47. package/dist/types/web/search/providers/tinyfish.d.ts +4 -0
  48. package/package.json +13 -13
  49. package/src/advisor/advise-tool.ts +5 -3
  50. package/src/cli/auth-broker-cli.ts +36 -1
  51. package/src/cli/profile-bootstrap.ts +2 -6
  52. package/src/cli/stats-cli.ts +6 -72
  53. package/src/cli/update-cli.ts +63 -11
  54. package/src/cli-commands.ts +61 -7
  55. package/src/commands/completions.ts +2 -1
  56. package/src/commands/stats.ts +7 -4
  57. package/src/commit/agentic/index.ts +15 -2
  58. package/src/commit/git/diff.ts +6 -2
  59. package/src/config/model-resolver.ts +52 -6
  60. package/src/config/models-config.ts +2 -2
  61. package/src/config/settings-schema.ts +33 -0
  62. package/src/config/settings.ts +159 -30
  63. package/src/discovery/helpers.ts +45 -2
  64. package/src/discovery/omp-plugins.ts +2 -1
  65. package/src/discovery/opencode.ts +56 -3
  66. package/src/edit/hashline/filesystem.ts +9 -3
  67. package/src/edit/modes/patch.ts +31 -5
  68. package/src/eval/js/process-entry.ts +4 -4
  69. package/src/export/html/tool-views.generated.js +19 -19
  70. package/src/extensibility/extensions/loader.ts +11 -0
  71. package/src/extensibility/extensions/runner.ts +118 -5
  72. package/src/extensibility/extensions/types.ts +60 -0
  73. package/src/extensibility/extensions/wrapper.ts +10 -1
  74. package/src/extensibility/plugins/legacy-pi-compat.ts +47 -0
  75. package/src/launch/client.ts +9 -4
  76. package/src/launch/presence.ts +19 -4
  77. package/src/lsp/defaults.json +1 -1
  78. package/src/lsp/writethrough.ts +20 -10
  79. package/src/mcp/manager.ts +41 -20
  80. package/src/mcp/oauth-credentials.ts +38 -0
  81. package/src/mcp/oauth-flow.ts +21 -0
  82. package/src/mcp/tool-bridge.ts +32 -16
  83. package/src/mnemopi/backend.ts +35 -3
  84. package/src/modes/components/model-hub.ts +37 -4
  85. package/src/modes/components/settings-selector.ts +17 -11
  86. package/src/modes/components/tool-execution.ts +97 -29
  87. package/src/modes/components/tree-selector.ts +7 -2
  88. package/src/modes/controllers/event-controller.ts +12 -2
  89. package/src/modes/controllers/input-controller.ts +64 -27
  90. package/src/modes/controllers/mcp-command-controller.ts +13 -4
  91. package/src/modes/interactive-mode.ts +79 -11
  92. package/src/modes/types.ts +11 -0
  93. package/src/prompts/system/memory-extraction-system.md +5 -22
  94. package/src/prompts/system/system-prompt.md +1 -1
  95. package/src/session/agent-session.ts +52 -6
  96. package/src/session/messages.ts +6 -0
  97. package/src/session/prewalk.ts +25 -7
  98. package/src/session/session-entries.ts +0 -1
  99. package/src/session/session-maintenance.ts +10 -1
  100. package/src/session/session-manager.ts +15 -0
  101. package/src/session/session-stats.ts +24 -3
  102. package/src/session/settings-stream-fn.ts +7 -0
  103. package/src/session/skill-title-input.ts +32 -0
  104. package/src/session/turn-recovery.ts +23 -18
  105. package/src/slash-commands/builtin-session.ts +1 -1
  106. package/src/slash-commands/helpers/stats-dashboard.ts +23 -9
  107. package/src/subprocess/worker-client.ts +8 -5
  108. package/src/task/executor.ts +11 -0
  109. package/src/task/index.ts +2 -0
  110. package/src/task/label.ts +14 -1
  111. package/src/task/persisted-revive.ts +13 -0
  112. package/src/task/render.ts +1 -1
  113. package/src/task/structured-subagent.ts +5 -2
  114. package/src/tiny/completion-prompt.ts +16 -0
  115. package/src/tiny/title-client.ts +15 -6
  116. package/src/tiny/title-protocol.ts +8 -1
  117. package/src/tiny/worker.ts +21 -19
  118. package/src/tools/bash.ts +7 -1
  119. package/src/tools/file-write-fallback.ts +467 -0
  120. package/src/tools/index.ts +1 -0
  121. package/src/tools/path-utils.ts +79 -0
  122. package/src/tools/read-format.ts +16 -2
  123. package/src/tools/read-summary.ts +9 -4
  124. package/src/tools/read.ts +306 -72
  125. package/src/utils/block-context.ts +15 -1
  126. package/src/utils/fetch-timeout.ts +33 -0
  127. package/src/utils/git.ts +54 -11
  128. package/src/web/search/providers/browser-page.ts +21 -3
  129. package/src/web/search/providers/tinyfish.ts +26 -0
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { truncateToWidth } from "@oh-my-pi/pi-tui/utils";
8
- import { APP_NAME, formatDuration, formatNumber, formatPercent } from "@oh-my-pi/pi-utils";
8
+ import { formatDuration, formatNumber, formatPercent } from "@oh-my-pi/pi-utils";
9
9
  import chalk from "@oh-my-pi/pi-utils/chalk";
10
10
  import { openPath } from "../utils/open";
11
11
 
@@ -57,45 +57,11 @@ function shortenSessionFile(p: string): string {
57
57
 
58
58
  export interface StatsCommandArgs {
59
59
  port: number;
60
+ host: string;
60
61
  json: boolean;
61
62
  summary: boolean;
62
63
  }
63
64
 
64
- // =============================================================================
65
- // Argument Parser
66
- // =============================================================================
67
-
68
- /**
69
- * Parse stats subcommand arguments.
70
- * Returns undefined if not a stats command.
71
- */
72
- export function parseStatsArgs(args: string[]): StatsCommandArgs | undefined {
73
- if (args.length === 0 || args[0] !== "stats") {
74
- return undefined;
75
- }
76
-
77
- const result: StatsCommandArgs = {
78
- port: 3847,
79
- json: false,
80
- summary: false,
81
- };
82
-
83
- for (let i = 1; i < args.length; i++) {
84
- const arg = args[i];
85
- if (arg === "--json" || arg === "-j") {
86
- result.json = true;
87
- } else if (arg === "--summary" || arg === "-s") {
88
- result.summary = true;
89
- } else if ((arg === "--port" || arg === "-p") && i + 1 < args.length) {
90
- result.port = parseInt(args[++i], 10);
91
- } else if (arg.startsWith("--port=")) {
92
- result.port = parseInt(arg.split("=")[1], 10);
93
- }
94
- }
95
-
96
- return result;
97
- }
98
-
99
65
  function formatCost(n: number): string {
100
66
  if (n < 0.01) return `$${n.toFixed(4)}`;
101
67
  if (n < 1) return `$${n.toFixed(3)}`;
@@ -112,9 +78,8 @@ function normalizePremiumRequests(n: number): number {
112
78
 
113
79
  export async function runStatsCommand(cmd: StatsCommandArgs): Promise<void> {
114
80
  // Lazy import to avoid loading stats module when not needed
115
- const { getDashboardStats, syncAllSessions, getTotalMessageCount, startServer, closeDb } = await import(
116
- "@oh-my-pi/omp-stats"
117
- );
81
+ const { closeDb, formatStatsDashboardUrl, getDashboardStats, getTotalMessageCount, startServer, syncAllSessions } =
82
+ await import("@oh-my-pi/omp-stats");
118
83
 
119
84
  // Sync session files first
120
85
  const progress = createSyncProgressReporter();
@@ -136,8 +101,8 @@ export async function runStatsCommand(cmd: StatsCommandArgs): Promise<void> {
136
101
  }
137
102
 
138
103
  // Start the dashboard server
139
- const { hostname, port } = await startServer(cmd.port);
140
- const url = `http://${hostname}:${port}`;
104
+ const { hostname, port } = await startServer(cmd.port, cmd.host);
105
+ const url = formatStatsDashboardUrl(hostname, port);
141
106
  console.log(chalk.green(`Dashboard available at: ${url}`));
142
107
 
143
108
  // Open browser
@@ -197,34 +162,3 @@ async function printStatsSummary(): Promise<void> {
197
162
 
198
163
  console.log("");
199
164
  }
200
-
201
- // =============================================================================
202
- // Help
203
- // =============================================================================
204
-
205
- export function printStatsHelp(): void {
206
- console.log(`${chalk.bold(`${APP_NAME} stats`)} - AI Usage Statistics Dashboard
207
-
208
- ${chalk.bold("Usage:")}
209
- ${APP_NAME} stats [options]
210
-
211
- ${chalk.bold("Options:")}
212
- -p, --port <port> Port for the dashboard server (default: 3847)
213
- -j, --json Output stats as JSON and exit
214
- -s, --summary Print summary to console and exit
215
- -h, --help Show this help message
216
-
217
- ${chalk.bold("Examples:")}
218
- ${APP_NAME} stats # Start dashboard server
219
- ${APP_NAME} stats --json # Print stats as JSON
220
- ${APP_NAME} stats --summary # Print summary to console
221
- ${APP_NAME} stats --port 8080 # Start on custom port
222
-
223
- ${chalk.bold("Metrics:")}
224
- - Total requests and error rate
225
- - Token usage (input, output, cache)
226
- - Cost breakdown
227
- - Average duration and time to first token (TTFT)
228
- - Tokens per second throughput
229
- `);
230
- }
@@ -15,7 +15,12 @@ import chalk from "@oh-my-pi/pi-utils/chalk";
15
15
  import { withFileLock } from "@oh-my-pi/pi-utils/file-lock";
16
16
  import { $ } from "bun";
17
17
  import { theme } from "../modes/theme/theme";
18
- import { isTimeoutError, withTimeoutSignal } from "../utils/fetch-timeout";
18
+ import {
19
+ isTimeoutError,
20
+ isUnsupportedProxyError,
21
+ unsupportedProxyMessage,
22
+ withTimeoutSignal,
23
+ } from "../utils/fetch-timeout";
19
24
 
20
25
  const REPO = "can1357/oh-my-pi";
21
26
  const PACKAGE = "@oh-my-pi/pi-coding-agent";
@@ -248,6 +253,7 @@ async function getReleaseBinaryAsset(
248
253
  if (isTimeoutError(err)) {
249
254
  throw new Error("Timed out fetching GitHub release metadata after 30s", { cause: err });
250
255
  }
256
+ if (isUnsupportedProxyError(err)) throw new Error(unsupportedProxyMessage(), { cause: err });
251
257
  throw err;
252
258
  }
253
259
  if ((response.status === 403 && !githubToken) || response.status === 429) {
@@ -287,6 +293,7 @@ export async function downloadVerifiedBinary(options: VerifiedBinaryDownloadOpti
287
293
  if (isTimeoutError(err)) {
288
294
  throw new Error("Timed out downloading release binary after 15 minutes", { cause: err });
289
295
  }
296
+ if (isUnsupportedProxyError(err)) throw new Error(unsupportedProxyMessage(), { cause: err });
290
297
  throw err;
291
298
  }
292
299
  if (!response.ok || !response.body) {
@@ -326,6 +333,7 @@ export async function downloadVerifiedBinary(options: VerifiedBinaryDownloadOpti
326
333
  if (isTimeoutError(err)) {
327
334
  throw new Error("Timed out downloading release binary after 15 minutes", { cause: err });
328
335
  }
336
+ if (isUnsupportedProxyError(err)) throw new Error(unsupportedProxyMessage(), { cause: err });
329
337
  throw err;
330
338
  }
331
339
  }
@@ -441,6 +449,14 @@ function tryRealpath(p: string): string | undefined {
441
449
  }
442
450
  }
443
451
 
452
+ function isSymlinkPath(p: string): boolean {
453
+ try {
454
+ return fs.lstatSync(p).isSymbolicLink();
455
+ } catch {
456
+ return false;
457
+ }
458
+ }
459
+
444
460
  function isPathInDirectoryLexical(filePath: string, directoryPath: string): boolean {
445
461
  const normalizedPath = normalizePathForComparison(path.resolve(filePath));
446
462
  const normalizedDirectory = normalizePathForComparison(path.resolve(directoryPath));
@@ -509,6 +525,14 @@ interface UpdateMethodResolutionOptions {
509
525
  * preserves a global package symlink instead of resolving into its checkout.
510
526
  */
511
527
  ompLinkTarget?: string;
528
+ /**
529
+ * Whether package-manager routing (bun/npm) is permitted. Binary-only
530
+ * releases pass `false`: a manager launcher then resolves to `"binary"` and
531
+ * is taken over in place rather than reinstalled through its manager. Defaults
532
+ * to `true` in {@link resolveUpdateMethod} so callers that only classify need
533
+ * not set it.
534
+ */
535
+ allowPackageManagers?: boolean;
512
536
  }
513
537
 
514
538
  type UpdateTarget =
@@ -525,6 +549,7 @@ function resolveUpdateMethod(
525
549
  options: UpdateMethodResolutionOptions = {},
526
550
  ): UpdateMethod {
527
551
  const {
552
+ allowPackageManagers = true,
528
553
  bunGlobalDir,
529
554
  homebrewPrefix,
530
555
  miseBinDirs = [],
@@ -555,6 +580,7 @@ function resolveUpdateMethod(
555
580
  globalBinDir: bunBinDir,
556
581
  });
557
582
  if (
583
+ allowPackageManagers &&
558
584
  bunBinDir &&
559
585
  isPathInDirectory(ompPath, bunBinDir) &&
560
586
  !isStandaloneRegularFile &&
@@ -564,6 +590,7 @@ function resolveUpdateMethod(
564
590
  }
565
591
  const npmNodeModulesDir = resolveNpmGlobalNodeModulesDir(npmBinDir);
566
592
  if (
593
+ allowPackageManagers &&
567
594
  npmBinDir &&
568
595
  isPathInDirectory(ompPath, npmBinDir) &&
569
596
  !isStandaloneRegularFile &&
@@ -611,10 +638,25 @@ export function resolveUpdateTargetFromPath(
611
638
  ompLinkTarget,
612
639
  });
613
640
  if (method === "binary") {
614
- // A package-manager-enabled update follows a foreign alias to replace
615
- // its standalone binary. Binary-only releases intentionally replace the
616
- // selected manager launcher in place.
617
- const binaryPath = options.allowPackageManagers && ompIsSymlink ? (ompRealpath ?? ompPath) : ompPath;
641
+ // A symlinked launcher created by bun/npm is taken over in place on a
642
+ // binary-only release: routing through the manager is impossible, so the
643
+ // standalone binary replaces the launcher and keeps the PATH entry live.
644
+ // Every other symlink a foreign alias, or an admin symlink into a
645
+ // shared install — is self-healing: update the real binary it resolves
646
+ // to and leave the launcher untouched, in every distribution channel.
647
+ // The old channel gate clobbered these foreign launchers on binary-only
648
+ // releases (EACCES on a root-owned link dir, or a stale split-brain copy
649
+ // of the binary shadowing the shared install).
650
+ const managerLauncher =
651
+ ompIsSymlink &&
652
+ !options.allowPackageManagers &&
653
+ resolveUpdateMethod(ompPath, bunBinDir, {
654
+ ...options,
655
+ allowPackageManagers: true,
656
+ ompIsRegularFile,
657
+ ompLinkTarget,
658
+ }) !== "binary";
659
+ const binaryPath = ompIsSymlink && !managerLauncher ? (ompRealpath ?? ompPath) : ompPath;
618
660
  return { method, path: binaryPath, replacesSymlink: ompIsSymlink && binaryPath === ompPath };
619
661
  }
620
662
  if (method === "bun" || method === "npm") return { method, path: ompPath };
@@ -623,25 +665,34 @@ export function resolveUpdateTargetFromPath(
623
665
  /**
624
666
  * Resolve how the running install should be updated.
625
667
  *
626
- * `allowPackageManagers: false` skips the `bun pm bin -g` / `npm prefix -g`
627
- * probes entirely — used for binary-only releases, where routing through a
628
- * package manager is never valid and the probes would be wasted subprocesses.
668
+ * `allowPackageManagers: false` disables bun/npm routing used for
669
+ * binary-only releases, where reinstalling through a package manager is never
670
+ * valid. The `bun pm bin -g` / `npm prefix -g` probes are then skipped unless
671
+ * the launcher is a symlink, whose bin dirs distinguish a manager launcher
672
+ * (taken over in place) from a foreign symlink (resolved to its real binary).
629
673
  * Homebrew/mise detection always runs: both managers install GitHub release
630
674
  * binaries and stay valid regardless of how the release is distributed.
631
675
  */
632
676
  async function resolveUpdateTarget(options: { allowPackageManagers: boolean }): Promise<UpdateTarget> {
633
- const bunBinDir = options.allowPackageManagers ? await getBunGlobalBinDir() : undefined;
634
- const npmBinDir = options.allowPackageManagers ? await getNpmGlobalBinDir() : undefined;
635
677
  const homebrewPrefix = await getHomebrewFormulaPrefix();
636
678
  const miseAvailable = $which("mise") !== undefined;
637
679
  const miseBinDirs = miseAvailable ? await getMiseBinDirs() : [];
638
680
  const miseDataDir = miseAvailable ? getMiseDataDir() : undefined;
639
681
  const ompPath = resolveOmpPath();
640
682
 
683
+ // Binary-only releases skip package-manager routing, but a symlinked
684
+ // launcher still needs the manager bin dirs to tell a bun/npm launcher
685
+ // (taken over in place) from a foreign symlink (resolved to its real
686
+ // binary). A plain-file install never needs the distinction, so the common
687
+ // case stays probe-free.
688
+ const probeManagers = options.allowPackageManagers || (ompPath !== undefined && isSymlinkPath(ompPath));
689
+ const bunBinDir = probeManagers ? await getBunGlobalBinDir() : undefined;
690
+ const npmBinDir = probeManagers ? await getNpmGlobalBinDir() : undefined;
691
+
641
692
  if (ompPath) {
642
693
  return resolveUpdateTargetFromPath(ompPath, bunBinDir, {
643
694
  allowPackageManagers: options.allowPackageManagers,
644
- bunGlobalDir: options.allowPackageManagers ? process.env.BUN_INSTALL_GLOBAL_DIR : undefined,
695
+ bunGlobalDir: probeManagers ? process.env.BUN_INSTALL_GLOBAL_DIR : undefined,
645
696
  homebrewPrefix,
646
697
  miseBinDirs,
647
698
  miseDataDir,
@@ -672,6 +723,7 @@ async function fetchLatestManifest(
672
723
  cause: err,
673
724
  });
674
725
  }
726
+ if (isUnsupportedProxyError(err)) throw new Error(unsupportedProxyMessage(), { cause: err });
675
727
  throw err;
676
728
  }
677
729
  if (!response.ok) {
@@ -10,7 +10,13 @@
10
10
  */
11
11
  import type { CommandEntry } from "@oh-my-pi/pi-utils/cli";
12
12
  import * as commandHelp from "./cli/command-help";
13
- import { flagConsumesValue } from "./cli/flag-tables";
13
+ import {
14
+ EXTENSION_SHADOWABLE_STRING_FLAGS,
15
+ flagConsumesValue,
16
+ OPTIONAL_VALUE_FLAGS,
17
+ STRING_VALUE_FLAGS,
18
+ VALUELESS_FLAGS,
19
+ } from "./cli/flag-tables";
14
20
  import { launchHelp } from "./commands/launch-help";
15
21
 
16
22
  export const commands: CommandEntry[] = [
@@ -285,12 +291,54 @@ function leadingSubcommandIndex(argv: string[]): number {
285
291
  return -1;
286
292
  }
287
293
 
294
+ /**
295
+ * Subcommands that share the launch flag surface, so leading global flags
296
+ * (`--cwd`, `--model`, `--approval-mode`, …) placed before them are meaningful
297
+ * and must be forwarded ({@link resolveCliArgv}, #2970). Every other subcommand
298
+ * parses only its own flags.
299
+ */
300
+ export const LAUNCH_FLAG_COMMANDS: Record<string, true> = { launch: true, acp: true };
301
+
302
+ /** Whether `arg` names a flag from the launch surface (bare or `--flag=value`). */
303
+ function isLaunchGlobalFlag(arg: string): boolean {
304
+ const eq = arg.indexOf("=");
305
+ const name = arg.startsWith("--") && eq !== -1 ? arg.slice(0, eq) : arg;
306
+ return (
307
+ STRING_VALUE_FLAGS.has(name) ||
308
+ OPTIONAL_VALUE_FLAGS.has(name) ||
309
+ VALUELESS_FLAGS.has(name) ||
310
+ EXTENSION_SHADOWABLE_STRING_FLAGS.has(name)
311
+ );
312
+ }
313
+
314
+ /**
315
+ * Drop recognized launch-global flags (and any value they consume) from the
316
+ * leading segment before a hoisted non-launch subcommand. `--cwd` and friends
317
+ * belong to the launch surface and mean nothing to a subcommand like `update`,
318
+ * whose strict parser would otherwise reject them with a cryptic
319
+ * `node:util.parseArgs` error (#8891). Tokens the launch tables don't recognize
320
+ * are kept, so a subcommand's own leading flags still reach it.
321
+ */
322
+ function stripLaunchGlobalFlags(leading: readonly string[]): string[] {
323
+ const kept: string[] = [];
324
+ for (let index = 0; index < leading.length; index += 1) {
325
+ const arg = leading[index];
326
+ if (isLaunchGlobalFlag(arg)) {
327
+ if (flagConsumesValue(arg, leading[index + 1])) index += 1;
328
+ continue;
329
+ }
330
+ kept.push(arg);
331
+ }
332
+ return kept;
333
+ }
334
+
288
335
  /**
289
336
  * Decide what the CLI runner should do with raw argv: reject bare reserved
290
337
  * management words, pass help/version through untouched, route a recognized
291
338
  * subcommand (even behind leading global flags like `--approval-mode=yolo`) to
292
- * that command with the flags preserved, and forward everything else to
293
- * `launch` (#2970).
339
+ * that command, and forward everything else to `launch` (#2970). Leading
340
+ * launch-global flags are forwarded to launch-shaped commands but stripped for
341
+ * other subcommands that cannot parse them (#8891).
294
342
  */
295
343
  export function resolveCliArgv(argv: string[]): ResolvedCliArgv {
296
344
  const first = argv[0];
@@ -302,12 +350,18 @@ export function resolveCliArgv(argv: string[]): ResolvedCliArgv {
302
350
  if (isSubcommand(first)) return { argv };
303
351
  // A subcommand can hide behind leading global option flags
304
352
  // (`omp --approval-mode=yolo acp`). `run` dispatches strictly on argv[0], so
305
- // hoist the subcommand to the front and keep the leading flags as its own
306
- // argv; the command's parser then applies them. Genuine launch prompts (no
307
- // trailing subcommand) are untouched.
353
+ // hoist the subcommand to the front. Launch-shaped commands share the launch
354
+ // flag surface, so their leading flags are forwarded and applied; every other
355
+ // subcommand parses only its own flags, so launch-global flags placed before
356
+ // it (`omp --cwd <dir> update`) are stripped rather than forwarded into a
357
+ // crash (#8891). Genuine launch prompts (no trailing subcommand) are untouched.
308
358
  const subIndex = leadingSubcommandIndex(argv);
309
359
  if (subIndex >= 0) {
310
- return { argv: [argv[subIndex], ...argv.slice(0, subIndex), ...argv.slice(subIndex + 1)] };
360
+ const sub = argv[subIndex];
361
+ const leading = argv.slice(0, subIndex);
362
+ const trailing = argv.slice(subIndex + 1);
363
+ const forwardedLeading = LAUNCH_FLAG_COMMANDS[sub] === true ? leading : stripLaunchGlobalFlags(leading);
364
+ return { argv: [sub, ...forwardedLeading, ...trailing] };
311
365
  }
312
366
  return { argv: ["launch", ...argv] };
313
367
  }
@@ -5,7 +5,7 @@
5
5
  * (see `cli/completion-gen.ts`), so it never drifts from the actual CLI surface.
6
6
  */
7
7
 
8
- import { APP_NAME, VERSION } from "@oh-my-pi/pi-utils";
8
+ import { APP_NAME, postmortem, VERSION } from "@oh-my-pi/pi-utils";
9
9
  import { Args, type CliConfig, Command, type CommandCtor } from "@oh-my-pi/pi-utils/cli";
10
10
  import { completionsHelp as commandHelp } from "../cli/command-help";
11
11
  import { buildSpec, generateCompletion, type Shell } from "../cli/completion-gen";
@@ -55,6 +55,7 @@ export default class Completions extends Command {
55
55
  }
56
56
 
57
57
  await Bun.write(Bun.stdout, await generateLiveCompletion(shell));
58
+ await postmortem.quit(0);
58
59
  }
59
60
  }
60
61
 
@@ -4,13 +4,15 @@
4
4
 
5
5
  import { Command, Flags } from "@oh-my-pi/pi-utils/cli";
6
6
  import { statsHelp as commandHelp } from "../cli/command-help";
7
- import { runStatsCommand, type StatsCommandArgs } from "../cli/stats-cli";
8
- import { initTheme } from "../modes/theme/theme";
7
+ import type { StatsCommandArgs } from "../cli/stats-cli";
8
+ import * as statsCli from "../cli/stats-cli";
9
+ import * as theme from "../modes/theme/theme";
9
10
 
10
11
  export default class Stats extends Command {
11
12
  static description = commandHelp.description;
12
13
  static flags = {
13
14
  port: Flags.integer({ char: "p", description: "Port for the dashboard server", default: 3847 }),
15
+ host: Flags.string({ description: "Host to bind", default: "127.0.0.1" }),
14
16
  json: Flags.boolean({ char: "j", description: "Output stats as JSON", default: false }),
15
17
  summary: Flags.boolean({ char: "s", description: "Print summary to console", default: false }),
16
18
  };
@@ -20,11 +22,12 @@ export default class Stats extends Command {
20
22
 
21
23
  const cmd: StatsCommandArgs = {
22
24
  port: flags.port,
25
+ host: flags.host ?? "127.0.0.1",
23
26
  json: flags.json,
24
27
  summary: flags.summary,
25
28
  };
26
29
 
27
- await initTheme();
28
- await runStatsCommand(cmd);
30
+ await theme.initTheme();
31
+ await statsCli.runStatsCommand(cmd);
29
32
  }
30
33
  }
@@ -11,7 +11,7 @@ import { ModelRegistry } from "../../config/model-registry";
11
11
  import { Settings } from "../../config/settings";
12
12
  import { discoverAuthStorage, discoverContextFiles, loadCliExtensionProviders } from "../../sdk";
13
13
  import * as git from "../../utils/git";
14
- import { abortOnGitFailure, pushOrAbort } from "../execute";
14
+ import { abortOnGitFailure, CommitAbortedError, pushOrAbort } from "../execute";
15
15
  import { type ExistingChangelogEntries, runCommitAgentSession } from "./agent";
16
16
  import { generateFallbackProposal } from "./fallback";
17
17
  import { assignLockFilesToPlan } from "./lock-files";
@@ -303,7 +303,20 @@ async function runSplitCommit(
303
303
  }
304
304
 
305
305
  process.stdout.write("● Creating split commits...\n");
306
- const stagedDiff = await git.diff(ctx.cwd, { cached: true, binary: true });
306
+ let stagedDiff: string;
307
+ try {
308
+ stagedDiff = await git.diff(ctx.cwd, { cached: true, binary: true, requireComplete: true });
309
+ } catch (error) {
310
+ if (error instanceof git.GitOutputTruncatedError) {
311
+ process.stderr.write(
312
+ `✗ Cannot create split commits: ${error.message}\n` +
313
+ " A large or binary file makes the staged diff too big to slice safely.\n" +
314
+ " Commit the large file(s) on their own, then re-run for the rest.\n",
315
+ );
316
+ throw new CommitAbortedError();
317
+ }
318
+ throw error;
319
+ }
307
320
  await git.stage.reset(ctx.cwd);
308
321
  for (const [position, commitIndex] of order.entries()) {
309
322
  const commit = plan.commits[commitIndex];
@@ -21,9 +21,13 @@ export function parseNumstat(output: string): NumstatEntry[] {
21
21
 
22
22
  export function parseFileDiffs(diff: string): FileDiff[] {
23
23
  const sections: FileDiff[] = [];
24
- const parts = diff.split("\ndiff --git ");
24
+ // Split on a line-start lookahead so each block keeps its terminating
25
+ // newline(s) verbatim. Consuming the delimiter would drop the blank line
26
+ // that terminates a `GIT binary patch` block, corrupting binary diffs on
27
+ // rebuild (issue #8899).
28
+ const parts = diff.split(/^(?=diff --git )/m);
25
29
  for (let index = 0; index < parts.length; index += 1) {
26
- const part = index === 0 ? parts[index] : `diff --git ${parts[index]}`;
30
+ const part = parts[index];
27
31
  if (!part.trim()) continue;
28
32
  const lines = part.split("\n");
29
33
  const header = lines[0] ?? "";
@@ -22,7 +22,7 @@ import { modelMatchesHost } from "@oh-my-pi/pi-catalog/hosts";
22
22
  import { buildModelProviderPriorityRank } from "@oh-my-pi/pi-catalog/identity";
23
23
  import { stripThinkingVariantToken } from "@oh-my-pi/pi-catalog/identity/family";
24
24
  import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
25
- import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
25
+ import { type GeneratedProvider, getBundledModels, modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
26
26
  import { DEFAULT_MODEL_PER_PROVIDER } from "@oh-my-pi/pi-catalog/provider-models";
27
27
  import { resolveBareVariantAlias, resolveVariantAlias } from "@oh-my-pi/pi-catalog/variant-collapse";
28
28
  import { fuzzyMatch } from "@oh-my-pi/pi-tui";
@@ -595,6 +595,41 @@ function includeSyntheticAllowedModels(available: Model<Api>[], allowedModels: I
595
595
  return result;
596
596
  }
597
597
 
598
+ /**
599
+ * Provider-lock a raw-id cross match.
600
+ *
601
+ * A slash-prefixed selector like `anthropic/claude-opus-5` is ambiguous: it is
602
+ * both the `anthropic` provider's canonical selector and — verbatim — an
603
+ * OpenRouter aggregator model id. When the named provider genuinely carries
604
+ * that model id in the bundled catalog but the model is missing from the
605
+ * candidate set (provider disabled, no credentials, or filtered out), letting
606
+ * the raw-id fallback re-bind the request onto a different provider's
607
+ * same-named model is a silent, expensive surprise — typically the aggregator's
608
+ * copy (OpenRouter bills published per-token Claude prices). Such a reference
609
+ * is provider-locked: it must fail rather than shadow.
610
+ *
611
+ * The lock only applies when the named provider carries the exact id in the
612
+ * bundled catalog. An aggregator raw id the named provider does NOT carry
613
+ * (e.g. `openai/gpt-4o:extended` — `openai` bundles `gpt-4o`, not the
614
+ * `:extended` variant) is legitimately an aggregator id and keeps resolving
615
+ * through the raw-id fallback, so bare aggregator selectors keep working.
616
+ */
617
+ function isProviderLockedCrossMatch(pattern: string, matchedModel: Model<Api>): boolean {
618
+ const slashIdx = pattern.indexOf("/");
619
+ if (slashIdx <= 0) {
620
+ return false;
621
+ }
622
+ const provider = pattern.slice(0, slashIdx).toLowerCase();
623
+ const modelId = pattern.slice(slashIdx + 1).toLowerCase();
624
+ if (matchedModel.provider.toLowerCase() === provider) {
625
+ return false;
626
+ }
627
+ // Case-insensitive on both halves: the surrounding matcher lowercases the
628
+ // selector before comparing ids, so the lock must not evaporate on case
629
+ // variance (catalog provider keys are lowercase; model ids may not be).
630
+ return getBundledModels(provider as GeneratedProvider).some(m => m.id.toLowerCase() === modelId);
631
+ }
632
+
598
633
  /**
599
634
  * Find an exact explicit provider/model match.
600
635
  */
@@ -644,11 +679,17 @@ function matchModel(
644
679
  // Exact ID match (case-insensitive) — this must happen before provider-scoped
645
680
  // fuzzy matching so raw IDs that contain slashes (for example OpenRouter model
646
681
  // IDs like "openai/gpt-4o:extended") still resolve as IDs instead of being
647
- // misread as a provider-qualified selector.
682
+ // misread as a provider-qualified selector. A provider-qualified pattern
683
+ // whose named provider carries the id stays locked to that provider when its
684
+ // only exact-id matches live on a different provider (isProviderLockedCrossMatch).
648
685
  const lowerPattern = modelPattern.toLowerCase();
649
686
  const exactMatches = availableModels.filter(m => m.id.toLowerCase() === lowerPattern);
650
687
  if (exactMatches.length > 0) {
651
- return pickPreferredModel(exactMatches, context);
688
+ const unlockedMatches = exactMatches.filter(m => !isProviderLockedCrossMatch(modelPattern, m));
689
+ if (unlockedMatches.length > 0) {
690
+ return pickPreferredModel(unlockedMatches, context);
691
+ }
692
+ return undefined;
652
693
  }
653
694
 
654
695
  const bedrockInferenceProfile = resolveBedrockInferenceProfileModelId(modelPattern, availableModels);
@@ -1710,11 +1751,14 @@ function findExactCliModel(
1710
1751
  // Flat-id (or full-selector-string) matches prefer authenticated providers,
1711
1752
  // then fall back to catalog order. This covers aggregator-style flat ids
1712
1753
  // that merely look provider-qualified (e.g. "openai/gpt-oss-120b" hosted on
1713
- // OpenRouter), where the provider/id decomposition above found nothing.
1754
+ // OpenRouter), where the provider/id decomposition above found nothing. A
1755
+ // provider-qualified selector whose named provider carries the id must not
1756
+ // re-bind onto another provider's same-named flat id
1757
+ // (isProviderLockedCrossMatch); it stays provider-locked and fails instead.
1714
1758
  const lower = selector.toLowerCase();
1715
1759
  const isFlatMatch = (model: Model<Api>) =>
1716
1760
  model.id.toLowerCase() === lower || formatModelString(model).toLowerCase() === lower;
1717
- const preferred = availableModels.find(isFlatMatch);
1761
+ const preferred = availableModels.find(m => isFlatMatch(m) && !isProviderLockedCrossMatch(selector, m));
1718
1762
  if (preferred) return preferred;
1719
1763
  // The unauthenticated catalog fallback is a weak match: a bare id like
1720
1764
  // `default` collides with the bundled `cursor/default` model, which must not
@@ -1723,7 +1767,9 @@ function findExactCliModel(
1723
1767
  // role gets a chance first; the deferred fuzzy fallback below still recovers
1724
1768
  // the catalog id when no role matches.
1725
1769
  if (options?.catalogFallback === false) return undefined;
1726
- return availableModels === allModels ? undefined : allModels.find(isFlatMatch);
1770
+ return availableModels === allModels
1771
+ ? undefined
1772
+ : allModels.find(m => isFlatMatch(m) && !isProviderLockedCrossMatch(selector, m));
1727
1773
  }
1728
1774
 
1729
1775
  export interface ResolveCliModelResult {
@@ -66,12 +66,12 @@ export function validateProviderConfiguration(
66
66
  const requiresAuth =
67
67
  mode === "runtime-register"
68
68
  ? !config.apiKey && !config.oauthConfigured
69
- : !config.apiKey && (config.auth ?? "apiKey") !== "none";
69
+ : !config.apiKey && (config.auth ?? "apiKey") !== "none" && (config.auth ?? "apiKey") !== "oauth";
70
70
  if (requiresAuth) {
71
71
  throw new Error(
72
72
  mode === "runtime-register"
73
73
  ? `Provider ${providerName}: "apiKey" or "oauth" is required when defining models.`
74
- : `Provider ${providerName}: "apiKey" is required when defining custom models unless auth is "none".`,
74
+ : `Provider ${providerName}: "apiKey" is required when defining custom models unless auth is "none" or "oauth".`,
75
75
  );
76
76
  }
77
77
  }
@@ -5254,6 +5254,39 @@ export const SETTINGS_SCHEMA = {
5254
5254
  },
5255
5255
  },
5256
5256
 
5257
+ "providers.cacheRetention": {
5258
+ type: "enum",
5259
+ values: ["auto", "short", "long", "none"] as const,
5260
+ default: "auto",
5261
+ ui: {
5262
+ tab: "providers",
5263
+ group: "Protocol",
5264
+ label: "Prompt Cache Retention",
5265
+ description:
5266
+ "Prompt-cache retention forwarded to providers that support it (Anthropic, Bedrock, OpenRouter, OpenAI)",
5267
+ options: [
5268
+ {
5269
+ value: "auto",
5270
+ label: "Auto",
5271
+ description:
5272
+ "Provider default — Anthropic uses 5m entries kept warm by idle keep-alive refreshes; PI_CACHE_RETENTION still applies",
5273
+ },
5274
+ {
5275
+ value: "short",
5276
+ label: "Short (5m)",
5277
+ description:
5278
+ "Cheapest cache writes; Anthropic keeps the entry warm with bounded keep-alive refreshes while idle",
5279
+ },
5280
+ {
5281
+ value: "long",
5282
+ label: "Long (1h)",
5283
+ description: "1h TTL where the provider supports it; pricier writes, no keep-alive refresh requests",
5284
+ },
5285
+ { value: "none", label: "Off", description: "Disable prompt caching and cache-affinity routing" },
5286
+ ],
5287
+ },
5288
+ },
5289
+
5257
5290
  "providers.streamFirstEventTimeoutSeconds": {
5258
5291
  type: "number",
5259
5292
  default: -1,