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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/{CHANGELOG-66nakf5b.md → CHANGELOG-fr2awajz.md} +20 -0
  3. package/dist/cli.js +2823 -2823
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/extension-flags.d.ts +3 -3
  7. package/dist/types/cli/flag-tables.d.ts +0 -1
  8. package/dist/types/cli/setup-cli.d.ts +10 -0
  9. package/dist/types/cli/update-cli.d.ts +48 -9
  10. package/dist/types/commands/completions.d.ts +3 -0
  11. package/dist/types/config/claude-paths.d.ts +7 -0
  12. package/dist/types/discovery/agents.d.ts +6 -6
  13. package/dist/types/discovery/helpers.d.ts +3 -4
  14. package/dist/types/extensibility/extensions/runner.d.ts +2 -2
  15. package/dist/types/extensibility/extensions/types.d.ts +4 -0
  16. package/dist/types/launch/broker.d.ts +5 -1
  17. package/dist/types/main.d.ts +1 -1
  18. package/dist/types/mcp/transports/stdio.d.ts +6 -3
  19. package/dist/types/modes/components/footer.d.ts +3 -2
  20. package/dist/types/modes/interactive-mode.d.ts +2 -1
  21. package/dist/types/modes/rpc/rpc-client.d.ts +2 -0
  22. package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
  23. package/dist/types/modes/runtime-init.d.ts +3 -1
  24. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  25. package/dist/types/task/executor.d.ts +2 -0
  26. package/dist/types/utils/git.d.ts +19 -0
  27. package/dist/types/utils/shell-snapshot.d.ts +4 -1
  28. package/package.json +13 -13
  29. package/src/async/job-manager.ts +33 -4
  30. package/src/cli/args.ts +14 -3
  31. package/src/cli/extension-flags.ts +6 -10
  32. package/src/cli/flag-tables.ts +2 -10
  33. package/src/cli/gc-cli.ts +13 -3
  34. package/src/cli/setup-cli.ts +2 -2
  35. package/src/cli/update-cli.ts +246 -107
  36. package/src/commands/completions.ts +16 -14
  37. package/src/config/claude-paths.ts +18 -0
  38. package/src/config/model-registry.ts +2 -2
  39. package/src/config.ts +4 -3
  40. package/src/discovery/agents.ts +7 -7
  41. package/src/discovery/claude.ts +5 -6
  42. package/src/discovery/helpers.ts +12 -11
  43. package/src/extensibility/extensions/runner.ts +5 -0
  44. package/src/extensibility/extensions/types.ts +5 -0
  45. package/src/extensibility/legacy-typebox.ts +45 -4
  46. package/src/launch/broker.ts +26 -4
  47. package/src/lsp/mux/server.ts +7 -1
  48. package/src/main.ts +30 -3
  49. package/src/mcp/transports/stdio.ts +7 -3
  50. package/src/modes/acp/acp-agent.ts +1 -0
  51. package/src/modes/components/footer.ts +17 -35
  52. package/src/modes/components/status-line/component.ts +14 -27
  53. package/src/modes/controllers/extension-ui-controller.ts +2 -2
  54. package/src/modes/interactive-mode.ts +16 -9
  55. package/src/modes/print-mode.ts +1 -0
  56. package/src/modes/rpc/rpc-client.ts +4 -2
  57. package/src/modes/rpc/rpc-input.ts +27 -0
  58. package/src/modes/rpc/rpc-mode.ts +11 -19
  59. package/src/modes/runtime-init.ts +5 -1
  60. package/src/modes/utils/ui-helpers.ts +74 -53
  61. package/src/session/agent-session.ts +24 -9
  62. package/src/session/claude-session-store.ts +4 -3
  63. package/src/task/executor.ts +8 -4
  64. package/src/tools/browser/launch.ts +9 -0
  65. package/src/tools/read-format.ts +11 -10
  66. package/src/tools/run-scope.ts +4 -2
  67. package/src/utils/external-editor.ts +10 -11
  68. package/src/utils/git.ts +27 -0
  69. package/src/utils/shell-snapshot.ts +5 -1
  70. package/src/web/search/providers/gemini.ts +4 -9
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. */
@@ -1,4 +1,5 @@
1
1
  import { type } from "@oh-my-pi/omptype";
