@selesai/code 0.13.39 → 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 (82) hide show
  1. package/dist/core/intercom-rpc.d.ts +135 -0
  2. package/dist/core/intercom-rpc.js +64 -0
  3. package/dist/core/release-rpc.d.ts +137 -0
  4. package/dist/core/release-rpc.js +77 -0
  5. package/dist/core/resource-loader.d.ts +3 -0
  6. package/dist/core/resource-loader.js +3 -0
  7. package/dist/core/subagent-rpc.d.ts +110 -0
  8. package/dist/core/subagent-rpc.js +60 -0
  9. package/dist/extensions/agent-browser.ts +9 -0
  10. package/dist/extensions/pi-intercom/README.md +15 -0
  11. package/dist/extensions/pi-intercom/broker/broker.ts +277 -13
  12. package/dist/extensions/pi-intercom/broker/client.ts +6 -2
  13. package/dist/extensions/pi-intercom/broker/mailbox-persistence.test.ts +523 -0
  14. package/dist/extensions/pi-intercom/broker/paths.test.ts +4 -4
  15. package/dist/extensions/pi-intercom/broker/paths.ts +4 -2
  16. package/dist/extensions/pi-intercom/broker/spawn.test.ts +1 -3
  17. package/dist/extensions/pi-intercom/extension-api.ts +10 -0
  18. package/dist/extensions/pi-intercom/index.ts +188 -8
  19. package/dist/extensions/pi-intercom/intercom.integration.test.ts +280 -0
  20. package/dist/extensions/pi-intercom/package.json +1 -1
  21. package/dist/extensions/pi-intercom/release-readiness.test.ts +116 -0
  22. package/dist/extensions/pi-intercom/release-readiness.ts +93 -0
  23. package/dist/extensions/pi-subagents/docs/extension-api.md +8 -0
  24. package/dist/extensions/pi-subagents/docs/observability.md +8 -0
  25. package/dist/extensions/pi-subagents/src/extension/index.ts +51 -1
  26. package/dist/extensions/pi-subagents/src/extension/observability.ts +164 -0
  27. package/dist/extensions/pi-subagents/src/extension/release-readiness.ts +367 -0
  28. package/dist/extensions/pi-subagents/src/extension/rpc.ts +8 -1
  29. package/dist/extensions/pi-subagents/src/integrations/pi-web-session-liveness.ts +7 -1
  30. package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +1 -44
  31. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +6 -0
  32. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +13 -3
  33. package/dist/extensions/pi-subagents/src/runs/background/result-delivery-ownership.ts +269 -7
  34. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +11 -10
  35. package/dist/extensions/pi-subagents/src/runs/background/retained-nested-route-tracker.ts +15 -3
  36. package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +64 -0
  37. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +69 -6
  38. package/dist/extensions/pi-subagents/src/runs/shared/subagent-control.ts +14 -1
  39. package/dist/extensions/pi-subagents/src/shared/owner-record.ts +107 -0
  40. package/dist/extensions/pi-subagents/src/shared/process-identity.ts +87 -0
  41. package/dist/extensions/pi-subagents/src/shared/types.ts +9 -0
  42. package/dist/extensions/pi-subagents/test/integration/async-attention-clearing.test.ts +191 -0
  43. package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +63 -0
  44. package/dist/extensions/pi-subagents/test/integration/stop-all-background.test.ts +142 -0
  45. package/dist/extensions/pi-subagents/test/support/fake-child-session.ts +8 -0
  46. package/dist/extensions/pi-subagents/test/unit/observability.test.ts +198 -0
  47. package/dist/extensions/pi-subagents/test/unit/process-identity.test.ts +75 -0
  48. package/dist/extensions/pi-subagents/test/unit/release-readiness.test.ts +715 -0
  49. package/dist/extensions/pi-subagents/test/unit/result-takeover.test.ts +332 -0
  50. package/dist/extensions/tokenin-onboarding.ts +4 -2
  51. package/dist/index.d.ts +6 -1
  52. package/dist/index.js +7 -0
  53. package/dist/main.d.ts +6 -0
  54. package/dist/main.js +1 -1
  55. package/dist/modes/index.d.ts +1 -1
  56. package/dist/modes/rpc/intercom-bridge.d.ts +14 -0
  57. package/dist/modes/rpc/intercom-bridge.js +75 -0
  58. package/dist/modes/rpc/intercom-bridge.test.d.ts +1 -0
  59. package/dist/modes/rpc/intercom-bridge.test.js +65 -0
  60. package/dist/modes/rpc/release-bridge.d.ts +35 -0
  61. package/dist/modes/rpc/release-bridge.js +159 -0
  62. package/dist/modes/rpc/release-bridge.test.d.ts +1 -0
  63. package/dist/modes/rpc/release-bridge.test.js +347 -0
  64. package/dist/modes/rpc/rpc-client.d.ts +34 -10
  65. package/dist/modes/rpc/rpc-client.js +45 -2
  66. package/dist/modes/rpc/rpc-intercom-transport.test.d.ts +1 -0
  67. package/dist/modes/rpc/rpc-intercom-transport.test.js +85 -0
  68. package/dist/modes/rpc/rpc-mode.d.ts +1 -13
  69. package/dist/modes/rpc/rpc-mode.js +239 -8
  70. package/dist/modes/rpc/rpc-release-transport.test.d.ts +1 -0
  71. package/dist/modes/rpc/rpc-release-transport.test.js +329 -0
  72. package/dist/modes/rpc/rpc-subagent-transport.test.d.ts +1 -0
  73. package/dist/modes/rpc/rpc-subagent-transport.test.js +60 -0
  74. package/dist/modes/rpc/rpc-types.d.ts +12 -2
  75. package/dist/modes/rpc/rpc-types.js +0 -6
  76. package/dist/modes/rpc/subagent-bridge.d.ts +23 -0
  77. package/dist/modes/rpc/subagent-bridge.js +93 -0
  78. package/dist/modes/rpc/test-fixtures/intercom-host.d.ts +1 -0
  79. package/dist/modes/rpc/test-fixtures/intercom-host.js +13 -0
  80. package/docs/rpc-subagent.schema.json +491 -0
  81. package/docs/rpc.md +210 -1
  82. package/package.json +2 -1
