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 +1 -1
- package/package.json +3 -2
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +908 -0
- package/src/db/delivery.ts +32 -0
- package/src/db/provisioning-registry.ts +4 -2
- package/src/db/stats.ts +32 -0
- package/src/maintenance/jobs.ts +29 -1
- package/src/services/managed-running.ts +6 -4
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.
|
|
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
|
+
"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",
|
|
@@ -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
|
+
}
|
package/src/db/delivery.ts
CHANGED
|
@@ -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:
|
|
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;
|
package/src/maintenance/jobs.ts
CHANGED
|
@@ -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)
|
|
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):
|
|
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
|
}
|