2
+ import { IR_BRAND } from "@oh-my-pi/omptype/ir";
2
3
  import {
3
4
  type AnySchema,
4
5
  type ObjectOpts,
@@ -40,6 +41,44 @@ function isRuntimeSchema(value: unknown): value is AnySchema {
40
41
  return typeof value === "function";
41
42
  }
42
43
 
44
+ /**
45
+ * Deep-copy a legacy `Type.Unsafe` document into a plain, structured-cloneable
46
+ * JSON Schema, lowering any embedded omptype schema to its wire JSON. Legacy
47
+ * Pi extensions were written against real TypeBox, whose `Type.*` builders
48
+ * return plain JSON-Schema objects; omptype's builders return callable schema
49
+ * values instead, which breaks two idioms extensions use inside raw documents:
50
+ *
51
+ * - Direct embedding — `Type.Unsafe({ anyOf: [Type.Array(...), Other] })`.
52
+ * The nested schema is a function; `structuredClone` throws
53
+ * `DataCloneError: The object can not be cloned.` (issue #8420) and omptype
54
+ * would drop its `toJsonSchema()` override during composition anyway.
55
+ * - Spreading — `Type.Unsafe({ ...Schema, description })`. Spreading a
56
+ * callable copies omptype's internal fields (`ir`, `run`, `$`, …) instead
57
+ * of JSON keywords. The copied `run` is a self-reference to the original
58
+ * schema, so its `toJsonSchema()` recovers the real wire document; the
59
+ * caller's own additions (everything not an omptype internal) are overlaid.
60
+ */
61
+ function lowerEmbeddedSchemas(value: unknown): unknown {
62
+ if (isRuntimeSchema(value)) return value.toJsonSchema();
63
+ if (Array.isArray(value)) return value.map(lowerEmbeddedSchemas);
64
+ if (value !== null && typeof value === "object") {
65
+ const source = value as Record<string, unknown>;
66
+ const canonical = source.run;
67
+ if (IR_BRAND in value && isRuntimeSchema(canonical)) {
68
+ const base = canonical.toJsonSchema();
69
+ const internalKeys = new Set(Object.keys(canonical));
70
+ for (const key in source) {
71
+ if (!internalKeys.has(key)) base[key] = lowerEmbeddedSchemas(source[key]);
72
+ }
73
+ return base;
74
+ }
75
+ const result: Record<string, unknown> = {};
76
+ for (const key in source) result[key] = lowerEmbeddedSchemas(source[key]);
77
+ return result;
78
+ }
79
+ return value;
80
+ }
81
+
43
82
  function defineHidden(target: object, key: PropertyKey, value: unknown): void {
44
83
  Object.defineProperty(target, key, {
45
84
  value,
@@ -50,12 +89,14 @@ function defineHidden(target: object, key: PropertyKey, value: unknown): void {
50
89
 
51
90
  function unsafe<T = unknown>(jsonSchema: Record<string, unknown> = {}): LegacyUnsafeSchema<T> {
52
91
  // `document` is the verbatim wire schema; keep it isolated from the validator.
92
+ // `lowerEmbeddedSchemas` returns a fresh plain-JSON copy (lowering any nested
93
+ // omptype builder to its wire form), so it doubles as the detaching clone.
53
94
  // `upgradeJsonSchemaTo202012` returns its input untouched when no upgrade is
54
95
  // needed, and `validateJsonSchemaValue` then annotates that object with JIT
55
96
  // epoch metadata and normalized keywords — which would leak into emission if
56
- // the two shared a reference.
57
- const document = structuredClone(jsonSchema);
58
- const upgradedSchema = upgradeJsonSchemaTo202012(structuredClone(jsonSchema));
97
+ // the two shared a reference, so give the validator its own structured clone.
98
+ const document = lowerEmbeddedSchemas(jsonSchema) as Record<string, unknown>;
99
+ const upgradedSchema = upgradeJsonSchemaTo202012(structuredClone(document));
59
100
  const validate = (data: unknown): T | ValidationFailure => {
60
101
  const result = validateJsonSchemaValue(upgradedSchema, data);
61
102
  if (result.success) return data as T;
@@ -125,7 +166,7 @@ const object = ((properties: Record<string, unknown>, opts?: ObjectOpts) => {
125
166
  const document = OmpType.Object(normalizedProperties, objectOpts).toJsonSchema();
126
167
  document.additionalProperties = isRuntimeSchema(additionalProperties)
127
168
  ? additionalProperties.toJsonSchema()
128
- : structuredClone(additionalProperties);
169
+ : lowerEmbeddedSchemas(additionalProperties);
129
170
  return unsafe(document);
130
171
  }
131
172
  return OmpType.Object(normalizedProperties, normalizedOpts);
@@ -37,6 +37,7 @@ const MAX_LOG_BYTES = 25 * 1024 * 1024;
37
37
  const LOG_READ_BYTES = 2 * 1024 * 1024;
38
38
  const READINESS_BUFFER_CHARS = 64 * 1024;
39
39
  const RESTART_MAX_DELAY_MS = 30_000;
40
+ const RESTART_BACKOFF_BASE_MS = 1_000;
40
41
  /**
41
42
  * Cap on terminal (exited/failed) daemons surfaced by `list`. Active daemons
42
43
  * are always shown in full; older history is truncated so the response stays
@@ -351,6 +352,7 @@ class DaemonBroker {
351
352
  readonly #endpoint: string;
352
353
  readonly #token: string;
353
354
  readonly #idleGraceMs: number;
355
+ readonly #restartBackoffBaseMs: number;
354
356
  readonly #records = new Map<string, ManagedDaemon>();
355
357
  /**
356
358
  * Names reserved by an in-flight `start` before its record lands in
@@ -371,12 +373,19 @@ class DaemonBroker {
371
373
  #idleTimer: NodeJS.Timeout | undefined;
372
374
  #shuttingDown = false;
373
375
 
374
- constructor(projectDir: string, runtimeDir: string, token: string, idleGraceMs: number) {
376
+ constructor(
377
+ projectDir: string,
378
+ runtimeDir: string,
379
+ token: string,
380
+ idleGraceMs: number,
381
+ restartBackoffBaseMs: number,
382
+ ) {
375
383
  this.#projectDir = projectDir;
376
384
  this.#runtimeDir = runtimeDir;
377
385
  this.#endpoint = daemonBrokerEndpoint(projectDir, runtimeDir);
378
386
  this.#token = token;
379
387
  this.#idleGraceMs = idleGraceMs;
388
+ this.#restartBackoffBaseMs = restartBackoffBaseMs;
380
389
  }
381
390
 
382
391
  async run(): Promise<void> {
@@ -955,7 +964,10 @@ class DaemonBroker {
955
964
  record.snapshot.readyAt = undefined;
956
965
  record.snapshot.readyMatch = undefined;
957
966
  record.snapshot.state = "restarting";
958
- const delay = Math.min(1_000 * 2 ** Math.min(record.consecutiveFailures, 5), RESTART_MAX_DELAY_MS);
967
+ const delay = Math.min(
968
+ this.#restartBackoffBaseMs * 2 ** Math.min(record.consecutiveFailures, 5),
969
+ RESTART_MAX_DELAY_MS,
970
+ );
959
971
  record.log?.append(
960
972
  `\n[daemon exited${exitCode === undefined ? "" : ` with code ${exitCode}`}; restarting in ${delay}ms]\n`,
961
973
  );
@@ -1347,8 +1359,13 @@ class DaemonBroker {
1347
1359
  }
1348
1360
  }
1349
1361
 
1362
+ export interface DaemonBrokerStartOptions {
1363
+ /** Base of the exponential child-restart backoff. */
1364
+ restartBackoffBaseMs?: number;
1365
+ }
1366
+
1350
1367
  /** Start the detached project or global daemon broker selected by the CLI worker host. */
1351
- export async function startDaemonBrokerFromEnvironment(): Promise<void> {
1368
+ export async function startDaemonBrokerFromEnvironment(options: DaemonBrokerStartOptions = {}): Promise<void> {
1352
1369
  const projectDir = process.env[DAEMON_PROJECT_DIR_ENV];
1353
1370
  const runtimeDir = process.env[DAEMON_RUNTIME_DIR_ENV];
1354
1371
  if (!projectDir || !runtimeDir) throw new Error("Daemon broker environment is incomplete");
@@ -1358,13 +1375,18 @@ export async function startDaemonBrokerFromEnvironment(): Promise<void> {
1358
1375
  delete process.env[DAEMON_IDLE_GRACE_ENV];
1359
1376
  const parsedGrace = rawGrace === undefined ? DEFAULT_IDLE_GRACE_MS : Number.parseInt(rawGrace, 10);
1360
1377
  const idleGraceMs = Number.isFinite(parsedGrace) && parsedGrace >= 0 ? parsedGrace : DEFAULT_IDLE_GRACE_MS;
1378
+ const requestedRestartBackoffBaseMs = options.restartBackoffBaseMs ?? RESTART_BACKOFF_BASE_MS;
1379
+ const restartBackoffBaseMs =
1380
+ Number.isFinite(requestedRestartBackoffBaseMs) && requestedRestartBackoffBaseMs >= 0
1381
+ ? requestedRestartBackoffBaseMs
1382
+ : RESTART_BACKOFF_BASE_MS;
1361
1383
  await fs.mkdir(runtimeDir, { recursive: true, mode: 0o700 });
1362
1384
  const lease = await acquireBrokerLease(runtimeDir);
1363
1385
  if (!lease) return;
1364
1386
  setProcessName("omp daemon broker");
1365
1387
  const token = (await Bun.file(path.join(runtimeDir, TOKEN_FILE)).text()).trim();
1366
1388
  if (!token) throw new Error("Daemon broker token is empty");
1367
- const broker = new DaemonBroker(projectDir, runtimeDir, token, idleGraceMs);
1389
+ const broker = new DaemonBroker(projectDir, runtimeDir, token, idleGraceMs, restartBackoffBaseMs);
1368
1390
  const cancelCleanup = postmortem.register("daemon-broker", () => broker.shutdown());
1369
1391
  try {
1370
1392
  await broker.run();
@@ -687,7 +687,13 @@ export class LspMuxServer {
687
687
  server.pending.set(id, { resolveInternal: resolve });
688
688
  try {
689
689
  await this.#writeServer(server, { jsonrpc: "2.0", id, method: "shutdown", params: null });
690
- await Promise.race([promise, Bun.sleep(SHUTDOWN_BUDGET_MS)]);
690
+ const timeout = Promise.withResolvers<void>();
691
+ const timer = setTimeout(timeout.resolve, SHUTDOWN_BUDGET_MS);
692
+ try {
693
+ await Promise.race([promise, timeout.promise]);
694
+ } finally {
695
+ clearTimeout(timer);
696
+ }
691
697
  await this.#writeServer(server, { jsonrpc: "2.0", method: "exit" });
692
698
  } catch (error) {
693
699
  logger.warn("LSP mux graceful server shutdown failed", { server: server.key, error: String(error) });
package/src/main.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  } from "@oh-my-pi/pi-utils";
24
24
  import chalk from "@oh-my-pi/pi-utils/chalk";
25
25
  import { reset as resetCapabilities } from "./capability";
26
- import { type Args, reportUnrecognizedFlags } from "./cli/args";
26
+ import { type Args, reportUnrecognizedFlags, validateToolNames } from "./cli/args";
27
27
  import { applyExtensionFlags, type ExtensionFlagSink } from "./cli/extension-flags";
28
28
  import { processFileArguments } from "./cli/file-processor";
29
29
  import { buildInitialMessage } from "./cli/initial-message";
@@ -349,7 +349,7 @@ export interface AcpSessionFactoryOptions {
349
349
  sessionDir?: string;
350
350
  authStorage: AuthStorage;
351
351
  modelRegistry: ModelRegistry;
352
- parsedArgs: Pick<Args, "apiKey" | "trustedExtensions">;
352
+ parsedArgs: Pick<Args, "apiKey" | "trustedExtensions" | "tools">;
353
353
  rawArgs: string[];
354
354
  createSession: (options: CreateAgentSessionOptions) => Promise<CreateAgentSessionResult>;
355
355
  }
@@ -425,7 +425,27 @@ export function createAcpSessionFactory(args: AcpSessionFactoryOptions): AcpSess
425
425
  if (args.parsedArgs.apiKey && !args.baseOptions.model && nextSession.model) {
426
426
  args.authStorage.setRuntimeApiKey(nextSession.model.provider, args.parsedArgs.apiKey);
427
427
  }
428
- applyExtensionFlags(nextSession.extensionRunner, args.rawArgs);
428
+ const runner = nextSession.extensionRunner;
429
+ const reparsedArgs = applyExtensionFlags(
430
+ runner
431
+ ? {
432
+ getFlags: () => runner.getFlags(),
433
+ setFlagValue: (name, value) => {
434
+ runner.setFlagValue(name, value);
435
+ },
436
+ }
437
+ : undefined,
438
+ args.rawArgs,
439
+ );
440
+ const requestedTools = reparsedArgs?.tools ?? args.parsedArgs.tools;
441
+ if (requestedTools) {
442
+ try {
443
+ validateToolNames(requestedTools, nextSession.getAllToolNames());
444
+ } catch (error) {
445
+ await nextSession.dispose();
446
+ throw error;
447
+ }
448
+ }
429
449
  return nextSession;
430
450
  };
431
451
  }
@@ -1685,6 +1705,13 @@ export async function runRootCommand(
1685
1705
  preloadedExtensions: extensionsResult,
1686
1706
  });
1687
1707
 
1708
+ try {
1709
+ validateToolNames(initialArgs.tools, session.getAllToolNames());
1710
+ } catch (error) {
1711
+ await session.dispose();
1712
+ throw error;
1713
+ }
1714
+
1688
1715
  // Cold-revive support: a `parked` subagent ref restored from disk (Agent Hub
1689
1716
  // scan, collab mirror, resumed process) has a sessionFile but no in-memory
1690
1717
  // reviver, so `ensureLive` (IRC sends, hub focus) would refuse it. Install a
@@ -495,7 +495,7 @@ function signalStdioProcess(
495
495
 
496
496
  /**
497
497
  * Terminate an MCP stdio subprocess: SIGTERM (process-group when `detached`
498
- * on POSIX, direct child otherwise), wait up to `TERM_GRACE_MS` for a
498
+ * on POSIX, direct child otherwise), wait up to `termGraceMs` for a
499
499
  * cooperative exit, then escalate to SIGKILL — waiting up to `KILL_GRACE_MS`
500
500
  * more only when the leader itself hadn't already exited. A detached
501
501
  * leader's cooperative exit does not prove the whole process group is gone
@@ -508,15 +508,19 @@ function signalStdioProcess(
508
508
  * `detached`/`platform` pair: `StdioTransport.connect()` derives `detached`
509
509
  * from `resolveStdioSpawnCommand()`, which is tied to the host's real
510
510
  * `process.platform`, so a POSIX detached session cannot be reproduced
511
- * end-to-end through `connect()` on a non-Linux dev/CI host.
511
+ * end-to-end through `connect()` on a non-Linux dev/CI host. `termGraceMs`
512
+ * preserves the production grace by default while allowing those real
513
+ * subprocess tests to cover the same transition without sleeping for a
514
+ * production-length shutdown window.
512
515
  */
513
516
  export async function terminateStdioProcess(
514
517
  proc: KillableSubprocess,
515
518
  detached: boolean,
516
519
  platform: NodeJS.Platform = process.platform,
520
+ termGraceMs = TERM_GRACE_MS,
517
521
  ): Promise<void> {
518
522
  signalStdioProcess(proc, detached, "SIGTERM", platform);
519
- const exitedOnTerm = await waitForProcessExit(proc.exited, TERM_GRACE_MS);
523
+ const exitedOnTerm = await waitForProcessExit(proc.exited, termGraceMs);
520
524
  // A non-detached transport has no process group beyond the leader itself:
521
525
  // once it exits, there is nothing left to signal. A detached transport's
522
526
  // leader exiting is NOT proof the group is empty — a grandchild it spawned
@@ -2419,6 +2419,7 @@ export class AcpAgent implements Agent {
2419
2419
  compact: instructionsOrOptions => runExtensionCompact(record.session, instructionsOrOptions),
2420
2420
  },
2421
2421
  uiContext,
2422
+ "rpc",
2422
2423
  );
2423
2424
  await extensionRunner.emit({ type: "session_start" });
2424
2425
  record.extensionsConfigured = true;
@@ -1,5 +1,3 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
1
  import { stripVTControlCharacters } from "node:util";
4
2
  import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
5
3
  import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
@@ -17,7 +15,7 @@ import { formatContextUsage, getContextUsageLevel, getContextUsageThemeColor } f
17
15
  */
18
16
  export class FooterComponent implements Component {
19
17
  #cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name
20
- #gitWatcher: fs.FSWatcher | null = null;
18
+ #gitUnwatch: (() => void) | null = null;
21
19
  #onBranchChange: (() => void) | null = null;
22
20
  #autoCompactEnabled: boolean = true;
23
21
  #extensionStatuses: Map<string, string> = new Map();
@@ -44,8 +42,9 @@ export class FooterComponent implements Component {
44
42
  }
45
43
 
46
44
  /**
47
- * Set up a file watcher on .git/HEAD to detect branch changes.
48
- * Call the provided callback when branch changes.
45
+ * Watch the repository HEAD for branch changes; invokes the callback so the
46
+ * footer repaints with the new branch. Uses `git.head.watch` (stat-poll) —
47
+ * see that helper for why `fs.watch` cannot track git's atomic HEAD swaps.
49
48
  */
50
49
  watchBranch(onBranchChange: () => void): void {
51
50
  this.#onBranchChange = onBranchChange;
@@ -53,46 +52,29 @@ export class FooterComponent implements Component {
53
52
  }
54
53
 
55
54
  #setupGitWatcher(): void {
56
- // Clean up existing watcher
57
- if (this.#gitWatcher) {
58
- this.#gitWatcher.close();
59
- this.#gitWatcher = null;
60
- }
55
+ this.#gitUnwatch?.();
56
+ this.#gitUnwatch = null;
61
57
 
62
58
  if (!settings.get("git.enabled")) return;
59
+ const repository = git.repo.resolveSync(getProjectDir());
60
+ if (!repository) return;
63
61
 
64
- void git.head
65
- .resolve(getProjectDir())
66
- .then(head => {
67
- if (!head) {
68
- return;
69
- }
70
-
71
- try {
72
- const watchPath = head.isReftable ? path.join(head.gitDir, "reftable") : head.headPath;
73
- this.#gitWatcher = fs.watch(watchPath, () => {
74
- this.#cachedBranch = undefined; // Invalidate cache
75
- if (this.#onBranchChange) {
76
- this.#onBranchChange();
77
- }
78
- });
79
- } catch {
80
- // Silently fail if we can't watch
81
- }
82
- })
83
- .catch(() => {
84
- this.#cachedBranch = null;
62
+ try {
63
+ this.#gitUnwatch = git.head.watch(repository, () => {
64
+ this.#cachedBranch = undefined; // Invalidate cache
65
+ this.#onBranchChange?.();
85
66
  });
67
+ } catch {
68
+ // Silently fail if we can't watch
69
+ }
86
70
  }
87
71
 
88
72
  /**
89
73
  * Clean up the file watcher
90
74
  */
91
75
  dispose(): void {
92
- if (this.#gitWatcher) {
93
- this.#gitWatcher.close();
94
- this.#gitWatcher = null;
95
- }
76
+ this.#gitUnwatch?.();
77
+ this.#gitUnwatch = null;
96
78
  }
97
79
 
98
80
  invalidate(): void {
@@ -1,4 +1,3 @@
1
- import * as fs from "node:fs";
2
1
  import * as path from "node:path";
3
2
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
4
3
  import type { AssistantMessage, UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
@@ -320,8 +319,7 @@ export class StatusLineComponent implements Component {
320
319
  // dropped rather than overwrite the value the newer resolve committed.
321
320
  // Mirrors #jjCacheGeneration / #getJjBranch in this file.
322
321
  #branchCacheGeneration = 0;
323
- #gitWatcher: fs.FSWatcher | null = null;
324
- #gitWatcherErrorListener: (() => void) | undefined = undefined;
322
+ #gitUnwatch: (() => void) | null = null;
325
323
  #gitWatcherUnavailable = false;
326
324
  #onBranchChange: (() => void) | null = null;
327
325
  #disposed = false;
@@ -665,40 +663,29 @@ export class StatusLineComponent implements Component {
665
663
  return;
666
664
  }
667
665
 
668
- const watchPath = git.repo.isReftableSync(repository)
669
- ? path.join(repository.gitDir, "reftable")
670
- : repository.headPath;
671
-
666
+ // git swaps HEAD via `HEAD.lock` + atomic rename. That both unlinks the
667
+ // HEAD inode (freezing a file-bound `fs.watch` after the first switch —
668
+ // issue #8412) and, on Bun/Linux, permanently wedges an inotify-backed
669
+ // directory watch after the first rename event (oven-sh/bun#24875).
670
+ // `git.head.watch` stat-polls the HEAD path (or the reftable dir), which
671
+ // survives inode swaps on every platform. A vanished repo surfaces as a
672
+ // stat change too, so there is no separate watcher error path.
672
673
  try {
673
- const watcher = fs.watch(watchPath, () => {
674
- if (this.#disposed || this.#gitWatcher !== watcher) return;
674
+ const unwatch = git.head.watch(repository, () => {
675
+ if (this.#disposed || this.#gitUnwatch !== unwatch) return;
675
676
  this.invalidateGitCaches();
676
677
  this.#onBranchChange?.();
677
678
  });
678
- const onError = () => {
679
- if (this.#gitWatcher !== watcher) return;
680
- this.#retireGitWatcher();
681
- this.#gitWatcherUnavailable = true;
682
- if (this.#disposed) return;
683
- this.invalidateGitCaches();
684
- this.#onBranchChange?.();
685
- };
686
- this.#gitWatcher = watcher;
687
- this.#gitWatcherErrorListener = onError;
688
- watcher.on("error", onError);
679
+ this.#gitUnwatch = unwatch;
689
680
  } catch {
690
681
  this.#gitWatcherUnavailable = true;
691
682
  }
692
683
  }
693
684
 
694
685
  #retireGitWatcher(): void {
695
- const watcher = this.#gitWatcher;
696
- const onError = this.#gitWatcherErrorListener;
697
- this.#gitWatcher = null;
698
- this.#gitWatcherErrorListener = undefined;
699
- if (!watcher) return;
700
- if (onError) watcher.off("error", onError);
701
- watcher.close();
686
+ const unwatch = this.#gitUnwatch;
687
+ this.#gitUnwatch = null;
688
+ unwatch?.();
702
689
  }
703
690
 
704
691
  dispose(): void {