@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.
Files changed (41) hide show
  1. package/README.md +75 -0
  2. package/dist/bin.js +2657 -93
  3. package/dist/bridge-agent.d.ts +331 -0
  4. package/dist/config.d.ts +3 -0
  5. package/dist/errors.d.ts +1 -1
  6. package/dist/http-server.d.ts +4 -4
  7. package/dist/index.js +2674 -112
  8. package/dist/memory-taps.d.ts +6 -2
  9. package/dist/rate-limit.d.ts +10 -0
  10. package/dist/sodium-ready.d.ts +16 -8
  11. package/dist/summarize.d.ts +54 -0
  12. package/dist/voice/fast-client.d.ts +13 -0
  13. package/dist/voice/fast-prompt.d.ts +53 -0
  14. package/dist/voice/persona.d.ts +23 -0
  15. package/dist/voice/prompt-compiler.d.ts +4 -0
  16. package/dist/voice/settings.d.ts +31 -0
  17. package/package.json +7 -1
  18. package/dist/attachments.js +0 -49
  19. package/dist/auth.js +0 -56
  20. package/dist/bin.js.pre-c1-f803007 +0 -5795
  21. package/dist/bin.js.pre-c3-096a9a3 +0 -5832
  22. package/dist/bin.js.pre-ffrebuild-b162d4a +0 -5639
  23. package/dist/bin.js.pre-kpheal-20260708-150924 +0 -5531
  24. package/dist/bin.js.pre-kppersist-20260708-152341 +0 -5543
  25. package/dist/bin.js.pre-p0-20260708-102415 +0 -5196
  26. package/dist/bin.js.pre-p1b-02f47e1 +0 -5639
  27. package/dist/bin.js.pre-warmfix-20260708-153705 +0 -5613
  28. package/dist/bin.js.pretrace-113205 +0 -5395
  29. package/dist/bridge-agent.js +0 -831
  30. package/dist/config.js +0 -74
  31. package/dist/coordination.js +0 -63
  32. package/dist/errors.js +0 -25
  33. package/dist/http-server.js +0 -220
  34. package/dist/index.js.pre-p0-20260708-102415 +0 -4879
  35. package/dist/latency.js +0 -87
  36. package/dist/rate-limit.js +0 -56
  37. package/dist/service/config.js +0 -26
  38. package/dist/service/install.js +0 -60
  39. package/dist/service/launchd.js +0 -30
  40. package/dist/service/systemd.js +0 -25
  41. 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
- }
@@ -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
- }
@@ -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
- }
@@ -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
- }
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
30
- }
@@ -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
- }