@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
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.47.0",
3
+ "version": "0.49.0",
4
4
  "description": "Pi extension for subagent orchestration and local session coordination",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { join } from "node:path";
4
+ import { existsSync } from "node:fs";
5
+ import { readBridgeFile, writeBridgeJson } from "../src/pi-intercom/bridge-files.ts";
6
+ import { stateDirectory } from "../src/pi-intercom/bridge-storage.ts";
7
+ import { isRecord } from "../src/shared/unknown.ts";
8
+
9
+ // Run only after proving this history covers every legacy worker and stopping those workers.
10
+ // --complete-since is the operator's coverage attestation, not something outcomes can prove.
11
+ const { values } = parseArgs({
12
+ options: {
13
+ "state-dir": { type: "string" },
14
+ audit: { type: "string" },
15
+ "complete-since": { type: "string" },
16
+ "stopped-at": { type: "string" },
17
+ "stopped-pid": { type: "string" },
18
+ "unknown-attempts": { type: "string" },
19
+ },
20
+ });
21
+ try {
22
+ const stateDir = stateDirectory(values["state-dir"]);
23
+ const coveredSince = Date.parse(values["complete-since"] ?? "");
24
+ const stoppedAt = Date.parse(values["stopped-at"] ?? "");
25
+ const pid = Number(values["stopped-pid"]);
26
+ const unknownAttempts = Number(values["unknown-attempts"]);
27
+ if (
28
+ stateDir === undefined ||
29
+ values.audit === undefined ||
30
+ !Number.isSafeInteger(coveredSince) ||
31
+ !Number.isSafeInteger(stoppedAt) ||
32
+ coveredSince > stoppedAt - 3600000 ||
33
+ stoppedAt > Date.now() ||
34
+ !Number.isSafeInteger(pid) ||
35
+ pid <= 0 ||
36
+ values["unknown-attempts"] === undefined ||
37
+ !Number.isInteger(unknownAttempts) ||
38
+ unknownAttempts < 0 ||
39
+ unknownAttempts > 20
40
+ ) {
41
+ throw new Error(
42
+ "Require state-dir, private audit, complete-since (at least one hour before stopped-at), stopped-at, stopped-pid and unknown-attempts (0..20).",
43
+ );
44
+ }
45
+ try {
46
+ process.kill(pid, 0);
47
+ throw new Error("Legacy helper is still running; stop it before preflight.");
48
+ } catch (error) {
49
+ if (error.code !== "ESRCH") {
50
+ throw error;
51
+ }
52
+ }
53
+ if (
54
+ existsSync(join(stateDir, "wake-quota.sqlite")) ||
55
+ existsSync(join(stateDir, "wake-history.json"))
56
+ ) {
57
+ throw new Error("Preflight cannot replace an existing ledger/history. Preserve it.");
58
+ }
59
+ const attempts = [];
60
+ const outcomes = new Set(["accepted", "http_rejected", "transport_unconfirmed"]);
61
+ const audit = readBridgeFile(values.audit, true, 32 * 1024 * 1024).toString("utf8");
62
+ for (const line of audit.split("\n")) {
63
+ if (line.trim() === "") {
64
+ continue;
65
+ }
66
+ /** @type {unknown} */
67
+ const raw = JSON.parse(line);
68
+ if (!isRecord(raw) || typeof raw.timestamp !== "string") {
69
+ throw new Error("Invalid audit record.");
70
+ }
71
+ const entry = raw;
72
+ const timestamp = Date.parse(entry.timestamp);
73
+ if (!Number.isSafeInteger(timestamp)) {
74
+ throw new Error("Audit record has no valid timestamp.");
75
+ }
76
+ if (timestamp < coveredSince || timestamp > stoppedAt) {
77
+ continue;
78
+ }
79
+ if (entry.event === "log_overflow") {
80
+ throw new Error(
81
+ "Audit overflow prevents complete-history migration; use conservative startup.",
82
+ );
83
+ }
84
+ if (
85
+ entry.event === "wake_delivery" &&
86
+ typeof entry.result === "string" &&
87
+ outcomes.has(entry.result)
88
+ ) {
89
+ attempts.push(timestamp);
90
+ }
91
+ }
92
+ if (attempts.length > 10000) {
93
+ throw new Error("Attempt history exceeds bounded migration input.");
94
+ }
95
+ writeBridgeJson(join(stateDir, "wake-history.json"), {
96
+ coveredSince,
97
+ stoppedAt,
98
+ attempts,
99
+ unknownAttempts,
100
+ });
101
+ console.log(
102
+ JSON.stringify({
103
+ result: "prepared",
104
+ recordedAttempts: attempts.length,
105
+ unknownAttempts,
106
+ note: "Start the new helper against this same stateDir; never copy/reset its ledger. Coverage and unknown-attempt bounds remain operator-owned proof.",
107
+ }),
108
+ );
109
+ } catch (error) {
110
+ console.error(error.message);
111
+ process.exitCode = 1;
112
+ }
@@ -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,14 +13,30 @@ 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
+ --id ID Send only: exact message ID (default: random; no deduplication guarantee)
19
+ --passive Send only: mailbox delivery without waking the recipient
18
20
  --reply-to ID Inbound ask ID from inbox, for reply
