@fitchmultz/pi-subagents 0.46.2 → 0.48.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 (71) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +1 -1
  3. package/dist/extension/build-info.js +1 -1
  4. package/dist/pi-intercom/bridge-broadcast.js +60 -0
  5. package/dist/pi-intercom/bridge-catalog.js +372 -0
  6. package/dist/pi-intercom/bridge-config.js +47 -26
  7. package/dist/pi-intercom/bridge-control.js +104 -0
  8. package/dist/pi-intercom/bridge-files.js +47 -0
  9. package/dist/pi-intercom/bridge-groups.js +135 -0
  10. package/dist/pi-intercom/bridge-http.js +8 -1
  11. package/dist/pi-intercom/bridge-launch-journal.js +84 -0
  12. package/dist/pi-intercom/bridge-launch-policy.js +71 -5
  13. package/dist/pi-intercom/bridge-launch.js +186 -76
  14. package/dist/pi-intercom/bridge-mailbox.js +324 -0
  15. package/dist/pi-intercom/bridge-operations.js +127 -0
  16. package/dist/pi-intercom/bridge-presence.js +100 -0
  17. package/dist/pi-intercom/bridge-protocol.js +7 -0
  18. package/dist/pi-intercom/bridge-routes.js +190 -17
  19. package/dist/pi-intercom/bridge-rpc-process.js +43 -15
  20. package/dist/pi-intercom/bridge-session.js +83 -85
  21. package/dist/pi-intercom/bridge-target.js +22 -0
  22. package/dist/pi-intercom/bridge-wake.js +158 -0
  23. package/dist/pi-intercom/bridge.js +84 -44
  24. package/dist/pi-intercom/broker/client.js +1 -0
  25. package/dist/pi-intercom/connection.js +2 -3
  26. package/dist/pi-intercom/inbound.js +2 -4
  27. package/dist/pi-intercom/index.js +7 -1
  28. package/dist/pi-intercom/native-control-auth.js +80 -0
  29. package/dist/pi-intercom/native-control-protocol.js +111 -0
  30. package/dist/pi-intercom/native-control.js +130 -0
  31. package/dist/pi-intercom/saved-transcript.js +135 -0
  32. package/dist/pi-intercom/session-targets.js +3 -0
  33. package/dist/pi-intercom/transcript.js +175 -0
  34. package/docs/intercom-bridge.md +293 -161
  35. package/docs/intercom.md +1 -1
  36. package/docs/reference.md +1 -1
  37. package/package.json +1 -1
  38. package/scripts/intercom-bridge-credentials.mjs +54 -12
  39. package/scripts/intercom-remote.mjs +196 -31
  40. package/scripts/package-smoke.mjs +1 -1
  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 +64 -37
  44. package/src/pi-intercom/bridge-control.ts +179 -0
  45. package/src/pi-intercom/bridge-files.ts +59 -0
  46. package/src/pi-intercom/bridge-groups.ts +169 -0
  47. package/src/pi-intercom/bridge-http.ts +15 -1
  48. package/src/pi-intercom/bridge-launch-journal.ts +122 -0
  49. package/src/pi-intercom/bridge-launch-policy.ts +88 -7
  50. package/src/pi-intercom/bridge-launch.ts +296 -107
  51. package/src/pi-intercom/bridge-mailbox.ts +395 -0
  52. package/src/pi-intercom/bridge-operations.ts +169 -0
  53. package/src/pi-intercom/bridge-presence.ts +128 -0
  54. package/src/pi-intercom/bridge-protocol.ts +8 -0
  55. package/src/pi-intercom/bridge-routes.ts +281 -32
  56. package/src/pi-intercom/bridge-rpc-process.ts +53 -16
  57. package/src/pi-intercom/bridge-session.ts +106 -105
  58. package/src/pi-intercom/bridge-target.ts +35 -0
  59. package/src/pi-intercom/bridge-wake.ts +188 -0
  60. package/src/pi-intercom/bridge.ts +88 -54
  61. package/src/pi-intercom/broker/client.ts +3 -0
  62. package/src/pi-intercom/connection.ts +2 -3
  63. package/src/pi-intercom/inbound.ts +2 -4
  64. package/src/pi-intercom/index.ts +7 -1
  65. package/src/pi-intercom/native-control-auth.ts +89 -0
  66. package/src/pi-intercom/native-control-protocol.ts +180 -0
  67. package/src/pi-intercom/native-control.ts +160 -0
  68. package/src/pi-intercom/saved-transcript.ts +175 -0
  69. package/src/pi-intercom/session-targets.ts +4 -0
  70. package/src/pi-intercom/transcript.ts +226 -0
  71. package/src/pi-intercom/types.ts +2 -0
