@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.
@@ -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
+ });