@oh-my-pi/pi-coding-agent 17.0.3 → 17.0.5

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 (120) hide show
  1. package/CHANGELOG.md +88 -35
  2. package/dist/cli.js +3540 -3521
  3. package/dist/types/advisor/advise-tool.d.ts +3 -0
  4. package/dist/types/advisor/runtime.d.ts +7 -1
  5. package/dist/types/async/job-manager.d.ts +2 -0
  6. package/dist/types/config/model-registry.d.ts +7 -0
  7. package/dist/types/config/model-resolver.d.ts +8 -0
  8. package/dist/types/config/models-config-schema.d.ts +10 -0
  9. package/dist/types/config/models-config.d.ts +6 -0
  10. package/dist/types/dap/client.d.ts +10 -0
  11. package/dist/types/dap/types.d.ts +6 -5
  12. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  13. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +16 -0
  14. package/dist/types/launch/spawn-options.d.ts +10 -0
  15. package/dist/types/launch/spawn-options.test.d.ts +1 -0
  16. package/dist/types/mnemopi/state.d.ts +32 -9
  17. package/dist/types/modes/components/status-line/component.d.ts +2 -3
  18. package/dist/types/modes/components/status-line/types.d.ts +3 -1
  19. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  20. package/dist/types/modes/components/transcript-container.d.ts +4 -3
  21. package/dist/types/modes/components/tree-selector.d.ts +6 -2
  22. package/dist/types/modes/interactive-mode.d.ts +2 -0
  23. package/dist/types/modes/print-mode.d.ts +4 -0
  24. package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
  25. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  26. package/dist/types/modes/types.d.ts +2 -0
  27. package/dist/types/sdk.d.ts +2 -0
  28. package/dist/types/session/agent-session.d.ts +19 -1
  29. package/dist/types/session/messages.d.ts +6 -0
  30. package/dist/types/task/types.d.ts +6 -6
  31. package/dist/types/tools/browser/aria/aria-snapshot.d.ts +17 -7
  32. package/dist/types/tools/browser/cmux/socket-client.d.ts +2 -0
  33. package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
  34. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  35. package/dist/types/tools/gh.d.ts +5 -3
  36. package/dist/types/tools/hub/index.d.ts +1 -1
  37. package/dist/types/tools/hub/jobs.d.ts +1 -0
  38. package/dist/types/tools/hub/types.d.ts +2 -0
  39. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  40. package/dist/types/utils/git.d.ts +33 -0
  41. package/dist/types/web/search/providers/codex.d.ts +1 -1
  42. package/package.json +12 -12
  43. package/src/advisor/__tests__/advisor.test.ts +150 -0
  44. package/src/advisor/advise-tool.ts +4 -0
  45. package/src/advisor/runtime.ts +38 -14
  46. package/src/async/job-manager.ts +3 -0
  47. package/src/cli/bench-cli.ts +8 -2
  48. package/src/cli/dry-balance-cli.ts +1 -0
  49. package/src/config/model-registry.ts +89 -8
  50. package/src/config/model-resolver.ts +78 -14
  51. package/src/config/models-config-schema.ts +1 -0
  52. package/src/config/settings.ts +3 -1
  53. package/src/dap/client.ts +168 -1
  54. package/src/dap/config.ts +51 -1
  55. package/src/dap/session.ts +575 -234
  56. package/src/dap/types.ts +6 -5
  57. package/src/discovery/agents.ts +2 -2
  58. package/src/extensibility/extensions/runner.ts +6 -4
  59. package/src/extensibility/legacy-pi-coding-agent-shim.ts +26 -0
  60. package/src/launch/broker.ts +34 -31
  61. package/src/launch/client.ts +7 -1
  62. package/src/launch/spawn-options.test.ts +31 -0
  63. package/src/launch/spawn-options.ts +17 -0
  64. package/src/lsp/types.ts +5 -1
  65. package/src/main.ts +17 -4
  66. package/src/mcp/transports/stdio.test.ts +9 -1
  67. package/src/mnemopi/backend.ts +1 -1
  68. package/src/mnemopi/embed-worker.ts +8 -7
  69. package/src/mnemopi/state.ts +45 -18
  70. package/src/modes/components/ask-dialog.ts +137 -73
  71. package/src/modes/components/status-line/component.ts +2 -2
  72. package/src/modes/components/status-line/segments.ts +20 -3
  73. package/src/modes/components/status-line/types.ts +3 -1
  74. package/src/modes/components/tool-execution.ts +5 -0
  75. package/src/modes/components/transcript-container.ts +18 -111
  76. package/src/modes/components/tree-selector.ts +10 -4
  77. package/src/modes/controllers/event-controller.ts +1 -2
  78. package/src/modes/controllers/input-controller.ts +1 -1
  79. package/src/modes/controllers/selector-controller.ts +29 -8
  80. package/src/modes/interactive-mode.ts +40 -8
  81. package/src/modes/noninteractive-dispose.test.ts +12 -1
  82. package/src/modes/print-mode.ts +21 -9
  83. package/src/modes/rpc/rpc-input.ts +38 -0
  84. package/src/modes/rpc/rpc-mode.ts +7 -2
  85. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -1
  86. package/src/modes/types.ts +2 -0
  87. package/src/modes/utils/ui-helpers.ts +3 -2
  88. package/src/prompts/tools/browser.md +3 -2
  89. package/src/prompts/tools/debug.md +2 -7
  90. package/src/prompts/tools/github.md +6 -1
  91. package/src/prompts/tools/hub.md +4 -2
  92. package/src/prompts/tools/task-async-contract.md +1 -0
  93. package/src/prompts/tools/task.md +9 -2
  94. package/src/sdk.ts +38 -6
  95. package/src/session/agent-session.ts +395 -162
  96. package/src/session/messages.test.ts +91 -0
  97. package/src/session/messages.ts +248 -110
  98. package/src/session/session-loader.ts +31 -3
  99. package/src/slash-commands/builtin-registry.ts +1 -0
  100. package/src/stt/recorder.ts +68 -55
  101. package/src/task/executor.ts +59 -33
  102. package/src/task/index.ts +46 -9
  103. package/src/task/types.ts +11 -8
  104. package/src/task/worktree.ts +10 -0
  105. package/src/tools/browser/aria/aria-snapshot.ts +36 -8
  106. package/src/tools/browser/cmux/cmux-tab.ts +2 -1
  107. package/src/tools/browser/cmux/socket-client.ts +139 -3
  108. package/src/tools/browser/run-cancellation.ts +4 -0
  109. package/src/tools/browser/tab-protocol.ts +2 -0
  110. package/src/tools/browser/tab-supervisor.ts +21 -11
  111. package/src/tools/browser/tab-worker.ts +199 -33
  112. package/src/tools/debug.ts +3 -0
  113. package/src/tools/gh.ts +40 -2
  114. package/src/tools/hub/index.ts +4 -1
  115. package/src/tools/hub/jobs.ts +42 -1
  116. package/src/tools/hub/types.ts +2 -0
  117. package/src/tools/tool-timeouts.ts +1 -1
  118. package/src/utils/git.ts +237 -0
  119. package/src/web/search/index.ts +9 -5
  120. package/src/web/search/providers/codex.ts +195 -99