@@ -0,0 +1,332 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import * as fs from "node:fs";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import { afterEach, describe, it } from "node:test";
7
+ import { createResultDeliveryOwnership, type ResultTakeoverDeps } from "../../src/runs/background/result-delivery-ownership.ts";
8
+ import { ensureOwnerRecord, ownerFileStem, ownerRecordPath, readOwnerRecord, type OwnerRecord } from "../../src/shared/owner-record.ts";
9
+ import { processLiveness, processStartKey } from "../../src/shared/process-identity.ts";
10
+
11
+ const roots: string[] = [];
12
+ afterEach(() => {
13
+ for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
14
+ });
15
+ function tempDir(): string {
16
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-takeover-test-"));
17
+ roots.push(dir);
18
+ return dir;
19
+ }
20
+
21
+ const HOST = "host-a";
22
+ const SESSION = "session-1";
23
+
24
+ function writeRecord(ownersDir: string, id: string, overrides: Partial<OwnerRecord> = {}): OwnerRecord {
25
+ const record: OwnerRecord = { version: 1, completionOwnerId: id, pid: 4242, hostname: HOST, createdAt: 1_000, ...overrides };
26
+ fs.mkdirSync(ownersDir, { recursive: true });
27
+ fs.writeFileSync(ownerRecordPath(ownersDir, id)!, JSON.stringify(record));
28
+ return record;
29
+ }
30
+
31
+ type Liveness = "alive" | "dead" | "unknown";
32
+
33
+ /** pid -> liveness table so each fake owner can live or die independently. */
34
+ function fakes(ownersDir: string, table: Record<number, Liveness>, overrides: Partial<ResultTakeoverDeps> = {}): ResultTakeoverDeps {
35
+ return {
36
+ ownersDir,
37
+ hostname: () => HOST,
38
+ processLiveness: (pid) => table[pid] ?? "unknown",
39
+ processStartKey: () => undefined,
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ function ownershipFor(ownerId: string, ownersDir: string, table: Record<number, Liveness>, sessionId: string | null = SESSION, overrides: Partial<ResultTakeoverDeps> = {}) {
45
+ const state = { currentSessionId: sessionId, completionOwnerId: ownerId };
46
+ return { state, ownership: createResultDeliveryOwnership(state, fakes(ownersDir, table, overrides)) };
47
+ }
48
+
49
+ function claimFiles(ownersDir: string): string[] {
50
+ try {
51
+ return fs.readdirSync(path.join(ownersDir, "claims")).sort();
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+
57
+ describe("cross-process result takeover", () => {
58
+ it("takes over a dead same-host owner's result for the current session, exactly once", () => {
59
+ const dir = tempDir();
60
+ writeRecord(dir, "dead-owner", { pid: 100 });
61
+ const { ownership } = ownershipFor("me", dir, { 100: "dead" }, SESSION, { now: () => 5_000 });
62
+ assert.equal(ownership.owns(SESSION, "dead-owner", "run-1"), true);
63
+ assert.equal(ownership.owns(SESSION, "dead-owner", "run-1"), true, "idempotent for the same claimant");
64
+ assert.deepEqual(claimFiles(dir), ["run-1.json"]);
65
+ assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, "claims", "run-1.json"), "utf-8")), { claimedBy: "me", previousOwner: "dead-owner", claimedAt: 5_000 });
66
+ });
67
+
68
+ it("never takes over from a live owner", () => {
69
+ const dir = tempDir();
70
+ writeRecord(dir, "live-owner", { pid: 100 });
71
+ const { ownership } = ownershipFor("me", dir, { 100: "alive" });
72
+ assert.equal(ownership.owns(SESSION, "live-owner", "run-1"), false);
73
+ assert.equal(ownership.canTakeOver(SESSION, "live-owner", "run-1"), false);
74
+ assert.deepEqual(claimFiles(dir), []);
75
+ });
76
+
77
+ it("does not take over without an owner record", () => {
78
+ const dir = tempDir();
79
+ fs.mkdirSync(dir, { recursive: true });
80
+ const { ownership } = ownershipFor("me", dir, { 100: "dead" });
81
+ assert.equal(ownership.owns(SESSION, "no-record", "run-1"), false);
82
+ fs.writeFileSync(ownerRecordPath(dir, "garbage")!, "{not json");
83
+ assert.equal(ownership.owns(SESSION, "garbage", "run-1"), false);
84
+ writeRecord(dir, "wrong-id", { pid: 100 });
85
+ fs.renameSync(ownerRecordPath(dir, "wrong-id")!, ownerRecordPath(dir, "impostor")!);
86
+ assert.equal(ownership.owns(SESSION, "impostor", "run-1"), false, "a record must describe the id it is filed under");
87
+ assert.deepEqual(claimFiles(dir), []);
88
+ });
89
+
90
+ it("does not take over when liveness is unknown", () => {
91
+ const dir = tempDir();
92
+ writeRecord(dir, "mystery", { pid: 100 });
93
+ const { ownership } = ownershipFor("me", dir, { 100: "unknown" });
94
+ assert.equal(ownership.owns(SESSION, "mystery", "run-1"), false);
95
+ assert.deepEqual(claimFiles(dir), []);
96
+ });
97
+
98
+ it("does not take over from another host", () => {
99
+ const dir = tempDir();
100
+ writeRecord(dir, "elsewhere", { pid: 100, hostname: "host-b" });
101
+ const { ownership } = ownershipFor("me", dir, { 100: "dead" });
102
+ assert.equal(ownership.owns(SESSION, "elsewhere", "run-1"), false);
103
+ assert.deepEqual(claimFiles(dir), []);
104
+ });
105
+
106
+ it("treats a reused pid (different start key) as a dead owner, but only with comparable evidence", () => {
107
+ const dir = tempDir();
108
+ const cases: Array<{ name: string; recorded?: string; current?: string; takeable: boolean }> = [
109
+ { name: "reused", recorded: "ps:Mon Oct 4 10:00:00 2026", current: "ps:Mon Oct 4 12:30:00 2026", takeable: true },
110
+ { name: "same-process", recorded: "ps:Mon Oct 4 10:00:00 2026", current: "ps:Mon Oct 4 10:00:00 2026", takeable: false },
111
+ { name: "no-recorded-key", current: "ps:Mon Oct 4 12:30:00 2026", takeable: false },
112
+ { name: "no-current-key", recorded: "ps:Mon Oct 4 10:00:00 2026", takeable: false },
113
+ { name: "scheme-changed", recorded: "linux:12345", current: "ps:Mon Oct 4 12:30:00 2026", takeable: false },
114
+ ];
115
+ for (const entry of cases) {
116
+ writeRecord(dir, entry.name, { pid: 100, ...(entry.recorded ? { startKey: entry.recorded } : {}) });
117
+ const { ownership } = ownershipFor("me", dir, { 100: "alive" }, SESSION, { processStartKey: () => entry.current });
118
+ assert.equal(ownership.owns(SESSION, entry.name, `run-${entry.name}`), entry.takeable, entry.name);
119
+ }
120
+ });
121
+
122
+ it("never takes another session's result, even from a dead owner", () => {
123
+ const dir = tempDir();
124
+ writeRecord(dir, "dead-owner", { pid: 100 });
125
+ const { state, ownership } = ownershipFor("me", dir, { 100: "dead" });
126
+ assert.equal(ownership.owns("other-session", "dead-owner", "run-1"), false);
127
+ assert.equal(ownership.canTakeOver("other-session", "dead-owner", "run-1"), false);
128
+ assert.deepEqual(claimFiles(dir), []);
129
+ // An already-claimed predecessor session is part of this process's scope, exactly as for in-process ownership.
130
+ state.currentSessionId = "old";
131
+ assert.equal(ownership.claimPredecessor("old", "old"), true);
132
+ state.currentSessionId = "new";
133
+ assert.equal(ownership.owns("old", "dead-owner", "run-old"), true);
134
+ assert.equal(ownership.owns("new", "dead-owner", "run-new"), true);
135
+ assert.equal(ownership.owns("other-session", "dead-owner", "run-2"), false);
136
+ });
137
+
138
+ it("requires a run key and a non-empty foreign owner id; the in-process rule is unchanged", () => {
139
+ const dir = tempDir();
140
+ writeRecord(dir, "dead-owner", { pid: 100 });
141
+ const { ownership } = ownershipFor("me", dir, { 100: "dead" });
142
+ assert.equal(ownership.owns(SESSION, "dead-owner"), false);
143
+ assert.equal(ownership.owns(SESSION, undefined, "run-1"), false);
144
+ assert.equal(ownership.owns(SESSION, "", "run-1"), false);
145
+ assert.equal(ownership.owns(SESSION, "me", "run-1"), true);
146
+ assert.equal(ownership.owns("other-session", "me", "run-1"), false);
147
+ assert.deepEqual(claimFiles(dir), []);
148
+ });
149
+
150
+ it("lets exactly one of two racing processes win a result", () => {
151
+ for (const order of [["a", "b"], ["b", "a"]] as const) {
152
+ const dir = tempDir();
153
+ writeRecord(dir, "dead-owner", { pid: 100 });
154
+ const table: Record<number, Liveness> = { 100: "dead", 201: "alive", 202: "alive" };
155
+ writeRecord(dir, "proc-a", { pid: 201 });
156
+ writeRecord(dir, "proc-b", { pid: 202 });
157
+ const a = ownershipFor("proc-a", dir, table);
158
+ const b = ownershipFor("proc-b", dir, table);
159
+ const by = { a: a.ownership, b: b.ownership };
160
+ const results = order.map((name) => by[name].owns(SESSION, "dead-owner", "run-1"));
161
+ assert.deepEqual(results, [true, false], `first caller (${order[0]}) wins`);
162
+ assert.equal(by[order[0]].owns(SESSION, "dead-owner", "run-1"), true);
163
+ assert.equal(by[order[1]].owns(SESSION, "dead-owner", "run-1"), false);
164
+ assert.equal(by[order[1]].canTakeOver(SESSION, "dead-owner", "run-1"), false);
165
+ assert.deepEqual(claimFiles(dir), ["run-1.json"]);
166
+ }
167
+ });
168
+
169
+ it("re-claims a result whose claimer died, race-safely and with a bounded chain", () => {
170
+ const dir = tempDir();
171
+ writeRecord(dir, "dead-owner", { pid: 100 });
172
+ writeRecord(dir, "claimer-1", { pid: 201 });
173
+ writeRecord(dir, "claimer-2", { pid: 202 });
174
+ writeRecord(dir, "claimer-3", { pid: 203 });
175
+ const table: Record<number, Liveness> = { 100: "dead", 201: "alive", 202: "alive", 203: "alive", 301: "alive", 302: "alive" };
176
+ writeRecord(dir, "taker-a", { pid: 301 });
177
+ writeRecord(dir, "taker-b", { pid: 302 });
178
+
179
+ const first = ownershipFor("claimer-1", dir, table);
180
+ assert.equal(first.ownership.owns(SESSION, "dead-owner", "run-1"), true);
181
+ // While the first claimer lives nobody else may take the result.
182
+ assert.equal(ownershipFor("taker-a", dir, table).ownership.owns(SESSION, "dead-owner", "run-1"), false);
183
+
184
+ table[201] = "dead";
185
+ const a = ownershipFor("taker-a", dir, table, SESSION, { livenessTtlMs: 0 });
186
+ const b = ownershipFor("taker-b", dir, table, SESSION, { livenessTtlMs: 0 });
187
+ assert.equal(a.ownership.canTakeOver(SESSION, "dead-owner", "run-1"), true, "peek never claims");
188
+ assert.deepEqual(claimFiles(dir), ["run-1.json"]);
189
+ assert.equal(a.ownership.owns(SESSION, "dead-owner", "run-1"), true);
190
+ assert.equal(b.ownership.owns(SESSION, "dead-owner", "run-1"), false, "the second re-claimer loses");
191
+ assert.deepEqual(claimFiles(dir), ["run-1%g2.json", "run-1.json"].sort());
192
+ assert.equal(JSON.parse(fs.readFileSync(path.join(dir, "claims", "run-1%g2.json"), "utf-8")).claimedBy, "taker-a");
193
+
194
+ // A claimer whose record is gone cannot be proven dead.
195
+ fs.rmSync(ownerRecordPath(dir, "taker-a")!);
196
+ assert.equal(ownershipFor("claimer-2", dir, table, SESSION, { livenessTtlMs: 0 }).ownership.owns(SESSION, "dead-owner", "run-1"), false);
197
+ });
198
+
199
+ it("gives up once the claim chain is exhausted", () => {
200
+ const dir = tempDir();
201
+ writeRecord(dir, "dead-owner", { pid: 100 });
202
+ const table: Record<number, Liveness> = { 100: "dead" };
203
+ const claimers = ["c1", "c2", "c3", "c4"];
204
+ claimers.forEach((id, index) => {
205
+ writeRecord(dir, id, { pid: 200 + index });
206
+ table[200 + index] = "alive";
207
+ });
208
+ for (const id of claimers) {
209
+ assert.equal(ownershipFor(id, dir, table, SESSION, { livenessTtlMs: 0 }).ownership.owns(SESSION, "dead-owner", "run-1"), true, id);
210
+ table[200 + claimers.indexOf(id)] = "dead";
211
+ }
212
+ writeRecord(dir, "late", { pid: 300 });
213
+ table[300] = "alive";
214
+ assert.equal(ownershipFor("late", dir, table, SESSION, { livenessTtlMs: 0 }).ownership.owns(SESSION, "dead-owner", "run-1"), false);
215
+ });
216
+
217
+ it("fails safe on an unreadable claim marker", () => {
218
+ const dir = tempDir();
219
+ writeRecord(dir, "dead-owner", { pid: 100 });
220
+ fs.mkdirSync(path.join(dir, "claims"), { recursive: true });
221
+ fs.writeFileSync(path.join(dir, "claims", "run-1.json"), "{half");
222
+ const { ownership } = ownershipFor("me", dir, { 100: "dead" });
223
+ assert.equal(ownership.owns(SESSION, "dead-owner", "run-1"), false);
224
+ assert.equal(ownership.canTakeOver(SESSION, "dead-owner", "run-1"), false);
225
+ });
226
+
227
+ it("keeps run keys from escaping the claims directory", () => {
228
+ const dir = tempDir();
229
+ writeRecord(dir, "dead-owner", { pid: 100 });
230
+ const { ownership } = ownershipFor("me", dir, { 100: "dead" });
231
+ assert.equal(ownership.owns(SESSION, "dead-owner", "../../escape"), true);
232
+ assert.deepEqual(claimFiles(dir), [`${encodeURIComponent("../../escape")}.json`]);
233
+ assert.equal(fs.existsSync(path.join(dir, "..", "escape.json")), false);
234
+ assert.equal(ownerFileStem("../x/y"), "..%2Fx%2Fy");
235
+ });
236
+
237
+ it("probes each owner at most once per TTL window", () => {
238
+ const dir = tempDir();
239
+ writeRecord(dir, "live-owner", { pid: 100 });
240
+ let probes = 0;
241
+ let clock = 10_000;
242
+ const { ownership } = ownershipFor("me", dir, {}, SESSION, {
243
+ now: () => clock,
244
+ processLiveness: () => { probes += 1; return "alive"; },
245
+ });
246
+ for (let index = 0; index < 50; index += 1) ownership.owns(SESSION, "live-owner", `run-${index}`);
247
+ assert.equal(probes, 1);
248
+ clock += 1_999;
249
+ ownership.owns(SESSION, "live-owner", "run-x");
250
+ assert.equal(probes, 1);
251
+ clock += 2;
252
+ ownership.owns(SESSION, "live-owner", "run-y");
253
+ assert.equal(probes, 2);
254
+ });
255
+
256
+ it("garbage-collects only provably dead, week-old owner records and stale claims", () => {
257
+ const dir = tempDir();
258
+ const week = 8 * 24 * 60 * 60 * 1000;
259
+ const old = new Date(Date.now() - week);
260
+ const table: Record<number, Liveness> = { 1: "dead", 2: "alive", 3: "dead", 4: "unknown" };
261
+ writeRecord(dir, "old-dead", { pid: 1 });
262
+ writeRecord(dir, "old-alive", { pid: 2 });
263
+ writeRecord(dir, "young-dead", { pid: 3 });
264
+ writeRecord(dir, "old-unknown", { pid: 4 });
265
+ writeRecord(dir, "me", { pid: 5 });
266
+ table[5] = "dead";
267
+ for (const id of ["old-dead", "old-alive", "old-unknown", "me"]) fs.utimesSync(ownerRecordPath(dir, id)!, old, old);
268
+ fs.mkdirSync(path.join(dir, "claims"));
269
+ fs.writeFileSync(path.join(dir, "claims", "gone.json"), JSON.stringify({ claimedBy: "young-dead", previousOwner: "x", claimedAt: 1 }));
270
+ fs.utimesSync(path.join(dir, "claims", "gone.json"), old, old);
271
+ fs.writeFileSync(path.join(dir, "claims", "kept.json"), JSON.stringify({ claimedBy: "old-alive", previousOwner: "x", claimedAt: 1 }));
272
+ fs.utimesSync(path.join(dir, "claims", "kept.json"), old, old);
273
+ fs.writeFileSync(path.join(dir, "claims", "stale.json.1.abc.tmp"), "x");
274
+ fs.utimesSync(path.join(dir, "claims", "stale.json.1.abc.tmp"), old, old);
275
+ writeRecord(dir, "dead-owner", { pid: 9 });
276
+ table[9] = "dead";
277
+ const { ownership } = ownershipFor("me", dir, table);
278
+ ownership.owns(SESSION, "dead-owner", "run-1");
279
+ const left = fs.readdirSync(dir).sort();
280
+ assert.deepEqual(left, ["claims", "dead-owner.json", "me.json", "old-alive.json", "old-unknown.json", "young-dead.json"].sort());
281
+ assert.deepEqual(claimFiles(dir), ["kept.json", "run-1.json"]);
282
+ });
283
+
284
+ it("takes over from a real exited process, identified by its real pid", () => {
285
+ const dir = tempDir();
286
+ const child = spawnSync(process.execPath, ["-e", "process.stdout.write(String(process.pid))"], { encoding: "utf-8" });
287
+ const pid = Number(child.stdout);
288
+ assert.ok(Number.isSafeInteger(pid) && pid > 0);
289
+ writeRecord(dir, "exited", { pid, hostname: os.hostname() });
290
+ writeRecord(dir, "running", { pid: process.pid, hostname: os.hostname() });
291
+ const state = { currentSessionId: SESSION as string | null, completionOwnerId: "me" };
292
+ const ownership = createResultDeliveryOwnership(state, { ownersDir: dir });
293
+ assert.equal(ownership.owns(SESSION, "exited", "run-1"), true);
294
+ assert.equal(ownership.owns(SESSION, "running", "run-2"), false, "this very process is alive");
295
+ });
296
+ });
297
+
298
+ describe("owner record", () => {
299
+ it("is written once per process, shared across reloads, and survives shutdown", () => {
300
+ const dir = tempDir();
301
+ const ids = { completionOwnerId: "owner-xyz", pid: 777, hostname: HOST, startKey: () => "ps:start" };
302
+ assert.equal(ensureOwnerRecord({ ownersDir: dir, ...ids, now: () => 111 }), true);
303
+ const file = ownerRecordPath(dir, "owner-xyz")!;
304
+ const first = fs.readFileSync(file, "utf-8");
305
+ assert.deepEqual(JSON.parse(first), { version: 1, completionOwnerId: "owner-xyz", pid: 777, startKey: "ps:start", hostname: HOST, createdAt: 111 });
306
+ // Simulated extension reload: same owner id, later clock.
307
+ let startKeyCalls = 0;
308
+ assert.equal(ensureOwnerRecord({ ownersDir: dir, ...ids, startKey: () => { startKeyCalls += 1; return "x"; }, now: () => 999 }), false);
309
+ assert.equal(fs.readFileSync(file, "utf-8"), first);
310
+ assert.equal(startKeyCalls, 0, "a reload must not spawn ps again");
311
+ assert.deepEqual(fs.readdirSync(dir), ["owner-xyz.json"], "no temp files left behind");
312
+ if (process.platform !== "win32") assert.equal(fs.statSync(file).mode & 0o077, 0, "record is private to the user");
313
+ // Not removed by anything this process does on shutdown: detached runners outlive it and need the record.
314
+ assert.deepEqual(readOwnerRecord(dir, "owner-xyz")?.pid, 777);
315
+ });
316
+
317
+ it("replaces a record left by a different process that reused the owner id slot", () => {
318
+ const dir = tempDir();
319
+ ensureOwnerRecord({ ownersDir: dir, completionOwnerId: "o", pid: 1, hostname: HOST, startKey: () => undefined, now: () => 1 });
320
+ assert.equal(ensureOwnerRecord({ ownersDir: dir, completionOwnerId: "o", pid: 2, hostname: HOST, startKey: () => undefined, now: () => 2 }), true);
321
+ assert.equal(readOwnerRecord(dir, "o")?.pid, 2);
322
+ assert.equal("startKey" in (readOwnerRecord(dir, "o") ?? {}), false);
323
+ });
324
+
325
+ it("resolves this process's real liveness and start key", () => {
326
+ assert.equal(processLiveness(process.pid), "alive");
327
+ assert.equal(processLiveness(0), "unknown");
328
+ assert.equal(processLiveness(-5), "unknown");
329
+ const key = processStartKey(process.pid);
330
+ assert.equal(processStartKey(process.pid), key, "own key is cached");
331
+ });
332
+ });
@@ -1102,8 +1102,10 @@ async function runOnboarding(pi: ExtensionAPI, ctx: ExtensionContext): Promise<v
1102
1102
  return;
