@snowyroad/arp 0.1.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 (3) hide show
  1. package/README.md +112 -0
  2. package/dist/cli.js +1878 -0
  3. package/package.json +54 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1878 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/invite.ts
4
+ var REQUIRED_FIELDS = ["relayUrl", "code"];
5
+ function decodeInvite(code) {
6
+ const trimmed = (code ?? "").trim();
7
+ if (trimmed === "") throw new Error("Invalid invite: empty");
8
+ const json = Buffer.from(trimmed, "base64url").toString("utf8");
9
+ let parsed;
10
+ try {
11
+ parsed = JSON.parse(json);
12
+ } catch {
13
+ throw new Error("Invalid invite: payload is not valid JSON");
14
+ }
15
+ if (typeof parsed !== "object" || parsed === null) {
16
+ throw new Error("Invalid invite: payload is not an object");
17
+ }
18
+ const p = parsed;
19
+ for (const field of REQUIRED_FIELDS) {
20
+ const v = p[field];
21
+ if (typeof v !== "string" || v.trim() === "") {
22
+ throw new Error(`Invalid invite: missing or empty field "${field}"`);
23
+ }
24
+ }
25
+ const relayUrl = p.relayUrl.trim();
26
+ if (!/^wss?:\/\//.test(relayUrl)) {
27
+ throw new Error("Invalid invite: relayUrl must start with ws:// or wss://");
28
+ }
29
+ return {
30
+ relayUrl,
31
+ code: p.code.trim()
32
+ };
33
+ }
34
+
35
+ // src/redeem.ts
36
+ async function redeemInvite(relayHttpUrl, code) {
37
+ const res = await fetch(`${relayHttpUrl.replace(/\/$/, "")}/invites/redeem`, {
38
+ method: "POST",
39
+ headers: { "content-type": "application/json" },
40
+ body: JSON.stringify({ code })
41
+ });
42
+ if (res.status === 410) {
43
+ throw new Error("This invite has expired or been revoked. Ask an admin to mint a new one.");
44
+ }
45
+ if (!res.ok) {
46
+ throw new Error(`Invite redemption failed (HTTP ${res.status}).`);
47
+ }
48
+ const data = await res.json();
49
+ if (!data.ok || !data.token || !data.agentId || !data.agentUuid || !data.channelId) {
50
+ throw new Error("Invite redemption returned an incomplete response.");
51
+ }
52
+ return {
53
+ token: data.token,
54
+ agentId: data.agentId,
55
+ agentName: data.agentName ?? data.agentId,
56
+ agentUuid: data.agentUuid,
57
+ channelId: data.channelId
58
+ };
59
+ }
60
+
61
+ // src/config.ts
62
+ var DEFAULT_MODEL = "claude-opus-4-8";
63
+ var DEFAULT_AGENT_MODE = "acp";
64
+ var DEFAULT_AGENT = "claude-code";
65
+ var VALID_AGENT_MODES = ["acp", "generic"];
66
+ var VALID_AGENTS = ["claude-code", "codex", "gemini", "grok", "cursor"];
67
+ function positiveIntEnv(v, dflt) {
68
+ const n = v == null ? NaN : Number(v);
69
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : dflt;
70
+ }
71
+ function required(env, key) {
72
+ const v = env[key];
73
+ if (!v || v.trim() === "") throw new Error(`Missing required env var: ${key}`);
74
+ return v.trim();
75
+ }
76
+ function resolveAgentSelection(env) {
77
+ const agentMode = env.ARP_AGENT_MODE?.trim() || DEFAULT_AGENT_MODE;
78
+ if (!VALID_AGENT_MODES.includes(agentMode)) {
79
+ throw new Error(
80
+ `Invalid ARP_AGENT_MODE: ${agentMode}. Expected one of: ${VALID_AGENT_MODES.join(", ")}`
81
+ );
82
+ }
83
+ const agent = env.ARP_AGENT?.trim() || DEFAULT_AGENT;
84
+ if (!VALID_AGENTS.includes(agent)) {
85
+ throw new Error(
86
+ `Invalid ARP_AGENT: ${agent}. Expected one of: ${VALID_AGENTS.join(", ")}`
87
+ );
88
+ }
89
+ if (agentMode === "generic") required(env, "ANTHROPIC_API_KEY");
90
+ return { agentMode, agent };
91
+ }
92
+ function loadConfig(env) {
93
+ const { agentMode, agent } = resolveAgentSelection(env);
94
+ const relayWsUrl = required(env, "ARP_RELAY_URL");
95
+ if (!/^wss?:\/\//.test(relayWsUrl)) {
96
+ throw new Error(`ARP_RELAY_URL must start with ws:// or wss://, got: ${relayWsUrl}`);
97
+ }
98
+ const relayHttpUrl = relayWsUrl.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://");
99
+ const agentName = required(env, "ARP_AGENT_NAME");
100
+ return {
101
+ relayWsUrl,
102
+ relayHttpUrl,
103
+ token: required(env, "ARP_TOKEN"),
104
+ agentId: required(env, "ARP_AGENT_ID"),
105
+ agentName,
106
+ agentUuid: required(env, "ARP_AGENT_UUID"),
107
+ agentMode,
108
+ agent,
109
+ model: env.ARP_MODEL?.trim() || DEFAULT_MODEL,
110
+ catchUpTtlMs: positiveIntEnv(env.ARP_CATCHUP_TTL_MS, 72e5),
111
+ catchUpMaxMentions: positiveIntEnv(env.ARP_CATCHUP_MAX_MENTIONS, 3)
112
+ };
113
+ }
114
+ async function loadConfigFromInvite(code, env) {
115
+ const { agentMode, agent } = resolveAgentSelection(env);
116
+ const inv = decodeInvite(code);
117
+ const relayWsUrl = inv.relayUrl;
118
+ const relayHttpUrl = relayWsUrl.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://");
119
+ const bundle = await redeemInvite(relayHttpUrl, inv.code);
120
+ return {
121
+ relayWsUrl,
122
+ relayHttpUrl,
123
+ token: bundle.token,
124
+ agentId: bundle.agentId,
125
+ agentName: bundle.agentName,
126
+ agentUuid: bundle.agentUuid,
127
+ agentMode,
128
+ agent,
129
+ model: env.ARP_MODEL?.trim() || DEFAULT_MODEL,
130
+ catchUpTtlMs: positiveIntEnv(env.ARP_CATCHUP_TTL_MS, 72e5),
131
+ catchUpMaxMentions: positiveIntEnv(env.ARP_CATCHUP_MAX_MENTIONS, 3)
132
+ };
133
+ }
134
+ function getFlag(argv, name) {
135
+ for (let i = 0; i < argv.length; i++) {
136
+ const a = argv[i];
137
+ if (a === name) return argv[i + 1] ?? "";
138
+ if (a.startsWith(`${name}=`)) return a.slice(name.length + 1);
139
+ }
140
+ return void 0;
141
+ }
142
+ async function resolveConfig(argv, env) {
143
+ if (argv[0] === "join") {
144
+ const code = argv[1];
145
+ if (!code || code.trim() === "") throw new Error("Missing value for join");
146
+ return loadConfigFromInvite(code.trim(), env);
147
+ }
148
+ const argInvite = getFlag(argv, "--invite");
149
+ if (argInvite !== void 0 && argInvite.trim() === "") {
150
+ throw new Error("Missing value for --invite");
151
+ }
152
+ const invite = (argInvite ?? env.ARP_INVITE)?.trim();
153
+ if (invite) return loadConfigFromInvite(invite, env);
154
+ return loadConfig(env);
155
+ }
156
+ function redactConfig(cfg) {
157
+ const model = cfg.agentMode === "acp" ? `(provider default; ARP_MODEL ignored in acp mode)` : cfg.model;
158
+ return { ...cfg, token: "<redacted>", model };
159
+ }
160
+
161
+ // src/relayClient.ts
162
+ import { randomUUID } from "crypto";
163
+
164
+ // src/card.ts
165
+ function parseCardReply(raw) {
166
+ const candidate = extractJsonObject(raw);
167
+ if (!candidate) return null;
168
+ let obj;
169
+ try {
170
+ obj = JSON.parse(candidate);
171
+ } catch {
172
+ return null;
173
+ }
174
+ if (typeof obj !== "object" || obj === null) return null;
175
+ if (typeof obj.description !== "string") return null;
176
+ const rawSkills = Array.isArray(obj.skills) ? obj.skills : [];
177
+ const skills = [];
178
+ for (const s of rawSkills) {
179
+ if (!s || typeof s.name !== "string") continue;
180
+ skills.push({
181
+ id: typeof s.id === "string" ? s.id : s.name,
182
+ name: s.name,
183
+ description: typeof s.description === "string" ? s.description : "",
184
+ tags: Array.isArray(s.tags) ? s.tags.filter((t) => typeof t === "string") : [],
185
+ ...Array.isArray(s.examples) ? { examples: s.examples.filter((e) => typeof e === "string") } : {}
186
+ });
187
+ }
188
+ return { description: obj.description, skills };
189
+ }
190
+ function extractJsonObject(raw) {
191
+ const start = raw.indexOf("{");
192
+ if (start < 0) return null;
193
+ let depth = 0;
194
+ for (let i = start; i < raw.length; i++) {
195
+ const ch = raw[i];
196
+ if (ch === '"') {
197
+ i++;
198
+ while (i < raw.length && raw[i] !== '"') {
199
+ if (raw[i] === "\\") i++;
200
+ i++;
201
+ }
202
+ continue;
203
+ }
204
+ if (ch === "{") depth++;
205
+ else if (ch === "}") {
206
+ depth--;
207
+ if (depth === 0) return raw.slice(start, i + 1);
208
+ }
209
+ }
210
+ return null;
211
+ }
212
+ function buildPartialCard(agentName, self) {
213
+ return {
214
+ protocolVersion: "0.3.0",
215
+ name: agentName,
216
+ description: self.description,
217
+ capabilities: { streaming: false, pushNotifications: false, stateTransitionHistory: false },
218
+ defaultInputModes: ["text/plain"],
219
+ defaultOutputModes: ["text/plain"],
220
+ skills: self.skills,
221
+ preferredTransport: "JSONRPC",
222
+ additionalInterfaces: [],
223
+ iconUrl: "",
224
+ documentationUrl: "",
225
+ securitySchemes: {},
226
+ security: [],
227
+ supportsAuthenticatedExtendedCard: false,
228
+ signatures: []
229
+ };
230
+ }
231
+ function normalizeRosterEntry(name, memberDescription, card) {
232
+ let description = memberDescription ?? "";
233
+ let skills = [];
234
+ if (card && typeof card === "object") {
235
+ if (typeof card.description === "string" && card.description) description = card.description;
236
+ if (Array.isArray(card.skills)) skills = card.skills.map((s) => s && typeof s.name === "string" ? s.name : "").filter(Boolean);
237
+ }
238
+ return { name, description, skills };
239
+ }
240
+ function assembleRosterFacts(entries, selfName) {
241
+ const peers = entries.filter((e) => e.name !== selfName);
242
+ if (peers.length === 0) return "";
243
+ const lines = peers.map((p) => {
244
+ const desc = p.description ? `: ${p.description}` : "";
245
+ const skills = p.skills.length ? ` [skills: ${p.skills.join(", ")}]` : "";
246
+ return `- ${p.name}${desc}${skills}`;
247
+ });
248
+ return `Also in this channel:
249
+ ${lines.join("\n")}`;
250
+ }
251
+
252
+ // src/channelContext.ts
253
+ function buildChannelContext(input) {
254
+ let out = "";
255
+ if (input.memory.trim()) {
256
+ out += `## Channel Memory (shared context for this channel)
257
+ ${input.memory}
258
+ ---
259
+
260
+ `;
261
+ }
262
+ if (input.pins.length > 0) {
263
+ const sections = input.pins.map((p) => `### \u{1F4CC} ${p.label}
264
+ ${p.content}`);
265
+ out += `## Pinned Files (from GitHub)
266
+ ${sections.join("\n\n")}
267
+ ---
268
+
269
+ `;
270
+ }
271
+ if (input.topics.length > 0) {
272
+ const lines = input.topics.map((t) => {
273
+ const count = t.count != null ? ` (${t.count} messages)` : "";
274
+ return `- ${t.title}${count}`;
275
+ });
276
+ out += `## Channel Topics
277
+ ${lines.join("\n")}
278
+ ---
279
+
280
+ `;
281
+ }
282
+ return out;
283
+ }
284
+
285
+ // src/catchup.ts
286
+ function isAddressed(content, agentName) {
287
+ const name = agentName.trim();
288
+ if (!name) return false;
289
+ const c = content.toLowerCase();
290
+ const forms = /* @__PURE__ */ new Set([name.toLowerCase(), name.toLowerCase().replace(/\s+/g, "_")]);
291
+ for (const f of forms) if (c.includes("@" + f)) return true;
292
+ return false;
293
+ }
294
+ function classifyCatchUp(messages, agentName, nowMs, opts) {
295
+ const inWindow = messages.filter((m) => {
296
+ const t = Date.parse(m.createdAt);
297
+ return Number.isFinite(t) ? t >= nowMs - opts.ttlMs : true;
298
+ });
299
+ const mentions = inWindow.filter(
300
+ (m) => m.senderName !== agentName && isAddressed(m.content, agentName)
301
+ );
302
+ const capped = mentions.slice(-opts.maxMentions);
303
+ return { context: inWindow, mentions: capped };
304
+ }
305
+
306
+ // src/relayClient.ts
307
+ var HEARTBEAT_MS = 25e3;
308
+ var WATCHDOG_MS = 4e4;
309
+ var STABLE_RESET_MS = 45e3;
310
+ var MAX_BACKOFF_MS = 3e4;
311
+ var AUTH_GRACE_MS = 1500;
312
+ var CATCHUP_WINDOW_MS = 8e3;
313
+ var SEEN_CAP = 5e3;
314
+ var RESUME_MAX_PAGES = 200;
315
+ var FATAL_CLOSE_CODES = /* @__PURE__ */ new Set([
316
+ 4001,
317
+ // auth failed (bad/expired/tampered token)
318
+ 4002,
319
+ // agent not found
320
+ 4003
321
+ // duplicate connection
322
+ ]);
323
+ var RelayClient = class {
324
+ constructor(cfg, deps) {
325
+ this.cfg = cfg;
326
+ this.deps = deps;
327
+ }
328
+ cfg;
329
+ deps;
330
+ ws = null;
331
+ inboundCbs = [];
332
+ rosterCbs = [];
333
+ flowCbs = [];
334
+ heartbeatTimer = null;
335
+ watchdog = null;
336
+ stableTimer = null;
337
+ reconnectTimer = null;
338
+ reconnectAttempts = 0;
339
+ stopped = false;
340
+ seenByChannel = /* @__PURE__ */ new Map();
341
+ cursors = /* @__PURE__ */ new Map();
342
+ connectedAt = 0;
343
+ // ms timestamp of the latest WS open; drives the catch-up window
344
+ graceTimer = null;
345
+ confirmed = false;
346
+ // single-shot: onReady has fired (relay accepted the agent)
347
+ catchUpCbs = [];
348
+ caughtUp = /* @__PURE__ */ new Set();
349
+ // channels caught up this connection
350
+ readyCb = null;
351
+ fatalCb = null;
352
+ removedCb = null;
353
+ addedCb = null;
354
+ onInbound(cb) {
355
+ this.inboundCbs.push(cb);
356
+ }
357
+ /** Subscribe to roster updates; returns an unsubscribe so a per-channel session can drop
358
+ * its subscription on teardown (otherwise handlers accumulate across channel churn). */
359
+ onRoster(cb) {
360
+ this.rosterCbs.push(cb);
361
+ return () => {
362
+ const i = this.rosterCbs.indexOf(cb);
363
+ if (i >= 0) this.rosterCbs.splice(i, 1);
364
+ };
365
+ }
366
+ onFlowSignal(cb) {
367
+ this.flowCbs.push(cb);
368
+ }
369
+ onCatchUp(cb) {
370
+ this.catchUpCbs.push(cb);
371
+ }
372
+ onReady(cb) {
373
+ this.readyCb = cb;
374
+ }
375
+ onFatal(cb) {
376
+ this.fatalCb = cb;
377
+ }
378
+ onRemoved(cb) {
379
+ this.removedCb = cb;
380
+ }
381
+ onAdded(cb) {
382
+ this.addedCb = cb;
383
+ }
384
+ start() {
385
+ this.connect();
386
+ }
387
+ connect() {
388
+ const url = `${this.cfg.relayWsUrl}/ws/agent/${this.cfg.agentId}?token=${this.cfg.token}`;
389
+ const ws = this.deps.wsFactory(url);
390
+ this.ws = ws;
391
+ ws.on("open", () => this.onOpen());
392
+ ws.on("message", (data) => this.onMessage(data.toString()));
393
+ ws.on("close", (code, reason) => this.onClose(code, reason.toString()));
394
+ ws.on("error", () => {
395
+ });
396
+ }
397
+ onOpen() {
398
+ this.caughtUp.clear();
399
+ this.connectedAt = Date.now();
400
+ this.heartbeatTimer = setInterval(() => this.send({ type: "ping" }), HEARTBEAT_MS);
401
+ this.armWatchdog();
402
+ this.stableTimer = setTimeout(() => {
403
+ this.reconnectAttempts = 0;
404
+ }, STABLE_RESET_MS);
405
+ if (this.graceTimer) clearTimeout(this.graceTimer);
406
+ this.graceTimer = setTimeout(() => this.confirmReady(), AUTH_GRACE_MS);
407
+ this.onConnected();
408
+ }
409
+ /** Single-shot: fires onReady at most once, on the FIRST successful connect. */
410
+ confirmReady() {
411
+ if (this.graceTimer) {
412
+ clearTimeout(this.graceTimer);
413
+ this.graceTimer = null;
414
+ }
415
+ if (this.confirmed) return;
416
+ this.confirmed = true;
417
+ this.readyCb?.();
418
+ }
419
+ armWatchdog() {
420
+ if (this.watchdog) clearTimeout(this.watchdog);
421
+ this.watchdog = setTimeout(() => {
422
+ try {
423
+ this.ws?.close();
424
+ } catch {
425
+ }
426
+ }, WATCHDOG_MS);
427
+ }
428
+ onConnected() {
429
+ }
430
+ /** Pull every message after a sequence, paginating on hasMore. Returns them in order.
431
+ * The relay paginates and reports `hasMore` (a full page == limit). Follow the
432
+ * cursor page by page until the server says there is nothing left, advancing
433
+ * afterSeq to the max seq received so far. A defensive page cap prevents an
434
+ * infinite loop if a misbehaving server keeps claiming hasMore. */
435
+ async fetchAfterSeq(channelId, afterSeq) {
436
+ const out = [];
437
+ let cursor = afterSeq;
438
+ for (let page = 0; page < RESUME_MAX_PAGES; page++) {
439
+ const url = `${this.cfg.relayHttpUrl}/channels/${channelId}/messages?afterSeq=${cursor}`;
440
+ let res;
441
+ try {
442
+ res = await this.deps.fetchFn(url, { headers: { Authorization: `Bearer ${this.cfg.token}` } });
443
+ } catch (err) {
444
+ console.warn("[arp-bridge] backfill fetch failed:", String(err));
445
+ return out;
446
+ }
447
+ if (!res.ok) {
448
+ console.warn("[arp-bridge] backfill HTTP", res.status);
449
+ return out;
450
+ }
451
+ const body = await res.json();
452
+ const list = Array.isArray(body) ? body : body.messages ?? [];
453
+ if (list.length === 0) return out;
454
+ let maxSeq = cursor;
455
+ for (const m of list) {
456
+ const seq = Number(m.seq ?? 0);
457
+ if (seq > maxSeq) maxSeq = seq;
458
+ out.push({
459
+ id: String(m.id ?? ""),
460
+ seq,
461
+ channelId,
462
+ content: String(m.content ?? ""),
463
+ senderId: String(m.agentId ?? ""),
464
+ senderName: String(m.agentName ?? ""),
465
+ senderType: String(m.messageType ?? m.type ?? ""),
466
+ // relay live/resume key is messageType; history path uses type
467
+ createdAt: String(m.createdAt ?? ""),
468
+ isHistory: false
469
+ // shape parity with live messages; the caller decides how to handle them
470
+ });
471
+ }
472
+ if (!body.hasMore) return out;
473
+ cursor = maxSeq;
474
+ }
475
+ console.warn("[arp-bridge] backfill hit page cap", RESUME_MAX_PAGES);
476
+ return out;
477
+ }
478
+ /** Mid-session gap-fill: replay missed messages live (each dedupes via emitInbound). */
479
+ async resumeAfterSeq(channelId, afterSeq) {
480
+ for (const m of await this.fetchAfterSeq(channelId, afterSeq)) this.emitInbound(m);
481
+ }
482
+ /** Offline-rejoin catch-up: classify the missed window and hand it to onCatchUp once.
483
+ * Does NOT route through emitInbound/onInbound to avoid per-message passive submits. */
484
+ async catchUp(channelId, afterSeq) {
485
+ const missed = await this.fetchAfterSeq(channelId, afterSeq);
486
+ if (missed.length === 0) return;
487
+ for (const m of missed) if (m.id) this.markSeen(channelId, m.id);
488
+ this.bumpCursor(channelId, missed[missed.length - 1].seq);
489
+ const result = classifyCatchUp(missed, this.cfg.agentName, Date.now(), {
490
+ ttlMs: this.cfg.catchUpTtlMs,
491
+ maxMentions: this.cfg.catchUpMaxMentions
492
+ });
493
+ this.catchUpCbs.forEach((cb) => cb(channelId, result));
494
+ }
495
+ onClose(code, reason) {
496
+ this.clearTimers();
497
+ if (this.stopped) return;
498
+ if (FATAL_CLOSE_CODES.has(code)) {
499
+ this.stopped = true;
500
+ this.fatalCb?.(code, reason);
501
+ return;
502
+ }
503
+ const delay3 = Math.min(1e3 * 2 ** this.reconnectAttempts, MAX_BACKOFF_MS);
504
+ this.reconnectAttempts++;
505
+ this.reconnectTimer = setTimeout(() => {
506
+ if (!this.stopped) this.connect();
507
+ }, delay3);
508
+ }
509
+ clearTimers() {
510
+ if (this.heartbeatTimer) {
511
+ clearInterval(this.heartbeatTimer);
512
+ this.heartbeatTimer = null;
513
+ }
514
+ if (this.watchdog) {
515
+ clearTimeout(this.watchdog);
516
+ this.watchdog = null;
517
+ }
518
+ if (this.stableTimer) {
519
+ clearTimeout(this.stableTimer);
520
+ this.stableTimer = null;
521
+ }
522
+ if (this.reconnectTimer) {
523
+ clearTimeout(this.reconnectTimer);
524
+ this.reconnectTimer = null;
525
+ }
526
+ if (this.graceTimer) {
527
+ clearTimeout(this.graceTimer);
528
+ this.graceTimer = null;
529
+ }
530
+ }
531
+ stop() {
532
+ this.stopped = true;
533
+ this.clearTimers();
534
+ try {
535
+ this.ws?.removeAllListeners?.();
536
+ } catch {
537
+ }
538
+ try {
539
+ this.ws?.close();
540
+ } catch {
541
+ }
542
+ }
543
+ onMessage(raw) {
544
+ this.armWatchdog();
545
+ if (!this.confirmed) this.confirmReady();
546
+ let msg;
547
+ try {
548
+ msg = JSON.parse(raw);
549
+ } catch {
550
+ return;
551
+ }
552
+ switch (msg?.type) {
553
+ case "heartbeat":
554
+ this.send({ type: "heartbeat_ack", clientType: "arp-bridge" });
555
+ break;
556
+ case "channel_message":
557
+ this.handleChannelMessage(msg);
558
+ break;
559
+ case "token_refresh":
560
+ if (typeof msg.token === "string" && msg.token.length > 0) {
561
+ this.cfg.token = msg.token;
562
+ console.log("[arp-bridge] token refreshed");
563
+ }
564
+ break;
565
+ case "removed": {
566
+ const ch = String(msg.channelId ?? "");
567
+ if (!ch) break;
568
+ this.cursors.delete(ch);
569
+ this.seenByChannel.delete(ch);
570
+ this.caughtUp.delete(ch);
571
+ this.removedCb?.(ch);
572
+ break;
573
+ }
574
+ case "added": {
575
+ const ch = String(msg.channelId ?? "");
576
+ if (!ch) break;
577
+ this.addedCb?.(ch);
578
+ break;
579
+ }
580
+ case "roster_update": {
581
+ const m = msg.member ?? {};
582
+ if (typeof m.name === "string" && m.name.length > 0) {
583
+ const entry = normalizeRosterEntry(m.name, m.description, m.card);
584
+ this.rosterCbs.forEach((cb) => cb(entry));
585
+ }
586
+ break;
587
+ }
588
+ case "turn_notification":
589
+ this.handleFlowSignal("turn", msg);
590
+ break;
591
+ case "synthesis_request":
592
+ this.handleFlowSignal("synthesis", msg);
593
+ break;
594
+ case "direction_request":
595
+ this.handleFlowSignal("direction", msg);
596
+ break;
597
+ case "hello": {
598
+ const resume = msg?.resume;
599
+ if (resume && typeof resume === "object") {
600
+ for (const [ch, seqRaw] of Object.entries(resume)) {
601
+ const seq = Number(seqRaw);
602
+ if (Number.isFinite(seq) && seq > this.cursorOf(ch)) this.cursors.set(ch, seq);
603
+ const floor = this.cursorOf(ch);
604
+ if (!this.caughtUp.has(ch) && floor > 0) {
605
+ this.caughtUp.add(ch);
606
+ void this.catchUp(ch, floor);
607
+ }
608
+ }
609
+ }
610
+ break;
611
+ }
612
+ default:
613
+ break;
614
+ }
615
+ }
616
+ handleChannelMessage(msg) {
617
+ const channelId = String(msg.channelId ?? "");
618
+ if (!channelId) {
619
+ console.warn("[arp-bridge] channel_message with no channelId; dropped");
620
+ return;
621
+ }
622
+ const m = msg.message ?? {};
623
+ const inbound = {
624
+ id: String(m.id ?? ""),
625
+ seq: Number(m.seq ?? 0),
626
+ channelId,
627
+ content: String(m.content ?? ""),
628
+ senderId: String(m.agentId ?? ""),
629
+ senderName: String(m.agentName ?? ""),
630
+ senderType: String(m.messageType ?? m.type ?? ""),
631
+ // relay live/resume key is messageType; history path uses type
632
+ createdAt: String(m.createdAt ?? ""),
633
+ isHistory: Boolean(msg.isHistory)
634
+ };
635
+ const gapDetected = !inbound.isHistory && this.cursorOf(channelId) > 0 && inbound.seq > this.cursorOf(channelId) + 1;
636
+ const gapFrom = this.cursorOf(channelId);
637
+ this.emitInbound(inbound);
638
+ if (gapDetected) void this.resumeAfterSeq(channelId, gapFrom);
639
+ }
640
+ /** Normalize a turn_notification / synthesis_request / direction_request into a FlowSignal and emit it. */
641
+ handleFlowSignal(kind, msg) {
642
+ const flowId = String(msg.flowId ?? "");
643
+ if (!flowId) return;
644
+ const rawHistory = kind === "synthesis" || kind === "direction" ? msg.flowHistory ?? msg.messages ?? [] : msg.recentMessages ?? [];
645
+ const history = (Array.isArray(rawHistory) ? rawHistory : []).map((e) => ({
646
+ agentName: String(e.agentName ?? ""),
647
+ content: String(e.content ?? ""),
648
+ messageType: e.messageType ?? e.type,
649
+ turnNumber: e.turnNumber,
650
+ createdAt: e.createdAt
651
+ }));
652
+ const signal = {
653
+ kind,
654
+ flowId,
655
+ channelId: String(msg.channelId ?? ""),
656
+ topic: String(msg.topic ?? ""),
657
+ rolePrompt: typeof msg.rolePrompt === "string" ? msg.rolePrompt : void 0,
658
+ contextPrompt: typeof msg.contextPrompt === "string" ? msg.contextPrompt : void 0,
659
+ synthesisPrompt: typeof msg.synthesisPrompt === "string" ? msg.synthesisPrompt : void 0,
660
+ candidates: Array.isArray(msg.candidates) ? msg.candidates : void 0,
661
+ history
662
+ };
663
+ this.flowCbs.forEach((cb) => cb(signal));
664
+ }
665
+ cursorOf(ch) {
666
+ return this.cursors.get(ch) ?? 0;
667
+ }
668
+ bumpCursor(ch, seq) {
669
+ if (seq > this.cursorOf(ch)) this.cursors.set(ch, seq);
670
+ }
671
+ seenSet(ch) {
672
+ let s = this.seenByChannel.get(ch);
673
+ if (!s) {
674
+ s = /* @__PURE__ */ new Set();
675
+ this.seenByChannel.set(ch, s);
676
+ }
677
+ return s;
678
+ }
679
+ /** Record an id in a channel's dedupe set. Evicts the oldest entry FIFO when it exceeds SEEN_CAP. */
680
+ markSeen(ch, id) {
681
+ const s = this.seenSet(ch);
682
+ s.add(id);
683
+ if (s.size > SEEN_CAP) {
684
+ const oldest = s.values().next().value;
685
+ if (oldest !== void 0) s.delete(oldest);
686
+ }
687
+ }
688
+ emitInbound(m) {
689
+ const s = this.seenSet(m.channelId);
690
+ if (m.id && s.has(m.id)) return;
691
+ if (m.id) this.markSeen(m.channelId, m.id);
692
+ this.bumpCursor(m.channelId, m.seq);
693
+ this.inboundCbs.forEach((cb) => cb(m));
694
+ }
695
+ send(obj) {
696
+ if (this.ws && this.ws.readyState === 1) this.ws.send(JSON.stringify(obj));
697
+ }
698
+ /** Emit a best-effort activity lease signal over the agent WS. No-op if the socket
699
+ * is closed (send() guards readyState). A "start" within CATCHUP_WINDOW_MS of (re)connect
700
+ * is labeled "catching_up" — the agent is re-reading messages it missed while offline,
701
+ * not freshly thinking. Otherwise "thinking". Relay broadcasts activity_changed to viewers. */
702
+ sendActivity(channelId, state) {
703
+ const catchingUp = state === "start" && this.connectedAt > 0 && Date.now() - this.connectedAt < CATCHUP_WINDOW_MS;
704
+ this.send({
705
+ type: state === "start" ? "activity_start" : "activity_stop",
706
+ channelId,
707
+ activity: catchingUp ? "catching_up" : "thinking"
708
+ });
709
+ }
710
+ /** Publish this agent's partial A2A card; the relay fills url/version/provider. */
711
+ async putAgentCard(card) {
712
+ const url = `${this.cfg.relayHttpUrl}/agents/me/agent-card`;
713
+ try {
714
+ const res = await this.deps.fetchFn(url, {
715
+ method: "PUT",
716
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.cfg.token}` },
717
+ body: JSON.stringify(card)
718
+ });
719
+ if (!res.ok) console.warn("[arp-bridge] put card HTTP", res.status);
720
+ } catch (err) {
721
+ console.warn("[arp-bridge] put card failed:", String(err));
722
+ }
723
+ }
724
+ /** Fetch the channel roster and return normalized bot entries (with cards). */
725
+ async fetchRoster(channelId) {
726
+ const url = `${this.cfg.relayHttpUrl}/channels/${channelId}`;
727
+ try {
728
+ const res = await this.deps.fetchFn(url, { headers: { Authorization: `Bearer ${this.cfg.token}` } });
729
+ if (!res.ok) {
730
+ console.warn("[arp-bridge] roster HTTP", res.status);
731
+ return [];
732
+ }
733
+ const body = await res.json();
734
+ const members = body?.channel?.members ?? [];
735
+ return members.filter((m) => m?.type === "bot" && typeof m.id === "string").map((m) => normalizeRosterEntry(m.id, m.description, m.card));
736
+ } catch (err) {
737
+ console.warn("[arp-bridge] roster fetch failed:", String(err));
738
+ return [];
739
+ }
740
+ }
741
+ async postMessage(channelId, content) {
742
+ const url = `${this.cfg.relayHttpUrl}/channels/${channelId}/messages`;
743
+ const body = JSON.stringify({
744
+ id: randomUUID(),
745
+ // client-generated -> server idempotent on retry
746
+ content,
747
+ agentId: this.cfg.agentUuid,
748
+ agentName: this.cfg.agentName,
749
+ messageType: "agent"
750
+ });
751
+ try {
752
+ const res = await this.deps.fetchFn(url, {
753
+ method: "POST",
754
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.cfg.token}` },
755
+ body
756
+ });
757
+ if (!res.ok) {
758
+ console.warn("[arp-bridge] post HTTP", res.status);
759
+ }
760
+ } catch (err) {
761
+ console.warn("[arp-bridge] post failed:", String(err));
762
+ }
763
+ }
764
+ /** Post a bounded-flow reply (turn or synthesis) to the flow-scoped endpoint.
765
+ * agentId MUST be the agent NAME — the relay's flow gate resolves turn ownership and
766
+ * synthesis role via resolveAgentUUID (a name lookup); a UUID resolves to uuid.Nil -> 403. */
767
+ async postFlowMessage(channelId, flowId, content) {
768
+ const url = `${this.cfg.relayHttpUrl}/channels/${channelId}/flows/${flowId}/messages`;
769
+ const body = JSON.stringify({
770
+ id: randomUUID(),
771
+ content,
772
+ // The relay's flow gate resolves turn ownership + synthesis role by NAME
773
+ // (resolveAgentUUID is a name lookup), so the flow reply must carry the agent name.
774
+ agentId: this.cfg.agentName,
775
+ agentName: this.cfg.agentName
776
+ });
777
+ try {
778
+ const res = await this.deps.fetchFn(url, {
779
+ method: "POST",
780
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.cfg.token}` },
781
+ body
782
+ });
783
+ if (!res.ok) console.warn("[arp-bridge] flow post HTTP", res.status);
784
+ } catch (err) {
785
+ console.warn("[arp-bridge] flow post failed:", String(err));
786
+ }
787
+ }
788
+ /** Channel memory text ("" if none or on error — never throws). */
789
+ async fetchChannelMemory(channelId) {
790
+ const url = `${this.cfg.relayHttpUrl}/channels/${channelId}/memory`;
791
+ try {
792
+ const res = await this.deps.fetchFn(url, { headers: { Authorization: `Bearer ${this.cfg.token}` } });
793
+ if (!res.ok) {
794
+ console.warn("[arp-bridge] memory HTTP", res.status);
795
+ return "";
796
+ }
797
+ const data = await res.json();
798
+ return typeof data?.content === "string" ? data.content : "";
799
+ } catch (err) {
800
+ console.warn("[arp-bridge] memory fetch failed:", String(err));
801
+ return "";
802
+ }
803
+ }
804
+ /** Channel topics with message counts ([] if none or on error). The relay returns
805
+ * topics with a `title` plus a separate `messageCounts` map keyed by topic id. */
806
+ async fetchChannelTopics(channelId) {
807
+ const url = `${this.cfg.relayHttpUrl}/channels/${channelId}/topics`;
808
+ try {
809
+ const res = await this.deps.fetchFn(url, { headers: { Authorization: `Bearer ${this.cfg.token}` } });
810
+ if (!res.ok) {
811
+ console.warn("[arp-bridge] topics HTTP", res.status);
812
+ return [];
813
+ }
814
+ const data = await res.json();
815
+ const topics = data?.topics ?? [];
816
+ const counts = data?.messageCounts ?? {};
817
+ return topics.filter((t) => typeof t?.title === "string").map((t) => ({ title: t.title, count: typeof counts[t.id] === "number" ? counts[t.id] : null }));
818
+ } catch (err) {
819
+ console.warn("[arp-bridge] topics fetch failed:", String(err));
820
+ return [];
821
+ }
822
+ }
823
+ /** Pinned files marked inject_context=true, labeled with cached content ([] otherwise). */
824
+ async fetchPinnedContext(channelId) {
825
+ const url = `${this.cfg.relayHttpUrl}/channels/${channelId}/pins`;
826
+ try {
827
+ const res = await this.deps.fetchFn(url, { headers: { Authorization: `Bearer ${this.cfg.token}` } });
828
+ if (!res.ok) {
829
+ console.warn("[arp-bridge] pins HTTP", res.status);
830
+ return [];
831
+ }
832
+ const data = await res.json();
833
+ const pins = data?.pins ?? [];
834
+ return pins.filter((p) => p?.injectContext && typeof p.cachedContent === "string" && p.cachedContent.trim()).map((p) => ({ label: p.displayName || `${p.repoUrl ?? ""}/${p.filePath ?? ""}`, content: p.cachedContent }));
835
+ } catch (err) {
836
+ console.warn("[arp-bridge] pins fetch failed:", String(err));
837
+ return [];
838
+ }
839
+ }
840
+ /** Assemble the situational channel-context block (memory + pinned files + topics) for a
841
+ * passive message. Parallel fetch with per-source graceful degradation (each fetcher
842
+ * swallows its own errors). Returns "" when there is nothing to inject. */
843
+ async fetchChannelContext(channelId) {
844
+ const [memory, topics, pins] = await Promise.all([
845
+ this.fetchChannelMemory(channelId),
846
+ this.fetchChannelTopics(channelId),
847
+ this.fetchPinnedContext(channelId)
848
+ ]);
849
+ return buildChannelContext({ memory, topics, pins });
850
+ }
851
+ /** Fetch a flow's transcript (used to backfill a minimal turn_notification). [] on error. */
852
+ async fetchFlowMessages(channelId, flowId) {
853
+ const url = `${this.cfg.relayHttpUrl}/channels/${channelId}/flows/${flowId}/messages`;
854
+ try {
855
+ const res = await this.deps.fetchFn(url, { headers: { Authorization: `Bearer ${this.cfg.token}` } });
856
+ if (!res.ok) {
857
+ console.warn("[arp-bridge] flow messages HTTP", res.status);
858
+ return [];
859
+ }
860
+ const data = await res.json();
861
+ const list = Array.isArray(data) ? data : data.messages ?? [];
862
+ return list.map((e) => ({
863
+ agentName: String(e.agentName ?? ""),
864
+ content: String(e.content ?? ""),
865
+ messageType: e.messageType ?? e.type,
866
+ turnNumber: e.turnNumber,
867
+ createdAt: e.createdAt
868
+ }));
869
+ } catch (err) {
870
+ console.warn("[arp-bridge] flow messages failed:", String(err));
871
+ return [];
872
+ }
873
+ }
874
+ };
875
+
876
+ // src/flow.ts
877
+ function renderFlowHistory(entries) {
878
+ if (entries.length === 0) return "";
879
+ const lines = entries.map((e) => `${e.agentName || "someone"}: ${e.content}`);
880
+ return `DISCUSSION HISTORY:
881
+ ${lines.join("\n")}
882
+
883
+ `;
884
+ }
885
+ function buildFlowPrompt(signal, agentName, channelId) {
886
+ if (signal.kind === "direction") {
887
+ const candidates = (signal.candidates ?? []).join(", ");
888
+ const history2 = renderFlowHistory(signal.history);
889
+ const hasHistory = history2 !== "";
890
+ const preamble = hasHistory ? [``, history2.trimEnd(), ``, `Read the conversation above and decide who should speak next.`] : [``, `No turns have been taken yet \u2014 choose who should speak FIRST.`];
891
+ return [
892
+ `You are the DIRECTOR of a structured discussion on: ${signal.topic}`,
893
+ ...preamble,
894
+ `Available participants: ${candidates || "(none online)"}.`,
895
+ ``,
896
+ `Reply with ONLY the name of the single participant who should speak next,`,
897
+ `or reply with ONLY the word END to conclude the discussion and move to synthesis.`,
898
+ `Do not add any other text \u2014 just the name, or END.`
899
+ ].filter((line, i, arr) => !(line === "" && arr[i - 1] === "")).join("\n");
900
+ }
901
+ const isSynthesis = signal.kind === "synthesis";
902
+ const header = isSynthesis ? `You are ${agentName} and the TEAM LEAD for this ARP bounded discussion.` : `You are ${agentName} responding in an ARP bounded discussion.`;
903
+ const role = signal.rolePrompt ? `YOUR ROLE: ${signal.rolePrompt}
904
+ ` : "";
905
+ const ctx = signal.contextPrompt ? `CONTEXT: ${signal.contextPrompt}
906
+ ` : "";
907
+ const synth = signal.synthesisPrompt ? `SYNTHESIS INSTRUCTIONS: ${signal.synthesisPrompt}
908
+ ` : "";
909
+ const history = renderFlowHistory(signal.history);
910
+ const closer = isSynthesis ? "Synthesize the discussion above: the key findings, points of agreement, and conclusions. Provide the synthesis now." : "It's your turn. Provide a substantive response to the discussion.";
911
+ return `${header}
912
+ CHANNEL: ${channelId}
913
+ FLOW: ${signal.flowId}
914
+ TOPIC: ${signal.topic}
915
+ ` + role + ctx + synth + "\n" + history + closer;
916
+ }
917
+
918
+ // src/session.ts
919
+ var SILENCE_SENTINEL = "<<silent>>";
920
+ var ChannelSession = class {
921
+ constructor(adapter, onReply, agentName, channelId, flow, fetchContext, beacon) {
922
+ this.adapter = adapter;
923
+ this.onReply = onReply;
924
+ this.agentName = agentName;
925
+ this.channelId = channelId;
926
+ this.flow = flow;
927
+ this.fetchContext = fetchContext;
928
+ this.beacon = beacon;
929
+ }
930
+ adapter;
931
+ onReply;
932
+ agentName;
933
+ channelId;
934
+ flow;
935
+ fetchContext;
936
+ beacon;
937
+ session = null;
938
+ roster = [];
939
+ /** Replace the known peer roster (situational facts surfaced per message). */
940
+ setRoster(entries) {
941
+ this.roster = entries;
942
+ }
943
+ async start(opts) {
944
+ this.session = await this.adapter.start(opts);
945
+ this.session.onTurn((full) => {
946
+ this.beacon?.end();
947
+ if (full.replace(/<<silent>>/gi, "").trim() === "") return;
948
+ this.onReply(full.replace(/^\s*(?:<<silent>>\s*)+/i, "").trim());
949
+ });
950
+ }
951
+ /**
952
+ * Frame one inbound channel message with SITUATIONAL FACTS only (no persona, no tone,
953
+ * no voice). Mirrors the legacy openclaw-arp/src/inbound.ts channel_message case:
954
+ * tell the agent where it is, who is talking, that this is a passive multi-party
955
+ * channel, and how to stay silent. Our relay delivers only channel_message to agents
956
+ * in this slice, so there is a single message-type shape.
957
+ */
958
+ async submit(msg) {
959
+ if (!this.session) throw new Error("ChannelSession not started");
960
+ const who = msg.senderName || msg.senderId || "someone";
961
+ const facts = assembleRosterFacts(this.roster, this.agentName);
962
+ const rosterBlock = facts ? `${facts}
963
+
964
+ ` : "";
965
+ const channelContext = this.fetchContext ? await this.fetchContext() : "";
966
+ const head = channelContext + `You are ${this.agentName} observing a message in ARP channel ${this.channelId}.
967
+ FROM: ${who}
968
+ MESSAGE: ${msg.content}
969
+
970
+ ` + rosterBlock;
971
+ const instructions = isAddressed(msg.content, this.agentName) ? "You were directly addressed (@mentioned), so respond. Output ONLY your channel message itself, concisely. Do NOT include the silence sentinel and do NOT explain whether or why you are responding." : `You received this as a passive channel message. You do NOT need to respond unless it is directly relevant to you. If you have nothing to add, reply with exactly ${SILENCE_SENTINEL} and nothing else. Otherwise output ONLY your channel message, concisely \u2014 do NOT explain whether or why you are responding.`;
972
+ this.beacon?.begin();
973
+ this.session.submit(head + instructions);
974
+ }
975
+ /**
976
+ * Run one bounded-flow turn or synthesis. Frames a MUST-RESPOND prompt (no
977
+ * `<<silent>>`), runs it through the SAME warm session via converseLocal (so the
978
+ * reply is NOT posted to the channel by onTurn), and routes the reply to the flow
979
+ * endpoint. A minimal turn_notification carries no transcript, so we backfill it
980
+ * from the relay first. An empty reply still posts a fallback so the relay-blocked
981
+ * flow advances rather than timing out.
982
+ */
983
+ async runFlowTurn(signal) {
984
+ if (!this.session) throw new Error("ChannelSession not started");
985
+ if (!this.session.converseLocal || !this.flow) {
986
+ console.warn("[arp-bridge] flow turn skipped: warm aside / flow routing unavailable");
987
+ return;
988
+ }
989
+ this.beacon?.begin();
990
+ try {
991
+ let history = signal.history;
992
+ if (history.length === 0) history = await this.flow.fetchHistory(signal.flowId);
993
+ const prompt = buildFlowPrompt({ ...signal, history }, this.agentName, this.channelId);
994
+ const reply = await this.session.converseLocal(prompt);
995
+ await this.flow.postReply(signal.flowId, reply.trim() || "(no response)");
996
+ } finally {
997
+ this.beacon?.end();
998
+ }
999
+ }
1000
+ /**
1001
+ * Talk to the SAME warm agent locally as a private aside. Delegates to the
1002
+ * underlying session's converseLocal (shared warm context, reply NOT posted to the
1003
+ * channel). Throws a clear error in modes that do not support it (generic mode).
1004
+ */
1005
+ async converseLocal(text) {
1006
+ if (!this.session) throw new Error("ChannelSession not started");
1007
+ if (!this.session.converseLocal) {
1008
+ throw new Error("local conversation not supported in this mode");
1009
+ }
1010
+ this.beacon?.begin();
1011
+ try {
1012
+ return await this.session.converseLocal(text);
1013
+ } finally {
1014
+ this.beacon?.end();
1015
+ }
1016
+ }
1017
+ /**
1018
+ * Offline-rejoin catch-up. Absorbs the within-TTL missed window as context; if any missed
1019
+ * @mentions are in scope, responds ONCE (consolidated) to them and posts to the channel.
1020
+ * No mentions -> absorb privately via converseLocal (not posted). Empty window -> no-op.
1021
+ *
1022
+ * Like `submit()`, the mention branch is fire-and-forget: the returned Promise resolves
1023
+ * after dispatching the turn to the agent, not after the reply lands — the reply arrives
1024
+ * later via the `onTurn` handler (and is then forwarded through `onReply`).
1025
+ */
1026
+ async submitCatchUp(result) {
1027
+ if (!this.session) throw new Error("ChannelSession not started");
1028
+ if (result.context.length === 0) return;
1029
+ const transcript = result.context.map((m) => `[${m.createdAt}] ${m.senderName || m.senderId || "someone"}: ${m.content}`).join("\n");
1030
+ if (result.mentions.length === 0) {
1031
+ if (!this.session.converseLocal) return;
1032
+ this.beacon?.begin();
1033
+ try {
1034
+ await this.session.converseLocal(
1035
+ `You just reconnected to ARP channel ${this.channelId} after being away. Here is what you missed (context only, do NOT reply to it):
1036
+ ${transcript}`
1037
+ );
1038
+ } finally {
1039
+ this.beacon?.end();
1040
+ }
1041
+ return;
1042
+ }
1043
+ const channelContext = this.fetchContext ? await this.fetchContext() : "";
1044
+ const addressed = result.mentions.map((m) => `[${m.createdAt}] ${m.senderName || m.senderId || "someone"}: ${m.content}`).join("\n");
1045
+ const head = channelContext + `You are ${this.agentName}. You just reconnected to ARP channel ${this.channelId} after being away. While you were gone, the channel said (context):
1046
+ ${transcript}
1047
+
1048
+ You were directly addressed (@mentioned) in:
1049
+ ${addressed}
1050
+
1051
+ `;
1052
+ const instructions = `Respond ONCE, concisely, to what was directed at you. If something is already resolved by the later messages above, say so briefly. Output ONLY your channel message \u2014 do NOT include the silence sentinel and do NOT explain whether or why you are responding.`;
1053
+ this.beacon?.begin();
1054
+ this.session.submit(head + instructions);
1055
+ }
1056
+ async stop() {
1057
+ this.beacon?.stop?.();
1058
+ await this.session?.stop();
1059
+ }
1060
+ };
1061
+
1062
+ // src/activityBeacon.ts
1063
+ var ActivityBeacon = class {
1064
+ constructor(emit, heartbeatMs = 3e4) {
1065
+ this.emit = emit;
1066
+ this.heartbeatMs = heartbeatMs;
1067
+ }
1068
+ emit;
1069
+ heartbeatMs;
1070
+ inFlight = 0;
1071
+ timer = null;
1072
+ begin() {
1073
+ if (this.inFlight++ === 0) {
1074
+ this.emit("start");
1075
+ this.timer = setInterval(() => this.emit("start"), this.heartbeatMs);
1076
+ }
1077
+ }
1078
+ end() {
1079
+ if (this.inFlight === 0) return;
1080
+ if (--this.inFlight === 0) {
1081
+ this.clearTimer();
1082
+ this.emit("stop");
1083
+ }
1084
+ }
1085
+ /** Unconditional teardown (bridge shutdown). Does not emit. */
1086
+ stop() {
1087
+ this.inFlight = 0;
1088
+ this.clearTimer();
1089
+ }
1090
+ clearTimer() {
1091
+ if (this.timer) {
1092
+ clearInterval(this.timer);
1093
+ this.timer = null;
1094
+ }
1095
+ }
1096
+ };
1097
+
1098
+ // src/adapter.ts
1099
+ import { query } from "@anthropic-ai/claude-agent-sdk";
1100
+
1101
+ // src/acp/client.ts
1102
+ import { spawn } from "child_process";
1103
+ import { Readable, Writable } from "stream";
1104
+ import { ClientSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
1105
+
1106
+ // src/acp/filterStream.ts
1107
+ function dropVendorNotifications(input) {
1108
+ const decoder = new TextDecoder();
1109
+ const encoder = new TextEncoder();
1110
+ let buf = "";
1111
+ const emit = (controller, line) => {
1112
+ const trimmed = line.trim();
1113
+ if (trimmed) {
1114
+ try {
1115
+ const msg = JSON.parse(trimmed);
1116
+ const isNotification = msg.id === void 0;
1117
+ const method = typeof msg.method === "string" ? msg.method : "";
1118
+ if (isNotification && method.startsWith("_")) {
1119
+ return;
1120
+ }
1121
+ } catch {
1122
+ }
1123
+ }
1124
+ controller.enqueue(encoder.encode(line + "\n"));
1125
+ };
1126
+ return new ReadableStream({
1127
+ async start(controller) {
1128
+ const reader = input.getReader();
1129
+ try {
1130
+ for (; ; ) {
1131
+ const { value, done } = await reader.read();
1132
+ if (done) break;
1133
+ if (!value) continue;
1134
+ buf += decoder.decode(value, { stream: true });
1135
+ const lines = buf.split("\n");
1136
+ buf = lines.pop() ?? "";
1137
+ for (const line of lines) emit(controller, line);
1138
+ }
1139
+ buf += decoder.decode();
1140
+ if (buf.trim()) emit(controller, buf);
1141
+ } catch (err) {
1142
+ controller.error(err);
1143
+ return;
1144
+ } finally {
1145
+ reader.releaseLock();
1146
+ }
1147
+ controller.close();
1148
+ }
1149
+ });
1150
+ }
1151
+
1152
+ // src/acp/client.ts
1153
+ var MODEL_AUTH_ENV_KEYS = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
1154
+ function buildAcpEnv(base, extra) {
1155
+ const merged = {};
1156
+ for (const [k, v] of Object.entries({ ...base, ...extra ?? {} })) {
1157
+ if (v === void 0) continue;
1158
+ if (MODEL_AUTH_ENV_KEYS.includes(k)) continue;
1159
+ merged[k] = v;
1160
+ }
1161
+ return merged;
1162
+ }
1163
+ var STOP_DRAIN_MS = 3e3;
1164
+ function killProcessGroup(child, signal) {
1165
+ const pid = child.pid;
1166
+ try {
1167
+ if (pid) process.kill(-pid, signal);
1168
+ else child.kill(signal);
1169
+ } catch {
1170
+ try {
1171
+ child.kill(signal);
1172
+ } catch {
1173
+ }
1174
+ }
1175
+ }
1176
+ function pickAllowOption(req) {
1177
+ const opts = req.options ?? [];
1178
+ const always = opts.find((o) => o.kind === "allow_always");
1179
+ if (always) return always.optionId;
1180
+ const once = opts.find((o) => o.kind === "allow_once");
1181
+ if (once) return once.optionId;
1182
+ throw new Error(
1183
+ "ACP request_permission had no allow option (allow_always/allow_once); refusing to auto-select a non-allow option"
1184
+ );
1185
+ }
1186
+ var AcpClient = class {
1187
+ constructor(launch) {
1188
+ this.launch = launch;
1189
+ }
1190
+ launch;
1191
+ child = null;
1192
+ conn = null;
1193
+ _sessionId = null;
1194
+ /**
1195
+ * The currently-running turn's reply accumulator. Set for the duration of one
1196
+ * turn so agent_message_chunk text lands in THIS turn's buffer only. Because
1197
+ * turns are serialized (see #turnQueue), exactly one turn is active at a time,
1198
+ * so a single active buffer cannot interleave across turns.
1199
+ */
1200
+ activeTurnBuffer = null;
1201
+ /** Promise chain serializing overlapping submits onto the one warm session. */
1202
+ turnQueue = Promise.resolve();
1203
+ /** Set when the subprocess exits unexpectedly; surfaced to the next await. */
1204
+ exitError = null;
1205
+ /** Pending rejecters waiting on an in-flight operation (start/submit). */
1206
+ exitRejecters = /* @__PURE__ */ new Set();
1207
+ get sessionId() {
1208
+ return this._sessionId;
1209
+ }
1210
+ /**
1211
+ * The sticky subprocess-exit error, or null if the client is alive. Set when the
1212
+ * subprocess exits/errs unexpectedly and cleared by the next start(). A supervisor
1213
+ * (the AcpAdapter) reads this to decide whether a failed turn was a crash that
1214
+ * warrants a restart, without coupling to the relay WebSocket.
1215
+ */
1216
+ get exited() {
1217
+ return this.exitError;
1218
+ }
1219
+ /**
1220
+ * Spawn the adapter, build the connection, initialize, and open one session.
1221
+ * Any failure (spawn error, premature exit, protocol error) rejects.
1222
+ */
1223
+ async start() {
1224
+ this.exitError = null;
1225
+ this.stopping = false;
1226
+ this.activeTurnBuffer = null;
1227
+ this.turnQueue = Promise.resolve();
1228
+ this.exitRejecters.clear();
1229
+ try {
1230
+ await this.startInner();
1231
+ } catch (err) {
1232
+ await this.stop().catch(() => {
1233
+ });
1234
+ throw err;
1235
+ }
1236
+ }
1237
+ async startInner() {
1238
+ const child = spawn(this.launch.command, this.launch.args, {
1239
+ cwd: this.launch.cwd,
1240
+ // Inherit the user's env so the agent uses ITS OWN auth, but strip any
1241
+ // model-API-auth keys (e.g. a stale ANTHROPIC_API_KEY in the launching
1242
+ // shell) so the agent falls back to its own login instead of silently
1243
+ // billing an API account. See buildAcpEnv / MODEL_AUTH_ENV_KEYS.
1244
+ env: buildAcpEnv(process.env, this.launch.env),
1245
+ stdio: ["pipe", "pipe", "inherit"],
1246
+ // stderr passes through for debugging
1247
+ // Own process group: Ctrl-C (SIGINT to the bridge's foreground group) does NOT reach
1248
+ // the agent subprocess, so the bridge can drain the in-flight turn and shut it down
1249
+ // cleanly instead of the SDK tearing down mid-query. stop() kills the whole group.
1250
+ detached: true
1251
+ });
1252
+ this.child = child;
1253
+ child.on("error", (err) => {
1254
+ this.failPending(new Error(`ACP agent subprocess error: ${err.message}`));
1255
+ });
1256
+ child.on("exit", (code, signal) => {
1257
+ const reason = signal != null ? `signal ${signal}` : `code ${code ?? "unknown"}`;
1258
+ const err = new Error(`ACP agent subprocess exited (${reason})`);
1259
+ if (code !== 0 && !this.stopping) {
1260
+ this.failPending(err);
1261
+ }
1262
+ });
1263
+ const output = Writable.toWeb(child.stdin);
1264
+ const rawInput = Readable.toWeb(
1265
+ child.stdout
1266
+ );
1267
+ const input = dropVendorNotifications(rawInput);
1268
+ const stream = ndJsonStream(output, input);
1269
+ const client = {
1270
+ sessionUpdate: async (params) => {
1271
+ const u = params.update;
1272
+ if (process.env.ARP_ACP_DEBUG) {
1273
+ const au = u;
1274
+ const c = au.content;
1275
+ const preview = c?.type === "text" ? JSON.stringify(c.text).slice(0, 160) : JSON.stringify(u).slice(0, 220);
1276
+ console.error(`[ACP_DEBUG] update=${u.sessionUpdate} content=${c?.type ?? "-"} :: ${preview}`);
1277
+ }
1278
+ if (u.sessionUpdate === "agent_message_chunk" && u.content?.type === "text" && this.activeTurnBuffer) {
1279
+ this.activeTurnBuffer.text += u.content.text;
1280
+ }
1281
+ },
1282
+ requestPermission: async (req) => {
1283
+ return {
1284
+ outcome: { outcome: "selected", optionId: pickAllowOption(req) }
1285
+ };
1286
+ }
1287
+ };
1288
+ this.conn = new ClientSideConnection(() => client, stream);
1289
+ await this.guard(
1290
+ this.conn.initialize({
1291
+ protocolVersion: 1,
1292
+ clientCapabilities: {
1293
+ fs: { readTextFile: false, writeTextFile: false },
1294
+ terminal: false
1295
+ },
1296
+ clientInfo: { name: "arp-bridge", version: "0.1.0" }
1297
+ })
1298
+ );
1299
+ const session = await this.guard(
1300
+ this.conn.newSession({ cwd: this.launch.cwd, mcpServers: [] })
1301
+ );
1302
+ this._sessionId = session.sessionId;
1303
+ }
1304
+ /**
1305
+ * Send one user turn. Resolves with the full assembled reply text once the
1306
+ * agent signals end-of-turn (the prompt promise resolves with a stopReason).
1307
+ *
1308
+ * One-turn-at-a-time contract: an ACP session processes a single prompt turn
1309
+ * at a time. Overlapping submit() calls are SERIALIZED onto the one warm
1310
+ * session via an internal promise-chain queue and run sequentially in call
1311
+ * order. Each turn captures ONLY its own agent_message_chunk text into its own
1312
+ * per-turn buffer, so concurrent callers each receive their own correct,
1313
+ * uncontaminated reply. A turn that rejects (e.g. subprocess death) does not
1314
+ * break the queue for subsequent turns.
1315
+ */
1316
+ async submit(text) {
1317
+ if (!this.conn || !this._sessionId) {
1318
+ throw new Error("AcpClient.submit called before start()");
1319
+ }
1320
+ const run = this.turnQueue.then(() => this.runTurn(text));
1321
+ this.turnQueue = run.catch(() => {
1322
+ });
1323
+ return run;
1324
+ }
1325
+ /** Execute exactly one prompt turn with its own isolated reply buffer. */
1326
+ async runTurn(text) {
1327
+ if (!this.conn || !this._sessionId) {
1328
+ throw new Error("AcpClient.submit called before start()");
1329
+ }
1330
+ const buffer = { text: "" };
1331
+ this.activeTurnBuffer = buffer;
1332
+ try {
1333
+ await this.guard(
1334
+ this.conn.prompt({
1335
+ sessionId: this._sessionId,
1336
+ prompt: [{ type: "text", text }]
1337
+ })
1338
+ );
1339
+ return buffer.text;
1340
+ } finally {
1341
+ if (this.activeTurnBuffer === buffer) this.activeTurnBuffer = null;
1342
+ }
1343
+ }
1344
+ /** Terminate the subprocess. Tolerant of an already-exited child. */
1345
+ stopping = false;
1346
+ async stop() {
1347
+ this.stopping = true;
1348
+ await Promise.race([
1349
+ this.turnQueue.catch(() => {
1350
+ }),
1351
+ new Promise((resolve) => setTimeout(resolve, STOP_DRAIN_MS))
1352
+ ]);
1353
+ const child = this.child;
1354
+ this.child = null;
1355
+ this.conn = null;
1356
+ if (!child || child.exitCode !== null || child.signalCode !== null) return;
1357
+ await new Promise((resolve) => {
1358
+ const done = () => resolve();
1359
+ child.once("exit", done);
1360
+ try {
1361
+ child.stdin.end();
1362
+ } catch {
1363
+ }
1364
+ killProcessGroup(child, "SIGTERM");
1365
+ const t = setTimeout(() => killProcessGroup(child, "SIGKILL"), 2e3);
1366
+ child.once("exit", () => clearTimeout(t));
1367
+ });
1368
+ }
1369
+ /**
1370
+ * Race a connection promise against an unexpected subprocess exit so that a
1371
+ * crash propagates as a rejection instead of leaving the await pending.
1372
+ */
1373
+ guard(p) {
1374
+ if (this.exitError) return Promise.reject(this.exitError);
1375
+ return new Promise((resolve, reject) => {
1376
+ const rej = (err) => reject(err);
1377
+ this.exitRejecters.add(rej);
1378
+ p.then(
1379
+ (v) => {
1380
+ this.exitRejecters.delete(rej);
1381
+ resolve(v);
1382
+ },
1383
+ (err) => {
1384
+ this.exitRejecters.delete(rej);
1385
+ reject(err);
1386
+ }
1387
+ );
1388
+ });
1389
+ }
1390
+ /**
1391
+ * Record an exit error and reject anything currently in flight. The SDK's
1392
+ * ClientSideConnection is the primary safety net (it rejects pending JSON-RPC
1393
+ * requests when the stream closes); this is the spawn-error/clarity layer that
1394
+ * also covers spawn-level failures and gives a clear subprocess-exit message.
1395
+ */
1396
+ failPending(err) {
1397
+ this.exitError = err;
1398
+ for (const rej of this.exitRejecters) rej(err);
1399
+ this.exitRejecters.clear();
1400
+ }
1401
+ };
1402
+
1403
+ // src/adapter.ts
1404
+ function launchSpecFor(agent) {
1405
+ const cwd = process.cwd();
1406
+ switch (agent) {
1407
+ case "claude-code":
1408
+ return { command: "npx", args: ["@agentclientprotocol/claude-agent-acp@latest"], cwd };
1409
+ // codex: live-verified 2026-06-05 — clean ACP output, no tweaks needed.
1410
+ case "codex":
1411
+ return { command: "npx", args: ["@zed-industries/codex-acp@latest"], cwd };
1412
+ // gemini: live-verified 2026-06-05. Use the current `--acp` flag (not the deprecated
1413
+ // `--experimental-acp`) and pin a GA model — gemini-cli's default is a capacity-starved
1414
+ // preview ("No capacity available for ... preview"); gemini-2.5-flash is GA + fast.
1415
+ case "gemini":
1416
+ return { command: "npx", args: ["@google/gemini-cli@latest", "--acp", "-m", "gemini-2.5-flash"], cwd };
1417
+ // grok: xAI's Grok Build CLI has native ACP baked into the binary (`grok agent stdio`);
1418
+ // no npx wrapper. Runs under the operator's own `grok` login / XAI_API_KEY. Pin a model
1419
+ // via `-m` only if the default proves wrong (as gemini needed).
1420
+ case "grok":
1421
+ return { command: "grok", args: ["agent", "stdio"], cwd };
1422
+ case "cursor":
1423
+ throw new Error(
1424
+ "cursor ACP adapter is unverified / not yet supported; choose claude-code, codex, or gemini"
1425
+ );
1426
+ default: {
1427
+ const exhaustive = agent;
1428
+ throw new Error(`Unknown ACP agent: ${String(exhaustive)}`);
1429
+ }
1430
+ }
1431
+ }
1432
+ var defaultAcpClientFactory = (launch) => new AcpClient(launch);
1433
+ var MAX_CONSECUTIVE_RESTARTS = 3;
1434
+ var RESTART_BACKOFF_MS = 250;
1435
+ var AcpAdapter = class {
1436
+ constructor(agent, makeClient = defaultAcpClientFactory, backoffMs = RESTART_BACKOFF_MS) {
1437
+ this.makeClient = makeClient;
1438
+ this.backoffMs = backoffMs;
1439
+ this.launch = launchSpecFor(agent);
1440
+ }
1441
+ makeClient;
1442
+ backoffMs;
1443
+ launch;
1444
+ // --- supervised live state (set in start()) ---
1445
+ client = null;
1446
+ turnCbs = [];
1447
+ /** Set true by stop() so a crash during/after intentional shutdown does NOT restart. */
1448
+ stopped = false;
1449
+ /** In-progress restart, shared by all concurrent failing turns (single-flight). */
1450
+ restartInFlight = null;
1451
+ /** Consecutive restart count; reset to 0 on any successful turn. */
1452
+ consecutiveRestarts = 0;
1453
+ /** Latched once the loop guard trips, so we stop trying (and stop retrying turns). */
1454
+ gaveUp = false;
1455
+ async start(_opts) {
1456
+ this.client = this.makeClient(this.launch);
1457
+ await this.client.start();
1458
+ return {
1459
+ submit: (text) => {
1460
+ void this.handleTurn(text, true).then((delivered) => {
1461
+ if (!delivered) this.turnCbs.forEach((cb) => cb(""));
1462
+ }).catch(() => {
1463
+ this.turnCbs.forEach((cb) => cb(""));
1464
+ });
1465
+ },
1466
+ onTurn: (cb) => {
1467
+ this.turnCbs.push(cb);
1468
+ },
1469
+ converseLocal: (text) => this.converseLocal(text),
1470
+ stop: async () => {
1471
+ this.stopped = true;
1472
+ await this.client?.stop();
1473
+ }
1474
+ };
1475
+ }
1476
+ /**
1477
+ * Submit a PRIVATE local aside on the SAME warm session and return the reply
1478
+ * directly. Calls the underlying AcpClient.submit, which shares the one warm
1479
+ * session/context and the serialized FIFO queue with channel turns, so the agent
1480
+ * "remembers" both surfaces. Crucially it does NOT touch turnCbs, so the reply is
1481
+ * never posted to the channel. A local aside on a dead/gave-up client returns a
1482
+ * clear "agent unavailable" string rather than restarting (restart supervision is
1483
+ * reserved for channel turns; a local REPL caller sees the message inline).
1484
+ */
1485
+ async converseLocal(text) {
1486
+ const client = this.client;
1487
+ if (!client || this.stopped || this.gaveUp) {
1488
+ return "[arp-bridge] agent unavailable for local conversation";
1489
+ }
1490
+ try {
1491
+ return await client.submit(text);
1492
+ } catch (err) {
1493
+ return `[arp-bridge] local turn failed: ${err?.message ?? String(err)}`;
1494
+ }
1495
+ }
1496
+ /**
1497
+ * Run one real channel turn against the warm session. On a turn that fails because
1498
+ * the subprocess crashed, supervise a restart (single-flight, capped) and best-effort
1499
+ * retry the triggering message ONCE. A non-crash failure (or a failure after the loop
1500
+ * guard tripped, or after intentional stop) warns and posts nothing.
1501
+ */
1502
+ /** Returns true if a reply was delivered to onTurn, false on terminal failure. The
1503
+ * caller (submit) fires a terminal empty onTurn when this returns false, so onTurn
1504
+ * fires exactly once per submit. */
1505
+ async handleTurn(text, allowRetry) {
1506
+ const client = this.client;
1507
+ if (!client) return false;
1508
+ try {
1509
+ const reply = await client.submit(text);
1510
+ this.consecutiveRestarts = 0;
1511
+ this.turnCbs.forEach((cb) => cb(reply));
1512
+ return true;
1513
+ } catch (err) {
1514
+ if (this.stopped) {
1515
+ console.warn(
1516
+ "[arp-bridge] ACP turn failed during shutdown:",
1517
+ err?.message ?? err
1518
+ );
1519
+ return false;
1520
+ }
1521
+ if (!client.exited || this.gaveUp) {
1522
+ console.warn(
1523
+ "[arp-bridge] ACP turn failed:",
1524
+ err?.message ?? err
1525
+ );
1526
+ return false;
1527
+ }
1528
+ console.warn(
1529
+ "[arp-bridge] ACP subprocess crashed mid-turn; attempting restart:",
1530
+ err?.message ?? err
1531
+ );
1532
+ const recovered = await this.ensureRestarted();
1533
+ if (recovered && allowRetry && !this.stopped) {
1534
+ return await this.handleTurn(text, false);
1535
+ }
1536
+ return false;
1537
+ }
1538
+ }
1539
+ /**
1540
+ * Single-flight restart of the crashed subprocess. Concurrent failing turns all await
1541
+ * the SAME in-flight restart (never spawning parallel restarts).
1542
+ * Bounded by MAX_CONSECUTIVE_RESTARTS with a small linear backoff; on exceeding the
1543
+ * cap it logs a clear error, latches gaveUp, and stops trying. Returns true if the
1544
+ * session is alive again, false if it gave up.
1545
+ */
1546
+ ensureRestarted() {
1547
+ if (this.restartInFlight) return this.restartInFlight;
1548
+ this.restartInFlight = this.doRestart().finally(() => {
1549
+ this.restartInFlight = null;
1550
+ });
1551
+ return this.restartInFlight;
1552
+ }
1553
+ async doRestart() {
1554
+ if (this.stopped || this.gaveUp || !this.client) return false;
1555
+ this.consecutiveRestarts += 1;
1556
+ if (this.consecutiveRestarts > MAX_CONSECUTIVE_RESTARTS) {
1557
+ this.gaveUp = true;
1558
+ console.error(
1559
+ `[arp-bridge] ACP subprocess failed to recover after ${MAX_CONSECUTIVE_RESTARTS} consecutive restarts; giving up (relay WS stays connected, channel messages will be dropped until the bridge is restarted)`
1560
+ );
1561
+ return false;
1562
+ }
1563
+ if (this.backoffMs > 0) await delay(this.backoffMs * this.consecutiveRestarts);
1564
+ if (this.stopped) return false;
1565
+ try {
1566
+ await this.client.start();
1567
+ return true;
1568
+ } catch (e) {
1569
+ console.warn(
1570
+ "[arp-bridge] ACP restart attempt failed:",
1571
+ e?.message ?? e
1572
+ );
1573
+ return false;
1574
+ }
1575
+ }
1576
+ };
1577
+ function delay(ms) {
1578
+ return new Promise((r) => setTimeout(r, ms));
1579
+ }
1580
+ function makeInputQueue() {
1581
+ const buf = [];
1582
+ let resolve = null;
1583
+ let done = false;
1584
+ const iterable = {
1585
+ async *[Symbol.asyncIterator]() {
1586
+ while (!done) {
1587
+ if (buf.length === 0) await new Promise((r) => resolve = r);
1588
+ while (buf.length) yield buf.shift();
1589
+ }
1590
+ }
1591
+ };
1592
+ return {
1593
+ iterable,
1594
+ push(text) {
1595
+ buf.push({
1596
+ type: "user",
1597
+ message: { role: "user", content: text },
1598
+ parent_tool_use_id: null,
1599
+ session_id: ""
1600
+ });
1601
+ resolve?.();
1602
+ resolve = null;
1603
+ },
1604
+ end() {
1605
+ done = true;
1606
+ resolve?.();
1607
+ resolve = null;
1608
+ }
1609
+ };
1610
+ }
1611
+ var ClaudeAdapter = class {
1612
+ async start(opts) {
1613
+ const input = makeInputQueue();
1614
+ const turnCbs = [];
1615
+ let buffer = "";
1616
+ const q = query({
1617
+ prompt: input.iterable,
1618
+ options: {
1619
+ model: opts.model,
1620
+ // No systemPrompt: the bridge imposes no persona. The SDK uses its default; the
1621
+ // user's agent is itself. Situational framing is sent per message.
1622
+ permissionMode: "bypassPermissions",
1623
+ // headless: no interactive approval surface
1624
+ allowDangerouslySkipPermissions: true
1625
+ // required by the SDK when bypassing permissions
1626
+ // ANTHROPIC_API_KEY is read by the SDK from the process env; we never pass it explicitly here.
1627
+ }
1628
+ });
1629
+ (async () => {
1630
+ for await (const m of q) {
1631
+ if (m.type === "assistant") {
1632
+ const blocks = m.message?.content ?? [];
1633
+ const text = blocks.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
1634
+ buffer += text;
1635
+ } else if (m.type === "result") {
1636
+ if (m.subtype === "success") {
1637
+ const full = buffer.trim();
1638
+ buffer = "";
1639
+ if (full.length > 0) turnCbs.forEach((cb) => cb(full));
1640
+ } else {
1641
+ buffer = "";
1642
+ console.warn("[arp-bridge] agent turn ended without success:", m.subtype);
1643
+ }
1644
+ }
1645
+ }
1646
+ })().catch((e) => {
1647
+ console.warn("[arp-bridge] generic adapter stream error:", e && e.message || e);
1648
+ });
1649
+ return {
1650
+ submit(text) {
1651
+ input.push(text);
1652
+ },
1653
+ onTurn(cb) {
1654
+ turnCbs.push(cb);
1655
+ },
1656
+ async stop() {
1657
+ input.end();
1658
+ try {
1659
+ await q.interrupt();
1660
+ } catch {
1661
+ }
1662
+ }
1663
+ };
1664
+ }
1665
+ };
1666
+ function createAdapter(cfg) {
1667
+ return cfg.agentMode === "acp" ? new AcpAdapter(cfg.agent) : new ClaudeAdapter();
1668
+ }
1669
+
1670
+ // src/elicit.ts
1671
+ var CARD_PROMPT = 'Answer IMMEDIATELY with ONLY a JSON object \u2014 do not deliberate, plan, or explain: { "description": string, "skills": [{ "id": string, "name": string, "description": string, "tags": string[] }] }. description = ONE short sentence on what you do. List ALL your skills (do not omit any), each with a SHORT description. No prose outside the JSON.';
1672
+ var CARD_PROMPT_RETRY = 'Reply with ONLY the JSON object, nothing else, immediately: { "description": string, "skills": [{ "id": string, "name": string, "description": string, "tags": string[] }] }';
1673
+ function isUsable(self) {
1674
+ return !!self && self.description.trim().length > 0;
1675
+ }
1676
+ function delay2(ms) {
1677
+ return new Promise((r) => setTimeout(r, ms));
1678
+ }
1679
+ async function elicitCard(converse, agentName, opts = {}) {
1680
+ const timeoutMs = opts.timeoutMs ?? 12e4;
1681
+ const maxAttempts = Math.max(1, opts.maxAttempts ?? 4);
1682
+ const retryDelayMs = opts.retryDelayMs ?? 2e3;
1683
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1684
+ const prompt = attempt === 0 ? CARD_PROMPT : CARD_PROMPT_RETRY;
1685
+ try {
1686
+ const reply = await withTimeout(converse(prompt), timeoutMs);
1687
+ const self = parseCardReply(reply);
1688
+ if (isUsable(self)) return buildPartialCard(agentName, self);
1689
+ } catch {
1690
+ }
1691
+ if (attempt < maxAttempts - 1 && retryDelayMs > 0) await delay2(retryDelayMs);
1692
+ }
1693
+ return buildPartialCard(agentName, { description: opts.fallbackDescription ?? "", skills: [] });
1694
+ }
1695
+ function withTimeout(p, ms) {
1696
+ return new Promise((resolve, reject) => {
1697
+ const t = setTimeout(() => reject(new Error("elicit timeout")), ms);
1698
+ p.then((v) => {
1699
+ clearTimeout(t);
1700
+ resolve(v);
1701
+ }, (e) => {
1702
+ clearTimeout(t);
1703
+ reject(e);
1704
+ });
1705
+ });
1706
+ }
1707
+
1708
+ // src/bridge.ts
1709
+ async function startBridge(cfg, relay, deps) {
1710
+ const sessions = /* @__PURE__ */ new Map();
1711
+ const pending = /* @__PURE__ */ new Map();
1712
+ const rosterUnsubs = /* @__PURE__ */ new Map();
1713
+ const tornDownWhilePending = /* @__PURE__ */ new Set();
1714
+ let selfCardPublished = false;
1715
+ async function ensureSession(channelId) {
1716
+ const live = sessions.get(channelId);
1717
+ if (live) return live;
1718
+ const inFlight = pending.get(channelId);
1719
+ if (inFlight) return inFlight;
1720
+ const p = (async () => {
1721
+ const adapter = deps.makeAdapter(cfg);
1722
+ const beacon = new ActivityBeacon((state) => relay.sendActivity(channelId, state));
1723
+ const session = new ChannelSession(
1724
+ adapter,
1725
+ (text) => void relay.postMessage(channelId, text),
1726
+ cfg.agentName,
1727
+ channelId,
1728
+ {
1729
+ postReply: (flowId, content) => relay.postFlowMessage(channelId, flowId, content),
1730
+ fetchHistory: (flowId) => relay.fetchFlowMessages(channelId, flowId)
1731
+ },
1732
+ () => relay.fetchChannelContext(channelId),
1733
+ beacon
1734
+ );
1735
+ await session.start({ model: cfg.model });
1736
+ sessions.set(channelId, session);
1737
+ pending.delete(channelId);
1738
+ if (!selfCardPublished) {
1739
+ selfCardPublished = true;
1740
+ void publishSelfCard(cfg, relay, session).catch(
1741
+ (e) => console.warn("[arp-bridge] self card publish failed:", String(e))
1742
+ );
1743
+ }
1744
+ const unsub = learnRoster(relay, channelId, session);
1745
+ if (tornDownWhilePending.delete(channelId)) unsub();
1746
+ else rosterUnsubs.set(channelId, unsub);
1747
+ return session;
1748
+ })();
1749
+ pending.set(channelId, p);
1750
+ p.catch(() => pending.delete(channelId));
1751
+ return p;
1752
+ }
1753
+ function teardown(channelId) {
1754
+ rosterUnsubs.get(channelId)?.();
1755
+ rosterUnsubs.delete(channelId);
1756
+ const s = sessions.get(channelId);
1757
+ if (!s) {
1758
+ if (pending.has(channelId)) tornDownWhilePending.add(channelId);
1759
+ return;
1760
+ }
1761
+ sessions.delete(channelId);
1762
+ void s.stop();
1763
+ }
1764
+ relay.onInbound((m) => {
1765
+ if (m.isHistory) return;
1766
+ if (m.senderId && m.senderId === cfg.agentUuid || m.senderName && m.senderName === cfg.agentName) return;
1767
+ if (!m.content.trim()) return;
1768
+ ensureSession(m.channelId).then((s) => s.submit(m)).catch((e) => console.warn(`[arp-bridge] inbound routing failed for channel ${m.channelId}:`, String(e)));
1769
+ });
1770
+ relay.onFlowSignal((signal) => {
1771
+ if (!signal.channelId) return;
1772
+ ensureSession(signal.channelId).then((s) => s.runFlowTurn(signal)).catch((e) => console.warn(`[arp-bridge] flow routing failed for channel ${signal.channelId}:`, String(e)));
1773
+ });
1774
+ relay.onCatchUp((channelId, result) => {
1775
+ ensureSession(channelId).then((s) => s.submitCatchUp(result)).catch((e) => console.warn(`[arp-bridge] catch-up routing failed for channel ${channelId}:`, String(e)));
1776
+ });
1777
+ relay.onAdded((channelId) => {
1778
+ ensureSession(channelId).catch((e) => console.warn(`[arp-bridge] pre-warm failed for channel ${channelId}:`, String(e)));
1779
+ });
1780
+ relay.onRemoved((channelId) => teardown(channelId));
1781
+ relay.start();
1782
+ maybeStartLocalRepl(cfg);
1783
+ return { sessions, ensureSession, teardown };
1784
+ }
1785
+ function maybeStartLocalRepl(cfg) {
1786
+ const optIn = isTruthy(process.env.ARP_LOCAL_REPL);
1787
+ const interactive = process.stdin.isTTY === true;
1788
+ if (!optIn || !interactive) return;
1789
+ process.stdout.write(
1790
+ "[arp-bridge] local chat is not available with the multi-channel router yet (no startup session).\n"
1791
+ );
1792
+ }
1793
+ function isTruthy(v) {
1794
+ if (!v) return false;
1795
+ const s = v.trim().toLowerCase();
1796
+ return s === "1" || s === "true" || s === "yes" || s === "on";
1797
+ }
1798
+ async function publishSelfCard(cfg, relay, session) {
1799
+ let card;
1800
+ if (cfg.agentMode === "acp") {
1801
+ card = await elicitCard((t) => session.converseLocal(t), cfg.agentName);
1802
+ } else {
1803
+ card = buildPartialCard(cfg.agentName, { description: "", skills: [] });
1804
+ }
1805
+ await relay.putAgentCard(card);
1806
+ }
1807
+ function learnRoster(relay, channelId, session) {
1808
+ const byName = /* @__PURE__ */ new Map();
1809
+ const apply = () => session.setRoster([...byName.values()]);
1810
+ const unsub = relay.onRoster((e) => {
1811
+ byName.set(e.name, e);
1812
+ apply();
1813
+ });
1814
+ void relay.fetchRoster(channelId).then((roster) => {
1815
+ for (const e of roster) byName.set(e.name, e);
1816
+ apply();
1817
+ }).catch((err) => console.warn("[arp-bridge] learnRoster fetch failed:", String(err)));
1818
+ return unsub;
1819
+ }
1820
+ async function createAndStartBridge(cfg, deps = {}) {
1821
+ let wsFactory = deps.wsFactory;
1822
+ if (!wsFactory) {
1823
+ const WebSocketImpl = (await import("ws")).default;
1824
+ wsFactory = (url) => new WebSocketImpl(url);
1825
+ }
1826
+ const relay = new RelayClient(cfg, {
1827
+ wsFactory,
1828
+ fetchFn: deps.fetchFn ?? fetch
1829
+ });
1830
+ if (deps.onFatal) relay.onFatal(deps.onFatal);
1831
+ const makeAdapter = deps.makeAdapter ?? createAdapter;
1832
+ const userOnReady = deps.onReady;
1833
+ relay.onReady(() => {
1834
+ userOnReady?.();
1835
+ });
1836
+ const { sessions, ensureSession } = await startBridge(cfg, relay, { makeAdapter });
1837
+ return { relay, sessions, ensureSession };
1838
+ }
1839
+
1840
+ // src/shutdown.ts
1841
+ function installGracefulShutdown(bridge) {
1842
+ let shuttingDown = false;
1843
+ const shutdown = async (sig) => {
1844
+ if (shuttingDown) return;
1845
+ shuttingDown = true;
1846
+ console.log(`
1847
+ [arp-bridge] ${sig} received; shutting down gracefully...`);
1848
+ const force = setTimeout(() => process.exit(0), 8e3);
1849
+ force.unref?.();
1850
+ try {
1851
+ bridge.relay.stop();
1852
+ } catch {
1853
+ }
1854
+ for (const s of bridge.sessions.values()) {
1855
+ try {
1856
+ await s.stop();
1857
+ } catch {
1858
+ }
1859
+ }
1860
+ clearTimeout(force);
1861
+ process.exit(0);
1862
+ };
1863
+ process.once("SIGINT", () => void shutdown("SIGINT"));
1864
+ process.once("SIGTERM", () => void shutdown("SIGTERM"));
1865
+ }
1866
+
1867
+ // src/cli.ts
1868
+ async function main() {
1869
+ const cfg = await resolveConfig(process.argv.slice(2), process.env);
1870
+ console.log("[arp-bridge] starting", redactConfig(cfg));
1871
+ const bridge = await createAndStartBridge(cfg);
1872
+ installGracefulShutdown(bridge);
1873
+ console.log("[arp-bridge] connected; routing per-channel sessions. Ctrl-C to stop.");
1874
+ }
1875
+ main().catch((err) => {
1876
+ console.error("[arp-bridge] fatal:", err);
1877
+ process.exit(1);
1878
+ });