package/src/dap/types.ts CHANGED
@@ -484,10 +484,9 @@ export interface DapAdapterConfig {
484
484
  launchDefaults?: Record<string, unknown>;
485
485
  attachDefaults?: Record<string, unknown>;
486
486
  /** "stdio" (default): communicate via stdin/stdout pipes.
487
- * "socket": adapter uses a network socket instead of stdio.
488
- * On Linux, connects via a unix domain socket.
489
- * On macOS, the adapter dials into a local TCP listener (--client-addr). */
490
- connectMode?: "stdio" | "socket";
487
+ * "socket": adapter-specific socket launch (currently Delve).
488
+ * "tcp": spawn a DAP server with `${port}` substituted in `args`, then connect to it. */
489
+ connectMode?: "stdio" | "socket" | "tcp";
491
490
  /** When true, the adapter accepts a directory as the launch `program`
492
491
  * (e.g. dlv treats it as a Go package path). When false/undefined, the
493
492
  * debug tool rejects directory programs upfront. */
@@ -504,7 +503,7 @@ export interface DapResolvedAdapter {
504
503
  rootMarkers: string[];
505
504
  launchDefaults: Record<string, unknown>;
506
505
  attachDefaults: Record<string, unknown>;
507
- connectMode: "stdio" | "socket";
506
+ connectMode: "stdio" | "socket" | "tcp";
508
507
  acceptsDirectoryProgram: boolean;
509
508
  }
510
509
 
@@ -581,6 +580,8 @@ export interface DapSessionSummary {
581
580
  outputTruncated: boolean;
582
581
  exitCode?: number;
583
582
  needsConfigurationDone: boolean;
583
+ parentSessionId?: string;
584
+ childSessionIds?: string[];
584
585
  }
585
586
 
586
587
  export interface DapContinueOutcome {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Agents (standard) Provider
2
+ * Agent Dirs (.agent/.agents) Provider
3
3
  *
4
4
  * Loads skills, rules, prompts, commands, context files, and system prompts
5
5
  * from .agent/ and .agents/ directories at both user (~/) and project levels.
@@ -24,7 +24,7 @@ import {
24
24
  } from "./helpers";
25
25
 
26
26
  const PROVIDER_ID = "agents";
27
- const DISPLAY_NAME = "Agents (standard)";
27
+ const DISPLAY_NAME = "Agent Dirs (.agent/.agents)";
28
28
  const PRIORITY = 70;
29
29
  const AGENT_DIR_CANDIDATES = [".agent", ".agents"] as const;
30
30
 
@@ -534,8 +534,9 @@ export class ExtensionRunner {
534
534
  return undefined;
535
535
  }
536
536
 
537
- createContext(): ExtensionContext {
538
- const getModel = this.#getModel;
537
+ /** Creates an extension context, optionally scoped to a provider request model. */
538
+ createContext(model?: Model): ExtensionContext {
539
+ const getModel = model ? () => model : this.#getModel;
539
540
  return {
540
541
  ui: this.#uiContext,
541
542
  getContextUsage: () => this.#getContextUsageFn(),
@@ -965,8 +966,9 @@ export class ExtensionRunner {
965
966
  return currentMessages;
966
967
  }
967
968
 
968
- async emitBeforeProviderRequest(payload: unknown): Promise<BeforeProviderRequestEventResult> {
969
- const ctx = this.createContext();
969
+ /** Runs request payload hooks with the model used for that provider request. */
970
+ async emitBeforeProviderRequest(payload: unknown, model?: Model): Promise<BeforeProviderRequestEventResult> {
971
+ const ctx = this.createContext(model);
970
972
  let currentPayload = payload;
971
973
 
972
974
  for (const ext of this.extensions) {
@@ -22,8 +22,10 @@ import {
22
22
  getAgentDbPath,
23
23
  getAgentDir,
24
24
  getProjectDir,
25
+ isCompiledBinary,
25
26
  parseFrontmatter as parseOmpFrontmatter,
26
27
  } from "@oh-my-pi/pi-utils";
28
+ import { getPackageDir as getOmpPackageDir } from "../config";
27
29
  import type { PromptTemplate } from "../config/prompt-templates";
28
30
  import { type SettingPath, Settings } from "../config/settings";
29
31
  import { EditTool } from "../edit";
@@ -1334,6 +1336,30 @@ export function readStoredCredential(provider: string): AuthCredential | undefin
1334
1336
  return storage.get(provider);
1335
1337
  }
1336
1338
 
1339
+ // Pi SDK path helpers. `export * from "../index"` above only forwards
1340
+ // `getAgentDir`; `getProjectDir` (a `@oh-my-pi/pi-utils` helper) and
1341
+ // `getPackageDir` are absent from that barrel, so legacy extensions importing
1342
+ // either fail Bun's static export check during validation (issue #5968).
1343
+ export { getProjectDir } from "@oh-my-pi/pi-utils";
1344
+
1345
+ /**
1346
+ * Coding-agent package install directory, matching pi's string-valued
1347
+ * `getPackageDir()` contract (extensions do `path.join(getPackageDir(), ...)`
1348
+ * to auto-allow bundled docs/resources).
1349
+ *
1350
+ * omp's canonical `getPackageDir()` (`../config`) returns `undefined` inside a
1351
+ * `bun --compile` binary — `import.meta.dir` is `/$bunfs/root` and no owning
1352
+ * `package.json` exists (issue #1423). Returning `undefined` there would crash
1353
+ * every legacy `path.join(getPackageDir(), ...)` at runtime in the shipped
1354
+ * binary, the primary distribution. So fall back to the executable's own
1355
+ * directory in compiled mode, where the binary *is* the install root. The
1356
+ * `PI_PACKAGE_DIR` override and dev/source/npm-dist walk-up still win via the
1357
+ * canonical helper.
1358
+ */
1359
+ export function getPackageDir(): string {
1360
+ return getOmpPackageDir() ?? (isCompiledBinary() ? path.dirname(process.execPath) : process.cwd());
1361
+ }
1362
+
1337
1363
  export * from "../index";
1338
1364
  export { formatBytes as formatSize } from "../tools/render-utils";
1339
1365
  export { Type } from "./typebox";
@@ -4,6 +4,7 @@ import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { Process, type PtyRunResult, PtySession } from "@oh-my-pi/pi-natives";
6
6
  import { isEexist, isEnoent, logger, postmortem, procmgr, sanitizeText } from "@oh-my-pi/pi-utils";
7
+ import { hostHasInheritableConsole } from "../eval/py/spawn-options";
7
8
  import { truncateHead, truncateHeadBytes, truncateTail, truncateTailBytes } from "../session/streaming-output";
8
9
  import { workerEnvFromParent } from "../subprocess/worker-client";
9
10
  import { daemonBrokerEndpoint } from "./paths";
@@ -24,6 +25,7 @@ import {
24
25
  parseDaemonSpec,
25
26
  parseDaemonWireRequest,
26
27
  } from "./protocol";
28
+ import { resolveDaemonSpawnOptions } from "./spawn-options";
27
29
 
28
30
  const DEFAULT_IDLE_GRACE_MS = 3_000;
29
31
  const MAX_REQUEST_BYTES = 1024 * 1024;
@@ -36,6 +38,10 @@ const PID_FILE = "broker.pid";
36
38
  const META_FILE = "meta.json";
37
39
  const LOG_FILE = "output.log";
38
40
  const PREVIOUS_LOG_FILE = "output.previous.log";
41
+ const DAEMON_SPAWN_OPTIONS = resolveDaemonSpawnOptions({
42
+ platform: process.platform,
43
+ hostHasInheritableConsole: hostHasInheritableConsole(),
44
+ });
39
45
 
40
46
  const SIGNAL_NUMBER: Record<DaemonSignal, number> = {
41
47
  SIGINT: os.constants.signals.SIGINT,
@@ -537,6 +543,15 @@ class DaemonBroker {
537
543
  if (error) record.log?.append(`PTY output error: ${error.message}\n`);
538
544
  if (chunk) this.#onOutput(record, generation, chunk);
539
545
  };
546
+ const started = Promise.withResolvers<number | undefined>();
547
+ const onStart = (error: Error | null, pid: number): void => {
548
+ if (error) {
549
+ record.log?.append(`PTY startup callback failed: ${error.message}\n`);
550
+ started.resolve(undefined);
551
+ return;
552
+ }
553
+ started.resolve(Number.isSafeInteger(pid) && pid > 0 ? pid : undefined);
554
+ };
540
555
  let run: Promise<PtyRunResult>;
541
556
  if (process.platform === "win32") {
542
557
  run = session.startArgv(
@@ -546,41 +561,29 @@ class DaemonBroker {
546
561
  ...options,
547
562
  },
548
563
  onChunk,
564
+ onStart,
549
565
  );
550
566
  } else {
551
- const pidPath = path.join(record.dir, "process.pid");
552
- await fs.rm(pidPath, { force: true });
553
567
  const argv = [record.spec.application, ...record.spec.args];
554
- const command = [
555
- `printf '%s' "$$" > ${quoteShellArg(pidPath)}`,
556
- `exec ${argv.map(quoteShellArg).join(" ")}`,
557
- ].join("; ");
568
+ const command = `exec ${argv.map(quoteShellArg).join(" ")}`;
558
569
  const shell = procmgr.getShellConfig().shell;
559
- run = session.start({ command, shell, ...options }, onChunk);
570
+ run = session.start({ command, shell, ...options }, onChunk, onStart);
560
571
  }
561
- void run
562
- .then(result => this.#onPtyExit(record, generation, result))
563
- .catch(error =>
564
- this.#settle(record, generation, undefined, error instanceof Error ? error.message : String(error)),
565
- );
572
+ void run.then(
573
+ async result => {
574
+ await this.#onPtyExit(record, generation, result);
575
+ started.resolve(undefined);
576
+ },
577
+ async error => {
578
+ await this.#settle(record, generation, undefined, error instanceof Error ? error.message : String(error));
579
+ started.resolve(undefined);
580
+ },
581
+ );
566
582
 
567
- if (process.platform === "win32") return;
568
- const pidPath = path.join(record.dir, "process.pid");
569
- const deadline = Date.now() + 5_000;
570
- const pidFile = Bun.file(pidPath);
571
- while (Date.now() < deadline && generation === record.generation) {
572
- try {
573
- const pid = Number.parseInt((await pidFile.text()).trim(), 10);
574
- if (Number.isSafeInteger(pid) && pid > 0) {
575
- record.snapshot.pid = pid;
576
- this.#persist(record);
577
- return;
578
- }
579
- } catch (error) {
580
- if (!isEnoent(error)) throw error;
581
- }
582
- if (terminalState(record.snapshot.state)) return;
583
- await Bun.sleep(20);
583
+ const pid = await started.promise;
584
+ if (pid !== undefined && generation === record.generation) {
585
+ record.snapshot.pid = pid;
586
+ this.#persist(record);
584
587
  }
585
588
  }
586
589
 
@@ -591,7 +594,7 @@ class DaemonBroker {
591
594
  stdin: "pipe",
592
595
  stdout: "pipe",
593
596
  stderr: "pipe",
594
- detached: true,
597
+ ...DAEMON_SPAWN_OPTIONS,
595
598
  });
596
599
  record.process = process;
597
600
  record.input = process.stdin;
@@ -614,7 +617,7 @@ class DaemonBroker {
614
617
  cwd: record.spec.cwd,
615
618
  env: workerEnvFromParent(record.spec.env),
616
619
  stdio: ["ignore", output.fd, output.fd],
617
- detached: true,
620
+ ...DAEMON_SPAWN_OPTIONS,
618
621
  });
619
622
  record.process = process;
620
623
  record.snapshot.pid = process.pid;
@@ -3,6 +3,7 @@ import * as net from "node:net";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { isEexist, isEisdir, isEnoent, postmortem } from "@oh-my-pi/pi-utils";
6
+ import { hostHasInheritableConsole } from "../eval/py/spawn-options";
6
7
  import { resolveWorkerSpawnCmd, workerEnvFromParent } from "../subprocess/worker-client";
7
8
  import { daemonBrokerEndpoint, daemonRuntimeDir } from "./paths";
8
9
  import {
@@ -16,10 +17,15 @@ import {
16
17
  parseDaemonRpcResult,
17
18
  parseDaemonWireResponse,
18
19
  } from "./protocol";
20
+ import { resolveDaemonSpawnOptions } from "./spawn-options";
19
21
 
20
22
  const CONNECT_TIMEOUT_MS = 10_000;
21
23
  const CONNECT_RETRY_MS = 50;
22
24
  const TOKEN_FILE = "broker.token";
25
+ const BROKER_SPAWN_OPTIONS = resolveDaemonSpawnOptions({
26
+ platform: process.platform,
27
+ hostHasInheritableConsole: hostHasInheritableConsole(),
28
+ });
23
29
 
24
30
  interface PendingRequest {
25
31
  operation: DaemonOperation;
@@ -228,7 +234,7 @@ class SocketDaemonClient implements DaemonBrokerClient {
228
234
  stdin: "ignore",
229
235
  stdout: "ignore",
230
236
  stderr: "ignore",
231
- detached: true,
237
+ ...BROKER_SPAWN_OPTIONS,
232
238
  });
233
239
  child.unref();
234
240
  }
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { resolveDaemonSpawnOptions } from "./spawn-options";
3
+
4
+ describe("resolveDaemonSpawnOptions", () => {
5
+ it("hides Windows daemons when the host has no console", () => {
6
+ expect(
7
+ resolveDaemonSpawnOptions({
8
+ platform: "win32",
9
+ hostHasInheritableConsole: false,
10
+ }),
11
+ ).toEqual({ detached: false, windowsHide: true });
12
+ });
13
+
14
+ it("inherits the Windows host console instead of detaching", () => {
15
+ expect(
16
+ resolveDaemonSpawnOptions({
17
+ platform: "win32",
18
+ hostHasInheritableConsole: true,
19
+ }),
20
+ ).toEqual({ detached: false, windowsHide: false });
21
+ });
22
+
23
+ it("keeps POSIX daemons in their own session", () => {
24
+ expect(
25
+ resolveDaemonSpawnOptions({
26
+ platform: "linux",
27
+ hostHasInheritableConsole: false,
28
+ }),
29
+ ).toEqual({ detached: true });
30
+ });
31
+ });
@@ -0,0 +1,17 @@
1
+ /** Platform-specific options for the launch broker and its non-PTY children. */
2
+ export interface DaemonSpawnOptions {
3
+ detached: boolean;
4
+ windowsHide?: boolean;
5
+ }
6
+
7
+ /** Keep launch processes headless without discarding an inheritable Windows console. */
8
+ export function resolveDaemonSpawnOptions(opts: {
9
+ platform: NodeJS.Platform;
10
+ hostHasInheritableConsole: boolean;
11
+ }): DaemonSpawnOptions {
12
+ if (opts.platform !== "win32") return { detached: true };
13
+ return {
14
+ detached: false,
15
+ windowsHide: !opts.hostHasInheritableConsole,
16
+ };
17
+ }
package/src/lsp/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ptree } from "@oh-my-pi/pi-utils";
2
2
  import { type } from "arktype";
3
+ import { TOOL_TIMEOUTS } from "../tools/tool-timeouts";
3
4
 
4
5
  // =============================================================================
5
6
  // Tool Schema
@@ -14,7 +15,10 @@ export const lspSchema = type({
14
15
  query: "string?",
15
16
  new_name: "string?",
16
17
  apply: "boolean?",
17
- timeout: "number?",
18
+ "timeout?": type.number
19
+ .atLeast(TOOL_TIMEOUTS.lsp.min)
20
+ .atMost(TOOL_TIMEOUTS.lsp.max)
21
+ .describe("Timeout in seconds (default 20; range 5–300)."),
18
22
  payload: "string?",
19
23
  });
20
24
 
package/src/main.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  logger,
18
18
  normalizePathForComparison,
19
19
  postmortem,
20
+ setInteractiveHost,
20
21
  setProjectDir,
21
22
  VERSION,
22
23
  } from "@oh-my-pi/pi-utils";
@@ -56,6 +57,7 @@ import { registerDaemonProjectPresence } from "./launch/presence";
56
57
  import type { MCPManager } from "./mcp";
57
58
  import { InteractiveMode } from "./modes/interactive-mode";
58
59
  import type { PrintModeOptions } from "./modes/print-mode";
60
+ import { claimRpcInput } from "./modes/rpc/rpc-input";
59
61
  import { CURRENT_SETUP_VERSION } from "./modes/setup-version";
60
62
  import { initTheme, stopThemeWatcher } from "./modes/theme/theme";
61
63
  import type { SubmittedUserInput } from "./modes/types";
@@ -97,6 +99,7 @@ type RunRpcMode = (
97
99
  session: AgentSession,
98
100
  setToolUIContext?: (uiContext: ExtensionUIContext, hasUI: boolean) => void,
99
101
  eventBus?: EventBus,
102
+ input?: ReadableStream<Uint8Array>,
100
103
  ) => Promise<never>;
101
104
 
102
105
  export function writeStartupNotice(parsedArgs: Pick<Args, "mode">, text: string): void {
@@ -884,8 +887,12 @@ export async function buildSessionOptions(
884
887
  if (resolved.warning) {
885
888
  process.stderr.write(`${chalk.yellow(`Warning: ${resolved.warning}`)}\n`);
886
889
  }
887
- if (resolved.error) {
888
- if (!parsed.provider && !parsed.model.includes(":")) {
890
+ const matchedAfterMissingRolePattern = (resolved.configuredPatternIndex ?? 0) > 0;
891
+ if (matchedAfterMissingRolePattern) {
892
+ // Extensions may register an earlier configured role candidate.
893
+ options.modelPattern = parsed.model;
894
+ } else if (resolved.error) {
895
+ if (!parsed.provider && ((resolved.configuredPatterns?.length ?? 0) > 0 || !parsed.model.includes(":"))) {
889
896
  // Model not found in built-in registry — defer resolution to after extensions load
890
897
  // (extensions may register additional providers/models via registerProvider)
891
898
  options.modelPattern = parsed.model;
@@ -1106,6 +1113,9 @@ export async function runRootCommand(
1106
1113
  process.stderr.write(`${chalk.red("Error: @file arguments are not supported in RPC mode")}\n`);
1107
1114
  process.exit(1);
1108
1115
  }
1116
+ const mode = parsedArgs.mode || "text";
1117
+ // RPC owns stdin. Claim its singleton stream before plugin/extension discovery can load an in-process consumer.
1118
+ const rpcInput = mode === "rpc" || mode === "rpc-ui" ? claimRpcInput() : undefined;
1109
1119
 
1110
1120
  // Kick off plugin-root preload in parallel with the remaining startup work.
1111
1121
  // Awaited later (before extension/skill discovery in createAgentSession needs it).
@@ -1152,12 +1162,15 @@ export async function runRootCommand(
1152
1162
  if (parsedArgs.noTitle || parsedArgs.mode === "rpc" || parsedArgs.mode === "rpc-ui" || parsedArgs.mode === "acp") {
1153
1163
  Bun.env.PI_NO_TITLE = "1";
1154
1164
  }
1155
- const mode = parsedArgs.mode || "text";
1156
1165
  const isProtocolMode = mode === "rpc" || mode === "rpc-ui" || mode === "acp";
1157
1166
  // Protocol modes own stdin; treating it as prompt text would consume JSON-RPC frames before their transports start.
1158
1167
  const pipedInput = isProtocolMode ? undefined : await logger.time("readPipedInput", readPipedInput);
1159
1168
  const autoPrint = pipedInput !== undefined && !parsedArgs.print && parsedArgs.mode === undefined;
1160
1169
  const isInteractive = !parsedArgs.print && !autoPrint && parsedArgs.mode === undefined;
1170
+ // Only the interactive host renders a focusable Agent Hub / subagent session
1171
+ // tree; declare it so headless subagent optimizations (e.g. skipping replan
1172
+ // title refresh) can tell a focusable process from a print/RPC/eval one.
1173
+ setInteractiveHost(isInteractive);
1161
1174
 
1162
1175
  // Initialize discovery system with settings for provider persistence
1163
1176
  logger.time("initializeWithSettings", initializeWithSettings, settingsInstance);
@@ -1509,7 +1522,7 @@ export async function runRootCommand(
1509
1522
  // Branch-only protocol runner: keep RPC host code out of normal interactive startup.
1510
1523
  const runRpcMode: RunRpcMode = (await import("./modes/rpc/rpc-mode")).runRpcMode;
1511
1524
  stopStartupWatchdog();
1512
- await runRpcMode(session, mode === "rpc-ui" ? setToolUIContext : undefined, eventBus);
1525
+ await runRpcMode(session, mode === "rpc-ui" ? setToolUIContext : undefined, eventBus, rpcInput);
1513
1526
  } else if (isInteractive) {
1514
1527
  const versionCheckPromise = checkForNewVersion(VERSION).catch(() => undefined);
1515
1528
  const changelogMarkdown = await logger.time("main:getChangelogForDisplay", getChangelogForDisplay, parsedArgs);
@@ -153,13 +153,21 @@ describe.skipIf(process.platform === "win32")("StdioTransport request write stal
153
153
  }, 8000);
154
154
  });
155
155
 
156
+ // `kill(pid, 0)` succeeds for a zombie too: a grandchild whose parent (the
157
+ // killed leader) is gone sits as <defunct> until whatever reaps orphans
158
+ // (init/subreaper) gets around to it — which can lag on some hosts. A
159
+ // zombie already received and honored the group SIGKILL; it is just not
160
+ // harvested yet, so treating it as "still alive" would make the group-kill
161
+ // assertions below flaky rather than testing what they claim to test.
156
162
  function processExists(pid: number): boolean {
157
163
  try {
158
164
  process.kill(pid, 0);
159
- return true;
160
165
  } catch {
161
166
  return false;
162
167
  }
168
+ const result = Bun.spawnSync(["ps", "-o", "stat=", "-p", String(pid)]);
169
+ const state = result.stdout.toString().trim();
170
+ return result.exitCode === 0 && state.length > 0 && !state.startsWith("Z");
163
171
  }
164
172
 
165
173
  // Regression for #5578: `close()` used a bare `this.#process.kill()` (direct
@@ -145,7 +145,7 @@ export const mnemopiBackend: MemoryBackend = {
145
145
  state = new MnemopiSessionState({ sessionId: session.sessionId, config, session });
146
146
  setMnemopiSessionState(session, state);
147
147
  }
148
- await state?.consolidate();
148
+ await state?.consolidate({ full: true });
149
149
  } catch (error) {
150
150
  logger.warn("Mnemopi: enqueue failed.", { error: String(error) });
151
151
  }
@@ -9,8 +9,7 @@
9
9
  * in either process.
10
10
  */
11
11
 
12
- import type { StandardEmbeddingModel } from "@oh-my-pi/pi-mnemopi/core";
13
- import { loadFastembed } from "@oh-my-pi/pi-mnemopi/core/fastembed-runtime";
12
+ import { defaultLocalModelInitializer, type StandardEmbeddingModel } from "@oh-my-pi/pi-mnemopi/core";
14
13
  import type { MnemopiEmbedModelId, MnemopiEmbedTransport, MnemopiEmbedWorkerInbound } from "./embed-protocol";
15
14
 
16
15
  interface LoadedModel {
@@ -25,12 +24,14 @@ let loaded: Promise<LoadedModel> | null = null;
25
24
  let loadedKey = "";
26
25
 
27
26
  async function loadModel(model: MnemopiEmbedModelId, cacheDir: string | undefined): Promise<LoadedModel> {
28
- const { FlagEmbedding } = await loadFastembed();
27
+ // Route through mnemopi's shared initializer so the worker inherits BOTH
28
+ // cache heals (sidecar re-fetch AND corrupt-model quarantine/retry) —
29
+ // fastembed/onnxruntime still load only in this child address space, the
30
+ // initializer calls loadFastembed() itself.
29
31
  // Cast: `model` arrives as a string from the parent (resolved by
30
- // mnemopi's `fastembedModelName`). Cast to the non-CUSTOM overload's
31
- // argument so TypeScript picks the standard-model branch — the parent
32
- // only ever passes pre-vetted fast-* identifiers.
33
- const instance = await FlagEmbedding.init({
32
+ // mnemopi's `fastembedModelName`); the parent only ever passes pre-vetted
33
+ // fast-* identifiers.
34
+ const instance = await defaultLocalModelInitializer({
34
35
  model: model as StandardEmbeddingModel,
35
36
  cacheDir,
36
37
  showDownloadProgress: false,
@@ -454,18 +454,23 @@ export class MnemopiSessionState {
454
454
  this.lastRetainedTurn = userTurns;
455
455
  }
456
456
 
457
- async forceRetainCurrentSession(): Promise<void> {
457
+ async forceRetainCurrentSession(options: { extract?: boolean } = {}): Promise<void> {
458
458
  if (this.aliasOf) return;
459
459
  const flat = extractMessages(this.session.sessionManager);
460
- await this.retainMessages(flat, this.sessionId);
460
+ await this.retainMessages(flat, this.sessionId, options);
461
461
  this.lastRetainedTurn = flat.filter(message => message.role === "user").length;
462
462
  }
463
463
 
464
- async retainMessages(messages: Array<{ role: string; content: string }>, sourceId: string): Promise<void> {
464
+ async retainMessages(
465
+ messages: Array<{ role: string; content: string }>,
466
+ sourceId: string,
467
+ options: { extract?: boolean } = {},
468
+ ): Promise<void> {
465
469
  const { transcript, messageCount } = prepareRetentionTranscript(messages, true);
466
470
  if (!transcript) return;
467
471
  const { transcript: extractText } = prepareUserRetentionTranscript(messages);
468
472
  const { transcript: embedText } = prepareEmbeddableRetentionTranscript(messages);
473
+ const shouldExtract = options.extract !== false && extractText !== null;
469
474
  this.rememberInScope(transcript, {
470
475
  source: "coding-agent-transcript",
471
476
  importance: 0.65,
@@ -476,9 +481,9 @@ export class MnemopiSessionState {
476
481
  cwd: this.session.sessionManager.getCwd(),
477
482
  },
478
483
  scope: "bank",
479
- extract: extractText !== null,
480
- extractEntities: extractText !== null,
481
- extractText,
484
+ extract: shouldExtract,
485
+ extractEntities: shouldExtract,
486
+ extractText: shouldExtract ? extractText : null,
482
487
  embedText,
483
488
  veracity: "unknown",
484
489
  memoryType: "episode",
@@ -530,22 +535,42 @@ export class MnemopiSessionState {
530
535
  * otherwise enqueue would report success while leaving the subagent's
531
536
  * retained memories unconsolidated until the parent eventually shuts down
532
537
  * (PR #2327 review).
538
+ *
539
+ * @param options.full - When true, run `sleepAllSessions` on every owned bank
540
+ * (the full cross-session consolidation used by `/memory enqueue`). When
541
+ * false (the default), run only `sleep` on the current session for a
542
+ * lighter, bounded shutdown pass.
543
+ * @param options.sleep - When false, skips the bank sleep step entirely.
544
+ * Used on the interactive shutdown path so `dispose` does not block on
545
+ * synchronous consolidation of old working rows from previous sessions.
546
+ * @param options.extract - When false, the retained transcript is stored but
547
+ * no LLM fact extraction is scheduled. Used on the interactive shutdown path
548
+ * so `dispose` does not block on a fresh LLM round-trip.
533
549
  */
534
- async consolidate(): Promise<void> {
535
- await this.forceRetainCurrentSession();
550
+ async consolidate(options: { full?: boolean; extract?: boolean; sleep?: boolean } = {}): Promise<void> {
551
+ await this.forceRetainCurrentSession({ extract: options.extract });
536
552
  for (const memory of this.scoped.owned) {
537
553
  await memory.flushExtractions();
538
- memory.sleepAllSessions(false);
554
+ if (options.sleep === false) continue;
555
+ if (options.full) {
556
+ memory.sleepAllSessions(false);
557
+ } else {
558
+ memory.sleep(false);
559
+ }
539
560
  }
540
561
  }
541
562
 
542
563
  /**
543
- * Release the per-session resources. Defaults to running {@link consolidate}
544
- * before closing handles so normal session shutdown promotes working memory
545
- * into long-term storage. Callers that are about to delete the DB files —
546
- * e.g. `mnemopiBackend.clear` pass `{ consolidate: false }` to skip the
547
- * extraction/sleep pass, since spending tokens on memories that will be
548
- * wiped on the next line is wasted work (PR #2327 review).
564
+ * Release the per-session resources. Defaults to running a lighter
565
+ * {@link consolidate} pass before closing handles: it retains the current
566
+ * transcript and flushes in-flight extractions, but skips the synchronous
567
+ * bank sleep so normal session shutdown returns promptly. Full promotion of
568
+ * working memory into long-term storage is still performed by the explicit
569
+ * `/memory enqueue` and backend enqueue paths. Callers that are about to
570
+ * delete the DB files — e.g. `mnemopiBackend.clear` — pass
571
+ * `{ consolidate: false }` to skip the retain/flush pass, since spending
572
+ * tokens on memories that will be wiped on the next line is wasted work
573
+ * (PR #2327 review).
549
574
  *
550
575
  * `timeoutMs` caps how long the consolidate await blocks the caller
551
576
  * (the user-visible `/quit` / `/exit` shutdown path passes this so
@@ -569,9 +594,11 @@ export class MnemopiSessionState {
569
594
  closeOwned();
570
595
  return;
571
596
  }
572
- const consolidatePromise = this.consolidate().catch((error: unknown) => {
573
- logger.warn("Mnemopi: consolidation on dispose failed.", { error: String(error) });
574
- });
597
+ const consolidatePromise = this.consolidate({ full: false, extract: false, sleep: false }).catch(
598
+ (error: unknown) => {
599
+ logger.warn("Mnemopi: consolidation on dispose failed.", { error: String(error) });
600
+ },
601
+ );
575
602
  const { timeoutMs } = options;
576
603
  if (timeoutMs !== undefined && timeoutMs > 0) {
577
604
  const TIMED_OUT = Symbol("mnemopi.dispose.timedOut");