@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/{CHANGELOG-66nakf5b.md → CHANGELOG-2swgsx9f.md} +12 -0
  3. package/dist/cli.js +3437 -3437
  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 +13 -8
  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 +125 -82
  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 +1 -0
  62. package/src/session/claude-session-store.ts +4 -3
  63. package/src/task/executor.ts +6 -3
  64. package/src/tools/browser/launch.ts +9 -0
  65. package/src/utils/git.ts +27 -0
  66. package/src/utils/shell-snapshot.ts +5 -1
@@ -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";
@@ -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 sweepStaleBackups} on the next update once it is no
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 backups left by earlier runs.
1004
+ * Best-effort removal of binary-update leftovers from earlier runs.
1004
1005
  *
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.
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 sweepStaleBackups(targetPath: string): Promise<void> {
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}.`) || !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);
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
- await removeBackupBestEffort(path.join(dir, entry));
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
- 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`;
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
- console.log(chalk.dim("Installing update..."));
1369
- await replaceBinaryForUpdate({
1370
- targetPath,
1371
- tempPath,
1372
- backupPath,
1373
- expectedVersion,
1374
- verifyInstalledVersion: options.verifyInstalledVersion ?? verifyInstalledVersion,
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 tempPath = `${exePath}.new`;
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
- 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;
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
- 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);
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
- // 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 {}
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 { launcher, original } of forwarded) {
1478
- try {
1479
- await Bun.write(launcher, original);
1480
- } catch {}
1529
+ for (const { backup } of retired) {
1530
+ await removeBackupBestEffort(backup);
1481
1531
  }
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
- }
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
- // 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 => {
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
- * Config directory bases in priority order (highest first).
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: () => path.join(os.homedir(), globalAgentDir ? globalAgentDir() : dir),
84
+ base: () =>
85
+ dir === ".claude" ? resolveClaudePaths().configDir : path.join(os.homedir(), globalAgentDir?.() ?? dir),
85
86
  name: dir,
86
87
  }));
87
88
 
@@ -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
- * {@link HOST_PROBE_TIMEOUT_MS}. On timeout the child is killed with SIGKILL so
72
- * a wedged interop pipe cannot hang startup; the killed/non-zero exit is then
73
- * reported as "unavailable" and discovery falls back to the Linux
74
- * `$HOME`/`~/.omp` candidates.
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: HOST_PROBE_TIMEOUT_MS,
81
+ timeout: timeoutMs,
82
82
  killSignal: "SIGKILL",
83
83
  });
84
84
  if (result.exitCode !== 0) return undefined;
@@ -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
- return path.join(ctx.home, CONFIG_DIR);
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 = getUserClaude(ctx);
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);
@@ -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"). External tools
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 the plugin cache.
907
- * Reads ~/.claude/plugins/installed_plugins.json and ~/.omp/plugins/installed_plugins.json,
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 home, project registry, and canonical active project.
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 = `${home}:${resolvedProjectPath ?? ""}:${activeClaudeProjectPath ?? ""}`;
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(home, ".claude", "plugins", "installed_plugins.json");
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
- const ompRegistryPath = path.join(getPluginsDir(home), "installed_plugins.json");
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(os.homedir(), ".claude", "plugins", "installed_plugins.json"));
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. */