@snowyroad/arp 0.3.2 → 0.3.4
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.
- package/dist/cli.js +296 -86
- package/package.json +10 -7
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",
|
|
@@ -640,6 +680,33 @@ function redactConfig(cfg) {
|
|
|
640
680
|
import { randomUUID } from "crypto";
|
|
641
681
|
|
|
642
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
|
+
}
|
|
643
710
|
var MARKER_RE = /<<<(?=[\s\u200B-\u200D\u2060\uFEFF]*(?:END[\s\u200B-\u200D\u2060\uFEFF]+)?UNTRUSTED)/gi;
|
|
644
711
|
function neutralizeMarkers(content) {
|
|
645
712
|
return content.replace(MARKER_RE, "<<\\<");
|
|
@@ -650,7 +717,7 @@ function sanitizeLabel(label) {
|
|
|
650
717
|
function fence(label, content) {
|
|
651
718
|
const l = sanitizeLabel(label);
|
|
652
719
|
return `<<<UNTRUSTED ${l}>>>
|
|
653
|
-
${neutralizeMarkers(content)}
|
|
720
|
+
${neutralizeMarkers(rawUntrusted(content))}
|
|
654
721
|
<<<END UNTRUSTED ${l}>>>`;
|
|
655
722
|
}
|
|
656
723
|
function untrustedPreamble(mode) {
|
|
@@ -734,24 +801,24 @@ function normalizeRosterEntry(name, memberDescription, card) {
|
|
|
734
801
|
if (typeof card.description === "string" && card.description) description = card.description;
|
|
735
802
|
if (Array.isArray(card.skills)) skills = card.skills.map((s) => s && typeof s.name === "string" ? s.name : "").filter(Boolean);
|
|
736
803
|
}
|
|
737
|
-
return { name, description, skills };
|
|
804
|
+
return { name: untrusted(name), description: untrusted(description), skills: skills.map(untrusted) };
|
|
738
805
|
}
|
|
739
806
|
function assembleRosterFacts(entries, selfName) {
|
|
740
|
-
const peers = entries.filter((e) => e.name
|
|
807
|
+
const peers = entries.filter((e) => !sameText(e.name, selfName));
|
|
741
808
|
if (peers.length === 0) return "";
|
|
742
809
|
const lines = peers.map((p) => {
|
|
743
|
-
const desc = p.description ? `: ${p.description}` : "";
|
|
744
|
-
const skills = p.skills.length ? ` [skills: ${p.skills
|
|
745
|
-
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}`;
|
|
746
813
|
});
|
|
747
814
|
return `Also in this channel:
|
|
748
|
-
${fence("peer roster", lines
|
|
815
|
+
${fence("peer roster", joinUntrusted(lines, "\n"))}`;
|
|
749
816
|
}
|
|
750
817
|
|
|
751
818
|
// src/channelContext.ts
|
|
752
819
|
function buildChannelContext(input) {
|
|
753
820
|
let out = "";
|
|
754
|
-
if (input.memory
|
|
821
|
+
if (!isBlankText(input.memory)) {
|
|
755
822
|
out += `## Channel Memory (shared context for this channel)
|
|
756
823
|
${fence("channel memory", input.memory)}
|
|
757
824
|
---
|
|
@@ -759,8 +826,7 @@ ${fence("channel memory", input.memory)}
|
|
|
759
826
|
`;
|
|
760
827
|
}
|
|
761
828
|
if (input.pins.length > 0) {
|
|
762
|
-
const sections = input.pins.map((p) => fence("pinned file",
|
|
763
|
-
${p.content}`));
|
|
829
|
+
const sections = input.pins.map((p) => fence("pinned file", utext`📌 ${p.label}\n${p.content}`));
|
|
764
830
|
out += `## Pinned Files (from GitHub)
|
|
765
831
|
${sections.join("\n\n")}
|
|
766
832
|
---
|
|
@@ -770,10 +836,10 @@ ${sections.join("\n\n")}
|
|
|
770
836
|
if (input.topics.length > 0) {
|
|
771
837
|
const lines = input.topics.map((t) => {
|
|
772
838
|
const count = t.count != null ? ` (${t.count} messages)` : "";
|
|
773
|
-
return `- ${t.title}${count}`;
|
|
839
|
+
return utext`- ${t.title}${count}`;
|
|
774
840
|
});
|
|
775
841
|
out += `## Channel Topics
|
|
776
|
-
${fence("channel topic titles", lines
|
|
842
|
+
${fence("channel topic titles", joinUntrusted(lines, "\n"))}
|
|
777
843
|
---
|
|
778
844
|
|
|
779
845
|
`;
|
|
@@ -785,21 +851,21 @@ ${fence("channel topic titles", lines.join("\n"))}
|
|
|
785
851
|
function isAddressed(content, agentName) {
|
|
786
852
|
const name = agentName.trim();
|
|
787
853
|
if (!name) return false;
|
|
788
|
-
const c = content.toLowerCase();
|
|
854
|
+
const c = rawUntrusted(content).toLowerCase();
|
|
789
855
|
const forms = /* @__PURE__ */ new Set([name.toLowerCase(), name.toLowerCase().replace(/\s+/g, "_")]);
|
|
790
856
|
for (const f of forms) if (c.includes("@" + f)) return true;
|
|
791
857
|
return false;
|
|
792
858
|
}
|
|
793
859
|
function classifyCatchUp(messages, agentName, nowMs, opts) {
|
|
794
|
-
const
|
|
795
|
-
|
|
796
|
-
|
|
860
|
+
const mentions = messages.filter((m) => {
|
|
861
|
+
if (m.seq <= opts.deliveredMaxSeq) return false;
|
|
862
|
+
const t = Date.parse(rawUntrusted(m.createdAt));
|
|
863
|
+
const withinTtl = Number.isFinite(t) ? t >= nowMs - opts.ttlMs : true;
|
|
864
|
+
if (!withinTtl) return false;
|
|
865
|
+
return !sameText(m.senderName, agentName) && isAddressed(m.content, agentName);
|
|
797
866
|
});
|
|
798
|
-
const mentions = inWindow.filter(
|
|
799
|
-
(m) => m.senderName !== agentName && isAddressed(m.content, agentName)
|
|
800
|
-
);
|
|
801
867
|
const capped = mentions.slice(-opts.maxMentions);
|
|
802
|
-
return { context:
|
|
868
|
+
return { context: messages, mentions: capped };
|
|
803
869
|
}
|
|
804
870
|
|
|
805
871
|
// src/relayClient.ts
|
|
@@ -811,6 +877,22 @@ var AUTH_GRACE_MS = 1500;
|
|
|
811
877
|
var CATCHUP_WINDOW_MS = 8e3;
|
|
812
878
|
var SEEN_CAP = 5e3;
|
|
813
879
|
var RESUME_MAX_PAGES = 200;
|
|
880
|
+
var WS_MAX_PAYLOAD_BYTES = 4 * 1024 * 1024;
|
|
881
|
+
var MAX_MESSAGE_CONTENT_CHARS = 65536;
|
|
882
|
+
var MAX_BACKFILL_CHARS_PER_CATCHUP = 2e6;
|
|
883
|
+
var MAX_BACKFILL_CHARS_PER_CONNECTION = 8e6;
|
|
884
|
+
var MAX_CONCURRENT_CATCHUPS = 3;
|
|
885
|
+
var GAP_RESUME_MIN_INTERVAL_MS = 5e3;
|
|
886
|
+
var REBUILD_LOOKBACK_SEQS = 100;
|
|
887
|
+
function clampContent(s) {
|
|
888
|
+
if (s.length <= MAX_MESSAGE_CONTENT_CHARS) return s;
|
|
889
|
+
return `${s.slice(0, MAX_MESSAGE_CONTENT_CHARS)}
|
|
890
|
+
[message truncated by bridge: exceeded ${MAX_MESSAGE_CONTENT_CHARS} chars]`;
|
|
891
|
+
}
|
|
892
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
893
|
+
function isUuid(s) {
|
|
894
|
+
return UUID_RE.test(s);
|
|
895
|
+
}
|
|
814
896
|
var FATAL_CLOSE_CODES = /* @__PURE__ */ new Set([
|
|
815
897
|
4001,
|
|
816
898
|
// auth failed (bad/expired/tampered token) — recoverable via key re-mint when cfg.mintToken exists
|
|
@@ -824,6 +906,7 @@ var FATAL_CLOSE_CODES = /* @__PURE__ */ new Set([
|
|
|
824
906
|
var MAX_REMINT_ATTEMPTS = 3;
|
|
825
907
|
var PRE_HELLO_HINT_AFTER = 5;
|
|
826
908
|
var RelayClient = class {
|
|
909
|
+
// per-channel gap-backfill rate limit
|
|
827
910
|
constructor(cfg, deps) {
|
|
828
911
|
this.cfg = cfg;
|
|
829
912
|
this.deps = deps;
|
|
@@ -848,6 +931,8 @@ var RelayClient = class {
|
|
|
848
931
|
graceTimer = null;
|
|
849
932
|
confirmed = false;
|
|
850
933
|
// single-shot: onReady has fired (relay accepted the agent)
|
|
934
|
+
reconnectAnnounced = false;
|
|
935
|
+
// per-connection: "reconnected" printed for this connection
|
|
851
936
|
catchUpCbs = [];
|
|
852
937
|
caughtUp = /* @__PURE__ */ new Set();
|
|
853
938
|
// channels caught up this connection
|
|
@@ -861,8 +946,22 @@ var RelayClient = class {
|
|
|
861
946
|
fatalCb = null;
|
|
862
947
|
removedCb = null;
|
|
863
948
|
addedCb = null;
|
|
949
|
+
backfillCharsThisConn = 0;
|
|
950
|
+
// per-connection backfill budget (BRIDGE-09/12)
|
|
951
|
+
activeCatchUps = 0;
|
|
952
|
+
// concurrent catch-up limiter (BRIDGE-12)
|
|
953
|
+
catchUpWaiters = [];
|
|
954
|
+
lastGapResumeAt = /* @__PURE__ */ new Map();
|
|
955
|
+
// Every multi-listener subscription returns an unsubscribe (BRIDGE-18) so no
|
|
956
|
+
// handler array can grow without a way to shrink it. The router registers each
|
|
957
|
+
// once per process, but parity here means future per-channel subscribers cannot
|
|
958
|
+
// accumulate across channel churn the way pre-unsubscribe onRoster handlers did.
|
|
864
959
|
onInbound(cb) {
|
|
865
960
|
this.inboundCbs.push(cb);
|
|
961
|
+
return () => {
|
|
962
|
+
const i = this.inboundCbs.indexOf(cb);
|
|
963
|
+
if (i >= 0) this.inboundCbs.splice(i, 1);
|
|
964
|
+
};
|
|
866
965
|
}
|
|
867
966
|
/** Subscribe to roster updates; returns an unsubscribe so a per-channel session can drop
|
|
868
967
|
* its subscription on teardown (otherwise handlers accumulate across channel churn). */
|
|
@@ -875,9 +974,17 @@ var RelayClient = class {
|
|
|
875
974
|
}
|
|
876
975
|
onFlowSignal(cb) {
|
|
877
976
|
this.flowCbs.push(cb);
|
|
977
|
+
return () => {
|
|
978
|
+
const i = this.flowCbs.indexOf(cb);
|
|
979
|
+
if (i >= 0) this.flowCbs.splice(i, 1);
|
|
980
|
+
};
|
|
878
981
|
}
|
|
879
982
|
onCatchUp(cb) {
|
|
880
983
|
this.catchUpCbs.push(cb);
|
|
984
|
+
return () => {
|
|
985
|
+
const i = this.catchUpCbs.indexOf(cb);
|
|
986
|
+
if (i >= 0) this.catchUpCbs.splice(i, 1);
|
|
987
|
+
};
|
|
881
988
|
}
|
|
882
989
|
onReady(cb) {
|
|
883
990
|
this.readyCb = cb;
|
|
@@ -894,8 +1001,17 @@ var RelayClient = class {
|
|
|
894
1001
|
start() {
|
|
895
1002
|
this.connect();
|
|
896
1003
|
}
|
|
1004
|
+
/** Validate a relay-supplied id as a UUID and return it URL-encoded, or null
|
|
1005
|
+
* (with a warning) when it is not one — the REST call is then refused (BRIDGE-11). */
|
|
1006
|
+
pathId(id, what) {
|
|
1007
|
+
if (!isUuid(id)) {
|
|
1008
|
+
console.warn(`[arp-bridge] refusing REST call: ${what} is not a UUID: ${sanitizeForTty(id).slice(0, 80)}`);
|
|
1009
|
+
return null;
|
|
1010
|
+
}
|
|
1011
|
+
return encodeURIComponent(id);
|
|
1012
|
+
}
|
|
897
1013
|
connect() {
|
|
898
|
-
const url = `${this.cfg.relayWsUrl}/ws/agent/${this.cfg.agentId}`;
|
|
1014
|
+
const url = `${this.cfg.relayWsUrl}/ws/agent/${encodeURIComponent(this.cfg.agentId)}`;
|
|
899
1015
|
const ws = this.deps.wsFactory(url, ["bearer.arp.v1", this.cfg.token]);
|
|
900
1016
|
this.ws = ws;
|
|
901
1017
|
ws.on("open", () => this.onOpen());
|
|
@@ -906,7 +1022,9 @@ var RelayClient = class {
|
|
|
906
1022
|
}
|
|
907
1023
|
onOpen() {
|
|
908
1024
|
this.caughtUp.clear();
|
|
1025
|
+
this.backfillCharsThisConn = 0;
|
|
909
1026
|
this.connectedAt = Date.now();
|
|
1027
|
+
this.reconnectAnnounced = false;
|
|
910
1028
|
this.heartbeatTimer = setInterval(() => this.send({ type: "ping" }), HEARTBEAT_MS);
|
|
911
1029
|
this.armWatchdog();
|
|
912
1030
|
this.stableTimer = setTimeout(() => {
|
|
@@ -917,13 +1035,20 @@ var RelayClient = class {
|
|
|
917
1035
|
this.graceTimer = setTimeout(() => this.confirmReady(), AUTH_GRACE_MS);
|
|
918
1036
|
this.onConnected();
|
|
919
1037
|
}
|
|
920
|
-
/**
|
|
1038
|
+
/** Fires onReady at most once, on the FIRST successful connect; on later
|
|
1039
|
+
* connections it announces the reconnect instead (once per connection). */
|
|
921
1040
|
confirmReady() {
|
|
922
1041
|
if (this.graceTimer) {
|
|
923
1042
|
clearTimeout(this.graceTimer);
|
|
924
1043
|
this.graceTimer = null;
|
|
925
1044
|
}
|
|
926
|
-
if (this.confirmed)
|
|
1045
|
+
if (this.confirmed) {
|
|
1046
|
+
if (!this.reconnectAnnounced) {
|
|
1047
|
+
this.reconnectAnnounced = true;
|
|
1048
|
+
console.log("[arp-bridge] reconnected");
|
|
1049
|
+
}
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
927
1052
|
this.confirmed = true;
|
|
928
1053
|
this.readyCb?.();
|
|
929
1054
|
}
|
|
@@ -945,9 +1070,12 @@ var RelayClient = class {
|
|
|
945
1070
|
* infinite loop if a misbehaving server keeps claiming hasMore. */
|
|
946
1071
|
async fetchAfterSeq(channelId, afterSeq) {
|
|
947
1072
|
const out = [];
|
|
1073
|
+
const ch = this.pathId(channelId, "channelId");
|
|
1074
|
+
if (!ch) return out;
|
|
948
1075
|
let cursor = afterSeq;
|
|
1076
|
+
let takenChars = 0;
|
|
949
1077
|
for (let page = 0; page < RESUME_MAX_PAGES; page++) {
|
|
950
|
-
const url = `${this.cfg.relayHttpUrl}/channels/${
|
|
1078
|
+
const url = `${this.cfg.relayHttpUrl}/channels/${ch}/messages?afterSeq=${cursor}`;
|
|
951
1079
|
let res;
|
|
952
1080
|
try {
|
|
953
1081
|
res = await this.deps.fetchFn(url, { headers: { Authorization: `Bearer ${this.cfg.token}` } });
|
|
@@ -966,40 +1094,62 @@ var RelayClient = class {
|
|
|
966
1094
|
for (const m of list) {
|
|
967
1095
|
const seq = Number(m.seq ?? 0);
|
|
968
1096
|
if (seq > maxSeq) maxSeq = seq;
|
|
1097
|
+
const content = clampContent(String(m.content ?? ""));
|
|
1098
|
+
takenChars += content.length;
|
|
1099
|
+
this.backfillCharsThisConn += content.length;
|
|
969
1100
|
out.push({
|
|
970
1101
|
id: String(m.id ?? ""),
|
|
971
1102
|
seq,
|
|
972
1103
|
channelId,
|
|
973
|
-
content:
|
|
974
|
-
senderId: String(m.agentId ?? ""),
|
|
975
|
-
senderName: String(m.agentName ?? ""),
|
|
1104
|
+
content: untrusted(content),
|
|
1105
|
+
senderId: untrusted(String(m.agentId ?? "")),
|
|
1106
|
+
senderName: untrusted(String(m.agentName ?? "")),
|
|
976
1107
|
senderType: String(m.messageType ?? m.type ?? ""),
|
|
977
1108
|
// relay live/resume key is messageType; history path uses type
|
|
978
|
-
createdAt: String(m.createdAt ?? ""),
|
|
1109
|
+
createdAt: untrusted(String(m.createdAt ?? "")),
|
|
979
1110
|
isHistory: false
|
|
980
1111
|
// shape parity with live messages; the caller decides how to handle them
|
|
981
1112
|
});
|
|
982
1113
|
}
|
|
983
1114
|
if (!body.hasMore) return out;
|
|
1115
|
+
if (takenChars >= MAX_BACKFILL_CHARS_PER_CATCHUP || this.backfillCharsThisConn >= MAX_BACKFILL_CHARS_PER_CONNECTION) {
|
|
1116
|
+
console.warn("[arp-bridge] backfill content budget exhausted; truncating catch-up");
|
|
1117
|
+
return out;
|
|
1118
|
+
}
|
|
984
1119
|
cursor = maxSeq;
|
|
985
1120
|
}
|
|
986
1121
|
console.warn("[arp-bridge] backfill hit page cap", RESUME_MAX_PAGES);
|
|
987
1122
|
return out;
|
|
988
1123
|
}
|
|
1124
|
+
/** Run a catch-up with bounded concurrency (BRIDGE-12): at most
|
|
1125
|
+
* MAX_CONCURRENT_CATCHUPS paginated backfill loops in flight; extras queue FIFO. */
|
|
1126
|
+
scheduleCatchUp(channelId, afterSeq, deliveredMaxSeq) {
|
|
1127
|
+
const run = () => {
|
|
1128
|
+
this.activeCatchUps++;
|
|
1129
|
+
void this.catchUp(channelId, afterSeq, deliveredMaxSeq).finally(() => {
|
|
1130
|
+
this.activeCatchUps--;
|
|
1131
|
+
const next = this.catchUpWaiters.shift();
|
|
1132
|
+
if (next) next();
|
|
1133
|
+
});
|
|
1134
|
+
};
|
|
1135
|
+
if (this.activeCatchUps < MAX_CONCURRENT_CATCHUPS) run();
|
|
1136
|
+
else this.catchUpWaiters.push(run);
|
|
1137
|
+
}
|
|
989
1138
|
/** Mid-session gap-fill: replay missed messages live (each dedupes via emitInbound). */
|
|
990
1139
|
async resumeAfterSeq(channelId, afterSeq) {
|
|
991
1140
|
for (const m of await this.fetchAfterSeq(channelId, afterSeq)) this.emitInbound(m);
|
|
992
1141
|
}
|
|
993
1142
|
/** Offline-rejoin catch-up: classify the missed window and hand it to onCatchUp once.
|
|
994
1143
|
* Does NOT route through emitInbound/onInbound to avoid per-message passive submits. */
|
|
995
|
-
async catchUp(channelId, afterSeq) {
|
|
1144
|
+
async catchUp(channelId, afterSeq, deliveredMaxSeq) {
|
|
996
1145
|
const missed = await this.fetchAfterSeq(channelId, afterSeq);
|
|
997
1146
|
if (missed.length === 0) return;
|
|
998
1147
|
for (const m of missed) if (m.id) this.markSeen(channelId, m.id);
|
|
999
1148
|
this.bumpCursor(channelId, missed[missed.length - 1].seq);
|
|
1000
1149
|
const result = classifyCatchUp(missed, this.cfg.agentName, Date.now(), {
|
|
1001
1150
|
ttlMs: this.cfg.catchUpTtlMs,
|
|
1002
|
-
maxMentions: this.cfg.catchUpMaxMentions
|
|
1151
|
+
maxMentions: this.cfg.catchUpMaxMentions,
|
|
1152
|
+
deliveredMaxSeq
|
|
1003
1153
|
});
|
|
1004
1154
|
this.catchUpCbs.forEach((cb) => cb(channelId, result));
|
|
1005
1155
|
}
|
|
@@ -1011,6 +1161,8 @@ var RelayClient = class {
|
|
|
1011
1161
|
this.remintAttempts++;
|
|
1012
1162
|
console.log("[arp-bridge] access token rejected; re-minting from agent key");
|
|
1013
1163
|
void this.cfg.mintToken().then((token) => {
|
|
1164
|
+
const verdict = validateReplacementToken(token, this.cfg.token);
|
|
1165
|
+
if (!verdict.ok) throw new Error(`re-minted token failed validation: ${verdict.reason}`);
|
|
1014
1166
|
this.cfg.token = token;
|
|
1015
1167
|
if (!this.stopped) this.connect();
|
|
1016
1168
|
}).catch((err) => {
|
|
@@ -1075,7 +1227,7 @@ var RelayClient = class {
|
|
|
1075
1227
|
}
|
|
1076
1228
|
onMessage(raw) {
|
|
1077
1229
|
this.armWatchdog();
|
|
1078
|
-
|
|
1230
|
+
this.confirmReady();
|
|
1079
1231
|
let msg;
|
|
1080
1232
|
try {
|
|
1081
1233
|
msg = JSON.parse(raw);
|
|
@@ -1089,18 +1241,24 @@ var RelayClient = class {
|
|
|
1089
1241
|
case "channel_message":
|
|
1090
1242
|
this.handleChannelMessage(msg);
|
|
1091
1243
|
break;
|
|
1092
|
-
case "token_refresh":
|
|
1093
|
-
if (typeof msg.token
|
|
1094
|
-
|
|
1095
|
-
|
|
1244
|
+
case "token_refresh": {
|
|
1245
|
+
if (typeof msg.token !== "string" || msg.token.length === 0) break;
|
|
1246
|
+
const verdict = validateReplacementToken(msg.token, this.cfg.token);
|
|
1247
|
+
if (!verdict.ok) {
|
|
1248
|
+
console.warn(`[arp-bridge] rejected token_refresh (${verdict.reason}); keeping the current token`);
|
|
1249
|
+
break;
|
|
1096
1250
|
}
|
|
1251
|
+
this.cfg.token = msg.token;
|
|
1252
|
+
console.log("[arp-bridge] token refreshed");
|
|
1097
1253
|
break;
|
|
1254
|
+
}
|
|
1098
1255
|
case "removed": {
|
|
1099
1256
|
const ch = String(msg.channelId ?? "");
|
|
1100
1257
|
if (!ch) break;
|
|
1101
1258
|
this.cursors.delete(ch);
|
|
1102
1259
|
this.seenByChannel.delete(ch);
|
|
1103
1260
|
this.caughtUp.delete(ch);
|
|
1261
|
+
this.lastGapResumeAt.delete(ch);
|
|
1104
1262
|
this.removedCb?.(ch);
|
|
1105
1263
|
break;
|
|
1106
1264
|
}
|
|
@@ -1135,10 +1293,11 @@ var RelayClient = class {
|
|
|
1135
1293
|
for (const [ch, seqRaw] of Object.entries(resume)) {
|
|
1136
1294
|
const seq = Number(seqRaw);
|
|
1137
1295
|
if (Number.isFinite(seq) && seq > this.cursorOf(ch)) this.cursors.set(ch, seq);
|
|
1138
|
-
const
|
|
1139
|
-
if (!this.caughtUp.has(ch) &&
|
|
1296
|
+
const deliveredMax = this.cursorOf(ch);
|
|
1297
|
+
if (!this.caughtUp.has(ch) && deliveredMax > 0) {
|
|
1140
1298
|
this.caughtUp.add(ch);
|
|
1141
|
-
|
|
1299
|
+
const rebuildFloor = Math.max(0, deliveredMax - REBUILD_LOOKBACK_SEQS);
|
|
1300
|
+
this.scheduleCatchUp(ch, rebuildFloor, deliveredMax);
|
|
1142
1301
|
}
|
|
1143
1302
|
}
|
|
1144
1303
|
}
|
|
@@ -1159,18 +1318,25 @@ var RelayClient = class {
|
|
|
1159
1318
|
id: String(m.id ?? ""),
|
|
1160
1319
|
seq: Number(m.seq ?? 0),
|
|
1161
1320
|
channelId,
|
|
1162
|
-
content: String(m.content ?? ""),
|
|
1163
|
-
|
|
1164
|
-
|
|
1321
|
+
content: untrusted(clampContent(String(m.content ?? ""))),
|
|
1322
|
+
// bound per-message memory/prompt size (BRIDGE-09)
|
|
1323
|
+
senderId: untrusted(String(m.agentId ?? "")),
|
|
1324
|
+
senderName: untrusted(String(m.agentName ?? "")),
|
|
1165
1325
|
senderType: String(m.messageType ?? m.type ?? ""),
|
|
1166
1326
|
// relay live/resume key is messageType; history path uses type
|
|
1167
|
-
createdAt: String(m.createdAt ?? ""),
|
|
1327
|
+
createdAt: untrusted(String(m.createdAt ?? "")),
|
|
1168
1328
|
isHistory: Boolean(msg.isHistory)
|
|
1169
1329
|
};
|
|
1170
1330
|
const gapDetected = !inbound.isHistory && this.cursorOf(channelId) > 0 && inbound.seq > this.cursorOf(channelId) + 1;
|
|
1171
1331
|
const gapFrom = this.cursorOf(channelId);
|
|
1172
1332
|
this.emitInbound(inbound);
|
|
1173
|
-
if (gapDetected)
|
|
1333
|
+
if (gapDetected) {
|
|
1334
|
+
const now = Date.now();
|
|
1335
|
+
if (now - (this.lastGapResumeAt.get(channelId) ?? 0) >= GAP_RESUME_MIN_INTERVAL_MS) {
|
|
1336
|
+
this.lastGapResumeAt.set(channelId, now);
|
|
1337
|
+
void this.resumeAfterSeq(channelId, gapFrom);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1174
1340
|
}
|
|
1175
1341
|
/** Normalize a turn_notification / synthesis_request / direction_request into a FlowSignal and emit it. */
|
|
1176
1342
|
handleFlowSignal(kind, msg) {
|
|
@@ -1178,8 +1344,9 @@ var RelayClient = class {
|
|
|
1178
1344
|
if (!flowId) return;
|
|
1179
1345
|
const rawHistory = kind === "synthesis" || kind === "direction" ? msg.flowHistory ?? msg.messages ?? [] : msg.recentMessages ?? [];
|
|
1180
1346
|
const history = (Array.isArray(rawHistory) ? rawHistory : []).map((e) => ({
|
|
1181
|
-
agentName: String(e.agentName ?? ""),
|
|
1182
|
-
content: String(e.content ?? ""),
|
|
1347
|
+
agentName: untrusted(String(e.agentName ?? "")),
|
|
1348
|
+
content: untrusted(clampContent(String(e.content ?? ""))),
|
|
1349
|
+
// bound per-entry size (BRIDGE-09)
|
|
1183
1350
|
messageType: e.messageType ?? e.type,
|
|
1184
1351
|
turnNumber: e.turnNumber,
|
|
1185
1352
|
createdAt: e.createdAt
|
|
@@ -1188,11 +1355,11 @@ var RelayClient = class {
|
|
|
1188
1355
|
kind,
|
|
1189
1356
|
flowId,
|
|
1190
1357
|
channelId: String(msg.channelId ?? ""),
|
|
1191
|
-
topic: String(msg.topic ?? ""),
|
|
1192
|
-
rolePrompt: typeof msg.rolePrompt === "string" ? msg.rolePrompt : void 0,
|
|
1193
|
-
contextPrompt: typeof msg.contextPrompt === "string" ? msg.contextPrompt : void 0,
|
|
1194
|
-
synthesisPrompt: typeof msg.synthesisPrompt === "string" ? msg.synthesisPrompt : void 0,
|
|
1195
|
-
candidates: Array.isArray(msg.candidates) ? msg.candidates : void 0,
|
|
1358
|
+
topic: untrusted(String(msg.topic ?? "")),
|
|
1359
|
+
rolePrompt: typeof msg.rolePrompt === "string" ? untrusted(msg.rolePrompt) : void 0,
|
|
1360
|
+
contextPrompt: typeof msg.contextPrompt === "string" ? untrusted(msg.contextPrompt) : void 0,
|
|
1361
|
+
synthesisPrompt: typeof msg.synthesisPrompt === "string" ? untrusted(msg.synthesisPrompt) : void 0,
|
|
1362
|
+
candidates: Array.isArray(msg.candidates) ? msg.candidates.map((c) => untrusted(String(c ?? ""))) : void 0,
|
|
1196
1363
|
history
|
|
1197
1364
|
};
|
|
1198
1365
|
this.flowCbs.forEach((cb) => cb(signal));
|
|
@@ -1258,7 +1425,9 @@ var RelayClient = class {
|
|
|
1258
1425
|
}
|
|
1259
1426
|
/** Fetch the channel roster and return normalized bot entries (with cards). */
|
|
1260
1427
|
async fetchRoster(channelId) {
|
|
1261
|
-
const
|
|
1428
|
+
const ch = this.pathId(channelId, "channelId");
|
|
1429
|
+
if (!ch) return [];
|
|
1430
|
+
const url = `${this.cfg.relayHttpUrl}/channels/${ch}`;
|
|
1262
1431
|
try {
|
|
1263
1432
|
const res = await this.deps.fetchFn(url, { headers: { Authorization: `Bearer ${this.cfg.token}` } });
|
|
1264
1433
|
if (!res.ok) {
|
|
@@ -1274,7 +1443,9 @@ var RelayClient = class {
|
|
|
1274
1443
|
}
|
|
1275
1444
|
}
|
|
1276
1445
|
async postMessage(channelId, content) {
|
|
1277
|
-
const
|
|
1446
|
+
const ch = this.pathId(channelId, "channelId");
|
|
1447
|
+
if (!ch) return;
|
|
1448
|
+
const url = `${this.cfg.relayHttpUrl}/channels/${ch}/messages`;
|
|
1278
1449
|
const body = JSON.stringify({
|
|
1279
1450
|
id: randomUUID(),
|
|
1280
1451
|
// client-generated -> server idempotent on retry
|
|
@@ -1300,7 +1471,10 @@ var RelayClient = class {
|
|
|
1300
1471
|
* agentId MUST be the agent NAME — the relay's flow gate resolves turn ownership and
|
|
1301
1472
|
* synthesis role via resolveAgentUUID (a name lookup); a UUID resolves to uuid.Nil -> 403. */
|
|
1302
1473
|
async postFlowMessage(channelId, flowId, content) {
|
|
1303
|
-
const
|
|
1474
|
+
const ch = this.pathId(channelId, "channelId");
|
|
1475
|
+
const fl = this.pathId(flowId, "flowId");
|
|
1476
|
+
if (!ch || !fl) return;
|
|
1477
|
+
const url = `${this.cfg.relayHttpUrl}/channels/${ch}/flows/${fl}/messages`;
|
|
1304
1478
|
const body = JSON.stringify({
|
|
1305
1479
|
id: randomUUID(),
|
|
1306
1480
|
content,
|
|
@@ -1320,26 +1494,30 @@ var RelayClient = class {
|
|
|
1320
1494
|
console.warn("[arp-bridge] flow post failed:", sanitizeForTty(String(err)));
|
|
1321
1495
|
}
|
|
1322
1496
|
}
|
|
1323
|
-
/** Channel memory text (
|
|
1497
|
+
/** Channel memory text (empty if none or on error — never throws). Branded (H2-4). */
|
|
1324
1498
|
async fetchChannelMemory(channelId) {
|
|
1325
|
-
const
|
|
1499
|
+
const ch = this.pathId(channelId, "channelId");
|
|
1500
|
+
if (!ch) return untrusted("");
|
|
1501
|
+
const url = `${this.cfg.relayHttpUrl}/channels/${ch}/memory`;
|
|
1326
1502
|
try {
|
|
1327
1503
|
const res = await this.deps.fetchFn(url, { headers: { Authorization: `Bearer ${this.cfg.token}` } });
|
|
1328
1504
|
if (!res.ok) {
|
|
1329
1505
|
console.warn("[arp-bridge] memory HTTP", res.status);
|
|
1330
|
-
return "";
|
|
1506
|
+
return untrusted("");
|
|
1331
1507
|
}
|
|
1332
1508
|
const data = await res.json();
|
|
1333
|
-
return typeof data?.content === "string" ? data.content : "";
|
|
1509
|
+
return untrusted(typeof data?.content === "string" ? data.content : "");
|
|
1334
1510
|
} catch (err) {
|
|
1335
1511
|
console.warn("[arp-bridge] memory fetch failed:", sanitizeForTty(String(err)));
|
|
1336
|
-
return "";
|
|
1512
|
+
return untrusted("");
|
|
1337
1513
|
}
|
|
1338
1514
|
}
|
|
1339
1515
|
/** Channel topics with message counts ([] if none or on error). The relay returns
|
|
1340
1516
|
* topics with a `title` plus a separate `messageCounts` map keyed by topic id. */
|
|
1341
1517
|
async fetchChannelTopics(channelId) {
|
|
1342
|
-
const
|
|
1518
|
+
const ch = this.pathId(channelId, "channelId");
|
|
1519
|
+
if (!ch) return [];
|
|
1520
|
+
const url = `${this.cfg.relayHttpUrl}/channels/${ch}/topics`;
|
|
1343
1521
|
try {
|
|
1344
1522
|
const res = await this.deps.fetchFn(url, { headers: { Authorization: `Bearer ${this.cfg.token}` } });
|
|
1345
1523
|
if (!res.ok) {
|
|
@@ -1349,7 +1527,7 @@ var RelayClient = class {
|
|
|
1349
1527
|
const data = await res.json();
|
|
1350
1528
|
const topics = data?.topics ?? [];
|
|
1351
1529
|
const counts = data?.messageCounts ?? {};
|
|
1352
|
-
return topics.filter((t) => typeof t?.title === "string").map((t) => ({ title: t.title, count: typeof counts[t.id] === "number" ? counts[t.id] : null }));
|
|
1530
|
+
return topics.filter((t) => typeof t?.title === "string").map((t) => ({ title: untrusted(t.title), count: typeof counts[t.id] === "number" ? counts[t.id] : null }));
|
|
1353
1531
|
} catch (err) {
|
|
1354
1532
|
console.warn("[arp-bridge] topics fetch failed:", sanitizeForTty(String(err)));
|
|
1355
1533
|
return [];
|
|
@@ -1357,7 +1535,9 @@ var RelayClient = class {
|
|
|
1357
1535
|
}
|
|
1358
1536
|
/** Pinned files marked inject_context=true, labeled with cached content ([] otherwise). */
|
|
1359
1537
|
async fetchPinnedContext(channelId) {
|
|
1360
|
-
const
|
|
1538
|
+
const ch = this.pathId(channelId, "channelId");
|
|
1539
|
+
if (!ch) return [];
|
|
1540
|
+
const url = `${this.cfg.relayHttpUrl}/channels/${ch}/pins`;
|
|
1361
1541
|
try {
|
|
1362
1542
|
const res = await this.deps.fetchFn(url, { headers: { Authorization: `Bearer ${this.cfg.token}` } });
|
|
1363
1543
|
if (!res.ok) {
|
|
@@ -1366,7 +1546,10 @@ var RelayClient = class {
|
|
|
1366
1546
|
}
|
|
1367
1547
|
const data = await res.json();
|
|
1368
1548
|
const pins = data?.pins ?? [];
|
|
1369
|
-
return pins.filter((p) => p?.injectContext && typeof p.cachedContent === "string" && p.cachedContent.trim()).map((p) => ({
|
|
1549
|
+
return pins.filter((p) => p?.injectContext && typeof p.cachedContent === "string" && p.cachedContent.trim()).map((p) => ({
|
|
1550
|
+
label: untrusted(p.displayName || `${p.repoUrl ?? ""}/${p.filePath ?? ""}`),
|
|
1551
|
+
content: untrusted(p.cachedContent)
|
|
1552
|
+
}));
|
|
1370
1553
|
} catch (err) {
|
|
1371
1554
|
console.warn("[arp-bridge] pins fetch failed:", sanitizeForTty(String(err)));
|
|
1372
1555
|
return [];
|
|
@@ -1387,7 +1570,10 @@ var RelayClient = class {
|
|
|
1387
1570
|
}
|
|
1388
1571
|
/** Fetch a flow's transcript (used to backfill a minimal turn_notification). [] on error. */
|
|
1389
1572
|
async fetchFlowMessages(channelId, flowId) {
|
|
1390
|
-
const
|
|
1573
|
+
const ch = this.pathId(channelId, "channelId");
|
|
1574
|
+
const fl = this.pathId(flowId, "flowId");
|
|
1575
|
+
if (!ch || !fl) return [];
|
|
1576
|
+
const url = `${this.cfg.relayHttpUrl}/channels/${ch}/flows/${fl}/messages`;
|
|
1391
1577
|
try {
|
|
1392
1578
|
const res = await this.deps.fetchFn(url, { headers: { Authorization: `Bearer ${this.cfg.token}` } });
|
|
1393
1579
|
if (!res.ok) {
|
|
@@ -1397,8 +1583,8 @@ var RelayClient = class {
|
|
|
1397
1583
|
const data = await res.json();
|
|
1398
1584
|
const list = Array.isArray(data) ? data : data.messages ?? [];
|
|
1399
1585
|
return list.map((e) => ({
|
|
1400
|
-
agentName: String(e.agentName ?? ""),
|
|
1401
|
-
content: String(e.content ?? ""),
|
|
1586
|
+
agentName: untrusted(String(e.agentName ?? "")),
|
|
1587
|
+
content: untrusted(String(e.content ?? "")),
|
|
1402
1588
|
messageType: e.messageType ?? e.type,
|
|
1403
1589
|
turnNumber: e.turnNumber,
|
|
1404
1590
|
createdAt: e.createdAt
|
|
@@ -1413,15 +1599,15 @@ var RelayClient = class {
|
|
|
1413
1599
|
// src/flow.ts
|
|
1414
1600
|
function renderFlowHistory(entries) {
|
|
1415
1601
|
if (entries.length === 0) return "";
|
|
1416
|
-
const lines = entries.map((e) => `${e.agentName
|
|
1602
|
+
const lines = entries.map((e) => utext`${firstNonEmpty([e.agentName], "someone")}: ${e.content}`);
|
|
1417
1603
|
return `DISCUSSION HISTORY:
|
|
1418
|
-
${fence("flow discussion history", lines
|
|
1604
|
+
${fence("flow discussion history", joinUntrusted(lines, "\n"))}
|
|
1419
1605
|
|
|
1420
1606
|
`;
|
|
1421
1607
|
}
|
|
1422
1608
|
function buildFlowPrompt(signal, agentName, channelId, toolMode = "readonly") {
|
|
1423
1609
|
if (signal.kind === "direction") {
|
|
1424
|
-
const candidates = (signal.candidates ?? []
|
|
1610
|
+
const candidates = joinUntrusted(signal.candidates ?? [], ", ");
|
|
1425
1611
|
const history2 = renderFlowHistory(signal.history);
|
|
1426
1612
|
const hasHistory = history2 !== "";
|
|
1427
1613
|
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.`];
|
|
@@ -1433,7 +1619,7 @@ function buildFlowPrompt(signal, agentName, channelId, toolMode = "readonly") {
|
|
|
1433
1619
|
fence("flow topic", signal.topic),
|
|
1434
1620
|
...preamble,
|
|
1435
1621
|
`Available participants:`,
|
|
1436
|
-
fence("flow participant names", candidates
|
|
1622
|
+
fence("flow participant names", firstNonEmpty([candidates], "(none online)")),
|
|
1437
1623
|
``,
|
|
1438
1624
|
`Reply with ONLY the name of the single participant who should speak next,`,
|
|
1439
1625
|
`or reply with ONLY the word END to conclude the discussion and move to synthesis.`,
|
|
@@ -1461,6 +1647,22 @@ ${fence("flow topic", signal.topic)}
|
|
|
1461
1647
|
` + role + ctx + synth + "\n" + history + closer;
|
|
1462
1648
|
}
|
|
1463
1649
|
|
|
1650
|
+
// src/promptLimit.ts
|
|
1651
|
+
var DEFAULT_MAX_PROMPT_CHARS = 4e5;
|
|
1652
|
+
function maxPromptChars(env = process.env) {
|
|
1653
|
+
const n = Number(env.ARP_MAX_PROMPT_CHARS);
|
|
1654
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_MAX_PROMPT_CHARS;
|
|
1655
|
+
}
|
|
1656
|
+
function capPrompt(prompt, max = maxPromptChars()) {
|
|
1657
|
+
if (prompt.length <= max) return prompt;
|
|
1658
|
+
const headLen = Math.floor(max * 0.8);
|
|
1659
|
+
const tailLen = max - headLen;
|
|
1660
|
+
const removed = prompt.length - headLen - tailLen;
|
|
1661
|
+
return prompt.slice(0, headLen) + `
|
|
1662
|
+
[...prompt truncated by bridge: ${removed} chars of channel content removed...]
|
|
1663
|
+
` + prompt.slice(prompt.length - tailLen);
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1464
1666
|
// src/session.ts
|
|
1465
1667
|
var SILENCE_SENTINEL = "<<silent>>";
|
|
1466
1668
|
var ChannelSession = class {
|
|
@@ -1519,7 +1721,7 @@ ${toolStatusLine(this.toolMode)}
|
|
|
1519
1721
|
*/
|
|
1520
1722
|
async submit(msg) {
|
|
1521
1723
|
if (!this.session) throw new Error("ChannelSession not started");
|
|
1522
|
-
const who = msg.senderName
|
|
1724
|
+
const who = firstNonEmpty([msg.senderName, msg.senderId], "someone");
|
|
1523
1725
|
const facts = assembleRosterFacts(this.roster, this.agentName);
|
|
1524
1726
|
const rosterBlock = facts ? `${facts}
|
|
1525
1727
|
|
|
@@ -1534,7 +1736,7 @@ ${fence("channel message", msg.content)}
|
|
|
1534
1736
|
` + rosterBlock;
|
|
1535
1737
|
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
1738
|
this.beacon?.begin();
|
|
1537
|
-
this.session.submit(head + instructions);
|
|
1739
|
+
this.session.submit(capPrompt(head + instructions));
|
|
1538
1740
|
}
|
|
1539
1741
|
/**
|
|
1540
1742
|
* Run one bounded-flow turn or synthesis. Frames a MUST-RESPOND prompt (no
|
|
@@ -1555,7 +1757,7 @@ ${fence("channel message", msg.content)}
|
|
|
1555
1757
|
let history = signal.history;
|
|
1556
1758
|
if (history.length === 0) history = await this.flow.fetchHistory(signal.flowId);
|
|
1557
1759
|
const prompt = buildFlowPrompt({ ...signal, history }, this.agentName, this.channelId, this.toolMode);
|
|
1558
|
-
const reply = await this.session.converseLocal(prompt);
|
|
1760
|
+
const reply = await this.session.converseLocal(capPrompt(prompt));
|
|
1559
1761
|
await this.flow.postReply(signal.flowId, reply.trim() || "(no response)");
|
|
1560
1762
|
} finally {
|
|
1561
1763
|
this.beacon?.end();
|
|
@@ -1590,22 +1792,28 @@ ${fence("channel message", msg.content)}
|
|
|
1590
1792
|
async submitCatchUp(result) {
|
|
1591
1793
|
if (!this.session) throw new Error("ChannelSession not started");
|
|
1592
1794
|
if (result.context.length === 0) return;
|
|
1593
|
-
const transcript =
|
|
1795
|
+
const transcript = joinUntrusted(
|
|
1796
|
+
result.context.map((m) => utext`[${m.createdAt}] ${firstNonEmpty([m.senderName, m.senderId], "someone")}: ${m.content}`),
|
|
1797
|
+
"\n"
|
|
1798
|
+
);
|
|
1594
1799
|
if (result.mentions.length === 0) {
|
|
1595
1800
|
if (!this.session.converseLocal) return;
|
|
1596
1801
|
this.beacon?.begin();
|
|
1597
1802
|
try {
|
|
1598
|
-
await this.session.converseLocal(
|
|
1599
|
-
this.promptHead() + `You just
|
|
1600
|
-
` + fence("
|
|
1601
|
-
);
|
|
1803
|
+
await this.session.converseLocal(capPrompt(
|
|
1804
|
+
this.promptHead() + `You just (re)connected to ARP channel ${this.channelId}. Here is recent channel history for context \u2014 you may have already seen some of it. Absorb it so you can follow back-references in later messages; do NOT reply to it:
|
|
1805
|
+
` + fence("recent channel history", transcript)
|
|
1806
|
+
));
|
|
1602
1807
|
} finally {
|
|
1603
1808
|
this.beacon?.end();
|
|
1604
1809
|
}
|
|
1605
1810
|
return;
|
|
1606
1811
|
}
|
|
1607
1812
|
const channelContext = this.fetchContext ? await this.fetchContext() : "";
|
|
1608
|
-
const addressed =
|
|
1813
|
+
const addressed = joinUntrusted(
|
|
1814
|
+
result.mentions.map((m) => utext`[${m.createdAt}] ${firstNonEmpty([m.senderName, m.senderId], "someone")}: ${m.content}`),
|
|
1815
|
+
"\n"
|
|
1816
|
+
);
|
|
1609
1817
|
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):
|
|
1610
1818
|
${fence("missed channel messages", transcript)}
|
|
1611
1819
|
|
|
@@ -1615,7 +1823,7 @@ ${fence("messages mentioning you", addressed)}
|
|
|
1615
1823
|
`;
|
|
1616
1824
|
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
1825
|
this.beacon?.begin();
|
|
1618
|
-
this.session.submit(head + instructions);
|
|
1826
|
+
this.session.submit(capPrompt(head + instructions));
|
|
1619
1827
|
}
|
|
1620
1828
|
async stop() {
|
|
1621
1829
|
this.beacon?.stop?.();
|
|
@@ -1717,13 +1925,13 @@ function dropVendorNotifications(input) {
|
|
|
1717
1925
|
|
|
1718
1926
|
// src/acp/client.ts
|
|
1719
1927
|
var MODEL_AUTH_ENV_KEYS = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
|
|
1720
|
-
var
|
|
1928
|
+
var BRIDGE_ENV_PREFIX = "ARP_";
|
|
1721
1929
|
function buildAcpEnv(base, extra) {
|
|
1722
1930
|
const merged = {};
|
|
1723
1931
|
for (const [k, v] of Object.entries({ ...base, ...extra ?? {} })) {
|
|
1724
1932
|
if (v === void 0) continue;
|
|
1725
1933
|
if (MODEL_AUTH_ENV_KEYS.includes(k)) continue;
|
|
1726
|
-
if (
|
|
1934
|
+
if (k.startsWith(BRIDGE_ENV_PREFIX)) continue;
|
|
1727
1935
|
merged[k] = v;
|
|
1728
1936
|
}
|
|
1729
1937
|
return merged;
|
|
@@ -2291,7 +2499,7 @@ var ClaudeAdapter = class {
|
|
|
2291
2499
|
if (full.length > 0) turnCbs.forEach((cb) => cb(full));
|
|
2292
2500
|
} else {
|
|
2293
2501
|
buffer = "";
|
|
2294
|
-
console.warn("[arp-bridge] agent turn ended without success:", m.subtype);
|
|
2502
|
+
console.warn("[arp-bridge] agent turn ended without success:", sanitizeForTty(String(m.subtype)));
|
|
2295
2503
|
}
|
|
2296
2504
|
}
|
|
2297
2505
|
}
|
|
@@ -2453,8 +2661,8 @@ async function startBridge(cfg, relay, deps) {
|
|
|
2453
2661
|
}
|
|
2454
2662
|
relay.onInbound((m) => {
|
|
2455
2663
|
if (m.isHistory) return;
|
|
2456
|
-
if (m.senderId && m.senderId
|
|
2457
|
-
if (
|
|
2664
|
+
if (hasText(m.senderId) && sameText(m.senderId, cfg.agentUuid) || hasText(m.senderName) && sameText(m.senderName, cfg.agentName)) return;
|
|
2665
|
+
if (isBlankText(m.content)) return;
|
|
2458
2666
|
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))));
|
|
2459
2667
|
});
|
|
2460
2668
|
relay.onFlowSignal((signal) => {
|
|
@@ -2516,11 +2724,13 @@ function reportFatalClose(code, reason) {
|
|
|
2516
2724
|
console.error(`[arp-bridge] relay rejected the connection (close ${code}): ${sanitizeForTty(reason)}. Not retrying.`);
|
|
2517
2725
|
}
|
|
2518
2726
|
}
|
|
2727
|
+
function makeDefaultWsFactory(WebSocketImpl) {
|
|
2728
|
+
return (url, protocols) => new WebSocketImpl(url, protocols, { maxPayload: WS_MAX_PAYLOAD_BYTES });
|
|
2729
|
+
}
|
|
2519
2730
|
async function createAndStartBridge(cfg, deps = {}) {
|
|
2520
2731
|
let wsFactory = deps.wsFactory;
|
|
2521
2732
|
if (!wsFactory) {
|
|
2522
|
-
|
|
2523
|
-
wsFactory = (url, protocols) => new WebSocketImpl(url, protocols);
|
|
2733
|
+
wsFactory = makeDefaultWsFactory((await import("ws")).default);
|
|
2524
2734
|
}
|
|
2525
2735
|
const relay = new RelayClient(cfg, {
|
|
2526
2736
|
wsFactory,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@snowyroad/arp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
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
|
},
|
|
@@ -33,23 +34,25 @@
|
|
|
33
34
|
"prepublishOnly": "pnpm build",
|
|
34
35
|
"dev": "tsx src/index.ts",
|
|
35
36
|
"join": "tsx src/index.ts",
|
|
36
|
-
"generate-invite": "tsx scripts/generate-invite.ts",
|
|
37
37
|
"test": "vitest run",
|
|
38
38
|
"test:watch": "vitest",
|
|
39
39
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
40
40
|
"typecheck": "tsc --noEmit"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@agentclientprotocol/sdk": "0.
|
|
44
|
-
"@anthropic-ai/claude-agent-sdk": "0.
|
|
45
|
-
"
|
|
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"
|
|
46
49
|
},
|
|
47
50
|
"devDependencies": {
|
|
48
|
-
"@types/node": "^
|
|
51
|
+
"@types/node": "^25.9.3",
|
|
49
52
|
"@types/ws": "^8.5.12",
|
|
50
53
|
"tsup": "^8.5.1",
|
|
51
54
|
"tsx": "^4.19.0",
|
|
52
|
-
"typescript": "^
|
|
55
|
+
"typescript": "^6.0.3",
|
|
53
56
|
"vitest": "^2.1.0"
|
|
54
57
|
}
|
|
55
58
|
}
|