@oh-my-pi/pi-coding-agent 17.3.0 → 17.3.2

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 (70) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/{CHANGELOG-66nakf5b.md → CHANGELOG-fr2awajz.md} +20 -0
  3. package/dist/cli.js +2823 -2823
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/extension-flags.d.ts +3 -3
  7. package/dist/types/cli/flag-tables.d.ts +0 -1
  8. package/dist/types/cli/setup-cli.d.ts +10 -0
  9. package/dist/types/cli/update-cli.d.ts +48 -9
  10. package/dist/types/commands/completions.d.ts +3 -0
  11. package/dist/types/config/claude-paths.d.ts +7 -0
  12. package/dist/types/discovery/agents.d.ts +6 -6
  13. package/dist/types/discovery/helpers.d.ts +3 -4
  14. package/dist/types/extensibility/extensions/runner.d.ts +2 -2
  15. package/dist/types/extensibility/extensions/types.d.ts +4 -0
  16. package/dist/types/launch/broker.d.ts +5 -1
  17. package/dist/types/main.d.ts +1 -1
  18. package/dist/types/mcp/transports/stdio.d.ts +6 -3
  19. package/dist/types/modes/components/footer.d.ts +3 -2
  20. package/dist/types/modes/interactive-mode.d.ts +2 -1
  21. package/dist/types/modes/rpc/rpc-client.d.ts +2 -0
  22. package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
  23. package/dist/types/modes/runtime-init.d.ts +3 -1
  24. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  25. package/dist/types/task/executor.d.ts +2 -0
  26. package/dist/types/utils/git.d.ts +19 -0
  27. package/dist/types/utils/shell-snapshot.d.ts +4 -1
  28. package/package.json +13 -13
  29. package/src/async/job-manager.ts +33 -4
  30. package/src/cli/args.ts +14 -3
  31. package/src/cli/extension-flags.ts +6 -10
  32. package/src/cli/flag-tables.ts +2 -10
  33. package/src/cli/gc-cli.ts +13 -3
  34. package/src/cli/setup-cli.ts +2 -2
  35. package/src/cli/update-cli.ts +246 -107
  36. package/src/commands/completions.ts +16 -14
  37. package/src/config/claude-paths.ts +18 -0
  38. package/src/config/model-registry.ts +2 -2
  39. package/src/config.ts +4 -3
  40. package/src/discovery/agents.ts +7 -7
  41. package/src/discovery/claude.ts +5 -6
  42. package/src/discovery/helpers.ts +12 -11
  43. package/src/extensibility/extensions/runner.ts +5 -0
  44. package/src/extensibility/extensions/types.ts +5 -0
  45. package/src/extensibility/legacy-typebox.ts +45 -4
  46. package/src/launch/broker.ts +26 -4
  47. package/src/lsp/mux/server.ts +7 -1
  48. package/src/main.ts +30 -3
  49. package/src/mcp/transports/stdio.ts +7 -3
  50. package/src/modes/acp/acp-agent.ts +1 -0
  51. package/src/modes/components/footer.ts +17 -35
  52. package/src/modes/components/status-line/component.ts +14 -27
  53. package/src/modes/controllers/extension-ui-controller.ts +2 -2
  54. package/src/modes/interactive-mode.ts +16 -9
  55. package/src/modes/print-mode.ts +1 -0
  56. package/src/modes/rpc/rpc-client.ts +4 -2
  57. package/src/modes/rpc/rpc-input.ts +27 -0
  58. package/src/modes/rpc/rpc-mode.ts +11 -19
  59. package/src/modes/runtime-init.ts +5 -1
  60. package/src/modes/utils/ui-helpers.ts +74 -53
  61. package/src/session/agent-session.ts +24 -9
  62. package/src/session/claude-session-store.ts +4 -3
  63. package/src/task/executor.ts +8 -4
  64. package/src/tools/browser/launch.ts +9 -0
  65. package/src/tools/read-format.ts +11 -10
  66. package/src/tools/run-scope.ts +4 -2
  67. package/src/utils/external-editor.ts +10 -11
  68. package/src/utils/git.ts +27 -0
  69. package/src/utils/shell-snapshot.ts +5 -1
  70. package/src/web/search/providers/gemini.ts +4 -9
@@ -47,7 +47,6 @@ import { CliUsageError } from "./usage-error";
47
47
  export interface ParseDeps {
48
48
  logger: { warn: (message: string, meta?: Record<string, unknown>) => void };
49
49
  parseThinking: (value: string | null | undefined) => ConfiguredThinkingLevel | undefined;
50
- builtinToolNames: readonly string[];
51
50
  normalizeToolNames: (values: Iterable<string>) => string[];
52
51
  thinkingEfforts: readonly string[];
53
52
  }
@@ -189,15 +188,8 @@ export const STRING_SETTERS: Record<string, StringSetter> = {
189
188
  .map(s => s.trim())
190
189
  .filter(Boolean),
191
190
  );
