@oh-my-pi/pi-coding-agent 17.3.0 → 17.3.1
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 +12 -0
- package/dist/{CHANGELOG-66nakf5b.md → CHANGELOG-2swgsx9f.md} +12 -0
- package/dist/cli.js +3437 -3437
- 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 +13 -8
- 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 +125 -82
- 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 +1 -0
- package/src/session/claude-session-store.ts +4 -3
- package/src/task/executor.ts +6 -3
- package/src/tools/browser/launch.ts +9 -0
- package/src/utils/git.ts +27 -0
- package/src/utils/shell-snapshot.ts +5 -1
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";
|
|
@@ -987,8 +988,8 @@ async function unlinkIfExists(filePath: string): Promise<void> {
|
|
|
987
988
|
* running process image, so unlinking it fails with EPERM/EACCES until this
|
|
988
989
|
* process exits (issue #845). The replacement and verification already
|
|
989
990
|
* 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.
|
|
991
|
+
* is reclaimed by {@link sweepStaleUpdateArtifacts} on the next update once it
|
|
992
|
+
* is no longer in use. Returns whether the file is gone.
|
|
992
993
|
*/
|
|
993
994
|
async function removeBackupBestEffort(filePath: string): Promise<boolean> {
|
|
994
995
|
try {
|
|
@@ -1000,16 +1001,21 @@ async function removeBackupBestEffort(filePath: string): Promise<boolean> {
|
|
|
1000
1001
|
}
|
|
1001
1002
|
|
|
1002
1003
|
/**
|
|
1003
|
-
* Best-effort removal of binary-update
|
|
1004
|
+
* Best-effort removal of binary-update leftovers from earlier runs.
|
|
1004
1005
|
*
|
|
1005
|
-
* Each self-update
|
|
1006
|
-
*
|
|
1007
|
-
*
|
|
1008
|
-
*
|
|
1009
|
-
*
|
|
1010
|
-
*
|
|
1006
|
+
* Each self-update writes to `<binary>.<timestamp>.<pid>.new` and moves the
|
|
1007
|
+
* previous executable to `<binary>.<timestamp>.<pid>.bak` before swapping the
|
|
1008
|
+
* new one in. On Windows a backup cannot be deleted while the updating process
|
|
1009
|
+
* is alive (it is the running process image), so it is left for a later run to
|
|
1010
|
+
* reclaim once its owning process has exited. A `.new` temp file only survives
|
|
1011
|
+
* a hard kill mid-download; it is reaped once older than the download window,
|
|
1012
|
+
* which a live download cannot exceed without timing out and cleaning up after
|
|
1013
|
+
* itself — so a concurrent run's in-progress temp is never deleted. Legacy
|
|
1014
|
+
* fixed `<binary>.bak` / `<binary>.new` names (from before suffixes were made
|
|
1015
|
+
* unique) are matched too, so users upgrading from a buggy release get the
|
|
1016
|
+
* orphaned files cleaned up.
|
|
1011
1017
|
*/
|
|
1012
|
-
export async function
|
|
1018
|
+
export async function sweepStaleUpdateArtifacts(targetPath: string): Promise<void> {
|
|
1013
1019
|
const dir = path.dirname(targetPath);
|
|
1014
1020
|
const base = path.basename(targetPath);
|
|
1015
1021
|
let entries: string[];
|
|
@@ -1018,13 +1024,28 @@ export async function sweepStaleBackups(targetPath: string): Promise<void> {
|
|
|
1018
1024
|
} catch {
|
|
1019
1025
|
return;
|
|
1020
1026
|
}
|
|
1027
|
+
const now = Date.now();
|
|
1021
1028
|
for (const entry of entries) {
|
|
1022
|
-
if (!entry.startsWith(`${base}.`)
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1029
|
+
if (!entry.startsWith(`${base}.`)) continue;
|
|
1030
|
+
const suffix = entry.endsWith(".bak") ? ".bak" : entry.endsWith(".new") ? ".new" : undefined;
|
|
1031
|
+
if (!suffix) continue;
|
|
1032
|
+
// Legacy "<base><suffix>" → empty middle; new "<base>.<timestamp>.<pid><suffix>"
|
|
1033
|
+
// → dot-separated numeric run. Anything else is an unrelated file.
|
|
1034
|
+
const middle = entry.slice(base.length + 1, entry.length - suffix.length);
|
|
1026
1035
|
if (middle.length > 0 && !/^\d+(\.\d+)*$/.test(middle)) continue;
|
|
1027
|
-
|
|
1036
|
+
const full = path.join(dir, entry);
|
|
1037
|
+
if (suffix === ".new") {
|
|
1038
|
+
// A temp file may belong to a concurrent update still downloading, so
|
|
1039
|
+
// only reap ones older than the download window.
|
|
1040
|
+
let mtimeMs: number;
|
|
1041
|
+
try {
|
|
1042
|
+
mtimeMs = (await fs.promises.stat(full)).mtimeMs;
|
|
1043
|
+
} catch {
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
if (now - mtimeMs < BINARY_DOWNLOAD_TIMEOUT_MS) continue;
|
|
1047
|
+
}
|
|
1048
|
+
await removeBackupBestEffort(full);
|
|
1028
1049
|
}
|
|
1029
1050
|
}
|
|
1030
1051
|
|
|
@@ -1334,6 +1355,11 @@ async function updateViaMise(expectedVersion: string, force: boolean): Promise<v
|
|
|
1334
1355
|
await printVerification(expectedVersion);
|
|
1335
1356
|
}
|
|
1336
1357
|
|
|
1358
|
+
// Monotonic within this process so two updates started in the same millisecond
|
|
1359
|
+
// (same pid, same `Date.now()`) still get distinct temp/backup paths. Kept
|
|
1360
|
+
// numeric so the artifact sweep's `\d+(\.\d+)*` matcher still reclaims them.
|
|
1361
|
+
let updateAttemptSeq = 0;
|
|
1362
|
+
|
|
1337
1363
|
/**
|
|
1338
1364
|
* Download a release binary to a target path, replacing an existing file.
|
|
1339
1365
|
*/
|
|
@@ -1348,12 +1374,18 @@ export async function updateViaBinaryAt(
|
|
|
1348
1374
|
} = {},
|
|
1349
1375
|
): Promise<void> {
|
|
1350
1376
|
const binaryName = options.binaryName ?? getBinaryName();
|
|
1351
|
-
|
|
1352
|
-
//
|
|
1353
|
-
//
|
|
1354
|
-
//
|
|
1355
|
-
//
|
|
1356
|
-
|
|
1377
|
+
// Unique per attempt so two overlapping `omp update` runs never share a temp
|
|
1378
|
+
// or backup path. A fixed temp name (`<binary>.new`) let the second run's
|
|
1379
|
+
// pre-download unlink delete the first run's still-downloading temp file; the
|
|
1380
|
+
// first kept writing to its open fd (size + digest still passed), then chmod
|
|
1381
|
+
// hit the missing path and the update aborted (issue #8434). The backup needs
|
|
1382
|
+
// the same uniqueness: a stale backup from an earlier update may still be
|
|
1383
|
+
// locked (the previous process image on Windows), so a fixed name would force
|
|
1384
|
+
// the move-aside rename to overwrite it. pid, timestamp, and a process-local
|
|
1385
|
+
// counter keep two updates started in the same millisecond from colliding.
|
|
1386
|
+
const attempt = `${Date.now()}.${process.pid}.${updateAttemptSeq++}`;
|
|
1387
|
+
const tempPath = `${targetPath}.${attempt}.new`;
|
|
1388
|
+
const backupPath = `${targetPath}.${attempt}.bak`;
|
|
1357
1389
|
const asset = await getReleaseBinaryAsset(expectedVersion, binaryName, options.fetchImpl, options.githubToken);
|
|
1358
1390
|
console.log(chalk.dim(`Downloading ${binaryName}…`));
|
|
1359
1391
|
await downloadVerifiedBinary({
|
|
@@ -1365,16 +1397,22 @@ export async function updateViaBinaryAt(
|
|
|
1365
1397
|
});
|
|
1366
1398
|
console.log(chalk.dim(`Verified ${asset.digest}`));
|
|
1367
1399
|
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1400
|
+
// Serialize the target swap and stale-artifact sweep per target so two
|
|
1401
|
+
// overlapping `omp update` runs never replace the same binary concurrently
|
|
1402
|
+
// or reclaim each other's live backup/temp files. The download above writes
|
|
1403
|
+
// to a unique temp path and is safe to overlap; only the swap is shared.
|
|
1404
|
+
await withFileLock(targetPath, async () => {
|
|
1405
|
+
console.log(chalk.dim("Installing update..."));
|
|
1406
|
+
await replaceBinaryForUpdate({
|
|
1407
|
+
targetPath,
|
|
1408
|
+
tempPath,
|
|
1409
|
+
backupPath,
|
|
1410
|
+
expectedVersion,
|
|
1411
|
+
verifyInstalledVersion: options.verifyInstalledVersion ?? verifyInstalledVersion,
|
|
1412
|
+
});
|
|
1413
|
+
// Reclaim backups from earlier updates whose owning process has since exited.
|
|
1414
|
+
await sweepStaleUpdateArtifacts(targetPath);
|
|
1375
1415
|
});
|
|
1376
|
-
// Reclaim backups from earlier updates whose owning process has since exited.
|
|
1377
|
-
await sweepStaleBackups(targetPath);
|
|
1378
1416
|
printVerifiedVersion(expectedVersion);
|
|
1379
1417
|
console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`));
|
|
1380
1418
|
}
|
|
@@ -1421,7 +1459,8 @@ export async function updateViaShimTakeover(
|
|
|
1421
1459
|
const binaryName = options.binaryName ?? getBinaryName();
|
|
1422
1460
|
const launcherDir = path.dirname(shimPath);
|
|
1423
1461
|
const exePath = path.join(launcherDir, `${APP_NAME}.exe`);
|
|
1424
|
-
const
|
|
1462
|
+
const attempt = `${Date.now()}.${process.pid}.${updateAttemptSeq++}`;
|
|
1463
|
+
const tempPath = `${exePath}.${attempt}.new`;
|
|
1425
1464
|
const asset = await getReleaseBinaryAsset(expectedVersion, binaryName, options.fetchImpl, options.githubToken);
|
|
1426
1465
|
console.log(chalk.dim(`Downloading ${binaryName}…`));
|
|
1427
1466
|
await downloadVerifiedBinary({
|
|
@@ -1432,65 +1471,69 @@ export async function updateViaShimTakeover(
|
|
|
1432
1471
|
fetchImpl: options.fetchImpl,
|
|
1433
1472
|
});
|
|
1434
1473
|
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
1474
|
const forwarded: Array<{ launcher: string; original: string }> = [];
|
|
1447
1475
|
const stuck: string[] = [];
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1476
|
+
// Serialize the launcher swap and artifact sweep so two overlapping updates
|
|
1477
|
+
// never retire the same shims or reclaim a live run's backup before its
|
|
1478
|
+
// verification can roll it back.
|
|
1479
|
+
await withFileLock(exePath, async () => {
|
|
1480
|
+
console.log(chalk.dim(`Installing ${APP_NAME}.exe beside the script launcher...`));
|
|
1481
|
+
await fs.promises.rename(tempPath, exePath);
|
|
1482
|
+
// Retire the shims so PATH resolution lands on the new exe. Renamed, not
|
|
1483
|
+
// deleted: restorable on verification failure, and Windows permits
|
|
1484
|
+
// renaming a batch file that is still executing. A shim that cannot be
|
|
1485
|
+
// renamed (held open without delete sharing) is rewritten in place as a
|
|
1486
|
+
// forwarder to the exe — write and rename take different Windows locks,
|
|
1487
|
+
// so one can succeed where the other fails.
|
|
1488
|
+
const backupSuffix = `${attempt}.bak`;
|
|
1489
|
+
const retired: Array<{ launcher: string; backup: string }> = [];
|
|
1490
|
+
for (const ext of ["", ".cmd", ".ps1", ".bat"]) {
|
|
1491
|
+
const launcher = path.join(launcherDir, `${APP_NAME}${ext}`);
|
|
1492
|
+
const backup = `${launcher}.${backupSuffix}`;
|
|
1456
1493
|
try {
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1494
|
+
await fs.promises.rename(launcher, backup);
|
|
1495
|
+
retired.push({ launcher, backup });
|
|
1496
|
+
} catch (err) {
|
|
1497
|
+
if (isEnoent(err)) continue;
|
|
1498
|
+
try {
|
|
1499
|
+
const original = await Bun.file(launcher).text();
|
|
1500
|
+
await Bun.write(launcher, SHIM_FORWARDERS[ext]);
|
|
1501
|
+
forwarded.push({ launcher, original });
|
|
1502
|
+
} catch {
|
|
1503
|
+
stuck.push(launcher);
|
|
1504
|
+
}
|
|
1462
1505
|
}
|
|
1463
1506
|
}
|
|
1464
|
-
}
|
|
1465
1507
|
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1508
|
+
// Verify the exe by its explicit path: $which cached the shim path when
|
|
1509
|
+
// the update target was resolved, and the shim was just renamed away, so
|
|
1510
|
+
// a PATH re-resolution here would test a file that no longer exists.
|
|
1511
|
+
const verify = options.verifyBinary ?? verifyBinaryAtPath;
|
|
1512
|
+
const verification = await verify(exePath, expectedVersion);
|
|
1513
|
+
if (!verification.ok) {
|
|
1514
|
+
for (const { launcher, backup } of retired) {
|
|
1515
|
+
try {
|
|
1516
|
+
await fs.promises.rename(backup, launcher);
|
|
1517
|
+
} catch {}
|
|
1518
|
+
}
|
|
1519
|
+
for (const { launcher, original } of forwarded) {
|
|
1520
|
+
try {
|
|
1521
|
+
await Bun.write(launcher, original);
|
|
1522
|
+
} catch {}
|
|
1523
|
+
}
|
|
1524
|
+
await unlinkIfExists(exePath);
|
|
1525
|
+
throw new Error(
|
|
1526
|
+
`${formatVerificationFailure(verification, expectedVersion)}; restored previous ${APP_NAME} launcher`,
|
|
1527
|
+
);
|
|
1476
1528
|
}
|
|
1477
|
-
for (const {
|
|
1478
|
-
|
|
1479
|
-
await Bun.write(launcher, original);
|
|
1480
|
-
} catch {}
|
|
1529
|
+
for (const { backup } of retired) {
|
|
1530
|
+
await removeBackupBestEffort(backup);
|
|
1481
1531
|
}
|
|
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
|
-
}
|
|
1532
|
+
// Reclaim exe backups and retired-shim leftovers from earlier attempts.
|
|
1533
|
+
for (const ext of [".exe", "", ".cmd", ".ps1", ".bat"]) {
|
|
1534
|
+
await sweepStaleUpdateArtifacts(path.join(launcherDir, `${APP_NAME}${ext}`));
|
|
1535
|
+
}
|
|
1536
|
+
});
|
|
1494
1537
|
for (const { launcher } of forwarded) {
|
|
1495
1538
|
console.log(chalk.dim(`Converted ${launcher} to a forwarder (it could not be removed).`));
|
|
1496
1539
|
}
|
|
@@ -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 => {
|
package/src/config.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { CONFIG_DIR_NAME, getConfigAgentDirName, getProjectDir } from "@oh-my-pi/pi-utils";
|
|
5
|
+
import { resolveClaudePaths } from "./config/claude-paths";
|
|
5
6
|
import { expandTilde } from "./tools/path-utils";
|
|
6
7
|
|
|
7
8
|
export * from "./config/config-file";
|
|
@@ -76,12 +77,12 @@ export function getChangelogPath(): string | undefined {
|
|
|
76
77
|
// =============================================================================
|
|
77
78
|
|
|
78
79
|
/**
|
|
79
|
-
*
|
|
80
|
-
* User-level: ~/.omp/agent, ~/.claude, ~/.codex, ~/.gemini
|
|
80
|
+
* User-level: ~/.omp/agent, Claude's active config directory, ~/.codex, ~/.gemini
|
|
81
81
|
* Project-level: .omp, .claude, .codex, .gemini
|
|
82
82
|
*/
|
|
83
83
|
const USER_CONFIG_BASES = priorityList.map(({ dir, globalAgentDir }) => ({
|
|
84
|
-
base: () =>
|
|
84
|
+
base: () =>
|
|
85
|
+
dir === ".claude" ? resolveClaudePaths().configDir : path.join(os.homedir(), globalAgentDir?.() ?? dir),
|
|
85
86
|
name: dir,
|
|
86
87
|
}));
|
|
87
88
|
|
package/src/discovery/agents.ts
CHANGED
|
@@ -67,18 +67,18 @@ const HOST_PROBE_TIMEOUT_MS = 500;
|
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
69
|
* Run a best-effort discovery probe and return its trimmed stdout, or
|
|
70
|
-
* `undefined` when the command fails, produces no output, or exceeds
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
70
|
+
* `undefined` when the command fails, produces no output, or exceeds the
|
|
71
|
+
* timeout. On timeout the child is killed with SIGKILL so a wedged interop pipe
|
|
72
|
+
* cannot hang startup; the killed/non-zero exit is then reported as
|
|
73
|
+
* "unavailable" and discovery falls back to the Linux `$HOME`/`~/.omp`
|
|
74
|
+
* candidates.
|
|
75
75
|
*/
|
|
76
|
-
export function runHostProbe(cmd: string[]): string | undefined {
|
|
76
|
+
export function runHostProbe(cmd: string[], timeoutMs = HOST_PROBE_TIMEOUT_MS): string | undefined {
|
|
77
77
|
try {
|
|
78
78
|
const result = Bun.spawnSync(cmd, {
|
|
79
79
|
stdout: "pipe",
|
|
80
80
|
stderr: "ignore",
|
|
81
|
-
timeout:
|
|
81
|
+
timeout: timeoutMs,
|
|
82
82
|
killSignal: "SIGKILL",
|
|
83
83
|
});
|
|
84
84
|
if (result.exitCode !== 0) return undefined;
|
package/src/discovery/claude.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { type SlashCommand, slashCommandCapability } from "../capability/slash-c
|
|
|
18
18
|
import { type SystemPrompt, systemPromptCapability } from "../capability/system-prompt";
|
|
19
19
|
import { type CustomTool, toolCapability } from "../capability/tool";
|
|
20
20
|
import type { LoadContext, LoadResult } from "../capability/types";
|
|
21
|
+
import { resolveClaudePaths } from "../config/claude-paths";
|
|
21
22
|
import { settings } from "../config/settings";
|
|
22
23
|
import {
|
|
23
24
|
calculateDepth,
|
|
@@ -34,11 +35,10 @@ const DISPLAY_NAME = "Claude Code";
|
|
|
34
35
|
const PRIORITY = 80;
|
|
35
36
|
const CONFIG_DIR = ".claude";
|
|
36
37
|
|
|
37
|
-
/**
|
|
38
|
-
* Get user-level .claude path.
|
|
39
|
-
*/
|
|
38
|
+
/** Get the active user-level Claude Code directory. */
|
|
40
39
|
function getUserClaude(ctx: LoadContext): string {
|
|
41
|
-
|
|
40
|
+
const { configDir } = resolveClaudePaths(ctx.home);
|
|
41
|
+
return configDir;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
@@ -60,8 +60,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
|
|
60
60
|
const items: MCPServer[] = [];
|
|
61
61
|
const warnings: string[] = [];
|
|
62
62
|
|
|
63
|
-
const userBase =
|
|
64
|
-
const userClaudeJson = path.join(ctx.home, ".claude.json");
|
|
63
|
+
const { configDir: userBase, configFile: userClaudeJson } = resolveClaudePaths(ctx.home);
|
|
65
64
|
const userMcpJson = path.join(userBase, "mcp.json");
|
|
66
65
|
|
|
67
66
|
const projectBase = path.join(ctx.cwd, CONFIG_DIR);
|
package/src/discovery/helpers.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { invalidate as invalidateFsCache, readDirEntries, readFile } from "../ca
|
|
|
16
16
|
import { parseRuleConditionAndScope, type Rule, type RuleFrontmatter } from "../capability/rule";
|
|
17
17
|
import type { Skill, SkillFrontmatter } from "../capability/skill";
|
|
18
18
|
import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
|
|
19
|
+
import { resolveClaudePaths } from "../config/claude-paths";
|
|
19
20
|
import type { MCPRequestIdFormat } from "../mcp/types";
|
|
20
21
|
import { type ConfiguredThinkingLevel, parseConfiguredThinkingLevel } from "../thinking";
|
|
21
22
|
import { normalizeToolNames } from "../tools/builtin-names";
|
|
@@ -90,10 +91,9 @@ export type SourceId = keyof typeof SOURCE_PATHS;
|
|
|
90
91
|
*/
|
|
91
92
|
export function getUserPath(ctx: LoadContext, source: SourceId, subpath: string): string | null {
|
|
92
93
|
// Native user config is profile-scoped via getAgentDir() (the active profile's
|
|
93
|
-
// agent dir), matching builtin.ts and getMCPConfigPath("user").
|
|
94
|
-
// (~/.claude, ~/.gemini, …) are intentionally not profile-scoped, so they keep
|
|
95
|
-
// resolving against ctx.home below.
|
|
94
|
+
// agent dir), matching builtin.ts and getMCPConfigPath("user").
|
|
96
95
|
if (source === "native") return path.join(getAgentDir(), subpath);
|
|
96
|
+
if (source === "claude") return path.join(resolveClaudePaths(ctx.home).configDir, subpath);
|
|
97
97
|
const paths = SOURCE_PATHS[source];
|
|
98
98
|
if (!paths.userAgent) return null;
|
|
99
99
|
return path.join(ctx.home, paths.userAgent, subpath);
|
|
@@ -903,20 +903,21 @@ export function registerPluginCacheInvalidator(invalidator: () => void): void {
|
|
|
903
903
|
}
|
|
904
904
|
|
|
905
905
|
/**
|
|
906
|
-
* List all installed Claude Code plugin roots from
|
|
907
|
-
*
|
|
908
|
-
* and optionally the nearest project-scoped registry resolved from `cwd`.
|
|
906
|
+
* List all installed Claude Code plugin roots from its active plugin cache and
|
|
907
|
+
* ~/.omp/plugins/installed_plugins.json, plus the nearest project registry when present.
|
|
909
908
|
*
|
|
910
|
-
* Results are cached per
|
|
909
|
+
* Results are cached per Claude and OMP config directories, project registry, and canonical active project.
|
|
911
910
|
*/
|
|
912
911
|
export async function listClaudePluginRoots(
|
|
913
912
|
home: string,
|
|
914
913
|
cwd?: string,
|
|
915
914
|
): Promise<{ roots: ClaudePluginRoot[]; warnings: string[] }> {
|
|
915
|
+
const claudeConfigDir = resolveClaudePaths(home).configDir;
|
|
916
|
+
const ompRegistryPath = path.join(getPluginsDir(home), "installed_plugins.json");
|
|
916
917
|
const resolvedProjectPath = cwd ? await resolveActiveProjectRegistryPath(cwd) : null;
|
|
917
918
|
const projectRoot = resolvedProjectPath ? path.dirname(path.dirname(path.dirname(resolvedProjectPath))) : cwd;
|
|
918
919
|
const activeClaudeProjectPath = projectRoot ? await canonicalClaudeProjectPath(projectRoot) : null;
|
|
919
|
-
const cacheKey = `${
|
|
920
|
+
const cacheKey = `${claudeConfigDir}:${ompRegistryPath}:${resolvedProjectPath ?? ""}:${activeClaudeProjectPath ?? ""}`;
|
|
920
921
|
const cached = pluginRootsCache.get(cacheKey);
|
|
921
922
|
if (cached) return cached;
|
|
922
923
|
|
|
@@ -926,7 +927,7 @@ export async function listClaudePluginRoots(
|
|
|
926
927
|
const canonicalClaudeProjectPaths = new Map<string, string | null>();
|
|
927
928
|
|
|
928
929
|
// ── Claude Code registry ──────────────────────────────────────────────────
|
|
929
|
-
const registryPath = path.join(
|
|
930
|
+
const registryPath = path.join(claudeConfigDir, "plugins", "installed_plugins.json");
|
|
930
931
|
const content = await readFile(registryPath);
|
|
931
932
|
|
|
932
933
|
if (content) {
|
|
@@ -983,7 +984,7 @@ export async function listClaudePluginRoots(
|
|
|
983
984
|
// In production `home` is `os.homedir()`, so `getPluginsDir(home)` resolves to the
|
|
984
985
|
// same XDG-aware path the marketplace writer uses (reads and writes always agree).
|
|
985
986
|
// Tests pass a temp dir, which short-circuits the resolver for deterministic isolation.
|
|
986
|
-
|
|
987
|
+
// Computed before the cache lookup because isolated SDK homes select distinct OMP registries.
|
|
987
988
|
const ompContent = await readFile(ompRegistryPath);
|
|
988
989
|
if (ompContent) {
|
|
989
990
|
const ompRegistry = parseClaudePluginsRegistry(ompContent);
|
|
@@ -1107,7 +1108,7 @@ export function clearClaudePluginRootsCache(): void {
|
|
|
1107
1108
|
* installing/uninstalling/enabling/disabling plugins.
|
|
1108
1109
|
*/
|
|
1109
1110
|
export function clearPluginRootsAndCaches(extraPaths?: readonly string[]): void {
|
|
1110
|
-
invalidateFsCache(path.join(
|
|
1111
|
+
invalidateFsCache(path.join(resolveClaudePaths().configDir, "plugins", "installed_plugins.json"));
|
|
1111
1112
|
invalidateFsCache(path.join(getPluginsDir(), "installed_plugins.json"));
|
|
1112
1113
|
for (const p of extraPaths ?? []) invalidateFsCache(p);
|
|
1113
1114
|
clearClaudePluginRootsCache();
|
|
@@ -42,6 +42,7 @@ import type {
|
|
|
42
42
|
ExtensionError,
|
|
43
43
|
ExtensionEvent,
|
|
44
44
|
ExtensionFlag,
|
|
45
|
+
ExtensionMode,
|
|
45
46
|
ExtensionRuntime,
|
|
46
47
|
ExtensionShortcut,
|
|
47
48
|
ExtensionUIContext,
|
|
@@ -342,6 +343,7 @@ interface ToolRegistrationScope {
|
|
|
342
343
|
|
|
343
344
|
export class ExtensionRunner {
|
|
344
345
|
#uiContext: ExtensionUIContext;
|
|
346
|
+
#mode: ExtensionMode = "print";
|
|
345
347
|
#toolApprovalPreviewWaiter?: (toolCallId: string) => Promise<void>;
|
|
346
348
|
#errorListeners: Set<ExtensionErrorListener> = new Set();
|
|
347
349
|
#getModel: () => Model | undefined = () => undefined;
|
|
@@ -525,6 +527,7 @@ export class ExtensionRunner {
|
|
|
525
527
|
contextActions: ExtensionContextActions,
|
|
526
528
|
commandContextActions?: ExtensionCommandContextActions,
|
|
527
529
|
uiContext?: ExtensionUIContext,
|
|
530
|
+
mode: ExtensionMode = "print",
|
|
528
531
|
): void {
|
|
529
532
|
// Copy actions into the shared runtime (all extension APIs reference this)
|
|
530
533
|
this.runtime.sendMessage = actions.sendMessage;
|
|
@@ -573,6 +576,7 @@ export class ExtensionRunner {
|
|
|
573
576
|
}
|
|
574
577
|
|
|
575
578
|
this.#uiContext = uiContext ?? noOpUIContext;
|
|
579
|
+
this.#mode = mode;
|
|
576
580
|
this.#initialized = true;
|
|
577
581
|
|
|
578
582
|
// Drain events buffered by emitCredentialDisabled() before initialize ran. The
|
|
@@ -953,6 +957,7 @@ export class ExtensionRunner {
|
|
|
953
957
|
const getModel = model ? () => model : this.#getModel;
|
|
954
958
|
return {
|
|
955
959
|
ui: this.#uiContext,
|
|
960
|
+
mode: this.#mode,
|
|
956
961
|
getContextUsage: () => this.#getContextUsageFn(),
|
|
957
962
|
compact: instructionsOrOptions => this.#compactFn(instructionsOrOptions),
|
|
958
963
|
getAsyncJobSnapshot: () => this.#getAsyncJobSnapshotFn(),
|
|
@@ -434,9 +434,14 @@ export interface ExtensionModelQuery {
|
|
|
434
434
|
family(model: Model): string;
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
+
/** Runtime host mode exposed to Pi-compatible extensions. */
|
|
438
|
+
export type ExtensionMode = "tui" | "rpc" | "json" | "print";
|
|
439
|
+
|
|
437
440
|
export interface ExtensionContext {
|
|
438
441
|
/** UI methods for user interaction */
|
|
439
442
|
ui: ExtensionUIContext;
|
|
443
|
+
/** Current run mode. Use `"tui"` to guard terminal-only UI such as custom components. */
|
|
444
|
+
mode: ExtensionMode;
|
|
440
445
|
/** Get current context usage for the active model. */
|
|
441
446
|
getContextUsage(): ContextUsage | undefined;
|
|
442
447
|
/** Get a read-only snapshot of async jobs owned by this session. */
|