1103
1103
  }
1104
1104
 
1105
- // No UI? Defer to a later session.
1106
- if (!ctx.hasUI) {
1105
+ // No UI, or a headless RPC host? Defer to a later session. In RPC mode a startup
1106
+ // confirm blocks the whole command loop (activation is awaited before stdin is served),
1107
+ // and an unattended host may never answer it; onboarding stays available via /tokenin add.
1108
+ if (!ctx.hasUI || ctx.mode === "rpc") {
1107
1109
  return;
1108
1110
  }
1109
1111
 
package/dist/index.d.ts CHANGED
@@ -26,7 +26,7 @@ export { type EditDiffResult, generateDiffString, generateUnifiedPatch } from ".
26
26
  export { type BashOperations, type BashSpawnContext, type BashSpawnHook, type BashToolDetails, type BashToolInput, type BashToolOptions, createBashToolDefinition, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLocalBashOperations, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type EditOperations, type EditToolDetails, type EditToolInput, type EditToolOptions, type FindOperations, type FindToolDetails, type FindToolInput, type FindToolOptions, formatSize, type GrepOperations, type GrepToolDetails, type GrepToolInput, type GrepToolOptions, type LsOperations, type LsToolDetails, type LsToolInput, type LsToolOptions, type ReadOperations, type ReadToolDetails, type ReadToolInput, type ReadToolOptions, type ToolsOptions, type TruncationOptions, type TruncationResult, truncateHead, truncateLine, truncateTail, type WriteOperations, type WriteToolInput, type WriteToolOptions, withFileMutationQueue, } from "./core/tools/index.ts";
27
27
  export { hasTrustRequiringProjectResources, type ProjectTrustDecision, ProjectTrustStore, type ProjectTrustStoreEntry, type ProjectTrustUpdate, } from "./core/trust-manager.ts";
28
28
  export { type MainOptions, main } from "./main.ts";
29
- export { InteractiveMode, type InteractiveModeOptions, type ModelInfo, type PrintModeOptions, RpcClient, type RpcClientOptions, type RpcCommand, type RpcEventListener, type RpcExtensionUIRequest, type RpcExtensionUIResponse, type RpcResponse, type RpcSessionState, runPrintMode, runRpcMode, } from "./modes/index.ts";
29
+ export { InteractiveMode, type InteractiveModeOptions, type ModelInfo, type PrintModeOptions, RpcClient, type RpcClientOptions, type RpcCommand, type RpcEvent, type SubagentRpcCommand, type SubagentRpcEvent, type SubagentRpcResponse, type SubagentRpcSnapshot, type SubagentRpcNode, type SubagentRpcFleet, type SubagentRpcTokens, type SubagentRpcLifecycle, type RpcObservationScope, type ReleaseCommand, type ReleaseResponse, type ReleaseResult, type ReleaseReadinessCommand, type ReleaseReadinessResponse, type ReleaseReadiness, type StopAllBackgroundCommand, type StopAllBackgroundResponse, type StopAllBackgroundResult, type StopAllBackgroundItem, type IntercomRpcCommand, type IntercomRpcResponse, type IntercomRpcEvent, type IntercomRpcStatus, type IntercomRpcDelivery, type IntercomRpcMessageOptions, type IntercomRpcDataMap, type RpcEventListener, type RpcExtensionUIRequest, type RpcExtensionUIResponse, type RpcResponse, type RpcSessionState, runPrintMode, runRpcMode, } from "./modes/index.ts";
30
30
  export { ArminComponent, AssistantMessageComponent, BashExecutionComponent, BorderedLoader, BranchSummaryMessageComponent, CompactionSummaryMessageComponent, CustomEditor, type CustomEditorOptions, CustomMessageComponent, DynamicBorder, ExtensionEditorComponent, ExtensionInputComponent, ExtensionSelectorComponent, FooterComponent, keyHint, keyText, LoginDialogComponent, ModelSelectorComponent, OAuthSelectorComponent, type RenderDiffOptions, rawKeyHint, renderDiff, SessionSelectorComponent, type SettingsCallbacks, type SettingsConfig, SettingsSelectorComponent, ShowImagesSelectorComponent, SkillInvocationMessageComponent, ThemeSelectorComponent, ThinkingSelectorComponent, ToolExecutionComponent, type ToolExecutionOptions, TreeSelectorComponent, truncateToVisualLines, UserMessageComponent, UserMessageSelectorComponent, type VisualTruncateResult, } from "./modes/interactive/components/index.ts";
31
31
  export { getLanguageFromPath, getMarkdownTheme, getSelectListTheme, getSettingsListTheme, highlightCode, initTheme, Theme, type ThemeColor, } from "./modes/interactive/theme/theme.ts";
32
32
  export { copyToClipboard } from "./utils/clipboard.ts";
@@ -35,3 +35,8 @@ export { convertToPng } from "./utils/image-convert.ts";
35
35
  export { formatDimensionNote, type ResizedImage, resizeImage } from "./utils/image-resize.ts";
36
36
  export { detectSupportedImageMimeTypeFromFile } from "./utils/mime.ts";
37
37
  export { getShellConfig } from "./utils/shell.ts";
38
+ export { INTERCOM_RPC_REQUEST_EVENT, INTERCOM_RPC_RESULT_EVENT, INTERCOM_RPC_EVENT, INTERCOM_RPC_ASK_TIMEOUT_MS, intercomRpcTimeout, type IntercomRpcRequest, type IntercomRpcResult, } from "./core/intercom-rpc.ts";
39
+ export { SUBAGENT_OBSERVE_EVENT, SUBAGENT_RPC_MAX_BYTES, type SubagentObserveRequest, type SubagentObservationSource, type SubagentRpcProjection } from "./core/subagent-rpc.ts";
40
+ export type { IntercomRpcEventData } from "./core/intercom-rpc.ts";
41
+ export { RELEASE_READINESS_EVENT, answerReleaseReadiness, isReleaseBlocker, type ReleaseBlocker, type ReleaseBlockerSource, type ReleaseReadinessRequest } from "./core/release-rpc.ts";
42
+ export { STOP_ALL_BACKGROUND_EVENT, STOP_ALL_BACKGROUND_TIMEOUT_MS, answerStopAllBackground, isStopAllBackgroundItem, stopAllContributorError, type StopAllBackgroundRequest } from "./core/release-rpc.ts";
package/dist/index.js CHANGED
@@ -46,3 +46,10 @@ export { formatDimensionNote, resizeImage } from "./utils/image-resize.js";
46
46
  export { detectSupportedImageMimeTypeFromFile } from "./utils/mime.js";
47
47
  // Shell utilities
48
48
  export { getShellConfig } from "./utils/shell.js";
49
+ // Dedicated Intercom host/extension bridge (not a generic RPC event bus).
50
+ export { INTERCOM_RPC_REQUEST_EVENT, INTERCOM_RPC_RESULT_EVENT, INTERCOM_RPC_EVENT, INTERCOM_RPC_ASK_TIMEOUT_MS, intercomRpcTimeout, } from "./core/intercom-rpc.js";
51
+ export { SUBAGENT_OBSERVE_EVENT, SUBAGENT_RPC_MAX_BYTES } from "./core/subagent-rpc.js";
52
+ // Release-readiness broadcast: extensions contribute blockers; the RPC host aggregates them.
53
+ export { RELEASE_READINESS_EVENT, answerReleaseReadiness, isReleaseBlocker } from "./core/release-rpc.js";
54
+ // Kill switch (`stop_all_background`): extensions register a stop handler next to their readiness contributor.
55
+ export { STOP_ALL_BACKGROUND_EVENT, STOP_ALL_BACKGROUND_TIMEOUT_MS, answerStopAllBackground, isStopAllBackgroundItem, stopAllContributorError } from "./core/release-rpc.js";
package/dist/main.d.ts CHANGED
@@ -7,5 +7,11 @@
7
7
  import type { InlineExtension } from "./core/extensions/types.ts";
8
8
  export interface MainOptions {
9
9
  extensionFactories?: InlineExtension[];
10
+ /**
11
+ * Built-in extensions arrive compiled in through `extensionFactories` (native
12
+ * binary), so the on-disk bundled extensions dir is shipped only for its
13
+ * assets and must not be loaded a second time.
14
+ */
15
+ builtinExtensionsCompiled?: boolean;
10
16
  }
11
17
  export declare function main(args: string[], options?: MainOptions): Promise<void>;
package/dist/main.js CHANGED
@@ -545,7 +545,7 @@ export async function main(args, options) {
545
545
  const resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates);
546
546
  const resolvedThemePaths = resolveCliPaths(cwd, parsed.themes);
547
547
  // Bundled Selesai resources ship in dist and load through the same hooks as CLI paths.
548
- const bundledExtensionPaths = [getBundledExtensionsDir()];
548
+ const bundledExtensionPaths = options?.builtinExtensionsCompiled ? [] : [getBundledExtensionsDir()];
549
549
  const bundledThemePaths = [getBundledThemesDir()];
550
550
  const bundledSkillPaths = [getBundledSkillsDir()];
551
551
  const createRuntime = async ({ cwd, agentDir, sessionManager, sessionStartEvent, projectTrustContext, }) => {
@@ -5,4 +5,4 @@ export { InteractiveMode, type InteractiveModeOptions } from "./interactive/inte
5
5
  export { type PrintModeOptions, runPrintMode } from "./print-mode.ts";
6
6
  export { type ModelInfo, RpcClient, type RpcClientOptions, type RpcEventListener } from "./rpc/rpc-client.ts";
7
7
  export { runRpcMode } from "./rpc/rpc-mode.ts";
8
- export type { RpcCommand, RpcExtensionUIRequest, RpcExtensionUIResponse, RpcResponse, RpcSessionState, PrototypePhase, QuicktypePhase, WorkflowState, } from "./rpc/rpc-types.ts";
8
+ export type { RpcCommand, RpcEvent, SubagentRpcCommand, SubagentRpcEvent, SubagentRpcResponse, SubagentRpcSnapshot, SubagentRpcNode, SubagentRpcFleet, SubagentRpcTokens, SubagentRpcLifecycle, RpcObservationScope, ReleaseCommand, ReleaseResponse, ReleaseResult, ReleaseReadinessCommand, ReleaseReadinessResponse, ReleaseReadiness, StopAllBackgroundCommand, StopAllBackgroundResponse, StopAllBackgroundResult, StopAllBackgroundItem, IntercomRpcCommand, IntercomRpcResponse, IntercomRpcEvent, IntercomRpcStatus, IntercomRpcDelivery, IntercomRpcMessageOptions, IntercomRpcDataMap, RpcExtensionUIRequest, RpcExtensionUIResponse, RpcResponse, RpcSessionState, PrototypePhase, QuicktypePhase, WorkflowState, } from "./rpc/rpc-types.ts";
@@ -0,0 +1,14 @@
1
+ import type { EventBus } from "../../core/event-bus.ts";
2
+ import { type IntercomRpcCommand, type IntercomRpcEvent } from "../../core/intercom-rpc.ts";
3
+ import type { RpcResponse } from "./rpc-types.ts";
4
+ /** One listener pair per bound session; replacement rejects pending calls and ignores stale results. */
5
+ export declare class RpcIntercomBridge {
6
+ private disposed;
7
+ private readonly unsubscribe;
8
+ private readonly pending;
9
+ private readonly scope;
10
+ private readonly bus;
11
+ constructor(bus: EventBus | undefined, output: (event: IntercomRpcEvent) => void, sessionId: string, epoch?: string);
12
+ request(command: IntercomRpcCommand): Promise<RpcResponse>;
13
+ dispose(): void;
14
+ }
@@ -0,0 +1,75 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { INTERCOM_RPC_EVENT, INTERCOM_RPC_REQUEST_EVENT, INTERCOM_RPC_RESULT_EVENT, intercomRpcTimeout, isIntercomRpcEvent, } from "../../core/intercom-rpc.js";
3
+ /** One listener pair per bound session; replacement rejects pending calls and ignores stale results. */
4
+ export class RpcIntercomBridge {
5
+ disposed = false;
6
+ unsubscribe = [];
7
+ pending = new Map();
8
+ scope;
9
+ bus;
10
+ constructor(bus, output, sessionId, epoch = randomUUID()) {
11
+ this.scope = { version: 1, sessionId, epoch };
12
+ this.bus = bus;
13
+ if (!bus)
14
+ return;
15
+ this.unsubscribe.push(bus.on(INTERCOM_RPC_EVENT, (event) => {
16
+ const envelope = event;
17
+ if (!this.disposed && envelope?.sessionId === sessionId && isIntercomRpcEvent(envelope.event))
18
+ output({ ...envelope.event, scope: this.scope });
19
+ }));
20
+ this.unsubscribe.push(bus.on(INTERCOM_RPC_RESULT_EVENT, (result) => {
21
+ if (this.disposed)
22
+ return;
23
+ const value = result;
24
+ if (!value || typeof value.requestId !== "string" || typeof value.success !== "boolean")
25
+ return;
26
+ this.pending.get(value.requestId)?.finish(value);
27
+ }));
28
+ }
29
+ request(command) {
30
+ const failure = (error) => ({ id: command.id, type: "response", command: command.type, success: false, error });
31
+ if (this.disposed)
32
+ return Promise.resolve(failure("Intercom session ended"));
33
+ if (!this.bus)
34
+ return Promise.resolve(failure("Intercom extension unavailable"));
35
+ let timeoutMs;
36
+ try {
37
+ timeoutMs = intercomRpcTimeout(command);
38
+ }
39
+ catch (error) {
40
+ return Promise.resolve(failure(error.message));
41
+ }
42
+ const requestId = randomUUID();
43
+ return new Promise((resolve) => {
44
+ const controller = new AbortController();
45
+ const timer = setTimeout(() => {
46
+ this.pending.get(requestId)?.finish({ requestId, success: false, error: "Intercom request timed out; timeout is not cancellation of delivered messages" });
47
+ controller.abort();
48
+ }, timeoutMs + 1000);
49
+ this.pending.set(requestId, {
50
+ controller,
51
+ finish: (result) => {
52
+ clearTimeout(timer);
53
+ this.pending.delete(requestId);
54
+ resolve(result.success
55
+ ? { id: command.id, type: "response", command: command.type, success: true, data: command.type === "intercom_status" ? { ...result.data, scope: this.scope } : result.data }
56
+ : failure(result.error));
57
+ },
58
+ });
59
+ let accepted = false;
60
+ const request = { requestId, command, signal: controller.signal, accept: () => { accepted = true; } };
61
+ this.bus.emit(INTERCOM_RPC_REQUEST_EVENT, request);
62
+ if (!accepted)
63
+ this.pending.get(requestId)?.finish({ requestId, success: false, error: "Intercom extension unavailable" });
64
+ });
65
+ }
66
+ dispose() {
67
+ this.disposed = true;
68
+ for (const unsubscribe of this.unsubscribe)
69
+ unsubscribe();
70
+ for (const [requestId, pending] of [...this.pending]) {
71
+ pending.finish({ requestId, success: false, error: "Intercom session ended" });
72
+ pending.controller.abort();
73
+ }
74
+ }
75
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createEventBus } from "../../core/event-bus.js";
3
+ import { INTERCOM_RPC_EVENT, INTERCOM_RPC_REQUEST_EVENT, INTERCOM_RPC_RESULT_EVENT } from "../../core/intercom-rpc.js";
4
+ import { RpcIntercomBridge } from "./intercom-bridge.js";
5
+ describe("RpcIntercomBridge", () => {
6
+ it("fails promptly for absent backend and invalid timeout", async () => {
7
+ const bridge = new RpcIntercomBridge(createEventBus(), () => { }, "self");
8
+ expect(await bridge.request({ id: "missing", type: "intercom_list" })).toMatchObject({ id: "missing", success: false, error: "Intercom extension unavailable" });
9
+ expect(await bridge.request({ type: "intercom_ask", to: "peer", message: "?", timeoutMs: 0 })).toMatchObject({ success: false, error: expect.stringContaining("timeoutMs") });
10
+ bridge.dispose();
11
+ expect(await new RpcIntercomBridge(undefined, () => { }, "self").request({ type: "intercom_status" })).toMatchObject({ success: false });
12
+ });
13
+ it("correlates concurrent commands independently of external ids", async () => {
14
+ const bus = createEventBus();
15
+ const requests = [];
16
+ bus.on(INTERCOM_RPC_REQUEST_EVENT, payload => { const request = payload; request.accept(); requests.push(request); });
17
+ const bridge = new RpcIntercomBridge(bus, () => { }, "self");
18
+ const first = bridge.request({ id: "same", type: "intercom_list" });
19
+ const second = bridge.request({ id: "same", type: "intercom_status" });
20
+ bus.emit(INTERCOM_RPC_RESULT_EVENT, { requestId: requests[1].requestId, success: true, data: { enabled: true, connected: false, sessionId: "self" } });
21
+ bus.emit(INTERCOM_RPC_RESULT_EVENT, { requestId: requests[0].requestId, success: true, data: { sessionId: "self", sessions: [] } });
22
+ expect(await first).toMatchObject({ id: "same", command: "intercom_list", data: { sessions: [] } });
23
+ expect(await second).toMatchObject({ command: "intercom_status", data: { connected: false, scope: { version: 1, sessionId: "self", epoch: expect.any(String) } } });
24
+ bridge.dispose();
25
+ });
26
+ it("bounds requests and aborts the extension operation", async () => {
27
+ vi.useFakeTimers();
28
+ try {
29
+ const bus = createEventBus();
30
+ let signal;
31
+ bus.on(INTERCOM_RPC_REQUEST_EVENT, payload => { const request = payload; request.accept(); signal = request.signal; });
32
+ const bridge = new RpcIntercomBridge(bus, () => { }, "self");
33
+ const response = bridge.request({ type: "intercom_ask", to: "peer", message: "?", timeoutMs: 10 });
34
+ await vi.advanceTimersByTimeAsync(1010);
35
+ expect(await response).toMatchObject({ success: false, error: expect.stringContaining("timed out") });
36
+ expect(signal?.aborted).toBe(true);
37
+ bridge.dispose();
38
+ }
39
+ finally {
40
+ vi.useRealTimers();
41
+ }
42
+ });
43
+ it("rejects replacement requests and detaches old event/result listeners", async () => {
44
+ const bus = createEventBus();
45
+ let request;
46
+ bus.on(INTERCOM_RPC_REQUEST_EVENT, payload => { request = payload; request.accept(); });
47
+ const oldOutput = vi.fn();
48
+ const bridge = new RpcIntercomBridge(bus, oldOutput, "self");
49
+ const pending = bridge.request({ type: "intercom_list" });
50
+ bridge.dispose();
51
+ expect(await pending).toMatchObject({ success: false, error: "Intercom session ended" });
52
+ expect(request?.signal.aborted).toBe(true);
53
+ const output = vi.fn();
54
+ const replacement = new RpcIntercomBridge(bus, output, "new", "new-epoch");
55
+ bus.emit(INTERCOM_RPC_EVENT, { sessionId: "new", event: { type: "intercom_connection", enabled: true, connected: true, sessionId: "new" } });
56
+ bus.emit(INTERCOM_RPC_RESULT_EVENT, { requestId: request?.requestId, success: true, data: {} });
57
+ expect(oldOutput).not.toHaveBeenCalled();
58
+ bus.emit(INTERCOM_RPC_EVENT, { sessionId: "old", event: { type: "intercom_connection", enabled: true, connected: true, sessionId: "old" } });
59
+ bus.emit(INTERCOM_RPC_EVENT, { sessionId: "new", event: { type: "arbitrary", payload: "must not forward" } });
60
+ bus.emit(INTERCOM_RPC_EVENT, null);
61
+ expect(output).toHaveBeenCalledTimes(1);
62
+ expect(output).toHaveBeenCalledWith({ type: "intercom_connection", enabled: true, connected: true, sessionId: "new", scope: { version: 1, sessionId: "new", epoch: "new-epoch" } });
63
+ replacement.dispose();
64
+ });
65
+ });
@@ -0,0 +1,35 @@
1
+ import type { EventBus } from "../../core/event-bus.ts";
2
+ import { type ReleaseBlocker, type ReleaseCommand, type ReleaseReadiness, type ReleaseReadinessCommand, type StopAllBackgroundCommand, type StopAllBackgroundItem } from "../../core/release-rpc.ts";
3
+ import type { RpcObservationScope } from "../../core/subagent-rpc.ts";
4
+ import type { RpcResponse } from "./rpc-types.ts";
5
+ /** Shared by `release_readiness`, `release` and `stop_all_background`: version 1, no unknown keys, bounded single-line id. Returns the error text, if any. */
6
+ export declare function validateReleaseCommand(command: ReleaseReadinessCommand | ReleaseCommand | StopAllBackgroundCommand): string | undefined;
7
+ /**
8
+ * Collects release blockers from every extension listener on one host binding. Synchronous and fail closed:
9
+ * malformed or throwing contributors become `contributor_error` blockers, never silent "safe".
10
+ */
11
+ export declare class RpcReleaseBridge {
12
+ private disposed;
13
+ private bus;
14
+ private scope;
15
+ constructor(bus: EventBus | undefined, scope: RpcObservationScope);
16
+ request(command: ReleaseReadinessCommand, coreBlockers?: ReleaseBlocker[]): RpcResponse;
17
+ /**
18
+ * Atomic release decision (`release`): validates like `request`, collects readiness and, only when it permits release
19
+ * (`decideRelease`), calls `commit` before returning. Everything here is synchronous, so nothing can interleave between the
20
+ * check and the commitment; the caller's `commit` must therefore also be synchronous (set the "releasing" flag).
21
+ * Not releasable: nothing is committed and the process keeps running.
22
+ */
23
+ release(command: ReleaseCommand, coreBlockers: ReleaseBlocker[], commit: (readiness: ReleaseReadiness) => void): RpcResponse;
24
+ /**
25
+ * Kill switch (`stop_all_background`). Validates like `request`, then starts the core stop and broadcasts
26
+ * `STOP_ALL_BACKGROUND_EVENT` so every extension starts its own stop synchronously, and awaits all of them under ONE total
27
+ * budget (`timeoutMs`). A contributor that throws, rejects or is still running at the deadline becomes an `ok:false` item;
28
+ * it never prevents the others from running or the response from being sent. `remaining` is the readiness collected AFTER
29
+ * the stop. `success:false` only for a malformed request or a disposed binding.
30
+ */
31
+ stopAll(command: StopAllBackgroundCommand, coreStop: () => StopAllBackgroundItem[] | PromiseLike<StopAllBackgroundItem[]>, coreBlockers: () => ReleaseBlocker[], timeoutMs?: number): Promise<RpcResponse>;
32
+ /** Validation error text, or the collected readiness. */
33
+ private collect;
34
+ dispose(): void;
35
+ }