@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,17 +1,30 @@
1
1
  import { isUnknownArray } from "../shared/unknown.js";
2
2
  import { ASK_TIMEOUT, Fault, INBOX_COUNT, label, object, text } from "./bridge-protocol.js";
3
3
  import { body } from "./bridge-http.js";
4
+ import { groupDefinition } from "./bridge-groups.js";
5
+ import { isControlVerb } from "./native-control-protocol.js";
6
+ import { transcriptOptions } from "./transcript.js";
4
7
  const fields = {
5
8
  "GET /v1/list": [],
6
9
  "GET /v1/inbox": [],
7
10
  "POST /v1/register": [],
8
11
  "POST /v1/ack": ["ids"],
9
- "POST /v1/send": ["to", "message"],
12
+ "POST /v1/send": ["to", "message", "passive", "messageId"],
10
13
  "POST /v1/ask": ["to", "message", "timeoutMs"],
11
14
  "POST /v1/reply": ["replyTo", "message"],
12
15
  "POST /v1/start": ["name"],
16
+ "POST /v1/spawn": ["name", "cwd", "task", "provider", "model"],
17
+ "POST /v1/resume": ["sessionId", "name", "target", "task"],
13
18
  "GET /v1/sessions": [],
14
- "POST /v1/stop": ["sessionId"],
19
+ "POST /v1/stop": ["sessionId", "target", "to", "mode", "timeoutMs"],
20
+ "POST /v1/cancel": ["sessionId", "target", "to", "timeoutMs"],
21
+ "GET /v1/groups": [],
22
+ "POST /v1/groups": ["name", "members", "namePattern", "cwdPattern", "match"],
23
+ "POST /v1/broadcast": ["message", "group"],
24
+ "POST /v1/control": ["to", "verb", "options", "timeoutMs"],
25
+ "POST /v1/capabilities": ["to", "timeoutMs"],
26
+ "POST /v1/transcript": ["to", "limit", "maxBytes", "cursor", "timeoutMs"],
27
+ "GET /v1/transcript": ["target", "limit", "maxBytes", "cursor", "timeoutMs"],
15
28
  };
