agent-relay-orchestrator 0.10.19 → 0.10.21
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 +4 -2
- package/src/api.ts +542 -40
- package/src/artifact-proxy.ts +173 -0
- package/src/control.ts +156 -18
- package/src/index.ts +53 -7
- package/src/provider-probe.ts +184 -0
- package/src/recovery.ts +1 -1
- package/src/relay.ts +106 -15
- package/src/self-supervision.ts +82 -0
- package/src/self-upgrade.ts +143 -0
- package/src/spawn.ts +1267 -0
- package/src/version.ts +30 -1
- package/src/workspace-probe.ts +513 -0
- package/src/tmux.ts +0 -298
package/src/tmux.ts
DELETED
|
@@ -1,298 +0,0 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
-
import type { OrchestratorConfig } from "./config";
|
|
5
|
-
import type { ManagedAgentReport } from "./relay";
|
|
6
|
-
|
|
7
|
-
export interface SpawnOptions {
|
|
8
|
-
provider: "claude" | "codex";
|
|
9
|
-
cwd: string;
|
|
10
|
-
label?: string;
|
|
11
|
-
agentId?: string;
|
|
12
|
-
approvalMode: string;
|
|
13
|
-
prompt?: string;
|
|
14
|
-
env?: Record<string, string>;
|
|
15
|
-
tags?: string[];
|
|
16
|
-
capabilities?: string[];
|
|
17
|
-
providerArgs?: string[];
|
|
18
|
-
policyName?: string;
|
|
19
|
-
spawnRequestId?: string;
|
|
20
|
-
tmuxSession?: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface TmuxSession {
|
|
24
|
-
name: string;
|
|
25
|
-
pid: number;
|
|
26
|
-
attached: boolean;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function isWithinBaseDir(path: string, baseDir: string): boolean {
|
|
30
|
-
const base = resolve(baseDir);
|
|
31
|
-
const target = resolve(path);
|
|
32
|
-
const rel = relative(base, target);
|
|
33
|
-
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function sessionName(config: OrchestratorConfig, provider: string, label: string): string {
|
|
37
|
-
const clean = label.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
|
|
38
|
-
return `${config.tmuxPrefix}-${provider}-${clean}`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export async function listTmuxSessions(prefix: string): Promise<TmuxSession[]> {
|
|
42
|
-
try {
|
|
43
|
-
const proc = Bun.spawn(["tmux", "list-sessions", "-F", "#{session_name}\t#{pid}\t#{session_attached}"], {
|
|
44
|
-
stdout: "pipe",
|
|
45
|
-
stderr: "pipe",
|
|
46
|
-
});
|
|
47
|
-
const out = await new Response(proc.stdout).text();
|
|
48
|
-
const code = await proc.exited;
|
|
49
|
-
if (code !== 0) return [];
|
|
50
|
-
|
|
51
|
-
return out.trim().split("\n").filter(Boolean).map((line) => {
|
|
52
|
-
const [name, pid, attached] = line.split("\t");
|
|
53
|
-
return { name: name!, pid: parseInt(pid!, 10), attached: attached === "1" };
|
|
54
|
-
}).filter((s) => s.name.startsWith(`${prefix}-`));
|
|
55
|
-
} catch {
|
|
56
|
-
return [];
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export async function hasSession(name: string): Promise<boolean> {
|
|
61
|
-
const proc = Bun.spawn(["tmux", "has-session", "-t", name], {
|
|
62
|
-
stdout: "ignore",
|
|
63
|
-
stderr: "ignore",
|
|
64
|
-
});
|
|
65
|
-
return (await proc.exited) === 0;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function buildRunnerCommand(opts: SpawnOptions, config: OrchestratorConfig): string[] {
|
|
69
|
-
const repoLauncher = resolve(import.meta.dir, "../../runner/src/index.ts");
|
|
70
|
-
const launcher = existsSync(repoLauncher)
|
|
71
|
-
? ["bun", "run", repoLauncher, opts.provider]
|
|
72
|
-
: [`${opts.provider}-relay`, opts.provider];
|
|
73
|
-
const args = [
|
|
74
|
-
...launcher,
|
|
75
|
-
"--headless",
|
|
76
|
-
"--cwd", opts.cwd,
|
|
77
|
-
"--relay-url", config.relayUrl,
|
|
78
|
-
"--approval", opts.approvalMode || "guarded",
|
|
79
|
-
];
|
|
80
|
-
if (opts.label) args.push("--label", opts.label);
|
|
81
|
-
if (opts.agentId) args.push("--agent-id", opts.agentId);
|
|
82
|
-
if (opts.prompt) args.push("--prompt", opts.prompt);
|
|
83
|
-
if (opts.tags?.length) args.push("--tags", opts.tags.join(","));
|
|
84
|
-
if (opts.capabilities?.length) args.push("--caps", opts.capabilities.join(","));
|
|
85
|
-
if (opts.providerArgs?.length) args.push("--", ...opts.providerArgs);
|
|
86
|
-
return [
|
|
87
|
-
...args,
|
|
88
|
-
];
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function buildEnv(opts: SpawnOptions, config: OrchestratorConfig): Record<string, string> {
|
|
92
|
-
const currentPath = process.env.PATH || "";
|
|
93
|
-
const extraPaths = [
|
|
94
|
-
join(homedir(), ".local", "bin"),
|
|
95
|
-
join(homedir(), ".bun", "bin"),
|
|
96
|
-
join(homedir(), ".npm-global", "bin"),
|
|
97
|
-
];
|
|
98
|
-
const fullPath = [...extraPaths, ...currentPath.split(":").filter(Boolean)]
|
|
99
|
-
.filter((v, i, a) => a.indexOf(v) === i)
|
|
100
|
-
.join(":");
|
|
101
|
-
|
|
102
|
-
return {
|
|
103
|
-
...process.env as Record<string, string>,
|
|
104
|
-
...config.env,
|
|
105
|
-
...(opts.env || {}),
|
|
106
|
-
PATH: fullPath,
|
|
107
|
-
AGENT_RELAY_URL: config.relayUrl,
|
|
108
|
-
AGENT_RELAY_APPROVAL: opts.approvalMode || "guarded",
|
|
109
|
-
AGENT_RELAY_TAGS: [...new Set(["headless", "dashboard-spawned", config.hostname, ...(opts.tags ?? [])])].join(","),
|
|
110
|
-
AGENT_RELAY_CAPS: [...new Set(opts.capabilities ?? [])].join(","),
|
|
111
|
-
AGENT_RELAY_CAPABILITIES: [...new Set(opts.capabilities ?? [])].join(","),
|
|
112
|
-
AGENT_RELAY_HEADLESS: "1",
|
|
113
|
-
...(opts.label ? { AGENT_RELAY_LABEL: opts.label } : {}),
|
|
114
|
-
...(opts.policyName ? { AGENT_RELAY_POLICY: opts.policyName } : {}),
|
|
115
|
-
...(opts.spawnRequestId ? { AGENT_RELAY_SPAWN_REQUEST_ID: opts.spawnRequestId } : {}),
|
|
116
|
-
...(opts.tmuxSession ? { AGENT_RELAY_TMUX_SESSION: opts.tmuxSession } : {}),
|
|
117
|
-
...(config.token ? { AGENT_RELAY_TOKEN: config.token } : {}),
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export async function spawnAgent(
|
|
122
|
-
opts: SpawnOptions,
|
|
123
|
-
config: OrchestratorConfig,
|
|
124
|
-
): Promise<ManagedAgentReport> {
|
|
125
|
-
const label = opts.label || `${opts.provider}-${Date.now()}`;
|
|
126
|
-
const agentId = opts.agentId || managedAgentId(config, opts.provider, label);
|
|
127
|
-
const name = sessionName(config, opts.provider, label);
|
|
128
|
-
const spawnOpts: SpawnOptions = { ...opts, label, agentId, tmuxSession: name };
|
|
129
|
-
|
|
130
|
-
if (!existsSync(opts.cwd)) {
|
|
131
|
-
throw new Error(`cwd does not exist: ${opts.cwd}`);
|
|
132
|
-
}
|
|
133
|
-
if (!isWithinBaseDir(opts.cwd, config.baseDir)) {
|
|
134
|
-
throw new Error(`cwd must be within base directory: ${config.baseDir}`);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const command = buildRunnerCommand(spawnOpts, config);
|
|
138
|
-
|
|
139
|
-
const env = buildEnv(spawnOpts, config);
|
|
140
|
-
|
|
141
|
-
// Build the env export string for tmux
|
|
142
|
-
const envExports = Object.entries(env)
|
|
143
|
-
.filter(([k]) => k === "PATH" || k.startsWith("AGENT_RELAY_") || k.startsWith("CLAUDE_") || k.startsWith("ANTHROPIC_") || k === "GITHUB_TOKEN" || k === "NPM_TOKEN" || k === "HOME" || k === "USER" || k === "SHELL")
|
|
144
|
-
.map(([k, v]) => `${k}=${shellEscape(v)}`)
|
|
145
|
-
.join(" ");
|
|
146
|
-
|
|
147
|
-
const fullCommand = envExports
|
|
148
|
-
? `env ${envExports} ${command.map(shellEscape).join(" ")}`
|
|
149
|
-
: command.map(shellEscape).join(" ");
|
|
150
|
-
|
|
151
|
-
const tmuxArgs = [
|
|
152
|
-
"tmux", "new-session", "-d",
|
|
153
|
-
"-s", name,
|
|
154
|
-
"-c", opts.cwd,
|
|
155
|
-
fullCommand,
|
|
156
|
-
];
|
|
157
|
-
|
|
158
|
-
console.error(`[orchestrator] Spawning ${opts.provider} agent: ${name}`);
|
|
159
|
-
console.error(`[orchestrator] cwd: ${opts.cwd}`);
|
|
160
|
-
console.error(`[orchestrator] command: ${fullCommand}`);
|
|
161
|
-
|
|
162
|
-
const proc = Bun.spawn(tmuxArgs, {
|
|
163
|
-
stdout: "pipe",
|
|
164
|
-
stderr: "pipe",
|
|
165
|
-
env,
|
|
166
|
-
});
|
|
167
|
-
const exitCode = await proc.exited;
|
|
168
|
-
if (exitCode !== 0) {
|
|
169
|
-
const stderr = await new Response(proc.stderr).text();
|
|
170
|
-
throw new Error(`tmux spawn failed (exit ${exitCode}): ${stderr.trim()}`);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Get the PID from tmux
|
|
174
|
-
const pid = await getSessionPid(name);
|
|
175
|
-
|
|
176
|
-
return {
|
|
177
|
-
agentId,
|
|
178
|
-
provider: spawnOpts.provider,
|
|
179
|
-
tmuxSession: name,
|
|
180
|
-
cwd: spawnOpts.cwd,
|
|
181
|
-
label,
|
|
182
|
-
approvalMode: spawnOpts.approvalMode || "guarded",
|
|
183
|
-
policyName: spawnOpts.policyName,
|
|
184
|
-
spawnRequestId: spawnOpts.spawnRequestId,
|
|
185
|
-
pid: pid ?? undefined,
|
|
186
|
-
startedAt: Date.now(),
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
export async function stopSession(name: string, config: OrchestratorConfig, reason: string, graceful = true): Promise<{ stopped: boolean; wasRunning: boolean }> {
|
|
191
|
-
if (!name.startsWith(`${config.tmuxPrefix}-`)) throw new Error("session is not managed by this orchestrator");
|
|
192
|
-
const wasRunning = await hasSession(name);
|
|
193
|
-
if (!wasRunning) return { stopped: false, wasRunning: false };
|
|
194
|
-
if (graceful) {
|
|
195
|
-
await Bun.spawn(["tmux", "send-keys", "-t", name, "C-c"], { stdout: "ignore", stderr: "ignore" }).exited;
|
|
196
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
197
|
-
}
|
|
198
|
-
if (await hasSession(name)) {
|
|
199
|
-
await Bun.spawn(["tmux", "kill-session", "-t", name], {
|
|
200
|
-
stdout: "ignore",
|
|
201
|
-
stderr: "ignore",
|
|
202
|
-
env: { ...process.env, AGENT_RELAY_STOP_REASON: reason },
|
|
203
|
-
}).exited;
|
|
204
|
-
}
|
|
205
|
-
return { stopped: true, wasRunning };
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
export async function captureSession(name: string, config: OrchestratorConfig, lines = 100): Promise<{ session: string; lines: string[]; running: boolean }> {
|
|
209
|
-
if (!name.startsWith(`${config.tmuxPrefix}-`)) throw new Error("session is not managed by this orchestrator");
|
|
210
|
-
const running = await hasSession(name);
|
|
211
|
-
if (!running) return { session: name, lines: [], running };
|
|
212
|
-
const safeLines = Math.min(Math.max(lines, 1), 1000);
|
|
213
|
-
const proc = Bun.spawn(["tmux", "capture-pane", "-p", "-t", name, "-S", `-${safeLines}`], {
|
|
214
|
-
stdout: "pipe",
|
|
215
|
-
stderr: "pipe",
|
|
216
|
-
});
|
|
217
|
-
const out = await new Response(proc.stdout).text();
|
|
218
|
-
const code = await proc.exited;
|
|
219
|
-
if (code !== 0) {
|
|
220
|
-
const err = await new Response(proc.stderr).text();
|
|
221
|
-
throw new Error(err.trim() || "tmux capture failed");
|
|
222
|
-
}
|
|
223
|
-
return { session: name, lines: out.split("\n").filter(Boolean), running };
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function managedAgentId(config: OrchestratorConfig, provider: string, label: string): string {
|
|
227
|
-
const cleanHost = config.hostname.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
|
|
228
|
-
const cleanLabel = label.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
|
|
229
|
-
return `${cleanHost}-${provider}-${cleanLabel}-${crypto.randomUUID().slice(0, 8)}`;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
async function getSessionPid(name: string): Promise<number | null> {
|
|
233
|
-
try {
|
|
234
|
-
const proc = Bun.spawn(["tmux", "display-message", "-p", "-t", name, "#{pane_pid}"], {
|
|
235
|
-
stdout: "pipe",
|
|
236
|
-
stderr: "pipe",
|
|
237
|
-
});
|
|
238
|
-
const out = await new Response(proc.stdout).text();
|
|
239
|
-
const code = await proc.exited;
|
|
240
|
-
if (code !== 0) return null;
|
|
241
|
-
const pid = parseInt(out.trim(), 10);
|
|
242
|
-
return isNaN(pid) ? null : pid;
|
|
243
|
-
} catch {
|
|
244
|
-
return null;
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
export async function recoverExistingSessions(
|
|
249
|
-
config: OrchestratorConfig,
|
|
250
|
-
): Promise<ManagedAgentReport[]> {
|
|
251
|
-
const sessions = await listTmuxSessions(config.tmuxPrefix);
|
|
252
|
-
const managed: ManagedAgentReport[] = [];
|
|
253
|
-
|
|
254
|
-
for (const session of sessions) {
|
|
255
|
-
// Parse provider and label from session name: "ar-claude-backend" → provider=claude, label=backend
|
|
256
|
-
const parts = session.name.slice(config.tmuxPrefix.length + 1).split("-");
|
|
257
|
-
const provider = parts[0] as "claude" | "codex";
|
|
258
|
-
if (provider !== "claude" && provider !== "codex") continue;
|
|
259
|
-
const label = parts.slice(1).join("-") || undefined;
|
|
260
|
-
|
|
261
|
-
// Get cwd from tmux
|
|
262
|
-
const cwd = await getSessionCwd(session.name);
|
|
263
|
-
|
|
264
|
-
managed.push({
|
|
265
|
-
agentId: managedAgentId(config, provider, label || session.name),
|
|
266
|
-
provider,
|
|
267
|
-
tmuxSession: session.name,
|
|
268
|
-
cwd: cwd || config.baseDir,
|
|
269
|
-
label,
|
|
270
|
-
approvalMode: "guarded",
|
|
271
|
-
pid: session.pid || undefined,
|
|
272
|
-
startedAt: Date.now(),
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
console.error(`[orchestrator] Recovered existing session: ${session.name} (${provider}, pid ${session.pid})`);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
return managed;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
async function getSessionCwd(name: string): Promise<string | null> {
|
|
282
|
-
try {
|
|
283
|
-
const proc = Bun.spawn(["tmux", "display-message", "-p", "-t", name, "#{pane_current_path}"], {
|
|
284
|
-
stdout: "pipe",
|
|
285
|
-
stderr: "pipe",
|
|
286
|
-
});
|
|
287
|
-
const out = await new Response(proc.stdout).text();
|
|
288
|
-
const code = await proc.exited;
|
|
289
|
-
return code === 0 ? out.trim() || null : null;
|
|
290
|
-
} catch {
|
|
291
|
-
return null;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
function shellEscape(s: string): string {
|
|
296
|
-
if (/^[a-zA-Z0-9._\-/:=@]+$/.test(s)) return s;
|
|
297
|
-
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
298
|
-
}
|