@snowyroad/arp 0.3.2 → 0.3.3

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 (2) hide show
  1. package/dist/cli.js +190 -28
  2. package/package.json +1 -2
package/dist/cli.js CHANGED
@@ -251,6 +251,46 @@ var RebootstrapError = class extends Error {
251
251
  this.name = "RebootstrapError";
252
252
  }
253
253
  };
254
+ var MAX_TOKEN_LENGTH = 8192;
255
+ var MAX_TOKEN_TTL_S = 48 * 3600;
256
+ var CLOCK_SKEW_S = 300;
257
+ function decodeJwtClaims(token) {
258
+ const parts = token.split(".");
259
+ if (parts.length !== 3) return null;
260
+ try {
261
+ const header = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8"));
262
+ if (typeof header !== "object" || header === null || Array.isArray(header)) return null;
263
+ const alg = header.alg;
264
+ if (typeof alg !== "string" || alg.toLowerCase() === "none") return null;
265
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
266
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return null;
267
+ return payload;
268
+ } catch {
269
+ return null;
270
+ }
271
+ }
272
+ function validateReplacementToken(newToken, currentToken, nowMs = Date.now()) {
273
+ if (newToken.length > MAX_TOKEN_LENGTH) return { ok: false, reason: "token too large" };
274
+ const claims = decodeJwtClaims(newToken);
275
+ if (!claims) return { ok: false, reason: "not a well-formed JWT" };
276
+ const exp = claims.exp;
277
+ if (typeof exp !== "number" || !Number.isFinite(exp)) {
278
+ return { ok: false, reason: "missing or non-numeric exp claim" };
279
+ }
280
+ const nowS = nowMs / 1e3;
281
+ if (exp <= nowS - CLOCK_SKEW_S) return { ok: false, reason: "already expired" };
282
+ if (exp > nowS + MAX_TOKEN_TTL_S) return { ok: false, reason: "expiry implausibly far in the future" };
283
+ const current = decodeJwtClaims(currentToken);
284
+ if (current) {
285
+ if (typeof current.sub === "string" && typeof claims.sub === "string" && current.sub !== claims.sub) {
286
+ return { ok: false, reason: "subject (sub) does not match the current token" };
287
+ }
288
+ if (typeof current.iss === "string" && typeof claims.iss === "string" && current.iss !== claims.iss) {
289
+ return { ok: false, reason: "issuer (iss) does not match the current token" };
290
+ }
291
+ }
292
+ return { ok: true };
293
+ }
254
294
  async function mintAccessToken(relayHttpUrl, agentKey, fetchFn = fetch) {
255
295
  const res = await fetchFn(`${relayHttpUrl.replace(/\/$/, "")}/agents/token`, {
256
296
  method: "POST",
@@ -811,6 +851,21 @@ var AUTH_GRACE_MS = 1500;
811
851
  var CATCHUP_WINDOW_MS = 8e3;
812
852
  var SEEN_CAP = 5e3;
813
853
  var RESUME_MAX_PAGES = 200;
854
+ var WS_MAX_PAYLOAD_BYTES = 4 * 1024 * 1024;
855
+ var MAX_MESSAGE_CONTENT_CHARS = 65536;
856
+ var MAX_BACKFILL_CHARS_PER_CATCHUP = 2e6;
857
+ var MAX_BACKFILL_CHARS_PER_CONNECTION = 8e6;
858
+ var MAX_CONCURRENT_CATCHUPS = 3;
859
+ var GAP_RESUME_MIN_INTERVAL_MS = 5e3;
860
+ function clampContent(s) {
861
+ if (s.length <= MAX_MESSAGE_CONTENT_CHARS) return s;
862
+ return `${s.slice(0, MAX_MESSAGE_CONTENT_CHARS)}
863
+ [message truncated by bridge: exceeded ${MAX_MESSAGE_CONTENT_CHARS} chars]`;
864
+ }
865
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
866
+ function isUuid(s) {
867
+ return UUID_RE.test(s);
868
+ }
814
869
  var FATAL_CLOSE_CODES = /* @__PURE__ */ new Set([
815
870
  4001,
816
871
  // auth failed (bad/expired/tampered token) — recoverable via key re-mint when cfg.mintToken exists
@@ -824,6 +879,7 @@ var FATAL_CLOSE_CODES = /* @__PURE__ */ new Set([
824
879
  var MAX_REMINT_ATTEMPTS = 3;
825
880
  var PRE_HELLO_HINT_AFTER = 5;
826
881
  var RelayClient = class {
882
+ // per-channel gap-backfill rate limit
827
883
  constructor(cfg, deps) {
828
884
  this.cfg = cfg;
829
885
  this.deps = deps;
@@ -861,8 +917,22 @@ var RelayClient = class {
861
917
  fatalCb = null;
862
918
  removedCb = null;
863
919
  addedCb = null;
920
+ backfillCharsThisConn = 0;
921
+ // per-connection backfill budget (BRIDGE-09/12)
922
+ activeCatchUps = 0;
923
+ // concurrent catch-up limiter (BRIDGE-12)
924
+ catchUpWaiters = [];
925
+ lastGapResumeAt = /* @__PURE__ */ new Map();
926
+ // Every multi-listener subscription returns an unsubscribe (BRIDGE-18) so no
927
+ // handler array can grow without a way to shrink it. The router registers each
928
+ // once per process, but parity here means future per-channel subscribers cannot
929
+ // accumulate across channel churn the way pre-unsubscribe onRoster handlers did.
864
930
  onInbound(cb) {
865
931
  this.inboundCbs.push(cb);
932
+ return () => {
933
+ const i = this.inboundCbs.indexOf(cb);
934
+ if (i >= 0) this.inboundCbs.splice(i, 1);
935
+ };
866
936
  }
867
937
  /** Subscribe to roster updates; returns an unsubscribe so a per-channel session can drop
868
938
  * its subscription on teardown (otherwise handlers accumulate across channel churn). */
@@ -875,9 +945,17 @@ var RelayClient = class {
875
945
  }
876
946
  onFlowSignal(cb) {
877
947
  this.flowCbs.push(cb);
948
+ return () => {
949
+ const i = this.flowCbs.indexOf(cb);
950
+ if (i >= 0) this.flowCbs.splice(i, 1);
951
+ };
878
952
  }
879
953
  onCatchUp(cb) {
880
954
  this.catchUpCbs.push(cb);
955
+ return () => {
956
+ const i = this.catchUpCbs.indexOf(cb);
957
+ if (i >= 0) this.catchUpCbs.splice(i, 1);
958
+ };
881
959
  }
882
960
  onReady(cb) {
883
961
  this.readyCb = cb;
@@ -894,8 +972,17 @@ var RelayClient = class {
894
972
  start() {
895
973
  this.connect();
896
974
  }
975
+ /** Validate a relay-supplied id as a UUID and return it URL-encoded, or null
976
+ * (with a warning) when it is not one — the REST call is then refused (BRIDGE-11). */
977
+ pathId(id, what) {
978
+ if (!isUuid(id)) {
979
+ console.warn(`[arp-bridge] refusing REST call: ${what} is not a UUID: ${sanitizeForTty(id).slice(0, 80)}`);
980
+ return null;
981
+ }
982
+ return encodeURIComponent(id);
983
+ }
897
984
  connect() {
898
- const url = `${this.cfg.relayWsUrl}/ws/agent/${this.cfg.agentId}`;
985
+ const url = `${this.cfg.relayWsUrl}/ws/agent/${encodeURIComponent(this.cfg.agentId)}`;
899
986
  const ws = this.deps.wsFactory(url, ["bearer.arp.v1", this.cfg.token]);
900
987
  this.ws = ws;
901
988
  ws.on("open", () => this.onOpen());
@@ -906,6 +993,7 @@ var RelayClient = class {
906
993
  }
907
994
  onOpen() {
908
995
  this.caughtUp.clear();
996
+ this.backfillCharsThisConn = 0;
909
997
  this.connectedAt = Date.now();
910
998
  this.heartbeatTimer = setInterval(() => this.send({ type: "ping" }), HEARTBEAT_MS);
911
999
  this.armWatchdog();
@@ -945,9 +1033,12 @@ var RelayClient = class {
945
1033
  * infinite loop if a misbehaving server keeps claiming hasMore. */
946
1034
  async fetchAfterSeq(channelId, afterSeq) {
947
1035
  const out = [];
1036
+ const ch = this.pathId(channelId, "channelId");
1037
+ if (!ch) return out;
948
1038
  let cursor = afterSeq;
1039
+ let takenChars = 0;
949
1040
  for (let page = 0; page < RESUME_MAX_PAGES; page++) {
950
- const url = `${this.cfg.relayHttpUrl}/channels/${channelId}/messages?afterSeq=${cursor}`;
1041
+ const url = `${this.cfg.relayHttpUrl}/channels/${ch}/messages?afterSeq=${cursor}`;
951
1042
  let res;
952
1043
  try {
953
1044
  res = await this.deps.fetchFn(url, { headers: { Authorization: `Bearer ${this.cfg.token}` } });
@@ -966,11 +1057,14 @@ var RelayClient = class {
966
1057
  for (const m of list) {
967
1058
  const seq = Number(m.seq ?? 0);
968
1059
  if (seq > maxSeq) maxSeq = seq;
1060
+ const content = clampContent(String(m.content ?? ""));
1061
+ takenChars += content.length;
1062
+ this.backfillCharsThisConn += content.length;
969
1063
  out.push({
970
1064
  id: String(m.id ?? ""),
971
1065
  seq,
972
1066
  channelId,
973
- content: String(m.content ?? ""),
1067
+ content,
974
1068
  senderId: String(m.agentId ?? ""),
975
1069
  senderName: String(m.agentName ?? ""),
976
1070
  senderType: String(m.messageType ?? m.type ?? ""),
@@ -981,11 +1075,29 @@ var RelayClient = class {
981
1075
  });
982
1076
  }
983
1077
  if (!body.hasMore) return out;
1078
+ if (takenChars >= MAX_BACKFILL_CHARS_PER_CATCHUP || this.backfillCharsThisConn >= MAX_BACKFILL_CHARS_PER_CONNECTION) {
1079
+ console.warn("[arp-bridge] backfill content budget exhausted; truncating catch-up");
1080
+ return out;
1081
+ }
984
1082
  cursor = maxSeq;
985
1083
  }
986
1084
  console.warn("[arp-bridge] backfill hit page cap", RESUME_MAX_PAGES);
987
1085
  return out;
988
1086
  }
1087
+ /** Run a catch-up with bounded concurrency (BRIDGE-12): at most
1088
+ * MAX_CONCURRENT_CATCHUPS paginated backfill loops in flight; extras queue FIFO. */
1089
+ scheduleCatchUp(channelId, afterSeq) {
1090
+ const run = () => {
1091
+ this.activeCatchUps++;
1092
+ void this.catchUp(channelId, afterSeq).finally(() => {
1093
+ this.activeCatchUps--;
1094
+ const next = this.catchUpWaiters.shift();
1095
+ if (next) next();
1096
+ });
1097
+ };
1098
+ if (this.activeCatchUps < MAX_CONCURRENT_CATCHUPS) run();
1099
+ else this.catchUpWaiters.push(run);
1100
+ }
989
1101
  /** Mid-session gap-fill: replay missed messages live (each dedupes via emitInbound). */
990
1102
  async resumeAfterSeq(channelId, afterSeq) {
991
1103
  for (const m of await this.fetchAfterSeq(channelId, afterSeq)) this.emitInbound(m);
@@ -1011,6 +1123,8 @@ var RelayClient = class {
1011
1123
  this.remintAttempts++;
1012
1124
  console.log("[arp-bridge] access token rejected; re-minting from agent key");
1013
1125
  void this.cfg.mintToken().then((token) => {
1126
+ const verdict = validateReplacementToken(token, this.cfg.token);
1127
+ if (!verdict.ok) throw new Error(`re-minted token failed validation: ${verdict.reason}`);
1014
1128
  this.cfg.token = token;
1015
1129
  if (!this.stopped) this.connect();
1016
1130
  }).catch((err) => {
@@ -1089,18 +1203,24 @@ var RelayClient = class {
1089
1203
  case "channel_message":
1090
1204
  this.handleChannelMessage(msg);
1091
1205
  break;
1092
- case "token_refresh":
1093
- if (typeof msg.token === "string" && msg.token.length > 0) {
1094
- this.cfg.token = msg.token;
1095
- console.log("[arp-bridge] token refreshed");
1206
+ case "token_refresh": {
1207
+ if (typeof msg.token !== "string" || msg.token.length === 0) break;
1208
+ const verdict = validateReplacementToken(msg.token, this.cfg.token);
1209
+ if (!verdict.ok) {
1210
+ console.warn(`[arp-bridge] rejected token_refresh (${verdict.reason}); keeping the current token`);
1211
+ break;
1096
1212
  }
1213
+ this.cfg.token = msg.token;
1214
+ console.log("[arp-bridge] token refreshed");
1097
1215
  break;
1216
+ }
1098
1217
  case "removed": {
1099
1218
  const ch = String(msg.channelId ?? "");
1100
1219
  if (!ch) break;
1101
1220
  this.cursors.delete(ch);
1102
1221
  this.seenByChannel.delete(ch);
1103
1222
  this.caughtUp.delete(ch);
1223
+ this.lastGapResumeAt.delete(ch);
1104
1224
  this.removedCb?.(ch);
1105
1225
  break;
1106
1226
  }
@@ -1138,7 +1258,7 @@ var RelayClient = class {
1138
1258
  const floor = this.cursorOf(ch);
1139
1259
  if (!this.caughtUp.has(ch) && floor > 0) {
1140
1260
  this.caughtUp.add(ch);
1141
- void this.catchUp(ch, floor);
1261
+ this.scheduleCatchUp(ch, floor);
1142
1262
  }
1143
1263
  }
1144
1264
  }
@@ -1159,7 +1279,8 @@ var RelayClient = class {
1159
1279
  id: String(m.id ?? ""),
1160
1280
  seq: Number(m.seq ?? 0),
1161
1281
  channelId,
1162
- content: String(m.content ?? ""),
1282
+ content: clampContent(String(m.content ?? "")),
1283
+ // bound per-message memory/prompt size (BRIDGE-09)
1163
1284
  senderId: String(m.agentId ?? ""),
1164
1285
  senderName: String(m.agentName ?? ""),
1165
1286
  senderType: String(m.messageType ?? m.type ?? ""),
@@ -1170,7 +1291,13 @@ var RelayClient = class {
1170
1291
  const gapDetected = !inbound.isHistory && this.cursorOf(channelId) > 0 && inbound.seq > this.cursorOf(channelId) + 1;
1171
1292
  const gapFrom = this.cursorOf(channelId);
1172
1293
  this.emitInbound(inbound);
1173
- if (gapDetected) void this.resumeAfterSeq(channelId, gapFrom);
1294
+ if (gapDetected) {
1295
+ const now = Date.now();
1296
+ if (now - (this.lastGapResumeAt.get(channelId) ?? 0) >= GAP_RESUME_MIN_INTERVAL_MS) {
1297
+ this.lastGapResumeAt.set(channelId, now);
1298
+ void this.resumeAfterSeq(channelId, gapFrom);
1299
+ }
1300
+ }
1174
1301
  }
1175
1302
  /** Normalize a turn_notification / synthesis_request / direction_request into a FlowSignal and emit it. */
1176
1303
  handleFlowSignal(kind, msg) {
@@ -1179,7 +1306,8 @@ var RelayClient = class {
1179
1306
  const rawHistory = kind === "synthesis" || kind === "direction" ? msg.flowHistory ?? msg.messages ?? [] : msg.recentMessages ?? [];
1180
1307
  const history = (Array.isArray(rawHistory) ? rawHistory : []).map((e) => ({
1181
1308
  agentName: String(e.agentName ?? ""),
1182
- content: String(e.content ?? ""),
1309
+ content: clampContent(String(e.content ?? "")),
1310
+ // bound per-entry size (BRIDGE-09)
1183
1311
  messageType: e.messageType ?? e.type,
1184
1312
  turnNumber: e.turnNumber,
1185
1313
  createdAt: e.createdAt
@@ -1258,7 +1386,9 @@ var RelayClient = class {
1258
1386
  }
1259
1387
  /** Fetch the channel roster and return normalized bot entries (with cards). */
1260
1388
  async fetchRoster(channelId) {
1261
- const url = `${this.cfg.relayHttpUrl}/channels/${channelId}`;
1389
+ const ch = this.pathId(channelId, "channelId");
1390
+ if (!ch) return [];
1391
+ const url = `${this.cfg.relayHttpUrl}/channels/${ch}`;
1262
1392
  try {
1263
1393
  const res = await this.deps.fetchFn(url, { headers: { Authorization: `Bearer ${this.cfg.token}` } });
1264
1394
  if (!res.ok) {
@@ -1274,7 +1404,9 @@ var RelayClient = class {
1274
1404
  }
1275
1405
  }
1276
1406
  async postMessage(channelId, content) {
1277
- const url = `${this.cfg.relayHttpUrl}/channels/${channelId}/messages`;
1407
+ const ch = this.pathId(channelId, "channelId");
1408
+ if (!ch) return;
1409
+ const url = `${this.cfg.relayHttpUrl}/channels/${ch}/messages`;
1278
1410
  const body = JSON.stringify({
1279
1411
  id: randomUUID(),
1280
1412
  // client-generated -> server idempotent on retry
@@ -1300,7 +1432,10 @@ var RelayClient = class {
1300
1432
  * agentId MUST be the agent NAME — the relay's flow gate resolves turn ownership and
1301
1433
  * synthesis role via resolveAgentUUID (a name lookup); a UUID resolves to uuid.Nil -> 403. */
1302
1434
  async postFlowMessage(channelId, flowId, content) {
1303
- const url = `${this.cfg.relayHttpUrl}/channels/${channelId}/flows/${flowId}/messages`;
1435
+ const ch = this.pathId(channelId, "channelId");
1436
+ const fl = this.pathId(flowId, "flowId");
1437
+ if (!ch || !fl) return;
1438
+ const url = `${this.cfg.relayHttpUrl}/channels/${ch}/flows/${fl}/messages`;
1304
1439
  const body = JSON.stringify({
1305
1440
  id: randomUUID(),
1306
1441
  content,
@@ -1322,7 +1457,9 @@ var RelayClient = class {
1322
1457
  }
1323
1458
  /** Channel memory text ("" if none or on error — never throws). */
1324
1459
  async fetchChannelMemory(channelId) {
1325
- const url = `${this.cfg.relayHttpUrl}/channels/${channelId}/memory`;
1460
+ const ch = this.pathId(channelId, "channelId");
1461
+ if (!ch) return "";
1462
+ const url = `${this.cfg.relayHttpUrl}/channels/${ch}/memory`;
1326
1463
  try {
1327
1464
  const res = await this.deps.fetchFn(url, { headers: { Authorization: `Bearer ${this.cfg.token}` } });
1328
1465
  if (!res.ok) {
@@ -1339,7 +1476,9 @@ var RelayClient = class {
1339
1476
  /** Channel topics with message counts ([] if none or on error). The relay returns
1340
1477
  * topics with a `title` plus a separate `messageCounts` map keyed by topic id. */
1341
1478
  async fetchChannelTopics(channelId) {
1342
- const url = `${this.cfg.relayHttpUrl}/channels/${channelId}/topics`;
1479
+ const ch = this.pathId(channelId, "channelId");
1480
+ if (!ch) return [];
1481
+ const url = `${this.cfg.relayHttpUrl}/channels/${ch}/topics`;
1343
1482
  try {
1344
1483
  const res = await this.deps.fetchFn(url, { headers: { Authorization: `Bearer ${this.cfg.token}` } });
1345
1484
  if (!res.ok) {
@@ -1357,7 +1496,9 @@ var RelayClient = class {
1357
1496
  }
1358
1497
  /** Pinned files marked inject_context=true, labeled with cached content ([] otherwise). */
1359
1498
  async fetchPinnedContext(channelId) {
1360
- const url = `${this.cfg.relayHttpUrl}/channels/${channelId}/pins`;
1499
+ const ch = this.pathId(channelId, "channelId");
1500
+ if (!ch) return [];
1501
+ const url = `${this.cfg.relayHttpUrl}/channels/${ch}/pins`;
1361
1502
  try {
1362
1503
  const res = await this.deps.fetchFn(url, { headers: { Authorization: `Bearer ${this.cfg.token}` } });
1363
1504
  if (!res.ok) {
@@ -1387,7 +1528,10 @@ var RelayClient = class {
1387
1528
  }
1388
1529
  /** Fetch a flow's transcript (used to backfill a minimal turn_notification). [] on error. */
1389
1530
  async fetchFlowMessages(channelId, flowId) {
1390
- const url = `${this.cfg.relayHttpUrl}/channels/${channelId}/flows/${flowId}/messages`;
1531
+ const ch = this.pathId(channelId, "channelId");
1532
+ const fl = this.pathId(flowId, "flowId");
1533
+ if (!ch || !fl) return [];
1534
+ const url = `${this.cfg.relayHttpUrl}/channels/${ch}/flows/${fl}/messages`;
1391
1535
  try {
1392
1536
  const res = await this.deps.fetchFn(url, { headers: { Authorization: `Bearer ${this.cfg.token}` } });
1393
1537
  if (!res.ok) {
@@ -1461,6 +1605,22 @@ ${fence("flow topic", signal.topic)}
1461
1605
  ` + role + ctx + synth + "\n" + history + closer;
1462
1606
  }
1463
1607
 
1608
+ // src/promptLimit.ts
1609
+ var DEFAULT_MAX_PROMPT_CHARS = 4e5;
1610
+ function maxPromptChars(env = process.env) {
1611
+ const n = Number(env.ARP_MAX_PROMPT_CHARS);
1612
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_MAX_PROMPT_CHARS;
1613
+ }
1614
+ function capPrompt(prompt, max = maxPromptChars()) {
1615
+ if (prompt.length <= max) return prompt;
1616
+ const headLen = Math.floor(max * 0.8);
1617
+ const tailLen = max - headLen;
1618
+ const removed = prompt.length - headLen - tailLen;
1619
+ return prompt.slice(0, headLen) + `
1620
+ [...prompt truncated by bridge: ${removed} chars of channel content removed...]
1621
+ ` + prompt.slice(prompt.length - tailLen);
1622
+ }
1623
+
1464
1624
  // src/session.ts
1465
1625
  var SILENCE_SENTINEL = "<<silent>>";
1466
1626
  var ChannelSession = class {
@@ -1534,7 +1694,7 @@ ${fence("channel message", msg.content)}
1534
1694
  ` + rosterBlock;
1535
1695
  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.`;
1536
1696
  this.beacon?.begin();
1537
- this.session.submit(head + instructions);
1697
+ this.session.submit(capPrompt(head + instructions));
1538
1698
  }
1539
1699
  /**
1540
1700
  * Run one bounded-flow turn or synthesis. Frames a MUST-RESPOND prompt (no
@@ -1555,7 +1715,7 @@ ${fence("channel message", msg.content)}
1555
1715
  let history = signal.history;
1556
1716
  if (history.length === 0) history = await this.flow.fetchHistory(signal.flowId);
1557
1717
  const prompt = buildFlowPrompt({ ...signal, history }, this.agentName, this.channelId, this.toolMode);
1558
- const reply = await this.session.converseLocal(prompt);
1718
+ const reply = await this.session.converseLocal(capPrompt(prompt));
1559
1719
  await this.flow.postReply(signal.flowId, reply.trim() || "(no response)");
1560
1720
  } finally {
1561
1721
  this.beacon?.end();
@@ -1595,10 +1755,10 @@ ${fence("channel message", msg.content)}
1595
1755
  if (!this.session.converseLocal) return;
1596
1756
  this.beacon?.begin();
1597
1757
  try {
1598
- await this.session.converseLocal(
1758
+ await this.session.converseLocal(capPrompt(
1599
1759
  this.promptHead() + `You just reconnected to ARP channel ${this.channelId} after being away. Here is what you missed (context only, do NOT reply to it):
1600
1760
  ` + fence("missed channel messages", transcript)
1601
- );
1761
+ ));
1602
1762
  } finally {
1603
1763
  this.beacon?.end();
1604
1764
  }
@@ -1615,7 +1775,7 @@ ${fence("messages mentioning you", addressed)}
1615
1775
  `;
1616
1776
  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.`;
1617
1777
  this.beacon?.begin();
1618
- this.session.submit(head + instructions);
1778
+ this.session.submit(capPrompt(head + instructions));
1619
1779
  }
1620
1780
  async stop() {
1621
1781
  this.beacon?.stop?.();
@@ -1717,13 +1877,13 @@ function dropVendorNotifications(input) {
1717
1877
 
1718
1878
  // src/acp/client.ts
1719
1879
  var MODEL_AUTH_ENV_KEYS = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
1720
- var BRIDGE_CRED_ENV_KEYS = ["ARP_TOKEN", "ARP_INVITE", "ARP_CONFIG_DIR"];
1880
+ var BRIDGE_ENV_PREFIX = "ARP_";
1721
1881
  function buildAcpEnv(base, extra) {
1722
1882
  const merged = {};
1723
1883
  for (const [k, v] of Object.entries({ ...base, ...extra ?? {} })) {
1724
1884
  if (v === void 0) continue;
1725
1885
  if (MODEL_AUTH_ENV_KEYS.includes(k)) continue;
1726
- if (BRIDGE_CRED_ENV_KEYS.includes(k)) continue;
1886
+ if (k.startsWith(BRIDGE_ENV_PREFIX)) continue;
1727
1887
  merged[k] = v;
1728
1888
  }
1729
1889
  return merged;
@@ -2291,7 +2451,7 @@ var ClaudeAdapter = class {
2291
2451
  if (full.length > 0) turnCbs.forEach((cb) => cb(full));
2292
2452
  } else {
2293
2453
  buffer = "";
2294
- console.warn("[arp-bridge] agent turn ended without success:", m.subtype);
2454
+ console.warn("[arp-bridge] agent turn ended without success:", sanitizeForTty(String(m.subtype)));
2295
2455
  }
2296
2456
  }
2297
2457
  }
@@ -2516,11 +2676,13 @@ function reportFatalClose(code, reason) {
2516
2676
  console.error(`[arp-bridge] relay rejected the connection (close ${code}): ${sanitizeForTty(reason)}. Not retrying.`);
2517
2677
  }
2518
2678
  }
2679
+ function makeDefaultWsFactory(WebSocketImpl) {
2680
+ return (url, protocols) => new WebSocketImpl(url, protocols, { maxPayload: WS_MAX_PAYLOAD_BYTES });
2681
+ }
2519
2682
  async function createAndStartBridge(cfg, deps = {}) {
2520
2683
  let wsFactory = deps.wsFactory;
2521
2684
  if (!wsFactory) {
2522
- const WebSocketImpl = (await import("ws")).default;
2523
- wsFactory = (url, protocols) => new WebSocketImpl(url, protocols);
2685
+ wsFactory = makeDefaultWsFactory((await import("ws")).default);
2524
2686
  }
2525
2687
  const relay = new RelayClient(cfg, {
2526
2688
  wsFactory,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snowyroad/arp",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Connect your own coding agent (Claude Code, Codex, Gemini, Grok) to an Agent Relay Protocol channel and collaborate with other agents and humans.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "SnowyRoad",
@@ -33,7 +33,6 @@
33
33
  "prepublishOnly": "pnpm build",
34
34
  "dev": "tsx src/index.ts",
35
35
  "join": "tsx src/index.ts",
36
- "generate-invite": "tsx scripts/generate-invite.ts",
37
36
  "test": "vitest run",
38
37
  "test:watch": "vitest",
39
38
  "test:integration": "vitest run --config vitest.integration.config.ts",