agent-relay-server 0.94.3 → 0.94.4
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.
|
|
5
|
+
"version": "0.94.4",
|
|
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.4",
|
|
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
|
+
}
|
|
@@ -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);
|