192
- // An unknown name silently narrowing the toolset is worse than a failed
193
- // launch: scripts keep running believing the tool is available (e.g. a
194
- // stale `--tools bash,ssh` after the ssh tool's removal).
195
- const unknown = names.filter(name => !deps.builtinToolNames.includes(name));
196
- if (unknown.length > 0) {
197
- throw new CliUsageError(
198
- `Unknown tool${unknown.length === 1 ? "" : "s"} in --tools: ${unknown.join(", ")}. Valid tools: ${deps.builtinToolNames.join(", ")}.`,
199
- );
200
- }
191
+ // Validation runs after session tool discovery. At this point extension,
192
+ // custom, plugin-manifest, and MCP tools are not all known yet.
201
193
  result.tools = names;
202
194
  },
203
195
  "--thinking": (result, value, deps) => {
package/src/cli/gc-cli.ts CHANGED
@@ -151,11 +151,21 @@ function numberSetting(value: number | undefined, fallback: unknown, defaultValu
151
151
  async function resolveOptions(flags: GcCommandFlags): Promise<ResolvedGcOptions> {
152
152
  const agentDir = path.resolve(flags.agentDir ?? getAgentDir());
153
153
  const selected = flags.blobs === true || flags.archive === true || flags.wal === true;
154
+ const archiveSelected = selected && flags.archive === true;
155
+ const needsArchiveSettings =
156
+ archiveSelected &&
157
+ (flags.coldArchiveAfterDays === undefined ||
158
+ flags.retainNewestGlobal === undefined ||
159
+ flags.retainNewestPerCwd === undefined);
154
160
  const settings =
155
- flags.apply === true ? await Settings.loadIsolated({ agentDir }) : await Settings.loadReadOnly({ agentDir });
156
- const getBoolean = (pathKey: "gc.blobs" | "gc.archive" | "gc.wal") => settings.get(pathKey);
161
+ !selected || needsArchiveSettings
162
+ ? flags.apply === true
163
+ ? await Settings.loadIsolated({ agentDir })
164
+ : await Settings.loadReadOnly({ agentDir })
165
+ : undefined;
166
+ const getBoolean = (pathKey: "gc.blobs" | "gc.archive" | "gc.wal") => settings?.get(pathKey) ?? getDefault(pathKey);
157
167
  const getNumber = (pathKey: "gc.coldArchiveAfterDays" | "gc.retainNewestGlobal" | "gc.retainNewestPerCwd") =>
158
- settings.get(pathKey);
168
+ settings?.get(pathKey) ?? getDefault(pathKey);
159
169
  return {
160
170
  apply: flags.apply === true,
161
171
  json: flags.json === true,
@@ -66,7 +66,7 @@ export function parseSetupArgs(args: string[]): SetupCommandArgs | undefined {
66
66
  };
67
67
  }
68
68
 
69
- interface PythonCheckResult {
69
+ export interface PythonCheckResult {
70
70
  available: boolean;
71
71
  pythonPath?: string;
72
72
  usingManagedEnv?: boolean;
@@ -82,7 +82,7 @@ function managedPythonPath(): string {
82
82
  /**
83
83
  * Check Python environment and kernel dependencies.
84
84
  */
85
- async function checkPythonSetup(cwd: string, interpreter?: string): Promise<PythonCheckResult> {
85
+ export async function checkPythonSetup(cwd: string, interpreter?: string): Promise<PythonCheckResult> {
86
86
  const availability = await checkPythonKernelAvailability(cwd, interpreter, { forceProbe: true });
87
87
  return {
88
88
  available: availability.ok,
@@ -12,6 +12,7 @@ import { Transform } from "node:stream";
12
12
  import { pipeline } from "node:stream/promises";
13
13
  import { $env, $which, APP_NAME, compareVersions, isEnoent, VERSION } from "@oh-my-pi/pi-utils";
14
14
  import chalk from "@oh-my-pi/pi-utils/chalk";
15
+ import { withFileLock } from "@oh-my-pi/pi-utils/file-lock";
15
16
  import { $ } from "bun";
16
17
  import { theme } from "../modes/theme/theme";
17
18
  import { isTimeoutError, withTimeoutSignal } from "../utils/fetch-timeout";
@@ -466,6 +467,27 @@ function isPathInDirectory(filePath: string, directoryPath: string): boolean {
466
467
  return isPathInDirectoryLexical(resolvedFile, dirReal);
467
468
  }
468
469
 
470
+ function isPathInManagerRoot(linkTarget: string, nodeModulesDir: string): boolean {
471
+ if (isPathInDirectoryLexical(linkTarget, nodeModulesDir)) return true;
472
+ // Resolve only the manager root. Resolving the link target itself would
473
+ // follow globally linked packages into their checkout and lose ownership.
474
+ const nodeModulesReal = tryRealpath(path.resolve(nodeModulesDir));
475
+ return nodeModulesReal !== undefined && isPathInDirectoryLexical(linkTarget, nodeModulesReal);
476
+ }
477
+
478
+ function resolveNpmGlobalNodeModulesDir(globalBinDir: string | undefined): string | undefined {
479
+ if (!globalBinDir) return undefined;
480
+ if (process.platform === "win32") return path.join(globalBinDir, "node_modules");
481
+ return path.join(path.dirname(globalBinDir), "lib", "node_modules");
482
+ }
483
+
484
+ function isManagerOwnedBinEntry(linkTarget: string | undefined, nodeModulesDir: string | undefined): boolean {
485
+ // Non-symlink launchers and unreadable links retain the existing bin-dir
486
+ // classification. A readable link must point through the manager's exact
487
+ // global node_modules tree.
488
+ return linkTarget === undefined || (nodeModulesDir !== undefined && isPathInManagerRoot(linkTarget, nodeModulesDir));
489
+ }
490
+
469
491
  type UpdateMethod = "brew" | "mise" | "nix" | "bun" | "npm" | "binary";
470
492
 
471
493
  interface UpdateMethodResolutionOptions {
@@ -473,6 +495,8 @@ interface UpdateMethodResolutionOptions {
473
495
  miseBinDirs?: readonly string[];
474
496
  miseDataDir?: string;
475
497
  npmBinDir?: string;
498
+ /** Bun's configured global package directory, independent of its bin directory. */
499
+ bunGlobalDir?: string;
476
500
  /**
477
501
  * Whether the resolved omp path is a plain file (the standalone binary)
478
502
  * rather than a package-manager symlink. Stops a binary install from being
@@ -480,6 +504,11 @@ interface UpdateMethodResolutionOptions {
480
504
  * target directory.
481
505
  */
482
506
  ompIsRegularFile?: boolean;
507
+ /**
508
+ * Absolute path named by the bin entry's first symlink hop. This deliberately
509
+ * preserves a global package symlink instead of resolving into its checkout.
510
+ */
511
+ ompLinkTarget?: string;
483
512
  }
484
513
 
485
514
  type UpdateTarget =
@@ -495,7 +524,15 @@ function resolveUpdateMethod(
495
524
  bunBinDir: string | undefined,
496
525
  options: UpdateMethodResolutionOptions = {},
497
526
  ): UpdateMethod {
498
- const { homebrewPrefix, miseBinDirs = [], miseDataDir, npmBinDir, ompIsRegularFile = false } = options;
527
+ const {
528
+ bunGlobalDir,
529
+ homebrewPrefix,
530
+ miseBinDirs = [],
531
+ miseDataDir,
532
+ npmBinDir,
533
+ ompIsRegularFile = false,
534
+ ompLinkTarget,
535
+ } = options;
499
536
  const launcherExtension = path.extname(ompPath).toLowerCase();
500
537
  const isWindowsScriptLauncher =
501
538
  launcherExtension === ".cmd" || launcherExtension === ".ps1" || launcherExtension === ".bat";
@@ -513,9 +550,28 @@ function resolveUpdateMethod(
513
550
  // (bun's .exe launcher, npm's .cmd/.ps1), so a regular file is NOT evidence
514
551
  // of a standalone install and the override would hijack managed installs.
515
552
  const isStandaloneRegularFile = ompIsRegularFile && process.platform !== "win32";
516
- if (bunBinDir && isPathInDirectory(ompPath, bunBinDir) && !isStandaloneRegularFile) return "bun";
517
- if ((npmBinDir && isPathInDirectory(ompPath, npmBinDir) && !isStandaloneRegularFile) || isWindowsScriptLauncher)
553
+ const bunNodeModulesDir = resolveBunGlobalNodeModulesDirFromLocations({
554
+ globalDir: bunGlobalDir,
555
+ globalBinDir: bunBinDir,
556
+ });
557
+ if (
558
+ bunBinDir &&
559
+ isPathInDirectory(ompPath, bunBinDir) &&
560
+ !isStandaloneRegularFile &&
561
+ isManagerOwnedBinEntry(ompLinkTarget, bunNodeModulesDir)
562
+ ) {
563
+ return "bun";
564
+ }
565
+ const npmNodeModulesDir = resolveNpmGlobalNodeModulesDir(npmBinDir);
566
+ if (
567
+ npmBinDir &&
568
+ isPathInDirectory(ompPath, npmBinDir) &&
569
+ !isStandaloneRegularFile &&
570
+ isManagerOwnedBinEntry(ompLinkTarget, npmNodeModulesDir)
571
+ ) {
518
572
  return "npm";
573
+ }
574
+ if (isWindowsScriptLauncher) return "npm";
519
575
  return "binary";
520
576
  }
521
577
 
@@ -526,6 +582,44 @@ export function resolveUpdateMethodForTest(
526
582
  ): UpdateMethod {
527
583
  return resolveUpdateMethod(ompPath, bunBinDir, options);
528
584
  }
585
+
586
+ /** Resolve an update target from the concrete PATH entry selected by the shell. */
587
+ export function resolveUpdateTargetFromPath(
588
+ ompPath: string,
589
+ bunBinDir: string | undefined,
590
+ options: UpdateMethodResolutionOptions & { allowPackageManagers: boolean },
591
+ ): UpdateTarget {
592
+ let ompIsRegularFile = false;
593
+ let ompIsSymlink = false;
594
+ let ompLinkTarget: string | undefined;
595
+ let ompRealpath: string | undefined;
596
+ try {
597
+ const stat = fs.lstatSync(ompPath);
598
+ ompIsRegularFile = stat.isFile() && !stat.isSymbolicLink();
599
+ ompIsSymlink = stat.isSymbolicLink();
600
+ if (ompIsSymlink) {
601
+ const rawTarget = fs.readlinkSync(ompPath);
602
+ const linkDir = path.dirname(ompPath);
603
+ ompLinkTarget = path.resolve(tryRealpath(linkDir) ?? linkDir, rawTarget);
604
+ ompRealpath = tryRealpath(ompPath);
605
+ }
606
+ } catch {}
607
+
608
+ const method = resolveUpdateMethod(ompPath, bunBinDir, {
609
+ ...options,
610
+ ompIsRegularFile,
611
+ ompLinkTarget,
612
+ });
613
+ 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;
618
+ return { method, path: binaryPath, replacesSymlink: ompIsSymlink && binaryPath === ompPath };
619
+ }
620
+ if (method === "bun" || method === "npm") return { method, path: ompPath };
621
+ return { method };
622
+ }
529
623
  /**
530
624
  * Resolve how the running install should be updated.
531
625
  *
@@ -545,27 +639,14 @@ async function resolveUpdateTarget(options: { allowPackageManagers: boolean }):
545
639
  const ompPath = resolveOmpPath();
546
640
 
547
641
  if (ompPath) {
548
- // Package-manager installs symlink the bin entry into node_modules; the
549
- // standalone installer writes a plain executable. When the global bin dir
550
- // overlaps the installer's default (~/.local/bin), that file type — not
551
- // directory containment — distinguishes a binary install from npm/bun.
552
- let ompIsRegularFile = false;
553
- let ompIsSymlink = false;
554
- try {
555
- const stat = fs.lstatSync(ompPath);
556
- ompIsRegularFile = stat.isFile() && !stat.isSymbolicLink();
557
- ompIsSymlink = stat.isSymbolicLink();
558
- } catch {}
559
- const method = resolveUpdateMethod(ompPath, bunBinDir, {
642
+ return resolveUpdateTargetFromPath(ompPath, bunBinDir, {
643
+ allowPackageManagers: options.allowPackageManagers,
644
+ bunGlobalDir: options.allowPackageManagers ? process.env.BUN_INSTALL_GLOBAL_DIR : undefined,
560
645
  homebrewPrefix,
561
646
  miseBinDirs,
562
647
  miseDataDir,
563
648
  npmBinDir,
564
- ompIsRegularFile,
565
649
  });
566
- if (method === "binary") return { method, path: ompPath, replacesSymlink: ompIsSymlink };
567
- if (method === "bun" || method === "npm") return { method, path: ompPath };
568
- return { method };
569
650
  }
570
651
 
571
652
  if (bunBinDir) return { method: "bun" };
@@ -794,10 +875,19 @@ async function resolveBunInstallCacheDir(): Promise<string | undefined> {
794
875
  }
795
876
  }
796
877
 
797
- export function resolveBunGlobalNodeModulesDirFromLocations(
798
- globalBinDir: string | undefined,
799
- cacheDir: string | undefined,
800
- ): string | undefined {
878
+ interface BunGlobalInstallLocations {
879
+ globalDir?: string;
880
+ globalBinDir?: string;
881
+ cacheDir?: string;
882
+ }
883
+
884
+ /** Resolve Bun's global node_modules root from explicit, default, or cache locations. */
885
+ export function resolveBunGlobalNodeModulesDirFromLocations({
886
+ globalDir,
887
+ globalBinDir,
888
+ cacheDir,
889
+ }: BunGlobalInstallLocations): string | undefined {
890
+ if (globalDir && globalDir.length > 0) return path.join(globalDir, "node_modules");
801
891
  if (globalBinDir && globalBinDir.length > 0) {
802
892
  return path.join(path.dirname(globalBinDir), "install", "global", "node_modules");
803
893
  }
@@ -811,9 +901,16 @@ async function resolveBunGlobalNodeModulesDir(cacheDir: string): Promise<string
811
901
  try {
812
902
  const result = await $`bun pm bin -g`.quiet().nothrow();
813
903
  const globalBinDir = result.exitCode === 0 ? result.text().trim() : undefined;
814
- return resolveBunGlobalNodeModulesDirFromLocations(globalBinDir, cacheDir);
904
+ return resolveBunGlobalNodeModulesDirFromLocations({
905
+ globalDir: process.env.BUN_INSTALL_GLOBAL_DIR,
906
+ globalBinDir,
907
+ cacheDir,
908
+ });
815
909
  } catch {
816
- return resolveBunGlobalNodeModulesDirFromLocations(undefined, cacheDir);
910
+ return resolveBunGlobalNodeModulesDirFromLocations({
911
+ globalDir: process.env.BUN_INSTALL_GLOBAL_DIR,
912
+ cacheDir,
913
+ });
817
914
  }
818
915
  }
819
916
 
@@ -987,8 +1084,8 @@ async function unlinkIfExists(filePath: string): Promise<void> {
987
1084
  * running process image, so unlinking it fails with EPERM/EACCES until this
988
1085
  * process exits (issue #845). The replacement and verification already
989
1086
  * succeeded by the time we get here, so every error is swallowed; the leftover
990
- * is reclaimed by {@link sweepStaleBackups} on the next update once it is no
991
- * longer in use. Returns whether the file is gone.
1087
+ * is reclaimed by {@link sweepStaleUpdateArtifacts} on the next update once it
1088
+ * is no longer in use. Returns whether the file is gone.
992
1089
  */
993
1090
  async function removeBackupBestEffort(filePath: string): Promise<boolean> {
994
1091
  try {
@@ -1000,16 +1097,21 @@ async function removeBackupBestEffort(filePath: string): Promise<boolean> {
1000
1097
  }
1001
1098
 
1002
1099
  /**
1003
- * Best-effort removal of binary-update backups left by earlier runs.
1100
+ * Best-effort removal of binary-update leftovers from earlier runs.
1004
1101
  *
1005
- * Each self-update moves the previous executable to `<binary>.<timestamp>.<pid>.bak`
1006
- * before swapping the new one in. On Windows that backup cannot be deleted
1007
- * while the updating process is alive, so it is left for a later run to reclaim
1008
- * once its owning process has exited. Also matches the legacy fixed
1009
- * `<binary>.bak` name produced before backups were timestamped, so users
1010
- * upgrading from a buggy release get the orphaned file cleaned up.
1102
+ * Each self-update writes to `<binary>.<timestamp>.<pid>.new` and moves the
1103
+ * previous executable to `<binary>.<timestamp>.<pid>.bak` before swapping the
1104
+ * new one in. On Windows a backup cannot be deleted while the updating process
1105
+ * is alive (it is the running process image), so it is left for a later run to
1106
+ * reclaim once its owning process has exited. A `.new` temp file only survives
1107
+ * a hard kill mid-download; it is reaped once older than the download window,
1108
+ * which a live download cannot exceed without timing out and cleaning up after
1109
+ * itself — so a concurrent run's in-progress temp is never deleted. Legacy
1110
+ * fixed `<binary>.bak` / `<binary>.new` names (from before suffixes were made
1111
+ * unique) are matched too, so users upgrading from a buggy release get the
1112
+ * orphaned files cleaned up.
1011
1113
  */
1012
- export async function sweepStaleBackups(targetPath: string): Promise<void> {
1114
+ export async function sweepStaleUpdateArtifacts(targetPath: string): Promise<void> {
1013
1115
  const dir = path.dirname(targetPath);
1014
1116
  const base = path.basename(targetPath);
1015
1117
  let entries: string[];
@@ -1018,13 +1120,28 @@ export async function sweepStaleBackups(targetPath: string): Promise<void> {
1018
1120
  } catch {
1019
1121
  return;
1020
1122
  }
1123
+ const now = Date.now();
1021
1124
  for (const entry of entries) {
1022
- if (!entry.startsWith(`${base}.`) || !entry.endsWith(".bak")) continue;
1023
- // Legacy "<base>.bak" empty middle; new "<base>.<timestamp>.<pid>.bak"
1024
- // dot-separated numeric run. Anything else is an unrelated *.bak file.
1025
- const middle = entry.slice(base.length + 1, entry.length - ".bak".length);
1125
+ if (!entry.startsWith(`${base}.`)) continue;
1126
+ const suffix = entry.endsWith(".bak") ? ".bak" : entry.endsWith(".new") ? ".new" : undefined;
1127
+ if (!suffix) continue;
1128
+ // Legacy "<base><suffix>" empty middle; new "<base>.<timestamp>.<pid><suffix>"
1129
+ // → dot-separated numeric run. Anything else is an unrelated file.
1130
+ const middle = entry.slice(base.length + 1, entry.length - suffix.length);
1026
1131
  if (middle.length > 0 && !/^\d+(\.\d+)*$/.test(middle)) continue;
1027
- await removeBackupBestEffort(path.join(dir, entry));
1132
+ const full = path.join(dir, entry);
1133
+ if (suffix === ".new") {
1134
+ // A temp file may belong to a concurrent update still downloading, so
1135
+ // only reap ones older than the download window.
1136
+ let mtimeMs: number;
1137
+ try {
1138
+ mtimeMs = (await fs.promises.stat(full)).mtimeMs;
1139
+ } catch {
1140
+ continue;
1141
+ }
1142
+ if (now - mtimeMs < BINARY_DOWNLOAD_TIMEOUT_MS) continue;
1143
+ }
1144
+ await removeBackupBestEffort(full);
1028
1145
  }
1029
1146
  }
1030
1147
 
@@ -1334,6 +1451,11 @@ async function updateViaMise(expectedVersion: string, force: boolean): Promise<v
1334
1451
  await printVerification(expectedVersion);
1335
1452
  }
1336
1453
 
1454
+ // Monotonic within this process so two updates started in the same millisecond
1455
+ // (same pid, same `Date.now()`) still get distinct temp/backup paths. Kept
1456
+ // numeric so the artifact sweep's `\d+(\.\d+)*` matcher still reclaims them.
1457
+ let updateAttemptSeq = 0;
1458
+
1337
1459
  /**
1338
1460
  * Download a release binary to a target path, replacing an existing file.
1339
1461
  */
@@ -1348,12 +1470,18 @@ export async function updateViaBinaryAt(
1348
1470
  } = {},
1349
1471
  ): Promise<void> {
1350
1472
  const binaryName = options.binaryName ?? getBinaryName();
1351
- const tempPath = `${targetPath}.new`;
1352
- // Unique per attempt: a stale backup from an earlier update may still be
1353
- // locked (it is the previous process image on Windows), and a fixed name
1354
- // would force the move-aside rename to overwrite it. pid + timestamp keeps
1355
- // two forced updates in the same millisecond from colliding.
1356
- const backupPath = `${targetPath}.${Date.now()}.${process.pid}.bak`;
1473
+ // Unique per attempt so two overlapping `omp update` runs never share a temp
1474
+ // or backup path. A fixed temp name (`<binary>.new`) let the second run's
1475
+ // pre-download unlink delete the first run's still-downloading temp file; the
1476
+ // first kept writing to its open fd (size + digest still passed), then chmod
1477
+ // hit the missing path and the update aborted (issue #8434). The backup needs
1478
+ // the same uniqueness: a stale backup from an earlier update may still be
1479
+ // locked (the previous process image on Windows), so a fixed name would force
1480
+ // the move-aside rename to overwrite it. pid, timestamp, and a process-local
1481
+ // counter keep two updates started in the same millisecond from colliding.
1482
+ const attempt = `${Date.now()}.${process.pid}.${updateAttemptSeq++}`;
1483
+ const tempPath = `${targetPath}.${attempt}.new`;
1484
+ const backupPath = `${targetPath}.${attempt}.bak`;
1357
1485
  const asset = await getReleaseBinaryAsset(expectedVersion, binaryName, options.fetchImpl, options.githubToken);
1358
1486
  console.log(chalk.dim(`Downloading ${binaryName}…`));
1359
1487
  await downloadVerifiedBinary({
@@ -1365,16 +1493,22 @@ export async function updateViaBinaryAt(
1365
1493
  });
1366
1494
  console.log(chalk.dim(`Verified ${asset.digest}`));
1367
1495
 
1368
- console.log(chalk.dim("Installing update..."));
1369
- await replaceBinaryForUpdate({
1370
- targetPath,
1371
- tempPath,
1372
- backupPath,
1373
- expectedVersion,
1374
- verifyInstalledVersion: options.verifyInstalledVersion ?? verifyInstalledVersion,
1496
+ // Serialize the target swap and stale-artifact sweep per target so two
1497
+ // overlapping `omp update` runs never replace the same binary concurrently
1498
+ // or reclaim each other's live backup/temp files. The download above writes
1499
+ // to a unique temp path and is safe to overlap; only the swap is shared.
1500
+ await withFileLock(targetPath, async () => {
1501
+ console.log(chalk.dim("Installing update..."));
1502
+ await replaceBinaryForUpdate({
1503
+ targetPath,
1504
+ tempPath,
1505
+ backupPath,
1506
+ expectedVersion,
1507
+ verifyInstalledVersion: options.verifyInstalledVersion ?? verifyInstalledVersion,
1508
+ });
1509
+ // Reclaim backups from earlier updates whose owning process has since exited.
1510
+ await sweepStaleUpdateArtifacts(targetPath);
1375
1511
  });
1376
- // Reclaim backups from earlier updates whose owning process has since exited.
1377
- await sweepStaleBackups(targetPath);
1378
1512
  printVerifiedVersion(expectedVersion);
1379
1513
  console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`));
1380
1514
  }
@@ -1421,7 +1555,8 @@ export async function updateViaShimTakeover(
1421
1555
  const binaryName = options.binaryName ?? getBinaryName();
1422
1556
  const launcherDir = path.dirname(shimPath);
1423
1557
  const exePath = path.join(launcherDir, `${APP_NAME}.exe`);
1424
- const tempPath = `${exePath}.new`;
1558
+ const attempt = `${Date.now()}.${process.pid}.${updateAttemptSeq++}`;
1559
+ const tempPath = `${exePath}.${attempt}.new`;
1425
1560
  const asset = await getReleaseBinaryAsset(expectedVersion, binaryName, options.fetchImpl, options.githubToken);
1426
1561
  console.log(chalk.dim(`Downloading ${binaryName}…`));
1427
1562
  await downloadVerifiedBinary({
@@ -1432,65 +1567,69 @@ export async function updateViaShimTakeover(
1432
1567
  fetchImpl: options.fetchImpl,
1433
1568
  });
1434
1569
  console.log(chalk.dim(`Verified ${asset.digest}`));
1435
-
1436
- console.log(chalk.dim(`Installing ${APP_NAME}.exe beside the script launcher...`));
1437
- await fs.promises.rename(tempPath, exePath);
1438
- // Retire the shims so PATH resolution lands on the new exe. Renamed, not
1439
- // deleted: restorable on verification failure, and Windows permits
1440
- // renaming a batch file that is still executing. A shim that cannot be
1441
- // renamed (held open without delete sharing) is rewritten in place as a
1442
- // forwarder to the exe — write and rename take different Windows locks,
1443
- // so one can succeed where the other fails.
1444
- const backupSuffix = `${Date.now()}.${process.pid}.bak`;
1445
- const retired: Array<{ launcher: string; backup: string }> = [];
1446
1570
  const forwarded: Array<{ launcher: string; original: string }> = [];
1447
1571
  const stuck: string[] = [];
1448
- for (const ext of ["", ".cmd", ".ps1", ".bat"]) {
1449
- const launcher = path.join(launcherDir, `${APP_NAME}${ext}`);
1450
- const backup = `${launcher}.${backupSuffix}`;
1451
- try {
1452
- await fs.promises.rename(launcher, backup);
1453
- retired.push({ launcher, backup });
1454
- } catch (err) {
1455
- if (isEnoent(err)) continue;
1572
+ // Serialize the launcher swap and artifact sweep so two overlapping updates
1573
+ // never retire the same shims or reclaim a live run's backup before its
1574
+ // verification can roll it back.
1575
+ await withFileLock(exePath, async () => {
1576
+ console.log(chalk.dim(`Installing ${APP_NAME}.exe beside the script launcher...`));
1577
+ await fs.promises.rename(tempPath, exePath);
1578
+ // Retire the shims so PATH resolution lands on the new exe. Renamed, not
1579
+ // deleted: restorable on verification failure, and Windows permits
1580
+ // renaming a batch file that is still executing. A shim that cannot be
1581
+ // renamed (held open without delete sharing) is rewritten in place as a
1582
+ // forwarder to the exe — write and rename take different Windows locks,
1583
+ // so one can succeed where the other fails.
1584
+ const backupSuffix = `${attempt}.bak`;
1585
+ const retired: Array<{ launcher: string; backup: string }> = [];
1586
+ for (const ext of ["", ".cmd", ".ps1", ".bat"]) {
1587
+ const launcher = path.join(launcherDir, `${APP_NAME}${ext}`);
1588
+ const backup = `${launcher}.${backupSuffix}`;
1456
1589
  try {
1457
- const original = await Bun.file(launcher).text();
1458
- await Bun.write(launcher, SHIM_FORWARDERS[ext]);
1459
- forwarded.push({ launcher, original });
1460
- } catch {
1461
- stuck.push(launcher);
1590
+ await fs.promises.rename(launcher, backup);
1591
+ retired.push({ launcher, backup });
1592
+ } catch (err) {
1593
+ if (isEnoent(err)) continue;
1594
+ try {
1595
+ const original = await Bun.file(launcher).text();
1596
+ await Bun.write(launcher, SHIM_FORWARDERS[ext]);
1597
+ forwarded.push({ launcher, original });
1598
+ } catch {
1599
+ stuck.push(launcher);
1600
+ }
1462
1601
  }
1463
1602
  }
1464
- }
1465
1603
 
1466
- // Verify the exe by its explicit path: $which cached the shim path when
1467
- // the update target was resolved, and the shim was just renamed away, so
1468
- // a PATH re-resolution here would test a file that no longer exists.
1469
- const verify = options.verifyBinary ?? verifyBinaryAtPath;
1470
- const verification = await verify(exePath, expectedVersion);
1471
- if (!verification.ok) {
1472
- for (const { launcher, backup } of retired) {
1473
- try {
1474
- await fs.promises.rename(backup, launcher);
1475
- } catch {}
1604
+ // Verify the exe by its explicit path: $which cached the shim path when
1605
+ // the update target was resolved, and the shim was just renamed away, so
1606
+ // a PATH re-resolution here would test a file that no longer exists.
1607
+ const verify = options.verifyBinary ?? verifyBinaryAtPath;
1608
+ const verification = await verify(exePath, expectedVersion);
1609
+ if (!verification.ok) {
1610
+ for (const { launcher, backup } of retired) {
1611
+ try {
1612
+ await fs.promises.rename(backup, launcher);
1613
+ } catch {}
1614
+ }
1615
+ for (const { launcher, original } of forwarded) {
1616
+ try {
1617
+ await Bun.write(launcher, original);
1618
+ } catch {}
1619
+ }
1620
+ await unlinkIfExists(exePath);
1621
+ throw new Error(
1622
+ `${formatVerificationFailure(verification, expectedVersion)}; restored previous ${APP_NAME} launcher`,
1623
+ );
1476
1624
  }
1477
- for (const { launcher, original } of forwarded) {
1478
- try {
1479
- await Bun.write(launcher, original);
1480
- } catch {}
1625
+ for (const { backup } of retired) {
1626
+ await removeBackupBestEffort(backup);
1481
1627
  }
1482
- await unlinkIfExists(exePath);
1483
- throw new Error(
1484
- `${formatVerificationFailure(verification, expectedVersion)}; restored previous ${APP_NAME} launcher`,
1485
- );
1486
- }
1487
- for (const { backup } of retired) {
1488
- await removeBackupBestEffort(backup);
1489
- }
1490
- // Reclaim exe backups and retired-shim leftovers from earlier attempts.
1491
- for (const ext of [".exe", "", ".cmd", ".ps1", ".bat"]) {
1492
- await sweepStaleBackups(path.join(launcherDir, `${APP_NAME}${ext}`));
1493
- }
1628
+ // Reclaim exe backups and retired-shim leftovers from earlier attempts.
1629
+ for (const ext of [".exe", "", ".cmd", ".ps1", ".bat"]) {
1630
+ await sweepStaleUpdateArtifacts(path.join(launcherDir, `${APP_NAME}${ext}`));
1631
+ }
1632
+ });
1494
1633
  for (const { launcher } of forwarded) {
1495
1634
  console.log(chalk.dim(`Converted ${launcher} to a forwarder (it could not be removed).`));
1496
1635
  }
@@ -15,6 +15,21 @@ import { commands } from "../cli-commands";
15
15
  const ROOT_COMMAND = "launch";
16
16
  const SHELLS = ["bash", "zsh", "fish"] as const;
17
17
 
18
+ /** Generate a completion script from the live command registry. */
19
+ export async function generateLiveCompletion(shell: Shell): Promise<string> {
20
+ const loaded = await Promise.all(commands.map(async entry => ({ entry, Cmd: await entry.load() })));
21
+ const map = new Map<string, CommandCtor>();
22
+ const aliasMap = new Map<string, readonly string[]>();
23
+ for (const { entry, Cmd } of loaded) {
24
+ map.set(entry.name, Cmd);
25
+ const merged = new Set<string>([...(Cmd.aliases ?? []), ...(entry.aliases ?? [])]);
26
+ aliasMap.set(entry.name, [...merged]);
27
+ }
28
+
29
+ const config: CliConfig = { bin: APP_NAME, version: VERSION, commands: map };
30
+ return generateCompletion(shell, buildSpec(config, ROOT_COMMAND, aliasMap));
31
+ }
32
+
18
33
  export default class Completions extends Command {
19
34
  static description = commandHelp.description;
20
35
  static args = {
@@ -39,20 +54,7 @@ export default class Completions extends Command {
39
54
  return;
40
55
  }
41
56
 
42
- // Load every command class so we can read its static flag/arg descriptors,
43
- // and collect aliases from both the registration table and the class.
44
- const loaded = await Promise.all(commands.map(async entry => ({ entry, Cmd: await entry.load() })));
45
- const map = new Map<string, CommandCtor>();
46
- const aliasMap = new Map<string, readonly string[]>();
47
- for (const { entry, Cmd } of loaded) {
48
- map.set(entry.name, Cmd);
49
- const merged = new Set<string>([...(Cmd.aliases ?? []), ...(entry.aliases ?? [])]);
50
- aliasMap.set(entry.name, [...merged]);
51
- }
52
-
53
- const config: CliConfig = { bin: APP_NAME, version: VERSION, commands: map };
54
- const spec = buildSpec(config, ROOT_COMMAND, aliasMap);
55
- await Bun.write(Bun.stdout, generateCompletion(shell, spec));
57
+ await Bun.write(Bun.stdout, await generateLiveCompletion(shell));
56
58
  }
57
59
  }
58
60
 
@@ -0,0 +1,18 @@
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
3
+
4
+ /** Paths to Claude Code's user data and configuration file. */
5
+ export interface ClaudePaths {
6
+ configDir: string;
7
+ configFile: string;
8
+ }
9
+
10
+ /** Resolves Claude Code's user paths, honoring `CLAUDE_CONFIG_DIR`. */
11
+ export function resolveClaudePaths(home: string = os.homedir()): ClaudePaths {
12
+ const override = process.env.CLAUDE_CONFIG_DIR?.trim();
13
+ if (override) {
14
+ const configDir = path.resolve(override);
15
+ return { configDir, configFile: path.join(configDir, ".claude.json") };
16
+ }
17
+ return { configDir: path.join(home, ".claude"), configFile: path.join(home, ".claude.json") };
18
+ }
@@ -32,7 +32,7 @@ import {
32
32
  resolveOllamaModelCacheProviderId,
33
33
  } from "@oh-my-pi/pi-catalog/provider-models";
34
34
  import { collapseBuiltModelVariants } from "@oh-my-pi/pi-catalog/variant-collapse";
35
- import { isBunTestRuntime, logger, wrapFetchForExtraCa } from "@oh-my-pi/pi-utils";
35
+ import { getAgentDir, isBunTestRuntime, logger, wrapFetchForExtraCa } from "@oh-my-pi/pi-utils";
36
36
  import { resolveProviderModelReference } from "../config/model-resolver";
37
37
  import { generateCodexAttestation } from "../live/attestation";
38
38
  import type { AuthStorage } from "../session/auth-storage";
@@ -246,7 +246,7 @@ export class ModelRegistry {
246
246
  (isBunTestRuntime()
247
247
  ? () => Promise.reject(new Error("network disabled in model-registry runtime test"))
248
248
  : wrapFetchForExtraCa(fetch));
249
- this.#modelsConfigFile = ModelsConfigFile.relocate(modelsPath);
249
+ this.#modelsConfigFile = ModelsConfigFile.relocate(modelsPath ?? path.join(getAgentDir(), "models.yml"));
250
250
  this.#cacheDbPath = modelsPath ? path.join(path.dirname(modelsPath), "models.db") : undefined;
251
251
  // Set up fallback resolver for custom provider API keys
252
252
  this.authStorage.setFallbackResolver(provider => {