@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
@@ -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 {
@@ -283,7 +283,7 @@ export class ExtensionUiController {
283
283
  },
284
284
  };
285
285
 
286
- extensionRunner.initialize(actions, contextActions, commandActions, uiContext);
286
+ extensionRunner.initialize(actions, contextActions, commandActions, uiContext, "tui");
287
287
 
288
288
  // Subscribe to extension errors
289
289
  extensionRunner.onError((error: ExtensionError) => {
@@ -512,7 +512,7 @@ export class ExtensionUiController {
512
512
  },
513
513
  };
514
514
 
515
- extensionRunner.initialize(actions, contextActions, commandActions, uiContext);
515
+ extensionRunner.initialize(actions, contextActions, commandActions, uiContext, "tui");
516
516
  }
517
517
 
518
518
  /**
@@ -359,6 +359,20 @@ function readPersistedToolNames(value: unknown): string[] | undefined {
359
359
  return value as string[];
360
360
  }
361
361
 
362
+ export function shouldEnterPlanModeOnStartup(
363
+ sessionManager: Pick<SessionManager, "buildSessionContext" | "getEntries">,
364
+ sessionSettings: Pick<Settings, "get">,
365
+ ): boolean {
366
+ const hasConversationContext = sessionManager.buildSessionContext().messages.length > 0;
367
+ const hasExplicitMode = sessionManager.getEntries().some(entry => entry.type === "mode_change");
368
+ return (
369
+ !hasConversationContext &&
370
+ !hasExplicitMode &&
371
+ sessionSettings.get("plan.defaultOnStartup") &&
372
+ sessionSettings.get("plan.enabled")
373
+ );
374
+ }
375
+
362
376
  /** Options for creating an InteractiveMode instance (for future API use) */
