@snowyroad/arp 0.3.3 → 0.3.5

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 +96 -51
  2. package/package.json +10 -6
package/dist/cli.js CHANGED
@@ -680,6 +680,33 @@ function redactConfig(cfg) {
680
680
  import { randomUUID } from "crypto";
681
681
 
682
682
  // src/untrusted.ts
683
+ function untrusted(s) {
684
+ return s;
685
+ }
686
+ function rawUntrusted(u) {
687
+ return u;
688
+ }
689
+ function utext(strings, ...values) {
690
+ let out = strings[0];
691
+ for (let i = 0; i < values.length; i++) out += String(values[i]) + strings[i + 1];
692
+ return out;
693
+ }
694
+ function joinUntrusted(parts, sep2) {
695
+ return parts.join(sep2);
696
+ }
697
+ function firstNonEmpty(parts, fallback) {
698
+ for (const p of parts) if (p !== "") return p;
699
+ return fallback;
700
+ }
701
+ function hasText(u) {
702
+ return u !== "";
703
+ }
704
+ function isBlankText(u) {
705
+ return u.trim() === "";
706
+ }
707
+ function sameText(u, s) {
708
+ return u === s;
709
+ }
683
710
  var MARKER_RE = /<<<(?=[\s\u200B-\u200D\u2060\uFEFF]*(?:END[\s\u200B-\u200D\u2060\uFEFF]+)?UNTRUSTED)/gi;
684
711
  function neutralizeMarkers(content) {
685
712
  return content.replace(MARKER_RE, "<<\\<");
@@ -690,7 +717,7 @@ function sanitizeLabel(label) {
690
717
  function fence(label, content) {
691
718
  const l = sanitizeLabel(label);
692
719
  return `<<<UNTRUSTED ${l}>>>
693
- ${neutralizeMarkers(content)}
720
+ ${neutralizeMarkers(rawUntrusted(content))}
694
721
  <<<END UNTRUSTED ${l}>>>`;
695
722
  }
696
723
  function untrustedPreamble(mode) {
@@ -774,24 +801,24 @@ function normalizeRosterEntry(name, memberDescription, card) {
774
801
  if (typeof card.description === "string" && card.description) description = card.description;
775
802
  if (Array.isArray(card.skills)) skills = card.skills.map((s) => s && typeof s.name === "string" ? s.name : "").filter(Boolean);
776
803
  }
777
- return { name, description, skills };
804
+ return { name: untrusted(name), description: untrusted(description), skills: skills.map(untrusted) };
778
805
  }
779
806
  function assembleRosterFacts(entries, selfName) {
780
- const peers = entries.filter((e) => e.name !== selfName);
807
+ const peers = entries.filter((e) => !sameText(e.name, selfName));
781
808
  if (peers.length === 0) return "";
782
809
  const lines = peers.map((p) => {
783
- const desc = p.description ? `: ${p.description}` : "";
784
- const skills = p.skills.length ? ` [skills: ${p.skills.join(", ")}]` : "";
785
- return `- ${p.name}${desc}${skills}`;
810
+ const desc = hasText(p.description) ? utext`: ${p.description}` : "";
811
+ const skills = p.skills.length ? utext` [skills: ${joinUntrusted(p.skills, ", ")}]` : "";
812
+ return utext`- ${p.name}${desc}${skills}`;
786
813
  });
787
814
  return `Also in this channel:
788
- ${fence("peer roster", lines.join("\n"))}`;
815
+ ${fence("peer roster", joinUntrusted(lines, "\n"))}`;
789
816
  }
790
817
 
791
818
  // src/channelContext.ts
792
819
  function buildChannelContext(input) {
793
820
  let out = "";
794
- if (input.memory.trim()) {
821
+ if (!isBlankText(input.memory)) {
795
822
  out += `## Channel Memory (shared context for this channel)
796
823
  ${fence("channel memory", input.memory)}
797
824
  ---
@@ -799,8 +826,7 @@ ${fence("channel memory", input.memory)}
799
826
  `;
800
827
  }
801
828
  if (input.pins.length > 0) {
802
- const sections = input.pins.map((p) => fence("pinned file", `\u{1F4CC} ${p.label}
803
- ${p.content}`));
829
+ const sections = input.pins.map((p) => fence("pinned file", utext`📌 ${p.label}\n${p.content}`));
804
830
  out += `## Pinned Files (from GitHub)
805
831
  ${sections.join("\n\n")}
806
832
  ---
@@ -810,10 +836,10 @@ ${sections.join("\n\n")}
810
836
  if (input.topics.length > 0) {
811
837
  const lines = input.topics.map((t) => {
812
838
  const count = t.count != null ? ` (${t.count} messages)` : "";
813
- return `- ${t.title}${count}`;
839
+ return utext`- ${t.title}${count}`;
814
840
  });
815
841
  out += `## Channel Topics
816
- ${fence("channel topic titles", lines.join("\n"))}
842
+ ${fence("channel topic titles", joinUntrusted(lines, "\n"))}
817
843
  ---
818
844
 
819
845
  `;
@@ -825,18 +851,18 @@ ${fence("channel topic titles", lines.join("\n"))}
825
851
  function isAddressed(content, agentName) {
826
852
  const name = agentName.trim();
827
853
  if (!name) return false;
828
- const c = content.toLowerCase();
854
+ const c = rawUntrusted(content).toLowerCase();
829
855
  const forms = /* @__PURE__ */ new Set([name.toLowerCase(), name.toLowerCase().replace(/\s+/g, "_")]);
830
856
  for (const f of forms) if (c.includes("@" + f)) return true;
831
857
  return false;
832
858
  }
833
859
  function classifyCatchUp(messages, agentName, nowMs, opts) {
834
860
  const inWindow = messages.filter((m) => {
835
- const t = Date.parse(m.createdAt);
861
+ const t = Date.parse(rawUntrusted(m.createdAt));
836
862
  return Number.isFinite(t) ? t >= nowMs - opts.ttlMs : true;
837
863
  });
838
864
  const mentions = inWindow.filter(
839
- (m) => m.senderName !== agentName && isAddressed(m.content, agentName)
865
+ (m) => !sameText(m.senderName, agentName) && isAddressed(m.content, agentName)
840
866
  );
841
867
  const capped = mentions.slice(-opts.maxMentions);
842
868
  return { context: inWindow, mentions: capped };
@@ -904,6 +930,8 @@ var RelayClient = class {
904
930
  graceTimer = null;
905
931
  confirmed = false;
906
932
  // single-shot: onReady has fired (relay accepted the agent)
933
+ reconnectAnnounced = false;
934
+ // per-connection: "reconnected" printed for this connection
907
935
  catchUpCbs = [];
908
936
  caughtUp = /* @__PURE__ */ new Set();
909
937
  // channels caught up this connection
@@ -995,6 +1023,7 @@ var RelayClient = class {
995
1023
  this.caughtUp.clear();
996
1024
  this.backfillCharsThisConn = 0;
997
1025
  this.connectedAt = Date.now();
1026
+ this.reconnectAnnounced = false;
998
1027
  this.heartbeatTimer = setInterval(() => this.send({ type: "ping" }), HEARTBEAT_MS);
999
1028
  this.armWatchdog();
1000
1029
  this.stableTimer = setTimeout(() => {
@@ -1005,13 +1034,20 @@ var RelayClient = class {
1005
1034
  this.graceTimer = setTimeout(() => this.confirmReady(), AUTH_GRACE_MS);
1006
1035
  this.onConnected();
1007
1036
  }
1008
- /** Single-shot: fires onReady at most once, on the FIRST successful connect. */
1037
+ /** Fires onReady at most once, on the FIRST successful connect; on later
1038
+ * connections it announces the reconnect instead (once per connection). */
1009
1039
  confirmReady() {
1010
1040
  if (this.graceTimer) {
1011
1041
  clearTimeout(this.graceTimer);
1012
1042
  this.graceTimer = null;
1013
1043
  }
1014
- if (this.confirmed) return;
1044
+ if (this.confirmed) {
1045
+ if (!this.reconnectAnnounced) {
1046
+ this.reconnectAnnounced = true;
1047
+ console.log("[arp-bridge] reconnected");
1048
+ }
1049
+ return;
1050
+ }
1015
1051
  this.confirmed = true;
1016
1052
  this.readyCb?.();
1017
1053
  }
@@ -1064,12 +1100,12 @@ var RelayClient = class {
1064
1100
  id: String(m.id ?? ""),
1065
1101
  seq,
1066
1102
  channelId,
1067
- content,
1068
- senderId: String(m.agentId ?? ""),
1069
- senderName: String(m.agentName ?? ""),
1103
+ content: untrusted(content),
1104
+ senderId: untrusted(String(m.agentId ?? "")),
1105
+ senderName: untrusted(String(m.agentName ?? "")),
1070
1106
  senderType: String(m.messageType ?? m.type ?? ""),
1071
1107
  // relay live/resume key is messageType; history path uses type
1072
- createdAt: String(m.createdAt ?? ""),
1108
+ createdAt: untrusted(String(m.createdAt ?? "")),
1073
1109
  isHistory: false
1074
1110
  // shape parity with live messages; the caller decides how to handle them
1075
1111
  });
@@ -1189,7 +1225,7 @@ var RelayClient = class {
1189
1225
  }
1190
1226
  onMessage(raw) {
1191
1227
  this.armWatchdog();
1192
- if (!this.confirmed) this.confirmReady();
1228
+ this.confirmReady();
1193
1229
  let msg;
1194
1230
  try {
1195
1231
  msg = JSON.parse(raw);
@@ -1279,13 +1315,13 @@ var RelayClient = class {
1279
1315
  id: String(m.id ?? ""),
1280
1316
  seq: Number(m.seq ?? 0),
1281
1317
  channelId,
1282
- content: clampContent(String(m.content ?? "")),
1318
+ content: untrusted(clampContent(String(m.content ?? ""))),
1283
1319
  // bound per-message memory/prompt size (BRIDGE-09)
1284
- senderId: String(m.agentId ?? ""),
1285
- senderName: String(m.agentName ?? ""),
1320
+ senderId: untrusted(String(m.agentId ?? "")),
1321
+ senderName: untrusted(String(m.agentName ?? "")),
1286
1322
  senderType: String(m.messageType ?? m.type ?? ""),
1287
1323
  // relay live/resume key is messageType; history path uses type
1288
- createdAt: String(m.createdAt ?? ""),
1324
+ createdAt: untrusted(String(m.createdAt ?? "")),
1289
1325
  isHistory: Boolean(msg.isHistory)
1290
1326
  };
1291
1327
  const gapDetected = !inbound.isHistory && this.cursorOf(channelId) > 0 && inbound.seq > this.cursorOf(channelId) + 1;
@@ -1305,8 +1341,8 @@ var RelayClient = class {
1305
1341
  if (!flowId) return;
1306
1342
  const rawHistory = kind === "synthesis" || kind === "direction" ? msg.flowHistory ?? msg.messages ?? [] : msg.recentMessages ?? [];
1307
1343
  const history = (Array.isArray(rawHistory) ? rawHistory : []).map((e) => ({
1308
- agentName: String(e.agentName ?? ""),
1309
- content: clampContent(String(e.content ?? "")),
1344
+ agentName: untrusted(String(e.agentName ?? "")),
1345
+ content: untrusted(clampContent(String(e.content ?? ""))),
1310
1346
  // bound per-entry size (BRIDGE-09)
1311
1347
  messageType: e.messageType ?? e.type,
1312
1348
  turnNumber: e.turnNumber,
@@ -1316,11 +1352,11 @@ var RelayClient = class {
1316
1352
  kind,
1317
1353
  flowId,
1318
1354
  channelId: String(msg.channelId ?? ""),
1319
- topic: String(msg.topic ?? ""),
1320
- rolePrompt: typeof msg.rolePrompt === "string" ? msg.rolePrompt : void 0,
1321
- contextPrompt: typeof msg.contextPrompt === "string" ? msg.contextPrompt : void 0,
1322
- synthesisPrompt: typeof msg.synthesisPrompt === "string" ? msg.synthesisPrompt : void 0,
1323
- candidates: Array.isArray(msg.candidates) ? msg.candidates : void 0,
1355
+ topic: untrusted(String(msg.topic ?? "")),
1356
+ rolePrompt: typeof msg.rolePrompt === "string" ? untrusted(msg.rolePrompt) : void 0,
1357
+ contextPrompt: typeof msg.contextPrompt === "string" ? untrusted(msg.contextPrompt) : void 0,
1358
+ synthesisPrompt: typeof msg.synthesisPrompt === "string" ? untrusted(msg.synthesisPrompt) : void 0,
1359
+ candidates: Array.isArray(msg.candidates) ? msg.candidates.map((c) => untrusted(String(c ?? ""))) : void 0,
1324
1360
  history
1325
1361
  };
1326
1362
  this.flowCbs.forEach((cb) => cb(signal));
@@ -1455,22 +1491,22 @@ var RelayClient = class {
1455
1491
  console.warn("[arp-bridge] flow post failed:", sanitizeForTty(String(err)));
1456
1492
  }
1457
1493
  }
1458
- /** Channel memory text ("" if none or on error — never throws). */
1494
+ /** Channel memory text (empty if none or on error — never throws). Branded (H2-4). */
1459
1495
  async fetchChannelMemory(channelId) {
1460
1496
  const ch = this.pathId(channelId, "channelId");
1461
- if (!ch) return "";
1497
+ if (!ch) return untrusted("");
1462
1498
  const url = `${this.cfg.relayHttpUrl}/channels/${ch}/memory`;
1463
1499
  try {
1464
1500
  const res = await this.deps.fetchFn(url, { headers: { Authorization: `Bearer ${this.cfg.token}` } });
1465
1501
  if (!res.ok) {
1466
1502
  console.warn("[arp-bridge] memory HTTP", res.status);
1467
- return "";
1503
+ return untrusted("");
1468
1504
  }
1469
1505
  const data = await res.json();
1470
- return typeof data?.content === "string" ? data.content : "";
1506
+ return untrusted(typeof data?.content === "string" ? data.content : "");
1471
1507
  } catch (err) {
1472
1508
  console.warn("[arp-bridge] memory fetch failed:", sanitizeForTty(String(err)));
1473
- return "";
1509
+ return untrusted("");
1474
1510
  }
1475
1511
  }
1476
1512
  /** Channel topics with message counts ([] if none or on error). The relay returns
@@ -1488,7 +1524,7 @@ var RelayClient = class {
1488
1524
  const data = await res.json();
1489
1525
  const topics = data?.topics ?? [];
1490
1526
  const counts = data?.messageCounts ?? {};
1491
- return topics.filter((t) => typeof t?.title === "string").map((t) => ({ title: t.title, count: typeof counts[t.id] === "number" ? counts[t.id] : null }));
1527
+ return topics.filter((t) => typeof t?.title === "string").map((t) => ({ title: untrusted(t.title), count: typeof counts[t.id] === "number" ? counts[t.id] : null }));
1492
1528
  } catch (err) {
1493
1529
  console.warn("[arp-bridge] topics fetch failed:", sanitizeForTty(String(err)));
1494
1530
  return [];
@@ -1507,7 +1543,10 @@ var RelayClient = class {
1507
1543
  }
1508
1544
  const data = await res.json();
1509
1545
  const pins = data?.pins ?? [];
1510
- 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 }));
1546
+ return pins.filter((p) => p?.injectContext && typeof p.cachedContent === "string" && p.cachedContent.trim()).map((p) => ({
1547
+ label: untrusted(p.displayName || `${p.repoUrl ?? ""}/${p.filePath ?? ""}`),
1548
+ content: untrusted(p.cachedContent)
1549
+ }));
1511
1550
  } catch (err) {
1512
1551
  console.warn("[arp-bridge] pins fetch failed:", sanitizeForTty(String(err)));
1513
1552
  return [];
@@ -1541,8 +1580,8 @@ var RelayClient = class {
1541
1580
  const data = await res.json();
1542
1581
  const list = Array.isArray(data) ? data : data.messages ?? [];
1543
1582
  return list.map((e) => ({
1544
- agentName: String(e.agentName ?? ""),
1545
- content: String(e.content ?? ""),
1583
+ agentName: untrusted(String(e.agentName ?? "")),
1584
+ content: untrusted(String(e.content ?? "")),
1546
1585
  messageType: e.messageType ?? e.type,
1547
1586
  turnNumber: e.turnNumber,
1548
1587
  createdAt: e.createdAt
@@ -1557,15 +1596,15 @@ var RelayClient = class {
1557
1596
  // src/flow.ts
1558
1597
  function renderFlowHistory(entries) {
1559
1598
  if (entries.length === 0) return "";
1560
- const lines = entries.map((e) => `${e.agentName || "someone"}: ${e.content}`);
1599
+ const lines = entries.map((e) => utext`${firstNonEmpty([e.agentName], "someone")}: ${e.content}`);
1561
1600
  return `DISCUSSION HISTORY:
1562
- ${fence("flow discussion history", lines.join("\n"))}
1601
+ ${fence("flow discussion history", joinUntrusted(lines, "\n"))}
1563
1602
 
1564
1603
  `;
1565
1604
  }
1566
1605
  function buildFlowPrompt(signal, agentName, channelId, toolMode = "readonly") {
1567
1606
  if (signal.kind === "direction") {
1568
- const candidates = (signal.candidates ?? []).join(", ");
1607
+ const candidates = joinUntrusted(signal.candidates ?? [], ", ");
1569
1608
  const history2 = renderFlowHistory(signal.history);
1570
1609
  const hasHistory = history2 !== "";
1571
1610
  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.`];
@@ -1577,7 +1616,7 @@ function buildFlowPrompt(signal, agentName, channelId, toolMode = "readonly") {
1577
1616
  fence("flow topic", signal.topic),
1578
1617
  ...preamble,
1579
1618
  `Available participants:`,
1580
- fence("flow participant names", candidates || "(none online)"),
1619
+ fence("flow participant names", firstNonEmpty([candidates], "(none online)")),
1581
1620
  ``,
1582
1621
  `Reply with ONLY the name of the single participant who should speak next,`,
1583
1622
  `or reply with ONLY the word END to conclude the discussion and move to synthesis.`,
@@ -1679,7 +1718,7 @@ ${toolStatusLine(this.toolMode)}
1679
1718
  */
1680
1719
  async submit(msg) {
1681
1720
  if (!this.session) throw new Error("ChannelSession not started");
1682
- const who = msg.senderName || msg.senderId || "someone";
1721
+ const who = firstNonEmpty([msg.senderName, msg.senderId], "someone");
1683
1722
  const facts = assembleRosterFacts(this.roster, this.agentName);
1684
1723
  const rosterBlock = facts ? `${facts}
1685
1724
 
@@ -1750,7 +1789,10 @@ ${fence("channel message", msg.content)}
1750
1789
  async submitCatchUp(result) {
1751
1790
  if (!this.session) throw new Error("ChannelSession not started");
1752
1791
  if (result.context.length === 0) return;
1753
- const transcript = result.context.map((m) => `[${m.createdAt}] ${m.senderName || m.senderId || "someone"}: ${m.content}`).join("\n");
1792
+ const transcript = joinUntrusted(
1793
+ result.context.map((m) => utext`[${m.createdAt}] ${firstNonEmpty([m.senderName, m.senderId], "someone")}: ${m.content}`),
1794
+ "\n"
1795
+ );
1754
1796
  if (result.mentions.length === 0) {
1755
1797
  if (!this.session.converseLocal) return;
1756
1798
  this.beacon?.begin();
@@ -1765,7 +1807,10 @@ ${fence("channel message", msg.content)}
1765
1807
  return;
1766
1808
  }
1767
1809
  const channelContext = this.fetchContext ? await this.fetchContext() : "";
1768
- const addressed = result.mentions.map((m) => `[${m.createdAt}] ${m.senderName || m.senderId || "someone"}: ${m.content}`).join("\n");
1810
+ const addressed = joinUntrusted(
1811
+ result.mentions.map((m) => utext`[${m.createdAt}] ${firstNonEmpty([m.senderName, m.senderId], "someone")}: ${m.content}`),
1812
+ "\n"
1813
+ );
1769
1814
  const head = this.promptHead() + channelContext + `You are ${this.agentName}. You just reconnected to ARP channel ${this.channelId} after being away. While you were gone, the channel said (context):
1770
1815
  ${fence("missed channel messages", transcript)}
1771
1816
 
@@ -2613,8 +2658,8 @@ async function startBridge(cfg, relay, deps) {
2613
2658
  }
2614
2659
  relay.onInbound((m) => {
2615
2660
  if (m.isHistory) return;
2616
- if (m.senderId && m.senderId === cfg.agentUuid || m.senderName && m.senderName === cfg.agentName) return;
2617
- if (!m.content.trim()) return;
2661
+ if (hasText(m.senderId) && sameText(m.senderId, cfg.agentUuid) || hasText(m.senderName) && sameText(m.senderName, cfg.agentName)) return;
2662
+ if (isBlankText(m.content)) return;
2618
2663
  ensureSession(m.channelId).then((s) => s.submit(m)).catch((e) => console.warn(`[arp-bridge] inbound routing failed for channel ${sanitizeForTty(m.channelId)}:`, sanitizeForTty(String(e))));
2619
2664
  });
2620
2665
  relay.onFlowSignal((signal) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snowyroad/arp",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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",
@@ -20,6 +20,7 @@
20
20
  "engines": {
21
21
  "node": ">=20"
22
22
  },
23
+ "packageManager": "pnpm@9.15.4",
23
24
  "bin": {
24
25
  "arp": "dist/cli.js"
25
26
  },
@@ -39,16 +40,19 @@
39
40
  "typecheck": "tsc --noEmit"
40
41
  },
41
42
  "dependencies": {
42
- "@agentclientprotocol/sdk": "0.24.0",
43
- "@anthropic-ai/claude-agent-sdk": "0.1.77",
44
- "ws": "8.21.0"
43
+ "@agentclientprotocol/sdk": "0.25.1",
44
+ "@anthropic-ai/claude-agent-sdk": "0.3.177",
45
+ "@anthropic-ai/sdk": "0.104.1",
46
+ "@modelcontextprotocol/sdk": "1.29.0",
47
+ "ws": "8.21.0",
48
+ "zod": "4.4.3"
45
49
  },
46
50
  "devDependencies": {
47
- "@types/node": "^22.0.0",
51
+ "@types/node": "^25.9.3",
48
52
  "@types/ws": "^8.5.12",
49
53
  "tsup": "^8.5.1",
50
54
  "tsx": "^4.19.0",
51
- "typescript": "^5.6.0",
55
+ "typescript": "^6.0.3",
52
56
  "vitest": "^2.1.0"
53
57
  }
54
58
  }