@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
@@ -3,7 +3,7 @@ import { StringDecoder } from "node:string_decoder";
3
3
  import type { RpcCommand, RpcExtensionUIResponse } from "@earendil-works/pi-coding-agent";
4
4
  import { trySignalChildTree } from "../shared/post-exit-stdio-guard.ts";
5
5
  import { isRecord, type UnknownRecord } from "../shared/unknown.ts";
6
- import { Fault, log, wait } from "./bridge-protocol.ts";
6
+ import { Fault, cancelled, log, wait } from "./bridge-protocol.ts";
7
7
  import type { LaunchPolicy } from "./bridge-launch-policy.ts";
8
8
 
9
9
  const FRAME_LIMIT = 4 * 1024 * 1024;
@@ -15,7 +15,7 @@ function launchEnvironment(packageRoot: string): NodeJS.ProcessEnv {
15
15
  }
16
16
 
17
17
  // Native RpcClient lacks public EOF/exit/UI control and retains/reprints unbounded stderr.
18
- // This owner implements only get_state and dialog cancellation, never a remote RPC proxy.
18
+ // This owner implements state, owned-run cancellation and dialog dismissal, never a raw RPC proxy.
19
19
  export class RpcProcess {
20
20
  private readonly child: ChildProcessWithoutNullStreams;
21
21
  readonly exited: Promise<void>;
@@ -27,15 +27,17 @@ export class RpcProcess {
27
27
  private sequence = 0;
28
28
  private pending?: {
29
29
  readonly id: string;
30
+ readonly command: "get_state" | "abort";
30
31
  readonly resolve: (data: UnknownRecord) => void;
31
32
  readonly reject: (error: Readonly<Error>) => void;
32
33
  };
33
34
  private stopping?: Promise<void>;
34
35
  private stderrBytes = 0;
35
36
  private exitObserved = false;
37
+ private cancelling = false;
36
38
 
37
- constructor(policy: LaunchPolicy, cwd: string, sessionFile: string) {
38
- this.child = spawn(process.execPath, this.arguments(policy, sessionFile), {
39
+ constructor(policy: LaunchPolicy, cwd: string, sessionFile: string, resuming = false) {
40
+ this.child = spawn(process.execPath, this.arguments(policy, sessionFile, resuming), {
39
41
  cwd,
40
42
  detached: true,
41
43
  env: launchEnvironment(policy.packageRoot),
@@ -78,19 +80,19 @@ export class RpcProcess {
78
80
  get pid(): number | undefined {
79
81
  return this.child.pid;
80
82
  }
81
- private arguments(policy: LaunchPolicy, sessionFile: string): string[] {
83
+ private arguments(policy: LaunchPolicy, sessionFile: string, resuming: boolean): string[] {
82
84
  const args = [
83
85
  policy.cli,
84
86
  "--mode",
85
87
  "rpc",
86
88
  "--session",
87
89
  sessionFile,
88
- "--provider",
89
- policy.provider,
90
- "--model",
91
- policy.model,
92
90
  policy.trustProject ? "--approve" : "--no-approve",
93
91
  ];
92
+ // Resume leaves native model/thinking restoration authoritative; never force the launch default.
93
+ if (!resuming && policy.provider !== undefined && policy.model !== undefined) {
94
+ args.push("--provider", policy.provider, "--model", policy.model);
95
+ }
94
96
  if (!policy.discoverResources) {
95
97
  args.push(
96
98
  "--no-extensions",
@@ -191,8 +193,12 @@ export class RpcProcess {
191
193
  private response(data: UnknownRecord): void {
192
194
  const pending = this.pending;
193
195
  this.pending = undefined;
194
- if (data.success === true && data.command === "get_state" && isRecord(data.data)) {
195
- pending?.resolve(data.data);
196
+ if (
197
+ data.success === true &&
198
+ data.command === pending?.command &&
199
+ (pending?.command !== "get_state" || isRecord(data.data))
200
+ ) {
201
+ pending?.resolve(isRecord(data.data) ? data.data : {});
196
202
  } else {
197
203
  pending?.reject(new Fault(503, "rpc_failed", "Pi RPC state request failed."));
198
204
  }
@@ -218,7 +224,7 @@ export class RpcProcess {
218
224
  }
219
225
  private write(
220
226
  command: Readonly<
221
- | Extract<RpcCommand, { type: "get_state" }>
227
+ | Extract<RpcCommand, { type: "get_state" | "abort" }>
222
228
  | Extract<RpcExtensionUIResponse, { cancelled: true }>
223
229
  >,
224
230
  ): void {
@@ -228,14 +234,25 @@ export class RpcProcess {
228
234
  this.cleanup();
229
235
  }
230
236
  }
231
- async state(signal: AbortSignal): Promise<UnknownRecord> {
237
+ private async command(
238
+ command: "get_state" | "abort",
239
+ signal: AbortSignal,
240
+ ): Promise<UnknownRecord> {
241
+ cancelled(signal);
232
242
  if (this.failure) {
233
243
  throw this.failure;
234
244
  }
235
- const id = `state-${++this.sequence}`;
245
+ if (this.pending) {
246
+ throw new Fault(
247
+ 409,
248
+ "control_in_progress",
249
+ "A native control operation is already in flight.",
250
+ );
251
+ }
252
+ const id = `control-${++this.sequence}`;
236
253
  const result = new Promise<UnknownRecord>((resolve, reject) => {
237
- this.pending = { id, resolve, reject };
238
- this.write({ type: "get_state", id });
254
+ this.pending = { id, command, resolve, reject };
255
+ this.write({ type: command, id });
239
256
  });
240
257
  try {
241
258
  return await wait(result, signal);
@@ -243,6 +260,26 @@ export class RpcProcess {
243
260
  this.pending = undefined;
244
261
  }
245
262
  }
263
+ async state(signal: AbortSignal): Promise<UnknownRecord> {
264
+ return this.command("get_state", signal);
265
+ }
266
+ async cancel(signal: AbortSignal): Promise<UnknownRecord> {
267
+ if (this.cancelling) {
268
+ throw new Fault(409, "control_in_progress", "Native cancellation is already in flight.");
269
+ }
270
+ this.cancelling = true;
271
+ try {
272
+ await this.command("abort", signal);
273
+ const state = await this.state(signal);
274
+ if (state.isStreaming !== false) {
275
+ throw new Fault(409, "cancel_unconfirmed", "Native Pi has not reached idle after abort.");
276
+ }
277
+ this.status = "idle";
278
+ return { isStreaming: false, pendingMessageCount: state.pendingMessageCount };
279
+ } finally {
280
+ this.cancelling = false;
281
+ }
282
+ }
246
283
  ready(): void {
247
284
  if (this.failure || this.exitObserved) {
248
285
  throw this.failure ?? new Fault(503, "session_exited", "Pi exited during startup.");
@@ -1,16 +1,17 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { setTimeout as delay } from "node:timers/promises";
2
3
  import { IntercomClient } from "./broker/client.ts";
3
- import {
4
- formatSessionTarget,
5
- resolveSessionProjectId,
6
- resolveSessionTarget,
7
- } from "./session-targets.ts";
4
+ import { resolveSessionProjectId } from "./session-targets.ts";
8
5
  import type { Message, SendResult, SessionInfo } from "./types.ts";
9
6
  import type { Identity } from "./bridge-config.ts";
10
7
  import { Fault, cancelled, log, wait } from "./bridge-protocol.ts";
11
8
  import { Mailbox } from "./bridge-mailbox.ts";
12
9
  import type { UnknownRecord } from "../shared/unknown.ts";
13
10
  import type { MessagingOperation as Operation } from "./bridge-routes.ts";
11
+ import type { RequestContext } from "./bridge-http.ts";
12
+ import { resolveBridgeTarget, bridgeSessionList } from "./bridge-target.ts";
13
+ import { ControlReplies } from "./bridge-control.ts";
14
+ import type { NativeControlOperation } from "./native-control-protocol.ts";
14
15
 
15
16
  import { WaitingAsk } from "./bridge-ask.ts";
16
17
  const MAX_REQUESTS = 8;
@@ -18,19 +19,13 @@ const IDLE_TIMEOUT = 5 * 60 * 1000;
18
19
  const SEND_TIMEOUT = 8000;
19
20
  const LIST_TIMEOUT = 5000;
20
21
 
21
- interface RequestContext {
22
- readonly signal: AbortSignal;
23
- readonly requestId: string;
24
- readonly onPeer: (peer: string) => void;
25
- }
26
- interface SendOptions {
22
+ interface SendOptions extends Pick<Message, "replyTo" | "expectsReply" | "delivery"> {
27
23
  readonly messageId?: string;
28
- readonly replyTo?: string;
29
- readonly expectsReply?: boolean;
30
24
  }
31
25
  export interface SessionOwner {
32
26
  readonly stateDir?: string;
33
27
  readonly notifications?: boolean;
28
+ readonly mailboxChanged?: () => void;
34
29
  readonly allowed: (identity: Identity) => boolean;
35
30
  readonly lost: (fp: string, count?: number) => void;
36
31
  readonly retired: (fp: string, generation: string) => void;
@@ -48,8 +43,10 @@ export class Session {
48
43
  });
49
44
  private readonly requests = new Set<AbortController>();
50
45
  private readonly inbox: Mailbox;
46
+ private inboxChanged = new AbortController();
51
47
  private readonly replying = new Set<string>();
52
48
  private pending?: WaitingAsk;
49
+ private readonly controls = new ControlReplies();
53
50
  private closed = false;
54
51
  private connecting?: Promise<void>;
55
52
  private disconnecting?: Promise<void>;
@@ -80,12 +77,13 @@ export class Session {
80
77
  }
81
78
 
82
79
  guard(signal?: AbortSignal): void {
83
- if (signal) {
84
- cancelled(signal);
85
- }
80
+ signal?.throwIfAborted();
86
81
  if (this.closed) {
87
82
  throw this.closeReason;
88
83
  }
84
+ this.authorize();
85
+ }
86
+ authorize(): void {
89
87
  if (!this.owner.allowed(this.identity)) {
90
88
  throw new Fault(403, "revoked", "Identity is no longer authorized.");
91
89
  }
@@ -197,21 +195,19 @@ export class Session {
197
195
  this.owner.retired(this.identity.fingerprint256, this.generation);
198
196
  log("session_closed", { ...this.fields(), result: this.closeReason.code });
199
197
  }
200
- close(error: Readonly<Fault>): void {
198
+ close(fault: Readonly<Fault>): void {
201
199
  if (this.closed) {
202
200
  return;
203
201
  }
204
202
  this.closed = true;
205
- this.closeReason = error;
203
+ this.closeReason = fault;
206
204
  clearTimeout(this.lease);
207
- this.pending?.reject(error);
205
+ this.pending?.reject(fault);
206
+ this.controls.close(fault);
208
207
  for (const controller of this.requests) {
209
- controller.abort(error);
208
+ controller.abort(fault);
210
209
  }
211
210
  // Retain the generation until acquisition/registration AND final disconnect settle.
212
- this.startRetirement();
213
- }
214
- private startRetirement(): void {
215
211
  this.retirement = this.retire().catch((caught: unknown) => this.cleanupFailed(caught));
216
212
  }
217
213
  async settled(): Promise<void> {
@@ -219,6 +215,7 @@ export class Session {
219
215
  }
220
216
 
221
217
  private peerLeft(id: string): void {
218
+ this.controls.peerLeft(id);
222
219
  if (this.pending?.peer === id && !this.pending.settled) {
223
220
  this.pending.reject(
224
221
  new Fault(409, "peer_offline", "Ask recipient disconnected.", { id: this.pending.id }),
@@ -240,6 +237,9 @@ export class Session {
240
237
  );
241
238
  return;
242
239
  }
240
+ if (this.controls.receive(from.id, message)) {
241
+ return;
242
+ }
243
243
  if (!this.inbox.persistent && this.resolveReply(from, message)) {
244
244
  return;
245
245
  }
@@ -266,12 +266,22 @@ export class Session {
266
266
  );
267
267
  return;
268
268
  }
269
- if (result === "lost" || (result === "duplicate" && !this.inbox.persistent)) {
270
- this.owner.lost(this.identity.fingerprint256);
269
+ if (result !== "duplicate" || !this.inbox.persistent) {
270
+ if (result !== "stored") {
271
+ this.owner.lost(this.identity.fingerprint256);
272
+ }
273
+ this.inboxChanged.abort();
274
+ this.inboxChanged = new AbortController();
271
275
  }
272
- this.recordReceive(from.id, message.id, result);
276
+ log(result === "lost" ? "inbox_overflow" : "received", {
277
+ ...this.fields(),
278
+ peer: from.id,
279
+ messageId: message.id,
280
+ result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
281
+ });
273
282
  if (result !== "lost") {
274
283
  this.resolveReply(from, message);
284
+ this.owner.mailboxChanged?.();
275
285
  }
276
286
  this.presence();
277
287
  }
@@ -283,18 +293,6 @@ export class Session {
283
293
  pending.resolve({ from, message });
284
294
  return true;
285
295
  }
286
- private recordReceive(
287
- peer: string,
288
- messageId: string,
289
- result: "stored" | "duplicate" | "lost",
290
- ): void {
291
- log(result === "lost" ? "inbox_overflow" : "received", {
292
- ...this.fields(),
293
- peer,
294
- messageId,
295
- result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
296
- });
297
- }
298
296
  private presence(): void {
299
297
  if (this.closed) {
300
298
  return;
@@ -320,29 +318,29 @@ export class Session {
320
318
  await this.connect(signal);
321
319
  return this.peers(signal);
322
320
  }
321
+ async nativeControl(
322
+ operation: NativeControlOperation,
323
+ certificate: string,
324
+ context: RequestContext,
325
+ ): Promise<UnknownRecord> {
326
+ await this.connect(context.signal);
327
+ const peer = await this.target(operation.to, context.signal);
328
+ return this.controls.request(
329
+ this.client,
330
+ { ...operation, peer: peer.id, certificate },
331
+ { ...context, guard: () => this.guard(context.signal) },
332
+ );
333
+ }
323
334
  private async target(to: string, signal: AbortSignal): Promise<SessionInfo> {
324
- const resolution = resolveSessionTarget(await this.peers(signal), to);
325
- if (resolution.status === "none") {
326
- throw new Fault(404, "peer_offline", "Peer is not connected.");
327
- }
328
- if (resolution.status !== "found" || !resolution.target) {
329
- throw new Fault(
330
- 409,
331
- "ambiguous_target",
332
- "Use an unambiguous peer name or session ID (prefixes need at least 8 characters).",
333
- );
334
- }
335
- if (resolution.target.id === this.client.sessionId) {
336
- throw new Fault(400, "self_target", "Choose another peer.");
337
- }
338
- return resolution.target;
335
+ return resolveBridgeTarget(await this.peers(signal), to, this.client.sessionId);
339
336
  }
340
- private async send(
337
+ async send(
341
338
  peer: string,
342
339
  message: string,
343
340
  context: RequestContext,
344
341
  options: SendOptions = {},
345
342
  ): Promise<SendResult> {
343
+ await this.connect(context.signal);
346
344
  const { signal, requestId } = context;
347
345
  context.onPeer(peer);
348
346
  this.guard(signal);
@@ -392,22 +390,6 @@ export class Session {
392
390
  const waiting = new WaitingAsk();
393
391
  this.pending = waiting;
394
392
  const { signal } = context;
395
- const abort = () => {
396
- try {
397
- cancelled(signal);
398
- } catch (error) {
399
- waiting.reject(
400
- error instanceof Error
401
- ? error
402
- : new Fault(
403
- 499,
404
- "client_aborted",
405
- "HTTP client disconnected; delivery may be unknown.",
406
- ),
407
- );
408
- }
409
- };
410
- signal.addEventListener("abort", abort, { once: true });
411
393
  try {
412
394
  await this.connect(signal);
413
395
  const peer = await this.target(operation.to, signal);
@@ -423,16 +405,39 @@ export class Session {
423
405
  return { ...waiting.receipt, replied: true, reply: answer };
424
406
  } finally {
425
407
  waiting.clear();
426
- signal.removeEventListener("abort", abort);
427
- if (this.pending === waiting) {
428
- this.pending = undefined;
429
- }
408
+ this.pending = undefined;
430
409
  }
431
410
  }
432
- private ack(ids: readonly string[]): UnknownRecord {
433
- const result = this.inbox.ack(ids);
434
- this.presence();
435
- return result;
411
+ private async readInbox(
412
+ operation: Extract<Operation, { readonly route: "GET /v1/inbox" }>,
413
+ context: RequestContext,
414
+ ): Promise<UnknownRecord> {
415
+ const read = (): ReturnType<Mailbox["page"]> & UnknownRecord => ({
416
+ ...this.inbox.page(operation.after, operation.limit),
417
+ ...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
418
+ });
419
+ const deadline = performance.now() + operation.waitMs;
420
+ const losses = read().lostMessages;
421
+ for (;;) {
422
+ // Subscribe to the post-commit signal before rereading the authoritative cursor page.
423
+ const signal = AbortSignal.any([this.inboxChanged.signal, context.signal]);
424
+ const page = read();
425
+ const remaining = deadline - performance.now();
426
+ if (page.messages.length > 0 || page.lostMessages !== losses || remaining <= 0) {
427
+ return page;
428
+ }
429
+ log("inbox_wait", { ...this.fields(), requestId: context.requestId, after: operation.after });
430
+ try {
431
+ // A nonmatching commit must rearm the same cursor, not start parallel readers.
432
+ // oxlint-disable-next-line no-await-in-loop
433
+ await delay(remaining, undefined, { signal });
434
+ } catch (error) {
435
+ if (!signal.aborted) {
436
+ throw error;
437
+ }
438
+ }
439
+ this.guard(context.signal);
440
+ }
436
441
  }
437
442
  private async answer(
438
443
  replyTo: string,
@@ -460,6 +465,7 @@ export class Session {
460
465
  }
461
466
  const data = { ...(await this.send(peer, message, context, { replyTo })) };
462
467
  this.inbox.remove(replyTo);
468
+ this.owner.mailboxChanged?.();
463
469
  this.presence();
464
470
  return data;
465
471
  } finally {
@@ -477,34 +483,30 @@ export class Session {
477
483
  await this.connect(context.signal);
478
484
  }
479
485
  if (operation.route === "POST /v1/ack") {
480
- return this.ack(operation.ids);
486
+ const result = this.inbox.ack(operation.ids);
487
+ this.owner.mailboxChanged?.();
488
+ this.presence();
489
+ return result;
481
490
  }
482
- return {
483
- ...this.inbox.page(operation.after, operation.limit),
484
- ...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
485
- };
491
+ return this.readInbox(operation, context);
486
492
  }
487
493
  await this.connect(context.signal);
488
494
  switch (operation.route) {
489
495
  case "POST /v1/register":
490
496
  return { sessionId: this.client.sessionId, name: this.identity.name };
491
- case "GET /v1/list": {
492
- const sessions = await this.peers(context.signal);
497
+ case "GET /v1/list":
493
498
  return {
494
499
  sessionId: this.client.sessionId,
495
- sessions: sessions.map(({ topics: _topics, subscriptions: _subscriptions, ...entry }) => {
496
- return Object.assign(entry, { target: formatSessionTarget(entry, sessions) });
497
- }),
500
+ sessions: bridgeSessionList(await this.peers(context.signal)),
498
501
  };
502
+ case "POST /v1/send": {
503
+ const peer = await this.target(operation.to, context.signal);
504
+ const receipt = await this.send(peer.id, operation.message, context, {
505
+ messageId: operation.messageId,
506
+ delivery: operation.passive === true ? "passive" : "steer",
507
+ });
508
+ return { ...receipt };
499
509
  }
500
- case "POST /v1/send":
501
- return {
502
- ...(await this.send(
503
- (await this.target(operation.to, context.signal)).id,
504
- operation.message,
505
- context,
506
- )),
507
- };
508
510
  case "POST /v1/reply":
509
511
  return this.answer(operation.replyTo, operation.message, context);
510
512
  }
@@ -0,0 +1,83 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, realpathSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { isRecord } from "../shared/unknown.ts";
5
+ import { label } from "./bridge-protocol.ts";
6
+
7
+ export function stateDirectory(value: unknown): string | undefined {
8
+ if (value === undefined) {
9
+ return;
10
+ }
11
+ const path = label(value, "stateDir", 4096);
12
+ const stat = lstatSync(path);
13
+ if (
14
+ !isAbsolute(path) ||
15
+ !stat.isDirectory() ||
16
+ stat.uid !== process.getuid?.() ||
17
+ (stat.mode & 0o777) !== 0o700
18
+ ) {
19
+ throw new Error("stateDir must be an absolute owned directory with mode 0700, not a symlink.");
20
+ }
21
+ return realpathSync(path);
22
+ }
23
+
24
+ export function privateFile(path: string): void {
25
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
26
+ try {
27
+ const stat = fstatSync(fd);
28
+ if (
29
+ !stat.isFile() ||
30
+ stat.uid !== process.getuid?.() ||
31
+ (stat.mode & 0o777) !== 0o600 ||
32
+ stat.nlink !== 1
33
+ ) {
34
+ throw new Error("Storage files must be owned regular files with mode 0600 and one link.");
35
+ }
36
+ } finally {
37
+ closeSync(fd);
38
+ }
39
+ }
40
+
41
+ /** Native SQLite FULL commits own durability; never replace an unreadable existing database. */
42
+ export function privateDatabase(
43
+ stateDir: string,
44
+ name: string,
45
+ ): { readonly db: DatabaseSync; readonly created: boolean } {
46
+ stateDirectory(stateDir);
47
+ const path = join(stateDir, name);
48
+ let created = false;
49
+ try {
50
+ const fd = openSync(
51
+ path,
52
+ constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
53
+ 0o600,
54
+ );
55
+ closeSync(fd);
56
+ created = true;
57
+ } catch (error) {
58
+ if (!isRecord(error) || error.code !== "EEXIST") {
59
+ throw error;
60
+ }
61
+ }
62
+ privateFile(path);
63
+ for (const suffix of ["-journal", "-wal", "-shm"]) {
64
+ try {
65
+ lstatSync(`${path}${suffix}`);
66
+ } catch (error) {
67
+ if (isRecord(error) && error.code === "ENOENT") {
68
+ continue;
69
+ }
70
+ throw error;
71
+ }
72
+ privateFile(`${path}${suffix}`);
73
+ }
74
+ const db = new DatabaseSync(path, { enableDoubleQuotedStringLiterals: false });
75
+ try {
76
+ db.exec(`PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA secure_delete=ON;
77
+ PRAGMA cache_size=-1024; PRAGMA busy_timeout=1000;`);
78
+ return { db, created };
79
+ } catch (error) {
80
+ db.close();
81
+ throw error;
82
+ }
83
+ }
@@ -0,0 +1,35 @@
1
+ import { resolveSessionTarget, formatSessionTarget, nativePeerId } from "./session-targets.ts";
2
+ import { Fault } from "./bridge-protocol.ts";
3
+ import type { SessionInfo } from "./types.ts";
4
+
5
+ export function bridgeSessionList(
6
+ peers: readonly SessionInfo[],
7
+ ): readonly (SessionInfo & { readonly target: string })[] {
8
+ return peers.map(({ topics: _topics, subscriptions: _subscriptions, ...entry }) => ({
9
+ ...entry,
10
+ target: formatSessionTarget(entry, peers),
11
+ }));
12
+ }
13
+
14
+ export function resolveBridgeTarget(
15
+ peers: readonly SessionInfo[],
16
+ to: string,
17
+ self: string | null,
18
+ ): SessionInfo {
19
+ const exactNative = peers.find((peer) => peer.id === nativePeerId(to));
20
+ const resolution = resolveSessionTarget(peers, exactNative?.id ?? to);
21
+ if (resolution.status === "none") {
22
+ throw new Fault(404, "peer_offline", "Peer is not connected.");
23
+ }
24
+ if (resolution.status !== "found" || !resolution.target) {
25
+ throw new Fault(
26
+ 409,
27
+ "ambiguous_target",
28
+ "Use an unambiguous peer name or session ID (prefixes need at least 8 characters).",
29
+ );
30
+ }
31
+ if (resolution.target.id === self) {
32
+ throw new Fault(400, "self_target", "Choose another peer.");
33
+ }
34
+ return resolution.target;
35
+ }
@@ -0,0 +1,58 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ import { privateDatabase, privateFile } from "./bridge-storage.ts";
6
+ import { isRecord } from "../shared/unknown.ts";
7
+
8
+ export const WAKE_OWNER_FILE = /^wake-owner-[0-9a-f-]{36}\.lock$/;
9
+
10
+ /** SQLite's OS lock survives pauses and is released on exit, without PID reuse or stale leases. */
11
+ export class WakeOwner {
12
+ readonly name = `wake-owner-${randomUUID()}.lock`;
13
+ private readonly db: DatabaseSync;
14
+ private readonly path: string;
15
+
16
+ constructor(stateDir: string) {
17
+ this.path = join(stateDir, this.name);
18
+ this.db = privateDatabase(stateDir, this.name).db;
19
+ try {
20
+ this.db.exec("BEGIN EXCLUSIVE");
21
+ } catch (error) {
22
+ this.db.close();
23
+ throw error;
24
+ }
25
+ }
26
+
27
+ close(): void {
28
+ this.db.close();
29
+ rmSync(this.path, { force: true });
30
+ }
31
+ }
32
+
33
+ export function stoppedWakeOwner(stateDir: string, name: string): boolean {
34
+ if (!WAKE_OWNER_FILE.test(name)) {
35
+ throw new Error("Invalid wake owner.");
36
+ }
37
+ const path = join(stateDir, name);
38
+ try {
39
+ privateFile(path);
40
+ } catch (error) {
41
+ if (isRecord(error) && error.code === "ENOENT") {
42
+ return true;
43
+ }
44
+ throw error;
45
+ }
46
+ const db = new DatabaseSync(path);
47
+ try {
48
+ db.exec("PRAGMA busy_timeout=0; BEGIN EXCLUSIVE");
49
+ return true;
50
+ } catch (error) {
51
+ if (isRecord(error) && error.errcode === 5) {
52
+ return false;
53
+ }
54
+ throw error;
55
+ } finally {
56
+ db.close();
57
+ }
58
+ }