@selesai/code 0.13.38 → 0.13.40

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 (92) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/core/intercom-rpc.d.ts +135 -0
  3. package/dist/core/intercom-rpc.js +64 -0
  4. package/dist/core/release-rpc.d.ts +137 -0
  5. package/dist/core/release-rpc.js +77 -0
  6. package/dist/core/resource-loader.d.ts +3 -0
  7. package/dist/core/resource-loader.js +3 -0
  8. package/dist/core/subagent-rpc.d.ts +110 -0
  9. package/dist/core/subagent-rpc.js +60 -0
  10. package/dist/extensions/agent-browser.ts +9 -0
  11. package/dist/extensions/pi-graft/cli.test.ts +5 -5
  12. package/dist/extensions/pi-graft/cli.ts +10 -5
  13. package/dist/extensions/pi-graft/commands.test.ts +5 -5
  14. package/dist/extensions/pi-graft/graft.integration.test.ts +1 -1
  15. package/dist/extensions/pi-graft/lifecycle.test.ts +26 -26
  16. package/dist/extensions/pi-graft/prompt.test.ts +1 -1
  17. package/dist/extensions/pi-graft/state.test.ts +9 -9
  18. package/dist/extensions/pi-graft/test-support.ts +1 -1
  19. package/dist/extensions/pi-graft/tools.test.ts +1 -1
  20. package/dist/extensions/pi-intercom/README.md +15 -0
  21. package/dist/extensions/pi-intercom/broker/broker.ts +277 -13
  22. package/dist/extensions/pi-intercom/broker/client.ts +6 -2
  23. package/dist/extensions/pi-intercom/broker/mailbox-persistence.test.ts +523 -0
  24. package/dist/extensions/pi-intercom/broker/paths.test.ts +4 -4
  25. package/dist/extensions/pi-intercom/broker/paths.ts +4 -2
  26. package/dist/extensions/pi-intercom/broker/spawn.test.ts +1 -3
  27. package/dist/extensions/pi-intercom/extension-api.ts +10 -0
  28. package/dist/extensions/pi-intercom/index.ts +188 -8
  29. package/dist/extensions/pi-intercom/intercom.integration.test.ts +280 -0
  30. package/dist/extensions/pi-intercom/package.json +1 -1
  31. package/dist/extensions/pi-intercom/release-readiness.test.ts +116 -0
  32. package/dist/extensions/pi-intercom/release-readiness.ts +93 -0
  33. package/dist/extensions/pi-subagents/docs/extension-api.md +8 -0
  34. package/dist/extensions/pi-subagents/docs/observability.md +8 -0
  35. package/dist/extensions/pi-subagents/src/extension/index.ts +51 -1
  36. package/dist/extensions/pi-subagents/src/extension/observability.ts +164 -0
  37. package/dist/extensions/pi-subagents/src/extension/release-readiness.ts +367 -0
  38. package/dist/extensions/pi-subagents/src/extension/rpc.ts +8 -1
  39. package/dist/extensions/pi-subagents/src/integrations/pi-web-session-liveness.ts +7 -1
  40. package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +1 -44
  41. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +6 -0
  42. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +13 -3
  43. package/dist/extensions/pi-subagents/src/runs/background/result-delivery-ownership.ts +269 -7
  44. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +11 -10
  45. package/dist/extensions/pi-subagents/src/runs/background/retained-nested-route-tracker.ts +15 -3
  46. package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +64 -0
  47. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +69 -6
  48. package/dist/extensions/pi-subagents/src/runs/shared/subagent-control.ts +14 -1
  49. package/dist/extensions/pi-subagents/src/shared/owner-record.ts +107 -0
  50. package/dist/extensions/pi-subagents/src/shared/process-identity.ts +87 -0
  51. package/dist/extensions/pi-subagents/src/shared/types.ts +9 -0
  52. package/dist/extensions/pi-subagents/test/integration/async-attention-clearing.test.ts +191 -0
  53. package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +63 -0
  54. package/dist/extensions/pi-subagents/test/integration/stop-all-background.test.ts +142 -0
  55. package/dist/extensions/pi-subagents/test/support/fake-child-session.ts +8 -0
  56. package/dist/extensions/pi-subagents/test/unit/observability.test.ts +198 -0
  57. package/dist/extensions/pi-subagents/test/unit/process-identity.test.ts +75 -0
  58. package/dist/extensions/pi-subagents/test/unit/release-readiness.test.ts +715 -0
  59. package/dist/extensions/pi-subagents/test/unit/result-takeover.test.ts +332 -0
  60. package/dist/extensions/tokenin-onboarding.ts +142 -3
  61. package/dist/index.d.ts +6 -1
  62. package/dist/index.js +7 -0
  63. package/dist/main.d.ts +6 -0
  64. package/dist/main.js +1 -1
  65. package/dist/modes/index.d.ts +1 -1
  66. package/dist/modes/rpc/intercom-bridge.d.ts +14 -0
  67. package/dist/modes/rpc/intercom-bridge.js +75 -0
  68. package/dist/modes/rpc/intercom-bridge.test.d.ts +1 -0
  69. package/dist/modes/rpc/intercom-bridge.test.js +65 -0
  70. package/dist/modes/rpc/release-bridge.d.ts +35 -0
  71. package/dist/modes/rpc/release-bridge.js +159 -0
  72. package/dist/modes/rpc/release-bridge.test.d.ts +1 -0
  73. package/dist/modes/rpc/release-bridge.test.js +347 -0
  74. package/dist/modes/rpc/rpc-client.d.ts +34 -10
  75. package/dist/modes/rpc/rpc-client.js +45 -2
  76. package/dist/modes/rpc/rpc-intercom-transport.test.d.ts +1 -0
  77. package/dist/modes/rpc/rpc-intercom-transport.test.js +85 -0
  78. package/dist/modes/rpc/rpc-mode.d.ts +1 -13
  79. package/dist/modes/rpc/rpc-mode.js +239 -8
  80. package/dist/modes/rpc/rpc-release-transport.test.d.ts +1 -0
  81. package/dist/modes/rpc/rpc-release-transport.test.js +329 -0
  82. package/dist/modes/rpc/rpc-subagent-transport.test.d.ts +1 -0
  83. package/dist/modes/rpc/rpc-subagent-transport.test.js +60 -0
  84. package/dist/modes/rpc/rpc-types.d.ts +12 -2
  85. package/dist/modes/rpc/rpc-types.js +0 -6
  86. package/dist/modes/rpc/subagent-bridge.d.ts +23 -0
  87. package/dist/modes/rpc/subagent-bridge.js +93 -0
  88. package/dist/modes/rpc/test-fixtures/intercom-host.d.ts +1 -0
  89. package/dist/modes/rpc/test-fixtures/intercom-host.js +13 -0
  90. package/docs/rpc-subagent.schema.json +491 -0
  91. package/docs/rpc.md +210 -1
  92. package/package.json +2 -1