19
- --timeout-ms MS Ask timeout (100..120000, default: 120000)
21
+ --timeout-ms MS Ask: 100..120000 (default 120000); control: 100..10000 (default 3000)
20
22
  --after CURSOR Inbox cursor from nextCursor (default: 0)
21
- --limit COUNT Inbox page size (1..256, default: 256)
23
+ --wait-ms MS Inbox: wait for new mail, 0..10000 (default: 0)
24
+ --limit COUNT Inbox: 1..256; sessions/transcript: 1..100
22
25
  --name NAME Start/reuse a named idle Pi session under local policy
23
- --session-id ID Stop only a Pi session launched by this identity/helper
26
+ --session-id ID Native ID for resume, stop or cancel
27
+ --target ID_OR_NAME Unique target for resume, stop, cancel or transcript
28
+ --cwd PATH Spawn in an existing locally approved workspace
29
+ --task TEXT Submit an Intercom task after spawn/resume readiness
30
+ --model PROVIDER/ID Exact spawn model; or --provider NAME with --model ID
31
+ --mode cancel|kill Stop mode: owned kill observes exit; foreign kill requests shutdown
32
+ --live --saved Opt into live/saved session metadata
33
+ --q TEXT --offset N Catalog filter and page cursor
34
+ --max-bytes N Transcript UTF-8 text budget (256..131072)
35
+ --cursor CURSOR Opaque cursor from an earlier transcript page
36
+ --group NAME Named group for broadcast/group-send
37
+ --members JSON Group members as a JSON array of IDs/names
38
+ --name-pattern GLOB Dynamic group name selector
39
+ --cwd-pattern GLOB Dynamic group cwd selector (combined with name pattern)
24
40
  -h, --help Show help
25
41
 
26
42
  Examples:
@@ -31,7 +47,10 @@ Examples:
31
47
  node intercom-remote.mjs reply --reply-to QUESTION_ID --message 'Proceed.'
32
48
  node intercom-remote.mjs ack MESSAGE_ID
33
49
  node intercom-remote.mjs start --name planner
34
- node intercom-remote.mjs sessions
50
+ node intercom-remote.mjs spawn --name planner --cwd /project --task 'Review this project.'
51
+ node intercom-remote.mjs sessions --live --saved
52
+ node intercom-remote.mjs resume --name saved-unique-name
53
+ node intercom-remote.mjs transcript --target NATIVE_SESSION_ID
35
54
  node intercom-remote.mjs stop --session-id NATIVE_SESSION_ID
36
55
 
37
56
  Outputs JSON. Exit: 0 success, 1 API/TLS/transport failure, 2 invalid arguments.
