agent-relay-codex 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 +25 -1
- package/bin/agent-relay-codex.ts +7 -2
- package/hooks/session-start.ts +26 -8
- package/live-sidecar.ts +99 -18
- package/package.json +1 -1
- package/plugin/skills/agent-relay/SKILL.md +31 -0
- package/profile.ts +96 -0
- package/relay.ts +48 -11
package/README.md
CHANGED
|
@@ -42,7 +42,31 @@ bunx -p agent-relay-codex@latest codex-relay
|
|
|
42
42
|
| `AGENT_RELAY_CAPS` | `chat` | Comma-separated capabilities |
|
|
43
43
|
| `AGENT_RELAY_APPROVAL` | `open` | Approval mode: `open`, `guarded`, `read-only` |
|
|
44
44
|
|
|
45
|
-
These
|
|
45
|
+
These env vars are shared with the [Claude plugin](https://www.npmjs.com/package/agent-relay-plugin). Agent profiles are also shared:
|
|
46
|
+
|
|
47
|
+
| Env var | Default | Purpose |
|
|
48
|
+
|---------|---------|---------|
|
|
49
|
+
| `AGENT_RELAY_PROFILE` | — | Profile name from the profiles file |
|
|
50
|
+
| `AGENT_RELAY_PROFILES_FILE` | `~/.config/agent-relay/profiles.json` | JSON profile file |
|
|
51
|
+
| `AGENT_RELAY_TAGS` | — | Extra comma-separated tags |
|
|
52
|
+
| `AGENT_RELAY_LABEL` | — | Human-friendly label |
|
|
53
|
+
| `AGENT_RELAY_CHANNELS` | all | Comma-separated channel subscriptions |
|
|
54
|
+
|
|
55
|
+
Example profile:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"frontend-developer": {
|
|
60
|
+
"label": "frontend dev",
|
|
61
|
+
"tags": ["frontend", "ui"],
|
|
62
|
+
"capabilities": ["chat", "review", "frontend"],
|
|
63
|
+
"channels": ["frontend"],
|
|
64
|
+
"approval": "guarded"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Run with `AGENT_RELAY_PROFILE=frontend-developer agent-relay-codex start`.
|
|
46
70
|
|
|
47
71
|
### Codex-specific
|
|
48
72
|
|
package/bin/agent-relay-codex.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import net from "node:net";
|
|
8
8
|
import { approvalModeFromPermissions, codexArgsForApprovalMode, parseApprovalMode } from "../approval";
|
|
9
|
+
import { loadAgentRelayProfile } from "../profile";
|
|
9
10
|
|
|
10
11
|
type HooksJson = {
|
|
11
12
|
hooks?: Record<string, Array<{ matcher?: string; hooks?: Array<Record<string, unknown>> }>>;
|
|
@@ -772,8 +773,12 @@ async function start(args: string[]): Promise<void> {
|
|
|
772
773
|
let relayUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
|
|
773
774
|
let listenUrl = process.env.CODEX_APP_SERVER_URL || "";
|
|
774
775
|
let threadMode = process.env.CODEX_THREAD_MODE || "start";
|
|
775
|
-
const
|
|
776
|
-
const
|
|
776
|
+
const cwd = process.cwd();
|
|
777
|
+
const rig = process.env.AGENT_RELAY_CODEX_RIG || "codex-live";
|
|
778
|
+
const project = cwd.split("/").filter(Boolean).at(-1) || "unknown";
|
|
779
|
+
const profile = loadAgentRelayProfile(process.env, { provider: "codex", rig, project });
|
|
780
|
+
const requestedApprovalMode = profile.approval ?? parseApprovalMode(process.env.AGENT_RELAY_APPROVAL);
|
|
781
|
+
const hasApprovalEnv = process.env.AGENT_RELAY_APPROVAL !== undefined || profile.approval !== undefined;
|
|
777
782
|
const codexArgs: string[] = [];
|
|
778
783
|
|
|
779
784
|
for (let index = 0; index < args.length; index += 1) {
|
package/hooks/session-start.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } fr
|
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { describeApprovalMode, parseApprovalMode } from "../approval.ts";
|
|
6
|
+
import { loadAgentRelayProfile } from "../profile.ts";
|
|
6
7
|
import { buildAgentIdentity } from "../relay.ts";
|
|
7
8
|
import { pickThreadId, type HookInput } from "./session-start-lib.ts";
|
|
8
9
|
|
|
@@ -69,7 +70,9 @@ const cwd = input.cwd || process.cwd();
|
|
|
69
70
|
const threadId = pickThreadId(input);
|
|
70
71
|
const relayUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
|
|
71
72
|
const rig = process.env.AGENT_RELAY_CODEX_RIG || "codex-live";
|
|
72
|
-
const
|
|
73
|
+
const project = cwd.split("/").filter(Boolean).at(-1) || "unknown";
|
|
74
|
+
const profile = loadAgentRelayProfile(process.env, { provider: "codex", rig, project });
|
|
75
|
+
const approvalMode = profile.approval ?? parseApprovalMode(process.env.AGENT_RELAY_APPROVAL);
|
|
73
76
|
|
|
74
77
|
if (!appServerUrl || !runId) {
|
|
75
78
|
outputContext(
|
|
@@ -102,8 +105,12 @@ if (activePid !== null) {
|
|
|
102
105
|
relayUrl,
|
|
103
106
|
cwd,
|
|
104
107
|
rig,
|
|
105
|
-
|
|
106
|
-
|
|
108
|
+
label: profile.label,
|
|
109
|
+
capabilities: profile.capabilities,
|
|
110
|
+
tags: profile.tags,
|
|
111
|
+
channels: profile.channels,
|
|
112
|
+
profileName: profile.profileName,
|
|
113
|
+
profileMeta: profile.meta,
|
|
107
114
|
threadId,
|
|
108
115
|
model: input.model,
|
|
109
116
|
appServerUrl,
|
|
@@ -121,6 +128,7 @@ const spawnEnv: Record<string, string | undefined> = {
|
|
|
121
128
|
AGENT_RELAY_CODEX_CWD: cwd,
|
|
122
129
|
AGENT_RELAY_CODEX_STATE_PATH: statePath,
|
|
123
130
|
CODEX_MODEL: input.model || process.env.CODEX_MODEL || "",
|
|
131
|
+
AGENT_RELAY_APPROVAL: process.env.AGENT_RELAY_APPROVAL || profile.approval || "",
|
|
124
132
|
};
|
|
125
133
|
if (threadId) {
|
|
126
134
|
spawnEnv.CODEX_THREAD_ID = threadId;
|
|
@@ -168,27 +176,37 @@ const identity = buildAgentIdentity({
|
|
|
168
176
|
relayUrl,
|
|
169
177
|
cwd,
|
|
170
178
|
rig,
|
|
171
|
-
|
|
172
|
-
|
|
179
|
+
label: profile.label,
|
|
180
|
+
capabilities: profile.capabilities,
|
|
181
|
+
tags: profile.tags,
|
|
182
|
+
channels: profile.channels,
|
|
183
|
+
profileName: profile.profileName,
|
|
184
|
+
profileMeta: profile.meta,
|
|
173
185
|
threadId,
|
|
174
186
|
model: input.model,
|
|
175
187
|
appServerUrl,
|
|
176
188
|
});
|
|
177
189
|
|
|
178
|
-
outputContext(buildStartupContext(identity.id, relayUrl, approvalMode));
|
|
190
|
+
outputContext(buildStartupContext(identity.id, relayUrl, approvalMode, profile));
|
|
179
191
|
|
|
180
|
-
function buildStartupContext(agentId: string, url: string, mode: string): string {
|
|
192
|
+
function buildStartupContext(agentId: string, url: string, mode: string, resolvedProfile = profile): string {
|
|
181
193
|
return [
|
|
182
194
|
"Agent Relay active.",
|
|
183
195
|
`Agent ID: ${agentId}`,
|
|
184
196
|
`Relay URL: ${url}`,
|
|
197
|
+
resolvedProfile.profileName ? `Profile: ${resolvedProfile.profileName}` : "",
|
|
198
|
+
resolvedProfile.label ? `Label: ${resolvedProfile.label}` : "",
|
|
199
|
+
resolvedProfile.tags.length ? `Tags: ${resolvedProfile.tags.join(", ")}` : "",
|
|
200
|
+
resolvedProfile.capabilities.length ? `Capabilities: ${resolvedProfile.capabilities.join(", ")}` : "",
|
|
201
|
+
resolvedProfile.channels.length ? `Channels: ${resolvedProfile.channels.join(", ")}` : "",
|
|
185
202
|
`Approval mode: ${mode} (${describeApprovalMode(parseApprovalMode(mode))})`,
|
|
186
203
|
"Incoming messages will arrive as live user turns.",
|
|
187
204
|
`To send a message, POST JSON to ${url}/api/messages with from="${agentId}", to, subject, and body.`,
|
|
188
205
|
"Targets can be an agent id, tag:name, cap:name, label:name, or broadcast.",
|
|
206
|
+
`To pair with one available session, POST JSON to ${url}/api/pairs with from="${agentId}", target, and optional objective; pair chat uses /api/pairs/{id}/messages.`,
|
|
189
207
|
"To reply to a specific incoming message, include replyTo set to that message id.",
|
|
190
208
|
"If AGENT_RELAY_TOKEN is set, include it as the X-Agent-Relay-Token header.",
|
|
191
209
|
"Message etiquette: acknowledge incoming agent messages briefly unless they are obvious noise.",
|
|
192
210
|
"Anti-loop rule: do not auto-reply to pure acknowledgements/thanks/received messages; acknowledge once, then follow up only when there is new work, a decision, or a deliverable.",
|
|
193
|
-
].join(" ");
|
|
211
|
+
].filter(Boolean).join(" ");
|
|
194
212
|
}
|
package/live-sidecar.ts
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { dirname, resolve } from "node:path";
|
|
3
4
|
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
-
import { approvalModeFromPermissions,
|
|
5
|
+
import { approvalModeFromPermissions, type ApprovalMode } from "./approval";
|
|
5
6
|
import { CodexAppClient, type ClientEvent, type Thread, type ThreadStatus } from "./app-client";
|
|
6
|
-
import {
|
|
7
|
+
import { loadAgentRelayProfile, messageMatchesProfileChannels } from "./profile";
|
|
8
|
+
import { RelayClient, RelayHttpError, type RelayAgentSession, type RelayAgentStatus, type RelayMessage } from "./relay";
|
|
7
9
|
|
|
8
10
|
interface Config {
|
|
9
11
|
relayUrl: string;
|
|
10
12
|
appServerUrl: string;
|
|
11
13
|
cwd: string;
|
|
12
14
|
rig: string;
|
|
15
|
+
label?: string;
|
|
13
16
|
capabilities: string[];
|
|
14
17
|
tags: string[];
|
|
18
|
+
channels: string[];
|
|
19
|
+
profileName?: string;
|
|
20
|
+
profileMeta: Record<string, unknown>;
|
|
15
21
|
statePath: string;
|
|
16
22
|
pollIntervalMs: number;
|
|
17
23
|
heartbeatIntervalMs: number;
|
|
@@ -30,6 +36,8 @@ interface Config {
|
|
|
30
36
|
|
|
31
37
|
interface RuntimeState {
|
|
32
38
|
agentId: string;
|
|
39
|
+
agentInstanceId: string;
|
|
40
|
+
agentEpoch: number;
|
|
33
41
|
threadId: string;
|
|
34
42
|
activeTurnId: string | null;
|
|
35
43
|
threadStatus: ThreadStatus["type"];
|
|
@@ -46,11 +54,22 @@ interface DeliveryBatch {
|
|
|
46
54
|
messages: RelayMessage[];
|
|
47
55
|
}
|
|
48
56
|
|
|
57
|
+
export function activeClaimRenewalIds(
|
|
58
|
+
messageIds: Iterable<number>,
|
|
59
|
+
activeTurnId: string | null,
|
|
60
|
+
threadStatus: ThreadStatus["type"],
|
|
61
|
+
): number[] {
|
|
62
|
+
if (!activeTurnId && threadStatus !== "active") return [];
|
|
63
|
+
return [...messageIds];
|
|
64
|
+
}
|
|
65
|
+
|
|
49
66
|
class CodexLiveSidecar {
|
|
50
67
|
private readonly relay: RelayClient;
|
|
51
68
|
private readonly logPrefix = "[codex-live]";
|
|
52
69
|
private app: CodexAppClient;
|
|
53
70
|
private agentId = "";
|
|
71
|
+
private readonly agentInstanceId = randomUUID();
|
|
72
|
+
private agentEpoch = 0;
|
|
54
73
|
private threadId = "";
|
|
55
74
|
private activeTurnId: string | null = null;
|
|
56
75
|
private threadStatus: ThreadStatus["type"] = "notLoaded";
|
|
@@ -58,12 +77,14 @@ class CodexLiveSidecar {
|
|
|
58
77
|
private lastHeartbeatAt = 0;
|
|
59
78
|
private hasSuccessfulPoll = false;
|
|
60
79
|
private relayBackoffMs = 0;
|
|
80
|
+
private lastClaimRenewalAt = 0;
|
|
61
81
|
private stopping = false;
|
|
62
82
|
private appConnected = false;
|
|
63
83
|
private draining = false;
|
|
64
84
|
private drainDueAt = 0;
|
|
65
85
|
private reconnecting: Promise<void> | null = null;
|
|
66
86
|
private readonly pendingMessages = new Map<number, RelayMessage>();
|
|
87
|
+
private readonly activeClaimedMessageIds = new Set<number>();
|
|
67
88
|
|
|
68
89
|
constructor(private readonly config: Config) {
|
|
69
90
|
this.relay = new RelayClient(config.relayUrl, (msg) => this.log(msg));
|
|
@@ -85,9 +106,13 @@ class CodexLiveSidecar {
|
|
|
85
106
|
try {
|
|
86
107
|
const now = Date.now();
|
|
87
108
|
if (now - this.lastHeartbeatAt >= this.config.heartbeatIntervalMs) {
|
|
88
|
-
await this.relay.heartbeat(this.agentId);
|
|
109
|
+
await this.relay.heartbeat(this.agentId, this.agentSession());
|
|
89
110
|
this.lastHeartbeatAt = now;
|
|
90
111
|
}
|
|
112
|
+
if (now - this.lastClaimRenewalAt >= this.config.heartbeatIntervalMs) {
|
|
113
|
+
await this.renewActiveClaims();
|
|
114
|
+
this.lastClaimRenewalAt = now;
|
|
115
|
+
}
|
|
91
116
|
|
|
92
117
|
const messages = await this.relay.pollMessages(this.agentId, this.lastSeenMessageId);
|
|
93
118
|
if (!this.hasSuccessfulPoll) {
|
|
@@ -95,7 +120,10 @@ class CodexLiveSidecar {
|
|
|
95
120
|
await this.refreshRelayStatus();
|
|
96
121
|
}
|
|
97
122
|
if (messages.length > 0) {
|
|
98
|
-
|
|
123
|
+
const deliverableMessages = messages.filter((message) => messageMatchesProfileChannels(message, this.config.channels));
|
|
124
|
+
const skippedMessages = messages.filter((message) => !messageMatchesProfileChannels(message, this.config.channels));
|
|
125
|
+
if (skippedMessages.length > 0) this.advanceCursor(skippedMessages);
|
|
126
|
+
this.addPendingMessages(deliverableMessages);
|
|
99
127
|
this.writeState();
|
|
100
128
|
}
|
|
101
129
|
|
|
@@ -107,6 +135,11 @@ class CodexLiveSidecar {
|
|
|
107
135
|
if (error instanceof RelayHttpError && error.status === 404 && error.path.includes("/heartbeat")) {
|
|
108
136
|
this.log("heartbeat returned 404; re-registering relay agent");
|
|
109
137
|
await this.registerRelayAgent();
|
|
138
|
+
} else if (error instanceof RelayHttpError && error.status === 409) {
|
|
139
|
+
this.log("relay rejected this stale agent instance; stopping sidecar");
|
|
140
|
+
this.stopping = true;
|
|
141
|
+
this.writeState();
|
|
142
|
+
this.app.close();
|
|
110
143
|
} else {
|
|
111
144
|
sleepMs = this.nextRelayBackoffMs();
|
|
112
145
|
this.log(`loop error: ${describeError(error)}; retrying in ${sleepMs}ms`);
|
|
@@ -121,14 +154,20 @@ class CodexLiveSidecar {
|
|
|
121
154
|
relayUrl: this.config.relayUrl,
|
|
122
155
|
cwd: this.config.cwd,
|
|
123
156
|
rig: this.config.rig,
|
|
157
|
+
label: this.config.label,
|
|
124
158
|
capabilities: this.config.capabilities,
|
|
125
159
|
tags: this.config.tags,
|
|
160
|
+
channels: this.config.channels,
|
|
161
|
+
profileName: this.config.profileName,
|
|
162
|
+
profileMeta: this.config.profileMeta,
|
|
126
163
|
threadId: this.threadId,
|
|
127
164
|
model: this.config.model,
|
|
128
165
|
appServerUrl: this.config.appServerUrl,
|
|
129
166
|
approvalMode: this.config.approvalMode,
|
|
167
|
+
instanceId: this.agentInstanceId,
|
|
130
168
|
});
|
|
131
169
|
this.agentId = registration.agentId;
|
|
170
|
+
this.agentEpoch = registration.session?.epoch ?? 0;
|
|
132
171
|
this.lastSeenMessageId = await this.relay.getCursor();
|
|
133
172
|
this.lastHeartbeatAt = 0;
|
|
134
173
|
this.hasSuccessfulPoll = false;
|
|
@@ -210,7 +249,7 @@ class CodexLiveSidecar {
|
|
|
210
249
|
this.log(`stopping on ${signal}`);
|
|
211
250
|
if (this.agentId) {
|
|
212
251
|
try {
|
|
213
|
-
await this.relay.setStatus(this.agentId, "offline");
|
|
252
|
+
await this.relay.setStatus(this.agentId, "offline", this.agentSession());
|
|
214
253
|
} catch {
|
|
215
254
|
// Best effort during shutdown.
|
|
216
255
|
}
|
|
@@ -304,6 +343,7 @@ class CodexLiveSidecar {
|
|
|
304
343
|
const turns = thread.turns ?? [];
|
|
305
344
|
const activeTurn = [...turns].reverse().find((turn) => turn.status === "inProgress");
|
|
306
345
|
this.activeTurnId = activeTurn?.id ?? null;
|
|
346
|
+
if (!this.activeTurnId && this.threadStatus !== "active") this.activeClaimedMessageIds.clear();
|
|
307
347
|
void this.refreshRelayStatus();
|
|
308
348
|
this.writeState();
|
|
309
349
|
}
|
|
@@ -336,6 +376,7 @@ class CodexLiveSidecar {
|
|
|
336
376
|
const completedId = "id" in params.turn ? String(params.turn.id) : null;
|
|
337
377
|
if (completedId && this.activeTurnId === completedId) this.activeTurnId = null;
|
|
338
378
|
this.threadStatus = "idle";
|
|
379
|
+
this.activeClaimedMessageIds.clear();
|
|
339
380
|
void this.refreshRelayStatus();
|
|
340
381
|
this.writeState();
|
|
341
382
|
}
|
|
@@ -354,14 +395,35 @@ class CodexLiveSidecar {
|
|
|
354
395
|
|
|
355
396
|
try {
|
|
356
397
|
await Promise.all([
|
|
357
|
-
this.relay.setStatus(this.agentId, status),
|
|
358
|
-
this.relay.setReady(this.agentId, ready),
|
|
398
|
+
this.relay.setStatus(this.agentId, status, this.agentSession()),
|
|
399
|
+
this.relay.setReady(this.agentId, ready, this.agentSession()),
|
|
359
400
|
]);
|
|
360
401
|
} catch {
|
|
361
402
|
// Best-effort status sync.
|
|
362
403
|
}
|
|
363
404
|
}
|
|
364
405
|
|
|
406
|
+
private async renewActiveClaims(): Promise<void> {
|
|
407
|
+
if (!this.agentId || this.activeClaimedMessageIds.size === 0) return;
|
|
408
|
+
const renewals = activeClaimRenewalIds(this.activeClaimedMessageIds, this.activeTurnId, this.threadStatus);
|
|
409
|
+
if (renewals.length === 0) {
|
|
410
|
+
this.activeClaimedMessageIds.clear();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
for (const messageId of renewals) {
|
|
415
|
+
try {
|
|
416
|
+
const renewed = await this.relay.renewMessageClaim(messageId, this.agentId, this.agentSession());
|
|
417
|
+
if (!renewed) {
|
|
418
|
+
this.activeClaimedMessageIds.delete(messageId);
|
|
419
|
+
this.log(`claim renewal rejected for message ${messageId}`);
|
|
420
|
+
}
|
|
421
|
+
} catch (error) {
|
|
422
|
+
this.log(`claim renewal failed for message ${messageId}: ${describeError(error)}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
365
427
|
private addPendingMessages(messages: RelayMessage[]): void {
|
|
366
428
|
let added = false;
|
|
367
429
|
for (const message of messages) {
|
|
@@ -417,15 +479,17 @@ class CodexLiveSidecar {
|
|
|
417
479
|
private async deliverBatch(batch: DeliveryBatch): Promise<void> {
|
|
418
480
|
await this.ensureAppReady();
|
|
419
481
|
|
|
482
|
+
const claimedMessageIds: number[] = [];
|
|
420
483
|
for (const message of batch.messages) {
|
|
421
484
|
if (!message.claimable) continue;
|
|
422
|
-
const claimed = await this.relay.claimMessage(message.id, this.agentId);
|
|
485
|
+
const claimed = await this.relay.claimMessage(message.id, this.agentId, this.agentSession());
|
|
423
486
|
if (!claimed) {
|
|
424
487
|
this.log(`skipping unclaimed message ${message.id}`);
|
|
425
488
|
this.advanceCursor([message]);
|
|
426
489
|
this.writeState();
|
|
427
490
|
return;
|
|
428
491
|
}
|
|
492
|
+
claimedMessageIds.push(message.id);
|
|
429
493
|
}
|
|
430
494
|
|
|
431
495
|
const delivery = this.pickDeliveryMode(batch.messages);
|
|
@@ -447,6 +511,7 @@ class CodexLiveSidecar {
|
|
|
447
511
|
if (delivery === "steer" && this.activeTurnId) {
|
|
448
512
|
try {
|
|
449
513
|
await this.app.turnSteer(this.threadId, this.activeTurnId, prompt);
|
|
514
|
+
for (const id of claimedMessageIds) this.activeClaimedMessageIds.add(id);
|
|
450
515
|
await this.markBatchRead(batch.messages);
|
|
451
516
|
this.advanceCursor(batch.messages);
|
|
452
517
|
this.writeState();
|
|
@@ -457,6 +522,7 @@ class CodexLiveSidecar {
|
|
|
457
522
|
}
|
|
458
523
|
|
|
459
524
|
await this.app.turnStart(this.threadId, prompt);
|
|
525
|
+
for (const id of claimedMessageIds) this.activeClaimedMessageIds.add(id);
|
|
460
526
|
await this.markBatchRead(batch.messages);
|
|
461
527
|
this.advanceCursor(batch.messages);
|
|
462
528
|
this.writeState();
|
|
@@ -474,6 +540,10 @@ class CodexLiveSidecar {
|
|
|
474
540
|
}
|
|
475
541
|
}
|
|
476
542
|
|
|
543
|
+
private agentSession(): RelayAgentSession | undefined {
|
|
544
|
+
return this.agentEpoch > 0 ? { instanceId: this.agentInstanceId, epoch: this.agentEpoch } : undefined;
|
|
545
|
+
}
|
|
546
|
+
|
|
477
547
|
private pickDeliveryMode(messages: RelayMessage[]): "start" | "steer" | "interrupt" {
|
|
478
548
|
const first = messages[0]!;
|
|
479
549
|
const meta = first.meta ?? {};
|
|
@@ -491,6 +561,8 @@ class CodexLiveSidecar {
|
|
|
491
561
|
if (!this.threadId) return;
|
|
492
562
|
const state: RuntimeState = {
|
|
493
563
|
agentId: this.agentId,
|
|
564
|
+
agentInstanceId: this.agentInstanceId,
|
|
565
|
+
agentEpoch: this.agentEpoch,
|
|
494
566
|
threadId: this.threadId,
|
|
495
567
|
activeTurnId: this.activeTurnId,
|
|
496
568
|
threadStatus: this.threadStatus,
|
|
@@ -562,6 +634,9 @@ export function formatRelayPrompt(messages: RelayMessage[]): string {
|
|
|
562
634
|
|
|
563
635
|
if (message.subject) lines.push(`Subject: ${message.subject}`);
|
|
564
636
|
if (message.replyTo) lines.push(`Reply To: ${message.replyTo}`);
|
|
637
|
+
if (typeof message.meta?.pairId === "string") {
|
|
638
|
+
lines.push(`Pair ID: ${message.meta.pairId}`);
|
|
639
|
+
}
|
|
565
640
|
lines.push(
|
|
566
641
|
"",
|
|
567
642
|
"Body:",
|
|
@@ -569,7 +644,9 @@ export function formatRelayPrompt(messages: RelayMessage[]): string {
|
|
|
569
644
|
"",
|
|
570
645
|
"Treat this as a live incoming message from another agent. Respond or act on it as appropriate.",
|
|
571
646
|
"If AGENT_RELAY_TOKEN is set, include it as the X-Agent-Relay-Token header when calling the relay API.",
|
|
572
|
-
|
|
647
|
+
typeof message.meta?.pairId === "string"
|
|
648
|
+
? `For pair chat, POST JSON to ${process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850"}/api/pairs/${message.meta.pairId}/messages with from set to your Agent Relay ID and body set to your response.`
|
|
649
|
+
: `To reply, POST JSON to ${process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850"}/api/messages with from set to your Agent Relay ID, to set to ${JSON.stringify(message.from)}, and replyTo set to ${message.id}.`,
|
|
573
650
|
);
|
|
574
651
|
return lines.join("\n");
|
|
575
652
|
}
|
|
@@ -591,6 +668,7 @@ export function formatRelayPrompt(messages: RelayMessage[]): string {
|
|
|
591
668
|
);
|
|
592
669
|
if (message.subject) lines.push(`Subject: ${message.subject}`);
|
|
593
670
|
if (message.replyTo) lines.push(`Reply To: ${message.replyTo}`);
|
|
671
|
+
if (typeof message.meta?.pairId === "string") lines.push(`Pair ID: ${message.meta.pairId}`);
|
|
594
672
|
lines.push("Body:", message.body, "", "---", "");
|
|
595
673
|
}
|
|
596
674
|
|
|
@@ -637,18 +715,21 @@ export function parseThreadMode(raw: string | undefined): Config["threadMode"] {
|
|
|
637
715
|
|
|
638
716
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
|
639
717
|
const cwd = env.AGENT_RELAY_CODEX_CWD || process.cwd();
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
.filter(Boolean);
|
|
718
|
+
const rig = env.AGENT_RELAY_CODEX_RIG || "codex-live";
|
|
719
|
+
const project = cwd.split("/").filter(Boolean).at(-1) || "unknown";
|
|
720
|
+
const profile = loadAgentRelayProfile(env, { provider: "codex", rig, project });
|
|
644
721
|
|
|
645
722
|
return {
|
|
646
723
|
relayUrl: env.AGENT_RELAY_URL || "http://127.0.0.1:4850",
|
|
647
724
|
appServerUrl: env.CODEX_APP_SERVER_URL || "ws://127.0.0.1:4501",
|
|
648
725
|
cwd,
|
|
649
|
-
rig
|
|
650
|
-
|
|
651
|
-
|
|
726
|
+
rig,
|
|
727
|
+
label: profile.label,
|
|
728
|
+
capabilities: profile.capabilities,
|
|
729
|
+
tags: profile.tags,
|
|
730
|
+
channels: profile.channels,
|
|
731
|
+
profileName: profile.profileName,
|
|
732
|
+
profileMeta: profile.meta,
|
|
652
733
|
statePath: env.AGENT_RELAY_CODEX_STATE_PATH || resolve(cwd, "codex/runtime/live-state.json"),
|
|
653
734
|
pollIntervalMs: envNumber(env, "AGENT_RELAY_CODEX_POLL_INTERVAL_MS", 2000),
|
|
654
735
|
heartbeatIntervalMs: envNumber(env, "AGENT_RELAY_CODEX_HEARTBEAT_INTERVAL_MS", 30000),
|
|
@@ -662,8 +743,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
|
|
662
743
|
model: env.CODEX_MODEL || undefined,
|
|
663
744
|
approvalPolicy: env.AGENT_RELAY_CODEX_APPROVAL_POLICY || undefined,
|
|
664
745
|
sandbox: env.AGENT_RELAY_CODEX_SANDBOX || undefined,
|
|
665
|
-
approvalMode:
|
|
666
|
-
?
|
|
746
|
+
approvalMode: profile.approval
|
|
747
|
+
? profile.approval
|
|
667
748
|
: approvalModeFromPermissions({
|
|
668
749
|
approvalPolicy: env.AGENT_RELAY_CODEX_APPROVAL_POLICY,
|
|
669
750
|
sandbox: env.AGENT_RELAY_CODEX_SANDBOX,
|
package/package.json
CHANGED
|
@@ -24,6 +24,37 @@ curl -sS -X POST "${AGENT_RELAY_URL:-http://127.0.0.1:4850}/api/messages" \
|
|
|
24
24
|
|
|
25
25
|
Targets can be an agent id, `broadcast`, `cap:<capability>`, `tag:<tag>`, or `label:<label>`.
|
|
26
26
|
|
|
27
|
+
## Pair with another session
|
|
28
|
+
|
|
29
|
+
Use pair sessions for focused, two-agent live collaboration. Pairing is exclusive: one agent can have only one pending or active pair.
|
|
30
|
+
|
|
31
|
+
When the user types a slash-style relay instruction such as `/pair codex`,
|
|
32
|
+
`/pair status`, `/pair send <pair-id> ...`, `/disconnect`, `/status`,
|
|
33
|
+
`/label ...`, or `/tags ...`, run the matching `agent-relay` CLI command
|
|
34
|
+
instead of explaining the API. The CLI auto-detects the current agent id from
|
|
35
|
+
provider state in normal plugin installs.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
agent-relay /pair codex "Debug flaky tests"
|
|
39
|
+
agent-relay /pair status
|
|
40
|
+
agent-relay /pair send PAIR_ID "What do you see?"
|
|
41
|
+
agent-relay /disconnect
|
|
42
|
+
agent-relay /status
|
|
43
|
+
agent-relay /label backend-fixer
|
|
44
|
+
agent-relay /tags backend tests urgent
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Create an invite:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
curl -sS -X POST "${AGENT_RELAY_URL:-http://127.0.0.1:4850}/api/pairs" \
|
|
51
|
+
${AGENT_RELAY_TOKEN:+-H "X-Agent-Relay-Token: ${AGENT_RELAY_TOKEN}"} \
|
|
52
|
+
-H 'Content-Type: application/json' \
|
|
53
|
+
-d '{"from":"<this-agent-id>","target":"codex","objective":"<what to solve together>"}'
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Accept/reject incoming pair invites with `/api/pairs/<pair-id>/accept` or `/api/pairs/<pair-id>/reject` and `{"agentId":"<this-agent-id>"}`. Send pair chat through `/api/pairs/<pair-id>/messages` with `{"from":"<this-agent-id>","body":"..."}`. Hang up with `/api/pairs/<pair-id>/hangup`.
|
|
57
|
+
|
|
27
58
|
## Claimable tasks
|
|
28
59
|
|
|
29
60
|
If an incoming relay message is described as claimable, only one matching agent should act on it. The Codex sidecar attempts to claim before delivery; if the claim fails, the message is skipped.
|
package/profile.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseApprovalMode, type ApprovalMode } from "./approval";
|
|
4
|
+
|
|
5
|
+
export interface AgentRelayProfile {
|
|
6
|
+
label?: string;
|
|
7
|
+
tags?: string[];
|
|
8
|
+
capabilities?: string[];
|
|
9
|
+
channels?: string[];
|
|
10
|
+
approval?: ApprovalMode;
|
|
11
|
+
meta?: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ResolvedAgentProfile {
|
|
15
|
+
profileName?: string;
|
|
16
|
+
label?: string;
|
|
17
|
+
tags: string[];
|
|
18
|
+
capabilities: string[];
|
|
19
|
+
channels: string[];
|
|
20
|
+
approval?: ApprovalMode;
|
|
21
|
+
meta: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function splitCsv(raw: string | undefined): string[] {
|
|
25
|
+
if (!raw) return [];
|
|
26
|
+
return [...new Set(raw.split(",").map((value) => value.trim()).filter(Boolean))];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stringArray(value: unknown): string[] {
|
|
30
|
+
if (!Array.isArray(value)) return [];
|
|
31
|
+
return [...new Set(value.filter((item): item is string => typeof item === "string" && item.trim().length > 0).map((item) => item.trim()))];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function cleanLabel(value: unknown): string | undefined {
|
|
35
|
+
if (typeof value !== "string") return undefined;
|
|
36
|
+
const trimmed = value.trim();
|
|
37
|
+
return trimmed ? trimmed : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function cleanMeta(value: unknown): Record<string, unknown> {
|
|
41
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function loadProfileFile(env: NodeJS.ProcessEnv): Record<string, unknown> {
|
|
45
|
+
const file = env.AGENT_RELAY_PROFILES_FILE || join(env.HOME || "", ".config", "agent-relay", "profiles.json");
|
|
46
|
+
if (!file || !existsSync(file)) return {};
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown;
|
|
49
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
|
|
50
|
+
} catch {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function loadAgentRelayProfile(
|
|
56
|
+
env: NodeJS.ProcessEnv,
|
|
57
|
+
defaults: { provider: string; rig: string; project: string },
|
|
58
|
+
): ResolvedAgentProfile {
|
|
59
|
+
const profileName = cleanLabel(env.AGENT_RELAY_PROFILE);
|
|
60
|
+
const rawProfile = profileName ? loadProfileFile(env)[profileName] : undefined;
|
|
61
|
+
const profile = cleanMeta(rawProfile);
|
|
62
|
+
const defaultTags = [defaults.provider, defaults.rig, defaults.project].filter(Boolean);
|
|
63
|
+
const profileTags = stringArray(profile.tags);
|
|
64
|
+
const envTags = splitCsv(env.AGENT_RELAY_TAGS);
|
|
65
|
+
const profileCapabilities = stringArray(profile.capabilities);
|
|
66
|
+
const profileChannels = stringArray(profile.channels);
|
|
67
|
+
const envChannels = splitCsv(env.AGENT_RELAY_CHANNELS);
|
|
68
|
+
const profileApproval = cleanLabel(profile.approval);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
profileName,
|
|
72
|
+
label: cleanLabel(env.AGENT_RELAY_LABEL) ?? cleanLabel(profile.label),
|
|
73
|
+
tags: [...new Set([...defaultTags, ...(envTags.length ? envTags : profileTags)])],
|
|
74
|
+
capabilities: splitCsv(env.AGENT_RELAY_CAPS).length
|
|
75
|
+
? splitCsv(env.AGENT_RELAY_CAPS)
|
|
76
|
+
: profileCapabilities.length
|
|
77
|
+
? profileCapabilities
|
|
78
|
+
: ["chat"],
|
|
79
|
+
channels: envChannels.length ? envChannels : profileChannels,
|
|
80
|
+
approval: env.AGENT_RELAY_APPROVAL
|
|
81
|
+
? parseApprovalMode(env.AGENT_RELAY_APPROVAL)
|
|
82
|
+
: profileApproval
|
|
83
|
+
? parseApprovalMode(profileApproval)
|
|
84
|
+
: undefined,
|
|
85
|
+
meta: {
|
|
86
|
+
...cleanMeta(profile.meta),
|
|
87
|
+
...(profileName ? { profile: profileName } : {}),
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function messageMatchesProfileChannels(message: { channel?: string }, channels: string[]): boolean {
|
|
93
|
+
if (channels.length === 0) return true;
|
|
94
|
+
if (!message.channel) return true;
|
|
95
|
+
return channels.includes(message.channel);
|
|
96
|
+
}
|
package/relay.ts
CHANGED
|
@@ -6,10 +6,14 @@ export interface RelayMessage {
|
|
|
6
6
|
id: number;
|
|
7
7
|
from: string;
|
|
8
8
|
to: string;
|
|
9
|
+
channel?: string;
|
|
9
10
|
subject?: string;
|
|
10
11
|
body: string;
|
|
11
12
|
replyTo?: number;
|
|
12
13
|
claimable?: boolean;
|
|
14
|
+
claimedBy?: string;
|
|
15
|
+
claimedAt?: number;
|
|
16
|
+
claimExpiresAt?: number;
|
|
13
17
|
type?: string;
|
|
14
18
|
meta?: Record<string, unknown>;
|
|
15
19
|
createdAt: number;
|
|
@@ -19,12 +23,22 @@ export interface RelayConfig {
|
|
|
19
23
|
relayUrl: string;
|
|
20
24
|
cwd: string;
|
|
21
25
|
rig: string;
|
|
26
|
+
label?: string;
|
|
22
27
|
capabilities: string[];
|
|
23
28
|
tags: string[];
|
|
29
|
+
channels?: string[];
|
|
30
|
+
profileName?: string;
|
|
31
|
+
profileMeta?: Record<string, unknown>;
|
|
24
32
|
threadId: string;
|
|
25
33
|
model?: string;
|
|
26
34
|
appServerUrl: string;
|
|
27
35
|
approvalMode?: string;
|
|
36
|
+
instanceId?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RelayAgentSession {
|
|
40
|
+
instanceId: string;
|
|
41
|
+
epoch: number;
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
export function buildAgentIdentity(config: RelayConfig): { id: string; name: string; machine: string; project: string } {
|
|
@@ -39,16 +53,18 @@ export function buildAgentIdentity(config: RelayConfig): { id: string; name: str
|
|
|
39
53
|
export class RelayClient {
|
|
40
54
|
constructor(private readonly baseUrl: string, private readonly log: (msg: string) => void = () => {}) {}
|
|
41
55
|
|
|
42
|
-
async registerAgent(config: RelayConfig): Promise<{ agentId: string; project: string }> {
|
|
56
|
+
async registerAgent(config: RelayConfig): Promise<{ agentId: string; project: string; session?: RelayAgentSession }> {
|
|
43
57
|
const identity = buildAgentIdentity(config);
|
|
44
58
|
const payload = {
|
|
45
59
|
id: identity.id,
|
|
46
60
|
name: identity.name,
|
|
61
|
+
label: config.label,
|
|
47
62
|
machine: identity.machine,
|
|
48
63
|
rig: config.rig,
|
|
49
64
|
tags: config.tags,
|
|
50
65
|
capabilities: config.capabilities,
|
|
51
66
|
status: "online",
|
|
67
|
+
instanceId: config.instanceId,
|
|
52
68
|
meta: {
|
|
53
69
|
client: "codex-live",
|
|
54
70
|
threadId: config.threadId,
|
|
@@ -56,15 +72,23 @@ export class RelayClient {
|
|
|
56
72
|
cwd: config.cwd,
|
|
57
73
|
model: config.model || null,
|
|
58
74
|
approvalMode: config.approvalMode || "open",
|
|
75
|
+
channels: config.channels ?? [],
|
|
76
|
+
profile: config.profileName || null,
|
|
77
|
+
...(config.profileMeta ?? {}),
|
|
59
78
|
},
|
|
60
79
|
};
|
|
61
80
|
|
|
62
|
-
await this.json("POST", "/api/agents", payload);
|
|
63
|
-
|
|
81
|
+
const agent = await this.json("POST", "/api/agents", payload) as { epoch?: unknown } | null;
|
|
82
|
+
const epoch = typeof agent?.epoch === "number" && Number.isSafeInteger(agent.epoch) ? agent.epoch : undefined;
|
|
83
|
+
return {
|
|
84
|
+
agentId: identity.id,
|
|
85
|
+
project: identity.project,
|
|
86
|
+
session: config.instanceId && epoch !== undefined ? { instanceId: config.instanceId, epoch } : undefined,
|
|
87
|
+
};
|
|
64
88
|
}
|
|
65
89
|
|
|
66
|
-
async heartbeat(agentId: string): Promise<void> {
|
|
67
|
-
await this.json("POST", `/api/agents/${encodeURIComponent(agentId)}/heartbeat
|
|
90
|
+
async heartbeat(agentId: string, session?: RelayAgentSession): Promise<void> {
|
|
91
|
+
await this.json("POST", `/api/agents/${encodeURIComponent(agentId)}/heartbeat`, session);
|
|
68
92
|
}
|
|
69
93
|
|
|
70
94
|
async getCursor(): Promise<number> {
|
|
@@ -72,12 +96,12 @@ export class RelayClient {
|
|
|
72
96
|
return typeof result?.latestId === "number" && Number.isSafeInteger(result.latestId) ? result.latestId : 0;
|
|
73
97
|
}
|
|
74
98
|
|
|
75
|
-
async setStatus(agentId: string, status: RelayAgentStatus): Promise<void> {
|
|
76
|
-
await this.json("PATCH", `/api/agents/${encodeURIComponent(agentId)}/status`, { status });
|
|
99
|
+
async setStatus(agentId: string, status: RelayAgentStatus, session?: RelayAgentSession): Promise<void> {
|
|
100
|
+
await this.json("PATCH", `/api/agents/${encodeURIComponent(agentId)}/status`, { status, ...session });
|
|
77
101
|
}
|
|
78
102
|
|
|
79
|
-
async setReady(agentId: string, ready: boolean): Promise<void> {
|
|
80
|
-
await this.json("PATCH", `/api/agents/${encodeURIComponent(agentId)}/ready`, { ready });
|
|
103
|
+
async setReady(agentId: string, ready: boolean, session?: RelayAgentSession): Promise<void> {
|
|
104
|
+
await this.json("PATCH", `/api/agents/${encodeURIComponent(agentId)}/ready`, { ready, ...session });
|
|
81
105
|
}
|
|
82
106
|
|
|
83
107
|
async pollMessages(agentId: string, sinceId: number): Promise<RelayMessage[]> {
|
|
@@ -92,11 +116,11 @@ export class RelayClient {
|
|
|
92
116
|
return (await response.json()) as RelayMessage[];
|
|
93
117
|
}
|
|
94
118
|
|
|
95
|
-
async claimMessage(messageId: number, agentId: string): Promise<boolean> {
|
|
119
|
+
async claimMessage(messageId: number, agentId: string, session?: RelayAgentSession): Promise<boolean> {
|
|
96
120
|
const response = await fetch(new URL(`/api/messages/${messageId}/claim`, this.baseUrl), {
|
|
97
121
|
method: "POST",
|
|
98
122
|
headers: this.headers({ "Content-Type": "application/json" }),
|
|
99
|
-
body: JSON.stringify({ agentId }),
|
|
123
|
+
body: JSON.stringify({ agentId, ...session }),
|
|
100
124
|
});
|
|
101
125
|
|
|
102
126
|
if (response.ok) return true;
|
|
@@ -105,6 +129,19 @@ export class RelayClient {
|
|
|
105
129
|
throw new RelayHttpError("POST", `/api/messages/${messageId}/claim`, response.status, response.statusText, await response.text());
|
|
106
130
|
}
|
|
107
131
|
|
|
132
|
+
async renewMessageClaim(messageId: number, agentId: string, session?: RelayAgentSession): Promise<boolean> {
|
|
133
|
+
const response = await fetch(new URL(`/api/messages/${messageId}/claim/renew`, this.baseUrl), {
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: this.headers({ "Content-Type": "application/json" }),
|
|
136
|
+
body: JSON.stringify({ agentId, ...session }),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (response.ok) return true;
|
|
140
|
+
if (response.status === 409) return false;
|
|
141
|
+
if (response.status === 400 || response.status === 404) return false;
|
|
142
|
+
throw new RelayHttpError("POST", `/api/messages/${messageId}/claim/renew`, response.status, response.statusText, await response.text());
|
|
143
|
+
}
|
|
144
|
+
|
|
108
145
|
async markRead(messageId: number, agentId: string): Promise<void> {
|
|
109
146
|
await this.json("PATCH", `/api/messages/${messageId}`, { readBy: agentId });
|
|
110
147
|
}
|