@fitchmultz/pi-subagents 0.47.0 → 0.49.0

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 (73) hide show
  1. package/CHANGELOG.md +29 -3
  2. package/dist/extension/build-info.js +1 -1
  3. package/dist/pi-intercom/bridge-broadcast.js +60 -0
  4. package/dist/pi-intercom/bridge-catalog.js +372 -0
  5. package/dist/pi-intercom/bridge-config.js +15 -3
  6. package/dist/pi-intercom/bridge-control.js +104 -0
  7. package/dist/pi-intercom/bridge-groups.js +135 -0
  8. package/dist/pi-intercom/bridge-http.js +8 -1
  9. package/dist/pi-intercom/bridge-launch-journal.js +84 -0
  10. package/dist/pi-intercom/bridge-launch-policy.js +71 -5
  11. package/dist/pi-intercom/bridge-launch.js +186 -76
  12. package/dist/pi-intercom/bridge-mailbox.js +3 -55
  13. package/dist/pi-intercom/bridge-operations.js +127 -0
  14. package/dist/pi-intercom/bridge-presence.js +5 -1
  15. package/dist/pi-intercom/bridge-protocol.js +7 -0
  16. package/dist/pi-intercom/bridge-routes.js +195 -27
  17. package/dist/pi-intercom/bridge-rpc-process.js +43 -15
  18. package/dist/pi-intercom/bridge-session.js +83 -66
  19. package/dist/pi-intercom/bridge-storage.js +73 -0
  20. package/dist/pi-intercom/bridge-target.js +22 -0
  21. package/dist/pi-intercom/bridge-wake-owner.js +57 -0
  22. package/dist/pi-intercom/bridge-wake-state.js +293 -0
  23. package/dist/pi-intercom/bridge-wake.js +190 -99
  24. package/dist/pi-intercom/bridge.js +39 -39
  25. package/dist/pi-intercom/broker/client.js +1 -0
  26. package/dist/pi-intercom/connection.js +2 -3
  27. package/dist/pi-intercom/inbound.js +2 -4
  28. package/dist/pi-intercom/index.js +7 -1
  29. package/dist/pi-intercom/native-control-auth.js +80 -0
  30. package/dist/pi-intercom/native-control-protocol.js +111 -0
  31. package/dist/pi-intercom/native-control.js +130 -0
  32. package/dist/pi-intercom/saved-transcript.js +135 -0
  33. package/dist/pi-intercom/session-targets.js +3 -0
  34. package/dist/pi-intercom/transcript.js +175 -0
  35. package/docs/intercom-bridge.md +315 -174
  36. package/docs/intercom.md +1 -1
  37. package/docs/reference.md +1 -1
  38. package/package.json +1 -1
  39. package/scripts/intercom-bridge-wake-preflight.mjs +112 -0
  40. package/scripts/intercom-remote.mjs +213 -38
  41. package/src/pi-intercom/bridge-broadcast.ts +70 -0
  42. package/src/pi-intercom/bridge-catalog.ts +489 -0
  43. package/src/pi-intercom/bridge-config.ts +20 -3
  44. package/src/pi-intercom/bridge-control.ts +179 -0
  45. package/src/pi-intercom/bridge-groups.ts +169 -0
  46. package/src/pi-intercom/bridge-http.ts +15 -1
  47. package/src/pi-intercom/bridge-launch-journal.ts +122 -0
  48. package/src/pi-intercom/bridge-launch-policy.ts +88 -7
  49. package/src/pi-intercom/bridge-launch.ts +296 -107
  50. package/src/pi-intercom/bridge-mailbox.ts +3 -59
  51. package/src/pi-intercom/bridge-operations.ts +169 -0
  52. package/src/pi-intercom/bridge-presence.ts +7 -1
  53. package/src/pi-intercom/bridge-protocol.ts +8 -0
  54. package/src/pi-intercom/bridge-routes.ts +291 -49
  55. package/src/pi-intercom/bridge-rpc-process.ts +53 -16
  56. package/src/pi-intercom/bridge-session.ts +98 -96
  57. package/src/pi-intercom/bridge-storage.ts +83 -0
  58. package/src/pi-intercom/bridge-target.ts +35 -0
  59. package/src/pi-intercom/bridge-wake-owner.ts +58 -0
  60. package/src/pi-intercom/bridge-wake-state.ts +321 -0
  61. package/src/pi-intercom/bridge-wake.ts +224 -130
  62. package/src/pi-intercom/bridge.ts +41 -49
  63. package/src/pi-intercom/broker/client.ts +3 -0
  64. package/src/pi-intercom/connection.ts +2 -3
  65. package/src/pi-intercom/inbound.ts +2 -4
  66. package/src/pi-intercom/index.ts +7 -1
  67. package/src/pi-intercom/native-control-auth.ts +89 -0
  68. package/src/pi-intercom/native-control-protocol.ts +180 -0
  69. package/src/pi-intercom/native-control.ts +160 -0
  70. package/src/pi-intercom/saved-transcript.ts +175 -0
  71. package/src/pi-intercom/session-targets.ts +4 -0
  72. package/src/pi-intercom/transcript.ts +226 -0
  73. package/src/pi-intercom/types.ts +2 -0