package/docs/intercom.md CHANGED
@@ -605,7 +605,7 @@ Assistant-error replies wait for broker acknowledgement during `message_end`, bo
605
605
  Historical official Pi 0.87.1 checks exposed queue visibility and prompt-preparation races. Current tests preserve exact-once receipts and input rejection/recovery on the selected host, including the genuine Pi 1.0 pre-admission window described above; they do not promise one combined provider turn. Historical results are not a current 1.0 pass. Use an explicit `async: false` wait when dependent work needs a finalized result. Native pending-call, idle-checkpoint and immediate-usage APIs are no longer supported. See [result delivery](reference.md#host-capabilities-and-result-delivery) and [local qualification](development.md#local-validation).
606
606
 
607
607
  - **Same machine only** — The core broker uses local Unix sockets, no network support. The optional [secure remote bridge](intercom-bridge.md) authenticates remote messaging through a separate loopback mTLS proxy; it does not expose the broker socket.
608
- - **No dedicated intercom log** — Messages are kept in Pi session history, but there is no separate intercom transcript or inbox
608
+ - **No dedicated local intercom log** — Messages are kept in Pi session history. The optional remote bridge has its own durable mailbox and recent-transcript API; the local peer tool has no separate inbox.
609
609
  - **No attachments UI** — `file`, `snippet`, and `context` attachments are supported in the protocol, but not in the compose overlay
610
610
  - **Only connected sessions appear** — The list shows Pi sessions that have loaded `pi-intercom` and successfully registered with the broker, not every open Pi process on the machine
611
611
  - **Broker lifecycle** — The broker auto-spawns on first use and exits when idle; sessions reconnect automatically if the broker restarts
package/docs/reference.md CHANGED
@@ -375,7 +375,7 @@ The files in `prompts/` document common workflows without registering additional
375
375
 
376
376
  ## Bundled intercom
377
377
 
378
- For a remote agent on Linux, the optional [secure remote bridge](intercom-bridge.md) provides a loopback-only mTLS HTTPS API over an SSH reverse forward. It registers a certificate-bound peer such as `grok-bot` and supports messaging and optional locally pinned, name-only starts of persistent Pi sessions on the destination machine; the private Unix broker socket remains local. The guide includes setup, remote start/sessions/stop commands, lifecycle limits, credential rotation and revocation.
378
+ For an authenticated remote agent, the optional [secure remote bridge](intercom-bridge.md) provides a loopback-only mTLS HTTPS API carried by Tailscale TCP forwarding or an SSH reverse forward. It supports unattended messaging, durable mailboxes and wake notifications, named groups, live/saved session discovery, workspace/model-aware spawn, resume by stable ID or unique name, recent transcripts and native stop/cancel controls. The private Unix broker socket remains local. The guide explains setup, full credential authority, native host limits, safe updates, rotation and revocation.
379
379
 
380
380
  The same `pi-subagents` install registers the intercom extension and skill. `load_intercom({})` exposes the full peer tool explicitly; actionable peer inbound and recovered reply needs also expose it before use. Presence, passive breadcrumbs, routine subagent notices, and human messages answered in chat do not. Broker presence and delivery continue even with the schema hidden. Managed children get a private coordination channel back to the parent Pi session unless an explicit agent extension allowlist excludes it. See [the intercom guide](intercom.md) for direct peer messaging, keyboard UI, tool actions, configuration, and broker details.
381
381
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitchmultz/pi-subagents",
3
- "version": "0.46.2",
3
+ "version": "0.48.0",
4
4
  "description": "Pi extension for subagent orchestration and local session coordination",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,13 +22,16 @@ function errorMessage(error) {
22
22
  }
23
23
 
24
24
  function help() {
25
- console.log(`Usage: node scripts/intercom-bridge-credentials.mjs <init|issue|revoke> DIR [NAME] [--cwd PATH]
25
+ console.log(`Usage: node scripts/intercom-bridge-credentials.mjs <init|issue|revoke|renew-server> DIR [NAME] [--cwd PATH] [--port N] [--days N]
26
26
 
27
27
  init Create a new private CA, loopback server certificate and grok-bot credential.
28
- issue Issue a fresh 7-day client certificate; preserve local launch policy.
28
+ issue Issue a fresh client certificate; preserve local identity policies.
29
29
  revoke Remove the named client from the allowlist. Does not delete saved credentials.
30
+ renew-server Renew the loopback server key/certificate using the same CA; preserve clients.
30
31
 
31
32
  Options: --cwd PATH Local project identity for init/issue (default: current directory)
33
+ --port N Loopback bridge port for init (default: 9443)
34
+ --days N Certificate validity (1–3650; client default: 7, renew-server: 365)
32
35
  -h, --help Show help
33
36
  Environment: OPENSSL OpenSSL executable (default: openssl; requires -addext support)
34
37
 
@@ -38,6 +41,7 @@ Examples:
38
41
  node scripts/intercom-bridge-credentials.mjs revoke ~/.pi/intercom-bridge grok-bot
39
42
 
40
43
  After issue/revoke, send SIGHUP to the helper PID to activate the allowlist.
44
+ After renew-server, restart only the helper; existing client credentials remain valid.
41
45
  Rotation/revocation stops that identity's helper-launched Pi children; journals remain.
42
46
  Concurrent credential commands fail rather than overwrite each other's authorization.
43
47
  After a crashed command, inspect DIR/.credentials.lock/owner and remove that lock
@@ -80,7 +84,7 @@ function key(path) {
80
84
  privatePath(path);
81
85
  }
82
86
 
83
- function issueCertificate(dir, stem, name, server = false) {
87
+ function issueCertificate(dir, stem, name, { server = false, days = 7 } = {}) {
84
88
  const keyPath = join(dir, `${stem}.key`);
85
89
  const certPath = join(dir, `${stem}.crt`);
86
90
  const csrPath = join(dir, `${stem}.csr`);
@@ -105,7 +109,7 @@ function issueCertificate(dir, stem, name, server = false) {
105
109
  "-set_serial",
106
110
  `0x${randomBytes(16).toString("hex")}`,
107
111
  "-days",
108
- server ? "365" : "7",
112
+ String(days),
109
113
  "-sha256",
110
114
  "-extfile",
111
115
  extPath,
@@ -145,23 +149,41 @@ function readConfig(path) {
145
149
  }
146
150
 
147
151
  let args;
152
+ let port;
153
+ let certificateDays;
148
154
  try {
149
155
  args = parseArgs({
150
156
  allowPositionals: true,
151
- options: { help: { type: "boolean", short: "h" }, cwd: { type: "string" } },
157
+ options: {
158
+ help: { type: "boolean", short: "h" },
159
+ cwd: { type: "string" },
160
+ port: { type: "string" },
161
+ days: { type: "string" },
162
+ },
152
163
  });
153
164
  if (args.values.help) {
154
165
  help();
155
166
  process.exit(0);
156
167
  }
157
168
  const [action, dir, name] = args.positionals;
169
+ port = args.values.port === undefined ? 9443 : Number(args.values.port);
170
+ const defaultDays = action === "renew-server" ? 365 : 7;
171
+ certificateDays = args.values.days === undefined ? defaultDays : Number(args.values.days);
158
172
  if (
159
- !["init", "issue", "revoke"].includes(action) ||
173
+ !["init", "issue", "revoke", "renew-server"].includes(action) ||
160
174
  !dir ||
161
175
  args.positionals.length > 3 ||
162
- (action !== "init" && !name) ||
176
+ (["issue", "revoke"].includes(action) && !name) ||
177
+ (action === "renew-server" && (name || args.values.cwd)) ||
163
178
  (name && !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name)) ||
164
- (action === "revoke" && args.values.cwd)
179
+ (action === "revoke" && (args.values.cwd || args.values.days)) ||
180
+ (action !== "init" && args.values.port !== undefined) ||
181
+ !Number.isInteger(port) ||
182
+ port < 0 ||
183
+ port > 65535 ||
184
+ !Number.isInteger(certificateDays) ||
185
+ certificateDays < 1 ||
186
+ certificateDays > 3650
165
187
  ) {
166
188
  throw new Error("Invalid arguments; use --help.");
167
189
  }
@@ -217,9 +239,9 @@ try {
217
239
  "-out",
218
240
  join(dir, "ca.crt"),
219
241
  );
220
- const server = issueCertificate(dir, "server", "localhost", true);
242
+ const server = issueCertificate(dir, "server", "localhost", { server: true, days: 365 });
221
243
  config = {
222
- port: 9443,
244
+ port,
223
245
  ca: join(dir, "ca.crt"),
224
246
  cert: server.cert,
225
247
  key: server.key,
@@ -239,16 +261,36 @@ try {
239
261
  console.log(
240
262
  JSON.stringify({ revoked: name, config: configPath, next: "Send SIGHUP to the helper PID." }),
241
263
  );
264
+ } else if (action === "renew-server") {
265
+ privatePath(join(dir, "ca.key"));
266
+ const server = issueCertificate(dir, `server-${randomBytes(8).toString("hex")}`, "localhost", {
267
+ server: true,
268
+ days: certificateDays,
269
+ });
270
+ config.cert = server.cert;
271
+ config.key = server.key;
272
+ atomicConfig(configPath, config);
273
+ console.log(
274
+ JSON.stringify({
275
+ renewed: "server",
276
+ ...server,
277
+ ca: config.ca,
278
+ config: configPath,
279
+ next: "Restart only this helper; client keys and CA are unchanged.",
280
+ }),
281
+ );
242
282
  } else {
243
283
  privatePath(join(dir, "ca.key"));
244
- const credential = issueCertificate(dir, `${name}-${randomBytes(8).toString("hex")}`, name);
284
+ const credential = issueCertificate(dir, `${name}-${randomBytes(8).toString("hex")}`, name, {
285
+ days: certificateDays,
286
+ });
245
287
  const previous = config.clients.find((client) => client.name === name);
246
288
  config.clients = config.clients.filter((client) => client.name !== name);
247
289
  config.clients.push({
290
+ ...previous,
248
291
  fingerprint256: credential.fingerprint256,
249
292
  name,
250
293
  cwd: resolve(args.values.cwd || previous?.cwd || process.cwd()),
251
- ...(previous?.launch === undefined ? {} : { launch: previous.launch }),
252
294
  });
253
295
  atomicConfig(configPath, config);
254
296
  console.log(
@@ -4,7 +4,7 @@ import { lstatSync, readFileSync } from "node:fs";
4
4
  import { parseArgs } from "node:util";
5
5
 
6
6
  function help() {
7
- console.log(`Usage: node intercom-remote.mjs <register|list|inbox|send|ask|reply|ack|start|sessions|stop> [options] [IDs...]
7
+ console.log(`Usage: node intercom-remote.mjs <register|list|inbox|send|ask|reply|ack|start|spawn|resume|sessions|stop|cancel|capabilities|transcript|groups|group-upsert|broadcast|group-send> [options] [IDs...]
8
8
 
9
9
  Environment (required):
10
10
  INTERCOM_BRIDGE_CA Path to trusted bridge CA certificate
@@ -13,22 +13,41 @@ Environment (required):
13
13
  INTERCOM_BRIDGE_URL HTTPS origin (default: https://127.0.0.1:9443)
14
14
 
15
15
  Options:
16
- --to PEER Recipient name or ID for send/ask
16
+ --to PEER Recipient name, peer ID or native ID for messages/controls
17
17
  --message TEXT Message for send/ask/reply
18
18
  --reply-to ID Inbound ask ID from inbox, for reply
19
- --timeout-ms MS Ask timeout (100..120000, default: 120000)
19
+ --timeout-ms MS Ask: 100..120000 (default 120000); control: 100..10000 (default 3000)
20
+ --after CURSOR Inbox cursor from nextCursor (default: 0)
21
+ --limit COUNT Inbox: 1..256; sessions/transcript: 1..100
20
22
  --name NAME Start/reuse a named idle Pi session under local policy
21
- --session-id ID Stop only a Pi session launched by this identity/helper
23
+ --session-id ID Native ID for resume, stop or cancel
24
+ --target ID_OR_NAME Unique target for resume, stop, cancel or transcript
25
+ --cwd PATH Spawn in an existing locally approved workspace
26
+ --task TEXT Submit an Intercom task after spawn/resume readiness
27
+ --model PROVIDER/ID Exact spawn model; or --provider NAME with --model ID
28
+ --mode cancel|kill Stop mode: owned kill observes exit; foreign kill requests shutdown
29
+ --live --saved Opt into live/saved session metadata
30
+ --q TEXT --offset N Catalog filter and page cursor
31
+ --max-bytes N Transcript UTF-8 text budget (256..131072)
32
+ --cursor CURSOR Opaque cursor from an earlier transcript page
33
+ --group NAME Named group for broadcast/group-send
34
+ --members JSON Group members as a JSON array of IDs/names
35
+ --name-pattern GLOB Dynamic group name selector
36
+ --cwd-pattern GLOB Dynamic group cwd selector (combined with name pattern)
22
37
  -h, --help Show help
23
38
 
24
39
  Examples:
25
40
  node intercom-remote.mjs list
26
41
  node intercom-remote.mjs ask --to planner --message 'What is the current status?'
27
42
  node intercom-remote.mjs inbox
43
+ node intercom-remote.mjs inbox --after NEXT_CURSOR --limit 64
28
44
  node intercom-remote.mjs reply --reply-to QUESTION_ID --message 'Proceed.'
29
45
  node intercom-remote.mjs ack MESSAGE_ID
30
46
  node intercom-remote.mjs start --name planner
31
- node intercom-remote.mjs sessions
47
+ node intercom-remote.mjs spawn --name planner --cwd /project --task 'Review this project.'
48
+ node intercom-remote.mjs sessions --live --saved
49
+ node intercom-remote.mjs resume --name saved-unique-name
50
+ node intercom-remote.mjs transcript --target NATIVE_SESSION_ID
32
51
  node intercom-remote.mjs stop --session-id NATIVE_SESSION_ID
33
52
 
34
53
  Outputs JSON. Exit: 0 success, 1 API/TLS/transport failure, 2 invalid arguments.
@@ -36,22 +55,24 @@ No automatic retries: a lost response does not prove a message was unsent.`);
36
55
  }
37
56
 
38
57
  function allowedOptions(action) {
39
- if (action === "start") {
40
- return ["name"];
41
- }
42
- if (action === "stop") {
43
- return ["session-id"];
44
- }
45
- if (action === "send") {
46
- return ["to", "message"];
47
- }
48
- if (action === "ask") {
49
- return ["to", "message", "timeout-ms"];
50
- }
51
- if (action === "reply") {
52
- return ["reply-to", "message"];
53
- }
54
- return [];
58
+ const fields = {
59
+ inbox: ["after", "limit"],
60
+ start: ["name"],
61
+ spawn: ["name", "cwd", "task", "provider", "model"],
62
+ resume: ["session-id", "name", "target", "task"],
63
+ stop: ["session-id", "target", "to", "mode", "timeout-ms"],
64
+ cancel: ["session-id", "target", "to", "timeout-ms"],
65
+ sessions: ["live", "saved", "q", "offset", "limit"],
66
+ capabilities: ["to", "target", "timeout-ms"],
67
+ transcript: ["to", "target", "timeout-ms", "limit", "max-bytes", "cursor"],
68
+ broadcast: ["message", "group"],
69
+ "group-send": ["message", "group"],
70
+ "group-upsert": ["name", "members", "name-pattern", "cwd-pattern"],
71
+ send: ["to", "message"],
72
+ ask: ["to", "message", "timeout-ms"],
73
+ reply: ["reply-to", "message"],
74
+ };
75
+ return fields[action] ?? [];
55
76
  }
56
77
 
57
78
  function requireMessage(recipient, message) {
@@ -65,15 +86,91 @@ function requireMessage(recipient, message) {
65
86
  }
66
87
  }
67
88
 
89
+ function resumeBody(options) {
90
+ if (["name", "session-id", "target"].filter((key) => options[key] !== undefined).length !== 1) {
91
+ throw new Error("Resume requires exactly one --name, --session-id or --target.");
92
+ }
93
+ return {
94
+ name: options.name,
95
+ sessionId: options["session-id"],
96
+ target: options.target,
97
+ task: options.task,
98
+ };
99
+ }
100
+ function stopBody(action, options) {
101
+ if (["session-id", "target", "to"].filter((key) => options[key] !== undefined).length !== 1) {
102
+ throw new Error("Choose exactly one --session-id, --target or --to.");
103
+ }
104
+ return {
105
+ target: options["session-id"] ?? options.target ?? options.to,
106
+ mode: action === "stop" ? options.mode : undefined,
107
+ timeoutMs: controlTimeout(options),
108
+ };
109
+ }
68
110
  function launchBody(action, options) {
69
- const value = action === "start" ? options.name : options["session-id"];
70
- if (typeof value !== "string" || value.trim() === "") {
71
- throw new Error(action === "start" ? "--name is required." : "--session-id is required.");
111
+ if (action === "resume") {
112
+ return resumeBody(options);
113
+ }
114
+ if (action === "stop" || action === "cancel") {
115
+ return stopBody(action, options);
116
+ }
117
+ if (typeof options.name !== "string" || options.name.trim() === "") {
118
+ throw new Error("--name is required.");
119
+ }
120
+ return action === "start"
121
+ ? { name: options.name }
122
+ : {
123
+ name: options.name,
124
+ cwd: options.cwd,
125
+ task: options.task,
126
+ provider: options.provider,
127
+ model: options.model,
128
+ };
129
+ }
130
+ function controlTimeout(options) {
131
+ const timeoutMs = options["timeout-ms"] === undefined ? 3000 : Number(options["timeout-ms"]);
132
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 10000) {
133
+ throw new Error("Control --timeout-ms must be 100..10000.");
134
+ }
135
+ return timeoutMs;
136
+ }
137
+ function extraBody(action, options) {
138
+ if (["capabilities", "transcript"].includes(action)) {
139
+ if (["to", "target"].filter((key) => options[key] !== undefined).length !== 1) {
140
+ throw new Error("Choose exactly one --to or --target.");
141
+ }
142
+ return {
143
+ to: options.to ?? options.target,
144
+ timeoutMs: controlTimeout(options),
145
+ ...(action === "transcript" ? transcriptBody(options) : {}),
146
+ };
72
147
  }
73
- return action === "start" ? { name: value } : { sessionId: value };
148
+ if (action === "group-upsert") {
149
+ return {
150
+ name: options.name,
151
+ members: options.members === undefined ? undefined : JSON.parse(options.members),
152
+ namePattern: options["name-pattern"],
153
+ cwdPattern: options["cwd-pattern"],
154
+ };
155
+ }
156
+ if (action === "broadcast" || action === "group-send") {
157
+ requireMessage(action === "group-send" ? options.group : "all", options.message);
158
+ return { message: options.message, group: options.group };
159
+ }
160
+ }
161
+ function transcriptBody(options) {
162
+ return {
163
+ limit: options.limit === undefined ? undefined : Number(options.limit),
164
+ maxBytes: options["max-bytes"] === undefined ? undefined : Number(options["max-bytes"]),
165
+ cursor: options.cursor,
166
+ };
74
167
  }
75
168
  function requestBody(action, options, ids, timeout) {
76
- if (action === "start" || action === "stop") {
169
+ const extra = extraBody(action, options);
170
+ if (extra !== undefined) {
171
+ return extra;
172
+ }
173
+ if (["start", "spawn", "resume", "stop", "cancel"].includes(action)) {
77
174
  return launchBody(action, options);
78
175
  }
79
176
  if (["send", "ask", "reply"].includes(action)) {
@@ -119,7 +216,32 @@ function privateKey(path) {
119
216
  }
120
217
  }
121
218
 
122
- let body, action, origin, timeout;
219
+ function inboxQuery(options) {
220
+ const after = options.after ?? "0";
221
+ const limit = options.limit ?? "256";
222
+ if (
223
+ !/^(0|[1-9]\d{0,15})$/.test(after) ||
224
+ !Number.isSafeInteger(Number(after)) ||
225
+ !/^[1-9]\d{0,2}$/.test(limit) ||
226
+ Number(limit) > 256
227
+ ) {
228
+ throw new Error("--after must be a nonnegative safe integer cursor; --limit must be 1..256.");
229
+ }
230
+ return new URLSearchParams({ after, limit }).toString();
231
+ }
232
+
233
+ function requestQuery(action, options) {
234
+ if (action === "inbox") {
235
+ return inboxQuery(options);
236
+ }
237
+ return action === "sessions"
238
+ ? new URLSearchParams(
239
+ Object.entries(options).map(([key, value]) => [key, String(value)]),
240
+ ).toString()
241
+ : "";
242
+ }
243
+
244
+ let body, action, origin, timeout, query;
123
245
  try {
124
246
  const parsed = parseArgs({
125
247
  allowPositionals: true,
@@ -129,8 +251,26 @@ try {
129
251
  message: { type: "string" },
130
252
  "reply-to": { type: "string" },
131
253
  "timeout-ms": { type: "string" },
254
+ after: { type: "string" },
255
+ limit: { type: "string" },
132
256
  name: { type: "string" },
133
257
  "session-id": { type: "string" },
258
+ cwd: { type: "string" },
259
+ target: { type: "string" },
260
+ mode: { type: "string" },
261
+ provider: { type: "string" },
262
+ model: { type: "string" },
263
+ task: { type: "string" },
264
+ live: { type: "boolean" },
265
+ saved: { type: "boolean" },
266
+ q: { type: "string" },
267
+ offset: { type: "string" },
268
+ group: { type: "string" },
269
+ members: { type: "string" },
270
+ "name-pattern": { type: "string" },
271
+ "cwd-pattern": { type: "string" },
272
+ "max-bytes": { type: "string" },
273
+ cursor: { type: "string" },
134
274
  },
135
275
  });
136
276
  if (parsed.values.help) {
@@ -149,8 +289,17 @@ try {
149
289
  "reply",
150
290
  "ack",
151
291
  "start",
292
+ "spawn",
293
+ "resume",
152
294
  "sessions",
153
295
  "stop",
296
+ "cancel",
297
+ "capabilities",
298
+ "transcript",
299
+ "groups",
300
+ "group-upsert",
301
+ "broadcast",
302
+ "group-send",
154
303
  ].includes(action)
155
304
  ) {
156
305
  throw new Error("Choose an action; use --help.");
@@ -167,6 +316,7 @@ try {
167
316
  throw new Error("--timeout-ms must be 100..120000.");
168
317
  }
169
318
  body = requestBody(action, options, parsed.positionals.slice(1), timeout);
319
+ query = requestQuery(action, options);
170
320
  origin = new URL(process.env.INTERCOM_BRIDGE_URL || "https://127.0.0.1:9443");
171
321
  if (
172
322
  origin.protocol !== "https:" ||
@@ -193,9 +343,11 @@ try {
193
343
 
194
344
  try {
195
345
  const files = tlsFiles();
196
- origin.pathname = `/v1/${action}`;
346
+ const aliases = { "group-upsert": "groups", "group-send": "broadcast" };
347
+ origin.pathname = `/v1/${aliases[action] ?? action}`;
348
+ origin.search = query;
197
349
  const payload = JSON.stringify(body);
198
- const get = ["list", "inbox", "sessions"].includes(action);
350
+ const get = ["list", "inbox", "sessions", "groups"].includes(action);
199
351
  const result = await new Promise((resolve, reject) => {
200
352
  const req = https.request(
201
353
  origin,
@@ -239,14 +391,21 @@ try {
239
391
  () => {
240
392
  req.destroy(new Error("Bridge request deadline exceeded; delivery may be unknown."));
241
393
  },
242
- action === "start" ? 90000 : (action === "ask" ? timeout : 0) + 15000,
394
+ ["start", "spawn", "resume", "transcript"].includes(action)
395
+ ? 90000
396
+ : (action === "ask" ? timeout : 0) + 15000,
243
397
  );
244
398
  req.once("close", () => clearTimeout(timer));
245
399
  req.once("error", reject);
246
400
  req.end(get ? undefined : payload);
247
401
  });
248
402
  console.log(JSON.stringify(result.value, null, 2));
249
- if (result.status < 200 || result.status >= 300 || result.value?.ok !== true) {
403
+ if (
404
+ result.status < 200 ||
405
+ result.status >= 300 ||
406
+ result.value?.ok !== true ||
407
+ unsuccessful(result.value)
408
+ ) {
250
409
  process.exitCode = 1;
251
410
  }
252
411
  } catch (error) {
@@ -262,3 +421,9 @@ try {
262
421
  );
263
422
  process.exitCode = 1;
264
423
  }
424
+ function unsuccessful(value) {
425
+ return (
426
+ value?.status === "partial" ||
427
+ ["denied", "failed", "unsupported"].includes(value?.native?.status)
428
+ );
429
+ }
@@ -200,7 +200,7 @@ function installProduction(packed) {
200
200
  );
201
201
  if (
202
202
  !credentialHelp.includes(
203
- "Usage: node scripts/intercom-bridge-credentials.mjs <init|issue|revoke>",
203
+ "Usage: node scripts/intercom-bridge-credentials.mjs <init|issue|revoke|renew-server>",
204
204
  )
205
205
  ) {
206
206
  throw new Error("installed credential CLI did not provide usage help");
@@ -0,0 +1,70 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Fault, BroadcastFault } from "./bridge-protocol.ts";
3
+ import type { GroupTargets } from "./bridge-groups.ts";
4
+ import type { UnknownRecord } from "../shared/unknown.ts";
5
+ import type { Session } from "./bridge-session.ts";
6
+ import type { RequestContext } from "./bridge-http.ts";
7
+
8
+ export async function broadcastTargets(
9
+ session: Readonly<Session>,
10
+ targets: GroupTargets,
11
+ message: string,
12
+ context: RequestContext,
13
+ ): Promise<UnknownRecord> {
14
+ const receipts: UnknownRecord[] = [];
15
+ for (const peer of targets.peers) {
16
+ if (context.signal.aborted) {
17
+ receipts.push({
18
+ peer: peer.id,
19
+ attempted: false,
20
+ error: {
21
+ code: "not_attempted",
22
+ message: "Broadcast stopped before sending.",
23
+ deliveryUnknown: false,
24
+ },
25
+ });
26
+ continue;
27
+ }
28
+ const id = randomUUID();
29
+ try {
30
+ // No hidden retries; sequential admission bounds broker pressure.
31
+ // oxlint-disable-next-line no-await-in-loop
32
+ const receipt = await session.send(peer.id, message, context, { messageId: id });
33
+ receipts.push({ peer: peer.id, attempted: true, ...receipt });
34
+ } catch (error) {
35
+ const fault =
36
+ error instanceof Fault
37
+ ? error
38
+ : new Fault(503, "send_unconfirmed", "Target delivery could not be confirmed.");
39
+ receipts.push({
40
+ peer: peer.id,
41
+ attempted: true,
42
+ error: {
43
+ code: fault.code,
44
+ message: fault.message,
45
+ ...fault.details,
46
+ id,
47
+ deliveryUnknown: fault.details.accepted !== false,
48
+ },
49
+ });
50
+ }
51
+ }
52
+ const failed =
53
+ receipts.filter((receipt) => receipt.accepted !== true).length + targets.errors.length;
54
+ const outcome = {
55
+ status: failed === 0 ? "complete" : "partial",
56
+ receipts,
57
+ errors: targets.errors,
58
+ };
59
+ // Transport closure must not erase outcomes, or disclose them after authorization is revoked.
60
+ session.authorize();
61
+ try {
62
+ session.guard(context.signal);
63
+ } catch (error) {
64
+ if (error instanceof Fault) {
65
+ throw new BroadcastFault(error, outcome);
66
+ }
67
+ throw error;
68
+ }
69
+ return outcome;
70
+ }