@@ -1,18 +1,15 @@
1
- /**
2
- * RPC Client for programmatic access to the coding agent.
3
- *
4
- * Spawns the agent in RPC mode and provides a typed API for all operations.
5
- */
1
+ import type { SubagentRpcSnapshot } from "../../core/subagent-rpc.ts";
2
+ import type { ReleaseReadiness, ReleaseResult, StopAllBackgroundResult } from "../../core/release-rpc.ts";
6
3
  import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
7
4
  import type { ImageContent, Model } from "@earendil-works/pi-ai";
8
- import type { AgentSessionEvent, SessionStats } from "../../core/agent-session.ts";
5
+ import type { SessionStats } from "../../core/agent-session.ts";
9
6
  import type { KeybindingsConfig } from "../../core/keybindings.ts";
10
7
  import type { Settings } from "../../core/settings-manager.ts";
11
8
  import type { BashResult } from "../../core/bash-executor.ts";
12
9
  import type { CompactionResult } from "../../core/compaction/index.ts";
13
10
  import type { SessionEntry, SessionTreeNode } from "../../core/session-manager.ts";
14
11
  import type { ChangelogEntry } from "../../utils/changelog.ts";
15
- import type { RpcSessionInfo, RpcSessionState, RpcSkillInfo, RpcSlashCommand } from "./rpc-types.ts";
12
+ import type { RpcEvent, IntercomRpcDataMap, IntercomRpcMessageOptions, RpcSessionInfo, RpcSessionState, RpcSkillInfo, RpcSlashCommand } from "./rpc-types.ts";
16
13
  export interface RpcClientOptions {
17
14
  /** Path to the CLI entry point (default: searches for dist/cli.js) */
18
15
  cliPath?: string;
@@ -33,7 +30,7 @@ export interface ModelInfo {
33
30
  contextWindow: number;
34
31
  reasoning: boolean;
35
32
  }
36
- export type RpcEventListener = (event: AgentSessionEvent) => void;
33
+ export type RpcEventListener = (event: RpcEvent) => void;
37
34
  export declare class RpcClient {
38
35
  private process;
39
36
  private stopReadingStdout;
@@ -97,6 +94,33 @@ export declare class RpcClient {
97
94
  /**
98
95
  * Get current session state.
99
96
  */
97
+ /** Read-only native subagent snapshot; never invokes a model. */
98
+ subagentStatus(): Promise<SubagentRpcSnapshot>;
99
+ /** Authoritative "is it safe to stop this process now" answer; never invokes a model. */
100
+ releaseReadiness(): Promise<ReleaseReadiness>;
101
+ /**
102
+ * Atomic "release if safe". `released: true` means the process has committed to a gentle exit (code 0, no abort) and rejects
103
+ * every further command except `release`/`release_readiness`; `released: false` carries the blockers and the process keeps running.
104
+ * "Nobody answered" (no extension contributors) is never released.
105
+ */
106
+ release(): Promise<ReleaseResult>;
107
+ /**
108
+ * DESTRUCTIVE kill switch for an explicit user "Quit anyway": aborts the turn (retry, compaction, bash, queued messages), stops every
109
+ * async/detached subagent run of this session (detached runners terminate on their own), disarms this session's session-only
110
+ * schedules (paused, project schedules untouched) and cancels pending outbound intercom asks. Per-item failures are `ok:false`
111
+ * in `stopped`; `remaining` is the readiness computed after the stop. Idempotent; refused while the session is releasing.
112
+ */
113
+ stopAllBackground(): Promise<StopAllBackgroundResult>;
114
+ intercomStatus(): Promise<IntercomRpcDataMap["intercom_status"]>;
115
+ intercomList(): Promise<IntercomRpcDataMap["intercom_list"]>;
116
+ intercomPending(): Promise<IntercomRpcDataMap["intercom_pending"]>;
117
+ intercomSend(to: string, options: IntercomRpcMessageOptions): Promise<IntercomRpcDataMap["intercom_send"]>;
118
+ intercomAsk(to: string, options: IntercomRpcMessageOptions & {
119
+ timeoutMs?: number;
120
+ }): Promise<IntercomRpcDataMap["intercom_ask"]>;
121
+ intercomReply(options: IntercomRpcMessageOptions & {
122
+ to?: string;
123
+ }): Promise<IntercomRpcDataMap["intercom_reply"]>;
100
124
  getState(): Promise<RpcSessionState>;
101
125
  /**
102
126
  * Set model by provider and ID.
@@ -425,11 +449,11 @@ export declare class RpcClient {
425
449
  /**
426
450
  * Collect events until agent becomes idle.
427
451
  */
428
- collectEvents(timeout?: number): Promise<AgentSessionEvent[]>;
452
+ collectEvents(timeout?: number): Promise<RpcEvent[]>;
429
453
  /**
430
454
  * Send prompt and wait for completion, returning all events.
431
455
  */
432
- promptAndWait(message: string, images?: ImageContent[], timeout?: number): Promise<AgentSessionEvent[]>;
456
+ promptAndWait(message: string, images?: ImageContent[], timeout?: number): Promise<RpcEvent[]>;
433
457
  private handleLine;
434
458
  private createProcessExitError;
435
459
  private rejectPendingRequests;
@@ -173,6 +173,49 @@ export class RpcClient {
173
173
  /**
174
174
  * Get current session state.
175
175
  */
176
+ /** Read-only native subagent snapshot; never invokes a model. */
177
+ async subagentStatus() {
178
+ return this.getData(await this.send({ type: "subagent_status", version: 1 }));
179
+ }
180
+ /** Authoritative "is it safe to stop this process now" answer; never invokes a model. */
181
+ async releaseReadiness() {
182
+ return this.getData(await this.send({ type: "release_readiness", version: 1 }));
183
+ }
184
+ /**
185
+ * Atomic "release if safe". `released: true` means the process has committed to a gentle exit (code 0, no abort) and rejects
186
+ * every further command except `release`/`release_readiness`; `released: false` carries the blockers and the process keeps running.
187
+ * "Nobody answered" (no extension contributors) is never released.
188
+ */
189
+ async release() {
190
+ return this.getData(await this.send({ type: "release", version: 1 }));
191
+ }
192
+ /**
193
+ * DESTRUCTIVE kill switch for an explicit user "Quit anyway": aborts the turn (retry, compaction, bash, queued messages), stops every
194
+ * async/detached subagent run of this session (detached runners terminate on their own), disarms this session's session-only
195
+ * schedules (paused, project schedules untouched) and cancels pending outbound intercom asks. Per-item failures are `ok:false`
196
+ * in `stopped`; `remaining` is the readiness computed after the stop. Idempotent; refused while the session is releasing.
197
+ */
198
+ async stopAllBackground() {
199
+ return this.getData(await this.send({ type: "stop_all_background", version: 1 }));
200
+ }
201
+ async intercomStatus() {
202
+ return this.getData(await this.send({ type: "intercom_status" }));
203
+ }
204
+ async intercomList() {
205
+ return this.getData(await this.send({ type: "intercom_list" }));
206
+ }
207
+ async intercomPending() {
208
+ return this.getData(await this.send({ type: "intercom_pending" }));
209
+ }
210
+ async intercomSend(to, options) {
211
+ return this.getData(await this.send({ type: "intercom_send", to, ...options }));
212
+ }
213
+ async intercomAsk(to, options) {
214
+ return this.getData(await this.send({ type: "intercom_ask", to, ...options }, (options.timeoutMs ?? 600_000) + 5000));
215
+ }
216
+ async intercomReply(options) {
217
+ return this.getData(await this.send({ type: "intercom_reply", ...options }));
218
+ }
176
219
  async getState() {
177
220
  const response = await this.send({ type: "get_state" });
178
221
  return this.getData(response);
@@ -652,7 +695,7 @@ export class RpcClient {
652
695
  }
653
696
  this.pendingRequests.clear();
654
697
  }
655
- async send(command) {
698
+ async send(command, timeoutMs = 35_000) {
656
699
  const childProcess = this.process;
657
700
  const stdin = childProcess?.stdin;
658
701
  if (!childProcess || !stdin) {
@@ -677,7 +720,7 @@ export class RpcClient {
677
720
  const timeout = setTimeout(() => {
678
721
  this.pendingRequests.delete(id);
679
722
  reject(new Error(`Timeout waiting for response to ${command.type}. Stderr: ${this.stderr}`));
680
- }, 30000);
723
+ }, timeoutMs);
681
724
  this.pendingRequests.set(id, {
682
725
  resolve: (response) => {
683
726
  clearTimeout(timeout);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,85 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { describe, expect, it } from "vitest";
7
+ import { RpcClient } from "./rpc-client.js";
8
+ // Run after npm run build: exercises built SDK host JSONL transport, avoiding unrelated CLI onboarding.
9
+ describe("Intercom RPC JSONL transport", () => {
10
+ it("starts the bundled standalone broker without a host-package alias", async () => {
11
+ const agentDir = mkdtempSync(join(tmpdir(), "rpc-broker-"));
12
+ const require = createRequire(import.meta.url);
13
+ const tsxCli = join(dirname(require.resolve("tsx")), "cli.mjs");
14
+ const broker = spawn(process.execPath, [tsxCli, resolve("dist/extensions/pi-intercom/broker/broker.ts")], {
15
+ cwd: agentDir, env: { ...process.env, SELESAI_CODING_AGENT_DIR: agentDir }, stdio: ["ignore", "pipe", "pipe"],
16
+ });
17
+ let stderr = "";
18
+ broker.stderr.on("data", chunk => { stderr += chunk.toString(); });
19
+ try {
20
+ await new Promise((resolveReady, reject) => {
21
+ const timer = setTimeout(() => reject(new Error(`Standalone broker startup timed out: ${stderr}`)), 5000);
22
+ broker.once("error", error => { clearTimeout(timer); reject(error); });
23
+ broker.once("exit", code => { clearTimeout(timer); reject(new Error(`Standalone broker exited ${code}: ${stderr}`)); });
24
+ broker.stdout.on("data", chunk => {
25
+ if (chunk.toString().includes("Intercom broker started")) {
26
+ clearTimeout(timer);
27
+ resolveReady();
28
+ }
29
+ });
30
+ });
31
+ expect(stderr).not.toContain("ERR_MODULE_NOT_FOUND");
32
+ }
33
+ finally {
34
+ if (broker.pid !== undefined && broker.exitCode === null && broker.signalCode === null) {
35
+ const exited = new Promise(resolveExit => broker.once("exit", () => resolveExit()));
36
+ broker.kill("SIGTERM");
37
+ await exited;
38
+ }
39
+ rmSync(agentDir, { recursive: true, force: true });
40
+ }
41
+ });
42
+ it("returns missing-backend errors without a model turn", async () => {
43
+ const agentDir = mkdtempSync(join(tmpdir(), "rpc-missing-"));
44
+ const client = new RpcClient({ cliPath: resolve("dist/modes/rpc/test-fixtures/intercom-host.js"), cwd: agentDir,
45
+ env: { SELESAI_CODING_AGENT_DIR: agentDir } });
46
+ try {
47
+ await client.start();
48
+ await expect(client.intercomList()).rejects.toThrow("Intercom extension unavailable");
49
+ }
50
+ finally {
51
+ await client.stop();
52
+ rmSync(agentDir, { recursive: true, force: true });
53
+ }
54
+ });
55
+ it("streams connection events while idle and rebinds on new session/reload", async () => {
56
+ const agentDir = mkdtempSync(join(tmpdir(), "rpc-live-"));
57
+ const client = new RpcClient({ cliPath: resolve("dist/modes/rpc/test-fixtures/intercom-host.js"), cwd: agentDir,
58
+ env: { SELESAI_CODING_AGENT_DIR: agentDir, RPC_INTERCOM_EXTENSION: resolve("dist/extensions/pi-intercom/index.ts"), PI_INTERCOM_STABLE_ID: "", SELESAI_SUBAGENT_ORCHESTRATOR_TARGET: "", SELESAI_SUBAGENT_RUN_ID: "" } });
59
+ const events = [];
60
+ const unsubscribe = client.onEvent(event => events.push(event));
61
+ try {
62
+ await client.start();
63
+ const first = await client.intercomList();
64
+ expect(first.sessions.some(peer => peer.id === first.sessionId)).toBe(true);
65
+ expect((await client.intercomStatus()).connected).toBe(true);
66
+ expect((await client.intercomPending()).asks).toEqual([]);
67
+ expect(events.some(event => event.type === "intercom_connection" && event.connected)).toBe(true);
68
+ expect(events.some(event => event.type === "agent_start")).toBe(false);
69
+ await client.newSession();
70
+ const next = await client.intercomList();
71
+ expect(next.sessionId).not.toBe(first.sessionId);
72
+ await client.reload();
73
+ const reloaded = await client.intercomList();
74
+ expect(reloaded.sessionId).toBe(next.sessionId);
75
+ expect(reloaded.sessions.filter(peer => peer.id === next.sessionId)).toHaveLength(1);
76
+ await expect(client.intercomSend(reloaded.sessionId, { message: "Self must fail" })).rejects.toThrow("current session");
77
+ expect(events.some(event => event.type === "agent_start")).toBe(false);
78
+ }
79
+ finally {
80
+ unsubscribe();
81
+ await client.stop();
82
+ rmSync(agentDir, { recursive: true, force: true });
83
+ }
84
+ });
85
+ });
@@ -1,17 +1,5 @@
1
- /**
2
- * RPC mode: Headless operation with JSON stdin/stdout protocol.
3
- *
4
- * Used for embedding the agent in other applications.
5
- * Receives commands as JSON on stdin, outputs events and responses as JSON on stdout.
6
- *
7
- * Protocol:
8
- * - Commands: JSON objects with `type` field, optional `id` for correlation
9
- * - Responses: JSON objects with `type: "response"`, `command`, `success`, and optional `data`/`error`
10
- * - Events: AgentSessionEvent objects streamed as they occur
11
- * - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response
12
- */
13
1
  import type { AgentSessionRuntime } from "../../core/agent-session-runtime.ts";
14
- export type { RpcCommand, RpcExtensionUIRequest, RpcExtensionUIResponse, RpcResponse, RpcSessionInfo, RpcSessionState, RpcSlashCommand, PrototypePhase, QuicktypePhase, WorkflowState, } from "./rpc-types.ts";
2
+ export type { RpcCommand, RpcEvent, IntercomRpcCommand, IntercomRpcResponse, IntercomRpcEvent, IntercomRpcStatus, IntercomRpcDelivery, IntercomRpcMessageOptions, IntercomRpcDataMap, RpcExtensionUIRequest, RpcExtensionUIResponse, RpcResponse, RpcSessionInfo, RpcSessionState, RpcSlashCommand, PrototypePhase, QuicktypePhase, WorkflowState, } from "./rpc-types.ts";
15
3
  /**
16
4
  * Run in RPC mode.
17
5
  * Listens for JSON commands on stdin, outputs events and responses on stdout.
@@ -1,3 +1,6 @@
1
+ import { RpcSubagentBridge } from "./subagent-bridge.js";
2
+ import { RpcReleaseBridge, validateReleaseCommand } from "./release-bridge.js";
3
+ import { RELEASING_BLOCKER_KIND } from "../../core/release-rpc.js";
1
4
  /**
2
5
  * RPC mode: Headless operation with JSON stdin/stdout protocol.
3
6
  *
@@ -10,6 +13,7 @@
10
13
  * - Events: AgentSessionEvent objects streamed as they occur
11
14
  * - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response
12
15
  */
16
+ import { RpcIntercomBridge } from "./intercom-bridge.js";
13
17
  import * as crypto from "node:crypto";
14
18
  import { spawnSync } from "node:child_process";
15
19
  import { chmodSync, copyFileSync, existsSync, readFileSync, unlinkSync } from "node:fs";
@@ -42,6 +46,9 @@ export async function runRpcMode(runtimeHost) {
42
46
  let session = runtimeHost.session;
43
47
  let unsubscribe;
44
48
  let unsubscribeBackpressure;
49
+ let intercomBridge;
50
+ let subagentBridge;
51
+ let releaseBridge;
45
52
  const output = (obj) => {
46
53
  writeRawStdout(serializeJsonLine(obj));
47
54
  };
@@ -95,6 +102,27 @@ export async function runRpcMode(runtimeHost) {
95
102
  const pendingExtensionRequests = new Map();
96
103
  // Prompts buffered while compacting (queueWhileCompacting) — flushed after compaction_end.
97
104
  const compactionPromptQueue = [];
105
+ // In-flight session transitions (switch/new/fork/reload/extension rebind) and prompts still inside preflight or
106
+ // run, tracked so release_readiness never answers "safe" in a window where session state is being swapped.
107
+ let sessionTransitions = 0;
108
+ let promptsInFlight = 0;
109
+ const withTransition = async (fn) => {
110
+ sessionTransitions++;
111
+ try {
112
+ return await fn();
113
+ }
114
+ finally {
115
+ sessionTransitions--;
116
+ }
117
+ };
118
+ const TRANSITION_COMMANDS = new Set([
119
+ "new_session", "handoff_new", "switch_session", "fork", "clone", "import_jsonl", "reload", "navigate_tree",
120
+ ]);
121
+ // Atomic `release` command: once committed (synchronously, in the same step as the readiness check) every other command is
122
+ // refused and the process exits gently right after the response is flushed. `releasedReadiness` is what the commit was based on.
123
+ let releasing = false;
124
+ let releaseShutdownPending = false;
125
+ let releasedReadiness;
98
126
  // Shutdown request flag
99
127
  let shutdownRequested = false;
100
128
  let shuttingDown = false;
@@ -302,16 +330,43 @@ export async function runRpcMode(runtimeHost) {
302
330
  runtimeHost.setRebindSession(async () => {
303
331
  await rebindSession();
304
332
  });
305
- const rebindSession = async () => {
333
+ const bindObservationBridges = () => {
334
+ intercomBridge?.dispose();
335
+ subagentBridge?.dispose();
336
+ releaseBridge?.dispose();
306
337
  session = runtimeHost.session;
338
+ const epoch = crypto.randomUUID();
339
+ const bus = session.resourceLoader.getEventBus?.();
340
+ intercomBridge = new RpcIntercomBridge(bus, output, session.sessionId, epoch);
341
+ subagentBridge = new RpcSubagentBridge(bus, output, { version: 1, sessionId: session.sessionId, epoch });
342
+ releaseBridge = new RpcReleaseBridge(bus, { version: 1, sessionId: session.sessionId, epoch });
343
+ };
344
+ const reloadSession = () => withTransition(reloadSessionUnguarded);
345
+ const reloadSessionUnguarded = async () => {
346
+ intercomBridge?.dispose();
347
+ subagentBridge?.dispose();
348
+ releaseBridge?.dispose();
349
+ let rebound = false;
350
+ try {
351
+ await session.reload({ beforeSessionStart: () => { bindObservationBridges(); rebound = true; } });
352
+ }
353
+ finally {
354
+ if (!rebound)
355
+ bindObservationBridges();
356
+ subagentBridge?.initialize();
357
+ }
358
+ };
359
+ const rebindSession = () => withTransition(rebindSessionUnguarded);
360
+ const rebindSessionUnguarded = async () => {
361
+ bindObservationBridges();
307
362
  await session.bindExtensions({
308
363
  uiContext: createExtensionUIContext(),
309
364
  mode: "rpc",
310
365
  commandContextActions: {
311
366
  waitForIdle: () => session.waitForIdle(),
312
- newSession: async (options) => runtimeHost.newSession(options),
367
+ newSession: async (options) => withTransition(() => runtimeHost.newSession(options)),
313
368
  fork: async (entryId, forkOptions) => {
314
- const result = await runtimeHost.fork(entryId, forkOptions);
369
+ const result = await withTransition(() => runtimeHost.fork(entryId, forkOptions));
315
370
  return { cancelled: result.cancelled };
316
371
  },
317
372
  navigateTree: async (targetId, options) => {
@@ -324,10 +379,10 @@ export async function runRpcMode(runtimeHost) {
324
379
  return { cancelled: result.cancelled };
325
380
  },
326
381
  switchSession: async (sessionPath, options) => {
327
- return runtimeHost.switchSession(sessionPath, options);
382
+ return withTransition(() => runtimeHost.switchSession(sessionPath, options));
328
383
  },
329
384
  reload: async () => {
330
- await session.reload();
385
+ await reloadSession();
331
386
  },
332
387
  },
333
388
  shutdownHandler: () => {
@@ -337,6 +392,7 @@ export async function runRpcMode(runtimeHost) {
337
392
  output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error });
338
393
  },
339
394
  });
395
+ subagentBridge?.initialize();
340
396
  unsubscribe?.();
341
397
  unsubscribeBackpressure?.();
342
398
  unsubscribe = session.subscribe((event) => {
@@ -389,10 +445,168 @@ export async function runRpcMode(runtimeHost) {
389
445
  };
390
446
  await rebindSession();
391
447
  registerSignalHandlers();
448
+ /**
449
+ * Release blockers owned by this process (not by an extension). Fail closed: if a probe throws, report it
450
+ * instead of answering "safe". Extension blockers are collected by RpcReleaseBridge.
451
+ */
452
+ const coreReleaseBlockers = () => {
453
+ const blockers = [];
454
+ const block = (kind, detail) => blockers.push({ source: "core", kind, detail });
455
+ if (releasing)
456
+ block(RELEASING_BLOCKER_KIND, "A release was committed; this process is shutting down");
457
+ if (sessionTransitions > 0)
458
+ block("session_transition", "Session switch, reload or extension rebind in progress");
459
+ if (promptsInFlight > 0)
460
+ block("prompt_in_flight", `${promptsInFlight} prompt command(s) still being processed`);
461
+ const probe = (kind, check) => {
462
+ try {
463
+ check();
464
+ }
465
+ catch (e) {
466
+ block("contributor_error", `${kind} probe failed: ${e instanceof Error ? e.message : String(e)}`);
467
+ }
468
+ };
469
+ probe("streaming", () => { if (session.isStreaming)
470
+ block("streaming", "Agent run in progress"); });
471
+ probe("compacting", () => { if (session.isCompacting)
472
+ block("compacting", "Compaction or branch summarization in progress"); });
473
+ probe("retrying", () => { if (session.isRetrying)
474
+ block("retrying", `Auto-retry in progress (attempt ${session.retryAttempt})`); });
475
+ probe("queued_messages", () => {
476
+ const steering = session.getSteeringMessages().length;
477
+ const followUp = session.getFollowUpMessages().length;
478
+ if (session.agent.hasQueuedMessages() || session.pendingMessageCount > 0)
479
+ block("queued_messages", `Queued messages: ${steering} steering, ${followUp} follow-up`);
480
+ });
481
+ probe("pending_tool_calls", () => {
482
+ const pending = session.agent.state.pendingToolCalls.size;
483
+ if (pending > 0)
484
+ block("pending_tool_calls", `${pending} tool call(s) in flight`);
485
+ });
486
+ probe("pending_bash", () => {
487
+ if (session.isBashRunning)
488
+ block("pending_bash", "Bash command running");
489
+ if (session.hasPendingBashMessages)
490
+ block("pending_bash", "Bash output awaiting flush into the session");
491
+ });
492
+ if (pendingExtensionRequests.size > 0)
493
+ block("extension_dialog", `${pendingExtensionRequests.size} extension dialog(s) awaiting a response`);
494
+ if (compactionPromptQueue.length > 0)
495
+ block("compaction_prompt_queue", `${compactionPromptQueue.length} prompt(s) buffered until compaction ends`);
496
+ return blockers;
497
+ };
498
+ /**
499
+ * Core half of `stop_all_background`: everything this process runs for the session itself. Mirrors the `abort` command (agent
500
+ * turn, retry, compaction, branch summary) and additionally kills a running `!bash`, drops queued steer/follow-up messages and
501
+ * buffered compaction prompts so nothing restarts the turn after the abort. Only what was actually active is reported, so an
502
+ * idle session yields no items. Foreground subagent runs belong to the turn: they are killed through the turn's abort signal.
503
+ * Extension dialogs are not touched (they are the host's UI, not background work).
504
+ */
505
+ const stopCoreWork = async () => {
506
+ const items = [];
507
+ const active = (kind, probe) => {
508
+ try {
509
+ return probe();
510
+ }
511
+ catch (e) {
512
+ items.push({ source: "core", kind, detail: `${kind} probe failed: ${e instanceof Error ? e.message : String(e)}`, ok: false, error: "probe_failed" });
513
+ return true;
514
+ }
515
+ };
516
+ const turn = active("turn", () => session.isStreaming);
517
+ const retry = active("retry", () => session.isRetrying);
518
+ const compaction = active("compaction", () => session.isCompacting);
519
+ const bash = active("bash", () => session.isBashRunning);
520
+ let queued = { steering: 0, followUp: 0 };
521
+ try {
522
+ queued = { steering: session.getSteeringMessages().length, followUp: session.getFollowUpMessages().length };
523
+ }
524
+ catch { /* reported by the abort below */ }
525
+ const bufferedPrompts = compactionPromptQueue.length;
526
+ const done = (kind, detail, failure) => {
527
+ const message = failure === undefined ? undefined : (failure instanceof Error ? failure.message : String(failure)).slice(0, 256);
528
+ items.push({ source: "core", kind, detail: message === undefined ? detail : `${detail} failed: ${message}`, ok: message === undefined, ...(message === undefined ? {} : { error: message }) });
529
+ };
530
+ // Order matters: drop queued work first so the abort's idle transition cannot start the next queued turn.
531
+ let queueFailure;
532
+ try {
533
+ compactionPromptQueue.length = 0;
534
+ session.clearQueue();
535
+ }
536
+ catch (e) {
537
+ queueFailure = e;
538
+ }
539
+ if (queued.steering + queued.followUp > 0)
540
+ done("queued_messages", `Dropped ${queued.steering} steering and ${queued.followUp} follow-up message(s)`, queueFailure);
541
+ if (bufferedPrompts > 0)
542
+ done("compaction_prompt_queue", `Dropped ${bufferedPrompts} prompt(s) buffered until compaction ends`, queueFailure);
543
+ let bashFailure;
544
+ try {
545
+ session.abortBash();
546
+ }
547
+ catch (e) {
548
+ bashFailure = e;
549
+ }
550
+ if (bash)
551
+ done("bash", "Aborted running bash command", bashFailure);
552
+ let abortFailure;
553
+ try {
554
+ session.abortBranchSummary();
555
+ session.abortCompaction();
556
+ await session.abort();
557
+ }
558
+ catch (e) {
559
+ abortFailure = e;
560
+ }
561
+ if (turn)
562
+ done("turn", "Aborted in-flight agent turn", abortFailure);
563
+ if (retry)
564
+ done("retry", "Cancelled auto-retry", abortFailure);
565
+ if (compaction)
566
+ done("compaction", "Aborted compaction or branch summarization", abortFailure);
567
+ if (abortFailure !== undefined && !turn && !retry && !compaction)
568
+ done("turn", "Abort", abortFailure);
569
+ return items;
570
+ };
392
571
  // Handle a single command
393
572
  const handleCommand = async (command) => {
394
573
  const id = command.id;
574
+ // Committed release: nothing new may start. Only the release probes still answer.
575
+ if (releasing && command.type !== "release" && command.type !== "release_readiness") {
576
+ return error(id, command.type, "Session is releasing");
577
+ }
395
578
  switch (command.type) {
579
+ case "subagent_status":
580
+ return subagentBridge.request(command);
581
+ case "release_readiness":
582
+ return releaseBridge.request(command, coreReleaseBlockers());
583
+ case "stop_all_background":
584
+ // Destructive kill switch for "Quit anyway". Refused above while `releasing`. The core abort and every extension's stop
585
+ // start synchronously inside the bridge and are awaited together under one ~5s budget.
586
+ return releaseBridge.stopAll(command, stopCoreWork, coreReleaseBlockers);
587
+ case "release": {
588
+ // ONE synchronous section: readiness check (core + extension broadcast) and commitment, no `await` in between.
589
+ // The shutdown itself runs from handleInputLine after the response is written and flushed.
590
+ if (releasing) {
591
+ // Idempotent: a repeated (valid) release reports the committed decision.
592
+ const invalid = validateReleaseCommand(command);
593
+ if (invalid)
594
+ return error(id, command.type, invalid);
595
+ return success(id, "release", { released: true, readiness: releasedReadiness });
596
+ }
597
+ return releaseBridge.release(command, coreReleaseBlockers(), (readiness) => {
598
+ releasing = true;
599
+ releasedReadiness = readiness;
600
+ releaseShutdownPending = true;
601
+ });
602
+ }
603
+ case "intercom_status":
604
+ case "intercom_list":
605
+ case "intercom_send":
606
+ case "intercom_ask":
607
+ case "intercom_reply":
608
+ case "intercom_pending":
609
+ return intercomBridge.request(command);
396
610
  // =================================================================
397
611
  // Prompting
398
612
  // =================================================================
@@ -415,6 +629,7 @@ export async function runRpcMode(runtimeHost) {
415
629
  compactionPromptQueue.push(queued);
416
630
  return undefined;
417
631
  }
632
+ promptsInFlight++;
418
633
  void session
419
634
  .prompt(command.message, {
420
635
  images: command.images,
@@ -431,6 +646,9 @@ export async function runRpcMode(runtimeHost) {
431
646
  if (!preflightSucceeded) {
432
647
  output(error(id, "prompt", e.message));
433
648
  }
649
+ })
650
+ .finally(() => {
651
+ promptsInFlight--;
434
652
  });
435
653
  return undefined;
436
654
  }
@@ -492,6 +710,7 @@ export async function runRpcMode(runtimeHost) {
492
710
  thinkingLevel: session.thinkingLevel,
493
711
  isStreaming: session.isStreaming,
494
712
  isCompacting: session.isCompacting,
713
+ isRetrying: session.isRetrying,
495
714
  steeringMode: session.steeringMode,
496
715
  followUpMode: session.followUpMode,
497
716
  sessionFile: session.sessionFile,
@@ -1008,7 +1227,7 @@ export async function runRpcMode(runtimeHost) {
1008
1227
  catch {
1009
1228
  // best-effort
1010
1229
  }
1011
- await session.reload();
1230
+ await reloadSession();
1012
1231
  return success(id, "factory_reset_settings");
1013
1232
  }
1014
1233
  catch (resetError) {
@@ -1276,7 +1495,7 @@ export async function runRpcMode(runtimeHost) {
1276
1495
  return error(id, "reload", "Wait for compaction to finish before reloading.");
1277
1496
  }
1278
1497
  try {
1279
- await session.reload();
1498
+ await reloadSession();
1280
1499
  return success(id, "reload");
1281
1500
  }
1282
1501
  catch (reloadError) {
@@ -1396,6 +1615,9 @@ export async function runRpcMode(runtimeHost) {
1396
1615
  }
1397
1616
  unsubscribe?.();
1398
1617
  unsubscribeBackpressure?.();
1618
+ intercomBridge?.dispose();
1619
+ subagentBridge?.dispose();
1620
+ releaseBridge?.dispose();
1399
1621
  await runtimeHost.dispose();
1400
1622
  detachInput();
1401
1623
  process.stdin.pause();
@@ -1434,11 +1656,17 @@ export async function runRpcMode(runtimeHost) {
1434
1656
  }
1435
1657
  const command = parsed;
1436
1658
  try {
1437
- const response = await handleCommand(command);
1659
+ const response = await (TRANSITION_COMMANDS.has(command.type) ? withTransition(() => handleCommand(command)) : handleCommand(command));
1438
1660
  if (response) {
1439
1661
  output(response);
1440
1662
  await waitForRawStdoutBackpressure();
1441
1663
  }
1664
+ if (releaseShutdownPending) {
1665
+ // The release response is out: flush it fully, then take the normal gentle shutdown path (extensions disposed, exit 0).
1666
+ releaseShutdownPending = false;
1667
+ await flushRawStdout();
1668
+ await shutdown();
1669
+ }
1442
1670
  await checkShutdownRequested();
1443
1671
  }
1444
1672
  catch (commandError) {
@@ -1447,6 +1675,9 @@ export async function runRpcMode(runtimeHost) {
1447
1675
  }
1448
1676
  };
1449
1677
  const onInputEnd = () => {
1678
+ // A committed release already owns the shutdown (response flush, then dispose); a second shutdown() would cut it short.
1679
+ if (releasing)
1680
+ return;
1450
1681
  void shutdown();
1451
1682
  };
1452
1683
  process.stdin.on("end", onInputEnd);
@@ -0,0 +1 @@
1
+ export {};