agent-relay-codex 0.4.16 → 0.4.18

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 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 four env vars are shared with the [Claude plugin](https://www.npmjs.com/package/agent-relay-plugin).
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
 
@@ -110,6 +134,25 @@ agent-relay-codex uninstall --purge # also remove runtime state and PATH entrie
110
134
 
111
135
  Message delivery adapts to thread state: `turn/start` when idle, `turn/steer` when active, `turn/interrupt` for urgent messages.
112
136
 
137
+ ## Command Skills
138
+
139
+ The Codex plugin ships command skills for Agent Relay slash commands:
140
+
141
+ ```bash
142
+ /pair codex "Debug flaky tests"
143
+ /message codex "Can you look at that failing action?"
144
+ /send-claimable tag:backend "Please claim and fix the failing API test"
145
+ /disconnect
146
+ /status
147
+ /label backend-fixer
148
+ /tags backend tests urgent
149
+ ```
150
+
151
+ If another installed skill uses the same name, invoke the prefixed form such as
152
+ `/agent-relay:pair`, `/agent-relay:message`,
153
+ `/agent-relay:send-claimable`, `/agent-relay:disconnect`,
154
+ `/agent-relay:status`, `/agent-relay:label`, or `/agent-relay:tags`.
155
+
113
156
  ## Development
114
157
 
115
158
  ```bash
@@ -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 requestedApprovalMode = parseApprovalMode(process.env.AGENT_RELAY_APPROVAL);
776
- const hasApprovalEnv = process.env.AGENT_RELAY_APPROVAL !== undefined;
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) {
@@ -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 approvalMode = parseApprovalMode(process.env.AGENT_RELAY_APPROVAL);
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
- capabilities: (process.env.AGENT_RELAY_CAPS || "chat").split(",").map((value) => value.trim()).filter(Boolean),
106
- tags: ["codex", rig, cwd.split("/").filter(Boolean).at(-1) || "unknown"],
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
- capabilities: (process.env.AGENT_RELAY_CAPS || "chat").split(",").map((value) => value.trim()).filter(Boolean),
172
- tags: ["codex", rig, cwd.split("/").filter(Boolean).at(-1) || "unknown"],
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, parseApprovalMode, type ApprovalMode } from "./approval";
5
+ import { approvalModeFromPermissions, type ApprovalMode } from "./approval";
5
6
  import { CodexAppClient, type ClientEvent, type Thread, type ThreadStatus } from "./app-client";
6
- import { RelayClient, RelayHttpError, type RelayAgentStatus, type RelayMessage } from "./relay";
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
- this.addPendingMessages(messages);
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
- `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}.`,
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 capabilities = (env.AGENT_RELAY_CAPS || "chat")
641
- .split(",")
642
- .map((value) => value.trim())
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: env.AGENT_RELAY_CODEX_RIG || "codex-live",
650
- capabilities,
651
- tags: ["codex", env.AGENT_RELAY_CODEX_RIG || "codex-live", cwd.split("/").filter(Boolean).at(-1) || "unknown"],
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: env.AGENT_RELAY_APPROVAL
666
- ? parseApprovalMode(env.AGENT_RELAY_APPROVAL)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-codex",
3
- "version": "0.4.16",
3
+ "version": "0.4.18",
4
4
  "description": "Codex integration for Agent Relay — auto-registers sessions as agents and enables inter-agent messaging",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay",
3
- "version": "0.4.16",
3
+ "version": "0.4.18",
4
4
  "description": "Agent Relay integration for Codex sessions",
5
5
  "author": {
6
6
  "name": "Edin Mujkanovic"
@@ -24,6 +24,40 @@ 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
+ `/message codex ...`, `/send-claimable tag:backend ...`, `/pair status`,
33
+ `/pair send <pair-id> ...`, `/disconnect`, `/status`, `/label ...`, or
34
+ `/tags ...`, run the matching `agent-relay` CLI command instead of explaining
35
+ the API. The CLI auto-detects the current agent id from provider state in normal
36
+ plugin installs.
37
+
38
+ ```bash
39
+ agent-relay /pair codex "Debug flaky tests"
40
+ agent-relay /message codex "Can you look at that failing action?"
41
+ agent-relay /send-claimable tag:backend "Please claim and fix the failing API test"
42
+ agent-relay /pair status
43
+ agent-relay /pair send PAIR_ID "What do you see?"
44
+ agent-relay /disconnect
45
+ agent-relay /status
46
+ agent-relay /label backend-fixer
47
+ agent-relay /tags backend tests urgent
48
+ ```
49
+
50
+ Create an invite:
51
+
52
+ ```bash
53
+ curl -sS -X POST "${AGENT_RELAY_URL:-http://127.0.0.1:4850}/api/pairs" \
54
+ ${AGENT_RELAY_TOKEN:+-H "X-Agent-Relay-Token: ${AGENT_RELAY_TOKEN}"} \
55
+ -H 'Content-Type: application/json' \
56
+ -d '{"from":"<this-agent-id>","target":"codex","objective":"<what to solve together>"}'
57
+ ```
58
+
59
+ 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`.
60
+
27
61
  ## Claimable tasks
28
62
 