16
29
  function isRoute(route) {
17
30
  return Object.hasOwn(fields, route);
@@ -25,8 +38,9 @@ export function routeFor(req) {
25
38
  if (!isRoute(route)) {
26
39
  throw new Fault(404, "not_found", "Unknown method or path.");
27
40
  }
28
- if (req.url?.includes("?") === true && route !== "GET /v1/inbox") {
29
- throw new Fault(400, "invalid_input", "Only inbox accepts query parameters.");
41
+ if (req.url?.includes("?") === true &&
42
+ !["GET /v1/inbox", "GET /v1/sessions", "GET /v1/transcript"].includes(route)) {
43
+ throw new Fault(400, "invalid_input", "Query parameters are only supported for inbox, sessions and transcript.");
30
44
  }
31
45
  checkGetBody(req);
32
46
  return route;
@@ -38,51 +52,145 @@ function checkGetBody(req) {
38
52
  throw new Fault(400, "invalid_input", "GET requests must not have a body.");
39
53
  }
40
54
  }
41
- function inboxQuery(req) {
42
- const url = req.url ?? "";
43
- const separator = url.indexOf("?");
44
- const query = new URLSearchParams(separator === -1 ? "" : url.slice(separator + 1));
45
- if ([...query.keys()].some((key) => !["after", "limit"].includes(key) || query.getAll(key).length !== 1)) {
46
- throw new Fault(400, "invalid_input", "Unknown or repeated inbox query parameter.");
55
+ function queryFor(req, allowed) {
56
+ const query = new URL(req.url ?? "", "https://127.0.0.1").searchParams;
57
+ if ([...query.keys()].some((key) => !allowed.includes(key) || query.getAll(key).length !== 1)) {
58
+ throw new Fault(400, "invalid_input", "Unknown or repeated query parameter.");
47
59
  }
60
+ return query;
61
+ }
62
+ function inboxQuery(req) {
63
+ const query = queryFor(req, ["after", "limit", "waitMs"]);
48
64
  const after = query.get("after") ?? "0";
49
65
  const limit = query.get("limit") ?? String(INBOX_COUNT);
66
+ const waitMs = query.get("waitMs") ?? "0";
50
67
  if (!/^(0|[1-9]\d{0,15})$/.test(after) ||
51
68
  !Number.isSafeInteger(Number(after)) ||
52
69
  !/^[1-9]\d{0,2}$/.test(limit) ||
53
- Number(limit) > INBOX_COUNT) {
54
- throw new Fault(400, "invalid_input", "after must be a nonnegative safe integer cursor; limit must be 1..256.");
70
+ Number(limit) > INBOX_COUNT ||
71
+ !/^(0|[1-9]\d{0,3}|10000)$/.test(waitMs)) {
72
+ throw new Fault(400, "invalid_input", "after must be a nonnegative safe integer cursor; limit must be 1..256; waitMs must be 0..10000.");
73
+ }
74
+ return {
75
+ route: "GET /v1/inbox",
76
+ after: Number(after),
77
+ limit: Number(limit),
78
+ waitMs: Number(waitMs),
79
+ };
80
+ }
81
+ function pageNumber(value, fallback, min, max) {
82
+ const number = value === null ? fallback : Number(value);
83
+ if (value === "" || !Number.isSafeInteger(number) || number < min || number > max) {
84
+ throw new Fault(400, "invalid_input", "Invalid catalog pagination.");
85
+ }
86
+ return number;
87
+ }
88
+ function queryBoolean(value) {
89
+ if (value !== null && value !== "true" && value !== "false") {
90
+ throw new Fault(400, "invalid_input", "Catalog switches must be true or false.");
55
91
  }
56
- return { route: "GET /v1/inbox", after: Number(after), limit: Number(limit) };
92
+ return value === "true";
57
93
  }
58
- function timeout(value) {
59
- const ms = value === undefined ? ASK_TIMEOUT : value;
60
- if (typeof ms !== "number" || !Number.isInteger(ms) || ms < 100 || ms > ASK_TIMEOUT) {
61
- throw new Fault(400, "invalid_input", "timeoutMs must be an integer from 100 to 120000.");
94
+ function catalogQuery(req) {
95
+ if (req.url?.includes("?") !== true) {
96
+ return undefined;
97
+ }
98
+ const params = queryFor(req, ["live", "saved", "q", "offset", "limit"]);
99
+ return {
100
+ live: queryBoolean(params.get("live")),
101
+ saved: queryBoolean(params.get("saved")),
102
+ q: params.has("q") ? label(params.get("q"), "catalog filter", 128) : undefined,
103
+ offset: pageNumber(params.get("offset"), 0, 0, Number.MAX_SAFE_INTEGER),
104
+ limit: pageNumber(params.get("limit"), 50, 1, 100),
105
+ };
106
+ }
107
+ function timeout(value, control = false) {
108
+ const defaultMs = control ? 3000 : ASK_TIMEOUT;
109
+ const ms = value === undefined ? defaultMs : value;
110
+ const max = control ? 10000 : ASK_TIMEOUT;
111
+ if (typeof ms !== "number" || !Number.isInteger(ms) || ms < 100 || ms > max) {
112
+ throw new Fault(400, "invalid_input", `timeoutMs must be an integer from 100 to ${max}.`);
62
113
  }
63
114
  return ms;
64
115
  }
65
- export async function operationFor(req, route, signal) {
66
- const input = req.method === "POST" ? object(await body(req, signal), fields[route]) : {};
116
+ function resume(input) {
117
+ if (["sessionId", "name", "target"].filter((key) => input[key] !== undefined).length !== 1) {
118
+ throw new Fault(400, "invalid_input", "Resume requires exactly one of sessionId, name or target.");
119
+ }
120
+ const sessionId = input.sessionId === undefined ? undefined : label(input.sessionId, "native session ID", 128);
121
+ if (sessionId !== undefined && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(sessionId)) {
122
+ throw new Fault(400, "invalid_input", "Resume takes a native ID, never a journal path.");
123
+ }
124
+ return {
125
+ route: "POST /v1/resume",
126
+ sessionId,
127
+ target: input.target === undefined ? undefined : label(input.target, "resume target", 256).trim(),
128
+ name: input.name === undefined ? undefined : label(input.name, "saved session name", 256).trim(),
129
+ task: input.task === undefined ? undefined : text(input.task),
130
+ };
131
+ }
132
+ function stop(route, input) {
133
+ if (["sessionId", "target", "to"].filter((key) => input[key] !== undefined).length !== 1) {
134
+ throw new Fault(400, "invalid_input", "Control requires exactly one sessionId, target or to.");
135
+ }
136
+ const mode = input.mode ?? "kill";
137
+ if (mode !== "kill" && mode !== "cancel") {
138
+ throw new Fault(400, "invalid_input", "Stop mode must be cancel or kill.");
139
+ }
140
+ return {
141
+ route: mode === "cancel" ? "POST /v1/cancel" : route,
142
+ sessionId: label(input.sessionId ?? input.target ?? input.to, "session target", 256).trim(),
143
+ timeoutMs: timeout(input.timeoutMs, true),
144
+ };
145
+ }
146
+ function nativeControl(route, input) {
147
+ const verb = route === "POST /v1/control" ? input.verb : route.split("/").at(-1);
148
+ if (!isControlVerb(verb)) {
149
+ throw new Fault(400, "invalid_input", "Unknown native control verb.");
150
+ }
151
+ const timeoutMs = timeout(input.timeoutMs, true);
152
+ const to = label(input.to ?? input.target, "session target").trim();
153
+ if (verb === "transcript") {
154
+ const options = transcriptOptions(route === "POST /v1/control"
155
+ ? input.options
156
+ : { limit: input.limit, maxBytes: input.maxBytes, cursor: input.cursor });
157
+ return { route: "GET /v1/transcript", target: to, options, timeoutMs };
158
+ }
159
+ if (input.options !== undefined) {
160
+ throw new Fault(400, "invalid_input", "Only transcript accepts options.");
161
+ }
162
+ return { route: "POST /v1/control", verb, to, timeoutMs };
163
+ }
164
+ async function operationInput(req, route, signal) {
165
+ if (req.method === "POST") {
166
+ return object(await body(req, signal), fields[route]);
167
+ }
168
+ if (route !== "GET /v1/transcript") {
169
+ return {};
170
+ }
171
+ const input = {};
172
+ for (const [key, value] of queryFor(req, fields[route])) {
173
+ input[key] = ["limit", "maxBytes", "timeoutMs"].includes(key) ? Number(value) : value;
174
+ }
175
+ return input;
176
+ }
177
+ function messagingOperation(route, input, req) {
67
178
  switch (route) {
68
179
  case "GET /v1/list":
69
180
  case "POST /v1/register":
70
- case "GET /v1/sessions":
181
+ case "GET /v1/groups":
71
182
  return { route };
72
183
  case "GET /v1/inbox":
73
184
  return inboxQuery(req);
74
- case "POST /v1/start":
75
- return { route, name: label(input.name, "session name", 64).trim() };
76
- case "POST /v1/stop":
77
- return { route, sessionId: label(input.sessionId, "session ID", 128) };
78
- case "POST /v1/ack": {
185
+ case "GET /v1/sessions":
186
+ return { route, query: catalogQuery(req) };
187
+ case "POST /v1/ack":
79
188
  if (!isUnknownArray(input.ids) || input.ids.length > INBOX_COUNT) {
80
189
  throw new Fault(400, "invalid_input", "ids must be an array of at most 256 message IDs.");
81
190
  }
82
191
  return { route, ids: [...new Set(input.ids.map((id) => label(id, "message ID")))] };
83
- }
84
192
  case "POST /v1/send":
85
- return { route, message: text(input.message), to: label(input.to, "to").trim() };
193
+ return send(input);
86
194
  case "POST /v1/ask":
87
195
  return {
88
196
  route,
@@ -92,5 +200,65 @@ export async function operationFor(req, route, signal) {
92
200
  };
93
201
  case "POST /v1/reply":
94
202
  return { route, message: text(input.message), replyTo: label(input.replyTo, "replyTo") };
203
+ case "GET /v1/transcript":
204
+ case "POST /v1/control":
205
+ case "POST /v1/transcript":
206
+ case "POST /v1/capabilities":
207
+ return nativeControl(route, input);
208
+ }
209
+ }
210
+ function send(input) {
211
+ if (input.passive !== undefined && typeof input.passive !== "boolean") {
212
+ throw new Fault(400, "invalid_input", "passive must be a boolean.");
213
+ }
214
+ return {
215
+ route: "POST /v1/send",
216
+ message: text(input.message),
217
+ to: label(input.to, "to").trim(),
218
+ passive: input.passive,
219
+ messageId: input.messageId === undefined ? undefined : label(input.messageId, "message ID"),
220
+ };
221
+ }
222
+ export async function operationFor(req, route, signal) {
223
+ const input = await operationInput(req, route, signal);
224
+ switch (route) {
225
+ case "POST /v1/start":
226
+ return { route, name: label(input.name, "session name", 64).trim() };
227
+ case "POST /v1/spawn":
228
+ return {
229
+ route,
230
+ name: label(input.name, "session name", 64).trim(),
231
+ cwd: input.cwd === undefined ? undefined : label(input.cwd, "cwd", 4096),
232
+ provider: input.provider === undefined ? undefined : label(input.provider, "provider", 128),
233
+ model: input.model === undefined ? undefined : label(input.model, "model reference", 512),
234
+ task: input.task === undefined ? undefined : text(input.task),
235
+ };
236
+ case "POST /v1/resume":
237
+ return resume(input);
238
+ case "POST /v1/stop":
239
+ case "POST /v1/cancel":
240
+ return stop(route, input);
241
+ case "POST /v1/groups":
242
+ return { route, group: groupDefinition(input) };
243
+ case "POST /v1/broadcast":
244
+ return {
245
+ route,
246
+ message: text(input.message),
247
+ group: input.group === undefined ? undefined : label(input.group, "group", 64).trim(),
248
+ };
249
+ case "GET /v1/groups":
250
+ case "GET /v1/inbox":
251
+ case "GET /v1/list":
252
+ case "GET /v1/sessions":
253
+ case "GET /v1/transcript":
254
+ case "POST /v1/ack":
255
+ case "POST /v1/ask":
256
+ case "POST /v1/capabilities":
257
+ case "POST /v1/control":
258
+ case "POST /v1/register":
259
+ case "POST /v1/reply":
260
+ case "POST /v1/send":
261
+ case "POST /v1/transcript":
262
+ return messagingOperation(route, input, req);
95
263
  }
96
264
  }
@@ -2,14 +2,14 @@ import { spawn } from "node:child_process";
2
2
  import { StringDecoder } from "node:string_decoder";
3
3
  import { trySignalChildTree } from "../shared/post-exit-stdio-guard.js";
4
4
  import { isRecord } from "../shared/unknown.js";
5
- import { Fault, log, wait } from "./bridge-protocol.js";
5
+ import { Fault, cancelled, log, wait } from "./bridge-protocol.js";
6
6
  const FRAME_LIMIT = 4 * 1024 * 1024;
7
7
  function launchEnvironment(packageRoot) {
8
8
  const environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PI_SUBAGENT_")));
9
9
  return { ...environment, PI_PACKAGE_DIR: packageRoot };
10
10
  }
11
11
  // Native RpcClient lacks public EOF/exit/UI control and retains/reprints unbounded stderr.
12
- // This owner implements only get_state and dialog cancellation, never a remote RPC proxy.
12
+ // This owner implements state, owned-run cancellation and dialog dismissal, never a raw RPC proxy.
13
13
  export class RpcProcess {
14
14
  child;
15
15
  exited;
@@ -23,8 +23,9 @@ export class RpcProcess {
23
23
  stopping;
24
24
  stderrBytes = 0;
25
25
  exitObserved = false;
26
- constructor(policy, cwd, sessionFile) {
27
- this.child = spawn(process.execPath, this.arguments(policy, sessionFile), {
26
+ cancelling = false;
27
+ constructor(policy, cwd, sessionFile, resuming = false) {
28
+ this.child = spawn(process.execPath, this.arguments(policy, sessionFile, resuming), {
28
29
  cwd,
29
30
  detached: true,
30
31
  env: launchEnvironment(policy.packageRoot),
@@ -65,19 +66,19 @@ export class RpcProcess {
65
66
  get pid() {
66
67
  return this.child.pid;
67
68
  }
68
- arguments(policy, sessionFile) {
69
+ arguments(policy, sessionFile, resuming) {
69
70
  const args = [
70
71
  policy.cli,
71
72
  "--mode",
72
73
  "rpc",
73
74
  "--session",
74
75
  sessionFile,
75
- "--provider",
76
- policy.provider,
77
- "--model",
78
- policy.model,
79
76
  policy.trustProject ? "--approve" : "--no-approve",
80
77
  ];
78
+ // Resume leaves native model/thinking restoration authoritative; never force the launch default.
79
+ if (!resuming && policy.provider !== undefined && policy.model !== undefined) {
80
+ args.push("--provider", policy.provider, "--model", policy.model);
81
+ }
81
82
  if (!policy.discoverResources) {
82
83
  args.push("--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes", "--no-context-files");
83
84
  }
@@ -175,8 +176,10 @@ export class RpcProcess {
175
176
  response(data) {
176
177
  const pending = this.pending;
177
178
  this.pending = undefined;
178
- if (data.success === true && data.command === "get_state" && isRecord(data.data)) {
179
- pending?.resolve(data.data);
179
+ if (data.success === true &&
180
+ data.command === pending?.command &&
181
+ (pending?.command !== "get_state" || isRecord(data.data))) {
182
+ pending?.resolve(isRecord(data.data) ? data.data : {});
180
183
  }
181
184
  else {
182
185
  pending?.reject(new Fault(503, "rpc_failed", "Pi RPC state request failed."));
@@ -200,14 +203,18 @@ export class RpcProcess {
200
203
  this.cleanup();
201
204
  }
202
205
  }
203
- async state(signal) {
206
+ async command(command, signal) {
207
+ cancelled(signal);
204
208
  if (this.failure) {
205
209
  throw this.failure;
206
210
  }
207
- const id = `state-${++this.sequence}`;
211
+ if (this.pending) {
212
+ throw new Fault(409, "control_in_progress", "A native control operation is already in flight.");
213
+ }
214
+ const id = `control-${++this.sequence}`;
208
215
  const result = new Promise((resolve, reject) => {
209
- this.pending = { id, resolve, reject };
210
- this.write({ type: "get_state", id });
216
+ this.pending = { id, command, resolve, reject };
217
+ this.write({ type: command, id });
211
218
  });
212
219
  try {
213
220
  return await wait(result, signal);
@@ -216,6 +223,27 @@ export class RpcProcess {
216
223
  this.pending = undefined;
217
224
  }
218
225
  }
226
+ async state(signal) {
227
+ return this.command("get_state", signal);
228
+ }
229
+ async cancel(signal) {
230
+ if (this.cancelling) {
231
+ throw new Fault(409, "control_in_progress", "Native cancellation is already in flight.");
232
+ }
233
+ this.cancelling = true;
234
+ try {
235
+ await this.command("abort", signal);
236
+ const state = await this.state(signal);
237
+ if (state.isStreaming !== false) {
238
+ throw new Fault(409, "cancel_unconfirmed", "Native Pi has not reached idle after abort.");
239
+ }
240
+ this.status = "idle";
241
+ return { isStreaming: false, pendingMessageCount: state.pendingMessageCount };
242
+ }
243
+ finally {
244
+ this.cancelling = false;
245
+ }
246
+ }
219
247
  ready() {
220
248
  if (this.failure || this.exitObserved) {
221
249
  throw this.failure ?? new Fault(503, "session_exited", "Pi exited during startup.");
@@ -1,8 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { setTimeout as delay } from "node:timers/promises";
2
3
  import { IntercomClient } from "./broker/client.js";
3
- import { formatSessionTarget, resolveSessionProjectId, resolveSessionTarget, } from "./session-targets.js";
4
+ import { resolveSessionProjectId } from "./session-targets.js";
4
5
  import { Fault, cancelled, log, wait } from "./bridge-protocol.js";
5
6
  import { Mailbox } from "./bridge-mailbox.js";
7
+ import { resolveBridgeTarget, bridgeSessionList } from "./bridge-target.js";
8
+ import { ControlReplies } from "./bridge-control.js";
6
9
  import { WaitingAsk } from "./bridge-ask.js";
7
10
  const MAX_REQUESTS = 8;
8
11
  const IDLE_TIMEOUT = 5 * 60 * 1000;
@@ -19,8 +22,10 @@ export class Session {
19
22
  });
20
23
  requests = new Set();
21
24
  inbox;
25
+ inboxChanged = new AbortController();
22
26
  replying = new Set();
23
27
  pending;
28
+ controls = new ControlReplies();
24
29
  closed = false;
25
30
  connecting;
26
31
  disconnecting;
@@ -45,12 +50,13 @@ export class Session {
45
50
  this.touch();
46
51
  }
47
52
  guard(signal) {
48
- if (signal) {
49
- cancelled(signal);
50
- }
53
+ signal?.throwIfAborted();
51
54
  if (this.closed) {
52
55
  throw this.closeReason;
53
56
  }
57
+ this.authorize();
58
+ }
59
+ authorize() {
54
60
  if (!this.owner.allowed(this.identity)) {
55
61
  throw new Fault(403, "revoked", "Identity is no longer authorized.");
56
62
  }
@@ -143,27 +149,26 @@ export class Session {
143
149
  this.owner.retired(this.identity.fingerprint256, this.generation);
144
150
  log("session_closed", { ...this.fields(), result: this.closeReason.code });
145
151
  }
146
- close(error) {
152
+ close(fault) {
147
153
  if (this.closed) {
148
154
  return;
149
155
  }
150
156
  this.closed = true;
151
- this.closeReason = error;
157
+ this.closeReason = fault;
152
158
  clearTimeout(this.lease);
153
- this.pending?.reject(error);
159
+ this.pending?.reject(fault);
160
+ this.controls.close(fault);
154
161
  for (const controller of this.requests) {
155
- controller.abort(error);
162
+ controller.abort(fault);
156
163
  }
157
164
  // Retain the generation until acquisition/registration AND final disconnect settle.
158
- this.startRetirement();
159
- }
160
- startRetirement() {
161
165
  this.retirement = this.retire().catch((caught) => this.cleanupFailed(caught));
162
166
  }
163
167
  async settled() {
164
168
  await this.retirement;
165
169
  }
166
170
  peerLeft(id) {
171
+ this.controls.peerLeft(id);
167
172
  if (this.pending?.peer === id && !this.pending.settled) {
168
173
  this.pending.reject(new Fault(409, "peer_offline", "Ask recipient disconnected.", { id: this.pending.id }));
169
174
  }
@@ -182,6 +187,9 @@ export class Session {
182
187
  : new Fault(403, "revoked", "Identity is no longer authorized."));
183
188
  return;
184
189
  }
190
+ if (this.controls.receive(from.id, message)) {
191
+ return;
192
+ }
185
193
  if (!this.inbox.persistent && this.resolveReply(from, message)) {
186
194
  return;
187
195
  }
@@ -203,12 +211,22 @@ export class Session {
203
211
  this.close(new Fault(507, "mailbox_storage_failed", "Mailbox storage failed; inspect the metadata audit for unrecorded losses."));
204
212
  return;
205
213
  }
206
- if (result === "lost" || (result === "duplicate" && !this.inbox.persistent)) {
207
- this.owner.lost(this.identity.fingerprint256);
214
+ if (result !== "duplicate" || !this.inbox.persistent) {
215
+ if (result !== "stored") {
216
+ this.owner.lost(this.identity.fingerprint256);
217
+ }
218
+ this.inboxChanged.abort();
219
+ this.inboxChanged = new AbortController();
208
220
  }
209
- this.recordReceive(from.id, message.id, result);
221
+ log(result === "lost" ? "inbox_overflow" : "received", {
222
+ ...this.fields(),
223
+ peer: from.id,
224
+ messageId: message.id,
225
+ result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
226
+ });
210
227
  if (result !== "lost") {
211
228
  this.resolveReply(from, message);
229
+ this.owner.mailboxChanged?.();
212
230
  }
213
231
  this.presence();
214
232
  }
@@ -220,14 +238,6 @@ export class Session {
220
238
  pending.resolve({ from, message });
221
239
  return true;
222
240
  }
223
- recordReceive(peer, messageId, result) {
224
- log(result === "lost" ? "inbox_overflow" : "received", {
225
- ...this.fields(),
226
- peer,
227
- messageId,
228
- result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
229
- });
230
- }
231
241
  presence() {
232
242
  if (this.closed) {
233
243
  return;
@@ -254,20 +264,16 @@ export class Session {
254
264
  await this.connect(signal);
255
265
  return this.peers(signal);
256
266
  }
267
+ async nativeControl(operation, certificate, context) {
268
+ await this.connect(context.signal);
269
+ const peer = await this.target(operation.to, context.signal);
270
+ return this.controls.request(this.client, { ...operation, peer: peer.id, certificate }, { ...context, guard: () => this.guard(context.signal) });
271
+ }
257
272
  async target(to, signal) {
258
- const resolution = resolveSessionTarget(await this.peers(signal), to);
259
- if (resolution.status === "none") {
260
- throw new Fault(404, "peer_offline", "Peer is not connected.");
261
- }
262
- if (resolution.status !== "found" || !resolution.target) {
263
- throw new Fault(409, "ambiguous_target", "Use an unambiguous peer name or session ID (prefixes need at least 8 characters).");
264
- }
265
- if (resolution.target.id === this.client.sessionId) {
266
- throw new Fault(400, "self_target", "Choose another peer.");
267
- }
268
- return resolution.target;
273
+ return resolveBridgeTarget(await this.peers(signal), to, this.client.sessionId);
269
274
  }
270
275
  async send(peer, message, context, options = {}) {
276
+ await this.connect(context.signal);
271
277
  const { signal, requestId } = context;
272
278
  context.onPeer(peer);
273
279
  this.guard(signal);
@@ -307,17 +313,6 @@ export class Session {
307
313
  const waiting = new WaitingAsk();
308
314
  this.pending = waiting;
309
315
  const { signal } = context;
310
- const abort = () => {
311
- try {
312
- cancelled(signal);
313
- }
314
- catch (error) {
315
- waiting.reject(error instanceof Error
316
- ? error
317
- : new Fault(499, "client_aborted", "HTTP client disconnected; delivery may be unknown."));
318
- }
319
- };
320
- signal.addEventListener("abort", abort, { once: true });
321
316
  try {
322
317
  await this.connect(signal);
323
318
  const peer = await this.target(operation.to, signal);
@@ -334,16 +329,37 @@ export class Session {
334
329
  }
335
330
  finally {
336
331
  waiting.clear();
337
- signal.removeEventListener("abort", abort);
338
- if (this.pending === waiting) {
339
- this.pending = undefined;
340
- }
332
+ this.pending = undefined;
341
333
  }
342
334
  }
343
- ack(ids) {
344
- const result = this.inbox.ack(ids);
345
- this.presence();
346
- return result;
335
+ async readInbox(operation, context) {
336
+ const read = () => ({
337
+ ...this.inbox.page(operation.after, operation.limit),
338
+ ...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
339
+ });
340
+ const deadline = performance.now() + operation.waitMs;
341
+ const losses = read().lostMessages;
342
+ for (;;) {
343
+ // Subscribe to the post-commit signal before rereading the authoritative cursor page.
344
+ const signal = AbortSignal.any([this.inboxChanged.signal, context.signal]);
345
+ const page = read();
346
+ const remaining = deadline - performance.now();
347
+ if (page.messages.length > 0 || page.lostMessages !== losses || remaining <= 0) {
348
+ return page;
349
+ }
350
+ log("inbox_wait", { ...this.fields(), requestId: context.requestId, after: operation.after });
351
+ try {
352
+ // A nonmatching commit must rearm the same cursor, not start parallel readers.
353
+ // oxlint-disable-next-line no-await-in-loop
354
+ await delay(remaining, undefined, { signal });
355
+ }
356
+ catch (error) {
357
+ if (!signal.aborted) {
358
+ throw error;
359
+ }
360
+ }
361
+ this.guard(context.signal);
362
+ }
347
363
  }
348
364
  async answer(replyTo, message, context) {
349
365
  const entry = this.inbox.get(replyTo);
@@ -367,6 +383,7 @@ export class Session {
367
383
  }
368
384
  const data = { ...(await this.send(peer, message, context, { replyTo })) };
369
385
  this.inbox.remove(replyTo);
386
+ this.owner.mailboxChanged?.();
370
387
  this.presence();
371
388
  return data;
372
389
  }
@@ -385,30 +402,30 @@ export class Session {
385
402
  await this.connect(context.signal);
386
403
  }
387
404
  if (operation.route === "POST /v1/ack") {
388
- return this.ack(operation.ids);
405
+ const result = this.inbox.ack(operation.ids);
406
+ this.owner.mailboxChanged?.();
407
+ this.presence();
408
+ return result;
389
409
  }
390
- return {
391
- ...this.inbox.page(operation.after, operation.limit),
392
- ...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
393
- };
410
+ return this.readInbox(operation, context);
394
411
  }
395
412
  await this.connect(context.signal);
396
413
  switch (operation.route) {
397
414
  case "POST /v1/register":
398
415
  return { sessionId: this.client.sessionId, name: this.identity.name };
399
- case "GET /v1/list": {
400
- const sessions = await this.peers(context.signal);
416
+ case "GET /v1/list":
401
417
  return {
402
418
  sessionId: this.client.sessionId,
403
- sessions: sessions.map(({ topics: _topics, subscriptions: _subscriptions, ...entry }) => {
404
- return Object.assign(entry, { target: formatSessionTarget(entry, sessions) });
405
- }),
419
+ sessions: bridgeSessionList(await this.peers(context.signal)),
406
420
  };
421
+ case "POST /v1/send": {
422
+ const peer = await this.target(operation.to, context.signal);
423
+ const receipt = await this.send(peer.id, operation.message, context, {
424
+ messageId: operation.messageId,
425
+ delivery: operation.passive === true ? "passive" : "steer",
426
+ });
427
+ return { ...receipt };
407
428
  }
408
- case "POST /v1/send":
409
- return {
410
- ...(await this.send((await this.target(operation.to, context.signal)).id, operation.message, context)),
411
- };
412
429
  case "POST /v1/reply":
413
430
  return this.answer(operation.replyTo, operation.message, context);
414
431
  }