@@ -1,23 +1,34 @@
1
- import { createHash } from "node:crypto";
2
- import { statSync } from "node:fs";
3
1
  import { setTimeout as delay } from "node:timers/promises";
4
- import { loadNativeSession } from "../shared/native-session-loader.ts";
5
2
  import { isRecord, type UnknownRecord } from "../shared/unknown.ts";
6
- import type { SessionInfo } from "./types.ts";
3
+ import type { SendResult, SessionInfo } from "./types.ts";
7
4
  import type { Identity } from "./bridge-config.ts";
8
- import type { LaunchPolicy } from "./bridge-launch-policy.ts";
5
+ import { launchCwd, spawnPolicy, type LaunchPolicy } from "./bridge-launch-policy.ts";
9
6
  import { Fault, cancelled, log, wait } from "./bridge-protocol.ts";
10
7
  import { RpcProcess } from "./bridge-rpc-process.ts";
8
+ import {
9
+ exactPeer,
10
+ savedView,
11
+ browseSessions,
12
+ type CatalogQuery,
13
+ type SessionCatalog,
14
+ } from "./bridge-catalog.ts";
15
+ import {
16
+ createLaunchJournal,
17
+ resumeLaunchJournal,
18
+ validateLaunchState,
19
+ type LaunchJournal,
20
+ } from "./bridge-launch-journal.ts";
21
+ import { spawnBrokerIfNeeded } from "./broker/spawn.ts";
22
+ import { resolveIntercomPresenceName } from "./identity.ts";
11
23
 
12
24
  interface Launch {
13
25
  readonly identity: Identity;
14
- readonly name: string;
26
+ readonly name?: string;
27
+ readonly cwd: string;
15
28
  readonly controller: Readonly<AbortController>;
16
29
  readonly expires: number;
17
30
  admitted: boolean;
18
- sessionId?: string;
19
- peerId?: string;
20
- sessionFile?: string;
31
+ journal?: LaunchJournal;
21
32
  process?: RpcProcess;
22
33
  timer?: NodeJS.Timeout;
23
34
  }
@@ -25,11 +36,23 @@ interface StartContext {
25
36
  readonly identity: Identity;
26
37
  readonly expires: number;
27
38
  readonly signal: AbortSignal;
28
- readonly peers: (
29
- signal: AbortSignal,
30
- ) => Promise<readonly Readonly<Pick<SessionInfo, "id" | "name" | "cwd">>[]>;
39
+ readonly peers: (signal: AbortSignal) => Promise<readonly SessionInfo[]>;
40
+ readonly submit: (peer: string, task: string) => Promise<SendResult>;
41
+ }
42
+ type ControlContext = Pick<StartContext, "signal" | "peers">;
43
+ interface SpawnInput {
44
+ readonly name: string;
45
+ readonly cwd?: string;
46
+ readonly provider?: string;
47
+ readonly model?: string;
48
+ readonly task?: string;
49
+ }
50
+ interface ResumeInput {
51
+ readonly sessionId?: string;
52
+ readonly target?: string;
53
+ readonly name?: string;
54
+ readonly task?: string;
31
55
  }
