agent-relay-runner 0.10.7 → 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 +1 -1
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/plugins/codex/skills/disconnect/SKILL.md +15 -0
- package/plugins/codex/skills/label/SKILL.md +22 -0
- package/plugins/codex/skills/message/SKILL.md +24 -0
- package/plugins/codex/skills/pair/SKILL.md +25 -0
- package/plugins/codex/skills/reply/SKILL.md +24 -0
- package/plugins/codex/skills/send-claimable/SKILL.md +24 -0
- package/plugins/codex/skills/status/SKILL.md +15 -0
- package/plugins/codex/skills/tags/SKILL.md +24 -0
- package/src/adapters/claude.ts +122 -0
- package/src/adapters/codex.ts +55 -4
- package/src/index.ts +24 -27
- package/src/runner.ts +64 -14
package/package.json
CHANGED
|
@@ -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/adapters/claude.ts
CHANGED
|
@@ -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> {
|
package/src/adapters/codex.ts
CHANGED
|
@@ -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
|
|
31
|
-
? Bun.spawn([
|
|
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
|
|
66
|
-
? [
|
|
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: (
|
|
33
|
-
const exitPromise =
|
|
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:
|
|
59
|
+
onProviderExit: (code) => exitResolve(code ?? 1),
|
|
54
60
|
});
|
|
55
61
|
await runner.run();
|
|
56
62
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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", {
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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,
|