@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.
- package/CHANGELOG.md +20 -0
- package/dist/{CHANGELOG-66nakf5b.md → CHANGELOG-fr2awajz.md} +20 -0
- package/dist/cli.js +2823 -2823
- package/dist/docs-index.generated.txt +1 -1
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/extension-flags.d.ts +3 -3
- package/dist/types/cli/flag-tables.d.ts +0 -1
- package/dist/types/cli/setup-cli.d.ts +10 -0
- package/dist/types/cli/update-cli.d.ts +48 -9
- package/dist/types/commands/completions.d.ts +3 -0
- package/dist/types/config/claude-paths.d.ts +7 -0
- package/dist/types/discovery/agents.d.ts +6 -6
- package/dist/types/discovery/helpers.d.ts +3 -4
- package/dist/types/extensibility/extensions/runner.d.ts +2 -2
- package/dist/types/extensibility/extensions/types.d.ts +4 -0
- package/dist/types/launch/broker.d.ts +5 -1
- package/dist/types/main.d.ts +1 -1
- package/dist/types/mcp/transports/stdio.d.ts +6 -3
- package/dist/types/modes/components/footer.d.ts +3 -2
- package/dist/types/modes/interactive-mode.d.ts +2 -1
- package/dist/types/modes/rpc/rpc-client.d.ts +2 -0
- package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
- package/dist/types/modes/runtime-init.d.ts +3 -1
- package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
- package/dist/types/task/executor.d.ts +2 -0
- package/dist/types/utils/git.d.ts +19 -0
- package/dist/types/utils/shell-snapshot.d.ts +4 -1
- package/package.json +13 -13
- package/src/async/job-manager.ts +33 -4
- package/src/cli/args.ts +14 -3
- package/src/cli/extension-flags.ts +6 -10
- package/src/cli/flag-tables.ts +2 -10
- package/src/cli/gc-cli.ts +13 -3
- package/src/cli/setup-cli.ts +2 -2
- package/src/cli/update-cli.ts +246 -107
- package/src/commands/completions.ts +16 -14
- package/src/config/claude-paths.ts +18 -0
- package/src/config/model-registry.ts +2 -2
- package/src/config.ts +4 -3
- package/src/discovery/agents.ts +7 -7
- package/src/discovery/claude.ts +5 -6
- package/src/discovery/helpers.ts +12 -11
- package/src/extensibility/extensions/runner.ts +5 -0
- package/src/extensibility/extensions/types.ts +5 -0
- package/src/extensibility/legacy-typebox.ts +45 -4
- package/src/launch/broker.ts +26 -4
- package/src/lsp/mux/server.ts +7 -1
- package/src/main.ts +30 -3
- package/src/mcp/transports/stdio.ts +7 -3
- package/src/modes/acp/acp-agent.ts +1 -0
- package/src/modes/components/footer.ts +17 -35
- package/src/modes/components/status-line/component.ts +14 -27
- package/src/modes/controllers/extension-ui-controller.ts +2 -2
- package/src/modes/interactive-mode.ts +16 -9
- package/src/modes/print-mode.ts +1 -0
- package/src/modes/rpc/rpc-client.ts +4 -2
- package/src/modes/rpc/rpc-input.ts +27 -0
- package/src/modes/rpc/rpc-mode.ts +11 -19
- package/src/modes/runtime-init.ts +5 -1
- package/src/modes/utils/ui-helpers.ts +74 -53
- package/src/session/agent-session.ts +24 -9
- package/src/session/claude-session-store.ts +4 -3
- package/src/task/executor.ts +8 -4
- package/src/tools/browser/launch.ts +9 -0
- package/src/tools/read-format.ts +11 -10
- package/src/tools/run-scope.ts +4 -2
- package/src/utils/external-editor.ts +10 -11
- package/src/utils/git.ts +27 -0
- package/src/utils/shell-snapshot.ts +5 -1
- package/src/web/search/providers/gemini.ts +4 -9
package/src/cli/flag-tables.ts
CHANGED
|
@@ -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
|
-
//
|
|
193
|
-
//
|
|
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
|
-
|
|
156
|
-
|
|
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
|
|
168
|
+
settings?.get(pathKey) ?? getDefault(pathKey);
|
|
159
169
|
return {
|
|
160
170
|
apply: flags.apply === true,
|
|
161
171
|
json: flags.json === true,
|
package/src/cli/setup-cli.ts
CHANGED
|
@@ -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,
|
package/src/cli/update-cli.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
517
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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(
|
|
904
|
+
return resolveBunGlobalNodeModulesDirFromLocations({
|
|
905
|
+
globalDir: process.env.BUN_INSTALL_GLOBAL_DIR,
|
|
906
|
+
globalBinDir,
|
|
907
|
+
cacheDir,
|
|
908
|
+
});
|
|
815
909
|
} catch {
|
|
816
|
-
return resolveBunGlobalNodeModulesDirFromLocations(
|
|
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
|
|
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
|
|
1100
|
+
* Best-effort removal of binary-update leftovers from earlier runs.
|
|
1004
1101
|
*
|
|
1005
|
-
* Each self-update
|
|
1006
|
-
*
|
|
1007
|
-
*
|
|
1008
|
-
*
|
|
1009
|
-
*
|
|
1010
|
-
*
|
|
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
|
|
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}.`)
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1352
|
-
//
|
|
1353
|
-
//
|
|
1354
|
-
//
|
|
1355
|
-
//
|
|
1356
|
-
|
|
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
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
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
|
|
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
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
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
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
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
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
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 {
|
|
1478
|
-
|
|
1479
|
-
await Bun.write(launcher, original);
|
|
1480
|
-
} catch {}
|
|
1625
|
+
for (const { backup } of retired) {
|
|
1626
|
+
await removeBackupBestEffort(backup);
|
|
1481
1627
|
}
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
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
|
-
|
|
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 => {
|