agent-relay-runner 0.10.7 → 0.10.9

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.7",
3
+ "version": "0.10.9",
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.7"
4
+ "version": "0.10.9"
5
5
  }
@@ -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.
@@ -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
 
@@ -55,6 +66,117 @@ export class ClaudeAdapter implements ProviderAdapter {
55
66
  },
56
67
  };
57
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, "'\\''")}'`;
58
180
  }
59
181
 
60
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/index.ts CHANGED
@@ -18,6 +18,8 @@ interface CliOptions {
18
18
  label?: string;
19
19
  agentId?: string;
20
20
  prompt?: string;
21
+ tags: string[];
22
+ caps: string[];
21
23
  providerArgs: string[];
22
24
  }
23
25
 
@@ -29,10 +31,8 @@ export async function main(argv = process.argv): Promise<void> {
29
31
  const id = runnerId(opts.provider, cwd, opts.label);
30
32
  const adapter = opts.provider === "claude" ? new ClaudeAdapter() : new CodexAdapter();
31
33
 
32
- let exitResolve: ((code: number) => void) | undefined;
33
- const exitPromise = opts.interactive
34
- ? new Promise<number>((resolve) => { exitResolve = resolve; })
35
- : undefined;
34
+ let exitResolve: (code: number) => void;
35
+ const exitPromise = new Promise<number>((resolve) => { exitResolve = resolve; });
36
36
 
37
37
  const runner = new AgentRunner({
38
38
  provider: opts.provider,
@@ -47,20 +47,22 @@ export async function main(argv = process.argv): Promise<void> {
47
47
  label: opts.label,
48
48
  rig: opts.rig,
49
49
  prompt: opts.prompt,
50
+ tags: opts.tags,
51
+ capabilities: opts.caps,
50
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(),
51
57
  providerConfig,
52
58
  adapter,
53
- onProviderExit: exitResolve ? (code) => exitResolve!(code ?? 1) : undefined,
59
+ onProviderExit: (code) => exitResolve(code ?? 1),
54
60
  });
55
61
  await runner.run();
56
62
 
57
- if (opts.interactive && exitPromise) {
58
- const code = await Promise.race([exitPromise, signalPromise()]);
59
- await runner.stop();
60
- process.exit(code);
61
- } else {
62
- await waitForShutdown(() => runner.stop());
63
- }
63
+ const code = await Promise.race([exitPromise, signalPromise()]);
64
+ await runner.stop();
65
+ process.exit(code);
64
66
  }
65
67
 
66
68
  function parseArgs(argv: string[]): CliOptions {
@@ -79,6 +81,8 @@ function parseArgs(argv: string[]): CliOptions {
79
81
  let label: string | undefined;
80
82
  let agentId: string | undefined;
81
83
  let prompt: string | undefined;
84
+ let tags: string[] = [];
85
+ let caps: string[] = [];
82
86
 
83
87
  for (let i = 0; i < ownArgs.length; i++) {
84
88
  const arg = ownArgs[i];
@@ -92,6 +96,8 @@ function parseArgs(argv: string[]): CliOptions {
92
96
  else if (arg === "--label" && ownArgs[i + 1]) label = ownArgs[++i];
93
97
  else if (arg === "--agent-id" && ownArgs[i + 1]) agentId = ownArgs[++i];
94
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]!);
95
101
  else if (arg === "--help" || arg === "-h") {
96
102
  printHelp(provider);
97
103
  process.exit(0);
@@ -101,11 +107,15 @@ function parseArgs(argv: string[]): CliOptions {
101
107
  }
102
108
 
103
109
  const interactive = !headless && isatty(0);
104
- return { provider, headless, interactive, cwd, rig, relayUrl, token, approvalMode, label, agentId, prompt, providerArgs };
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);
105
115
  }
106
116
 
107
117
  function printHelp(provider: string): void {
108
- console.log(`${provider}-relay [--headless] [--rig NAME] [--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...]`);
109
119
  }
110
120
 
111
121
  function signalPromise(): Promise<number> {
@@ -115,19 +125,6 @@ function signalPromise(): Promise<number> {
115
125
  });
116
126
  }
117
127
 
118
- async function waitForShutdown(stop: () => Promise<void>): Promise<void> {
119
- let stopping = false;
120
- const shutdown = async () => {
121
- if (stopping) return;
122
- stopping = true;
123
- await stop();
124
- process.exit(0);
125
- };
126
- process.on("SIGINT", () => void shutdown());
127
- process.on("SIGTERM", () => void shutdown());
128
- await new Promise(() => {});
129
- }
130
-
131
128
  if (import.meta.main) {
132
129
  await main();
133
130
  }
package/src/runner.ts CHANGED
@@ -18,7 +18,13 @@ interface RunnerOptions {
18
18
  label?: string;
19
19
  rig?: string;
20
20
  prompt?: string;
21
+ tags: string[];
22
+ capabilities: string[];
21
23
  providerArgs: string[];
24
+ policyName?: string;
25
+ spawnRequestId?: string;
26
+ tmuxSession?: string;
27
+ startedAt: number;
22
28
  providerConfig: ProviderConfig;
23
29
  adapter: ProviderAdapter;
24
30
  onProviderExit?: (code: number | null) => void;
@@ -32,6 +38,7 @@ export class AgentRunner {
32
38
  private control?: ControlServer;
33
39
  private process?: ManagedProcess;
34
40
  private stopped = false;
41
+ private exitCommandInProgress = false;
35
42
  private delivering = false;
36
43
  private readonly pendingMessages = new Map<number, Message>();
37
44
 
@@ -49,6 +56,8 @@ export class AgentRunner {
49
56
  capabilities: [
50
57
  ...new Set([
51
58
  ...options.providerConfig.defaultCapabilities,
59
+ ...options.capabilities,
60
+ ...csvTags(process.env.AGENT_RELAY_CAPS),
52
61
  "lifecycle.shutdown.hard",
53
62
  "lifecycle.restart.hard",
54
63
  "lifecycle.status.semantic",
@@ -58,10 +67,14 @@ export class AgentRunner {
58
67
  "capabilities.report",
59
68
  ]),
60
69
  ],
61
- 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"] : [])])],
62
71
  meta: {
63
72
  provider: options.provider,
64
73
  runnerId: options.runnerId,
74
+ startedAt: options.startedAt,
75
+ tmuxSession: options.tmuxSession ?? null,
76
+ policyName: options.policyName ?? null,
77
+ spawnRequestId: options.spawnRequestId ?? null,
65
78
  runnerManaged: true,
66
79
  cwd: options.cwd,
67
80
  approvalMode: options.approvalMode,
@@ -81,7 +94,7 @@ export class AgentRunner {
81
94
  this.control = startControlServer({ onStatus: (status) => this.setProviderStatus(status) });
82
95
  this.options.adapter.onStatusChange((status) => {
83
96
  this.setProviderStatus(status);
84
- if (status === "offline" || status === "error") this.options.onProviderExit?.(status === "offline" ? 0 : 1);
97
+ if (runnerShouldResolveProviderExit(status, this.exitCommandInProgress)) this.options.onProviderExit?.(status === "offline" ? 0 : 1);
85
98
  });
86
99
  this.bus.on("message.new", (message) => this.enqueueMessage(message as Message));
87
100
  this.bus.on("command", (type, params, commandId, command) => {
@@ -179,6 +192,7 @@ export class AgentRunner {
179
192
  if (type !== "agent.shutdown" && type !== "agent.restart" && type !== "agent.kill") return;
180
193
 
181
194
  const exitAfterCommand = type !== "agent.restart";
195
+ if (exitAfterCommand) this.exitCommandInProgress = true;
182
196
  this.claims.startClaim("command", commandId);
183
197
  this.publishStatus();
184
198
  await this.updateCommand(commandId, "accepted");
@@ -186,7 +200,14 @@ export class AgentRunner {
186
200
  try {
187
201
  if (type === "agent.restart") await this.restartProvider();
188
202
  else await this.shutdownProvider(type === "agent.kill");
189
- 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
+ });
190
211
  } catch (error) {
191
212
  await this.updateCommand(commandId, "failed", undefined, error instanceof Error ? error.message : String(error)).catch(() => {});
192
213
  } finally {
@@ -257,6 +278,10 @@ export class AgentRunner {
257
278
  ready: agentStatus !== "offline" && !this.stopped,
258
279
  meta: {
259
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,
260
285
  ...(status === "error" ? { terminalStatus: "error" } : {}),
261
286
  busyReasons: this.claims.reasons(),
262
287
  transport: this.bus.transportState,
@@ -265,20 +290,41 @@ export class AgentRunner {
265
290
  }
266
291
 
267
292
  private matchesMessage(message: Message): boolean {
268
- if (message.to === this.agentId || message.to === "broadcast") return true;
269
- if (message.to === `label:${this.options.label}`) return true;
270
- if (message.to.startsWith("tag:")) {
271
- const tag = message.to.slice("tag:".length);
272
- return this.options.providerConfig.defaultTags.includes(tag) || tag === this.options.provider;
273
- }
274
- if (message.to.startsWith("cap:")) {
275
- const cap = message.to.slice("cap:".length);
276
- return this.options.providerConfig.defaultCapabilities.includes(cap);
277
- }
278
- 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
+ });
279
302
  }
280
303
  }
281
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
+
282
328
  function csvTags(raw: string | undefined): string[] {
283
329
  return (raw || "").split(",").map((tag) => tag.trim()).filter(Boolean);
284
330
  }
@@ -300,6 +346,10 @@ export function runnerBusErrorAction(code: string, stopped: boolean): "ignore" |
300
346
  return "log";
301
347
  }
302
348
 
349
+ export function runnerShouldResolveProviderExit(status: SemanticStatus, exitCommandInProgress: boolean): boolean {
350
+ return !exitCommandInProgress && (status === "offline" || status === "error");
351
+ }
352
+
303
353
  function lifecycleCapabilities(): Record<string, true> {
304
354
  return {
305
355
  shutdownHard: true,