@@ -39,25 +58,24 @@ No automatic retries: a lost response does not prove a message was unsent.`);
39
58
  }
40
59
 
41
60
  function allowedOptions(action) {
42
- if (action === "inbox") {
43
- return ["after", "limit"];
44
- }
45
- if (action === "start") {
46
- return ["name"];
47
- }
48
- if (action === "stop") {
49
- return ["session-id"];
50
- }
51
- if (action === "send") {
52
- return ["to", "message"];
53
- }
54
- if (action === "ask") {
55
- return ["to", "message", "timeout-ms"];
56
- }
57
- if (action === "reply") {
58
- return ["reply-to", "message"];
59
- }
60
- return [];
61
+ const fields = {
62
+ inbox: ["after", "limit", "wait-ms"],
63
+ start: ["name"],
64
+ spawn: ["name", "cwd", "task", "provider", "model"],
65
+ resume: ["session-id", "name", "target", "task"],
66
+ stop: ["session-id", "target", "to", "mode", "timeout-ms"],
67
+ cancel: ["session-id", "target", "to", "timeout-ms"],
68
+ sessions: ["live", "saved", "q", "offset", "limit"],
69
+ capabilities: ["to", "target", "timeout-ms"],
70
+ transcript: ["to", "target", "timeout-ms", "limit", "max-bytes", "cursor"],
71
+ broadcast: ["message", "group"],
72
+ "group-send": ["message", "group"],
73
+ "group-upsert": ["name", "members", "name-pattern", "cwd-pattern"],
74
+ send: ["to", "message", "passive", "id"],
75
+ ask: ["to", "message", "timeout-ms"],
76
+ reply: ["reply-to", "message"],
77
+ };
78
+ return fields[action] ?? [];
61
79
  }
62
80
 
63
81
  function requireMessage(recipient, message) {
@@ -71,15 +89,101 @@ function requireMessage(recipient, message) {
71
89
  }
72
90
  }
73
91
 
92
+ function sendId(id) {
93
+ // Match the bridge's label bounds without changing the caller's ID.
94
+ // oxlint-disable-next-line no-control-regex
95
+ const controls = /[\u0000-\u001f\u007f-\u009f]/;
96
+ if (id !== undefined && (id.trim() === "" || id.length > 512 || controls.test(id))) {
97
+ throw new Error("Invalid message ID.");
98
+ }
99
+ return id;
100
+ }
101
+
102
+ function resumeBody(options) {
103
+ if (["name", "session-id", "target"].filter((key) => options[key] !== undefined).length !== 1) {
104
+ throw new Error("Resume requires exactly one --name, --session-id or --target.");
105
+ }
106
+ return {
107
+ name: options.name,
108
+ sessionId: options["session-id"],
109
+ target: options.target,
110
+ task: options.task,
111
+ };
112
+ }
113
+ function stopBody(action, options) {
114
+ if (["session-id", "target", "to"].filter((key) => options[key] !== undefined).length !== 1) {
115
+ throw new Error("Choose exactly one --session-id, --target or --to.");
116
+ }
117
+ return {
118
+ target: options["session-id"] ?? options.target ?? options.to,
119
+ mode: action === "stop" ? options.mode : undefined,
120
+ timeoutMs: controlTimeout(options),
121
+ };
122
+ }
74
123
  function launchBody(action, options) {
75
- const value = action === "start" ? options.name : options["session-id"];
76
- if (typeof value !== "string" || value.trim() === "") {
77
- throw new Error(action === "start" ? "--name is required." : "--session-id is required.");
124
+ if (action === "resume") {
125
+ return resumeBody(options);
126
+ }
127
+ if (action === "stop" || action === "cancel") {
128
+ return stopBody(action, options);
129
+ }
130
+ if (typeof options.name !== "string" || options.name.trim() === "") {
131
+ throw new Error("--name is required.");
78
132
  }
79
- return action === "start" ? { name: value } : { sessionId: value };
133
+ return action === "start"
134
+ ? { name: options.name }
135
+ : {
136
+ name: options.name,
137
+ cwd: options.cwd,
138
+ task: options.task,
139
+ provider: options.provider,
140
+ model: options.model,
141
+ };
142
+ }
143
+ function controlTimeout(options) {
144
+ const timeoutMs = options["timeout-ms"] === undefined ? 3000 : Number(options["timeout-ms"]);
145
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 10000) {
146
+ throw new Error("Control --timeout-ms must be 100..10000.");
147
+ }
148
+ return timeoutMs;
149
+ }
150
+ function extraBody(action, options) {
151
+ if (["capabilities", "transcript"].includes(action)) {
152
+ if (["to", "target"].filter((key) => options[key] !== undefined).length !== 1) {
153
+ throw new Error("Choose exactly one --to or --target.");
154
+ }
155
+ return {
156
+ to: options.to ?? options.target,
157
+ timeoutMs: controlTimeout(options),
158
+ ...(action === "transcript" ? transcriptBody(options) : {}),
159
+ };
160
+ }
161
+ if (action === "group-upsert") {
162
+ return {
163
+ name: options.name,
164
+ members: options.members === undefined ? undefined : JSON.parse(options.members),
165
+ namePattern: options["name-pattern"],
166
+ cwdPattern: options["cwd-pattern"],
167
+ };
168
+ }
169
+ if (action === "broadcast" || action === "group-send") {
170
+ requireMessage(action === "group-send" ? options.group : "all", options.message);
171
+ return { message: options.message, group: options.group };
172
+ }
173
+ }
174
+ function transcriptBody(options) {
175
+ return {
176
+ limit: options.limit === undefined ? undefined : Number(options.limit),
177
+ maxBytes: options["max-bytes"] === undefined ? undefined : Number(options["max-bytes"]),
178
+ cursor: options.cursor,
179
+ };
80
180
  }
81
181
  function requestBody(action, options, ids, timeout) {
82
- if (action === "start" || action === "stop") {
182
+ const extra = extraBody(action, options);
183
+ if (extra !== undefined) {
184
+ return extra;
185
+ }
186
+ if (["start", "spawn", "resume", "stop", "cancel"].includes(action)) {
83
187
  return launchBody(action, options);
84
188
  }
85
189
  if (["send", "ask", "reply"].includes(action)) {
@@ -91,7 +195,9 @@ function requestBody(action, options, ids, timeout) {
91
195
  return {
92
196
  to: recipient,
93
197
  message: options.message,
198
+ messageId: sendId(options.id),
94
199
  ...(action === "ask" ? { timeoutMs: timeout } : {}),
200
+ ...(options.passive === true ? { passive: true } : {}),
95
201
  };
96
202
  }
97
203
  if (action === "ack") {
@@ -128,31 +234,70 @@ function privateKey(path) {
128
234
  function inboxQuery(options) {
129
235
  const after = options.after ?? "0";
130
236
  const limit = options.limit ?? "256";
237
+ const waitMs = options["wait-ms"] ?? "0";
131
238
  if (
132
239
  !/^(0|[1-9]\d{0,15})$/.test(after) ||
133
240
  !Number.isSafeInteger(Number(after)) ||
134
241
  !/^[1-9]\d{0,2}$/.test(limit) ||
135
- Number(limit) > 256
242
+ Number(limit) > 256 ||
243
+ !/^(0|[1-9]\d{0,3}|10000)$/.test(waitMs)
136
244
  ) {
137
- throw new Error("--after must be a nonnegative safe integer cursor; --limit must be 1..256.");
245
+ throw new Error(
246
+ "--after must be a nonnegative safe integer cursor; --limit must be 1..256; --wait-ms must be 0..10000.",
247
+ );
248
+ }
249
+ const query = new URLSearchParams({ after, limit });
250
+ if (options["wait-ms"] !== undefined) {
251
+ query.set("waitMs", waitMs);
138
252
  }
139
- return new URLSearchParams({ after, limit }).toString();
253
+ return query.toString();
254
+ }
255
+
256
+ function requestQuery(action, options) {
257
+ if (action === "inbox") {
258
+ return inboxQuery(options);
259
+ }
260
+ return action === "sessions"
261
+ ? new URLSearchParams(
262
+ Object.entries(options).map(([key, value]) => [key, String(value)]),
263
+ ).toString()
264
+ : "";
140
265
  }
141
266
 
142
267
  let body, action, origin, timeout, query;
143
268
  try {
144
269
  const parsed = parseArgs({
145
270
  allowPositionals: true,
271
+ tokens: true,
146
272
  options: {
147
273
  help: { type: "boolean", short: "h" },
148
274
  to: { type: "string" },
149
275
  message: { type: "string" },
276
+ id: { type: "string" },
277
+ passive: { type: "boolean" },
150
278
  "reply-to": { type: "string" },
151
279
  "timeout-ms": { type: "string" },
152
280
  after: { type: "string" },
281
+ "wait-ms": { type: "string" },
153
282
  limit: { type: "string" },
154
283
  name: { type: "string" },
155
284
  "session-id": { type: "string" },
285
+ cwd: { type: "string" },
286
+ target: { type: "string" },
287
+ mode: { type: "string" },
288
+ provider: { type: "string" },
289
+ model: { type: "string" },
290
+ task: { type: "string" },
291
+ live: { type: "boolean" },
292
+ saved: { type: "boolean" },
293
+ q: { type: "string" },
294
+ offset: { type: "string" },
295
+ group: { type: "string" },
296
+ members: { type: "string" },
297
+ "name-pattern": { type: "string" },
298
+ "cwd-pattern": { type: "string" },
299
+ "max-bytes": { type: "string" },
300
+ cursor: { type: "string" },
156
301
  },
157
302
  });
158
303
  if (parsed.values.help) {
@@ -160,6 +305,13 @@ try {
160
305
  process.exit(0);
161
306
  }
162
307
  const options = parsed.values;
308
+ for (const name of ["wait-ms", "id"]) {
309
+ if (
310
+ parsed.tokens.filter((token) => token.kind === "option" && token.name === name).length > 1
311
+ ) {
312
+ throw new Error(`--${name} must not be repeated.`);
313
+ }
314
+ }
163
315
  [action] = parsed.positionals;
164
316
  if (
165
317
  ![
@@ -171,8 +323,17 @@ try {
171
323
  "reply",
172
324
  "ack",
173
325
  "start",
326
+ "spawn",
327
+ "resume",
174
328
  "sessions",
175
329
  "stop",
330
+ "cancel",
331
+ "capabilities",
332
+ "transcript",
333
+ "groups",
334
+ "group-upsert",
335
+ "broadcast",
336
+ "group-send",
176
337
  ].includes(action)
177
338
  ) {
178
339
  throw new Error("Choose an action; use --help.");
@@ -189,7 +350,7 @@ try {
189
350
  throw new Error("--timeout-ms must be 100..120000.");
190
351
  }
191
352
  body = requestBody(action, options, parsed.positionals.slice(1), timeout);
192
- query = action === "inbox" ? inboxQuery(options) : "";
353
+ query = requestQuery(action, options);
193
354
  origin = new URL(process.env.INTERCOM_BRIDGE_URL || "https://127.0.0.1:9443");
194
355
  if (
195
356
  origin.protocol !== "https:" ||
@@ -216,10 +377,11 @@ try {
216
377
 
217
378
  try {
218
379
  const files = tlsFiles();
219
- origin.pathname = `/v1/${action}`;
380
+ const aliases = { "group-upsert": "groups", "group-send": "broadcast" };
381
+ origin.pathname = `/v1/${aliases[action] ?? action}`;
220
382
  origin.search = query;
221
383
  const payload = JSON.stringify(body);
222
- const get = ["list", "inbox", "sessions"].includes(action);
384
+ const get = ["list", "inbox", "sessions", "groups"].includes(action);
223
385
  const result = await new Promise((resolve, reject) => {
224
386
  const req = https.request(
225
387
  origin,
@@ -263,14 +425,21 @@ try {
263
425
  () => {
264
426
  req.destroy(new Error("Bridge request deadline exceeded; delivery may be unknown."));
265
427
  },
266
- action === "start" ? 90000 : (action === "ask" ? timeout : 0) + 15000,
428
+ ["start", "spawn", "resume", "transcript"].includes(action)
429
+ ? 90000
430
+ : (action === "ask" ? timeout : 0) + 15000,
267
431
  );
268
432
  req.once("close", () => clearTimeout(timer));
269
433
  req.once("error", reject);
270
434
  req.end(get ? undefined : payload);
271
435
  });
272
436
  console.log(JSON.stringify(result.value, null, 2));
273
- if (result.status < 200 || result.status >= 300 || result.value?.ok !== true) {
437
+ if (
438
+ result.status < 200 ||
439
+ result.status >= 300 ||
440
+ result.value?.ok !== true ||
441
+ unsuccessful(result.value)
442
+ ) {
274
443
  process.exitCode = 1;
275
444
  }
276
445
  } catch (error) {
@@ -286,3 +455,9 @@ try {
286
455
  );
287
456
  process.exitCode = 1;
288
457
  }
458
+ function unsuccessful(value) {
459
+ return (
460
+ value?.status === "partial" ||
461
+ ["denied", "failed", "unsupported"].includes(value?.native?.status)
462
+ );
463
+ }
@@ -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
+ }