agent-relay-runner 0.10.0

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 ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "agent-relay-runner",
3
+ "version": "0.10.0",
4
+ "description": "Unified provider lifecycle runner for Agent Relay",
5
+ "type": "module",
6
+ "bin": {
7
+ "claude-relay": "src/index.ts",
8
+ "codex-relay": "src/index.ts"
9
+ },
10
+ "files": [
11
+ "src/**/*.ts",
12
+ "plugins/**",
13
+ "!src/**/*.test.ts"
14
+ ],
15
+ "license": "AGPL-3.0-or-later",
16
+ "author": "Edin Mujkanovic <edin@exelerus.com>",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/edimuj/agent-relay.git",
20
+ "directory": "runner"
21
+ },
22
+ "dependencies": {
23
+ "agent-relay-sdk": "0.2.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/bun": "latest",
27
+ "typescript": "^5"
28
+ },
29
+ "engines": {
30
+ "bun": ">=1.0.0"
31
+ }
32
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "agent-relay-runner",
3
+ "description": "Thin Agent Relay runner bridge for Claude Code",
4
+ "version": "0.10.0"
5
+ }
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ port="${AGENT_RELAY_RUNNER_PORT:-}"
4
+ [ -z "$port" ] && exit 0
5
+ curl -fsS -X POST "http://127.0.0.1:${port}/status" -H 'Content-Type: application/json' -d '{"status":"offline"}' >/dev/null || true
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ port="${AGENT_RELAY_RUNNER_PORT:-}"
4
+ [ -z "$port" ] && exit 0
5
+ curl -fsS -X POST "http://127.0.0.1:${port}/status" -H 'Content-Type: application/json' -d '{"status":"idle"}' >/dev/null || true
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ port="${AGENT_RELAY_RUNNER_PORT:-}"
4
+ [ -z "$port" ] && exit 0
5
+ curl -fsS -X POST "http://127.0.0.1:${port}/status" -H 'Content-Type: application/json' -d '{"status":"busy"}' >/dev/null || true
@@ -0,0 +1,7 @@
1
+ [
2
+ {
3
+ "name": "agent-relay-runner-monitor",
4
+ "command": "bun run \"${CLAUDE_PLUGIN_ROOT}/monitors/relay-monitor.ts\"",
5
+ "description": "Agent Relay runner message delivery pipe"
6
+ }
7
+ ]
@@ -0,0 +1,30 @@
1
+ const port = process.env.AGENT_RELAY_RUNNER_PORT;
2
+ if (!port) process.exit(0);
3
+
4
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/monitor`);
5
+
6
+ ws.onmessage = (event) => {
7
+ const payload = parsePayload(String(event.data));
8
+ if (!payload || payload.type !== "message.deliver" || !Array.isArray(payload.messages)) return;
9
+ const messages = payload.messages as Array<{ id: number; from: string; subject?: string; body: string }>;
10
+ for (const message of messages) {
11
+ const subject = message.subject ? `\nSubject: ${message.subject}` : "";
12
+ console.log(`Relay message #${message.id} from ${message.from}${subject}\n${message.body}`);
13
+ }
14
+ ws.send(JSON.stringify({
15
+ type: "message.delivered",
16
+ deliveryId: typeof payload.deliveryId === "string" ? payload.deliveryId : "",
17
+ messageIds: messages.map((message) => message.id),
18
+ }));
19
+ };
20
+
21
+ ws.onerror = () => process.exit(1);
22
+
23
+ function parsePayload(raw: string): Record<string, unknown> | null {
24
+ try {
25
+ const parsed = JSON.parse(raw);
26
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
package/src/adapter.ts ADDED
@@ -0,0 +1,69 @@
1
+ import type { Message } from "agent-relay-sdk";
2
+
3
+ export type SemanticStatus = "idle" | "busy" | "offline" | "error";
4
+
5
+ export interface ProviderConfig {
6
+ command: string;
7
+ defaultArgs: string[];
8
+ env: Record<string, string>;
9
+ pluginDirs: string[];
10
+ defaultCapabilities: string[];
11
+ defaultApprovalMode: string;
12
+ defaultTags: string[];
13
+ headless: {
14
+ tmuxPrefix: string;
15
+ shutdownTimeoutMs: number;
16
+ };
17
+ }
18
+
19
+ export interface RunnerSpawnConfig {
20
+ provider: string;
21
+ runnerId: string;
22
+ instanceId: string;
23
+ agentId: string;
24
+ relayUrl: string;
25
+ cwd: string;
26
+ headless: boolean;
27
+ approvalMode: string;
28
+ label?: string;
29
+ prompt?: string;
30
+ providerArgs: string[];
31
+ providerConfig: ProviderConfig;
32
+ env: Record<string, string>;
33
+ controlPort: number;
34
+ monitor?: {
35
+ deliver(messages: Message[]): Promise<number[]>;
36
+ };
37
+ [key: string]: unknown;
38
+ }
39
+
40
+ export interface SpawnArgs {
41
+ command: string;
42
+ args: string[];
43
+ cwd: string;
44
+ env: Record<string, string>;
45
+ }
46
+
47
+ export interface ManagedProcess {
48
+ pid?: number;
49
+ process?: Bun.Subprocess;
50
+ meta?: Record<string, unknown>;
51
+ }
52
+
53
+ export interface ProviderAdapter {
54
+ provider: string;
55
+ spawn(config: RunnerSpawnConfig): Promise<ManagedProcess>;
56
+ shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void>;
57
+ deliver(process: ManagedProcess, messages: Message[]): Promise<void>;
58
+ onStatusChange(cb: (status: SemanticStatus) => void): void;
59
+ buildSpawnArgs(config: RunnerSpawnConfig, providerConfig: ProviderConfig): SpawnArgs;
60
+ }
61
+
62
+ export function providerMessageText(messages: Message[]): string {
63
+ return messages
64
+ .map((message) => {
65
+ const subject = message.subject ? `Subject: ${message.subject}\n` : "";
66
+ return `[relay message #${message.id} from ${message.from}]\n${subject}${message.body}`;
67
+ })
68
+ .join("\n\n");
69
+ }
@@ -0,0 +1,73 @@
1
+ import { resolve } from "node:path";
2
+ import type { Message } from "agent-relay-sdk";
3
+ import type { ManagedProcess, ProviderAdapter, ProviderConfig, RunnerSpawnConfig, SemanticStatus, SpawnArgs } from "../adapter";
4
+
5
+ export class ClaudeAdapter implements ProviderAdapter {
6
+ readonly provider = "claude";
7
+ private statusCb: (status: SemanticStatus) => void = () => {};
8
+
9
+ onStatusChange(cb: (status: SemanticStatus) => void): void {
10
+ this.statusCb = cb;
11
+ }
12
+
13
+ async spawn(config: RunnerSpawnConfig): Promise<ManagedProcess> {
14
+ const args = this.buildSpawnArgs(config, config.providerConfig as ProviderConfig);
15
+ const proc = Bun.spawn([args.command, ...args.args], {
16
+ cwd: args.cwd,
17
+ env: args.env,
18
+ stdin: "inherit",
19
+ stdout: "inherit",
20
+ stderr: "inherit",
21
+ });
22
+ void proc.exited.then((code) => this.statusCb(code === 0 ? "offline" : "error"));
23
+ return { pid: proc.pid, process: proc, meta: { monitor: config.monitor } };
24
+ }
25
+
26
+ async shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
27
+ await terminateProcess(process, opts);
28
+ }
29
+
30
+ async deliver(_process: ManagedProcess, messages: Message[]): Promise<void> {
31
+ const monitor = _process.meta?.monitor as { deliver?(messages: Message[]): Promise<number[]> } | undefined;
32
+ if (!monitor?.deliver) throw new Error("Claude monitor delivery is unavailable");
33
+ await monitor.deliver(messages);
34
+ }
35
+
36
+ buildSpawnArgs(config: RunnerSpawnConfig, providerConfig: ProviderConfig): SpawnArgs {
37
+ const pluginRoot = resolve(import.meta.dir, "../../plugins/claude");
38
+ const pluginDirs = [...new Set([pluginRoot, ...providerConfig.pluginDirs])];
39
+ const args = [
40
+ ...pluginDirs.flatMap((dir) => ["--plugin-dir", dir]),
41
+ ...providerConfig.defaultArgs,
42
+ ...config.providerArgs,
43
+ ];
44
+ if (config.prompt) args.push(String(config.prompt));
45
+ return {
46
+ command: providerConfig.command,
47
+ args,
48
+ cwd: config.cwd,
49
+ env: {
50
+ ...config.env,
51
+ AGENT_RELAY_PROVIDER: "claude",
52
+ },
53
+ };
54
+ }
55
+ }
56
+
57
+ async function terminateProcess(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
58
+ const proc = process.process;
59
+ if (!proc) return;
60
+ try {
61
+ proc.kill(opts.graceful ? "SIGTERM" : "SIGKILL");
62
+ } catch {
63
+ return;
64
+ }
65
+ const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), opts.timeoutMs));
66
+ const result = await Promise.race([proc.exited, timeout]);
67
+ if (result === "timeout") {
68
+ try {
69
+ proc.kill("SIGKILL");
70
+ } catch {}
71
+ await proc.exited.catch(() => {});
72
+ }
73
+ }
@@ -0,0 +1,239 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+
3
+ type JsonRpcId = number | string;
4
+
5
+ type JsonRpcRequest = {
6
+ id: JsonRpcId;
7
+ method: string;
8
+ params?: unknown;
9
+ };
10
+
11
+ type JsonRpcResponse = {
12
+ id: JsonRpcId;
13
+ result?: unknown;
14
+ error?: { code: number; message: string; data?: unknown };
15
+ };
16
+
17
+ type JsonRpcNotification = {
18
+ method: string;
19
+ params?: Record<string, unknown>;
20
+ };
21
+
22
+ type ThreadStatus =
23
+ | { type: "notLoaded" }
24
+ | { type: "idle" }
25
+ | { type: "systemError" }
26
+ | { type: "active"; activeFlags: string[] };
27
+
28
+ type TurnStatus = "completed" | "interrupted" | "failed" | "inProgress";
29
+
30
+ interface Turn {
31
+ id: string;
32
+ status: TurnStatus;
33
+ startedAt: number | null;
34
+ completedAt: number | null;
35
+ }
36
+
37
+ export interface Thread {
38
+ id: string;
39
+ cwd: string;
40
+ status: ThreadStatus;
41
+ updatedAt: number;
42
+ preview: string;
43
+ turns?: Turn[];
44
+ }
45
+
46
+ export type ClientEvent =
47
+ | { type: "notification"; message: JsonRpcNotification }
48
+ | { type: "server-request"; message: JsonRpcRequest }
49
+ | { type: "response"; message: JsonRpcResponse };
50
+
51
+ interface TurnStartResponse {
52
+ turn: Turn;
53
+ }
54
+
55
+ interface ThreadStartResponse {
56
+ thread: Thread;
57
+ }
58
+
59
+ interface ThreadResumeResponse {
60
+ thread: Thread;
61
+ }
62
+
63
+ interface ThreadReadResponse {
64
+ thread: Thread;
65
+ }
66
+
67
+ interface ThreadListResponse {
68
+ data: Thread[];
69
+ nextCursor: string | null;
70
+ }
71
+
72
+ interface ThreadLoadedListResponse {
73
+ data: string[];
74
+ nextCursor: string | null;
75
+ }
76
+
77
+ export class CodexAppClient {
78
+ private ws!: WebSocket;
79
+ private nextId = 1;
80
+ private pending = new Map<JsonRpcId, { resolve: (value: any) => void; reject: (err: unknown) => void }>();
81
+ private events: ClientEvent[] = [];
82
+ private listeners = new Set<(event: ClientEvent) => void>();
83
+ private connected = false;
84
+ private connectionListeners = new Set<(connected: boolean) => void>();
85
+
86
+ constructor(private readonly url: string, private readonly log: (msg: string) => void = () => {}) {}
87
+
88
+ async connect(): Promise<void> {
89
+ if (this.connected) return;
90
+ await new Promise<void>((resolve, reject) => {
91
+ const ws = new WebSocket(this.url);
92
+ this.ws = ws;
93
+
94
+ ws.onopen = () => {
95
+ this.connected = true;
96
+ this.emitConnection(true);
97
+ resolve();
98
+ };
99
+ ws.onerror = (event) => reject(new Error(`websocket error: ${String((event as ErrorEvent).message || "unknown")}`));
100
+ ws.onclose = (event) => {
101
+ this.connected = false;
102
+ this.emitConnection(false);
103
+ const err = new Error(`websocket closed code=${event.code} reason=${event.reason || "(none)"}`);
104
+ for (const pending of this.pending.values()) pending.reject(err);
105
+ this.pending.clear();
106
+ };
107
+ ws.onmessage = (event) => this.handleMessage(String(event.data));
108
+ });
109
+ }
110
+
111
+ close(): void {
112
+ if (!this.ws) return;
113
+ this.ws.close();
114
+ }
115
+
116
+ isConnected(): boolean {
117
+ return this.connected;
118
+ }
119
+
120
+ async initialize(): Promise<unknown> {
121
+ return this.request("initialize", {
122
+ clientInfo: {
123
+ name: "agent-relay-runner-codex",
124
+ title: "Agent Relay Codex Runner",
125
+ version: "0.1.0",
126
+ },
127
+ capabilities: {
128
+ experimentalApi: true,
129
+ },
130
+ });
131
+ }
132
+
133
+ onEvent(listener: (event: ClientEvent) => void): () => void {
134
+ this.listeners.add(listener);
135
+ return () => this.listeners.delete(listener);
136
+ }
137
+
138
+ onConnectionChange(listener: (connected: boolean) => void): () => void {
139
+ this.connectionListeners.add(listener);
140
+ return () => this.connectionListeners.delete(listener);
141
+ }
142
+
143
+ getEvents(): ClientEvent[] {
144
+ return [...this.events];
145
+ }
146
+
147
+ async settle(ms = 150): Promise<void> {
148
+ await delay(ms);
149
+ }
150
+
151
+ async threadStart(params: Record<string, unknown>): Promise<ThreadStartResponse> {
152
+ return this.request<ThreadStartResponse>("thread/start", params);
153
+ }
154
+
155
+ async threadResume(params: Record<string, unknown>): Promise<ThreadResumeResponse> {
156
+ return this.request<ThreadResumeResponse>("thread/resume", params);
157
+ }
158
+
159
+ async threadRead(threadId: string, includeTurns = false): Promise<ThreadReadResponse> {
160
+ return this.request<ThreadReadResponse>("thread/read", { threadId, includeTurns });
161
+ }
162
+
163
+ async threadList(params: Record<string, unknown>): Promise<ThreadListResponse> {
164
+ return this.request<ThreadListResponse>("thread/list", params);
165
+ }
166
+
167
+ async threadLoadedList(limit = 20): Promise<ThreadLoadedListResponse> {
168
+ return this.request<ThreadLoadedListResponse>("thread/loaded/list", { limit });
169
+ }
170
+
171
+ async turnStart(threadId: string, text: string): Promise<TurnStartResponse> {
172
+ return this.request<TurnStartResponse>("turn/start", {
173
+ threadId,
174
+ input: [{ type: "text", text }],
175
+ });
176
+ }
177
+
178
+ async turnSteer(threadId: string, turnId: string, text: string): Promise<{ turnId: string }> {
179
+ return this.request<{ turnId: string }>("turn/steer", {
180
+ threadId,
181
+ expectedTurnId: turnId,
182
+ input: [{ type: "text", text }],
183
+ });
184
+ }
185
+
186
+ async turnInterrupt(threadId: string, turnId: string): Promise<Record<string, never>> {
187
+ return this.request<Record<string, never>>("turn/interrupt", { threadId, turnId });
188
+ }
189
+
190
+ private async request<T = unknown>(method: string, params?: unknown): Promise<T> {
191
+ if (!this.connected) {
192
+ throw new Error("websocket not connected");
193
+ }
194
+ const id = this.nextId++;
195
+ const payload: JsonRpcRequest = { id, method, params };
196
+ const promise = new Promise<T>((resolve, reject) => {
197
+ this.pending.set(id, { resolve, reject });
198
+ });
199
+ this.ws.send(JSON.stringify(payload));
200
+ return promise;
201
+ }
202
+
203
+ private handleMessage(raw: string): void {
204
+ const parsed = JSON.parse(raw) as JsonRpcRequest | JsonRpcResponse | JsonRpcNotification;
205
+
206
+ if ("id" in parsed && ("result" in parsed || "error" in parsed)) {
207
+ const pending = this.pending.get(parsed.id);
208
+ if (pending) {
209
+ this.pending.delete(parsed.id);
210
+ if (parsed.error) {
211
+ pending.reject(new Error(`${parsed.error.message} (${parsed.error.code})`));
212
+ } else {
213
+ pending.resolve(parsed.result);
214
+ }
215
+ }
216
+ this.record({ type: "response", message: parsed });
217
+ return;
218
+ }
219
+
220
+ if ("id" in parsed && "method" in parsed) {
221
+ this.log(`server-request ${parsed.method}`);
222
+ this.record({ type: "server-request", message: parsed });
223
+ return;
224
+ }
225
+
226
+ if ("method" in parsed) {
227
+ this.record({ type: "notification", message: parsed });
228
+ }
229
+ }
230
+
231
+ private record(event: ClientEvent): void {
232
+ this.events.push(event);
233
+ for (const listener of this.listeners) listener(event);
234
+ }
235
+
236
+ private emitConnection(connected: boolean): void {
237
+ for (const listener of this.connectionListeners) listener(connected);
238
+ }
239
+ }
@@ -0,0 +1,127 @@
1
+ import type { Message } from "agent-relay-sdk";
2
+ import { providerMessageText, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type RunnerSpawnConfig, type SemanticStatus, type SpawnArgs } from "../adapter";
3
+ import { CodexAppClient, type ClientEvent } from "./codex-client";
4
+
5
+ export class CodexAdapter implements ProviderAdapter {
6
+ readonly provider = "codex";
7
+ private statusCb: (status: SemanticStatus) => void = () => {};
8
+
9
+ onStatusChange(cb: (status: SemanticStatus) => void): void {
10
+ this.statusCb = cb;
11
+ }
12
+
13
+ async spawn(config: RunnerSpawnConfig): Promise<ManagedProcess> {
14
+ const args = this.buildSpawnArgs(config, config.providerConfig as ProviderConfig);
15
+ const appServer = Bun.spawn([args.command, ...args.args], {
16
+ cwd: args.cwd,
17
+ env: args.env,
18
+ stdin: "ignore",
19
+ stdout: "inherit",
20
+ stderr: "inherit",
21
+ });
22
+ const appServerUrl = String(config.appServerUrl || args.env.CODEX_APP_SERVER_URL || "");
23
+ const client = appServerUrl ? new CodexAppClient(appServerUrl, () => {}) : undefined;
24
+ if (client) {
25
+ await connectWithRetry(client);
26
+ await client.initialize().catch(() => undefined);
27
+ client.onEvent((event) => this.handleCodexEvent(event));
28
+ }
29
+
30
+ const tui = !config.headless && config.providerConfig.command === "codex"
31
+ ? Bun.spawn(["codex", "--remote", appServerUrl, ...config.providerConfig.defaultArgs, ...config.providerArgs], {
32
+ cwd: config.cwd,
33
+ env: args.env,
34
+ stdin: "inherit",
35
+ stdout: "inherit",
36
+ stderr: "inherit",
37
+ })
38
+ : undefined;
39
+
40
+ void appServer.exited.then((code) => this.statusCb(code === 0 ? "offline" : "error"));
41
+ if (tui) void tui.exited.then((code) => this.statusCb(code === 0 ? "idle" : "error"));
42
+ return { pid: tui?.pid ?? appServer.pid, process: appServer, meta: { client, threadId: config.threadId, cwd: config.cwd, appServer, tui } };
43
+ }
44
+
45
+ async shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
46
+ const client = process.meta?.client as CodexAppClient | undefined;
47
+ client?.close();
48
+ await terminateProcess(process, opts);
49
+ }
50
+
51
+ async deliver(process: ManagedProcess, messages: Message[]): Promise<void> {
52
+ const client = process.meta?.client as CodexAppClient | undefined;
53
+ if (!client) throw new Error("Codex App Server client is unavailable");
54
+ let threadId = typeof process.meta?.threadId === "string" ? process.meta.threadId : "";
55
+ if (!threadId) {
56
+ const started = await client.threadStart({ cwd: typeof process.meta?.cwd === "string" ? process.meta.cwd : globalThis.process.cwd() });
57
+ threadId = started.thread.id;
58
+ process.meta = { ...(process.meta ?? {}), threadId };
59
+ }
60
+ await client.turnStart(threadId, providerMessageText(messages));
61
+ }
62
+
63
+ buildSpawnArgs(config: RunnerSpawnConfig, providerConfig: ProviderConfig): SpawnArgs {
64
+ 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
+ : [...providerConfig.defaultArgs, ...config.providerArgs];
68
+ return {
69
+ command: providerConfig.command,
70
+ args,
71
+ cwd: config.cwd,
72
+ env: {
73
+ ...config.env,
74
+ CODEX_APP_SERVER_URL: appServerUrl,
75
+ AGENT_RELAY_PROVIDER: "codex",
76
+ },
77
+ };
78
+ }
79
+
80
+ private handleCodexEvent(event: ClientEvent): void {
81
+ const method = event.type === "notification" ? event.message.method : "";
82
+ if (method.includes("turn/started") || method.includes("turn.started")) this.statusCb("busy");
83
+ if (method.includes("turn/completed") || method.includes("turn.completed")) this.statusCb("idle");
84
+ if (method.includes("thread/status")) {
85
+ const params = event.type === "notification" ? event.message.params : undefined;
86
+ const status = params?.status;
87
+ if (typeof status === "string" && status.includes("active")) this.statusCb("busy");
88
+ if (typeof status === "string" && status.includes("idle")) this.statusCb("idle");
89
+ }
90
+ }
91
+ }
92
+
93
+ async function connectWithRetry(client: CodexAppClient, attempts = 40): Promise<void> {
94
+ let lastError: unknown;
95
+ for (let i = 0; i < attempts; i++) {
96
+ try {
97
+ await client.connect();
98
+ return;
99
+ } catch (error) {
100
+ lastError = error;
101
+ await Bun.sleep(250);
102
+ }
103
+ }
104
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
105
+ }
106
+
107
+ async function terminateProcess(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
108
+ const processes = [
109
+ process.meta?.tui as Bun.Subprocess | undefined,
110
+ process.meta?.appServer as Bun.Subprocess | undefined,
111
+ process.process,
112
+ ].filter(Boolean) as Bun.Subprocess[];
113
+ if (processes.length === 0) return;
114
+ try {
115
+ for (const proc of processes) proc.kill(opts.graceful ? "SIGTERM" : "SIGKILL");
116
+ } catch {
117
+ return;
118
+ }
119
+ const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), opts.timeoutMs));
120
+ const result = await Promise.race([Promise.all(processes.map((proc) => proc.exited)), timeout]);
121
+ if (result === "timeout") {
122
+ try {
123
+ for (const proc of processes) proc.kill("SIGKILL");
124
+ } catch {}
125
+ await Promise.all(processes.map((proc) => proc.exited.catch(() => {})));
126
+ }
127
+ }
@@ -0,0 +1,78 @@
1
+ type ClaimKind = "message" | "task" | "command";
2
+ type RunnerBusyReason = "provider-turn" | ClaimKind;
3
+ type RunnerSemanticStatus = "idle" | "busy" | "offline" | "error";
4
+
5
+ interface ClaimRecord {
6
+ id: string;
7
+ kind: ClaimKind;
8
+ startedAt: number;
9
+ expiresAt?: number;
10
+ }
11
+
12
+ export class ClaimTracker {
13
+ private providerBusy = false;
14
+ private status: RunnerSemanticStatus = "idle";
15
+ private readonly claims = new Map<string, ClaimRecord>();
16
+
17
+ setProviderBusy(busy: boolean): boolean {
18
+ const before = this.currentStatus();
19
+ this.providerBusy = busy;
20
+ return before !== this.currentStatus();
21
+ }
22
+
23
+ setTerminalStatus(status: "offline" | "error"): boolean {
24
+ const before = this.status;
25
+ this.status = status;
26
+ return before !== status;
27
+ }
28
+
29
+ clearTerminalStatus(): boolean {
30
+ const before = this.status;
31
+ this.status = "idle";
32
+ return before !== this.status;
33
+ }
34
+
35
+ startClaim(kind: ClaimKind, id: string, expiresAt?: number): boolean {
36
+ const before = this.currentStatus();
37
+ this.claims.set(`${kind}:${id}`, { kind, id, startedAt: Date.now(), expiresAt });
38
+ return before !== this.currentStatus();
39
+ }
40
+
41
+ finishClaim(kind: ClaimKind, id: string): boolean {
42
+ const before = this.currentStatus();
43
+ this.claims.delete(`${kind}:${id}`);
44
+ return before !== this.currentStatus();
45
+ }
46
+
47
+ clearKind(kind: ClaimKind): boolean {
48
+ const before = this.currentStatus();
49
+ for (const key of [...this.claims.keys()]) {
50
+ if (key.startsWith(`${kind}:`)) this.claims.delete(key);
51
+ }
52
+ return before !== this.currentStatus();
53
+ }
54
+
55
+ expire(now = Date.now()): boolean {
56
+ const before = this.currentStatus();
57
+ for (const [key, claim] of this.claims) {
58
+ if (claim.expiresAt !== undefined && claim.expiresAt <= now) this.claims.delete(key);
59
+ }
60
+ return before !== this.currentStatus();
61
+ }
62
+
63
+ currentStatus(): RunnerSemanticStatus {
64
+ if (this.status === "offline" || this.status === "error") return this.status;
65
+ return this.providerBusy || this.claims.size > 0 ? "busy" : "idle";
66
+ }
67
+
68
+ reasons(): RunnerBusyReason[] {
69
+ const reasons = new Set<RunnerBusyReason>();
70
+ if (this.providerBusy) reasons.add("provider-turn");
71
+ for (const claim of this.claims.values()) reasons.add(claim.kind);
72
+ return [...reasons];
73
+ }
74
+
75
+ activeClaims(): ClaimRecord[] {
76
+ return [...this.claims.values()];
77
+ }
78
+ }
package/src/config.ts ADDED
@@ -0,0 +1,144 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir, hostname } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import type { ProviderConfig } from "./adapter";
5
+
6
+ interface GlobalRunnerConfig {
7
+ relayUrl: string;
8
+ token?: string;
9
+ defaultCwd: string;
10
+ }
11
+
12
+ interface LoadedProviderConfig extends ProviderConfig {
13
+ path: string;
14
+ }
15
+
16
+ const DEFAULT_RELAY_URL = "http://127.0.0.1:4850";
17
+
18
+ function agentRelayHome(): string {
19
+ return process.env.AGENT_RELAY_HOME || join(homedir(), ".agent-relay");
20
+ }
21
+
22
+ function providersDir(home = agentRelayHome()): string {
23
+ return join(home, "providers");
24
+ }
25
+
26
+ export function defaultProviderConfig(provider: string): ProviderConfig {
27
+ const command = provider === "claude" ? "claude-rig" : provider;
28
+ return {
29
+ command,
30
+ defaultArgs: provider === "claude" ? ["--dangerously-skip-permissions"] : [],
31
+ env: {},
32
+ pluginDirs: [],
33
+ defaultCapabilities: ["chat", "code", "review"],
34
+ defaultApprovalMode: "guarded",
35
+ defaultTags: [],
36
+ headless: {
37
+ tmuxPrefix: `${provider}-relay`,
38
+ shutdownTimeoutMs: 10_000,
39
+ },
40
+ };
41
+ }
42
+
43
+ export function loadGlobalConfig(home = agentRelayHome()): GlobalRunnerConfig {
44
+ const path = join(home, "config.json");
45
+ const parsed = readJson(path);
46
+ return {
47
+ relayUrl: stringValue(parsed.relayUrl) ?? process.env.AGENT_RELAY_URL ?? DEFAULT_RELAY_URL,
48
+ token: stringValue(parsed.token) ?? process.env.AGENT_RELAY_TOKEN,
49
+ defaultCwd: stringValue(parsed.defaultCwd) ?? process.cwd(),
50
+ };
51
+ }
52
+
53
+ export function loadProviderConfig(provider: string, home = agentRelayHome()): LoadedProviderConfig {
54
+ const path = join(providersDir(home), `${provider}.json`);
55
+ const raw = readJson(path);
56
+ const defaults = defaultProviderConfig(provider);
57
+ return {
58
+ path,
59
+ command: stringValue(raw.command) ?? defaults.command,
60
+ defaultArgs: stringArray(raw.defaultArgs) ?? defaults.defaultArgs,
61
+ env: stringRecord(raw.env) ?? defaults.env,
62
+ pluginDirs: stringArray(raw.pluginDirs) ?? defaults.pluginDirs,
63
+ defaultCapabilities: stringArray(raw.defaultCapabilities) ?? defaults.defaultCapabilities,
64
+ defaultApprovalMode: stringValue(raw.defaultApprovalMode) ?? defaults.defaultApprovalMode,
65
+ defaultTags: stringArray(raw.defaultTags) ?? defaults.defaultTags,
66
+ headless: {
67
+ tmuxPrefix: stringValue(recordValue(raw.headless).tmuxPrefix) ?? defaults.headless.tmuxPrefix,
68
+ shutdownTimeoutMs: positiveInteger(recordValue(raw.headless).shutdownTimeoutMs) ?? defaults.headless.shutdownTimeoutMs,
69
+ },
70
+ };
71
+ }
72
+
73
+ export function writeProviderConfig(provider: string, config: ProviderConfig, home = agentRelayHome()): LoadedProviderConfig {
74
+ const dir = providersDir(home);
75
+ mkdirSync(dir, { recursive: true });
76
+ const path = join(dir, `${provider}.json`);
77
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, "utf8");
78
+ return { ...config, path };
79
+ }
80
+
81
+ export function providerConfigPublic(config: LoadedProviderConfig): Record<string, unknown> {
82
+ return {
83
+ path: config.path,
84
+ command: config.command,
85
+ defaultArgs: config.defaultArgs,
86
+ env: maskEnv(config.env),
87
+ pluginDirs: config.pluginDirs,
88
+ defaultCapabilities: config.defaultCapabilities,
89
+ defaultApprovalMode: config.defaultApprovalMode,
90
+ defaultTags: config.defaultTags,
91
+ headless: config.headless,
92
+ };
93
+ }
94
+
95
+ export function runnerId(provider: string, cwd: string, label?: string): string {
96
+ const project = cwd.split("/").filter(Boolean).at(-1) || "workspace";
97
+ const cleanLabel = (label || project).replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
98
+ return `${hostname()}-${provider}-${cleanLabel}-${crypto.randomUUID().slice(0, 8)}`;
99
+ }
100
+
101
+ export function resolveCwd(value: string | undefined, fallback: string): string {
102
+ return resolve(value || fallback);
103
+ }
104
+
105
+ function readJson(path: string): Record<string, unknown> {
106
+ if (!existsSync(path)) return {};
107
+ try {
108
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
109
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
110
+ } catch {
111
+ return {};
112
+ }
113
+ }
114
+
115
+ function stringValue(value: unknown): string | undefined {
116
+ return typeof value === "string" && value.length > 0 ? value : undefined;
117
+ }
118
+
119
+ function positiveInteger(value: unknown): number | undefined {
120
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
121
+ }
122
+
123
+ function stringArray(value: unknown): string[] | undefined {
124
+ return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : undefined;
125
+ }
126
+
127
+ function stringRecord(value: unknown): Record<string, string> | undefined {
128
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
129
+ const entries = Object.entries(value);
130
+ if (entries.some(([, item]) => typeof item !== "string")) return undefined;
131
+ return Object.fromEntries(entries) as Record<string, string>;
132
+ }
133
+
134
+ function recordValue(value: unknown): Record<string, unknown> {
135
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
136
+ }
137
+
138
+ function maskEnv(env: Record<string, string>): Record<string, string> {
139
+ const result: Record<string, string> = {};
140
+ for (const [key, value] of Object.entries(env)) {
141
+ result[key] = /token|secret|key|password/i.test(key) && !value.startsWith("$env:") ? "********" : value;
142
+ }
143
+ return result;
144
+ }
@@ -0,0 +1,109 @@
1
+ import type { Server, ServerWebSocket } from "bun";
2
+ import type { Message } from "agent-relay-sdk";
3
+ import type { SemanticStatus } from "./adapter";
4
+
5
+ interface MonitorSocketData {
6
+ kind: "monitor";
7
+ }
8
+
9
+ type MonitorSocket = ServerWebSocket<MonitorSocketData>;
10
+
11
+ export interface ControlServer {
12
+ port: number;
13
+ url: string;
14
+ stop(): void;
15
+ deliverToMonitor(messages: Message[], timeoutMs?: number): Promise<number[]>;
16
+ }
17
+
18
+ interface ControlServerOptions {
19
+ onStatus(status: SemanticStatus): void;
20
+ }
21
+
22
+ export function startControlServer(options: ControlServerOptions): ControlServer {
23
+ const monitors = new Set<MonitorSocket>();
24
+ const pendingDeliveries = new Map<string, { resolve(ids: number[]): void; timer: Timer }>();
25
+ let server!: Server<MonitorSocketData>;
26
+
27
+ server = Bun.serve<MonitorSocketData>({
28
+ hostname: "127.0.0.1",
29
+ port: 0,
30
+ fetch(req, srv) {
31
+ const url = new URL(req.url);
32
+ if (url.pathname === "/health") return Response.json({ ok: true });
33
+ if (url.pathname === "/status" && req.method === "GET") return Response.json({ ok: true });
34
+ if (url.pathname === "/status" && req.method === "POST") {
35
+ return handleStatus(req, options);
36
+ }
37
+ if (url.pathname === "/monitor") {
38
+ const upgraded = srv.upgrade(req, { data: { kind: "monitor" } });
39
+ return upgraded ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
40
+ }
41
+ return new Response("not found", { status: 404 });
42
+ },
43
+ websocket: {
44
+ open(ws) {
45
+ monitors.add(ws);
46
+ },
47
+ message(_ws, raw) {
48
+ const parsed = parseJson(raw);
49
+ if (parsed?.type === "message.delivered" && Array.isArray(parsed.messageIds)) {
50
+ const deliveryId = typeof parsed.deliveryId === "string" ? parsed.deliveryId : "";
51
+ const pending = pendingDeliveries.get(deliveryId);
52
+ if (pending) {
53
+ clearTimeout(pending.timer);
54
+ pendingDeliveries.delete(deliveryId);
55
+ pending.resolve(parsed.messageIds.filter((id): id is number => Number.isSafeInteger(id)));
56
+ }
57
+ }
58
+ },
59
+ close(ws) {
60
+ monitors.delete(ws);
61
+ },
62
+ },
63
+ });
64
+
65
+ const port = server.port;
66
+ if (port === undefined) throw new Error("runner control server did not bind a port");
67
+
68
+ return {
69
+ port,
70
+ url: `http://127.0.0.1:${port}`,
71
+ stop() {
72
+ for (const pending of pendingDeliveries.values()) clearTimeout(pending.timer);
73
+ pendingDeliveries.clear();
74
+ server.stop(true);
75
+ },
76
+ async deliverToMonitor(messages: Message[], timeoutMs = 30_000): Promise<number[]> {
77
+ if (monitors.size === 0) throw new Error("no Claude monitor connected");
78
+ const deliveryId = crypto.randomUUID();
79
+ const payload = JSON.stringify({ type: "message.deliver", deliveryId, messages });
80
+ return await new Promise<number[]>((resolve, reject) => {
81
+ const timer = setTimeout(() => {
82
+ pendingDeliveries.delete(deliveryId);
83
+ reject(new Error("monitor delivery timed out"));
84
+ }, timeoutMs);
85
+ pendingDeliveries.set(deliveryId, { resolve, timer });
86
+ for (const monitor of monitors) monitor.send(payload);
87
+ });
88
+ },
89
+ };
90
+ }
91
+
92
+ async function handleStatus(req: Request, options: ControlServerOptions): Promise<Response> {
93
+ const body = await req.json().catch(() => null) as { status?: unknown } | null;
94
+ const status = body?.status;
95
+ if (status !== "idle" && status !== "busy" && status !== "offline" && status !== "error") {
96
+ return Response.json({ error: "status must be idle, busy, offline, or error" }, { status: 400 });
97
+ }
98
+ options.onStatus(status);
99
+ return Response.json({ ok: true, status });
100
+ }
101
+
102
+ function parseJson(raw: string | Buffer): Record<string, unknown> | null {
103
+ try {
104
+ const parsed = JSON.parse(typeof raw === "string" ? raw : raw.toString());
105
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
package/src/index.ts ADDED
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env bun
2
+ import { basename } from "node:path";
3
+ import { ClaudeAdapter } from "./adapters/claude";
4
+ import { CodexAdapter } from "./adapters/codex";
5
+ import { AgentRunner } from "./runner";
6
+ import { loadGlobalConfig, loadProviderConfig, resolveCwd, runnerId } from "./config";
7
+
8
+ interface CliOptions {
9
+ provider: "claude" | "codex";
10
+ headless: boolean;
11
+ cwd?: string;
12
+ relayUrl?: string;
13
+ token?: string;
14
+ approvalMode?: string;
15
+ label?: string;
16
+ agentId?: string;
17
+ prompt?: string;
18
+ providerArgs: string[];
19
+ }
20
+
21
+ export async function main(argv = process.argv): Promise<void> {
22
+ const opts = parseArgs(argv);
23
+ const globalConfig = loadGlobalConfig();
24
+ const providerConfig = loadProviderConfig(opts.provider);
25
+ const cwd = resolveCwd(opts.cwd, globalConfig.defaultCwd);
26
+ const id = runnerId(opts.provider, cwd, opts.label);
27
+ const adapter = opts.provider === "claude" ? new ClaudeAdapter() : new CodexAdapter();
28
+ const runner = new AgentRunner({
29
+ provider: opts.provider,
30
+ runnerId: id,
31
+ instanceId: crypto.randomUUID(),
32
+ agentId: opts.agentId,
33
+ relayUrl: opts.relayUrl ?? globalConfig.relayUrl,
34
+ token: opts.token ?? globalConfig.token,
35
+ cwd,
36
+ headless: opts.headless,
37
+ approvalMode: opts.approvalMode ?? providerConfig.defaultApprovalMode,
38
+ label: opts.label,
39
+ prompt: opts.prompt,
40
+ providerArgs: opts.providerArgs,
41
+ providerConfig,
42
+ adapter,
43
+ });
44
+ await runner.run();
45
+ await waitForShutdown(() => runner.stop());
46
+ }
47
+
48
+ function parseArgs(argv: string[]): CliOptions {
49
+ const bin = basename(argv[1] || "");
50
+ let provider: "claude" | "codex" = bin.includes("claude") ? "claude" : "codex";
51
+ const relayArgs = argv.slice(2);
52
+ const providerSep = relayArgs.indexOf("--");
53
+ const ownArgs = providerSep >= 0 ? relayArgs.slice(0, providerSep) : relayArgs;
54
+ const providerArgs = providerSep >= 0 ? relayArgs.slice(providerSep + 1) : [];
55
+ let headless = false;
56
+ let cwd: string | undefined;
57
+ let relayUrl: string | undefined;
58
+ let token: string | undefined;
59
+ let approvalMode: string | undefined;
60
+ let label: string | undefined;
61
+ let agentId: string | undefined;
62
+ let prompt: string | undefined;
63
+
64
+ for (let i = 0; i < ownArgs.length; i++) {
65
+ const arg = ownArgs[i];
66
+ if (arg === "claude" || arg === "codex") provider = arg;
67
+ else if (arg === "--headless") headless = true;
68
+ else if (arg === "--cwd" && ownArgs[i + 1]) cwd = ownArgs[++i];
69
+ else if (arg === "--relay-url" && ownArgs[i + 1]) relayUrl = ownArgs[++i];
70
+ else if (arg === "--token" && ownArgs[i + 1]) token = ownArgs[++i];
71
+ else if ((arg === "--approval" || arg === "--approval-mode") && ownArgs[i + 1]) approvalMode = ownArgs[++i];
72
+ else if (arg === "--label" && ownArgs[i + 1]) label = ownArgs[++i];
73
+ else if (arg === "--agent-id" && ownArgs[i + 1]) agentId = ownArgs[++i];
74
+ else if (arg === "--prompt" && ownArgs[i + 1]) prompt = ownArgs[++i];
75
+ else if (arg === "--help" || arg === "-h") {
76
+ printHelp(provider);
77
+ process.exit(0);
78
+ } else {
79
+ providerArgs.push(arg!);
80
+ }
81
+ }
82
+
83
+ return { provider, headless, cwd, relayUrl, token, approvalMode, label, agentId, prompt, providerArgs };
84
+ }
85
+
86
+ 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...]`);
88
+ }
89
+
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(() => {});
101
+ }
102
+
103
+ if (import.meta.main) {
104
+ await main();
105
+ }
package/src/runner.ts ADDED
@@ -0,0 +1,274 @@
1
+ import { hostname } from "node:os";
2
+ import type { Message } from "agent-relay-sdk";
3
+ import { RelayBusClient, RelayHttpClient } from "agent-relay-sdk";
4
+ import type { ManagedProcess, ProviderAdapter, ProviderConfig, RunnerSpawnConfig, SemanticStatus } from "./adapter";
5
+ import { ClaimTracker } from "./claim-tracker";
6
+ import { startControlServer, type ControlServer } from "./control-server";
7
+
8
+ interface RunnerOptions {
9
+ provider: string;
10
+ runnerId: string;
11
+ instanceId: string;
12
+ agentId?: string;
13
+ relayUrl: string;
14
+ token?: string;
15
+ cwd: string;
16
+ headless: boolean;
17
+ approvalMode: string;
18
+ label?: string;
19
+ prompt?: string;
20
+ providerArgs: string[];
21
+ providerConfig: ProviderConfig;
22
+ adapter: ProviderAdapter;
23
+ }
24
+
25
+ export class AgentRunner {
26
+ private readonly agentId: string;
27
+ private readonly claims = new ClaimTracker();
28
+ private readonly http: RelayHttpClient;
29
+ private readonly bus: RelayBusClient;
30
+ private control?: ControlServer;
31
+ private process?: ManagedProcess;
32
+ private stopped = false;
33
+ private delivering = false;
34
+ private readonly pendingMessages = new Map<number, Message>();
35
+
36
+ constructor(private readonly options: RunnerOptions) {
37
+ this.agentId = options.agentId ?? options.runnerId;
38
+ this.http = new RelayHttpClient({ baseUrl: options.relayUrl, token: options.token });
39
+ this.bus = new RelayBusClient({
40
+ url: relayBusUrl(options.relayUrl),
41
+ role: "provider",
42
+ componentId: options.runnerId,
43
+ agentId: this.agentId,
44
+ instanceId: options.instanceId,
45
+ token: options.token,
46
+ machine: hostname(),
47
+ capabilities: [
48
+ ...new Set([
49
+ ...options.providerConfig.defaultCapabilities,
50
+ "lifecycle.shutdown.hard",
51
+ "lifecycle.restart.hard",
52
+ "lifecycle.status.semantic",
53
+ "lifecycle.heartbeat",
54
+ "lifecycle.reconnect.transport",
55
+ "commands.lifecycle",
56
+ "capabilities.report",
57
+ ]),
58
+ ],
59
+ tags: [...new Set([options.provider, ...csvTags(process.env.AGENT_RELAY_TAGS), ...options.providerConfig.defaultTags, ...(options.headless ? ["headless"] : [])])],
60
+ meta: {
61
+ provider: options.provider,
62
+ runnerId: options.runnerId,
63
+ runnerManaged: true,
64
+ cwd: options.cwd,
65
+ approvalMode: options.approvalMode,
66
+ label: options.label ?? null,
67
+ lifecycleCapabilities: lifecycleCapabilities(),
68
+ },
69
+ heartbeatIntervalMs: 30_000,
70
+ initialStatus: "idle",
71
+ });
72
+ }
73
+
74
+ get id(): string {
75
+ return this.agentId;
76
+ }
77
+
78
+ async run(): Promise<void> {
79
+ this.control = startControlServer({ onStatus: (status) => this.setProviderStatus(status) });
80
+ this.options.adapter.onStatusChange((status) => this.setProviderStatus(status));
81
+ this.bus.on("message.new", (message) => this.enqueueMessage(message as Message));
82
+ this.bus.on("command", (type, params, commandId, command) => {
83
+ void this.handleCommand(type, params, commandId, command);
84
+ });
85
+ await this.bus.connect();
86
+ this.process = await this.spawnProvider();
87
+ this.publishStatus();
88
+ }
89
+
90
+ async stop(): Promise<void> {
91
+ this.stopped = true;
92
+ await this.bus.statusAsync({ agentStatus: "offline", ready: false });
93
+ if (this.process) {
94
+ await this.options.adapter.shutdown(this.process, {
95
+ graceful: true,
96
+ timeoutMs: this.options.providerConfig.headless.shutdownTimeoutMs,
97
+ });
98
+ }
99
+ this.control?.stop();
100
+ await this.bus.close();
101
+ }
102
+
103
+ private async spawnProvider(): Promise<ManagedProcess> {
104
+ const env = {
105
+ ...process.env as Record<string, string>,
106
+ ...this.options.providerConfig.env,
107
+ AGENT_RELAY_RUNNER_PORT: String(this.control!.port),
108
+ AGENT_RELAY_RUNNER_ID: this.options.runnerId,
109
+ AGENT_RELAY_ID: this.agentId,
110
+ AGENT_RELAY_URL: this.options.relayUrl,
111
+ AGENT_RELAY_APPROVAL: this.options.approvalMode,
112
+ ...(this.options.token ? { AGENT_RELAY_TOKEN: this.options.token } : {}),
113
+ };
114
+ const config: RunnerSpawnConfig = {
115
+ provider: this.options.provider,
116
+ runnerId: this.options.runnerId,
117
+ instanceId: this.options.instanceId,
118
+ agentId: this.agentId,
119
+ relayUrl: this.options.relayUrl,
120
+ cwd: this.options.cwd,
121
+ headless: this.options.headless,
122
+ approvalMode: this.options.approvalMode,
123
+ ...(this.options.label ? { label: this.options.label } : {}),
124
+ ...(this.options.prompt ? { prompt: this.options.prompt } : {}),
125
+ providerArgs: this.options.providerArgs,
126
+ providerConfig: this.options.providerConfig,
127
+ env,
128
+ controlPort: this.control!.port,
129
+ monitor: {
130
+ deliver: (messages) => this.control!.deliverToMonitor(messages),
131
+ },
132
+ };
133
+ return this.options.adapter.spawn(config);
134
+ }
135
+
136
+ private enqueueMessage(message: Message): void {
137
+ if (!this.matchesMessage(message)) return;
138
+ this.pendingMessages.set(message.id, message);
139
+ void this.drainMessages();
140
+ }
141
+
142
+ private async drainMessages(): Promise<void> {
143
+ if (this.delivering || this.pendingMessages.size === 0 || !this.process) return;
144
+ this.delivering = true;
145
+ const messages = [...this.pendingMessages.values()].sort((a, b) => a.id - b.id);
146
+ this.pendingMessages.clear();
147
+ for (const message of messages) {
148
+ if (message.claimable) {
149
+ const claimed = await this.http.claimMessage(message.id, this.agentId).catch(() => false);
150
+ if (!claimed) continue;
151
+ this.claims.startClaim("message", String(message.id), message.claimExpiresAt);
152
+ }
153
+ }
154
+ this.publishStatus();
155
+ try {
156
+ await this.options.adapter.deliver(this.process, messages);
157
+ for (const message of messages) {
158
+ await this.http.markRead(message.id, this.agentId).catch(() => {});
159
+ this.claims.finishClaim("message", String(message.id));
160
+ }
161
+ } finally {
162
+ this.delivering = false;
163
+ this.publishStatus();
164
+ if (this.pendingMessages.size > 0) void this.drainMessages();
165
+ }
166
+ }
167
+
168
+ private async handleCommand(type: string, params: Record<string, unknown>, commandId: string, command?: Record<string, unknown>): Promise<void> {
169
+ const target = typeof command?.target === "string" ? command.target : this.agentId;
170
+ if (target !== this.agentId && target !== this.options.runnerId) return;
171
+ if (type !== "agent.shutdown" && type !== "agent.restart" && type !== "agent.kill") return;
172
+
173
+ this.claims.startClaim("command", commandId);
174
+ this.publishStatus();
175
+ await this.updateCommand(commandId, "accepted");
176
+ await this.updateCommand(commandId, "running");
177
+ try {
178
+ if (type === "agent.restart") await this.restartProvider();
179
+ else await this.shutdownProvider(type === "agent.kill");
180
+ await this.updateCommand(commandId, "succeeded", { action: type, agentId: this.agentId, runnerId: this.options.runnerId });
181
+ if (type !== "agent.restart") setTimeout(() => void this.stop().finally(() => process.exit(0)), 10);
182
+ } catch (error) {
183
+ await this.updateCommand(commandId, "failed", undefined, error instanceof Error ? error.message : String(error));
184
+ } finally {
185
+ this.claims.finishClaim("command", commandId);
186
+ this.publishStatus();
187
+ }
188
+ }
189
+
190
+ private async restartProvider(): Promise<void> {
191
+ if (this.process) {
192
+ await this.options.adapter.shutdown(this.process, {
193
+ graceful: true,
194
+ timeoutMs: this.options.providerConfig.headless.shutdownTimeoutMs,
195
+ });
196
+ }
197
+ if (this.stopped) return;
198
+ this.process = await this.spawnProvider();
199
+ }
200
+
201
+ private async shutdownProvider(hard: boolean): Promise<void> {
202
+ await this.bus.statusAsync({ agentStatus: "idle", ready: false });
203
+ if (this.process) {
204
+ await this.options.adapter.shutdown(this.process, {
205
+ graceful: !hard,
206
+ timeoutMs: this.options.providerConfig.headless.shutdownTimeoutMs,
207
+ });
208
+ }
209
+ this.claims.setTerminalStatus("offline");
210
+ this.publishStatus();
211
+ await this.http.deleteAgent(this.agentId).catch(() => {});
212
+ this.stopped = true;
213
+ }
214
+
215
+ private async updateCommand(commandId: string, status: string, result?: Record<string, unknown>, error?: string): Promise<void> {
216
+ await this.bus.updateCommand(commandId, { status, ...(result ? { result } : {}), ...(error ? { error } : {}) });
217
+ }
218
+
219
+ private setProviderStatus(status: SemanticStatus): void {
220
+ if (status === "busy") this.claims.setProviderBusy(true);
221
+ else if (status === "idle") this.claims.setProviderBusy(false);
222
+ else if (status === "offline" || status === "error") this.claims.setTerminalStatus(status);
223
+ this.publishStatus();
224
+ }
225
+
226
+ private publishStatus(): void {
227
+ const status = this.claims.currentStatus();
228
+ this.bus.setSemanticStatus(status === "offline" || status === "error" ? "idle" : status);
229
+ this.bus.status({
230
+ agentStatus: status,
231
+ ready: status !== "offline" && !this.stopped,
232
+ meta: {
233
+ runnerId: this.options.runnerId,
234
+ busyReasons: this.claims.reasons(),
235
+ transport: this.bus.transportState,
236
+ },
237
+ });
238
+ }
239
+
240
+ private matchesMessage(message: Message): boolean {
241
+ if (message.to === this.agentId || message.to === "broadcast") return true;
242
+ if (message.to === `label:${this.options.label}`) return true;
243
+ if (message.to.startsWith("tag:")) {
244
+ const tag = message.to.slice("tag:".length);
245
+ return this.options.providerConfig.defaultTags.includes(tag) || tag === this.options.provider;
246
+ }
247
+ if (message.to.startsWith("cap:")) {
248
+ const cap = message.to.slice("cap:".length);
249
+ return this.options.providerConfig.defaultCapabilities.includes(cap);
250
+ }
251
+ return false;
252
+ }
253
+ }
254
+
255
+ function csvTags(raw: string | undefined): string[] {
256
+ return (raw || "").split(",").map((tag) => tag.trim()).filter(Boolean);
257
+ }
258
+
259
+ function relayBusUrl(relayUrl: string): string {
260
+ const url = new URL(relayUrl);
261
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
262
+ url.pathname = `${url.pathname.replace(/\/+$/, "")}/bus`;
263
+ url.search = "";
264
+ return url.toString();
265
+ }
266
+
267
+ function lifecycleCapabilities(): Record<string, true> {
268
+ return {
269
+ shutdownHard: true,
270
+ restartHard: true,
271
+ semanticStatus: true,
272
+ transportReconnect: true,
273
+ };
274
+ }