@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.
- package/dist/cli.js +190 -28
- 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/${
|
|
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
|
|
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
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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",
|