@securecoms/agent-bridge 0.11.2 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -0
- package/dist/bin.js +2657 -93
- package/dist/bridge-agent.d.ts +331 -0
- package/dist/config.d.ts +3 -0
- package/dist/errors.d.ts +1 -1
- package/dist/http-server.d.ts +4 -4
- package/dist/index.js +2674 -112
- package/dist/memory-taps.d.ts +6 -2
- package/dist/rate-limit.d.ts +10 -0
- package/dist/sodium-ready.d.ts +16 -8
- package/dist/summarize.d.ts +54 -0
- package/dist/voice/fast-client.d.ts +13 -0
- package/dist/voice/fast-prompt.d.ts +53 -0
- package/dist/voice/persona.d.ts +23 -0
- package/dist/voice/prompt-compiler.d.ts +4 -0
- package/dist/voice/settings.d.ts +31 -0
- package/package.json +7 -1
- package/dist/attachments.js +0 -49
- package/dist/auth.js +0 -56
- package/dist/bin.js.pre-c1-f803007 +0 -5795
- package/dist/bin.js.pre-c3-096a9a3 +0 -5832
- package/dist/bin.js.pre-ffrebuild-b162d4a +0 -5639
- package/dist/bin.js.pre-kpheal-20260708-150924 +0 -5531
- package/dist/bin.js.pre-kppersist-20260708-152341 +0 -5543
- package/dist/bin.js.pre-p0-20260708-102415 +0 -5196
- package/dist/bin.js.pre-p1b-02f47e1 +0 -5639
- package/dist/bin.js.pre-warmfix-20260708-153705 +0 -5613
- package/dist/bin.js.pretrace-113205 +0 -5395
- package/dist/bridge-agent.js +0 -831
- package/dist/config.js +0 -74
- package/dist/coordination.js +0 -63
- package/dist/errors.js +0 -25
- package/dist/http-server.js +0 -220
- package/dist/index.js.pre-p0-20260708-102415 +0 -4879
- package/dist/latency.js +0 -87
- package/dist/rate-limit.js +0 -56
- package/dist/service/config.js +0 -26
- package/dist/service/install.js +0 -60
- package/dist/service/launchd.js +0 -30
- package/dist/service/systemd.js +0 -25
- package/dist/ws-server.js +0 -254
package/dist/latency.js
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
// latency.ts — lightweight per-stage latency recorder for the agent-bridge.
|
|
2
|
-
//
|
|
3
|
-
// Captures the SecureComs MESSAGE-ENVELOPE cost (MLS encrypt, relay publish,
|
|
4
|
-
// decrypt, receive cycle) so we can report real p50/p95/p99 SEPARATE from the
|
|
5
|
-
// agent's LLM time. Deliberately dependency-free and bounded: each stage keeps
|
|
6
|
-
// a fixed-size ring buffer of recent samples and percentiles are computed on
|
|
7
|
-
// demand from a sorted copy. Off the hot path beyond a single array write.
|
|
8
|
-
//
|
|
9
|
-
// This is NOT a metrics-pipeline replacement; it is a self-contained, loopback
|
|
10
|
-
// -exposed snapshot (`/v1/metrics`) for profiling + load tests. Per-stage
|
|
11
|
-
// timings are monotonic (performance.now) and local — true cross-process
|
|
12
|
-
// end-to-end (sender-publish → recipient-decrypt) needs clock-synced
|
|
13
|
-
// correlation by message id and is a follow-up.
|
|
14
|
-
const DEFAULT_CAPACITY = 1024;
|
|
15
|
-
function round(n) {
|
|
16
|
-
// Two-decimal ms is plenty; avoids float noise in the JSON snapshot.
|
|
17
|
-
return Math.round(n * 100) / 100;
|
|
18
|
-
}
|
|
19
|
-
/** Nearest-rank percentile (1-indexed ceil) over an ascending-sorted array. */
|
|
20
|
-
function percentile(sorted, p) {
|
|
21
|
-
if (sorted.length === 0)
|
|
22
|
-
return 0;
|
|
23
|
-
const rank = Math.ceil((p / 100) * sorted.length);
|
|
24
|
-
const idx = Math.min(Math.max(rank, 1), sorted.length) - 1;
|
|
25
|
-
return sorted[idx];
|
|
26
|
-
}
|
|
27
|
-
export class LatencyRecorder {
|
|
28
|
-
capacity;
|
|
29
|
-
// Per-stage ring buffer of recent samples (ms).
|
|
30
|
-
buffers = new Map();
|
|
31
|
-
// Write cursor per stage (for the ring buffer wrap).
|
|
32
|
-
cursors = new Map();
|
|
33
|
-
constructor(opts = {}) {
|
|
34
|
-
this.capacity = opts.capacity ?? DEFAULT_CAPACITY;
|
|
35
|
-
}
|
|
36
|
-
/** Record a single sample (ms) for a stage. O(1). */
|
|
37
|
-
record(stage, ms) {
|
|
38
|
-
let buf = this.buffers.get(stage);
|
|
39
|
-
if (!buf) {
|
|
40
|
-
buf = [];
|
|
41
|
-
this.buffers.set(stage, buf);
|
|
42
|
-
this.cursors.set(stage, 0);
|
|
43
|
-
}
|
|
44
|
-
if (buf.length < this.capacity) {
|
|
45
|
-
buf.push(ms);
|
|
46
|
-
}
|
|
47
|
-
else {
|
|
48
|
-
// Ring-buffer overwrite of the oldest slot.
|
|
49
|
-
const cur = this.cursors.get(stage) ?? 0;
|
|
50
|
-
buf[cur] = ms;
|
|
51
|
-
this.cursors.set(stage, (cur + 1) % this.capacity);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* Time an async fn, record its elapsed wall time under `stage`, return its
|
|
56
|
-
* value. Records even when the fn throws (the failure still consumed time),
|
|
57
|
-
* then re-throws — so a thrown error never loses the timing sample.
|
|
58
|
-
*/
|
|
59
|
-
async time(stage, fn) {
|
|
60
|
-
const start = performance.now();
|
|
61
|
-
try {
|
|
62
|
-
return await fn();
|
|
63
|
-
}
|
|
64
|
-
finally {
|
|
65
|
-
this.record(stage, performance.now() - start);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
/** Percentile snapshot per stage. Rounded to 2 decimals (ms). */
|
|
69
|
-
snapshot() {
|
|
70
|
-
const out = {};
|
|
71
|
-
for (const [stage, buf] of this.buffers) {
|
|
72
|
-
if (buf.length === 0)
|
|
73
|
-
continue;
|
|
74
|
-
const sorted = [...buf].sort((a, b) => a - b);
|
|
75
|
-
const sum = sorted.reduce((acc, n) => acc + n, 0);
|
|
76
|
-
out[stage] = {
|
|
77
|
-
count: sorted.length,
|
|
78
|
-
p50: round(percentile(sorted, 50)),
|
|
79
|
-
p95: round(percentile(sorted, 95)),
|
|
80
|
-
p99: round(percentile(sorted, 99)),
|
|
81
|
-
max: round(sorted[sorted.length - 1]),
|
|
82
|
-
mean: round(sum / sorted.length),
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
return out;
|
|
86
|
-
}
|
|
87
|
-
}
|
package/dist/rate-limit.js
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
// Per-channel token-bucket safety floor for the SecureComs agent bridge.
|
|
2
|
-
//
|
|
3
|
-
// A lazy-refill token bucket: tokens regenerate based on elapsed wall-clock time
|
|
4
|
-
// at each read, so there is no timer/interval. The clock is injectable for
|
|
5
|
-
// deterministic tests. State is per-key; an unseen key starts full.
|
|
6
|
-
//
|
|
7
|
-
// `capacity <= 0` disables the floor entirely (tryConsume always allows),
|
|
8
|
-
// mirroring how `maxBuffer <= 0` disables the bridge's buffer cap.
|
|
9
|
-
export class TokenBucketFloor {
|
|
10
|
-
capacity;
|
|
11
|
-
refillPerMs;
|
|
12
|
-
now;
|
|
13
|
-
buckets = new Map();
|
|
14
|
-
constructor(opts) {
|
|
15
|
-
this.capacity = opts.capacity;
|
|
16
|
-
this.refillPerMs = opts.refillPerMinute / 60_000;
|
|
17
|
-
this.now = opts.now ?? Date.now;
|
|
18
|
-
}
|
|
19
|
-
refill(key) {
|
|
20
|
-
const t = this.now();
|
|
21
|
-
let b = this.buckets.get(key);
|
|
22
|
-
if (!b) {
|
|
23
|
-
b = { tokens: this.capacity, lastRefill: t };
|
|
24
|
-
this.buckets.set(key, b);
|
|
25
|
-
return b;
|
|
26
|
-
}
|
|
27
|
-
const elapsed = t - b.lastRefill;
|
|
28
|
-
if (elapsed > 0) {
|
|
29
|
-
b.tokens = Math.min(this.capacity, b.tokens + elapsed * this.refillPerMs);
|
|
30
|
-
b.lastRefill = t;
|
|
31
|
-
}
|
|
32
|
-
return b;
|
|
33
|
-
}
|
|
34
|
-
/** Consume one token. Returns true if allowed, false if the bucket is empty. */
|
|
35
|
-
tryConsume(key) {
|
|
36
|
-
if (this.capacity <= 0)
|
|
37
|
-
return true; // disabled
|
|
38
|
-
const b = this.refill(key);
|
|
39
|
-
if (b.tokens >= 1) {
|
|
40
|
-
b.tokens -= 1;
|
|
41
|
-
return true;
|
|
42
|
-
}
|
|
43
|
-
return false;
|
|
44
|
-
}
|
|
45
|
-
/** Milliseconds until one token is available (0 if already available). */
|
|
46
|
-
msUntilNextToken(key) {
|
|
47
|
-
if (this.capacity <= 0)
|
|
48
|
-
return 0;
|
|
49
|
-
const b = this.refill(key);
|
|
50
|
-
if (b.tokens >= 1)
|
|
51
|
-
return 0;
|
|
52
|
-
if (this.refillPerMs <= 0)
|
|
53
|
-
return Infinity;
|
|
54
|
-
return Math.ceil((1 - b.tokens) / this.refillPerMs);
|
|
55
|
-
}
|
|
56
|
-
}
|
package/dist/service/config.js
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { basename, extname, isAbsolute, resolve } from "node:path";
|
|
2
|
-
function slugify(s) {
|
|
3
|
-
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
|
|
4
|
-
}
|
|
5
|
-
export function deriveServiceConfig(input) {
|
|
6
|
-
const stem = input.label ?? basename(input.credentialPath, extname(input.credentialPath));
|
|
7
|
-
const slug = slugify(stem);
|
|
8
|
-
const cred = isAbsolute(input.credentialPath) ? input.credentialPath : resolve(input.credentialPath);
|
|
9
|
-
const args = ["--credential", cred];
|
|
10
|
-
if (input.storePath)
|
|
11
|
-
args.push("--store", isAbsolute(input.storePath) ? input.storePath : resolve(input.storePath));
|
|
12
|
-
if (input.port != null)
|
|
13
|
-
args.push("--port", String(input.port));
|
|
14
|
-
return {
|
|
15
|
-
slug,
|
|
16
|
-
label: `com.securecoms.bridge.${slug}`,
|
|
17
|
-
unitName: `securecoms-bridge-${slug}`,
|
|
18
|
-
args,
|
|
19
|
-
execPath: input.execPath,
|
|
20
|
-
binScript: input.binScript,
|
|
21
|
-
uid: input.uid,
|
|
22
|
-
plistPath: `${input.home}/Library/LaunchAgents/com.securecoms.bridge.${slug}.plist`,
|
|
23
|
-
unitPath: `${input.home}/.config/systemd/user/securecoms-bridge-${slug}.service`,
|
|
24
|
-
logPath: `${input.home}/Library/Logs/securecoms-bridge-${slug}.log`,
|
|
25
|
-
};
|
|
26
|
-
}
|
package/dist/service/install.js
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { dirname } from "node:path";
|
|
2
|
-
import { deriveServiceConfig } from "./config.js";
|
|
3
|
-
import { renderLaunchdPlist } from "./launchd.js";
|
|
4
|
-
import { renderSystemdUnit } from "./systemd.js";
|
|
5
|
-
export class ServiceUnsupportedError extends Error {
|
|
6
|
-
}
|
|
7
|
-
function tokenHint(input) {
|
|
8
|
-
return `${dirname(input.credentialPath)}/bridge-token`;
|
|
9
|
-
}
|
|
10
|
-
function requireOk(r, what) {
|
|
11
|
-
if (r.status !== 0)
|
|
12
|
-
throw new Error(`${what} failed (exit ${r.status})${r.stderr ? `: ${r.stderr.trim()}` : ""}`);
|
|
13
|
-
}
|
|
14
|
-
export function installService(input, deps) {
|
|
15
|
-
const cfg = deriveServiceConfig(input);
|
|
16
|
-
if (deps.platform === "darwin") {
|
|
17
|
-
deps.mkdirp(dirname(cfg.plistPath));
|
|
18
|
-
deps.mkdirp(dirname(cfg.logPath));
|
|
19
|
-
deps.writeFile(cfg.plistPath, renderLaunchdPlist(cfg));
|
|
20
|
-
// Reload-safe: boot out any prior instance (ignore failure), then bootstrap + enable.
|
|
21
|
-
deps.run("launchctl", ["bootout", `gui/${cfg.uid}/${cfg.label}`]);
|
|
22
|
-
// bootstrap actually loads the service — its failure is the real install failure,
|
|
23
|
-
// so surface it (don't let a broken install look silently successful).
|
|
24
|
-
requireOk(deps.run("launchctl", ["bootstrap", `gui/${cfg.uid}`, cfg.plistPath]), "launchctl bootstrap");
|
|
25
|
-
deps.run("launchctl", ["enable", `gui/${cfg.uid}/${cfg.label}`]);
|
|
26
|
-
// Start (or restart) the service now so install doesn't wait for the next login.
|
|
27
|
-
// Mirrors the linux `enable --now`; kickstart -k re-launches if an old instance lingers.
|
|
28
|
-
requireOk(deps.run("launchctl", ["kickstart", "-k", `gui/${cfg.uid}/${cfg.label}`]), "launchctl kickstart");
|
|
29
|
-
return { label: cfg.label, filePath: cfg.plistPath, logHint: cfg.logPath, tokenHint: tokenHint(input) };
|
|
30
|
-
}
|
|
31
|
-
if (deps.platform === "linux") {
|
|
32
|
-
deps.mkdirp(dirname(cfg.unitPath));
|
|
33
|
-
deps.writeFile(cfg.unitPath, renderSystemdUnit(cfg));
|
|
34
|
-
deps.run("systemctl", ["--user", "daemon-reload"]);
|
|
35
|
-
deps.run("loginctl", ["enable-linger", deps.user]);
|
|
36
|
-
// enable --now is the load-bearing command (enable at login + start now); surface failure.
|
|
37
|
-
requireOk(deps.run("systemctl", ["--user", "enable", "--now", cfg.unitName]), "systemctl --user enable --now");
|
|
38
|
-
return { label: cfg.unitName, filePath: cfg.unitPath, logHint: `journalctl --user -u ${cfg.unitName} -f`, tokenHint: tokenHint(input) };
|
|
39
|
-
}
|
|
40
|
-
throw new ServiceUnsupportedError(`Service install isn't supported on ${deps.platform} yet. Run the agent in a terminal, or use a process manager.`);
|
|
41
|
-
}
|
|
42
|
-
export function uninstallService(input, deps) {
|
|
43
|
-
const cfg = deriveServiceConfig(input);
|
|
44
|
-
if (deps.platform === "darwin") {
|
|
45
|
-
const existed = deps.fileExists(cfg.plistPath);
|
|
46
|
-
deps.run("launchctl", ["bootout", `gui/${cfg.uid}/${cfg.label}`]);
|
|
47
|
-
if (existed)
|
|
48
|
-
deps.removeFile(cfg.plistPath);
|
|
49
|
-
return { removed: existed, filePath: cfg.plistPath };
|
|
50
|
-
}
|
|
51
|
-
if (deps.platform === "linux") {
|
|
52
|
-
const existed = deps.fileExists(cfg.unitPath);
|
|
53
|
-
deps.run("systemctl", ["--user", "disable", "--now", cfg.unitName]);
|
|
54
|
-
if (existed)
|
|
55
|
-
deps.removeFile(cfg.unitPath);
|
|
56
|
-
deps.run("systemctl", ["--user", "daemon-reload"]);
|
|
57
|
-
return { removed: existed, filePath: cfg.unitPath };
|
|
58
|
-
}
|
|
59
|
-
throw new ServiceUnsupportedError(`Service uninstall isn't supported on ${deps.platform} yet.`);
|
|
60
|
-
}
|
package/dist/service/launchd.js
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
/** A deterministic launchd LaunchAgent plist. ProgramArguments re-invoke the
|
|
2
|
-
* running bin in run mode; KeepAlive restarts on crash, RunAtLoad starts at login. */
|
|
3
|
-
export function renderLaunchdPlist(cfg) {
|
|
4
|
-
const argv = [cfg.execPath, cfg.binScript, ...cfg.args];
|
|
5
|
-
const programArgs = argv.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
|
|
6
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
7
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
8
|
-
<plist version="1.0">
|
|
9
|
-
<dict>
|
|
10
|
-
<key>Label</key>
|
|
11
|
-
<string>${cfg.label}</string>
|
|
12
|
-
<key>ProgramArguments</key>
|
|
13
|
-
<array>
|
|
14
|
-
${programArgs}
|
|
15
|
-
</array>
|
|
16
|
-
<key>RunAtLoad</key>
|
|
17
|
-
<true/>
|
|
18
|
-
<key>KeepAlive</key>
|
|
19
|
-
<true/>
|
|
20
|
-
<key>StandardOutPath</key>
|
|
21
|
-
<string>${escapeXml(cfg.logPath)}</string>
|
|
22
|
-
<key>StandardErrorPath</key>
|
|
23
|
-
<string>${escapeXml(cfg.logPath)}</string>
|
|
24
|
-
</dict>
|
|
25
|
-
</plist>
|
|
26
|
-
`;
|
|
27
|
-
}
|
|
28
|
-
function escapeXml(s) {
|
|
29
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
30
|
-
}
|
package/dist/service/systemd.js
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/** Quotes a single systemd ExecStart token if it contains whitespace or a
|
|
2
|
-
* double-quote character. Tokens that are already safe pass through unchanged
|
|
3
|
-
* so the common no-space case stays readable in the unit file. */
|
|
4
|
-
function sdQuote(s) {
|
|
5
|
-
return s.includes(" ") || s.includes('"') ? `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : s;
|
|
6
|
-
}
|
|
7
|
-
/** A systemd --user unit. ExecStart re-invokes the running bin in run mode;
|
|
8
|
-
* Restart=always supervises crashes. WantedBy=default.target enables at login. */
|
|
9
|
-
export function renderSystemdUnit(cfg) {
|
|
10
|
-
const execStart = [cfg.execPath, cfg.binScript, ...cfg.args].map(sdQuote).join(" ");
|
|
11
|
-
return `[Unit]
|
|
12
|
-
Description=SecureComs agent bridge (${cfg.slug})
|
|
13
|
-
After=network-online.target
|
|
14
|
-
Wants=network-online.target
|
|
15
|
-
|
|
16
|
-
[Service]
|
|
17
|
-
Type=simple
|
|
18
|
-
ExecStart=${execStart}
|
|
19
|
-
Restart=always
|
|
20
|
-
RestartSec=5
|
|
21
|
-
|
|
22
|
-
[Install]
|
|
23
|
-
WantedBy=default.target
|
|
24
|
-
`;
|
|
25
|
-
}
|
package/dist/ws-server.js
DELETED
|
@@ -1,254 +0,0 @@
|
|
|
1
|
-
import { WebSocketServer, WebSocket } from "ws";
|
|
2
|
-
import { ChannelPendingError, RateLimitedError } from "./bridge-agent.js";
|
|
3
|
-
import { tokenMatches, bearerFrom, originHostAllowed } from "./auth.js";
|
|
4
|
-
import { wsErrorFrame } from "./errors.js";
|
|
5
|
-
const PING_INTERVAL_MS = 30_000;
|
|
6
|
-
/** Pull the local token from `?token=` first, then `Authorization: Bearer`. */
|
|
7
|
-
function tokenFromUpgrade(req) {
|
|
8
|
-
try {
|
|
9
|
-
const url = new URL(req.url ?? "/", "http://localhost");
|
|
10
|
-
const q = url.searchParams.get("token");
|
|
11
|
-
if (q)
|
|
12
|
-
return q;
|
|
13
|
-
}
|
|
14
|
-
catch {
|
|
15
|
-
/* malformed URL — fall through to header */
|
|
16
|
-
}
|
|
17
|
-
return bearerFrom(req.headers.authorization);
|
|
18
|
-
}
|
|
19
|
-
function authedUpgrade(req, opts) {
|
|
20
|
-
if (!tokenMatches(tokenFromUpgrade(req), opts.token))
|
|
21
|
-
return false;
|
|
22
|
-
if (!originHostAllowed({ origin: req.headers.origin, host: req.headers.host }, opts.allowOrigins)) {
|
|
23
|
-
return false;
|
|
24
|
-
}
|
|
25
|
-
return true;
|
|
26
|
-
}
|
|
27
|
-
export function attachAgentWebSocket(server, agent, opts) {
|
|
28
|
-
const wss = new WebSocketServer({ server, path: "/v1/ws" });
|
|
29
|
-
// Server-side keepalive: ping every interval, drop sockets that miss a pong.
|
|
30
|
-
const keepalive = setInterval(() => {
|
|
31
|
-
for (const client of wss.clients) {
|
|
32
|
-
const sock = client;
|
|
33
|
-
if (sock.isAlive === false) {
|
|
34
|
-
sock.terminate();
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
sock.isAlive = false;
|
|
38
|
-
try {
|
|
39
|
-
sock.ping();
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
/* socket on its way out */
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}, PING_INTERVAL_MS);
|
|
46
|
-
// Don't let the interval pin the process open.
|
|
47
|
-
if (typeof keepalive.unref === "function")
|
|
48
|
-
keepalive.unref();
|
|
49
|
-
// One agent per process: the live connection currently owning the inbound
|
|
50
|
-
// stream, carrying its cleanup so a replacement can drain it synchronously.
|
|
51
|
-
let current = null;
|
|
52
|
-
wss.on("connection", (sock, req) => {
|
|
53
|
-
// Auth is enforced here (not verifyClient) so we can close with a clean
|
|
54
|
-
// 1008 policy-violation code that the client observes, rather than a bare
|
|
55
|
-
// HTTP handshake rejection.
|
|
56
|
-
if (!authedUpgrade(req, opts)) {
|
|
57
|
-
sock.close(1008, "unauthorized");
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
// Single consumer, newest wins (mirrors the SSE path). Close the prior
|
|
61
|
-
// socket AND run its cleanup synchronously, so its un-acked in-flight is
|
|
62
|
-
// re-buffered (in FIFO order) BEFORE this socket's initial flush — otherwise
|
|
63
|
-
// the replaced socket's async close could re-buffer those messages after
|
|
64
|
-
// this socket has already consumed newer ones, reordering delivery.
|
|
65
|
-
if (current && current.sock !== sock) {
|
|
66
|
-
const prior = current;
|
|
67
|
-
if (prior.sock.readyState === WebSocket.OPEN) {
|
|
68
|
-
prior.sock.close(1000, "replaced by newer connection");
|
|
69
|
-
}
|
|
70
|
-
prior.cleanup();
|
|
71
|
-
}
|
|
72
|
-
sock.isAlive = true;
|
|
73
|
-
sock.on("pong", () => {
|
|
74
|
-
sock.isAlive = true;
|
|
75
|
-
});
|
|
76
|
-
// Delivered-but-unacked messages, keyed by message_id in delivery order.
|
|
77
|
-
// Held until the agent acks; re-buffered on disconnect (at-least-once).
|
|
78
|
-
const inflight = new Map();
|
|
79
|
-
const flush = () => {
|
|
80
|
-
if (sock.readyState !== WebSocket.OPEN)
|
|
81
|
-
return;
|
|
82
|
-
const batch = agent.takeBuffered();
|
|
83
|
-
for (let i = 0; i < batch.length; i++) {
|
|
84
|
-
if (sock.readyState !== WebSocket.OPEN) {
|
|
85
|
-
// Socket closing mid-flush — give the rest back to the buffer.
|
|
86
|
-
agent.requeueFront(batch.slice(i));
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
const frame = { type: "message", ...batch[i] };
|
|
90
|
-
try {
|
|
91
|
-
sock.send(JSON.stringify(frame));
|
|
92
|
-
inflight.set(batch[i].message_id, batch[i]);
|
|
93
|
-
}
|
|
94
|
-
catch {
|
|
95
|
-
agent.requeueFront(batch.slice(i));
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
};
|
|
100
|
-
const unsubscribe = agent.subscribe(flush);
|
|
101
|
-
let cleaned = false;
|
|
102
|
-
const cleanup = () => {
|
|
103
|
-
if (cleaned)
|
|
104
|
-
return;
|
|
105
|
-
cleaned = true;
|
|
106
|
-
unsubscribe();
|
|
107
|
-
// Re-buffer anything delivered but never acked, in original order, so the
|
|
108
|
-
// next consumer redelivers it rather than the daemon dropping it. Poke
|
|
109
|
-
// listeners so a still-connected replacement consumer drains it now.
|
|
110
|
-
if (inflight.size) {
|
|
111
|
-
agent.requeueFront([...inflight.values()]);
|
|
112
|
-
inflight.clear();
|
|
113
|
-
agent.wakeListeners();
|
|
114
|
-
}
|
|
115
|
-
if (current?.sock === sock)
|
|
116
|
-
current = null;
|
|
117
|
-
};
|
|
118
|
-
current = { sock, cleanup };
|
|
119
|
-
sock.on("close", cleanup);
|
|
120
|
-
sock.on("error", cleanup);
|
|
121
|
-
sock.on("message", (data) => {
|
|
122
|
-
void handleInbound(sock, agent, data, inflight);
|
|
123
|
-
});
|
|
124
|
-
// Flush anything already buffered for an agent that connects after delivery.
|
|
125
|
-
flush();
|
|
126
|
-
});
|
|
127
|
-
return {
|
|
128
|
-
close() {
|
|
129
|
-
clearInterval(keepalive);
|
|
130
|
-
for (const client of wss.clients) {
|
|
131
|
-
try {
|
|
132
|
-
client.terminate();
|
|
133
|
-
}
|
|
134
|
-
catch {
|
|
135
|
-
/* already gone */
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
wss.close();
|
|
139
|
-
},
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
function sendJson(sock, body) {
|
|
143
|
-
if (sock.readyState !== WebSocket.OPEN)
|
|
144
|
-
return;
|
|
145
|
-
try {
|
|
146
|
-
sock.send(JSON.stringify(body));
|
|
147
|
-
}
|
|
148
|
-
catch {
|
|
149
|
-
/* socket closing */
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
async function handleInbound(sock, agent, data, inflight) {
|
|
153
|
-
let msg;
|
|
154
|
-
try {
|
|
155
|
-
msg = JSON.parse(data.toString("utf8"));
|
|
156
|
-
}
|
|
157
|
-
catch {
|
|
158
|
-
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
if (typeof msg !== "object" || msg === null) {
|
|
162
|
-
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
const m = msg;
|
|
166
|
-
if (m.type === "ack") {
|
|
167
|
-
// Consume confirmation: drop the message from in-flight so it is not
|
|
168
|
-
// re-buffered on disconnect. Fire-and-forget (no reply frame).
|
|
169
|
-
if (typeof m.message_id !== "string" || !m.message_id) {
|
|
170
|
-
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
inflight.delete(m.message_id);
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
if (m.type === "send") {
|
|
177
|
-
if (typeof m.channel_id !== "string" || !m.channel_id) {
|
|
178
|
-
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
if (m.a2a && typeof m.a2a === "object") {
|
|
182
|
-
const a = m.a2a;
|
|
183
|
-
if (typeof a.a2a_version !== "string" || !a.a2a_version || (a.kind !== "message" && a.kind !== "task" && a.kind !== "artifact") || typeof a.object !== "object" || a.object === null) {
|
|
184
|
-
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
try {
|
|
188
|
-
const r = await agent.sendA2A(m.channel_id, { a2a_version: a.a2a_version, kind: a.kind, object: a.object });
|
|
189
|
-
sendJson(sock, { type: "sent", message_id: r.message_id });
|
|
190
|
-
}
|
|
191
|
-
catch (e) {
|
|
192
|
-
if (e instanceof ChannelPendingError) {
|
|
193
|
-
sendJson(sock, wsErrorFrame("channel_pending"));
|
|
194
|
-
}
|
|
195
|
-
else if (e instanceof RateLimitedError) {
|
|
196
|
-
sendJson(sock, wsErrorFrame("rate_limited"));
|
|
197
|
-
}
|
|
198
|
-
else {
|
|
199
|
-
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
if (typeof m.text !== "string" || !m.text) {
|
|
205
|
-
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
const idempotencyKey = typeof m.idempotency_key === "string" ? m.idempotency_key : undefined;
|
|
209
|
-
try {
|
|
210
|
-
const r = await agent.send(m.channel_id, m.text, idempotencyKey);
|
|
211
|
-
sendJson(sock, { type: "sent", message_id: r.message_id });
|
|
212
|
-
}
|
|
213
|
-
catch (e) {
|
|
214
|
-
if (e instanceof ChannelPendingError) {
|
|
215
|
-
sendJson(sock, wsErrorFrame("channel_pending"));
|
|
216
|
-
}
|
|
217
|
-
else if (e instanceof RateLimitedError) {
|
|
218
|
-
sendJson(sock, wsErrorFrame("rate_limited"));
|
|
219
|
-
}
|
|
220
|
-
else {
|
|
221
|
-
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
if (m.type === "status") {
|
|
227
|
-
const states = ["idle", "working", "stalled", "error"];
|
|
228
|
-
if (typeof m.channel_id !== "string" ||
|
|
229
|
-
!m.channel_id ||
|
|
230
|
-
typeof m.state !== "string" ||
|
|
231
|
-
!states.includes(m.state)) {
|
|
232
|
-
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
const detail = typeof m.detail === "string" ? m.detail : undefined;
|
|
236
|
-
try {
|
|
237
|
-
await agent.reportStatus(m.channel_id, m.state, detail);
|
|
238
|
-
sendJson(sock, { type: "ok" });
|
|
239
|
-
}
|
|
240
|
-
catch (e) {
|
|
241
|
-
if (e instanceof ChannelPendingError) {
|
|
242
|
-
sendJson(sock, wsErrorFrame("channel_pending"));
|
|
243
|
-
}
|
|
244
|
-
else if (e instanceof RateLimitedError) {
|
|
245
|
-
sendJson(sock, wsErrorFrame("rate_limited"));
|
|
246
|
-
}
|
|
247
|
-
else {
|
|
248
|
-
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
254
|
-
}
|