agent-relay-runner 0.10.6 → 0.10.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.10.6",
3
+ "version": "0.10.8",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.10.6"
4
+ "version": "0.10.8"
5
5
  }
@@ -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,25 @@
1
+ ---
2
+ name: reply
3
+ description: Reply to an Agent Relay message by ID. Auto-routes to the sender and inherits channel context — no target needed. Use when the user invokes /reply or asks to reply to a specific relay message.
4
+ argument-hint: "<messageId> <message>"
5
+ allowed-tools: [Bash]
6
+ ---
7
+
8
+ # Agent Relay Reply
9
+
10
+ Run:
11
+
12
+ ```bash
13
+ agent-relay /reply $ARGUMENTS
14
+ ```
15
+
16
+ The server auto-routes the reply to the original sender and inherits the channel (Telegram, Slack, etc.) if applicable. No target or channel ID needed.
17
+
18
+ Examples:
19
+
20
+ ```bash
21
+ agent-relay /reply 206 "Sounds good, I'll take a look"
22
+ agent-relay /reply 42 "Done — the fix is in commit abc123"
23
+ ```
24
+
25
+ Report the sent message id and resolved target briefly.
@@ -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.
@@ -0,0 +1,15 @@
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
+ ---
6
+
7
+ # Agent Relay Disconnect
8
+
9
+ Run:
10
+
11
+ ```bash
12
+ agent-relay /disconnect $ARGUMENTS
13
+ ```
14
+
15
+ If no pair id is supplied, the CLI ends the active pair for this session. Report the result briefly.
@@ -0,0 +1,22 @@
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
+ ---
6
+
7
+ # Agent Relay Label
8
+
9
+ Run:
10
+
11
+ ```bash
12
+ agent-relay /label $ARGUMENTS
13
+ ```
14
+
15
+ Examples:
16
+
17
+ ```bash
18
+ agent-relay /label backend-fixer
19
+ agent-relay /label --clear
20
+ ```
21
+
22
+ 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, policy, 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
+ ---
6
+
7
+ # Agent Relay Message
8
+
9
+ Run:
10
+
11
+ ```bash
12
+ agent-relay /message $ARGUMENTS
13
+ ```
14
+
15
+ Examples:
16
+
17
+ ```bash
18
+ agent-relay /message codex "Can you look at that failing action?"
19
+ agent-relay /message tag:backend "Does anyone see the regression?"
20
+ agent-relay /message policy:reviewer "Review this when you come online"
21
+ agent-relay /message broadcast "Standup in five minutes"
22
+ ```
23
+
24
+ Report the sent message id briefly.
@@ -0,0 +1,25 @@
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
+ ---
6
+
7
+ # Agent Relay Pair
8
+
9
+ Run the Agent Relay CLI with the user's arguments:
10
+
11
+ ```bash
12
+ agent-relay /pair $ARGUMENTS
13
+ ```
14
+
15
+ Use this for pair-session commands such as:
16
+
17
+ ```bash
18
+ agent-relay /pair codex "Debug flaky tests"
19
+ agent-relay /pair status
20
+ agent-relay /pair accept PAIR_ID
21
+ agent-relay /pair reject PAIR_ID
22
+ agent-relay /pair send PAIR_ID "What do you see?"
23
+ ```
24
+
25
+ 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: reply
3
+ description: Reply to an Agent Relay message by ID. Auto-routes to the sender and inherits channel context, so no target is needed. Use when the user invokes /reply or asks to reply to a specific relay message.
4
+ argument-hint: "<messageId> <message>"
5
+ ---
6
+
7
+ # Agent Relay Reply
8
+
9
+ Run:
10
+
11
+ ```bash
12
+ agent-relay /reply $ARGUMENTS
13
+ ```
14
+
15
+ The server auto-routes the reply to the original sender and inherits the channel if applicable. No target or channel ID is needed.
16
+
17
+ Examples:
18
+
19
+ ```bash
20
+ agent-relay /reply 206 "Sounds good, I'll take a look"
21
+ agent-relay /reply 42 "Done; the fix is in commit abc123"
22
+ ```
23
+
24
+ Report the sent message id and resolved target briefly.
@@ -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
+ ---
6
+
7
+ # Agent Relay Send Claimable
8
+
9
+ Run:
10
+
11
+ ```bash
12
+ agent-relay /send-claimable $ARGUMENTS
13
+ ```
14
+
15
+ Examples:
16
+
17
+ ```bash
18
+ agent-relay /send-claimable codex "Claim this and inspect the failing action"
19
+ agent-relay /send-claimable tag:backend "Fix the failing API test"
20
+ agent-relay /send-claimable cap:review "Review the migration patch"
21
+ agent-relay /send-claimable policy:reviewer "Review this when the managed reviewer starts"
22
+ ```
23
+
24
+ Report the sent claimable message id briefly.
@@ -0,0 +1,15 @@
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
+ ---
6
+
7
+ # Agent Relay Status
8
+
9
+ Run:
10
+
11
+ ```bash
12
+ agent-relay /status $ARGUMENTS
13
+ ```
14
+
15
+ Summarize the current relay connection, agent identity, label, tags, and active pair state.
@@ -0,0 +1,24 @@
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
+ ---
6
+
7
+ # Agent Relay Tags
8
+
9
+ Run:
10
+
11
+ ```bash
12
+ agent-relay /tags $ARGUMENTS
13
+ ```
14
+
15
+ Examples:
16
+
17
+ ```bash
18
+ agent-relay /tags
19
+ agent-relay /tags backend tests urgent
20
+ agent-relay /tags --add backend,tests
21
+ agent-relay /tags --remove urgent
22
+ ```
23
+
24
+ Report the resulting tags briefly.
package/src/adapter.ts CHANGED
@@ -26,6 +26,7 @@ export interface RunnerSpawnConfig {
26
26
  headless: boolean;
27
27
  approvalMode: string;
28
28
  label?: string;
29
+ rig?: string;
29
30
  prompt?: string;
30
31
  providerArgs: string[];
31
32
  providerConfig: ProviderConfig;
@@ -5,6 +5,7 @@ import type { ManagedProcess, ProviderAdapter, ProviderConfig, RunnerSpawnConfig
5
5
  export class ClaudeAdapter implements ProviderAdapter {
6
6
  readonly provider = "claude";
7
7
  private statusCb: (status: SemanticStatus) => void = () => {};
8
+ private tmuxWatcher?: Timer;
8
9
 
9
10
  onStatusChange(cb: (status: SemanticStatus) => void): void {
10
11
  this.statusCb = cb;
@@ -12,6 +13,11 @@ export class ClaudeAdapter implements ProviderAdapter {
12
13
 
13
14
  async spawn(config: RunnerSpawnConfig): Promise<ManagedProcess> {
14
15
  const args = this.buildSpawnArgs(config, config.providerConfig as ProviderConfig);
16
+
17
+ if (config.headless) {
18
+ return this.spawnHeadless(config, args);
19
+ }
20
+
15
21
  const proc = Bun.spawn([args.command, ...args.args], {
16
22
  cwd: args.cwd,
17
23
  env: args.env,
@@ -24,6 +30,11 @@ export class ClaudeAdapter implements ProviderAdapter {
24
30
  }
25
31
 
26
32
  async shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
33
+ const tmuxSession = process.meta?.tmuxSession as string | undefined;
34
+ if (tmuxSession) {
35
+ await this.shutdownTmux(tmuxSession, opts);
36
+ return;
37
+ }
27
38
  await terminateProcess(process, opts);
28
39
  }
29
40
 
@@ -36,7 +47,10 @@ export class ClaudeAdapter implements ProviderAdapter {
36
47
  buildSpawnArgs(config: RunnerSpawnConfig, providerConfig: ProviderConfig): SpawnArgs {
37
48
  const pluginRoot = resolve(import.meta.dir, "../../plugins/claude");
38
49
  const pluginDirs = [...new Set([pluginRoot, ...providerConfig.pluginDirs])];
50
+ const isClaudeRig = /claude-rig/.test(providerConfig.command);
51
+ const rigPrefix = isClaudeRig && config.rig ? ["launch", config.rig] : [];
39
52
  const args = [
53
+ ...rigPrefix,
40
54
  ...pluginDirs.flatMap((dir) => ["--plugin-dir", dir]),
41
55
  ...providerConfig.defaultArgs,
42
56
  ...config.providerArgs,
@@ -52,6 +66,117 @@ export class ClaudeAdapter implements ProviderAdapter {
52
66
  },
53
67
  };
54
68
  }
69
+
70
+ buildTmuxArgs(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): { sessionName: string; args: string[] } {
71
+ const sessionName = tmuxSessionName(config.providerConfig.headless.tmuxPrefix, config.instanceId, config.label);
72
+ const shellCmd = [spawnArgs.command, ...spawnArgs.args].map(shellQuote).join(" ");
73
+ const tmuxArgs = ["new-session", "-d", "-s", sessionName, "-x", "200", "-y", "50"];
74
+
75
+ const envKeys = tmuxEnvKeys(spawnArgs.env, config.providerConfig.env);
76
+ for (const key of envKeys) {
77
+ if (spawnArgs.env[key] !== undefined) {
78
+ tmuxArgs.push("-e", `${key}=${spawnArgs.env[key]}`);
79
+ }
80
+ }
81
+
82
+ tmuxArgs.push("-c", spawnArgs.cwd, shellCmd);
83
+ return { sessionName, args: tmuxArgs };
84
+ }
85
+
86
+ private async spawnHeadless(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): Promise<ManagedProcess> {
87
+ const { sessionName, args: tmuxArgs } = this.buildTmuxArgs(config, spawnArgs);
88
+
89
+ Bun.spawnSync(["tmux", "kill-session", "-t", sessionName], {
90
+ stdin: "ignore", stdout: "ignore", stderr: "ignore",
91
+ });
92
+
93
+ const result = Bun.spawnSync(["tmux", ...tmuxArgs], {
94
+ stdin: "ignore",
95
+ stdout: "pipe",
96
+ stderr: "pipe",
97
+ });
98
+
99
+ if (result.exitCode !== 0) {
100
+ const stderr = result.stderr.toString().trim();
101
+ throw new Error(`tmux session creation failed: ${stderr || `exit code ${result.exitCode}`}`);
102
+ }
103
+
104
+ this.watchTmuxSession(sessionName);
105
+
106
+ return {
107
+ pid: undefined,
108
+ process: undefined,
109
+ meta: {
110
+ monitor: config.monitor,
111
+ tmuxSession: sessionName,
112
+ },
113
+ };
114
+ }
115
+
116
+ private watchTmuxSession(sessionName: string): void {
117
+ if (this.tmuxWatcher) clearInterval(this.tmuxWatcher);
118
+ this.tmuxWatcher = setInterval(() => {
119
+ if (!tmuxHasSession(sessionName)) {
120
+ clearInterval(this.tmuxWatcher!);
121
+ this.tmuxWatcher = undefined;
122
+ this.statusCb("offline");
123
+ }
124
+ }, 2000);
125
+ }
126
+
127
+ private async shutdownTmux(sessionName: string, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
128
+ if (this.tmuxWatcher) {
129
+ clearInterval(this.tmuxWatcher);
130
+ this.tmuxWatcher = undefined;
131
+ }
132
+
133
+ if (opts.graceful && tmuxHasSession(sessionName)) {
134
+ Bun.spawnSync(["tmux", "send-keys", "-t", sessionName, "C-c"], {
135
+ stdin: "ignore", stdout: "ignore", stderr: "ignore",
136
+ });
137
+
138
+ const deadline = Date.now() + opts.timeoutMs;
139
+ while (Date.now() < deadline) {
140
+ if (!tmuxHasSession(sessionName)) return;
141
+ await Bun.sleep(500);
142
+ }
143
+ }
144
+
145
+ Bun.spawnSync(["tmux", "kill-session", "-t", sessionName], {
146
+ stdin: "ignore", stdout: "ignore", stderr: "ignore",
147
+ });
148
+ }
149
+ }
150
+
151
+ export function tmuxSessionName(prefix: string, instanceId: string, label?: string): string {
152
+ if (label) return `${prefix}-${label.replace(/[^a-zA-Z0-9._-]/g, "-").toLowerCase()}`;
153
+ return `${prefix}-${instanceId.slice(0, 8)}`;
154
+ }
155
+
156
+ export function tmuxHasSession(sessionName: string): boolean {
157
+ const result = Bun.spawnSync(["tmux", "has-session", "-t", sessionName], {
158
+ stdin: "ignore", stdout: "ignore", stderr: "ignore",
159
+ });
160
+ return result.exitCode === 0;
161
+ }
162
+
163
+ export function tmuxEnvKeys(env: Record<string, string>, providerEnv: Record<string, string>): string[] {
164
+ const keys = new Set<string>();
165
+ for (const key of Object.keys(env)) {
166
+ if (key.startsWith("AGENT_RELAY_") || key.startsWith("CLAUDE_")) {
167
+ keys.add(key);
168
+ }
169
+ }
170
+ for (const key of Object.keys(providerEnv)) {
171
+ keys.add(key);
172
+ }
173
+ return [...keys].sort();
174
+ }
175
+
176
+ export function shellQuote(arg: string): string {
177
+ if (arg.length === 0) return "''";
178
+ if (/^[a-zA-Z0-9_./:@=+,-]+$/.test(arg)) return arg;
179
+ return `'${arg.replace(/'/g, "'\\''")}'`;
55
180
  }
56
181
 
57
182
  async function terminateProcess(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
@@ -1,3 +1,5 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { basename, join, resolve } from "node:path";
1
3
  import type { Message } from "agent-relay-sdk";
2
4
  import { providerMessageText, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type RunnerSpawnConfig, type SemanticStatus, type SpawnArgs } from "../adapter";
3
5
  import { CodexAppClient, type ClientEvent } from "./codex-client";
@@ -27,8 +29,8 @@ export class CodexAdapter implements ProviderAdapter {
27
29
  client.onEvent((event) => this.handleCodexEvent(event));
28
30
  }
29
31
 
30
- const tui = !config.headless && config.providerConfig.command === "codex"
31
- ? Bun.spawn(["codex", "--remote", appServerUrl, ...config.providerConfig.defaultArgs, ...config.providerArgs], {
32
+ const tui = !config.headless && isCodexCliCommand(config.providerConfig.command)
33
+ ? Bun.spawn([config.providerConfig.command, "--remote", appServerUrl, ...bundledSkillConfigArgs(), ...config.providerConfig.defaultArgs, ...config.providerArgs], {
32
34
  cwd: config.cwd,
33
35
  env: args.env,
34
36
  stdin: "inherit",
@@ -62,8 +64,14 @@ export class CodexAdapter implements ProviderAdapter {
62
64
 
63
65
  buildSpawnArgs(config: RunnerSpawnConfig, providerConfig: ProviderConfig): SpawnArgs {
64
66
  const appServerUrl = String(config.appServerUrl || process.env.CODEX_APP_SERVER_URL || `ws://127.0.0.1:${config.controlPort + 1000}`);
65
- const args = providerConfig.command === "codex"
66
- ? ["app-server", "--listen", appServerUrl]
67
+ const args = isCodexCliCommand(providerConfig.command)
68
+ ? [
69
+ "app-server",
70
+ ...codexAppServerConfigArgs(providerConfig.defaultArgs, config.providerArgs),
71
+ ...bundledSkillConfigArgs(),
72
+ "--listen",
73
+ appServerUrl,
74
+ ]
67
75
  : [...providerConfig.defaultArgs, ...config.providerArgs];
68
76
  return {
69
77
  command: providerConfig.command,
@@ -90,6 +98,49 @@ export class CodexAdapter implements ProviderAdapter {
90
98
  }
91
99
  }
92
100
 
101
+ export function isCodexCliCommand(command: string): boolean {
102
+ return basename(command) === "codex";
103
+ }
104
+
105
+ export function bundledCodexSkillDirs(baseDir = resolve(import.meta.dir, "../../plugins/codex/skills")): string[] {
106
+ if (!existsSync(baseDir)) return [];
107
+ return readdirSync(baseDir, { withFileTypes: true })
108
+ .filter((entry) => entry.isDirectory() && existsSync(join(baseDir, entry.name, "SKILL.md")))
109
+ .map((entry) => join(baseDir, entry.name))
110
+ .sort();
111
+ }
112
+
113
+ export function bundledSkillConfigArgs(skillDirs = bundledCodexSkillDirs()): string[] {
114
+ if (skillDirs.length === 0) return [];
115
+ const skillsConfig = skillDirs
116
+ .map((dir) => `{path=${tomlString(dir)},enabled=true}`)
117
+ .join(",");
118
+ return ["-c", `skills.config=[${skillsConfig}]`];
119
+ }
120
+
121
+ export function codexAppServerConfigArgs(...argLists: string[][]): string[] {
122
+ const result: string[] = [];
123
+ for (const args of argLists) {
124
+ for (let i = 0; i < args.length; i++) {
125
+ const arg = args[i];
126
+ if (arg === "-c" || arg === "--config" || arg === "--enable" || arg === "--disable") {
127
+ const value = args[i + 1];
128
+ if (value !== undefined) {
129
+ result.push(arg, value);
130
+ i++;
131
+ }
132
+ } else if (arg?.startsWith("--config=") || arg?.startsWith("--enable=") || arg?.startsWith("--disable=")) {
133
+ result.push(arg);
134
+ }
135
+ }
136
+ }
137
+ return result;
138
+ }
139
+
140
+ function tomlString(value: string): string {
141
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
142
+ }
143
+
93
144
  async function connectWithRetry(client: CodexAppClient, attempts = 40): Promise<void> {
94
145
  let lastError: unknown;
95
146
  for (let i = 0; i < attempts; i++) {
package/src/config.ts CHANGED
@@ -24,7 +24,7 @@ function providersDir(home = agentRelayHome()): string {
24
24
  }
25
25
 
26
26
  export function defaultProviderConfig(provider: string): ProviderConfig {
27
- const command = provider === "claude" ? "claude-rig" : provider;
27
+ const command = provider === "claude" ? "claude" : provider;
28
28
  return {
29
29
  command,
30
30
  defaultArgs: provider === "claude" ? ["--dangerously-skip-permissions"] : [],
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { basename } from "node:path";
3
+ import { isatty } from "node:tty";
3
4
  import { ClaudeAdapter } from "./adapters/claude";
4
5
  import { CodexAdapter } from "./adapters/codex";
5
6
  import { AgentRunner } from "./runner";
@@ -8,13 +9,17 @@ import { loadGlobalConfig, loadProviderConfig, resolveCwd, runnerId } from "./co
8
9
  interface CliOptions {
9
10
  provider: "claude" | "codex";
10
11
  headless: boolean;
12
+ interactive: boolean;
11
13
  cwd?: string;
14
+ rig?: string;
12
15
  relayUrl?: string;
13
16
  token?: string;
14
17
  approvalMode?: string;
15
18
  label?: string;
16
19
  agentId?: string;
17
20
  prompt?: string;
21
+ tags: string[];
22
+ caps: string[];
18
23
  providerArgs: string[];
19
24
  }
20
25
 
@@ -25,6 +30,10 @@ export async function main(argv = process.argv): Promise<void> {
25
30
  const cwd = resolveCwd(opts.cwd, globalConfig.defaultCwd);
26
31
  const id = runnerId(opts.provider, cwd, opts.label);
27
32
  const adapter = opts.provider === "claude" ? new ClaudeAdapter() : new CodexAdapter();
33
+
34
+ let exitResolve: (code: number) => void;
35
+ const exitPromise = new Promise<number>((resolve) => { exitResolve = resolve; });
36
+
28
37
  const runner = new AgentRunner({
29
38
  provider: opts.provider,
30
39
  runnerId: id,
@@ -36,17 +45,28 @@ export async function main(argv = process.argv): Promise<void> {
36
45
  headless: opts.headless,
37
46
  approvalMode: opts.approvalMode ?? providerConfig.defaultApprovalMode,
38
47
  label: opts.label,
48
+ rig: opts.rig,
39
49
  prompt: opts.prompt,
50
+ tags: opts.tags,
51
+ capabilities: opts.caps,
40
52
  providerArgs: opts.providerArgs,
53
+ policyName: process.env.AGENT_RELAY_POLICY,
54
+ spawnRequestId: process.env.AGENT_RELAY_SPAWN_REQUEST_ID,
55
+ tmuxSession: process.env.AGENT_RELAY_TMUX_SESSION,
56
+ startedAt: Date.now(),
41
57
  providerConfig,
42
58
  adapter,
59
+ onProviderExit: (code) => exitResolve(code ?? 1),
43
60
  });
44
61
  await runner.run();
45
- await waitForShutdown(() => runner.stop());
62
+
63
+ const code = await Promise.race([exitPromise, signalPromise()]);
64
+ await runner.stop();
65
+ process.exit(code);
46
66
  }
47
67
 
48
68
  function parseArgs(argv: string[]): CliOptions {
49
- const bin = basename(argv[1] || "");
69
+ const bin = [argv[1], process.env._].map((s) => basename(s || "")).join(" ");
50
70
  let provider: "claude" | "codex" = bin.includes("claude") ? "claude" : "codex";
51
71
  const relayArgs = argv.slice(2);
52
72
  const providerSep = relayArgs.indexOf("--");
@@ -54,24 +74,30 @@ function parseArgs(argv: string[]): CliOptions {
54
74
  const providerArgs = providerSep >= 0 ? relayArgs.slice(providerSep + 1) : [];
55
75
  let headless = false;
56
76
  let cwd: string | undefined;
77
+ let rig: string | undefined;
57
78
  let relayUrl: string | undefined;
58
79
  let token: string | undefined;
59
80
  let approvalMode: string | undefined;
60
81
  let label: string | undefined;
61
82
  let agentId: string | undefined;
62
83
  let prompt: string | undefined;
84
+ let tags: string[] = [];
85
+ let caps: string[] = [];
63
86
 
64
87
  for (let i = 0; i < ownArgs.length; i++) {
65
88
  const arg = ownArgs[i];
66
89
  if (arg === "claude" || arg === "codex") provider = arg;
67
90
  else if (arg === "--headless") headless = true;
68
91
  else if (arg === "--cwd" && ownArgs[i + 1]) cwd = ownArgs[++i];
92
+ else if (arg === "--rig" && ownArgs[i + 1]) rig = ownArgs[++i];
69
93
  else if (arg === "--relay-url" && ownArgs[i + 1]) relayUrl = ownArgs[++i];
70
94
  else if (arg === "--token" && ownArgs[i + 1]) token = ownArgs[++i];
71
95
  else if ((arg === "--approval" || arg === "--approval-mode") && ownArgs[i + 1]) approvalMode = ownArgs[++i];
72
96
  else if (arg === "--label" && ownArgs[i + 1]) label = ownArgs[++i];
73
97
  else if (arg === "--agent-id" && ownArgs[i + 1]) agentId = ownArgs[++i];
74
98
  else if (arg === "--prompt" && ownArgs[i + 1]) prompt = ownArgs[++i];
99
+ else if (arg === "--tags" && ownArgs[i + 1]) tags = csvSplit(ownArgs[++i]!);
100
+ else if (arg === "--caps" && ownArgs[i + 1]) caps = csvSplit(ownArgs[++i]!);
75
101
  else if (arg === "--help" || arg === "-h") {
76
102
  printHelp(provider);
77
103
  process.exit(0);
@@ -80,24 +106,23 @@ function parseArgs(argv: string[]): CliOptions {
80
106
  }
81
107
  }
82
108
 
83
- return { provider, headless, cwd, relayUrl, token, approvalMode, label, agentId, prompt, providerArgs };
109
+ const interactive = !headless && isatty(0);
110
+ return { provider, headless, interactive, cwd, rig, relayUrl, token, approvalMode, label, agentId, prompt, tags, caps, providerArgs };
111
+ }
112
+
113
+ function csvSplit(value: string): string[] {
114
+ return value.split(",").map((s) => s.trim()).filter(Boolean);
84
115
  }
85
116
 
86
117
  function printHelp(provider: string): void {
87
- console.log(`${provider}-relay [--headless] [--cwd PATH] [--relay-url URL] [--approval MODE] [--label NAME] [--agent-id ID] [-- provider-args...]`);
118
+ console.log(`${provider}-relay [--headless] [--rig NAME] [--cwd PATH] [--relay-url URL] [--approval MODE] [--label NAME] [--agent-id ID] [--tags a,b] [--caps x,y] [-- provider-args...]`);
88
119
  }
89
120
 
90
- async function waitForShutdown(stop: () => Promise<void>): Promise<void> {
91
- let stopping = false;
92
- const shutdown = async () => {
93
- if (stopping) return;
94
- stopping = true;
95
- await stop();
96
- process.exit(0);
97
- };
98
- process.on("SIGINT", () => void shutdown());
99
- process.on("SIGTERM", () => void shutdown());
100
- await new Promise(() => {});
121
+ function signalPromise(): Promise<number> {
122
+ return new Promise<number>((resolve) => {
123
+ process.on("SIGINT", () => resolve(130));
124
+ process.on("SIGTERM", () => resolve(143));
125
+ });
101
126
  }
102
127
 
103
128
  if (import.meta.main) {
package/src/runner.ts CHANGED
@@ -16,10 +16,18 @@ interface RunnerOptions {
16
16
  headless: boolean;
17
17
  approvalMode: string;
18
18
  label?: string;
19
+ rig?: string;
19
20
  prompt?: string;
21
+ tags: string[];
22
+ capabilities: string[];
20
23
  providerArgs: string[];
24
+ policyName?: string;
25
+ spawnRequestId?: string;
26
+ tmuxSession?: string;
27
+ startedAt: number;
21
28
  providerConfig: ProviderConfig;
22
29
  adapter: ProviderAdapter;
30
+ onProviderExit?: (code: number | null) => void;
23
31
  }
24
32
 
25
33
  export class AgentRunner {
@@ -30,6 +38,7 @@ export class AgentRunner {
30
38
  private control?: ControlServer;
31
39
  private process?: ManagedProcess;
32
40
  private stopped = false;
41
+ private exitCommandInProgress = false;
33
42
  private delivering = false;
34
43
  private readonly pendingMessages = new Map<number, Message>();
35
44
 
@@ -47,6 +56,8 @@ export class AgentRunner {
47
56
  capabilities: [
48
57
  ...new Set([
49
58
  ...options.providerConfig.defaultCapabilities,
59
+ ...options.capabilities,
60
+ ...csvTags(process.env.AGENT_RELAY_CAPS),
50
61
  "lifecycle.shutdown.hard",
51
62
  "lifecycle.restart.hard",
52
63
  "lifecycle.status.semantic",
@@ -56,10 +67,14 @@ export class AgentRunner {
56
67
  "capabilities.report",
57
68
  ]),
58
69
  ],
59
- tags: [...new Set([options.provider, ...csvTags(process.env.AGENT_RELAY_TAGS), ...options.providerConfig.defaultTags, ...(options.headless ? ["headless"] : [])])],
70
+ tags: [...new Set([options.provider, ...csvTags(process.env.AGENT_RELAY_TAGS), ...options.tags, ...options.providerConfig.defaultTags, ...(options.headless ? ["headless"] : [])])],
60
71
  meta: {
61
72
  provider: options.provider,
62
73
  runnerId: options.runnerId,
74
+ startedAt: options.startedAt,
75
+ tmuxSession: options.tmuxSession ?? null,
76
+ policyName: options.policyName ?? null,
77
+ spawnRequestId: options.spawnRequestId ?? null,
63
78
  runnerManaged: true,
64
79
  cwd: options.cwd,
65
80
  approvalMode: options.approvalMode,
@@ -77,7 +92,10 @@ export class AgentRunner {
77
92
 
78
93
  async run(): Promise<void> {
79
94
  this.control = startControlServer({ onStatus: (status) => this.setProviderStatus(status) });
80
- this.options.adapter.onStatusChange((status) => this.setProviderStatus(status));
95
+ this.options.adapter.onStatusChange((status) => {
96
+ this.setProviderStatus(status);
97
+ if (runnerShouldResolveProviderExit(status, this.exitCommandInProgress)) this.options.onProviderExit?.(status === "offline" ? 0 : 1);
98
+ });
81
99
  this.bus.on("message.new", (message) => this.enqueueMessage(message as Message));
82
100
  this.bus.on("command", (type, params, commandId, command) => {
83
101
  void this.handleCommand(type, params, commandId, command);
@@ -123,6 +141,7 @@ export class AgentRunner {
123
141
  headless: this.options.headless,
124
142
  approvalMode: this.options.approvalMode,
125
143
  ...(this.options.label ? { label: this.options.label } : {}),
144
+ ...(this.options.rig ? { rig: this.options.rig } : {}),
126
145
  ...(this.options.prompt ? { prompt: this.options.prompt } : {}),
127
146
  providerArgs: this.options.providerArgs,
128
147
  providerConfig: this.options.providerConfig,
@@ -173,6 +192,7 @@ export class AgentRunner {
173
192
  if (type !== "agent.shutdown" && type !== "agent.restart" && type !== "agent.kill") return;
174
193
 
175
194
  const exitAfterCommand = type !== "agent.restart";
195
+ if (exitAfterCommand) this.exitCommandInProgress = true;
176
196
  this.claims.startClaim("command", commandId);
177
197
  this.publishStatus();
178
198
  await this.updateCommand(commandId, "accepted");
@@ -180,7 +200,14 @@ export class AgentRunner {
180
200
  try {
181
201
  if (type === "agent.restart") await this.restartProvider();
182
202
  else await this.shutdownProvider(type === "agent.kill");
183
- await this.updateCommand(commandId, "succeeded", { action: type, agentId: this.agentId, runnerId: this.options.runnerId });
203
+ await this.updateCommand(commandId, "succeeded", {
204
+ action: type,
205
+ agentId: this.agentId,
206
+ runnerId: this.options.runnerId,
207
+ policyName: this.options.policyName,
208
+ spawnRequestId: this.options.spawnRequestId,
209
+ reason: typeof params.reason === "string" ? params.reason : undefined,
210
+ });
184
211
  } catch (error) {
185
212
  await this.updateCommand(commandId, "failed", undefined, error instanceof Error ? error.message : String(error)).catch(() => {});
186
213
  } finally {
@@ -251,6 +278,10 @@ export class AgentRunner {
251
278
  ready: agentStatus !== "offline" && !this.stopped,
252
279
  meta: {
253
280
  runnerId: this.options.runnerId,
281
+ startedAt: this.options.startedAt,
282
+ tmuxSession: this.options.tmuxSession ?? null,
283
+ policyName: this.options.policyName ?? null,
284
+ spawnRequestId: this.options.spawnRequestId ?? null,
254
285
  ...(status === "error" ? { terminalStatus: "error" } : {}),
255
286
  busyReasons: this.claims.reasons(),
256
287
  transport: this.bus.transportState,
@@ -259,20 +290,41 @@ export class AgentRunner {
259
290
  }
260
291
 
261
292
  private matchesMessage(message: Message): boolean {
262
- if (message.to === this.agentId || message.to === "broadcast") return true;
263
- if (message.to === `label:${this.options.label}`) return true;
264
- if (message.to.startsWith("tag:")) {
265
- const tag = message.to.slice("tag:".length);
266
- return this.options.providerConfig.defaultTags.includes(tag) || tag === this.options.provider;
267
- }
268
- if (message.to.startsWith("cap:")) {
269
- const cap = message.to.slice("cap:".length);
270
- return this.options.providerConfig.defaultCapabilities.includes(cap);
271
- }
272
- return false;
293
+ return runnerMessageMatches(message, {
294
+ agentId: this.agentId,
295
+ label: this.options.label,
296
+ provider: this.options.provider,
297
+ tags: this.options.tags,
298
+ capabilities: this.options.capabilities,
299
+ defaultTags: this.options.providerConfig.defaultTags,
300
+ defaultCapabilities: this.options.providerConfig.defaultCapabilities,
301
+ });
273
302
  }
274
303
  }
275
304
 
305
+ export function runnerMessageMatches(message: Pick<Message, "to" | "resolvedToAgent">, target: {
306
+ agentId: string;
307
+ label?: string;
308
+ provider: string;
309
+ tags: string[];
310
+ capabilities: string[];
311
+ defaultTags: string[];
312
+ defaultCapabilities: string[];
313
+ }): boolean {
314
+ if (message.resolvedToAgent === target.agentId) return true;
315
+ if (message.to === target.agentId || message.to === "broadcast") return true;
316
+ if (target.label && message.to === `label:${target.label}`) return true;
317
+ if (message.to.startsWith("tag:")) {
318
+ const tag = message.to.slice("tag:".length);
319
+ return target.tags.includes(tag) || target.defaultTags.includes(tag) || tag === target.provider;
320
+ }
321
+ if (message.to.startsWith("cap:")) {
322
+ const cap = message.to.slice("cap:".length);
323
+ return target.capabilities.includes(cap) || target.defaultCapabilities.includes(cap);
324
+ }
325
+ return false;
326
+ }
327
+
276
328
  function csvTags(raw: string | undefined): string[] {
277
329
  return (raw || "").split(",").map((tag) => tag.trim()).filter(Boolean);
278
330
  }
@@ -294,6 +346,10 @@ export function runnerBusErrorAction(code: string, stopped: boolean): "ignore" |
294
346
  return "log";
295
347
  }
296
348
 
349
+ export function runnerShouldResolveProviderExit(status: SemanticStatus, exitCommandInProgress: boolean): boolean {
350
+ return !exitCommandInProgress && (status === "offline" || status === "error");
351
+ }
352
+
297
353
  function lifecycleCapabilities(): Record<string, true> {
298
354
  return {
299
355
  shutdownHard: true,