agent-relay-server 0.94.3 → 0.94.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Relay API",
5
- "version": "0.94.3",
5
+ "version": "0.94.5",
6
6
  "description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
7
7
  "license": {
8
8
  "name": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.94.3",
3
+ "version": "0.94.5",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -38,12 +38,13 @@
38
38
  "ajv": "^8.20.0"
39
39
  },
40
40
  "scripts": {
41
- "prepack": "bun run build:dashboard:bundle >&2",
41
+ "prepack": "bun run build:claude-monitor >&2 && bun run build:dashboard:bundle >&2",
42
42
  "postinstall": "node scripts/install-bin-shim.cjs",
43
43
  "start": "bun run src/index.ts",
44
44
  "dev": "bun --watch run src/index.ts",
45
45
  "dev:dashboard": "cd dashboard && bun run dev",
46
46
  "build:sdk": "cd sdk && bun run build",
47
+ "build:claude-monitor": "bun run scripts/build-claude-monitor.ts",
47
48
  "build:dashboard": "bun run build:dashboard:bundle && bun run deploy:dashboard",
48
49
  "build:dashboard:bundle": "bun run build:sdk && cd dashboard && bun run build",
49
50
  "deploy:dashboard": "bun run scripts/deploy-dashboard.ts",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.94.3",
4
+ "version": "0.94.5",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -0,0 +1,908 @@
1
+ // @bun
2
+ // sdk/src/types/guards.ts
3
+ function isRecord(value) {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+ // sdk/src/types/constants.ts
7
+ var DEFAULT_RELAY_PORT = 4850;
8
+ var DEFAULT_RELAY_URL = `http://127.0.0.1:${DEFAULT_RELAY_PORT}`;
9
+ // sdk/src/provider-catalog.ts
10
+ var CLAUDE_LOW_TO_MAX = ["low", "medium", "high", "max"];
11
+ var CLAUDE_LOW_TO_XHIGH_MAX = ["low", "medium", "high", "xhigh", "max"];
12
+ var CODEX_REASONING = ["low", "medium", "high", "xhigh"];
13
+ var CONTEXT_200K = { value: 200000, source: "catalog", confidence: "declared" };
14
+ var CONTEXT_1M = { value: 1e6, source: "catalog", confidence: "declared" };
15
+ var CODE_MODEL_CAPABILITIES = {
16
+ modalities: {
17
+ input: { text: true, image: true },
18
+ output: { text: true }
19
+ },
20
+ tools: {
21
+ code: true,
22
+ review: true,
23
+ debug: true,
24
+ refactor: true
25
+ },
26
+ source: "catalog",
27
+ confidence: "declared"
28
+ };
29
+ function codeModel(limits) {
30
+ return {
31
+ ...limits ? { limits } : {},
32
+ capabilities: CODE_MODEL_CAPABILITIES
33
+ };
34
+ }
35
+ var PROVIDER_CATALOG = {
36
+ claude: {
37
+ provider: "claude",
38
+ label: "Claude Code",
39
+ defaultModel: "sonnet-4.6",
40
+ models: [
41
+ { alias: "fable-5", label: "Fable 5", providerModel: "claude-fable-5", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", unavailable: "suspended by export-control directive, 2026-06-12", ...codeModel({ contextWindowTokens: CONTEXT_1M }) },
42
+ { alias: "opus-4.8", label: "Opus 4.8", providerModel: "claude-opus-4-8", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
43
+ { alias: "opus-4.8[1m]", label: "Opus 4.8 1M", providerModel: "claude-opus-4-8[1m]", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_1M }) },
44
+ { alias: "opus-4.7", label: "Opus 4.7", providerModel: "claude-opus-4-7", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
45
+ { alias: "opus-4.7[1m]", label: "Opus 4.7 1M", providerModel: "claude-opus-4-7[1m]", efforts: CLAUDE_LOW_TO_XHIGH_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_1M }) },
46
+ { alias: "opus-4.6", label: "Opus 4.6", providerModel: "claude-opus-4-6", efforts: CLAUDE_LOW_TO_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
47
+ { alias: "opus-4.6[1m]", label: "Opus 4.6 1M", providerModel: "claude-opus-4-6[1m]", efforts: CLAUDE_LOW_TO_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_1M }) },
48
+ { alias: "sonnet-4.6", label: "Sonnet 4.6", providerModel: "claude-sonnet-4-6", efforts: CLAUDE_LOW_TO_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
49
+ { alias: "sonnet-4.6[1m]", label: "Sonnet 4.6 1M", providerModel: "claude-sonnet-4-6[1m]", efforts: CLAUDE_LOW_TO_MAX, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_1M }) },
50
+ { alias: "haiku", label: "Haiku", providerModel: "haiku", efforts: [], ...codeModel() }
51
+ ]
52
+ },
53
+ codex: {
54
+ provider: "codex",
55
+ label: "Codex",
56
+ defaultModel: "gpt-5.5",
57
+ models: [
58
+ { alias: "gpt-5.5", label: "GPT-5.5", providerModel: "gpt-5.5", efforts: CODEX_REASONING, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
59
+ { alias: "gpt-5.4", label: "GPT-5.4", providerModel: "gpt-5.4", efforts: CODEX_REASONING, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
60
+ { alias: "gpt-5.4-mini", label: "GPT-5.4 Mini", providerModel: "gpt-5.4-mini", efforts: CODEX_REASONING, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
61
+ { alias: "gpt-5.3-codex", label: "GPT-5.3 Codex", providerModel: "gpt-5.3-codex", efforts: CODEX_REASONING, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
62
+ { alias: "gpt-5.3-codex-spark", label: "GPT-5.3 Codex Spark", providerModel: "gpt-5.3-codex-spark", efforts: CODEX_REASONING, defaultEffort: "high", ...codeModel({ contextWindowTokens: CONTEXT_200K }) },
63
+ { alias: "gpt-5.2", label: "GPT-5.2", providerModel: "gpt-5.2", efforts: CODEX_REASONING, defaultEffort: "medium", ...codeModel({ contextWindowTokens: CONTEXT_200K }) }
64
+ ]
65
+ }
66
+ };
67
+ // sdk/src/reconnect.ts
68
+ import { setTimeout as delay } from "timers/promises";
69
+ var DEFAULTS = {
70
+ initialMs: 1000,
71
+ maxMs: 30000,
72
+ jitterMs: 500
73
+ };
74
+
75
+ class ReconnectionManager {
76
+ attempts = 0;
77
+ abort;
78
+ options;
79
+ constructor(options = {}) {
80
+ this.options = {
81
+ initialMs: options.initialMs ?? DEFAULTS.initialMs,
82
+ maxMs: options.maxMs ?? DEFAULTS.maxMs,
83
+ jitterMs: options.jitterMs ?? DEFAULTS.jitterMs
84
+ };
85
+ }
86
+ get attempt() {
87
+ return this.attempts;
88
+ }
89
+ nextDelay() {
90
+ const base = Math.min(this.options.maxMs, this.options.initialMs * 2 ** this.attempts);
91
+ this.attempts += 1;
92
+ const jitter = this.options.jitterMs > 0 ? Math.floor(Math.random() * (this.options.jitterMs + 1)) : 0;
93
+ return Math.min(this.options.maxMs, base + jitter);
94
+ }
95
+ reset() {
96
+ this.attempts = 0;
97
+ this.cancel();
98
+ }
99
+ async schedule() {
100
+ this.cancel();
101
+ this.abort = new AbortController;
102
+ const ms = this.nextDelay();
103
+ await delay(ms, undefined, { signal: this.abort.signal }).catch((error) => {
104
+ if (error.name !== "AbortError")
105
+ throw error;
106
+ });
107
+ }
108
+ cancel() {
109
+ this.abort?.abort();
110
+ this.abort = undefined;
111
+ }
112
+ }
113
+ // sdk/src/config.ts
114
+ function relayTokenFromEnv() {
115
+ return process.env.AGENT_RELAY_TOKEN;
116
+ }
117
+ // sdk/src/bus-client.ts
118
+ import { EventEmitter } from "events";
119
+ class MemoryCursorStore {
120
+ value = null;
121
+ async load() {
122
+ return this.value;
123
+ }
124
+ async save(cursor) {
125
+ this.value = cursor;
126
+ }
127
+ }
128
+
129
+ class RelayBusClient extends EventEmitter {
130
+ options;
131
+ ws = null;
132
+ registeredEpoch = 0;
133
+ lastCursor = 0;
134
+ serverCursor = 0;
135
+ closed = false;
136
+ connecting = null;
137
+ heartbeat;
138
+ heartbeatWatchdog;
139
+ pendingHeartbeatId;
140
+ pendingHeartbeatSentAt;
141
+ lastServerFrameAt = 0;
142
+ lastHeartbeatAckAt = 0;
143
+ heartbeatAckSupported = false;
144
+ semanticStatus;
145
+ transport = { connected: false, reconnecting: false };
146
+ reconnect;
147
+ cursorStore;
148
+ subscriptions = new Set(["message.*", "agent.status", "task.*"]);
149
+ pending = new Map;
150
+ constructor(options) {
151
+ super();
152
+ this.options = options;
153
+ this.reconnect = new ReconnectionManager({
154
+ initialMs: options.reconnect?.initialMs,
155
+ maxMs: options.reconnect?.maxMs,
156
+ jitterMs: options.reconnect?.jitterMs
157
+ });
158
+ this.cursorStore = options.cursorStore ?? new MemoryCursorStore;
159
+ this.semanticStatus = options.initialStatus ?? "idle";
160
+ }
161
+ get connected() {
162
+ return this.ws?.readyState === WebSocket.OPEN && this.registeredEpoch > 0;
163
+ }
164
+ get epoch() {
165
+ return this.registeredEpoch;
166
+ }
167
+ get cursor() {
168
+ return this.lastCursor;
169
+ }
170
+ get transportState() {
171
+ return { ...this.transport };
172
+ }
173
+ async connect() {
174
+ if (this.connecting)
175
+ return this.connecting;
176
+ this.closed = false;
177
+ this.connecting = this.connectLoop().finally(() => {
178
+ this.connecting = null;
179
+ });
180
+ return this.connecting;
181
+ }
182
+ async close() {
183
+ this.closed = true;
184
+ this.stopHeartbeat();
185
+ this.stopHeartbeatWatchdog();
186
+ this.reconnect.cancel();
187
+ for (const pending of this.pending.values())
188
+ pending.reject(new Error("bus client closed"));
189
+ this.pending.clear();
190
+ if (this.ws && this.ws.readyState <= WebSocket.OPEN)
191
+ this.ws.close();
192
+ this.ws = null;
193
+ }
194
+ setToken(token) {
195
+ this.options.token = token;
196
+ }
197
+ reconnectTransport(reason = "client requested reconnect") {
198
+ this.forceReconnect(reason, { emitError: false, code: 4001 });
199
+ }
200
+ status(update) {
201
+ if (update.agentStatus === "idle" || update.agentStatus === "busy")
202
+ this.semanticStatus = update.agentStatus;
203
+ this.send({
204
+ type: "status",
205
+ payload: {
206
+ agentStatus: update.agentStatus,
207
+ ready: update.ready,
208
+ meta: update.meta
209
+ }
210
+ });
211
+ }
212
+ async statusAsync(update) {
213
+ this.status(update);
214
+ await Promise.resolve();
215
+ }
216
+ setSemanticStatus(status) {
217
+ this.semanticStatus = status;
218
+ }
219
+ updateCommand(commandId, params) {
220
+ return this.command("command.update", commandId, params);
221
+ }
222
+ command(type, target, params) {
223
+ const id = crypto.randomUUID();
224
+ this.send({ type: "command", id, payload: { commandType: type, target, params } });
225
+ return new Promise((resolve, reject) => {
226
+ this.pending.set(id, { resolve, reject });
227
+ });
228
+ }
229
+ async subscribe(events, scopes) {
230
+ for (const event of events)
231
+ this.subscriptions.add(event);
232
+ const id = crypto.randomUUID();
233
+ this.send({ type: "subscribe", id, payload: { events, scopes } });
234
+ await Promise.resolve();
235
+ }
236
+ on(event, listener) {
237
+ return super.on(event, listener);
238
+ }
239
+ off(event, listener) {
240
+ return super.off(event, listener);
241
+ }
242
+ once(event, listener) {
243
+ return super.once(event, listener);
244
+ }
245
+ async connectLoop() {
246
+ while (!this.closed) {
247
+ try {
248
+ await this.openOnce();
249
+ this.reconnect.reset();
250
+ return;
251
+ } catch (error) {
252
+ if (this.closed)
253
+ throw error;
254
+ const delayMs = this.reconnect.nextDelay();
255
+ this.transport = { ...this.transport, connected: false, reconnecting: true };
256
+ this.emit("reconnecting", this.reconnect.attempt, delayMs);
257
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
258
+ }
259
+ }
260
+ }
261
+ async openOnce() {
262
+ const cursor = await this.cursorStore.load();
263
+ if (cursor !== null)
264
+ this.lastCursor = cursor;
265
+ await new Promise((resolve, reject) => {
266
+ const ws = new WebSocket(this.wsUrl());
267
+ this.ws = ws;
268
+ let resolved = false;
269
+ ws.onopen = () => {
270
+ if (this.ws !== ws)
271
+ return;
272
+ const now = Date.now();
273
+ this.lastServerFrameAt = now;
274
+ this.transport = { ...this.transport, connected: true, reconnecting: false, lastConnectedAt: now, lastServerFrameAt: now, reconnectReason: undefined };
275
+ this.emit("connected");
276
+ this.sendRegister();
277
+ };
278
+ ws.onmessage = (message) => {
279
+ if (this.ws !== ws)
280
+ return;
281
+ this.handleMessage(message.data, () => {
282
+ if (!resolved) {
283
+ resolved = true;
284
+ resolve();
285
+ }
286
+ });
287
+ };
288
+ ws.onerror = () => {
289
+ if (this.ws !== ws)
290
+ return;
291
+ if (!resolved)
292
+ reject(new Error("bus connection failed"));
293
+ };
294
+ ws.onclose = (event) => {
295
+ if (this.ws !== ws)
296
+ return;
297
+ this.stopHeartbeat();
298
+ this.stopHeartbeatWatchdog();
299
+ this.registeredEpoch = 0;
300
+ this.heartbeatAckSupported = false;
301
+ this.transport = { ...this.transport, connected: false, reconnecting: !this.closed, lastDisconnectedAt: Date.now() };
302
+ const reason = event.reason || `closed:${event.code}`;
303
+ this.emit("disconnected", reason);
304
+ if (!resolved) {
305
+ resolved = true;
306
+ reject(new Error(reason));
307
+ return;
308
+ }
309
+ if (!this.closed)
310
+ this.connect();
311
+ };
312
+ });
313
+ }
314
+ sendRegister() {
315
+ this.send({
316
+ type: "register",
317
+ id: crypto.randomUUID(),
318
+ payload: {
319
+ role: this.options.role,
320
+ componentId: this.options.componentId,
321
+ agentId: this.options.agentId,
322
+ instanceId: this.options.instanceId,
323
+ capabilities: this.options.capabilities ?? [],
324
+ tags: this.options.tags ?? [],
325
+ machine: this.options.machine ?? "unknown",
326
+ meta: this.registrationMeta()
327
+ }
328
+ });
329
+ }
330
+ async handleMessage(data, onRegistered) {
331
+ this.recordServerFrame();
332
+ let frame;
333
+ try {
334
+ frame = JSON.parse(typeof data === "string" ? data : String(data));
335
+ } catch {
336
+ this.emit("error", "INVALID_JSON", "server sent invalid JSON");
337
+ return;
338
+ }
339
+ if (frame.type === "registered") {
340
+ const payload = frame.payload;
341
+ const resumeFrom = this.lastCursor;
342
+ this.registeredEpoch = payload.epoch;
343
+ this.serverCursor = payload.cursor;
344
+ this.heartbeatAckSupported = payload.features?.heartbeatAck === true;
345
+ this.emit("registered", payload.epoch, payload.cursor);
346
+ this.send({ type: "subscribe", id: crypto.randomUUID(), payload: { events: [...this.subscriptions] } });
347
+ if (resumeFrom > 0 && resumeFrom < payload.cursor) {
348
+ this.send({ type: "resume", id: crypto.randomUUID(), payload: { since: resumeFrom } });
349
+ } else {
350
+ this.lastCursor = payload.cursor;
351
+ await this.cursorStore.save(this.lastCursor);
352
+ }
353
+ this.startHeartbeat();
354
+ if (this.heartbeatAckSupported)
355
+ this.startHeartbeatWatchdog();
356
+ else
357
+ this.stopHeartbeatWatchdog();
358
+ onRegistered();
359
+ return;
360
+ }
361
+ if (frame.type === "ack") {
362
+ if (frame.payload.frameId === this.pendingHeartbeatId) {
363
+ this.lastHeartbeatAckAt = Date.now();
364
+ this.pendingHeartbeatId = undefined;
365
+ this.pendingHeartbeatSentAt = undefined;
366
+ this.transport = { ...this.transport, lastHeartbeatAckAt: this.lastHeartbeatAckAt };
367
+ }
368
+ return;
369
+ }
370
+ if (frame.type === "event") {
371
+ await this.handleEvent(frame.payload);
372
+ return;
373
+ }
374
+ if (frame.type === "resumed") {
375
+ for (const event of frame.payload.events)
376
+ await this.handleEvent(event);
377
+ return;
378
+ }
379
+ if (frame.type === "command.result") {
380
+ const pending = this.pending.get(frame.payload.commandId);
381
+ if (!pending)
382
+ return;
383
+ this.pending.delete(frame.payload.commandId);
384
+ if (frame.payload.status === "succeeded")
385
+ pending.resolve(frame.payload.result ?? null);
386
+ else
387
+ pending.reject(new Error(frame.payload.error || frame.payload.status));
388
+ return;
389
+ }
390
+ if (frame.type === "error") {
391
+ const error = frame;
392
+ if (error.payload.code === "REPLAY_GAP") {
393
+ await this.options.reconcile?.();
394
+ this.lastCursor = this.serverCursor;
395
+ await this.cursorStore.save(this.lastCursor);
396
+ }
397
+ this.emit("error", error.payload.code, error.payload.message);
398
+ if (error.payload.frameId) {
399
+ const pending = this.pending.get(error.payload.frameId);
400
+ if (pending) {
401
+ this.pending.delete(error.payload.frameId);
402
+ pending.reject(new Error(error.payload.message));
403
+ }
404
+ }
405
+ }
406
+ }
407
+ async handleEvent(event) {
408
+ this.lastCursor = Math.max(this.lastCursor, event.seq);
409
+ await this.cursorStore.save(this.lastCursor);
410
+ this.emit("event", event.eventType, event.data, event.seq);
411
+ if (event.eventType === "command.requested") {
412
+ const command = isRecord(event.data.command) ? event.data.command : undefined;
413
+ if (command) {
414
+ const commandType = typeof command.type === "string" ? command.type : "";
415
+ const commandId = typeof command.id === "string" ? command.id : "";
416
+ const params = isRecord(command.params) ? command.params : {};
417
+ this.emit("command", commandType, params, commandId, command);
418
+ }
419
+ }
420
+ this.emit(event.eventType, event.data);
421
+ if (event.eventType.startsWith("task."))
422
+ this.emit("task.changed", event.data);
423
+ }
424
+ startHeartbeat() {
425
+ this.stopHeartbeat();
426
+ this.sendHeartbeat();
427
+ this.heartbeat = setInterval(() => {
428
+ this.sendHeartbeat();
429
+ }, this.options.heartbeatIntervalMs ?? 30000);
430
+ }
431
+ stopHeartbeat() {
432
+ if (this.heartbeat)
433
+ clearInterval(this.heartbeat);
434
+ this.heartbeat = undefined;
435
+ this.pendingHeartbeatId = undefined;
436
+ this.pendingHeartbeatSentAt = undefined;
437
+ }
438
+ startHeartbeatWatchdog() {
439
+ this.stopHeartbeatWatchdog();
440
+ const intervalMs = Math.max(100, Math.min(5000, this.heartbeatIntervalMs()));
441
+ this.heartbeatWatchdog = setInterval(() => this.checkHeartbeatAck(), intervalMs);
442
+ }
443
+ stopHeartbeatWatchdog() {
444
+ if (this.heartbeatWatchdog)
445
+ clearInterval(this.heartbeatWatchdog);
446
+ this.heartbeatWatchdog = undefined;
447
+ }
448
+ sendHeartbeat() {
449
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.registeredEpoch)
450
+ return;
451
+ const now = Date.now();
452
+ const expectAck = this.heartbeatAckSupported;
453
+ if (expectAck && this.pendingHeartbeatId && this.pendingHeartbeatSentAt && now - this.pendingHeartbeatSentAt < this.heartbeatIntervalMs())
454
+ return;
455
+ const id = expectAck ? crypto.randomUUID() : undefined;
456
+ const meta = this.heartbeatMeta();
457
+ if (expectAck && id) {
458
+ this.pendingHeartbeatId = id;
459
+ if (!this.pendingHeartbeatSentAt)
460
+ this.pendingHeartbeatSentAt = now;
461
+ }
462
+ this.send({ type: "heartbeat", ...id ? { id } : {}, payload: { status: this.semanticStatus, ...meta ? { meta } : {} } });
463
+ }
464
+ checkHeartbeatAck() {
465
+ if (!this.pendingHeartbeatSentAt || !this.ws || this.ws.readyState !== WebSocket.OPEN)
466
+ return;
467
+ const ageMs = Date.now() - this.pendingHeartbeatSentAt;
468
+ if (ageMs <= this.heartbeatAckTimeoutMs())
469
+ return;
470
+ this.forceReconnect(`heartbeat ack timeout after ${ageMs}ms`, { emitError: true, code: 4000 });
471
+ }
472
+ forceReconnect(reason, opts) {
473
+ if (this.closed)
474
+ return;
475
+ this.transport = { ...this.transport, connected: false, reconnecting: true, lastDisconnectedAt: Date.now(), reconnectReason: reason };
476
+ if (opts.emitError)
477
+ this.emit("error", "HEARTBEAT_TIMEOUT", reason);
478
+ this.stopHeartbeat();
479
+ this.stopHeartbeatWatchdog();
480
+ const ws = this.ws;
481
+ this.ws = null;
482
+ this.registeredEpoch = 0;
483
+ this.heartbeatAckSupported = false;
484
+ if (ws && ws.readyState <= WebSocket.OPEN)
485
+ ws.close(opts.code, reason.slice(0, 120));
486
+ this.connect();
487
+ }
488
+ heartbeatIntervalMs() {
489
+ return this.options.heartbeatIntervalMs ?? 30000;
490
+ }
491
+ heartbeatAckTimeoutMs() {
492
+ return this.options.heartbeatAckTimeoutMs ?? Math.max(1e4, Math.ceil(this.heartbeatIntervalMs() * 1.5));
493
+ }
494
+ recordServerFrame() {
495
+ this.lastServerFrameAt = Date.now();
496
+ this.transport = { ...this.transport, lastServerFrameAt: this.lastServerFrameAt };
497
+ }
498
+ send(frame) {
499
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
500
+ return;
501
+ this.ws.send(JSON.stringify(frame));
502
+ }
503
+ wsUrl() {
504
+ const url = new URL(this.options.url);
505
+ const token = this.options.token ?? relayTokenFromEnv();
506
+ if (token && !url.searchParams.has("token"))
507
+ url.searchParams.set("token", token);
508
+ return url.toString();
509
+ }
510
+ registrationMeta() {
511
+ return {
512
+ ...this.options.meta ?? {},
513
+ ...this.options.providerCapabilities ? { providerCapabilities: this.options.providerCapabilities } : {},
514
+ ...this.options.context ? { context: this.options.context } : {}
515
+ };
516
+ }
517
+ heartbeatMeta() {
518
+ const dynamic = this.options.heartbeatMeta?.();
519
+ const meta = {
520
+ ...dynamic ?? {},
521
+ ...this.options.providerCapabilities ? { providerCapabilities: this.options.providerCapabilities } : {},
522
+ ...this.options.context ? { context: this.options.context } : {}
523
+ };
524
+ return Object.keys(meta).length ? meta : undefined;
525
+ }
526
+ }
527
+ // sdk/src/provider-base.ts
528
+ class ProviderBase {
529
+ options;
530
+ client;
531
+ running = false;
532
+ pending = new Map;
533
+ drain;
534
+ constructor(options) {
535
+ this.options = options;
536
+ this.client = new RelayBusClient({
537
+ url: options.relayUrl,
538
+ role: "provider",
539
+ componentId: `${options.adapter.providerName}:${options.agentId}`,
540
+ agentId: options.agentId,
541
+ instanceId: options.instanceId,
542
+ token: options.token,
543
+ capabilities: options.adapter.capabilities,
544
+ tags: options.tags ?? [options.adapter.providerName],
545
+ machine: options.machine,
546
+ meta: { provider: options.adapter.providerName, ...options.meta ?? {} }
547
+ });
548
+ }
549
+ async run() {
550
+ if (this.running)
551
+ return;
552
+ this.running = true;
553
+ this.client.on("message.new", (message) => this.queueMessage(message));
554
+ await this.client.connect();
555
+ this.startStatusLoop();
556
+ }
557
+ async stop() {
558
+ this.running = false;
559
+ if (this.drain)
560
+ clearInterval(this.drain);
561
+ this.drain = undefined;
562
+ await this.client.close();
563
+ }
564
+ queueMessage(message) {
565
+ if (message.to !== this.options.agentId && message.to !== "broadcast" && !message.to.startsWith("tag:") && !message.to.startsWith("cap:") && !message.to.startsWith("label:") && !message.to.startsWith("team:")) {
566
+ return;
567
+ }
568
+ this.pending.set(message.id, message);
569
+ this.drainMessages();
570
+ }
571
+ async drainMessages() {
572
+ if (!this.running || this.pending.size === 0)
573
+ return;
574
+ const messages2 = [...this.pending.values()].sort((a, b) => a.id - b.id);
575
+ this.pending.clear();
576
+ await this.options.adapter.deliverMessage(this.options.agentId, messages2);
577
+ }
578
+ startStatusLoop() {
579
+ this.drain = setInterval(async () => {
580
+ if (!this.running)
581
+ return;
582
+ try {
583
+ const status = await this.options.adapter.getAgentStatus(this.options.agentId);
584
+ this.client.status({ agentStatus: status, ready: true });
585
+ } catch {
586
+ this.client.status({ agentStatus: "online", ready: false });
587
+ }
588
+ }, 30000);
589
+ }
590
+ }
591
+ // sdk/src/claim-tracker.ts
592
+ class WorkClaimTracker {
593
+ providerActive = false;
594
+ claims = new Map;
595
+ setProviderActive(active) {
596
+ this.providerActive = active;
597
+ }
598
+ start(kind, id, expiresAt) {
599
+ this.claims.set(`${kind}:${id}`, { kind, id, startedAt: Date.now(), expiresAt });
600
+ }
601
+ finish(kind, id) {
602
+ this.claims.delete(`${kind}:${id}`);
603
+ }
604
+ expire(now = Date.now()) {
605
+ for (const [key, claim] of this.claims) {
606
+ if (claim.expiresAt !== undefined && claim.expiresAt <= now)
607
+ this.claims.delete(key);
608
+ }
609
+ }
610
+ status() {
611
+ return this.providerActive || this.claims.size > 0 ? "busy" : "idle";
612
+ }
613
+ active() {
614
+ return [...this.claims.values()];
615
+ }
616
+ }
617
+ // sdk/src/process-utils.ts
618
+ import { execFile } from "child_process";
619
+ import { promisify } from "util";
620
+ var execFileAsync = promisify(execFile);
621
+ // sdk/src/teams-config.ts
622
+ var TEAM_IDLE_THRESHOLD_MINUTES_MAX = 24 * 60;
623
+ // runner/src/config.ts
624
+ function messageBodyMaxCharsFromEnv() {
625
+ return process.env.AGENT_RELAY_MESSAGE_BODY_MAX_CHARS;
626
+ }
627
+
628
+ // runner/src/relay-instructions.ts
629
+ var CLAUDE_RELAY_CONTEXT = `[agent-relay] Relay is available. Follow the Agent Relay rules in CLAUDE.md when present, or run agent-relay /guide. Reply through Relay only when a response is needed; do not send status-only follow-ups after the useful Relay response was already delivered.`;
630
+ var CLAUDE_READ_ONLY_RELAY_CONTEXT = `${CLAUDE_RELAY_CONTEXT}
631
+
632
+ This Claude session is running with restricted read-only Relay permissions. Do not invoke Agent Relay skills. If you need to reply, use this Bash command shape:
633
+
634
+ agent-relay /reply <messageId> "<your reply>"`;
635
+ function relayReplyActionText(messageId) {
636
+ return `relay_reply (messageId: ${messageId}, body: "<your reply>"). CLI fallback: agent-relay /reply ${messageId} "<your reply>"`;
637
+ }
638
+ function relayReplyReminderText(messageId) {
639
+ return `If this batch needs a response, send one useful reply to the latest relevant message with ${relayReplyActionText(messageId)}`;
640
+ }
641
+ function workspaceDepsNote(input) {
642
+ if (input.mode !== "isolated")
643
+ return "";
644
+ switch (input.depsMode) {
645
+ case "symlink":
646
+ return "[agent-relay] Isolated workspace: this is a git worktree, and its node_modules are SYMLINKED from the main checkout \u2014 dependencies are already installed and ready to use. Do NOT run a clean dependency install (`bun install` / `npm install` / `pnpm install`): it writes through the symlink and mutates the main checkout's shared node_modules. Build caches written under node_modules are shared too. If typecheck/build fails on a missing module (a dependency added to the base AFTER this worktree was created), run `agent-relay workspace deps` \u2014 it re-provisions only the stale dirs with a real isolated install, safely, without touching the shared node_modules. If you genuinely need to change dependencies in isolation, ask the host to spawn with AGENT_RELAY_WORKSPACE_DEPS=install.";
647
+ case "none":
648
+ return "[agent-relay] Isolated workspace: dependencies were not provisioned (AGENT_RELAY_WORKSPACE_DEPS=none). You may need to install node_modules before typecheck/test/build work.";
649
+ default:
650
+ return "";
651
+ }
652
+ }
653
+ function workspaceLifecycleNote(input) {
654
+ if (input.mode !== "isolated")
655
+ return "";
656
+ const branch = input.branch ? `\`${input.branch}\`` : "an isolated agent branch";
657
+ const base = input.baseRef ? `\`${input.baseRef}\`` : "the base branch";
658
+ return [
659
+ `[agent-relay] Isolated workspace: you are in a git worktree on branch ${branch}, based on ${base} \u2014 NOT the main checkout. Other agents may work in parallel and land to ${base}, so ${base} will move under you. That is expected; don't fight it.`,
660
+ `Do NOT push this branch yourself \u2014 not with \`git push\`, not with \`tl push\` or any other push wrapper, and do not manually rebase or merge. A steward may be auto-rebasing this branch in the background; pushing concurrently races it and can leave the worktree mid-rebase. Just commit your work here. When the task is done, run \`agent-relay workspace ready\` \u2014 Relay rebases onto the latest ${base}, lands your work, and pushes for you. If the installed \`agent-relay\` binary is stale and says the workspace command is unknown, run the repo-local fallback: \`bun src/index.ts workspace ready\`.`,
661
+ `After \`ready\`, the status becomes \`review_requested\` \u2014 this is the NORMAL, healthy hand-off state, NOT an escalation or a stall. Relay auto-merges clean rebases roughly every 2 minutes; a steward agent is spawned (after a short delay) ONLY if it can't land deterministically, so seeing no steward means it's working, not stuck. Stop your turn after \`ready\` and go idle; do NOT wait or poll the steward queue. Relay wakes you with \`landed-success\` when your branch lands and refreshes, or \`landed-failure\` if landing fatally fails. On success you'll be on a fresh rebased branch whose name gains a \`--N\` suffix \u2014 expected, keep working there. Never \`cd\` into the main checkout, and never merge/push/resolve conflicts yourself \u2014 Relay and the steward own all of that. \`agent-relay workspace status\` anytime shows your current state and the exact next step.`
662
+ ].join(`
663
+ `);
664
+ }
665
+ function workspaceSymlinksNote(linked) {
666
+ if (!linked.length)
667
+ return "";
668
+ return `[agent-relay] Isolated workspace: these untracked paths are SYMLINKED from the main checkout: ${linked.join(", ")}. They resolve to the real files in main, so editing or deleting them writes THROUGH to main \u2014 treat them as read-only unless you intend to change main.`;
669
+ }
670
+ function workspaceDepsNoteFromEnv(env = process.env) {
671
+ const json = env.AGENT_RELAY_WORKSPACE_JSON;
672
+ if (!json)
673
+ return "";
674
+ try {
675
+ const parsed = JSON.parse(json);
676
+ return [
677
+ workspaceLifecycleNote({ mode: parsed.mode ?? null, branch: parsed.branch ?? null, baseRef: parsed.baseRef ?? null }),
678
+ workspaceDepsNote({ mode: parsed.mode ?? null, depsMode: parsed.deps?.mode ?? null }),
679
+ parsed.mode === "isolated" ? workspaceSymlinksNote(parsed.symlinks?.linked ?? []) : ""
680
+ ].filter(Boolean).join(`
681
+
682
+ `);
683
+ } catch {
684
+ return "";
685
+ }
686
+ }
687
+
688
+ // runner/src/adapter.ts
689
+ var DEFAULT_PROVIDER_MESSAGE_BODY_MAX_CHARS = 24000;
690
+ function providerMessageBodyMaxChars() {
691
+ const raw = messageBodyMaxCharsFromEnv();
692
+ if (raw !== undefined) {
693
+ const parsed = Number.parseInt(raw.trim(), 10);
694
+ if (Number.isFinite(parsed) && parsed > 0)
695
+ return parsed;
696
+ }
697
+ return DEFAULT_PROVIDER_MESSAGE_BODY_MAX_CHARS;
698
+ }
699
+ function attachmentRefs(message) {
700
+ const payloadRefs = message.payload?.attachments;
701
+ const topLevelRefs = message.attachments;
702
+ const refs = Array.isArray(payloadRefs) ? payloadRefs : Array.isArray(topLevelRefs) ? topLevelRefs : [];
703
+ return refs.filter(isRecord);
704
+ }
705
+ function attachmentLabel(ref) {
706
+ const parts = [
707
+ typeof ref.kind === "string" ? ref.kind : undefined,
708
+ typeof ref.role === "string" ? ref.role : undefined,
709
+ typeof ref.title === "string" ? `"${ref.title}"` : undefined
710
+ ].filter(Boolean);
711
+ return parts.length ? ` (${parts.join(", ")})` : "";
712
+ }
713
+ function attachmentCacheMetadata(ref) {
714
+ const metadata = isRecord(ref.metadata) ? ref.metadata : undefined;
715
+ const agentRelay = metadata && isRecord(metadata.agentRelay) ? metadata.agentRelay : undefined;
716
+ return agentRelay;
717
+ }
718
+ function isPersistedRelayMessage(message) {
719
+ return Number.isSafeInteger(message.id) && message.id > 0;
720
+ }
721
+ var NOTIFICATION_NUDGE = "\u21AA Notification \u2014 no reply needed.";
722
+ function isNotificationMessage(message) {
723
+ return isPersistedRelayMessage(message) && message.replyExpected === false;
724
+ }
725
+ function providerAttachmentText(message) {
726
+ const refs = attachmentRefs(message);
727
+ if (!refs.length)
728
+ return;
729
+ const lines = refs.flatMap((ref) => {
730
+ const artifactId = typeof ref.artifactId === "string" ? ref.artifactId : "unknown-artifact";
731
+ const source = isRecord(ref.ref) ? [
732
+ typeof ref.ref.provider === "string" ? ref.ref.provider : undefined,
733
+ typeof ref.ref.id === "string" ? ref.ref.id : undefined
734
+ ].filter(Boolean).join(":") : "";
735
+ const cache = attachmentCacheMetadata(ref);
736
+ const localPath = typeof cache?.localPath === "string" ? cache.localPath : undefined;
737
+ const cacheError = typeof cache?.cacheError === "string" ? cache.cacheError : undefined;
738
+ return [
739
+ `- ${artifactId}${attachmentLabel(ref)}${source ? ` source=${source}` : ""}`,
740
+ ...localPath ? [` local: ${localPath}`] : [],
741
+ ...cacheError ? [` cache error: ${cacheError}`] : []
742
+ ];
743
+ });
744
+ const hasLocalPaths = refs.some((ref) => typeof attachmentCacheMetadata(ref)?.localPath === "string");
745
+ const canFetchMessage = isPersistedRelayMessage(message);
746
+ const guidance = !canFetchMessage ? [] : hasLocalPaths ? [
747
+ "Use the local path above when inspecting the attachment.",
748
+ `Read attachment details: agent-relay get-message ${message.id} --json`
749
+ ] : [
750
+ `Read attachment details: agent-relay get-message ${message.id} --json`,
751
+ `Fetch content: curl -fsS -H "X-Agent-Relay-Token: $AGENT_RELAY_TOKEN" "$AGENT_RELAY_URL/api/artifacts/<artifactId>/content" -o <filename>`
752
+ ];
753
+ return [
754
+ "Attachments:",
755
+ ...lines,
756
+ ...guidance
757
+ ].join(`
758
+ `);
759
+ }
760
+
761
+ // runner/src/adapters/claude-delivery.ts
762
+ var REMINDER_EVERY_DELIVERIES = 5;
763
+ var TASK_CLAIM_INSTRUCTION = "Claim this task before working it, then update task status when finished.";
764
+ function stripRelayScaffolding(body) {
765
+ const lines = body.split(`
766
+ `);
767
+ const popTrailingBlanks = () => {
768
+ while (lines.length && (lines.at(-1) ?? "").trim() === "")
769
+ lines.pop();
770
+ };
771
+ popTrailingBlanks();
772
+ if (lines.at(-1) === TASK_CLAIM_INSTRUCTION) {
773
+ lines.pop();
774
+ popTrailingBlanks();
775
+ }
776
+ return lines.join(`
777
+ `);
778
+ }
779
+ function isMemoryInjection(message) {
780
+ return isRecord(message.payload) && message.payload.memoryInjection === true;
781
+ }
782
+ function isReactionNotification(message) {
783
+ const event = isRecord(message.payload?.event) ? message.payload.event : undefined;
784
+ return message.payload?.reactionNotification === true || event?.type === "message.reaction";
785
+ }
786
+ function isSpawnObligationMessage(message) {
787
+ return isRecord(message.payload) && message.payload.source === "spawn-obligation";
788
+ }
789
+ function isPersistedRelayMessage2(message) {
790
+ return Number.isSafeInteger(message.id) && message.id > 0;
791
+ }
792
+ function providerSenderLabel(message) {
793
+ if (message.from === "user")
794
+ return "human user";
795
+ const source = isRecord(message.payload?.source) ? message.payload.source : undefined;
796
+ const isHumanChannel = Boolean(source && (isRecord(source.telegram) || isRecord(source.slack) || isRecord(source.discord)));
797
+ return isHumanChannel ? `human user via ${message.from}` : message.from;
798
+ }
799
+ function shouldShowReplyReminder(deliveryCount) {
800
+ return deliveryCount <= 1 || deliveryCount % REMINDER_EVERY_DELIVERIES === 0;
801
+ }
802
+ function latestReplyableMessage(messages2) {
803
+ return messages2.filter((message) => isPersistedRelayMessage2(message) && !isMemoryInjection(message) && !isReactionNotification(message) && !isSpawnObligationMessage(message) && message.replyExpected !== false).at(-1);
804
+ }
805
+ function formatMessage(message, relaySurface) {
806
+ const subject = message.subject ? `Subject: ${message.subject}
807
+ ` : "";
808
+ const isMemoryContext = isMemoryInjection(message);
809
+ const canFetchMessage = isPersistedRelayMessage2(message);
810
+ const rawBody = relaySurface ? message.body : stripRelayScaffolding(message.body);
811
+ const maxChars = providerMessageBodyMaxChars();
812
+ const shouldPreview = canFetchMessage && rawBody.length > maxChars;
813
+ const preview = shouldPreview ? {
814
+ body: rawBody.slice(0, maxChars),
815
+ truncated: true
816
+ } : {
817
+ body: rawBody,
818
+ truncated: false
819
+ };
820
+ const truncationGuidance = preview.truncated ? [
821
+ `[truncated: showing first ${maxChars} of ${rawBody.length} chars]`,
822
+ `Read full: agent-relay get-message ${message.id}`,
823
+ `Body only: agent-relay get-message ${message.id} --body`
824
+ ].join(`
825
+ `) : undefined;
826
+ if (isMemoryContext) {
827
+ return [
828
+ `[agent-relay context from ${message.from}]`,
829
+ providerAttachmentText(message),
830
+ `${subject}${preview.body}`,
831
+ truncationGuidance
832
+ ].filter(Boolean).join(`
833
+ `);
834
+ }
835
+ return [
836
+ `[relay message #${message.id} from ${providerSenderLabel(message)}]`,
837
+ providerAttachmentText(message),
838
+ `${subject}${preview.body}`,
839
+ truncationGuidance
840
+ ].filter(Boolean).join(`
841
+ `);
842
+ }
843
+ function replyReminder(message, readOnly) {
844
+ const command = readOnly ? `agent-relay /reply ${message.id} "<your reply>"` : relayReplyReminderText(message.id);
845
+ return [
846
+ "[agent-relay reply reminder]",
847
+ readOnly ? `If this batch needs a response, send one useful reply to the latest relevant message: ${command}` : command,
848
+ "If you already delivered the useful response through Relay, do not send a separate status-only confirmation.",
849
+ "If multiple messages arrived together, cover them in one reply instead of answering each line separately."
850
+ ].join(`
851
+ `);
852
+ }
853
+ function claudeProviderMessageText(messages2, options) {
854
+ const relaySurface = options.relaySurface !== false;
855
+ const sections = messages2.map((message) => formatMessage(message, relaySurface));
856
+ const replyable = latestReplyableMessage(messages2);
857
+ if (relaySurface && replyable && shouldShowReplyReminder(options.deliveryCount)) {
858
+ sections.push(replyReminder(replyable, options.readOnly === true));
859
+ } else if (relaySurface && !replyable && messages2.some(isNotificationMessage)) {
860
+ sections.push(NOTIFICATION_NUDGE);
861
+ }
862
+ return sections.join(`
863
+
864
+ `);
865
+ }
866
+
867
+ // runner/plugins/claude/monitors/relay-monitor.ts
868
+ var port = process.env.AGENT_RELAY_RUNNER_PORT;
869
+ if (!port)
870
+ process.exit(0);
871
+ var firstDelivery = true;
872
+ var deliveryCount = 0;
873
+ var ws = new WebSocket(`ws://127.0.0.1:${port}/monitor`);
874
+ ws.onmessage = (event) => {
875
+ const payload = parsePayload(String(event.data));
876
+ if (!payload || payload.type !== "message.deliver" || !Array.isArray(payload.messages))
877
+ return;
878
+ const messages2 = payload.messages;
879
+ deliveryCount += 1;
880
+ if (firstDelivery) {
881
+ console.log(process.env.AGENT_RELAY_APPROVAL === "read-only" ? CLAUDE_READ_ONLY_RELAY_CONTEXT : CLAUDE_RELAY_CONTEXT);
882
+ const wsNote = workspaceDepsNoteFromEnv();
883
+ if (wsNote) {
884
+ console.log("");
885
+ console.log(wsNote);
886
+ }
887
+ console.log("");
888
+ firstDelivery = false;
889
+ }
890
+ console.log(claudeProviderMessageText(messages2, {
891
+ deliveryCount,
892
+ readOnly: process.env.AGENT_RELAY_APPROVAL === "read-only"
893
+ }));
894
+ ws.send(JSON.stringify({
895
+ type: "message.delivered",
896
+ deliveryId: typeof payload.deliveryId === "string" ? payload.deliveryId : "",
897
+ messageIds: messages2.map((message) => message.id)
898
+ }));
899
+ };
900
+ ws.onerror = () => process.exit(1);
901
+ function parsePayload(raw) {
902
+ try {
903
+ const parsed = JSON.parse(raw);
904
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
905
+ } catch {
906
+ return null;
907
+ }
908
+ }
@@ -422,6 +422,23 @@ export function resolveQueuedPolicyMessages(policyName: string, agentId: string)
422
422
  * Returns the flushed messages so callers can emit `message.available` / `message.new` /
423
423
  * `message.delivery_updated` events.
424
424
  */
425
+ /**
426
+ * Distinct direct (agent) targets that have messages still parked at
427
+ * `queued` older than `minAgeMs`. Backstop for #706: the primary recovery is
428
+ * flush-on-availability, but if that event is ever missed a queued message is
429
+ * invisible (pollMessages excludes `queued`) until it expires. The periodic
430
+ * resweep uses this to re-resolve such messages once the target is available.
431
+ */
432
+ export function listStaleQueuedDirectTargets(now: number = Date.now(), minAgeMs = 60_000): string[] {
433
+ const rows = getDb().query(`
434
+ SELECT DISTINCT to_target FROM messages
435
+ WHERE delivery_status = 'queued'
436
+ AND to_target IS NOT NULL
437
+ AND COALESCE(queued_at, created_at) <= ?
438
+ `).all(now - minAgeMs) as Array<{ to_target: string }>;
439
+ return rows.map((row) => row.to_target);
440
+ }
441
+
425
442
  export function resolveQueuedDirectMessages(agentId: string): Message[] {
426
443
  const rows = getDb().query(`
427
444
  SELECT m.id
@@ -451,6 +468,21 @@ export function resolveQueuedDirectMessages(agentId: string): Message[] {
451
468
  return ids.map((id) => getMessage(id)).filter((message): message is Message => Boolean(message));
452
469
  }
453
470
 
471
+ /**
472
+ * Count messages that expired undelivered at max_age within the trailing
473
+ * `windowMs` (default 24h). Surfaces silent channel data loss (#706) — a queued
474
+ * message that died without ever reaching its recipient — as a health signal.
475
+ */
476
+ export function countExpiredUndeliveredMessages(now: number = Date.now(), windowMs = 86_400_000): number {
477
+ return (getDb().query(`
478
+ SELECT COUNT(*) as c FROM messages
479
+ WHERE delivery_status = 'dead'
480
+ AND delivery_poison_reason = 'queued message expired'
481
+ AND delivery_updated_at IS NOT NULL
482
+ AND delivery_updated_at >= ?
483
+ `).get(now - windowMs) as { c: number }).c;
484
+ }
485
+
454
486
  export function expireQueuedMessages(now: number = Date.now()): Message[] {
455
487
  const rows = getDb().query(`
456
488
  SELECT id FROM messages
@@ -462,14 +462,16 @@ function readBundledFiles(root: string): BundledFile[] {
462
462
  const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
463
463
  for (const entry of entries) {
464
464
  const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
465
+ const cleanRel = cleanRelativePath(rel, "file path");
466
+ if (root === BUNDLED_CLAUDE_PLUGIN_DIR && cleanRel === "monitors/relay-monitor.provisioned.mjs") continue;
465
467
  const abs = join(dir, entry.name);
466
468
  if (entry.isDirectory()) {
467
469
  visit(abs, rel);
468
470
  continue;
469
471
  }
470
472
  if (!entry.isFile()) continue;
471
- const content = readFileSync(abs, "utf8");
472
- files.push({ path: cleanRelativePath(rel, "file path"), content, size: Buffer.byteLength(content, "utf8") });
473
+ const content = root === BUNDLED_CLAUDE_PLUGIN_DIR && cleanRel === "monitors/relay-monitor.ts" ? readFileSync(join(root, "monitors/relay-monitor.provisioned.mjs"), "utf8") : readFileSync(abs, "utf8");
474
+ files.push({ path: cleanRel, content, size: Buffer.byteLength(content, "utf8") });
473
475
  }
474
476
  };
475
477
  visit(root);
package/src/db/stats.ts CHANGED
@@ -16,6 +16,7 @@ import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_ME
16
16
  import { matchAgents } from "../agent-ref";
17
17
  import { listAgents } from "./agents.ts";
18
18
  import { listChannels } from "./channels.ts";
19
+ import { countExpiredUndeliveredMessages } from "./delivery.ts";
19
20
  import { messageTaskClosed } from "./claim-sql.ts";
20
21
  import { getDb } from "./connection.ts";
21
22
  import type {
@@ -466,6 +467,37 @@ export function getHealth(now: number = Date.now()): HealthReport {
466
467
  : undefined,
467
468
  });
468
469
 
470
+ // A channel bridge (e.g. Telegram) that is registered but stale or not-ready
471
+ // is silently NOT delivering — channel messages queue against it (#706). The
472
+ // binding-target health above can read "ok" while the bridge itself is wedged,
473
+ // so check the bridge agent's own liveness to avoid green-washing a down bridge.
474
+ const channelBridges = listAgents().filter((agent) =>
475
+ agent.id !== "user" && agent.id !== "system" &&
476
+ (agent.kind === "channel" || agent.meta?.kind === "channel"));
477
+ const staleChannelBridges = channelBridges.filter((agent) =>
478
+ agent.status !== "offline" && agent.lastSeen <= now - STALE_TTL_MS);
479
+ const notReadyChannelBridges = channelBridges.filter((agent) =>
480
+ agent.status !== "offline" && !agent.ready && agent.lastSeen > now - STALE_TTL_MS);
481
+ const unhealthyChannelBridges = [...staleChannelBridges, ...notReadyChannelBridges];
482
+ checks.push({
483
+ name: "channel-bridge-liveness",
484
+ status: staleChannelBridges.length > 0 ? "error" : notReadyChannelBridges.length > 0 ? "warn" : "ok",
485
+ count: unhealthyChannelBridges.length,
486
+ detail: unhealthyChannelBridges.length > 0
487
+ ? `${unhealthyChannelBridges.length} channel bridge(s) not delivering: ${unhealthyChannelBridges.slice(0, 3).map((a) => `${a.id} (status=${a.status}, ready=${a.ready})`).join("; ")}`
488
+ : undefined,
489
+ });
490
+
491
+ const expiredUndelivered = countExpiredUndeliveredMessages(now);
492
+ checks.push({
493
+ name: "expired-undelivered-messages",
494
+ status: expiredUndelivered > 0 ? "warn" : "ok",
495
+ count: expiredUndelivered,
496
+ detail: expiredUndelivered > 0
497
+ ? `${expiredUndelivered} queued message(s) expired undelivered at max_age in the last 24h (silent data loss)`
498
+ : undefined,
499
+ });
500
+
469
501
  const rootTokenRuntimeAgents = listAgents().filter((agent) => {
470
502
  if (agent.status === "offline") return false;
471
503
  if (agent.id === "user" || agent.id === "system") return false;
@@ -10,6 +10,7 @@ import {
10
10
  expireQueuedMessages,
11
11
  getAgent,
12
12
  listAgents,
13
+ listStaleQueuedDirectTargets,
13
14
  pruneOrphanedSharedWorkspaces,
14
15
  pruneOfflineAgents,
15
16
  pruneOldMessages,
@@ -22,6 +23,8 @@ import {
22
23
  runDbMaintenance,
23
24
  sweepArtifacts,
24
25
  } from "../db";
26
+ import { isAgentUnavailable } from "../db/agent-predicates";
27
+ import { flushQueuedDirectMessages } from "../services/managed-running";
25
28
  import { notifyAgentOffline } from "../agent-lifecycle-events";
26
29
  import { diagnoseAgentDeath } from "../agent-death-diagnosis";
27
30
  import { autoMergeCleanFastForwards } from "../workspace-auto-merge";
@@ -193,10 +196,35 @@ export const definitions: MaintenanceJobDefinition[] = [
193
196
  runOnStart: true,
194
197
  handler() {
195
198
  const messages = expireQueuedMessages();
196
- for (const message of messages) emitMessageExpired(message);
199
+ for (const message of messages) {
200
+ // Silent data loss is the worst failure mode here (#706): a queued
201
+ // message died without ever reaching its recipient. Surface it loudly.
202
+ console.warn(`[delivery] message ${message.id} to ${message.to} (kind=${message.kind}) expired undelivered after max_age — silent data loss`);
203
+ emitMessageExpired(message);
204
+ }
197
205
  return { expiredQueuedMessageIds: messages.map((message) => message.id) };
198
206
  },
199
207
  },
208
+ {
209
+ id: "queued-message-resweep",
210
+ title: "Queued message re-sweep",
211
+ description: "Re-resolve long-queued direct messages whose target agent is available again — safety net for a missed flush-on-recovery event (#706).",
212
+ intervalMs: REAP_INTERVAL_MS,
213
+ runOnStart: true,
214
+ handler() {
215
+ const reswept: Record<string, number> = {};
216
+ for (const target of listStaleQueuedDirectTargets()) {
217
+ const agent = getAgent(target);
218
+ // Only flush to a target that is actually available now; otherwise the
219
+ // message belongs in the queue (this is hooked to agent availability,
220
+ // not pool-slot reclaim).
221
+ if (!agent || isAgentUnavailable(agent)) continue;
222
+ const flushed = flushQueuedDirectMessages(target);
223
+ if (flushed.length > 0) reswept[target] = flushed.length;
224
+ }
225
+ return { resweptTargets: reswept };
226
+ },
227
+ },
200
228
  {
201
229
  id: "command-expiration",
202
230
  title: "Command expiration",
@@ -17,7 +17,7 @@ import { getManagedAgentState, updateManagedAgentState } from "../config-store";
17
17
  import { emitRelayEvent } from "../events";
18
18
  import { emitMessageAvailable, emitMessageDeliveryUpdated, emitNewMessage } from "../sse";
19
19
  import type { AuthContext } from "./auth-context";
20
- import type { ManagedAgentState } from "../types";
20
+ import type { ManagedAgentState, Message } from "../types";
21
21
 
22
22
  interface ManagedRunningInput {
23
23
  policyName?: string;
@@ -135,14 +135,16 @@ function recordManagedTransition(
135
135
  * transitions back to available: heartbeat stale→idle, re-registration after restart/compact, or
136
136
  * rate-limit resume. Emits the same `message.available` / `message.new` / `message.delivery_updated`
137
137
  * events that the policy flush emits so SSE subscribers and the dashboard react identically.
138
- * No-op when there are no queued direct messages.
138
+ * No-op when there are no queued direct messages. Returns the flushed messages so
139
+ * callers (e.g. the resweep safety net, #706) can report how many were recovered.
139
140
  */
140
- export function flushQueuedDirectMessages(agentId: string): void {
141
+ export function flushQueuedDirectMessages(agentId: string): Message[] {
141
142
  const available = resolveQueuedDirectMessages(agentId);
142
- if (!available.length) return;
143
+ if (!available.length) return [];
143
144
  emitMessageAvailable("direct", agentId, available);
144
145
  for (const message of available) {
145
146
  emitNewMessage(message);
146
147
  emitMessageDeliveryUpdated(message);
147
148
  }
149
+ return available;
148
150
  }