363
377
  export interface InteractiveModeOptions {
364
378
  /** Providers that were migrated during startup */
@@ -1140,14 +1154,7 @@ export class InteractiveMode implements InteractiveModeContext {
1140
1154
  // execution handoff clear never get dragged back into plan mode. #enterPlanMode
1141
1155
  // is idempotent and self-guards against an already-active plan/goal mode; it
1142
1156
  // does not check plan.enabled itself.
1143
- const hasConversationContext = this.sessionManager.buildSessionContext().messages.length > 0;
1144
- const hasExplicitMode = this.sessionManager.getEntries().some(entry => entry.type === "mode_change");
1145
- const isFreshSession = !hasConversationContext && !hasExplicitMode;
1146
- if (
1147
- isFreshSession &&
1148
- this.session.settings.get("plan.defaultOnStartup") &&
1149
- this.session.settings.get("plan.enabled")
1150
- ) {
1157
+ if (shouldEnterPlanModeOnStartup(this.sessionManager, this.session.settings)) {
1151
1158
  await this.#enterPlanMode();
1152
1159
  }
1153
1160
 
@@ -4597,7 +4604,7 @@ export class InteractiveMode implements InteractiveModeContext {
4597
4604
  this.#uiHelpers.renderSessionContext(sessionContext, options);
4598
4605
  }
4599
4606
 
4600
- /** Render a session context in bounded chunks so terminal input runs between transcript paints. */
4607
+ /** Build a session context in bounded chunks so terminal input runs between event-loop turns. */
4601
4608
  async renderSessionContextIncrementally(
4602
4609
  sessionContext: SessionContext,
4603
4610
  options: RenderSessionContextOptions,
@@ -118,6 +118,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
118
118
  }
119
119
  // Set up extensions for print mode (no UI, no command context)
120
120
  await initializeExtensions(session, {
121
+ mode: mode === "json" ? "json" : "print",
121
122
  reportSendError: (action, err) => {
122
123
  process.stderr.write(
123
124
  `Extension ${action === "extension_send" ? "sendMessage" : "sendUserMessage"} failed: ${err.message}\n`,
@@ -62,6 +62,8 @@ export interface RpcClientOptions {
62
62
  sessionDir?: string;
63
63
  /** Additional CLI arguments */
64
64
  args?: string[];
65
+ /** Grace period before escalating process termination (default: process utility default, 1000ms) */
66
+ terminationGraceMs?: number;
65
67
  /** Custom tools owned by the embedding host and exposed over the RPC transport */
66
68
  customTools?: RpcClientCustomTool[];
67
69
  }
@@ -324,7 +326,7 @@ export class RpcClient {
324
326
  this.#pendingHostToolCalls.clear();
325
327
 
326
328
  try {
327
- child.kill();
329
+ child.kill(undefined, this.options.terminationGraceMs);
328
330
  } catch {
329
331
  // The process may already have exited.
330
332
  }
@@ -440,7 +442,7 @@ export class RpcClient {
440
442
 
441
443
  const error = new Error("Client stopped");
442
444
  const child = this.#process;
443
- child.kill();
445
+ child.kill(undefined, this.options.terminationGraceMs);
444
446
  this.#abortController.abort(error);
445
447
  this.#process = null;
446
448
  for (const request of this.#pendingRequests.values()) request.reject(error);
@@ -1,3 +1,5 @@
1
+ import { readLines } from "@oh-my-pi/pi-utils";
2
+
1
3
  /**
2
4
  * Claims Bun's singleton stdin reader immediately and exposes a separately readable stream.
3
5
  * RPC startup uses this before extension discovery so in-process modules cannot steal protocol input.
@@ -36,3 +38,28 @@ export function claimRpcInput(): ReadableStream<Uint8Array> {
36
38
  },
37
39
  });
38
40
  }
41
+
42
+ /**
43
+ * Parses newline-delimited RPC input without letting one malformed line stop
44
+ * subsequent protocol frames.
45
+ */
46
+ export async function readRpcInputFrames(
47
+ input: ReadableStream<Uint8Array>,
48
+ onFrame: (frame: unknown) => void,
49
+ onParseError: (message: string) => void,
50
+ ): Promise<void> {
51
+ const decoder = new TextDecoder();
52
+ for await (const line of readLines(input)) {
53
+ const text = decoder.decode(line).trim();
54
+ if (!text) continue;
55
+ let parsed: unknown;
56
+ try {
57
+ parsed = JSON.parse(text);
58
+ } catch (error: unknown) {
59
+ const message = error instanceof Error ? error.message : String(error);
60
+ onParseError(`Failed to parse command: ${message}`);
61
+ continue;
62
+ }
63
+ onFrame(parsed);
64
+ }
65
+ }
@@ -13,7 +13,7 @@
13
13
  import { once } from "node:events";
14
14
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
15
15
  import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
16
- import { $env, isRecord, readLines, Snowflake } from "@oh-my-pi/pi-utils";
16
+ import { $env, isRecord, Snowflake } from "@oh-my-pi/pi-utils";
17
17
  import { reset as resetCapabilities } from "../../capability";
18
18
  import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
19
19
  import {
@@ -37,7 +37,7 @@ import { initializeExtensions } from "../runtime-init";
37
37
  import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools";
38
38
  import { isRpcHostUriResult, RpcHostUriBridge } from "./host-uris";
39
39
  import { MAX_RPC_FRAME_BYTES, MAX_RPC_REASSEMBLED_BYTES, RpcFrameEncoder } from "./rpc-frame";
40
- import { claimRpcInput } from "./rpc-input";
40
+ import { claimRpcInput, readRpcInputFrames } from "./rpc-input";
41
41
  import { pageRpcMessages, RPC_MESSAGES_PAGE_BUSY_ERROR, RpcMessagesPageError } from "./rpc-messages";
42
42
  import { RpcSubagentRegistry, readRpcSubagentTranscript } from "./rpc-subagents";
43
43
  import type {
@@ -933,6 +933,7 @@ export async function runRpcMode(
933
933
 
934
934
  // Set up extensions with RPC-based UI context
935
935
  await initializeExtensions(session, {
936
+ mode: "rpc",
936
937
  reportSendError: (action, err) => {
937
938
  output(error(undefined, action, err.message));
938
939
  },
@@ -1484,23 +1485,14 @@ export async function runRpcMode(
1484
1485
  // Keep the stdin reader moving: side-channel frames dispatch immediately,
1485
1486
  // ordinary commands serialize through inputDispatcher, and bash remains
1486
1487
  // background-dispatched so abort_bash can overtake it. Frames are read
1487
- // line-by-line and parsed here (not via readJsonl) so a single malformed
1488
- // line is reported as an error frame and the loop keeps running instead of
1489
- // throwing out of the generator and killing the whole process (issue #5194).
1490
- const decoder = new TextDecoder();
1491
- for await (const line of readLines(input ?? Bun.stdin.stream())) {
1492
- const text = decoder.decode(line).trim();
1493
- if (!text) continue;
1494
- let parsed: unknown;
1495
- try {
1496
- parsed = JSON.parse(text);
1497
- } catch (e: unknown) {
1498
- const message = e instanceof Error ? e.message : String(e);
1499
- output(error(undefined, "parse", `Failed to parse command: ${message}`));
1500
- continue;
1501
- }
1502
- inputDispatcher.dispatch(parsed);
1503
- }
1488
+ // line-by-line by readRpcInputFrames so a single malformed line is reported
1489
+ // as an error frame and the loop keeps running instead of throwing out of
1490
+ // the reader and killing the whole process (issue #5194).
1491
+ await readRpcInputFrames(
1492
+ input ?? Bun.stdin.stream(),
1493
+ parsed => inputDispatcher.dispatch(parsed),
1494
+ message => output(error(undefined, "parse", message)),
1495
+ );
1504
1496
 
1505
1497
  // stdin closed — RPC client is gone. Fail pending side-channel requests
1506
1498
  // first so active/queued commands can settle, then drain accepted work.
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler";
10
10
  import { getSessionSlashCommands } from "../extensibility/extensions/get-commands-handler";
11
- import type { ExtensionError, ExtensionUIContext } from "../extensibility/extensions/types";
11
+ import type { ExtensionError, ExtensionMode, ExtensionUIContext } from "../extensibility/extensions/types";
12
12
  import type { AgentSession } from "../session/agent-session";
13
13
  import { USER_INTERRUPT_LABEL } from "../session/messages";
14
14
 
@@ -22,6 +22,8 @@ export interface InitializeExtensionsOptions {
22
22
  reportRuntimeError: (error: ExtensionError) => void;
23
23
  /** Optional shutdown hook (rpc mode signals its loop; print mode is a no-op). */
24
24
  onShutdown?: () => void;
25
+ /** Pi-compatible mode exposed to extension contexts. Defaults to `"print"`. */
26
+ mode?: ExtensionMode;
25
27
  /** Optional UI context (rpc supplies one; print runs headless). */
26
28
  uiContext?: ExtensionUIContext;
27
29
  /** Optional lifecycle hook for extension-originated messages that can start an agent turn. */
@@ -44,6 +46,7 @@ export async function initializeExtensions(session: AgentSession, options: Initi
44
46
  reportSendError,
45
47
  reportRuntimeError,
46
48
  onShutdown,
49
+ mode = "print",
47
50
  uiContext,
48
51
  markAgentInvokingMessage,
49
52
  trackAgentInvokingMessage,
@@ -137,6 +140,7 @@ export async function initializeExtensions(session: AgentSession, options: Initi
137
140
  compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
138
141
  },
139
142
  uiContext,
143
+ mode,
140
144
  );
141
145
 
142
146
  runner.onError(reportRuntimeError);