29
63
  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.
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: disconnect
3
+ description: End the current Agent Relay pair session. Use when the user invokes /disconnect or asks to hang up, unpair, or disconnect from a paired agent.
4
+ argument-hint: "[PAIR_ID]"
5
+ allowed-tools: [Bash]
6
+ ---
7
+
8
+ # Agent Relay Disconnect
9
+
10
+ Run:
11
+
12
+ ```bash
13
+ agent-relay /disconnect $ARGUMENTS
14
+ ```
15
+
16
+ If no pair id is supplied, the CLI ends the active pair for this session. Report the result briefly.
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: label
3
+ description: Read, set, or clear the current Agent Relay agent label. Use when the user invokes /label or asks to rename this relay agent.
4
+ argument-hint: "[LABEL|--clear]"
5
+ allowed-tools: [Bash]
6
+ ---
7
+
8
+ # Agent Relay Label
9
+
10
+ Run:
11
+
12
+ ```bash
13
+ agent-relay /label $ARGUMENTS
14
+ ```
15
+
16
+ Examples:
17
+
18
+ ```bash
19
+ agent-relay /label backend-fixer
20
+ agent-relay /label --clear
21
+ ```
22
+
23
+ Report the new label or current label briefly.
@@ -0,0 +1,24 @@
1
+ ---
2
+ name: message
3
+ description: Send a normal Agent Relay message to another agent, label, tag, capability, or broadcast target. Use when the user invokes /message or asks to send a one-off relay message.
4
+ argument-hint: "<target> <message> [--subject TEXT] [--channel NAME]"
5
+ allowed-tools: [Bash]
6
+ ---
7
+
8
+ # Agent Relay Message
9
+
10
+ Run:
11
+
12
+ ```bash
13
+ agent-relay /message $ARGUMENTS
14
+ ```
15
+
16
+ Examples:
17
+
18
+ ```bash
19
+ agent-relay /message codex "Can you look at that failing action?"
20
+ agent-relay /message tag:backend "Does anyone see the regression?"
21
+ agent-relay /message broadcast "Standup in five minutes"
22
+ ```
23
+
24
+ Report the sent message id briefly.
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: pair
3
+ description: Start, inspect, accept, reject, or send messages in an Agent Relay two-agent pair session. Use when the user invokes /pair or asks to pair this agent with Codex, Claude, or another relay agent.
4
+ argument-hint: "<target|status|accept|reject|send> [args]"
5
+ allowed-tools: [Bash]
6
+ ---
7
+
8
+ # Agent Relay Pair
9
+
10
+ Run the Agent Relay CLI with the user's arguments:
11
+
12
+ ```bash
13
+ agent-relay /pair $ARGUMENTS
14
+ ```
15
+
16
+ Use this for pair-session commands such as:
17
+
18
+ ```bash
19
+ agent-relay /pair codex "Debug flaky tests"
20
+ agent-relay /pair status
21
+ agent-relay /pair accept PAIR_ID
22
+ agent-relay /pair reject PAIR_ID
23
+ agent-relay /pair send PAIR_ID "What do you see?"
24
+ ```
25
+
26
+ Report the command output briefly. If the CLI cannot detect this session's agent id, rerun with `--agent AGENT_ID` or `--from AGENT_ID` using the Agent Relay ID shown in session context.
@@ -0,0 +1,24 @@
1
+ ---
2
+ name: send-claimable
3
+ description: Send a claimable Agent Relay work item so one matching agent can claim and handle it. Use when the user invokes /send-claimable or wants to enqueue work for another agent.
4
+ argument-hint: "<target> <message> [--subject TEXT] [--channel NAME]"
5
+ allowed-tools: [Bash]
6
+ ---
7
+
8
+ # Agent Relay Send Claimable
9
+
10
+ Run:
11
+
12
+ ```bash
13
+ agent-relay /send-claimable $ARGUMENTS
14
+ ```
15
+
16
+ Examples:
17
+
18
+ ```bash
19
+ agent-relay /send-claimable codex "Claim this and inspect the failing action"
20
+ agent-relay /send-claimable tag:backend "Fix the failing API test"
21
+ agent-relay /send-claimable cap:review "Review the migration patch"
22
+ ```
23
+
24
+ Report the sent claimable message id briefly.
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: status
3
+ description: Show Agent Relay status for this session, including relay health, current agent id, label, tags, readiness, and active pair state. Use when the user invokes /status or asks for relay connection status.
4
+ argument-hint: "[--json]"
5
+ allowed-tools: [Bash]
6
+ ---
7
+
8
+ # Agent Relay Status
9
+
10
+ Run:
11
+
12
+ ```bash
13
+ agent-relay /status $ARGUMENTS
14
+ ```
15
+
16
+ Summarize the current relay connection, agent identity, label, tags, and active pair state.
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: tags
3
+ description: List or update Agent Relay tags for the current session. Use when the user invokes /tags or asks to set, add, remove, or inspect relay tags.
4
+ argument-hint: "[TAG ...|--list|--add TAGS|--remove TAGS]"
5
+ allowed-tools: [Bash]
6
+ ---
7
+
8
+ # Agent Relay Tags
9
+
10
+ Run:
11
+
12
+ ```bash
13
+ agent-relay /tags $ARGUMENTS
14
+ ```
15
+
16
+ Examples:
17
+
18
+ ```bash
19
+ agent-relay /tags
20
+ agent-relay /tags backend tests urgent
21
+ agent-relay /tags --add backend,tests
22
+ agent-relay /tags --remove urgent
23
+ ```
24
+
25
+ Report the resulting tags briefly.
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
- return { agentId: identity.id, project: identity.project };
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
  }