agent-relay-server 0.4.16 → 0.4.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -2
- package/package.json +2 -2
- package/public/dashboard.js +32 -2
- package/public/index.html +19 -0
- package/src/cli.ts +384 -0
- package/src/config.ts +1 -0
- package/src/db.ts +597 -28
- package/src/index.ts +13 -3
- package/src/routes.ts +330 -13
- package/src/security.ts +31 -1
- package/src/sse.ts +8 -2
- package/src/types.ts +65 -0
package/README.md
CHANGED
|
@@ -82,18 +82,42 @@ Open a second session and say:
|
|
|
82
82
|
|
|
83
83
|
## Configuration
|
|
84
84
|
|
|
85
|
-
Both integrations share the same
|
|
85
|
+
Both integrations share the same provider env vars:
|
|
86
86
|
|
|
87
87
|
| Env var | Default | Purpose |
|
|
88
88
|
|---------|---------|---------|
|
|
89
89
|
| `AGENT_RELAY_URL` | `http://localhost:4850` | Relay server URL |
|
|
90
90
|
| `AGENT_RELAY_TOKEN` | unset | Auth token (required for remote relays) |
|
|
91
91
|
| `AGENT_RELAY_CAPS` | `chat` | Comma-separated agent capabilities |
|
|
92
|
+
| `AGENT_RELAY_TAGS` | unset | Extra comma-separated tags added to provider defaults |
|
|
93
|
+
| `AGENT_RELAY_LABEL` | unset | Human-friendly agent label set at registration |
|
|
94
|
+
| `AGENT_RELAY_CHANNELS` | all | Comma-separated channel subscriptions; unchannelled direct work still arrives |
|
|
95
|
+
| `AGENT_RELAY_PROFILE` | unset | Named profile loaded from the profiles file |
|
|
96
|
+
| `AGENT_RELAY_PROFILES_FILE` | `~/.config/agent-relay/profiles.json` | JSON profile file |
|
|
92
97
|
| `AGENT_RELAY_APPROVAL` | `open` | Approval mode: `open`, `guarded`, `read-only` |
|
|
93
98
|
|
|
99
|
+
Profiles preconfigure labels, tags, capabilities, channels, approval mode, and meta fields:
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"backend-tester": {
|
|
104
|
+
"label": "backend tester",
|
|
105
|
+
"tags": ["backend", "api", "test"],
|
|
106
|
+
"capabilities": ["chat", "review", "test", "backend"],
|
|
107
|
+
"channels": ["backend", "qa"],
|
|
108
|
+
"approval": "guarded",
|
|
109
|
+
"meta": { "role": "backend-tester" }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Launch with `AGENT_RELAY_PROFILE=backend-tester codex` or
|
|
115
|
+
`AGENT_RELAY_PROFILE=backend-tester claude`. Explicit env vars override the
|
|
116
|
+
profile where they overlap.
|
|
117
|
+
|
|
94
118
|
Codex has additional tuning vars documented in [codex/README.md](codex/README.md).
|
|
95
119
|
|
|
96
|
-
Agent IDs are deterministic: `{hostname}-{
|
|
120
|
+
Agent IDs are deterministic: `{hostname}-{project}-{session-hash}`.
|
|
97
121
|
|
|
98
122
|
## Message Targeting
|
|
99
123
|
|
|
@@ -167,6 +191,14 @@ claimable message. Posting `"status": "resolved"` marks the active task `done`.
|
|
|
167
191
|
When an integration has `callbackUrl`, Agent Relay posts task lifecycle events
|
|
168
192
|
(creation, claim, status changes) back to the caller.
|
|
169
193
|
|
|
194
|
+
Integration tokens can also be used as scoped API tokens when the full admin
|
|
195
|
+
`AGENT_RELAY_TOKEN` would be too broad. Supported scopes include `stats:read`,
|
|
196
|
+
`health:read`, `events:read`, `agents:read`, `agents:write`, `messages:read`,
|
|
197
|
+
`messages:write`, `tasks:read`, `tasks:write`, `pairs:read`, `pairs:write`,
|
|
198
|
+
`system:write`, and `*`.
|
|
199
|
+
The event ingress endpoint also accepts the legacy `tasks:create` and
|
|
200
|
+
`events:create` scopes.
|
|
201
|
+
|
|
170
202
|
Task lifecycle API:
|
|
171
203
|
|
|
172
204
|
| Method | Path | Purpose |
|
|
@@ -281,6 +313,46 @@ curl -H "X-Agent-Relay-Token: $AGENT_RELAY_TOKEN" http://localhost:4850/api/stat
|
|
|
281
313
|
| `DELETE` | `/messages/:id` | Delete message |
|
|
282
314
|
| `GET` | `/messages/cursor` | Latest message ID (for poller bootstrap) |
|
|
283
315
|
|
|
316
|
+
### Pair Sessions
|
|
317
|
+
|
|
318
|
+
Pair sessions are exclusive two-agent live chats backed by normal relay
|
|
319
|
+
messages. They are useful when you want two agent-sessions to collaborate
|
|
320
|
+
in real time without turning the relay into a group chat.
|
|
321
|
+
The sessions can be claude to codex, codex to codex or claude to claude,
|
|
322
|
+
since agent-relay is provider-independent.
|
|
323
|
+
|
|
324
|
+
```bash
|
|
325
|
+
agent-relay /pair codex "Debug flaky auth tests"
|
|
326
|
+
agent-relay pair codex --objective "Debug flaky auth tests"
|
|
327
|
+
agent-relay pair accept PAIR_ID --agent "$AGENT_RELAY_ID"
|
|
328
|
+
agent-relay pair send PAIR_ID --from "$AGENT_RELAY_ID" --body "What do you see?"
|
|
329
|
+
agent-relay /disconnect
|
|
330
|
+
agent-relay /status
|
|
331
|
+
agent-relay /label backend-fixer
|
|
332
|
+
agent-relay /tags backend tests urgent
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Inside provider sessions, the Codex skill and Claude plugin context instruct
|
|
336
|
+
agents to treat slash-style user messages like `/pair codex`, `/status`,
|
|
337
|
+
`/label backend-fixer`, and `/tags backend tests` as relay commands and run the
|
|
338
|
+
CLI. The CLI tries to auto-detect the current agent id from provider state; pass
|
|
339
|
+
`--from` or `--agent` if you want to be explicit.
|
|
340
|
+
|
|
341
|
+
If the target is already in a pending or active pair, the relay returns
|
|
342
|
+
`409 Busy`. If a target like `codex` matches multiple available agents, the
|
|
343
|
+
relay asks for a more specific target such as `id:...`, `label:...`, `tag:...`,
|
|
344
|
+
`cap:...` or `machine:...`.
|
|
345
|
+
|
|
346
|
+
| Method | Path | Purpose |
|
|
347
|
+
|--------|------|---------|
|
|
348
|
+
| `POST` | `/pairs` | Create a pair invite |
|
|
349
|
+
| `GET` | `/pairs` | List pairs (`?agent=`, `?status=`) |
|
|
350
|
+
| `GET` | `/pairs/:id` | Get one pair |
|
|
351
|
+
| `POST` | `/pairs/:id/accept` | Accept a pending pair |
|
|
352
|
+
| `POST` | `/pairs/:id/reject` | Reject a pending pair |
|
|
353
|
+
| `POST` | `/pairs/:id/messages` | Send a pair message |
|
|
354
|
+
| `POST` | `/pairs/:id/hangup` | End a pending or active pair |
|
|
355
|
+
|
|
284
356
|
### Tasks
|
|
285
357
|
|
|
286
358
|
| Method | Path | Purpose |
|
|
@@ -358,3 +430,10 @@ claude --plugin-dir ./claude # test plugin locally
|
|
|
358
430
|
## License
|
|
359
431
|
|
|
360
432
|
AGPL-3.0-or-later. See [LICENSE](LICENSE).
|
|
433
|
+
|
|
434
|
+
## Related
|
|
435
|
+
|
|
436
|
+
- **[callmux](https://github.com/edimuj/callmux)** - MCP multiplexer: parallel execution, batching, caching, pipelining, and shared infrastructure for any AI agent
|
|
437
|
+
- **[tokenlean](https://github.com/edimuj/tokenlean)** - Make agents less wasteful: Token-efficient CLI tool-toolbox for AI agents
|
|
438
|
+
- **[claude-mneme](https://github.com/edimuj/claude-mneme) / [codex-mneme](https://github.com/edimuj/codex-mneme)** - Lightweight persistent agent memory: so every session picks up where the last one left off
|
|
439
|
+
- **[agent-awareness](https://github.com/edimuj/agent-awareness)** - Real-time situational awareness for your agents
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-server",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.17",
|
|
4
4
|
"description": "Lightweight HTTP message relay for inter-agent communication across machines",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"scripts": {
|
|
20
20
|
"start": "bun run src/index.ts",
|
|
21
21
|
"dev": "bun --watch run src/index.ts",
|
|
22
|
-
"test": "bun test
|
|
22
|
+
"test": "bun test",
|
|
23
23
|
"typecheck": "tsc --noEmit"
|
|
24
24
|
},
|
|
25
25
|
"keywords": [
|
package/public/dashboard.js
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
tasks: [],
|
|
34
34
|
taskEvents: [],
|
|
35
35
|
stats: {},
|
|
36
|
+
health: null,
|
|
36
37
|
now: Date.now(),
|
|
37
38
|
authToken: loadPref("authToken", ""),
|
|
38
39
|
|
|
@@ -186,6 +187,7 @@
|
|
|
186
187
|
es.addEventListener("agent.status", (event) => handleAgentStatus(this, parseEventData(event)));
|
|
187
188
|
es.addEventListener("agent.removed", (event) => handleAgentRemoved(this, parseEventData(event)));
|
|
188
189
|
es.addEventListener("message.claimed", (event) => handleMessageClaimed(this, parseEventData(event)));
|
|
190
|
+
es.addEventListener("message.claim_released", (event) => handleMessageClaimReleased(this, parseEventData(event)));
|
|
189
191
|
es.addEventListener("message.deleted", (event) => handleMessageDeleted(this, parseEventData(event)));
|
|
190
192
|
registerTaskEvents(this, es);
|
|
191
193
|
}
|
|
@@ -223,7 +225,17 @@
|
|
|
223
225
|
|
|
224
226
|
function handleMessageClaimed(vm, data) {
|
|
225
227
|
const msg = vm.messages.find((item) => item.id === data.messageId);
|
|
226
|
-
if (msg)
|
|
228
|
+
if (!msg) return;
|
|
229
|
+
msg.claimedBy = data.claimedBy;
|
|
230
|
+
msg.claimExpiresAt = data.claimExpiresAt;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function handleMessageClaimReleased(vm, data) {
|
|
234
|
+
const msg = vm.messages.find((item) => item.id === data.messageId);
|
|
235
|
+
if (!msg) return;
|
|
236
|
+
delete msg.claimedBy;
|
|
237
|
+
delete msg.claimedAt;
|
|
238
|
+
delete msg.claimExpiresAt;
|
|
227
239
|
}
|
|
228
240
|
|
|
229
241
|
function handleMessageDeleted(vm, data) {
|
|
@@ -265,7 +277,7 @@
|
|
|
265
277
|
},
|
|
266
278
|
|
|
267
279
|
async refresh() {
|
|
268
|
-
await Promise.all([this.fetchStats(), this.fetchAgents(), this.fetchMessages(), this.fetchTasks()]);
|
|
280
|
+
await Promise.all([this.fetchStats(), this.fetchHealth(), this.fetchAgents(), this.fetchMessages(), this.fetchTasks()]);
|
|
269
281
|
},
|
|
270
282
|
|
|
271
283
|
async refreshLiveData() {
|
|
@@ -285,6 +297,12 @@
|
|
|
285
297
|
} catch {}
|
|
286
298
|
},
|
|
287
299
|
|
|
300
|
+
async fetchHealth() {
|
|
301
|
+
try {
|
|
302
|
+
this.health = await this.api("GET", "/health");
|
|
303
|
+
} catch {}
|
|
304
|
+
},
|
|
305
|
+
|
|
288
306
|
async fetchAgents() {
|
|
289
307
|
try {
|
|
290
308
|
this.agents = await this.api("GET", "/agents");
|
|
@@ -323,6 +341,7 @@
|
|
|
323
341
|
uniqueLabels: { get: getUniqueLabels },
|
|
324
342
|
uniqueCaps: { get: getUniqueCaps },
|
|
325
343
|
uniqueTags: { get: getUniqueTags },
|
|
344
|
+
healthIssues: { get: getHealthIssues },
|
|
326
345
|
};
|
|
327
346
|
}
|
|
328
347
|
|
|
@@ -399,6 +418,10 @@
|
|
|
399
418
|
return [...new Set(this.agents.flatMap((agent) => agent.tags || []))];
|
|
400
419
|
}
|
|
401
420
|
|
|
421
|
+
function getHealthIssues() {
|
|
422
|
+
return (this.health?.checks || []).filter((check) => check.status !== "ok");
|
|
423
|
+
}
|
|
424
|
+
|
|
402
425
|
function compareAgents(vm, a, b) {
|
|
403
426
|
switch (vm.agentSort) {
|
|
404
427
|
case "name":
|
|
@@ -422,6 +445,7 @@
|
|
|
422
445
|
agentStatusTitle,
|
|
423
446
|
timeAgo,
|
|
424
447
|
fmtTime,
|
|
448
|
+
healthAlertClass,
|
|
425
449
|
};
|
|
426
450
|
}
|
|
427
451
|
|
|
@@ -476,6 +500,12 @@
|
|
|
476
500
|
return new Date(iso).toLocaleString();
|
|
477
501
|
}
|
|
478
502
|
|
|
503
|
+
function healthAlertClass(status) {
|
|
504
|
+
if (status === "error") return "alert-danger";
|
|
505
|
+
if (status === "degraded") return "alert-warning";
|
|
506
|
+
return "alert-success";
|
|
507
|
+
}
|
|
508
|
+
|
|
479
509
|
function createMessageActions() {
|
|
480
510
|
return {
|
|
481
511
|
openCompose,
|
package/public/index.html
CHANGED
|
@@ -207,6 +207,25 @@
|
|
|
207
207
|
</div>
|
|
208
208
|
</div>
|
|
209
209
|
|
|
210
|
+
<template x-if="health">
|
|
211
|
+
<div class="alert d-flex align-items-start gap-3 mb-4" :class="healthAlertClass(health.status)">
|
|
212
|
+
<i class="ti ti-heartbeat mt-1"></i>
|
|
213
|
+
<div class="flex-grow-1">
|
|
214
|
+
<div class="fw-bold" x-text="'Relay health: ' + health.status"></div>
|
|
215
|
+
<template x-if="healthIssues.length === 0">
|
|
216
|
+
<div class="small">All checks passing</div>
|
|
217
|
+
</template>
|
|
218
|
+
<template x-if="healthIssues.length > 0">
|
|
219
|
+
<div class="small">
|
|
220
|
+
<template x-for="check in healthIssues" :key="check.name">
|
|
221
|
+
<span class="me-3" x-text="check.detail || check.name"></span>
|
|
222
|
+
</template>
|
|
223
|
+
</div>
|
|
224
|
+
</template>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
</template>
|
|
228
|
+
|
|
210
229
|
<!-- Two-column: Agents + Recent messages -->
|
|
211
230
|
<div class="row g-3">
|
|
212
231
|
<div class="col-lg-5">
|
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
2
|
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
3
5
|
import {
|
|
4
6
|
createDaemonPlan,
|
|
5
7
|
detectDaemonEnvironment,
|
|
@@ -23,8 +25,25 @@ Usage:
|
|
|
23
25
|
agent-relay [start]
|
|
24
26
|
agent-relay setup [--yes] [--dry-run] [--force] [--env-file PATH] [--host HOST] [--port PORT] [--db-path PATH] [--token TOKEN|--no-token]
|
|
25
27
|
agent-relay daemon <install|uninstall|start|stop|restart|enable|disable|status|logs> [options]
|
|
28
|
+
agent-relay pair <target|create|status|accept|reject|hangup|send> [options]
|
|
29
|
+
agent-relay /pair <target|accept|reject|send|status> [...]
|
|
30
|
+
agent-relay /disconnect [PAIR_ID]
|
|
31
|
+
agent-relay /status
|
|
32
|
+
agent-relay /label [LABEL]
|
|
33
|
+
agent-relay /tags [TAG ...]
|
|
26
34
|
agent-relay --help
|
|
27
35
|
|
|
36
|
+
Pair examples:
|
|
37
|
+
agent-relay pair codex --objective "Debug flaky tests"
|
|
38
|
+
agent-relay /pair codex "Debug flaky tests"
|
|
39
|
+
agent-relay pair status
|
|
40
|
+
agent-relay pair accept PAIR_ID --agent AGENT_ID
|
|
41
|
+
agent-relay pair send PAIR_ID --from AGENT_ID --body "What do you see?"
|
|
42
|
+
agent-relay /disconnect
|
|
43
|
+
agent-relay /status
|
|
44
|
+
agent-relay /label backend-fixer
|
|
45
|
+
agent-relay /tags backend tests urgent
|
|
46
|
+
|
|
28
47
|
Daemon options:
|
|
29
48
|
--env-file PATH Env file sourced by the daemon (default: platform user config dir)
|
|
30
49
|
--binary PATH Stable agent-relay binary/script path for the service
|
|
@@ -71,9 +90,41 @@ export async function handleCli(args: string[]): Promise<"start" | "handled"> {
|
|
|
71
90
|
await handleDaemonCommand(args.slice(1));
|
|
72
91
|
return "handled";
|
|
73
92
|
}
|
|
93
|
+
if (command === "pair" || command === "/pair" || command === "/disconnect") {
|
|
94
|
+
await handleSlashOrPairCommand(command, args.slice(1));
|
|
95
|
+
return "handled";
|
|
96
|
+
}
|
|
97
|
+
if (command === "/status" || command === "status") {
|
|
98
|
+
await handleStatusCommand(args.slice(1));
|
|
99
|
+
return "handled";
|
|
100
|
+
}
|
|
101
|
+
if (command === "/label" || command === "label") {
|
|
102
|
+
await handleLabelCommand(args.slice(1));
|
|
103
|
+
return "handled";
|
|
104
|
+
}
|
|
105
|
+
if (command === "/tags" || command === "tags") {
|
|
106
|
+
await handleTagsCommand(args.slice(1));
|
|
107
|
+
return "handled";
|
|
108
|
+
}
|
|
109
|
+
if (command === "/reconnect") {
|
|
110
|
+
console.log("Reconnect is handled automatically by provider sidecars; use `agent-relay pair status` to inspect current pair state.");
|
|
111
|
+
return "handled";
|
|
112
|
+
}
|
|
74
113
|
throw new Error(`Unknown command "${command}". Run agent-relay --help.`);
|
|
75
114
|
}
|
|
76
115
|
|
|
116
|
+
async function handleSlashOrPairCommand(command: string, args: string[]): Promise<void> {
|
|
117
|
+
if (command === "/disconnect") {
|
|
118
|
+
await handlePairCommand(["hangup", ...args]);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (command === "/pair") {
|
|
122
|
+
await handlePairCommand(args);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await handlePairCommand(args);
|
|
126
|
+
}
|
|
127
|
+
|
|
77
128
|
async function handleSetupCommand(args: string[]): Promise<void> {
|
|
78
129
|
let envFile: string | undefined;
|
|
79
130
|
let host: string | undefined;
|
|
@@ -205,6 +256,339 @@ function parseDaemonAction(value: string | undefined): DaemonAction {
|
|
|
205
256
|
return value as DaemonAction;
|
|
206
257
|
}
|
|
207
258
|
|
|
259
|
+
async function handlePairCommand(args: string[]): Promise<void> {
|
|
260
|
+
if (!args.length) throw new Error("Usage: agent-relay pair <target|create|status|accept|reject|hangup|send> [options]");
|
|
261
|
+
const knownActions = new Set(["create", "status", "list", "accept", "reject", "hangup", "disconnect", "send"]);
|
|
262
|
+
const action = knownActions.has(args[0]!) ? args[0]! : "create";
|
|
263
|
+
const rest = action === "create" && args[0] !== "create" ? args : args.slice(1);
|
|
264
|
+
|
|
265
|
+
if (action === "status" || action === "list") {
|
|
266
|
+
let agent: string | undefined = await detectAgentId();
|
|
267
|
+
let status: string | undefined;
|
|
268
|
+
let json = false;
|
|
269
|
+
for (let i = 0; i < rest.length; i++) {
|
|
270
|
+
const arg = rest[i];
|
|
271
|
+
if (arg === "--agent" && i + 1 < rest.length) agent = rest[++i];
|
|
272
|
+
else if (arg === "--status" && i + 1 < rest.length) status = rest[++i];
|
|
273
|
+
else if (arg === "--json") json = true;
|
|
274
|
+
else throw new Error(`Unknown pair status option "${arg}"`);
|
|
275
|
+
}
|
|
276
|
+
const query = new URLSearchParams();
|
|
277
|
+
if (agent) query.set("agent", agent);
|
|
278
|
+
if (status) query.set("status", status);
|
|
279
|
+
const pairs = await apiRequest("GET", `/api/pairs${query.size ? `?${query}` : ""}`);
|
|
280
|
+
if (json) console.log(JSON.stringify(pairs, null, 2));
|
|
281
|
+
else console.log(formatPairs(pairs as any[]));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (action === "create") {
|
|
286
|
+
const target = rest[0];
|
|
287
|
+
if (!target || target.startsWith("--")) throw new Error("Usage: agent-relay pair <target> [--from AGENT_ID] [--objective TEXT]");
|
|
288
|
+
let from = await detectAgentId();
|
|
289
|
+
let objective: string | undefined;
|
|
290
|
+
let ttlMs: number | undefined;
|
|
291
|
+
let json = false;
|
|
292
|
+
const objectiveParts: string[] = [];
|
|
293
|
+
for (let i = 1; i < rest.length; i++) {
|
|
294
|
+
const arg = rest[i];
|
|
295
|
+
if (arg === "--from" && i + 1 < rest.length) from = rest[++i];
|
|
296
|
+
else if (arg === "--objective" && i + 1 < rest.length) objective = rest[++i];
|
|
297
|
+
else if (arg === "--ttl-ms" && i + 1 < rest.length) ttlMs = parseInt(rest[++i]!, 10);
|
|
298
|
+
else if (arg === "--json") json = true;
|
|
299
|
+
else objectiveParts.push(arg!);
|
|
300
|
+
}
|
|
301
|
+
objective ??= objectiveParts.length ? objectiveParts.join(" ") : undefined;
|
|
302
|
+
if (!from) throw new Error("Could not detect current Agent Relay ID. Pass --from AGENT_ID or set AGENT_RELAY_ID.");
|
|
303
|
+
const result = await apiRequest("POST", "/api/pairs", { from, target, objective, ttlMs });
|
|
304
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
305
|
+
else {
|
|
306
|
+
const pair = (result as any).pair;
|
|
307
|
+
console.log(`Pair invite ${pair.id} sent: ${pair.requesterId} -> ${pair.targetId}`);
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (action === "accept" || action === "reject" || action === "hangup" || action === "disconnect") {
|
|
313
|
+
const pairId = rest[0];
|
|
314
|
+
let agentId = await detectAgentId();
|
|
315
|
+
let reason: string | undefined;
|
|
316
|
+
let json = false;
|
|
317
|
+
let startIndex = 0;
|
|
318
|
+
if (pairId && !pairId.startsWith("--")) startIndex = 1;
|
|
319
|
+
for (let i = startIndex; i < rest.length; i++) {
|
|
320
|
+
const arg = rest[i];
|
|
321
|
+
if (arg === "--agent" && i + 1 < rest.length) agentId = rest[++i];
|
|
322
|
+
else if (arg === "--reason" && i + 1 < rest.length) reason = rest[++i];
|
|
323
|
+
else if (arg === "--json") json = true;
|
|
324
|
+
else throw new Error(`Unknown pair ${action} option "${arg}"`);
|
|
325
|
+
}
|
|
326
|
+
if (!agentId) throw new Error("Could not detect current Agent Relay ID. Pass --agent AGENT_ID or set AGENT_RELAY_ID.");
|
|
327
|
+
const resolvedPairId = pairId && !pairId.startsWith("--") ? pairId : await detectActivePairId(agentId);
|
|
328
|
+
if (!resolvedPairId) throw new Error(`Usage: agent-relay pair ${action} PAIR_ID --agent AGENT_ID`);
|
|
329
|
+
const endpoint = action === "disconnect" ? "hangup" : action;
|
|
330
|
+
const pair = await apiRequest("POST", `/api/pairs/${encodeURIComponent(resolvedPairId)}/${endpoint}`, { agentId, reason });
|
|
331
|
+
if (json) console.log(JSON.stringify(pair, null, 2));
|
|
332
|
+
else console.log(`Pair ${resolvedPairId}: ${(pair as any).status}`);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (action === "send") {
|
|
337
|
+
const pairId = rest[0];
|
|
338
|
+
if (!pairId || pairId.startsWith("--")) throw new Error("Usage: agent-relay pair send PAIR_ID --from AGENT_ID --body TEXT");
|
|
339
|
+
let from = await detectAgentId();
|
|
340
|
+
let body: string | undefined;
|
|
341
|
+
let subject: string | undefined;
|
|
342
|
+
let json = false;
|
|
343
|
+
for (let i = 1; i < rest.length; i++) {
|
|
344
|
+
const arg = rest[i];
|
|
345
|
+
if (arg === "--from" && i + 1 < rest.length) from = rest[++i];
|
|
346
|
+
else if (arg === "--body" && i + 1 < rest.length) body = rest[++i];
|
|
347
|
+
else if (arg === "--subject" && i + 1 < rest.length) subject = rest[++i];
|
|
348
|
+
else if (arg === "--json") json = true;
|
|
349
|
+
else throw new Error(`Unknown pair send option "${arg}"`);
|
|
350
|
+
}
|
|
351
|
+
if (!from) throw new Error("Could not detect current Agent Relay ID. Pass --from AGENT_ID or set AGENT_RELAY_ID.");
|
|
352
|
+
if (!body) throw new Error("--body TEXT required");
|
|
353
|
+
const result = await apiRequest("POST", `/api/pairs/${encodeURIComponent(pairId)}/messages`, { from, body, subject });
|
|
354
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
355
|
+
else console.log(`Pair message sent: ${(result as any).message.id}`);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function handleStatusCommand(args: string[]): Promise<void> {
|
|
361
|
+
let agentId = await detectAgentId();
|
|
362
|
+
let json = false;
|
|
363
|
+
for (let i = 0; i < args.length; i++) {
|
|
364
|
+
const arg = args[i];
|
|
365
|
+
if (arg === "--agent" && i + 1 < args.length) agentId = args[++i];
|
|
366
|
+
else if (arg === "--json") json = true;
|
|
367
|
+
else throw new Error(`Unknown status option "${arg}"`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const [stats, health, pairs, agent] = await Promise.all([
|
|
371
|
+
apiRequest("GET", "/api/stats"),
|
|
372
|
+
apiRequest("GET", "/api/health"),
|
|
373
|
+
agentId ? apiRequest("GET", `/api/pairs?agent=${encodeURIComponent(agentId)}`) : Promise.resolve([]),
|
|
374
|
+
agentId ? apiRequest("GET", `/api/agents/${encodeURIComponent(agentId)}`).catch(() => null) : Promise.resolve(null),
|
|
375
|
+
]);
|
|
376
|
+
const payload = { agent, stats, health, pairs };
|
|
377
|
+
if (json) console.log(JSON.stringify(payload, null, 2));
|
|
378
|
+
else console.log(formatStatus(payload));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function handleLabelCommand(args: string[]): Promise<void> {
|
|
382
|
+
let agentId = await detectAgentId();
|
|
383
|
+
let label: string | null | undefined;
|
|
384
|
+
let json = false;
|
|
385
|
+
for (let i = 0; i < args.length; i++) {
|
|
386
|
+
const arg = args[i];
|
|
387
|
+
if (arg === "--agent" && i + 1 < args.length) agentId = args[++i];
|
|
388
|
+
else if (arg === "--clear") label = null;
|
|
389
|
+
else if (arg === "--json") json = true;
|
|
390
|
+
else if (label === undefined) label = args.slice(i).join(" ");
|
|
391
|
+
}
|
|
392
|
+
if (!agentId) throw new Error("Could not detect current Agent Relay ID. Pass --agent AGENT_ID or set AGENT_RELAY_ID.");
|
|
393
|
+
if (label === undefined) {
|
|
394
|
+
const agent = await apiRequest("GET", `/api/agents/${encodeURIComponent(agentId)}`) as { label?: string };
|
|
395
|
+
console.log(agent.label ?? "(no label)");
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const result = await apiRequest("PATCH", `/api/agents/${encodeURIComponent(agentId)}/label`, { label });
|
|
399
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
400
|
+
else console.log(label ? `Label set: ${label}` : "Label cleared.");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function handleTagsCommand(args: string[]): Promise<void> {
|
|
404
|
+
let agentId = await detectAgentId();
|
|
405
|
+
let json = false;
|
|
406
|
+
let listOnly = false;
|
|
407
|
+
let add: string[] = [];
|
|
408
|
+
let remove: string[] = [];
|
|
409
|
+
const positional: string[] = [];
|
|
410
|
+
for (let i = 0; i < args.length; i++) {
|
|
411
|
+
const arg = args[i];
|
|
412
|
+
if (arg === "--agent" && i + 1 < args.length) agentId = args[++i];
|
|
413
|
+
else if (arg === "--json") json = true;
|
|
414
|
+
else if (arg === "--list") listOnly = true;
|
|
415
|
+
else if (arg === "--add" && i + 1 < args.length) add = add.concat(splitTagArgs(args[++i]!));
|
|
416
|
+
else if (arg === "--remove" && i + 1 < args.length) remove = remove.concat(splitTagArgs(args[++i]!));
|
|
417
|
+
else positional.push(...splitTagArgs(arg!));
|
|
418
|
+
}
|
|
419
|
+
if (!agentId) throw new Error("Could not detect current Agent Relay ID. Pass --agent AGENT_ID or set AGENT_RELAY_ID.");
|
|
420
|
+
const current = await apiRequest("GET", `/api/agents/${encodeURIComponent(agentId)}`) as { tags?: string[] };
|
|
421
|
+
if (listOnly || (positional.length === 0 && add.length === 0 && remove.length === 0)) {
|
|
422
|
+
console.log((current.tags ?? []).join(", ") || "(no tags)");
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const next = positional.length > 0
|
|
426
|
+
? uniqueStrings(positional)
|
|
427
|
+
: uniqueStrings([...(current.tags ?? []), ...add]).filter((tag) => !remove.includes(tag));
|
|
428
|
+
const updated = await apiRequest("PATCH", `/api/agents/${encodeURIComponent(agentId)}/tags`, { tags: next });
|
|
429
|
+
if (json) console.log(JSON.stringify(updated, null, 2));
|
|
430
|
+
else console.log(`Tags: ${next.join(", ") || "(none)"}`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function apiRequest(method: string, path: string, body?: unknown): Promise<unknown> {
|
|
434
|
+
const baseUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
|
|
435
|
+
const headers: Record<string, string> = {};
|
|
436
|
+
const token = process.env.AGENT_RELAY_TOKEN;
|
|
437
|
+
if (token) headers["X-Agent-Relay-Token"] = token;
|
|
438
|
+
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
439
|
+
const response = await fetch(new URL(path, baseUrl), {
|
|
440
|
+
method,
|
|
441
|
+
headers,
|
|
442
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
443
|
+
});
|
|
444
|
+
const text = await response.text();
|
|
445
|
+
const payload = text ? JSON.parse(text) : null;
|
|
446
|
+
if (!response.ok) {
|
|
447
|
+
const message = payload && typeof payload === "object" && "error" in payload ? String((payload as any).error) : text;
|
|
448
|
+
throw new Error(`agent-relay ${method} ${path} failed (${response.status}): ${message}`);
|
|
449
|
+
}
|
|
450
|
+
return payload;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function splitTagArgs(raw: string): string[] {
|
|
454
|
+
return raw.split(",").map((tag) => tag.trim()).filter(Boolean);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function uniqueStrings(values: string[]): string[] {
|
|
458
|
+
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function detectActivePairId(agentId: string): Promise<string | undefined> {
|
|
462
|
+
const pairs = await apiRequest("GET", `/api/pairs?agent=${encodeURIComponent(agentId)}&status=active`) as Array<{ id?: string }>;
|
|
463
|
+
return Array.isArray(pairs) && typeof pairs[0]?.id === "string" ? pairs[0].id : undefined;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function detectAgentId(): Promise<string | undefined> {
|
|
467
|
+
const explicit = process.env.AGENT_RELAY_ID;
|
|
468
|
+
if (explicit) return explicit;
|
|
469
|
+
|
|
470
|
+
const cwd = process.cwd();
|
|
471
|
+
const stateCandidates = [
|
|
472
|
+
process.env.AGENT_RELAY_CODEX_STATE_PATH,
|
|
473
|
+
resolve(cwd, "codex/runtime/live-state.json"),
|
|
474
|
+
...collectCodexStateFiles(),
|
|
475
|
+
].filter((path): path is string => Boolean(path));
|
|
476
|
+
|
|
477
|
+
const codexMatch = newestCodexAgentId(stateCandidates, cwd);
|
|
478
|
+
if (codexMatch) return codexMatch;
|
|
479
|
+
|
|
480
|
+
const claudeMatch = newestClaudeAgentId();
|
|
481
|
+
if (claudeMatch) return claudeMatch;
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
const agents = await apiRequest("GET", "/api/agents") as Array<{ id?: string; status?: string; ready?: boolean; meta?: { cwd?: unknown }; lastSeen?: number }>;
|
|
485
|
+
const cwdAgents = agents
|
|
486
|
+
.filter((agent) => agent.status !== "offline" && agent.ready !== false && agent.meta?.cwd === cwd && typeof agent.id === "string")
|
|
487
|
+
.sort((a, b) => (b.lastSeen ?? 0) - (a.lastSeen ?? 0));
|
|
488
|
+
return cwdAgents[0]?.id;
|
|
489
|
+
} catch {
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function newestCodexAgentId(paths: string[], cwd: string): string | undefined {
|
|
495
|
+
const states = paths
|
|
496
|
+
.map((path) => readCodexState(path))
|
|
497
|
+
.filter((state): state is { agentId: string; cwd?: string; updatedAtMs: number } => Boolean(state))
|
|
498
|
+
.sort((a, b) => b.updatedAtMs - a.updatedAtMs);
|
|
499
|
+
return states.find((state) => state.cwd === cwd)?.agentId ?? states[0]?.agentId;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function readCodexState(path: string): { agentId: string; cwd?: string; updatedAtMs: number } | null {
|
|
503
|
+
if (!existsSync(path)) return null;
|
|
504
|
+
try {
|
|
505
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as { agentId?: unknown; cwd?: unknown; updatedAt?: unknown };
|
|
506
|
+
if (typeof parsed.agentId !== "string" || !parsed.agentId) return null;
|
|
507
|
+
const stat = statSync(path);
|
|
508
|
+
const updatedAt = typeof parsed.updatedAt === "string" ? Date.parse(parsed.updatedAt) : Number.NaN;
|
|
509
|
+
return {
|
|
510
|
+
agentId: parsed.agentId,
|
|
511
|
+
cwd: typeof parsed.cwd === "string" ? parsed.cwd : undefined,
|
|
512
|
+
updatedAtMs: Number.isFinite(updatedAt) ? updatedAt : stat.mtimeMs,
|
|
513
|
+
};
|
|
514
|
+
} catch {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function collectCodexStateFiles(): string[] {
|
|
520
|
+
const roots = [
|
|
521
|
+
join(process.env.HOME || "", ".agent-relay", "codex", "runtime"),
|
|
522
|
+
resolve(process.cwd(), "codex", "runtime"),
|
|
523
|
+
].filter((root) => root && existsSync(root));
|
|
524
|
+
const files: string[] = [];
|
|
525
|
+
for (const root of roots) collectFiles(root, "live-state.json", files, 4);
|
|
526
|
+
return files;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function collectFiles(dir: string, name: string, output: string[], depth: number): void {
|
|
530
|
+
if (depth < 0) return;
|
|
531
|
+
let entries: string[];
|
|
532
|
+
try {
|
|
533
|
+
entries = readdirSync(dir);
|
|
534
|
+
} catch {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
for (const entry of entries) {
|
|
538
|
+
const path = join(dir, entry);
|
|
539
|
+
try {
|
|
540
|
+
const stat = statSync(path);
|
|
541
|
+
if (stat.isDirectory()) collectFiles(path, name, output, depth - 1);
|
|
542
|
+
else if (entry === name) output.push(path);
|
|
543
|
+
} catch {
|
|
544
|
+
// Ignore state files that disappear while scanning.
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function newestClaudeAgentId(): string | undefined {
|
|
550
|
+
if (!existsSync("/tmp")) return undefined;
|
|
551
|
+
try {
|
|
552
|
+
const candidates = readdirSync("/tmp")
|
|
553
|
+
.filter((entry) => entry.startsWith("agent-relay-instance-") && entry.endsWith(".state"))
|
|
554
|
+
.map((entry) => join("/tmp", entry))
|
|
555
|
+
.map((path) => ({ path, mtimeMs: statSync(path).mtimeMs }))
|
|
556
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
557
|
+
for (const candidate of candidates) {
|
|
558
|
+
const id = readFileSync(candidate.path, "utf8").split(/\r?\n/)[0]?.trim();
|
|
559
|
+
if (id) return id;
|
|
560
|
+
}
|
|
561
|
+
} catch {
|
|
562
|
+
return undefined;
|
|
563
|
+
}
|
|
564
|
+
return undefined;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function formatPairs(pairs: any[]): string {
|
|
568
|
+
if (!pairs.length) return "No pair sessions.";
|
|
569
|
+
return pairs
|
|
570
|
+
.map((pair) => `${pair.id} ${pair.status} ${pair.requesterId} <-> ${pair.targetId}${pair.objective ? ` ${pair.objective}` : ""}`)
|
|
571
|
+
.join("\n");
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function formatStatus(payload: any): string {
|
|
575
|
+
const agent = payload.agent;
|
|
576
|
+
const stats = payload.stats ?? {};
|
|
577
|
+
const health = payload.health ?? {};
|
|
578
|
+
const pairs = Array.isArray(payload.pairs) ? payload.pairs : [];
|
|
579
|
+
const activePair = pairs.find((pair: any) => pair.status === "active") ?? pairs.find((pair: any) => pair.status === "pending");
|
|
580
|
+
return [
|
|
581
|
+
`Relay: ${health.status ?? "unknown"} version=${stats.version ?? "unknown"}`,
|
|
582
|
+
`Agents: ${stats.online ?? "?"}/${stats.agents ?? "?"} online Messages: ${stats.messages ?? "?"} Tasks: ${stats.openTasks ?? "?"}/${stats.tasks ?? "?"} open`,
|
|
583
|
+
agent
|
|
584
|
+
? `Current: ${agent.id} status=${agent.status} ready=${agent.ready ? "yes" : "no"} label=${agent.label ?? "(none)"} tags=${(agent.tags ?? []).join(", ") || "(none)"}`
|
|
585
|
+
: "Current: unknown",
|
|
586
|
+
activePair
|
|
587
|
+
? `Pair: ${activePair.id} ${activePair.status} ${activePair.requesterId} <-> ${activePair.targetId}`
|
|
588
|
+
: "Pair: none active",
|
|
589
|
+
].join("\n");
|
|
590
|
+
}
|
|
591
|
+
|
|
208
592
|
async function confirm(message: string): Promise<boolean> {
|
|
209
593
|
if (!input.isTTY) return false;
|
|
210
594
|
const rl = createInterface({ input, output });
|
package/src/config.ts
CHANGED
|
@@ -20,6 +20,7 @@ function envPositiveInt(name: string, fallback: number): number {
|
|
|
20
20
|
export const STALE_TTL_MS = envPositiveInt("STALE_TTL_MS", 120_000); // 2min without heartbeat → offline
|
|
21
21
|
export const OFFLINE_PRUNE_MS = envPositiveInt("OFFLINE_PRUNE_MS", DAY_MS); // 24h offline → delete
|
|
22
22
|
export const REAP_INTERVAL_MS = envPositiveInt("REAP_INTERVAL_MS", 60_000); // reaper cadence
|
|
23
|
+
export const CLAIM_LEASE_MS = envPositiveInt("AGENT_RELAY_CLAIM_LEASE_MS", 1_800_000); // 30min claim lease
|
|
23
24
|
|
|
24
25
|
// Max body size for any POST/PATCH request (64 KiB).
|
|
25
26
|
export const MAX_BODY_BYTES = 64 * 1024;
|