32
-
33
56
  export function sameIdentity(left: Identity, right: Identity | undefined): boolean {
34
57
  return (
35
58
  left.name === right?.name &&
@@ -39,20 +62,29 @@ export function sameIdentity(left: Identity, right: Identity | undefined): boole
39
62
  }
40
63
  function view(entry: Readonly<Launch>): UnknownRecord {
41
64
  return {
42
- sessionId: entry.sessionId,
43
- peerId: entry.peerId,
65
+ id: entry.journal?.sessionId,
66
+ sessionId: entry.journal?.sessionId,
67
+ peerId: entry.journal?.peerId,
44
68
  name: entry.name,
45
- sessionFile: entry.sessionFile,
69
+ cwd: entry.cwd,
70
+ sessionFile: entry.journal?.sessionFile,
46
71
  pid: entry.process?.pid,
47
72
  status: entry.process?.status ?? "starting",
48
73
  error: entry.process?.failure?.code,
74
+ saved: entry.journal !== undefined,
75
+ owned: true,
49
76
  };
50
77
  }
51
-
52
78
  export class Launcher {
53
79
  private readonly launches = new Set<Launch>();
80
+ private readonly catalog: SessionCatalog;
81
+ private readonly resuming = new Set<string>();
54
82
  private stopping = false;
55
83
 
84
+ constructor(catalog: SessionCatalog) {
85
+ this.catalog = catalog;
86
+ }
87
+
56
88
  list(identity: Identity): UnknownRecord {
57
89
  return {
58
90
  sessions: [...this.launches]
@@ -60,42 +92,252 @@ export class Launcher {
60
92
  .map(view),
61
93
  };
62
94
  }
63
- async stop(identity: Identity, sessionId: string): Promise<UnknownRecord> {
64
- const entry = [...this.launches].find(
95
+ async browse(query: CatalogQuery, context: StartContext): Promise<UnknownRecord> {
96
+ return browseSessions(this.catalog, query, {
97
+ policy: context.identity.launch,
98
+ signal: context.signal,
99
+ peers: context.peers,
100
+ activeState: (id) => {
101
+ const entry = this.active(id) ?? this.activePeer(id);
102
+ return entry?.identity.fingerprint256 === context.identity.fingerprint256
103
+ ? (entry.process?.status ?? "starting")
104
+ : undefined;
105
+ },
106
+ });
107
+ }
108
+ private active(sessionId: string): Launch | undefined {
109
+ return [...this.launches].find(
110
+ (entry) => entry.journal?.sessionId === sessionId && entry.process?.status !== "exited",
111
+ );
112
+ }
113
+ private activePeer(peerId: string): Launch | undefined {
114
+ return [...this.launches].find(
115
+ (entry) => entry.journal?.peerId === peerId && entry.process?.status !== "exited",
116
+ );
117
+ }
118
+ private async owned(identity: Identity, id: string, context: ControlContext): Promise<Launch> {
119
+ const candidates = [...this.launches].filter(
120
+ (candidate) => candidate.identity.fingerprint256 === identity.fingerprint256,
121
+ );
122
+ const byId = candidates.findLast(
123
+ (candidate) => candidate.journal?.sessionId === id || candidate.journal?.peerId === id,
124
+ );
125
+ const named = candidates.filter(
65
126
  (candidate) =>
66
- candidate.identity.fingerprint256 === identity.fingerprint256 &&
67
- candidate.sessionId === sessionId,
127
+ candidate.process?.status !== "exited" &&
128
+ (candidate.name === id || candidate.name === `remote:${identity.name}:${id}`),
68
129
  );
69
- if (!entry) {
70
- throw new Fault(404, "unknown_session", "Session is not owned by this identity and helper.");
130
+ if (!byId && named.length > 1) {
131
+ throw new Fault(
132
+ 409,
133
+ "ambiguous_session",
134
+ "Multiple owned live processes match this name; use their native IDs.",
135
+ );
71
136
  }
137
+ const entry = byId ?? named.at(0);
138
+ if (!entry?.process) {
139
+ throw new Fault(
140
+ 404,
141
+ "unknown_session",
142
+ "Control is available only for this identity's helper-owned subprocesses, not unrelated live peers or saved history.",
143
+ );
144
+ }
145
+ if (entry.process.status === "exited") {
146
+ const peers = await context.peers(context.signal);
147
+ if (peers.some((peer) => peer.id === entry.journal?.peerId)) {
148
+ throw new Fault(
149
+ 404,
150
+ "unknown_session",
151
+ "This journal has a newer live owner; the historical process is not current ownership.",
152
+ );
153
+ }
154
+ }
155
+ return entry;
156
+ }
157
+ async stop(
158
+ identity: Identity,
159
+ sessionId: string,
160
+ context: ControlContext,
161
+ ): Promise<UnknownRecord> {
162
+ const entry = await this.owned(identity, sessionId, context);
72
163
  await this.retire(entry, new Fault(410, "session_stopped", "Session stopped by its owner."));
73
164
  return { session: view(entry) };
74
165
  }
75
- async start(name: string, context: StartContext): Promise<UnknownRecord> {
76
- cancelled(context.signal);
77
- const policy = context.identity.launch;
78
- if (!policy || this.stopping) {
79
- throw new Fault(403, "launch_disabled", "Session launching is not enabled by local policy.");
166
+ async cancel(
167
+ identity: Identity,
168
+ sessionId: string,
169
+ context: ControlContext,
170
+ ): Promise<UnknownRecord> {
171
+ const entry = await this.owned(identity, sessionId, context);
172
+ if (!entry.admitted || entry.process?.status === "exited" || !entry.process) {
173
+ throw new Fault(
174
+ 409,
175
+ "session_not_ready",
176
+ "Only an admitted live owned session can be cancelled.",
177
+ );
80
178
  }
81
- const fullName = `remote:${context.identity.name}:${name}`;
179
+ const state = await entry.process.cancel(context.signal);
180
+ return { session: view(entry), cancelled: true, state, queuesPreserved: true };
181
+ }
182
+ async start(name: string, context: StartContext): Promise<UnknownRecord> {
183
+ return this.spawn({ name }, context, true);
184
+ }
185
+ async spawn(input: SpawnInput, context: StartContext, legacy = false): Promise<UnknownRecord> {
186
+ const policy = spawnPolicy(this.policy(context), input);
187
+ const cwd = legacy
188
+ ? context.identity.cwd
189
+ : launchCwd(policy, input.cwd ?? context.identity.cwd);
190
+ const name = `remote:${context.identity.name}:${input.name}`;
82
191
  const existing = [...this.launches].find(
83
192
  (entry) =>
84
193
  entry.identity.fingerprint256 === context.identity.fingerprint256 &&
85
- entry.name === fullName &&
194
+ entry.name === name &&
86
195
  entry.process?.status !== "exited",
87
196
  );
88
- if (existing) {
89
- return this.reuse(existing);
197
+ const result = existing
198
+ ? this.reuseSpawn(existing, input, policy, cwd)
199
+ : await this.launch({ name, cwd }, context, policy);
200
+ return this.task(result, input.task, context);
201
+ }
202
+ private reuseSpawn(
203
+ entry: Readonly<Launch>,
204
+ input: SpawnInput,
205
+ policy: LaunchPolicy,
206
+ cwd: string,
207
+ ): UnknownRecord {
208
+ if (entry.cwd !== cwd) {
209
+ throw new Fault(
210
+ 409,
211
+ "cwd_conflict",
212
+ "This live name already belongs to a different workspace.",
213
+ );
214
+ }
215
+ if (
216
+ input.model !== undefined &&
217
+ (entry.journal?.model?.provider !== policy.provider ||
218
+ entry.journal?.model?.modelId !== policy.model)
219
+ ) {
220
+ throw new Fault(
221
+ 409,
222
+ "model_conflict",
223
+ "This live name already belongs to a different model selection; resume it without overriding history.",
224
+ );
225
+ }
226
+ return this.reuse(entry);
227
+ }
228
+ async resume(input: ResumeInput, context: StartContext): Promise<UnknownRecord> {
229
+ const policy = this.policy(context);
230
+ const selector = input.sessionId ?? input.target ?? `name:${input.name ?? ""}`;
231
+ if (this.resuming.has(selector)) {
232
+ throw new Fault(409, "launch_in_progress", "This resume selector is already starting.");
90
233
  }
234
+ this.resuming.add(selector);
235
+ try {
236
+ const saved = await this.catalog.resolve(policy, input, context.signal);
237
+ launchCwd(policy, saved.cwd);
238
+ const owned = this.active(saved.sessionId);
239
+ if (owned) {
240
+ const result = this.reuse(owned);
241
+ const reused =
242
+ owned.identity.fingerprint256 === context.identity.fingerprint256
243
+ ? result
244
+ : {
245
+ ...result,
246
+ session: {
247
+ ...savedView(saved),
248
+ peerId: saved.peerId,
249
+ status: owned.process?.status,
250
+ },
251
+ };
252
+ return await this.task(reused, input.task, context);
253
+ }
254
+ await wait(spawnBrokerIfNeeded(process.execPath, []), context.signal);
255
+ const peer = exactPeer(saved, await context.peers(context.signal));
256
+ if (peer) {
257
+ return await this.task(
258
+ { session: savedView(saved, peer), id: saved.sessionId, reused: true },
259
+ input.task,
260
+ context,
261
+ );
262
+ }
263
+ // A different selector/identity may have completed catalog resolution during the peer lookup.
264
+ if (this.active(saved.sessionId)) {
265
+ throw new Fault(409, "launch_in_progress", "This native session is already starting.");
266
+ }
267
+ const result = await this.launch(saved, context, policy);
268
+ return await this.task(result, input.task, context);
269
+ } finally {
270
+ this.resuming.delete(selector);
271
+ }
272
+ }
273
+ private policy(context: StartContext): LaunchPolicy {
274
+ cancelled(context.signal);
275
+ const policy = context.identity.launch;
276
+ if (!policy || this.stopping) {
277
+ throw new Fault(403, "launch_disabled", "Session launching is not enabled by local policy.");
278
+ }
279
+ return policy;
280
+ }
281
+ private async task(
282
+ result: UnknownRecord,
283
+ task: string | undefined,
284
+ context: StartContext,
285
+ ): Promise<UnknownRecord> {
286
+ const session = result.session;
287
+ if (task === undefined || !isRecord(session) || typeof session.peerId !== "string") {
288
+ return result;
289
+ }
290
+ try {
291
+ const receipt = await context.submit(session.peerId, task);
292
+ return { ...result, id: session.sessionId, task: { ...receipt, completed: false } };
293
+ } catch (error) {
294
+ const fault =
295
+ error instanceof Fault
296
+ ? error
297
+ : new Fault(
298
+ 503,
299
+ "task_unconfirmed",
300
+ "Task acceptance is unknown; inspect the session before retrying.",
301
+ );
302
+ throw new Fault(fault.status, fault.code, fault.message, {
303
+ ...fault.details,
304
+ session,
305
+ reused: result.reused,
306
+ });
307
+ }
308
+ }
309
+ private async launch(
310
+ target: {
311
+ readonly name?: string;
312
+ readonly cwd: string;
313
+ readonly sessionFile?: string;
314
+ readonly sessionId?: string;
315
+ readonly peerId?: string;
316
+ },
317
+ context: StartContext,
318
+ policy: LaunchPolicy,
319
+ ): Promise<UnknownRecord> {
91
320
  this.capacity(context.identity, policy);
92
321
  const entry: Launch = {
93
322
  identity: context.identity,
94
- name: fullName,
323
+ name: target.name,
324
+ cwd: target.cwd,
95
325
  controller: new AbortController(),
96
326
  expires: context.expires,
97
327
  admitted: false,
98
328
  };
329
+ if (
330
+ target.sessionId !== undefined &&
331
+ target.peerId !== undefined &&
332
+ target.sessionFile !== undefined
333
+ ) {
334
+ entry.journal = {
335
+ ...target,
336
+ sessionId: target.sessionId,
337
+ peerId: target.peerId,
338
+ sessionFile: target.sessionFile,
339
+ };
340
+ }
99
341
  this.launches.add(entry);
100
342
  try {
101
343
  return await this.establish(entry, policy, context);
@@ -108,11 +350,7 @@ export class Launcher {
108
350
  }
109
351
  throw error instanceof Fault
110
352
  ? error
111
- : new Fault(
112
- 503,
113
- "launch_failed",
114
- "Pi startup failed; inspect the pinned local policy and resources.",
115
- );
353
+ : new Fault(503, "launch_failed", "Pi startup failed; inspect local policy and resources.");
116
354
  }
117
355
  }
118
356
  private reuse(entry: Readonly<Launch>): UnknownRecord {
@@ -120,12 +358,12 @@ export class Launcher {
120
358
  entry.admitted &&
121
359
  (entry.process?.status === "idle" || entry.process?.status === "running")
122
360
  ) {
123
- return { session: view(entry), reused: true };
361
+ return { session: view(entry), id: entry.journal?.sessionId, reused: true };
124
362
  }
125
363
  throw new Fault(
126
364
  409,
127
365
  "launch_in_progress",
128
- "This named session is starting or stopping; inspect sessions before retrying.",
366
+ "This session is starting or stopping; inspect sessions before retrying.",
129
367
  );
130
368
  }
131
369
  private capacity(identity: Identity, policy: LaunchPolicy): void {
@@ -147,43 +385,6 @@ export class Launcher {
147
385
  }
148
386
  }
149
387
  }
150
- private async journal(entry: Launch, policy: LaunchPolicy): Promise<void> {
151
- const { SessionManager } = await wait(
152
- loadNativeSession(policy.packageRoot),
153
- entry.controller.signal,
154
- );
155
- cancelled(entry.controller.signal);
156
- const storage = statSync(policy.sessionDir);
157
- if (
158
- !storage.isDirectory() ||
159
- storage.uid !== process.getuid?.() ||
160
- (storage.mode & 0o777) !== 0o700
161
- ) {
162
- throw new Fault(503, "storage_invalid", "Private launch storage is no longer valid.");
163
- }
164
- const previous = process.umask(0o077);
165
- try {
166
- const manager = SessionManager.create(entry.identity.cwd, policy.sessionDir);
167
- manager.appendSessionInfo(entry.name);
168
- manager.appendMessage({ role: "user", content: policy.context, timestamp: Date.now() });
169
- entry.sessionId = manager.getSessionId();
170
- entry.sessionFile = manager.getSessionFile();
171
- entry.peerId = `pi-${createHash("sha256").update(entry.sessionId).digest("hex").slice(0, 32)}`;
172
- if (entry.sessionFile === undefined || (statSync(entry.sessionFile).mode & 0o777) !== 0o600) {
173
- throw new Fault(503, "journal_invalid", "Native journal was not privately persisted.");
174
- }
175
- const saved = SessionManager.open(entry.sessionFile, policy.sessionDir);
176
- if (
177
- saved.getSessionId() !== entry.sessionId ||
178
- saved.getSessionName() !== entry.name ||
179
- saved.getCwd() !== entry.identity.cwd
180
- ) {
181
- throw new Fault(503, "journal_invalid", "Native journal identity did not persist.");
182
- }
183
- } finally {
184
- process.umask(previous);
185
- }
186
- }
187
388
  private async establish(
188
389
  entry: Launch,
189
390
  policy: LaunchPolicy,
@@ -203,15 +404,20 @@ export class Launcher {
203
404
  );
204
405
  signal.addEventListener("abort", abort, { once: true });
205
406
  try {
206
- await this.journal(entry, policy);
407
+ const resuming = entry.journal !== undefined;
408
+ entry.journal = entry.journal
409
+ ? await resumeLaunchJournal(policy, entry.journal, signal)
410
+ : await createLaunchJournal(policy, { cwd: entry.cwd, name: entry.name ?? "" }, signal);
207
411
  cancelled(signal);
208
- if (entry.sessionFile === undefined) {
209
- throw new Fault(503, "journal_invalid", "Native journal path unavailable.");
210
- }
211
- entry.process = new RpcProcess(policy, entry.identity.cwd, entry.sessionFile);
412
+ entry.process = new RpcProcess(policy, entry.cwd, entry.journal.sessionFile, resuming);
212
413
  this.expiry(entry);
213
- const state = await entry.process.state(signal);
214
- this.validateState(entry, policy, state);
414
+ validateLaunchState(entry.journal, await entry.process.state(signal));
415
+ await wait(
416
+ spawnBrokerIfNeeded(process.execPath, []).catch(() => {
417
+ throw new Fault(503, "broker_unavailable", "Local broker could not be acquired safely.");
418
+ }),
419
+ signal,
420
+ );
215
421
  await this.peerReady(entry, context.peers, signal);
216
422
  cancelled(signal);
217
423
  entry.process.ready();
@@ -219,34 +425,16 @@ export class Launcher {
219
425
  log("launch_ready", {
220
426
  fingerprint: entry.identity.fingerprint256,
221
427
  name: entry.name,
222
- peer: entry.peerId,
428
+ peer: entry.journal.peerId,
223
429
  pid: entry.process.pid,
224
430
  result: "ready",
225
431
  });
226
- return { session: view(entry), reused: false };
432
+ return { session: view(entry), id: entry.journal.sessionId, reused: false };
227
433
  } finally {
228
434
  clearTimeout(deadline);
229
435
  signal.removeEventListener("abort", abort);
230
436
  }
231
437
  }
232
- private validateState(entry: Readonly<Launch>, policy: LaunchPolicy, state: UnknownRecord): void {
233
- if (
234
- state.sessionId !== entry.sessionId ||
235
- state.sessionFile !== entry.sessionFile ||
236
- state.sessionName !== entry.name ||
237
- state.isStreaming !== false ||
238
- state.pendingMessageCount !== 0 ||
239
- !isRecord(state.model) ||
240
- state.model.provider !== policy.provider ||
241
- state.model.id !== policy.model
242
- ) {
243
- throw new Fault(
244
- 503,
245
- "readiness_failed",
246
- "Pi did not start idle with the exact saved session and configured model.",
247
- );
248
- }
249
- }
250
438
  private async peerReady(
251
439
  entry: Readonly<Launch>,
252
440
  peers: StartContext["peers"],
@@ -254,18 +442,19 @@ export class Launcher {
254
442
  ): Promise<void> {
255
443
  while (!entry.process?.failure) {
256
444
  cancelled(signal);
257
- // Registration is asynchronous after native session_start; observe real broker publication.
445
+ // Native startup alone is insufficient: observe the exact Intercom publication.
258
446
  // oxlint-disable-next-line no-await-in-loop
259
447
  const rows = await peers(signal);
260
448
  if (
261
449
  rows.some(
262
450
  (peer) =>
263
- peer.id === entry.peerId && peer.name === entry.name && peer.cwd === entry.identity.cwd,
451
+ peer.id === entry.journal?.peerId &&
452
+ peer.name === resolveIntercomPresenceName(entry.name, entry.journal.sessionId) &&
453
+ peer.cwd === entry.cwd,
264
454
  )
265
455
  ) {
266
456
  return;
267
457
  }
268
- // Bounded readiness polling is not an acknowledgement substitute; only the row above succeeds.
269
458
  // oxlint-disable-next-line no-await-in-loop
270
459
  await delay(100, undefined, { signal });
271
460
  }
@@ -1,6 +1,5 @@
1
- import { closeSync, constants, fstatSync, lstatSync, openSync, realpathSync } from "node:fs";
2
- import { isAbsolute, join } from "node:path";
3
- import { DatabaseSync } from "node:sqlite";
1
+ import type { DatabaseSync } from "node:sqlite";
2
+ import { privateDatabase } from "./bridge-storage.ts";
4
3
  import { isRecord, type UnknownRecord } from "../shared/unknown.ts";
5
4
  import { normalizeMessage, normalizeSessionInfo } from "./types.ts";
6
5
  import type { Envelope } from "./bridge-ask.ts";
@@ -9,7 +8,6 @@ import {
9
8
  INBOX_BYTES,
10
9
  INBOX_COUNT,
11
10
  fingerprint as certificateFingerprint,
12
- label,
13
11
  } from "./bridge-protocol.ts";
14
12
 
15
13
  interface Entry extends Envelope {
@@ -32,64 +30,10 @@ export interface NotificationPage extends InboxPage {
32
30
  readonly messages: readonly PendingNotification[];
33
31
  }
34
32
 
35
- export function stateDirectory(value: unknown): string | undefined {
36
- if (value === undefined) {
37
- return;
38
- }
39
- const path = label(value, "stateDir", 4096);
40
- const stat = lstatSync(path);
41
- if (
42
- !isAbsolute(path) ||
43
- !stat.isDirectory() ||
44
- stat.uid !== process.getuid?.() ||
45
- (stat.mode & 0o777) !== 0o700
46
- ) {
47
- throw new Error("stateDir must be an absolute owned directory with mode 0700, not a symlink.");
48
- }
49
- return realpathSync(path);
50
- }
51
-
52
- function privateFile(path: string, create = false): void {
53
- const flags = create ? constants.O_RDWR | constants.O_CREAT : constants.O_RDONLY;
54
- const fd = openSync(path, flags | constants.O_NOFOLLOW, 0o600);
55
- try {
56
- const stat = fstatSync(fd);
57
- if (
58
- !stat.isFile() ||
59
- stat.uid !== process.getuid?.() ||
60
- (stat.mode & 0o777) !== 0o600 ||
61
- stat.nlink !== 1
62
- ) {
63
- throw new Error("Mailbox files must be owned regular files with mode 0600 and one link.");
64
- }
65
- } finally {
66
- closeSync(fd);
67
- }
68
- }
69
-
70
33
  function database(stateDir: string, fingerprint: string): DatabaseSync {
71
- stateDirectory(stateDir);
72
- const path = join(stateDir, `${certificateFingerprint(fingerprint)}.sqlite`);
73
- privateFile(path, true);
74
- for (const suffix of ["-journal", "-wal", "-shm"]) {
75
- try {
76
- lstatSync(`${path}${suffix}`);
77
- } catch (error) {
78
- if (isRecord(error) && error.code === "ENOENT") {
79
- continue;
80
- }
81
- throw error;
82
- }
83
- privateFile(`${path}${suffix}`);
84
- }
85
- const db = new DatabaseSync(path, { enableDoubleQuotedStringLiterals: false });
34
+ const { db } = privateDatabase(stateDir, `${certificateFingerprint(fingerprint)}.sqlite`);
86
35
  try {
87
36
  db.exec(`
88
- PRAGMA journal_mode=DELETE;
89
- PRAGMA synchronous=FULL;
90
- PRAGMA secure_delete=ON;
91
- PRAGMA cache_size=-1024;
92
- PRAGMA busy_timeout=1000;
93
37
  CREATE TABLE IF NOT EXISTS mailbox (
94
38
  sequence INTEGER PRIMARY KEY AUTOINCREMENT,
95
39
  id TEXT NOT NULL UNIQUE,