@launchchair/agent 0.4.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/AGENTS.md +248 -0
- package/LICENSE +8 -0
- package/README.md +188 -0
- package/chatgpt-runner.mjs +239 -0
- package/claude-code-runner.mjs +141 -0
- package/claude-runner.mjs +218 -0
- package/codex-runner.mjs +123 -0
- package/launchchair-cli.mjs +1013 -0
- package/local-bridge.mjs +540 -0
- package/mcp-bridge-server.mjs +1101 -0
- package/package.json +43 -0
- package/runner-command.mjs +96 -0
package/local-bridge.mjs
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LaunchChair local bridge.
|
|
5
|
+
*
|
|
6
|
+
* Supports:
|
|
7
|
+
* - mode-based runner routing (gpt vs codex)
|
|
8
|
+
* - target-based routing (chatgpt, claude, codex, claude_code)
|
|
9
|
+
* - queue claim/complete loop for Run with Agent
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import process from "node:process";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { buildRunnerCommand, parseRunnerCommand } from "./runner-command.mjs";
|
|
17
|
+
|
|
18
|
+
const VERSION = "0.4.0";
|
|
19
|
+
const CLIENT_TYPES = ["codex", "chatgpt", "claude", "claude_code", "other"];
|
|
20
|
+
const AGENT_SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
|
|
22
|
+
function nodeScriptCommand(scriptName) {
|
|
23
|
+
return `${JSON.stringify(process.execPath)} ${JSON.stringify(path.join(AGENT_SCRIPT_DIR, scriptName))}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toInt(value, fallback) {
|
|
27
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
28
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function env(name, fallback = "") {
|
|
32
|
+
const value = process.env[name];
|
|
33
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hasArg(name) {
|
|
37
|
+
return process.argv.includes(name);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sleep(ms) {
|
|
41
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function trimToBytes(input, maxBytes) {
|
|
45
|
+
const text = String(input ?? "");
|
|
46
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
47
|
+
const prefix = text.slice(0, Math.floor(text.length * 0.45));
|
|
48
|
+
const suffix = text.slice(Math.floor(text.length * 0.85));
|
|
49
|
+
const marker = "\n\n...[truncated by local bridge]...\n\n";
|
|
50
|
+
let result = `${prefix}${marker}${suffix}`;
|
|
51
|
+
while (Buffer.byteLength(result, "utf8") > maxBytes && result.length > 64) {
|
|
52
|
+
result = result.slice(0, Math.floor(result.length * 0.9));
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeTarget(value, fallback = "other") {
|
|
58
|
+
const normalized = String(value ?? "")
|
|
59
|
+
.trim()
|
|
60
|
+
.toLowerCase()
|
|
61
|
+
.replace(/\s+/g, "_");
|
|
62
|
+
if (CLIENT_TYPES.includes(normalized)) return normalized;
|
|
63
|
+
return fallback;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function hasOpenAiKey() {
|
|
67
|
+
return String(process.env.OPENAI_API_KEY ?? "").trim().length > 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function hasAnthropicKey() {
|
|
71
|
+
return String(process.env.ANTHROPIC_API_KEY ?? "").trim().length > 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function builtInRunnerCommand(target) {
|
|
75
|
+
if (target === "codex") {
|
|
76
|
+
return nodeScriptCommand("codex-runner.mjs");
|
|
77
|
+
}
|
|
78
|
+
if (target === "claude_code") {
|
|
79
|
+
return nodeScriptCommand("claude-code-runner.mjs");
|
|
80
|
+
}
|
|
81
|
+
if (target === "chatgpt" && hasOpenAiKey()) {
|
|
82
|
+
return nodeScriptCommand("chatgpt-runner.mjs");
|
|
83
|
+
}
|
|
84
|
+
if (target === "claude" && hasAnthropicKey()) {
|
|
85
|
+
return nodeScriptCommand("claude-runner.mjs");
|
|
86
|
+
}
|
|
87
|
+
return "";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function printHelp() {
|
|
91
|
+
// eslint-disable-next-line no-console
|
|
92
|
+
console.log(`
|
|
93
|
+
LaunchChair local bridge v${VERSION}
|
|
94
|
+
|
|
95
|
+
Required env:
|
|
96
|
+
LAUNCHCHAIR_AGENT_TOKEN
|
|
97
|
+
|
|
98
|
+
Optional env:
|
|
99
|
+
LAUNCHCHAIR_BASE_URL Default: http://localhost:3000
|
|
100
|
+
LAUNCHCHAIR_PROJECT_ID Restrict heartbeat/claim to one project (UUID)
|
|
101
|
+
LC_AGENT_CLIENT_NAME Default: Local Bridge
|
|
102
|
+
LC_AGENT_CLIENT_TYPE codex|chatgpt|claude|claude_code|other|auto (default: auto)
|
|
103
|
+
LC_AGENT_CLIENT_VERSION Default: ${VERSION}
|
|
104
|
+
LC_AGENT_POLL_MS Default: 2500
|
|
105
|
+
LC_AGENT_HEARTBEAT_MS Default: 15000
|
|
106
|
+
AGENT_MOCK_RESPONSE If set, skips runner command and returns this payload
|
|
107
|
+
OPENAI_API_KEY Enables built-in ChatGPT runner fallback
|
|
108
|
+
ANTHROPIC_API_KEY Enables built-in Claude runner fallback
|
|
109
|
+
LC_AGENT_CODEX_CWD Folder codex-runner should build in (default: bridge cwd)
|
|
110
|
+
LC_AGENT_CODEX_SANDBOX read-only|workspace-write|danger-full-access (default: workspace-write)
|
|
111
|
+
LC_AGENT_CODEX_MODEL Optional Codex model override
|
|
112
|
+
LC_AGENT_CLAUDE_CODE_CWD Folder claude-code-runner should build in (default: bridge cwd)
|
|
113
|
+
LC_AGENT_CLAUDE_CODE_MODEL Optional Claude Code model override
|
|
114
|
+
LC_AGENT_CLAUDE_CODE_ALLOWED_TOOLS
|
|
115
|
+
Optional Claude Code allowedTools list
|
|
116
|
+
LC_AGENT_CLAUDE_CODE_PERMISSION_MODE
|
|
117
|
+
Optional Claude Code permission mode
|
|
118
|
+
LC_AGENT_OPENAI_MODEL Optional model override for chatgpt-runner
|
|
119
|
+
LC_AGENT_ANTHROPIC_MODEL Optional model override for claude-runner
|
|
120
|
+
|
|
121
|
+
Mode router env:
|
|
122
|
+
LC_AGENT_ROUTER_GPT_TARGET codex|chatgpt|claude|claude_code|other (default: codex)
|
|
123
|
+
LC_AGENT_ROUTER_CODEX_TARGET codex|claude_code|other (default: codex)
|
|
124
|
+
|
|
125
|
+
Runner command env (priority order by mode + target):
|
|
126
|
+
LC_AGENT_RUNNER_CMD_CHATGPT
|
|
127
|
+
LC_AGENT_RUNNER_CMD_CLAUDE
|
|
128
|
+
LC_AGENT_RUNNER_CMD_CODEX
|
|
129
|
+
LC_AGENT_RUNNER_CMD_CLAUDE_CODE
|
|
130
|
+
LC_AGENT_RUNNER_CMD_GPT
|
|
131
|
+
LC_AGENT_RUNNER_CMD_CODEX
|
|
132
|
+
LC_AGENT_RUNNER_CMD
|
|
133
|
+
|
|
134
|
+
Built-in fallback runners (if no explicit LC_AGENT_RUNNER_CMD* set):
|
|
135
|
+
gpt->codex uses node ./scripts/agent/codex-runner.mjs (reuses Codex CLI auth)
|
|
136
|
+
codex->codex uses node ./scripts/agent/codex-runner.mjs (reuses Codex CLI auth)
|
|
137
|
+
gpt->claude_code uses node ./scripts/agent/claude-code-runner.mjs (reuses Claude Code auth)
|
|
138
|
+
codex->claude_code uses node ./scripts/agent/claude-code-runner.mjs (reuses Claude Code auth)
|
|
139
|
+
gpt->chatgpt uses node ./scripts/agent/chatgpt-runner.mjs (requires OPENAI_API_KEY)
|
|
140
|
+
gpt->claude uses node ./scripts/agent/claude-runner.mjs (requires ANTHROPIC_API_KEY)
|
|
141
|
+
|
|
142
|
+
CLI flags:
|
|
143
|
+
--once
|
|
144
|
+
--verbose
|
|
145
|
+
--help
|
|
146
|
+
|
|
147
|
+
Runner placeholders:
|
|
148
|
+
{{PROMPT}} JSON-quoted prompt token
|
|
149
|
+
{{PROMPT_B64}} base64 prompt
|
|
150
|
+
{{RUN_ID}} agent run id
|
|
151
|
+
{{PROJECT_ID}} project id
|
|
152
|
+
`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const config = {
|
|
156
|
+
baseUrl: env("LAUNCHCHAIR_BASE_URL", "http://localhost:3000").replace(/\/+$/, ""),
|
|
157
|
+
token: env("LAUNCHCHAIR_AGENT_TOKEN", env("LAUNCHCHAIR_API_KEY")),
|
|
158
|
+
projectId: env("LAUNCHCHAIR_PROJECT_ID") || null,
|
|
159
|
+
clientName: env("LC_AGENT_CLIENT_NAME", "Local Bridge"),
|
|
160
|
+
clientType: env("LC_AGENT_CLIENT_TYPE", "auto"),
|
|
161
|
+
clientVersion: env("LC_AGENT_CLIENT_VERSION", VERSION),
|
|
162
|
+
pollMs: toInt(process.env.LC_AGENT_POLL_MS, 2500),
|
|
163
|
+
heartbeatMs: toInt(process.env.LC_AGENT_HEARTBEAT_MS, 15000),
|
|
164
|
+
maxPayloadBytes: 850 * 1024,
|
|
165
|
+
mockResponse: process.env.AGENT_MOCK_RESPONSE ?? "",
|
|
166
|
+
once: hasArg("--once"),
|
|
167
|
+
verbose: hasArg("--verbose"),
|
|
168
|
+
modeTargets: {
|
|
169
|
+
gpt: normalizeTarget(env("LC_AGENT_ROUTER_GPT_TARGET", "codex"), "codex"),
|
|
170
|
+
codex: normalizeTarget(env("LC_AGENT_ROUTER_CODEX_TARGET", "codex"), "codex"),
|
|
171
|
+
},
|
|
172
|
+
runnerCommands: {
|
|
173
|
+
generic: env("LC_AGENT_RUNNER_CMD"),
|
|
174
|
+
gpt: env("LC_AGENT_RUNNER_CMD_GPT"),
|
|
175
|
+
codex: env("LC_AGENT_RUNNER_CMD_CODEX"),
|
|
176
|
+
chatgpt: env("LC_AGENT_RUNNER_CMD_CHATGPT", builtInRunnerCommand("chatgpt")),
|
|
177
|
+
claude: env("LC_AGENT_RUNNER_CMD_CLAUDE", builtInRunnerCommand("claude")),
|
|
178
|
+
codexTarget: env("LC_AGENT_RUNNER_CMD_CODEX", builtInRunnerCommand("codex")),
|
|
179
|
+
claudeCode: env("LC_AGENT_RUNNER_CMD_CLAUDE_CODE", builtInRunnerCommand("claude_code")),
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
if (hasArg("--help")) {
|
|
184
|
+
printHelp();
|
|
185
|
+
process.exit(0);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!config.token) {
|
|
189
|
+
// eslint-disable-next-line no-console
|
|
190
|
+
console.error("Missing LAUNCHCHAIR_AGENT_TOKEN. LAUNCHCHAIR_API_KEY is also supported as an alias.");
|
|
191
|
+
printHelp();
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function hasAnyRunnerCommand() {
|
|
196
|
+
if (config.mockResponse) return true;
|
|
197
|
+
const values = [
|
|
198
|
+
config.runnerCommands.generic,
|
|
199
|
+
config.runnerCommands.gpt,
|
|
200
|
+
config.runnerCommands.codex,
|
|
201
|
+
config.runnerCommands.chatgpt,
|
|
202
|
+
config.runnerCommands.claude,
|
|
203
|
+
config.runnerCommands.codexTarget,
|
|
204
|
+
config.runnerCommands.claudeCode,
|
|
205
|
+
];
|
|
206
|
+
return values.some((value) => String(value ?? "").trim().length > 0);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!hasAnyRunnerCommand()) {
|
|
210
|
+
// eslint-disable-next-line no-console
|
|
211
|
+
console.error(
|
|
212
|
+
"No runner command configured. Set AGENT_MOCK_RESPONSE, LC_AGENT_RUNNER_CMD*, or use the built-in Codex/Claude Code runners; ChatGPT/Claude API fallbacks require provider API keys.",
|
|
213
|
+
);
|
|
214
|
+
printHelp();
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function log(...args) {
|
|
219
|
+
// eslint-disable-next-line no-console
|
|
220
|
+
console.log("[agent-bridge]", ...args);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function logVerbose(...args) {
|
|
224
|
+
if (!config.verbose) return;
|
|
225
|
+
// eslint-disable-next-line no-console
|
|
226
|
+
console.log("[agent-bridge:verbose]", ...args);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function api(path, { method = "GET", body } = {}) {
|
|
230
|
+
const response = await fetch(`${config.baseUrl}${path}`, {
|
|
231
|
+
method,
|
|
232
|
+
headers: {
|
|
233
|
+
Authorization: `Bearer ${config.token}`,
|
|
234
|
+
"Content-Type": "application/json",
|
|
235
|
+
},
|
|
236
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
let payload = null;
|
|
240
|
+
try {
|
|
241
|
+
payload = await response.json();
|
|
242
|
+
} catch (_error) {
|
|
243
|
+
payload = null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (!response.ok) {
|
|
247
|
+
const err = new Error(`HTTP ${response.status}: ${String(payload?.error ?? response.statusText ?? "Request failed")}`);
|
|
248
|
+
err.status = response.status;
|
|
249
|
+
err.payload = payload;
|
|
250
|
+
err.retryAfterSeconds = Number.parseInt(response.headers.get("retry-after") ?? "", 10) || null;
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
253
|
+
return payload;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function getTargetForRun(run) {
|
|
257
|
+
const mode = String(run?.promptMode ?? "").trim().toLowerCase();
|
|
258
|
+
if (mode === "gpt") return config.modeTargets.gpt;
|
|
259
|
+
if (mode === "codex") return config.modeTargets.codex;
|
|
260
|
+
return "other";
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function getCommandForRun(run) {
|
|
264
|
+
const mode = String(run?.promptMode ?? "").trim().toLowerCase();
|
|
265
|
+
const target = getTargetForRun(run);
|
|
266
|
+
|
|
267
|
+
if (mode === "gpt") {
|
|
268
|
+
if (target === "codex" && config.runnerCommands.codexTarget) return config.runnerCommands.codexTarget;
|
|
269
|
+
if (target === "claude_code" && config.runnerCommands.claudeCode) return config.runnerCommands.claudeCode;
|
|
270
|
+
if (target === "chatgpt" && config.runnerCommands.chatgpt) return config.runnerCommands.chatgpt;
|
|
271
|
+
if (target === "claude" && config.runnerCommands.claude) return config.runnerCommands.claude;
|
|
272
|
+
if (config.runnerCommands.gpt) return config.runnerCommands.gpt;
|
|
273
|
+
if (config.runnerCommands.generic) return config.runnerCommands.generic;
|
|
274
|
+
return "";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (mode === "codex") {
|
|
278
|
+
if (target === "codex" && config.runnerCommands.codexTarget) return config.runnerCommands.codexTarget;
|
|
279
|
+
if (target === "claude_code" && config.runnerCommands.claudeCode) return config.runnerCommands.claudeCode;
|
|
280
|
+
if (config.runnerCommands.codex) return config.runnerCommands.codex;
|
|
281
|
+
if (config.runnerCommands.generic) return config.runnerCommands.generic;
|
|
282
|
+
return "";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (config.runnerCommands.generic) return config.runnerCommands.generic;
|
|
286
|
+
return "";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function getClientTypeForRun(run) {
|
|
290
|
+
const configured = String(config.clientType ?? "").trim().toLowerCase();
|
|
291
|
+
if (configured && configured !== "auto") {
|
|
292
|
+
return normalizeTarget(configured, "other");
|
|
293
|
+
}
|
|
294
|
+
return getTargetForRun(run);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function sendHeartbeat(lastAction = "heartbeat", run = null) {
|
|
298
|
+
return api("/api/agent/v1/heartbeat", {
|
|
299
|
+
method: "POST",
|
|
300
|
+
body: {
|
|
301
|
+
projectId: config.projectId,
|
|
302
|
+
clientName: config.clientName,
|
|
303
|
+
clientType: getClientTypeForRun(run),
|
|
304
|
+
clientVersion: config.clientVersion,
|
|
305
|
+
lastAction,
|
|
306
|
+
lastActionAt: new Date().toISOString(),
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function claimNextRun() {
|
|
312
|
+
const payload = await api("/api/agent/v1/runs/next", {
|
|
313
|
+
method: "POST",
|
|
314
|
+
body: {
|
|
315
|
+
projectId: config.projectId,
|
|
316
|
+
clientName: config.clientName,
|
|
317
|
+
clientType: normalizeTarget(config.clientType === "auto" ? "other" : config.clientType, "other"),
|
|
318
|
+
clientVersion: config.clientVersion,
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
return payload?.run ?? null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function executeRunner(run) {
|
|
325
|
+
if (config.mockResponse) {
|
|
326
|
+
return {
|
|
327
|
+
stdout: String(config.mockResponse),
|
|
328
|
+
stderr: "",
|
|
329
|
+
exitCode: 0,
|
|
330
|
+
durationMs: 0,
|
|
331
|
+
usedMock: true,
|
|
332
|
+
target: getTargetForRun(run),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const commandTemplate = getCommandForRun(run);
|
|
337
|
+
if (!commandTemplate) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`No runner command configured for promptMode=${String(run?.promptMode ?? "unknown")} target=${getTargetForRun(run)}.`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
const command = buildRunnerCommand(commandTemplate, run);
|
|
343
|
+
const parsedCommand = parseRunnerCommand(command);
|
|
344
|
+
|
|
345
|
+
const startedAt = Date.now();
|
|
346
|
+
return new Promise((resolve, reject) => {
|
|
347
|
+
const child = spawn(parsedCommand.file, parsedCommand.args, {
|
|
348
|
+
shell: false,
|
|
349
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
350
|
+
env: {
|
|
351
|
+
...process.env,
|
|
352
|
+
LC_AGENT_RUN_ID: String(run.id ?? ""),
|
|
353
|
+
LC_AGENT_PROJECT_ID: String(run.projectId ?? ""),
|
|
354
|
+
LC_AGENT_PHASE_KEY: String(run.phaseKey ?? ""),
|
|
355
|
+
LC_AGENT_BUCKET_KEY: String(run.bucketKey ?? ""),
|
|
356
|
+
LC_AGENT_CARD_ID: String(run.cardId ?? ""),
|
|
357
|
+
LC_AGENT_PROMPT_MODE: String(run.promptMode ?? ""),
|
|
358
|
+
LC_AGENT_RUN_TYPE: String(run.runType ?? ""),
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
let stdout = "";
|
|
363
|
+
let stderr = "";
|
|
364
|
+
child.stdout.on("data", (chunk) => {
|
|
365
|
+
stdout += chunk.toString();
|
|
366
|
+
});
|
|
367
|
+
child.stderr.on("data", (chunk) => {
|
|
368
|
+
stderr += chunk.toString();
|
|
369
|
+
});
|
|
370
|
+
child.on("error", (error) => {
|
|
371
|
+
reject(error);
|
|
372
|
+
});
|
|
373
|
+
child.on("close", (code) => {
|
|
374
|
+
const durationMs = Date.now() - startedAt;
|
|
375
|
+
if ((code ?? 1) !== 0) {
|
|
376
|
+
const message = `Runner exited with code ${String(code ?? 1)}${stderr ? `: ${stderr.trim()}` : ""}`;
|
|
377
|
+
const error = new Error(message);
|
|
378
|
+
error.code = code ?? 1;
|
|
379
|
+
error.stdout = stdout;
|
|
380
|
+
error.stderr = stderr;
|
|
381
|
+
error.durationMs = durationMs;
|
|
382
|
+
reject(error);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
resolve({
|
|
386
|
+
stdout,
|
|
387
|
+
stderr,
|
|
388
|
+
exitCode: code ?? 0,
|
|
389
|
+
durationMs,
|
|
390
|
+
usedMock: false,
|
|
391
|
+
target: getTargetForRun(run),
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
child.stdin.write(String(run.prompt ?? ""));
|
|
396
|
+
child.stdin.end();
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function completeRun(run, completion) {
|
|
401
|
+
const status = completion.status === "failed" ? "failed" : "completed";
|
|
402
|
+
const resultRaw = trimToBytes(completion.resultRaw ?? "", config.maxPayloadBytes);
|
|
403
|
+
const resultJsonPayload = trimToBytes(completion.resultJsonPayload ?? "", config.maxPayloadBytes);
|
|
404
|
+
return api(`/api/agent/v1/runs/${encodeURIComponent(String(run.id))}/complete`, {
|
|
405
|
+
method: "POST",
|
|
406
|
+
body: {
|
|
407
|
+
status,
|
|
408
|
+
resultRaw,
|
|
409
|
+
resultJsonPayload,
|
|
410
|
+
resultMeta: completion.resultMeta ?? {},
|
|
411
|
+
error: completion.error ?? null,
|
|
412
|
+
clientName: config.clientName,
|
|
413
|
+
clientType: getClientTypeForRun(run),
|
|
414
|
+
clientVersion: config.clientVersion,
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
let shuttingDown = false;
|
|
420
|
+
process.on("SIGINT", () => {
|
|
421
|
+
shuttingDown = true;
|
|
422
|
+
log("Stopping bridge (SIGINT)...");
|
|
423
|
+
});
|
|
424
|
+
process.on("SIGTERM", () => {
|
|
425
|
+
shuttingDown = true;
|
|
426
|
+
log("Stopping bridge (SIGTERM)...");
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
async function runOnce() {
|
|
430
|
+
const run = await claimNextRun();
|
|
431
|
+
if (!run) {
|
|
432
|
+
logVerbose("No queued run.");
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const target = getTargetForRun(run);
|
|
437
|
+
log(`Claimed run ${run.id} (${run.runType}/${run.promptMode} -> ${target})`);
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
const execution = await executeRunner(run);
|
|
441
|
+
const stdout = String(execution.stdout ?? "").trim();
|
|
442
|
+
const stderr = String(execution.stderr ?? "").trim();
|
|
443
|
+
const output = stdout || stderr;
|
|
444
|
+
await completeRun(run, {
|
|
445
|
+
status: "completed",
|
|
446
|
+
resultRaw: output,
|
|
447
|
+
resultJsonPayload: output,
|
|
448
|
+
resultMeta: {
|
|
449
|
+
exitCode: execution.exitCode ?? 0,
|
|
450
|
+
durationMs: execution.durationMs ?? 0,
|
|
451
|
+
usedMock: execution.usedMock === true,
|
|
452
|
+
target,
|
|
453
|
+
autoApplyRequested: run.autoApply === true,
|
|
454
|
+
runIntent: run.runIntent ?? "initial",
|
|
455
|
+
attemptNumber: run.attemptNumber ?? 1,
|
|
456
|
+
maxAttempts: run.maxAttempts ?? 1,
|
|
457
|
+
},
|
|
458
|
+
error: null,
|
|
459
|
+
});
|
|
460
|
+
log(`Completed run ${run.id}`);
|
|
461
|
+
} catch (error) {
|
|
462
|
+
const message = error instanceof Error ? error.message : String(error ?? "Runner failed");
|
|
463
|
+
const stdout = typeof error?.stdout === "string" ? error.stdout : "";
|
|
464
|
+
const stderr = typeof error?.stderr === "string" ? error.stderr : "";
|
|
465
|
+
const durationMs = typeof error?.durationMs === "number" ? error.durationMs : null;
|
|
466
|
+
await completeRun(run, {
|
|
467
|
+
status: "failed",
|
|
468
|
+
resultRaw: `${stdout}\n${stderr}`.trim() || message,
|
|
469
|
+
resultJsonPayload: "",
|
|
470
|
+
resultMeta: {
|
|
471
|
+
exitCode: typeof error?.code === "number" ? error.code : null,
|
|
472
|
+
durationMs,
|
|
473
|
+
target,
|
|
474
|
+
},
|
|
475
|
+
error: message,
|
|
476
|
+
});
|
|
477
|
+
log(`Failed run ${run.id}: ${message}`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function main() {
|
|
484
|
+
log(
|
|
485
|
+
`Starting bridge -> ${config.baseUrl} | client=${config.clientName} (${config.clientType}) | gpt=${config.modeTargets.gpt} codex=${config.modeTargets.codex} | project=${config.projectId ?? "all"}`,
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
let lastHeartbeat = 0;
|
|
489
|
+
while (!shuttingDown) {
|
|
490
|
+
const now = Date.now();
|
|
491
|
+
if (now - lastHeartbeat >= config.heartbeatMs) {
|
|
492
|
+
try {
|
|
493
|
+
await sendHeartbeat("heartbeat", null);
|
|
494
|
+
lastHeartbeat = now;
|
|
495
|
+
logVerbose("Heartbeat sent.");
|
|
496
|
+
} catch (error) {
|
|
497
|
+
const message = error instanceof Error ? error.message : String(error ?? "Heartbeat failed");
|
|
498
|
+
const status = typeof error?.status === "number" ? error.status : 0;
|
|
499
|
+
log(`Heartbeat failed: ${message}`);
|
|
500
|
+
if (status === 401 || status === 403) {
|
|
501
|
+
process.exitCode = 1;
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (status === 429) {
|
|
505
|
+
const retryMs = Math.max(config.pollMs, (Number(error?.retryAfterSeconds) || 30) * 1000);
|
|
506
|
+
log(`Rate limited; pausing heartbeat for ${Math.round(retryMs / 1000)}s.`);
|
|
507
|
+
await sleep(retryMs);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
try {
|
|
513
|
+
const processed = await runOnce();
|
|
514
|
+
if (config.once) return;
|
|
515
|
+
if (!processed) await sleep(config.pollMs);
|
|
516
|
+
} catch (error) {
|
|
517
|
+
const message = error instanceof Error ? error.message : String(error ?? "Bridge loop failure");
|
|
518
|
+
const status = typeof error?.status === "number" ? error.status : 0;
|
|
519
|
+
log(`Loop error: ${message}`);
|
|
520
|
+
if (status === 401 || status === 403) {
|
|
521
|
+
process.exitCode = 1;
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (status === 429) {
|
|
525
|
+
const retryMs = Math.max(config.pollMs, (Number(error?.retryAfterSeconds) || 30) * 1000);
|
|
526
|
+
log(`Rate limited; pausing run polling for ${Math.round(retryMs / 1000)}s.`);
|
|
527
|
+
await sleep(retryMs);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
await sleep(Math.max(config.pollMs, 2000));
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
main().catch((error) => {
|
|
536
|
+
const message = error instanceof Error ? error.stack ?? error.message : String(error);
|
|
537
|
+
// eslint-disable-next-line no-console
|
|
538
|
+
console.error(message);
|
|
539
|
+
process.exit(1);
|
|
540
|
+
});
|