@love-moon/conductor-cli 0.1.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,903 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * conductor-fire - Conductor-aware AI coding agent runner.
5
+ *
6
+ * Supports multiple backends: codex, claude, copilot, tmates, cursor, gemini.
7
+ * This CLI bridges various AI coding agents with Conductor via MCP.
8
+ */
9
+
10
+ import fs from "node:fs";
11
+ import { createRequire } from "node:module";
12
+ import path from "node:path";
13
+ import process from "node:process";
14
+ import { setTimeout as delay } from "node:timers/promises";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
18
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
19
+ import yargs from "yargs/yargs";
20
+ import { hideBin } from "yargs/helpers";
21
+ import { cli2sdk } from "@conductor/cli2sdk";
22
+ import { loadConfig } from "@conductor/sdk";
23
+ import {
24
+ loadHistoryFromSpec,
25
+ parseFromSpec,
26
+ selectHistorySession,
27
+ SUPPORTED_FROM_PROVIDERS,
28
+ } from "../src/fire/history.js";
29
+
30
+ const __filename = fileURLToPath(import.meta.url);
31
+ const __dirname = path.dirname(__filename);
32
+ const require = createRequire(import.meta.url);
33
+ const PKG_ROOT = path.join(__dirname, "..");
34
+ const CLI_PROJECT_PATH = process.cwd();
35
+ const REPO_ROOT_FALLBACK = path.resolve(__dirname, "..", "..");
36
+ const SDK_FALLBACK_PATH = path.join(REPO_ROOT_FALLBACK, "sdk", "typescript");
37
+ const SDK_ROOT =
38
+ process.env.CONDUCTOR_SDK_PATH ||
39
+ (() => {
40
+ try {
41
+ return path.dirname(require.resolve("@conductor/sdk/package.json"));
42
+ } catch {
43
+ return SDK_FALLBACK_PATH;
44
+ }
45
+ })();
46
+ const MCP_SERVER_SCRIPT = path.join(SDK_ROOT, "dist", "bin", "mcp-server.js");
47
+
48
+ const pkgJson = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf-8"));
49
+ const CLI_NAME = (process.env.CONDUCTOR_CLI_NAME || path.basename(process.argv[1] || "conductor-fire")).replace(
50
+ /\.js$/,
51
+ "",
52
+ );
53
+
54
+ // Supported backends
55
+ const SUPPORTED_BACKENDS = ["codex", "claude", "copilot", "tmates", "cursor", "gemini"];
56
+ const DEFAULT_BACKEND = process.env.CONDUCTOR_BACKEND || "codex";
57
+
58
+ const DEFAULT_POLL_INTERVAL_MS = parseInt(
59
+ process.env.CONDUCTOR_CLI_POLL_INTERVAL_MS || process.env.CCODEX_POLL_INTERVAL_MS || "2000",
60
+ 10,
61
+ );
62
+
63
+ const MCP_SERVER_LAUNCH = {
64
+ command: "node",
65
+ args: [MCP_SERVER_SCRIPT],
66
+ cwd: SDK_ROOT,
67
+ };
68
+
69
+ async function main() {
70
+ const cliArgs = parseCliArgs();
71
+
72
+ if (cliArgs.showHelp) {
73
+ return;
74
+ }
75
+
76
+ if (cliArgs.showVersion) {
77
+ process.stdout.write(`${CLI_NAME} version ${pkgJson.version}\n`);
78
+ return;
79
+ }
80
+
81
+ if (cliArgs.listBackends) {
82
+ process.stdout.write(`Supported backends: ${SUPPORTED_BACKENDS.join(", ")}\n`);
83
+ process.stdout.write(`Default: ${DEFAULT_BACKEND}\n`);
84
+ return;
85
+ }
86
+
87
+ let fromHistory = { history: [], provider: null, sessionId: null };
88
+ if (cliArgs.fromSpec) {
89
+ const provider = cliArgs.fromSpec.provider;
90
+ if (!SUPPORTED_FROM_PROVIDERS.includes(provider)) {
91
+ throw new Error(`--from only supports: ${SUPPORTED_FROM_PROVIDERS.join(", ")}`);
92
+ }
93
+
94
+ let resolvedSpec = cliArgs.fromSpec;
95
+ if (!resolvedSpec.sessionId) {
96
+ const selected = await selectHistorySession(provider);
97
+ if (!selected) {
98
+ log("No session selected. Starting a new conversation.");
99
+ resolvedSpec = null;
100
+ } else {
101
+ resolvedSpec = parseFromSpec(`${provider}:${selected.sessionId}`);
102
+ }
103
+ }
104
+
105
+ if (resolvedSpec) {
106
+ if (cliArgs.backend !== resolvedSpec.provider) {
107
+ log(
108
+ `Ignoring --from ${resolvedSpec.provider}:${resolvedSpec.sessionId} because backend is ${cliArgs.backend}`,
109
+ );
110
+ } else {
111
+ fromHistory = await loadHistoryFromSpec(resolvedSpec);
112
+ if (fromHistory.warning) {
113
+ log(fromHistory.warning);
114
+ } else {
115
+ log(
116
+ `Loaded ${fromHistory.history.length} history messages from ${fromHistory.provider} session ${fromHistory.sessionId}`,
117
+ );
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ // Create backend session based on selected backend
124
+ const backendSession = createBackendSession(cliArgs.backend, {
125
+ threadOptions: cliArgs.threadOptions,
126
+ initialImages: cliArgs.initialImages,
127
+ cwd: CLI_PROJECT_PATH,
128
+ initialHistory: fromHistory.history,
129
+ resumeSessionId: fromHistory.sessionId,
130
+ });
131
+
132
+ log(`Using backend: ${cliArgs.backend}`);
133
+
134
+ const env = buildEnv();
135
+ if (cliArgs.configFile) {
136
+ env.CONDUCTOR_CONFIG = cliArgs.configFile;
137
+ }
138
+ try {
139
+ const config = loadConfig(cliArgs.configFile);
140
+ if (config.backendUrl && !env.CONDUCTOR_BACKEND_URL) {
141
+ env.CONDUCTOR_BACKEND_URL = config.backendUrl;
142
+ }
143
+ if (config.websocketUrl && !env.CONDUCTOR_WS_URL) {
144
+ env.CONDUCTOR_WS_URL = config.websocketUrl;
145
+ }
146
+ } catch (error) {
147
+ // Ignore config loading errors, rely on env vars or defaults
148
+ }
149
+
150
+ const conductor = await ConductorClient.connect({
151
+ launcher: MCP_SERVER_LAUNCH,
152
+ workingDirectory: CLI_PROJECT_PATH,
153
+ projectPath: CLI_PROJECT_PATH,
154
+ extraEnv: env,
155
+ });
156
+
157
+ const taskContext = await ensureTaskContext(conductor, {
158
+ initialPrompt: cliArgs.initialPrompt,
159
+ requestedProjectId: process.env.CONDUCTOR_PROJECT_ID,
160
+ providedTaskId: process.env.CONDUCTOR_TASK_ID,
161
+ requestedTitle: cliArgs.taskTitle || process.env.CONDUCTOR_TASK_TITLE,
162
+ backend: cliArgs.backend,
163
+ });
164
+
165
+ log(
166
+ `Attached to Conductor task ${taskContext.taskId}${
167
+ taskContext.appUrl ? ` (${taskContext.appUrl})` : ""
168
+ }`,
169
+ );
170
+
171
+ const runner = new BridgeRunner({
172
+ backendSession,
173
+ conductor,
174
+ taskId: taskContext.taskId,
175
+ pollIntervalMs: Math.max(cliArgs.pollIntervalMs, 500),
176
+ initialPrompt: taskContext.shouldProcessInitialPrompt ? cliArgs.initialPrompt : "",
177
+ includeInitialImages: Boolean(cliArgs.initialPrompt && cliArgs.initialImages.length),
178
+ cliArgs: cliArgs.rawBackendArgs,
179
+ backendName: cliArgs.backend,
180
+ });
181
+
182
+ const signals = new AbortController();
183
+ process.on("SIGINT", () => signals.abort());
184
+ process.on("SIGTERM", () => signals.abort());
185
+
186
+ try {
187
+ await runner.start(signals.signal);
188
+ } finally {
189
+ await conductor.close();
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Create a backend session based on the selected backend type.
195
+ */
196
+ function createBackendSession(backend, options) {
197
+ // Use cli2sdk for all backends including codex
198
+ return new Cli2SdkSession(backend, options);
199
+ }
200
+
201
+ function parseCliArgs() {
202
+ const argv = hideBin(process.argv);
203
+ const separatorIndex = argv.indexOf("--");
204
+
205
+ // When no separator, parse all args first to check for conductor-specific options
206
+ const conductorArgv = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex);
207
+ const backendArgv = separatorIndex === -1 ? argv : argv.slice(separatorIndex + 1);
208
+
209
+ // Check for version/list-backends/help without separator
210
+ const versionWithoutSeparator = separatorIndex === -1 && (argv.includes("--version") || argv.includes("-v"));
211
+ const listBackendsWithoutSeparator = separatorIndex === -1 && argv.includes("--list-backends");
212
+ const helpWithoutSeparator = separatorIndex === -1 && (argv.includes("--help") || argv.includes("-h"));
213
+
214
+ const conductorArgs = yargs(conductorArgv)
215
+ .scriptName(CLI_NAME)
216
+ .usage("Usage: $0 [--backend <name>] -- [backend options and prompt]")
217
+ .version(false)
218
+ .option("backend", {
219
+ alias: "b",
220
+ type: "string",
221
+ describe: `Backend to use (${SUPPORTED_BACKENDS.join(", ")})`,
222
+ default: DEFAULT_BACKEND,
223
+ choices: SUPPORTED_BACKENDS,
224
+ })
225
+ .option("list-backends", {
226
+ type: "boolean",
227
+ describe: "List available backends and exit",
228
+ })
229
+ .option("config-file", {
230
+ type: "string",
231
+ describe: "Path to Conductor config file",
232
+ })
233
+ .option("poll-interval", {
234
+ type: "number",
235
+ describe: "Polling interval (ms) when waiting for Conductor messages",
236
+ })
237
+ .option("title", {
238
+ alias: "t",
239
+ type: "string",
240
+ describe: "Optional task title shown in the app task list",
241
+ })
242
+ .option("from", {
243
+ alias: "f",
244
+ type: "string",
245
+ describe: "Resume from local history (optional session id; otherwise pick interactively)",
246
+ })
247
+ .option("from-provider", {
248
+ type: "string",
249
+ describe: "Provider for --from picker (defaults to --backend)",
250
+ choices: SUPPORTED_FROM_PROVIDERS,
251
+ })
252
+ .option("prefill", {
253
+ type: "string",
254
+ describe: "Optional initial prompt forwarded to Conductor on task creation",
255
+ hidden: true,
256
+ })
257
+ .option("version", {
258
+ alias: "v",
259
+ type: "boolean",
260
+ describe: "Show Conductor CLI version and exit",
261
+ })
262
+ .help()
263
+ .strict(false)
264
+ .parserConfiguration({
265
+ "unknown-options-as-args": true,
266
+ "camel-case-expansion": true,
267
+ })
268
+ .parse();
269
+
270
+ // Handle help early
271
+ if (helpWithoutSeparator) {
272
+ process.stdout.write(`${CLI_NAME} - Conductor-aware AI coding agent runner
273
+
274
+ Usage: ${CLI_NAME} [options] -- [backend options and prompt]
275
+
276
+ Options:
277
+ -b, --backend <name> Backend to use (${SUPPORTED_BACKENDS.join(", ")}) [default: ${DEFAULT_BACKEND}]
278
+ --list-backends List available backends and exit
279
+ --config-file <path> Path to Conductor config file
280
+ --poll-interval <ms> Polling interval when waiting for Conductor messages
281
+ -t, --title <text> Optional task title shown in the app task list
282
+ -f, --from [id] Resume from local history (pick if id omitted)
283
+ --from-provider <p> Provider for --from picker (codex or claude)
284
+ -v, --version Show Conductor CLI version and exit
285
+ -h, --help Show this help message
286
+
287
+ Examples:
288
+ ${CLI_NAME} -- "fix the bug" # Use default backend (codex)
289
+ ${CLI_NAME} --backend claude -- "fix the bug" # Use Claude CLI backend
290
+ ${CLI_NAME} --backend tmates -- "fix the bug" # Use TMates CLI backend
291
+ ${CLI_NAME} --backend copilot -- "fix the bug" # Use Copilot CLI backend
292
+ ${CLI_NAME} --config-file ~/.conductor/config.yaml -- "fix the bug"
293
+ ${CLI_NAME} --backend codex --from -- "continue"
294
+ ${CLI_NAME} --backend codex --from codex-session-id -- "continue"
295
+
296
+ Environment:
297
+ CONDUCTOR_BACKEND Default backend (overrides codex)
298
+ CONDUCTOR_PROJECT_ID Project ID to attach to
299
+ CONDUCTOR_TASK_ID Attach to existing task instead of creating new one
300
+ `);
301
+ return { showHelp: true };
302
+ }
303
+
304
+ const backendArgs = yargs(backendArgv)
305
+ .help(false)
306
+ .version(false)
307
+ .strict(false)
308
+ .parserConfiguration({
309
+ "unknown-options-as-args": true,
310
+ "camel-case-expansion": true,
311
+ })
312
+ .parse();
313
+
314
+ // Apply codex-specific defaults when using codex backend
315
+ const backend = conductorArgs.backend || DEFAULT_BACKEND;
316
+ if (backend === "codex") {
317
+ backendArgs.sandboxMode ||= "danger-full-access";
318
+ backendArgs.approvalPolicy ||= "never";
319
+ backendArgs.skipGitRepoCheck ||= true;
320
+ }
321
+
322
+ const prompt = (backendArgs._ || []).map((part) => String(part)).join(" ").trim();
323
+ const initialImages = normalizeArray(backendArgs.image || backendArgs.i).map((img) => String(img));
324
+ const pollIntervalMs = Number.isFinite(conductorArgs.pollInterval)
325
+ ? Number(conductorArgs.pollInterval)
326
+ : DEFAULT_POLL_INTERVAL_MS;
327
+
328
+ let fromSpec = null;
329
+ try {
330
+ fromSpec = parseFromSpec(conductorArgs.from, conductorArgs.fromProvider, backend);
331
+ } catch (error) {
332
+ throw new Error(error.message);
333
+ }
334
+
335
+ return {
336
+ backend,
337
+ initialPrompt: prompt || conductorArgs.prefill || "",
338
+ initialImages,
339
+ threadOptions: deriveThreadOptions(backendArgs, CLI_PROJECT_PATH),
340
+ pollIntervalMs,
341
+ rawBackendArgs: backendArgv,
342
+ taskTitle: resolveTaskTitle(conductorArgs.title),
343
+ configFile: conductorArgs.configFile,
344
+ fromSpec,
345
+ showVersion: Boolean(conductorArgs.version) || versionWithoutSeparator,
346
+ listBackends: Boolean(conductorArgs.listBackends) || listBackendsWithoutSeparator,
347
+ };
348
+ }
349
+
350
+ function resolveTaskTitle(titleFlag) {
351
+ if (typeof titleFlag === "string" && titleFlag.trim()) {
352
+ return titleFlag.trim();
353
+ }
354
+ const cwdName = path.basename(CLI_PROJECT_PATH || process.cwd());
355
+ return cwdName || "";
356
+ }
357
+
358
+ function deriveThreadOptions(codexArgs, defaultWorkingDirectory = CLI_PROJECT_PATH) {
359
+ const options = {};
360
+ const allowedKeys = new Set([
361
+ "model",
362
+ "sandboxMode",
363
+ "workingDirectory",
364
+ "skipGitRepoCheck",
365
+ "modelReasoningEffort",
366
+ "networkAccessEnabled",
367
+ "webSearchEnabled",
368
+ "approvalPolicy",
369
+ ]);
370
+
371
+ Object.entries(codexArgs || {}).forEach(([key, value]) => {
372
+ if (key === "_" || key === "$0") {
373
+ return;
374
+ }
375
+ const optionKey = mapCodexFlagToThreadOption(key);
376
+ if (allowedKeys.has(optionKey)) {
377
+ options[optionKey] = value;
378
+ }
379
+ });
380
+
381
+ if (codexArgs.fullAuto) {
382
+ options.sandboxMode ||= "workspace-write";
383
+ options.approvalPolicy ||= "on-request";
384
+ }
385
+
386
+ if (codexArgs.dangerouslyBypassApprovalsAndSandbox) {
387
+ options.sandboxMode = "danger-full-access";
388
+ options.approvalPolicy = "never";
389
+ }
390
+
391
+ if (!options.workingDirectory && defaultWorkingDirectory) {
392
+ options.workingDirectory = defaultWorkingDirectory;
393
+ }
394
+
395
+ return options;
396
+ }
397
+
398
+ function mapCodexFlagToThreadOption(flag) {
399
+ const normalized = toCamelCase(flag);
400
+ if (normalized === "sandbox") {
401
+ return "sandboxMode";
402
+ }
403
+ if (normalized === "askForApproval" || normalized === "approval") {
404
+ return "approvalPolicy";
405
+ }
406
+ if (normalized === "cd" || normalized === "c" || normalized === "workingDirectory") {
407
+ return "workingDirectory";
408
+ }
409
+ if (normalized === "search" || normalized === "webSearch") {
410
+ return "webSearchEnabled";
411
+ }
412
+ if (normalized === "networkAccess" || normalized === "network") {
413
+ return "networkAccessEnabled";
414
+ }
415
+ return normalized;
416
+ }
417
+
418
+ function toCamelCase(value) {
419
+ return String(value || "")
420
+ .replace(/^[\s-_]+|[\s-_]+$/g, "")
421
+ .replace(/[-_]+(.)/g, (_, chr) => (chr ? chr.toUpperCase() : ""))
422
+ .replace(/^[A-Z]/, (chr) => chr.toLowerCase());
423
+ }
424
+
425
+ function normalizeArray(value) {
426
+ if (!value) {
427
+ return [];
428
+ }
429
+ return Array.isArray(value) ? value : [value];
430
+ }
431
+
432
+ function buildEnv() {
433
+ const env = { ...process.env };
434
+ env.NO_PROXY = env.NO_PROXY || "127.0.0.1,localhost";
435
+ env.no_proxy = env.no_proxy || env.NO_PROXY;
436
+ return env;
437
+ }
438
+
439
+ async function ensureTaskContext(conductor, opts) {
440
+ if (opts.providedTaskId) {
441
+ return {
442
+ taskId: opts.providedTaskId,
443
+ appUrl: null,
444
+ shouldProcessInitialPrompt: Boolean(opts.initialPrompt),
445
+ };
446
+ }
447
+
448
+ const projectId = await resolveProjectId(conductor, opts.requestedProjectId);
449
+ const payload = {
450
+ project_id: projectId,
451
+ task_title: deriveTaskTitle(opts.initialPrompt, opts.requestedTitle, opts.backend),
452
+ };
453
+ if (opts.initialPrompt) {
454
+ payload.prefill = opts.initialPrompt;
455
+ }
456
+
457
+ const session = await conductor.createTaskSession(payload);
458
+ return {
459
+ taskId: session.task_id,
460
+ appUrl: session.app_url || null,
461
+ shouldProcessInitialPrompt: Boolean(opts.initialPrompt),
462
+ };
463
+ }
464
+
465
+ async function resolveProjectId(conductor, explicit) {
466
+ if (explicit) {
467
+ return explicit;
468
+ }
469
+
470
+ try {
471
+ const record = await conductor.getLocalProjectRecord();
472
+ if (record?.project_id) {
473
+ return record.project_id;
474
+ }
475
+ } catch (error) {
476
+ log(`Unable to resolve project via local session: ${error.message}`);
477
+ }
478
+
479
+ const listing = await conductor.listProjects();
480
+ const first = listing?.projects?.[0];
481
+ if (first?.id) {
482
+ return first.id;
483
+ }
484
+ log("No projects available; creating default project...");
485
+ try {
486
+ const created = await conductor.createProject("default", "Auto-created by conductor-fire");
487
+ if (created?.id) {
488
+ log(`Created default project ${created.id}`);
489
+ return created.id;
490
+ }
491
+ throw new Error("create_project returned no id");
492
+ } catch (error) {
493
+ throw new Error(`Unable to resolve project and failed to create default: ${error.message}`);
494
+ }
495
+ }
496
+
497
+ function deriveTaskTitle(prompt, explicit, backend = "codex") {
498
+ if (explicit && explicit.trim()) {
499
+ return explicit.trim();
500
+ }
501
+ if (prompt) {
502
+ const compact = prompt.replace(/\s+/g, " ").trim();
503
+ if (compact) {
504
+ return compact.slice(0, 80);
505
+ }
506
+ }
507
+ const backendName = backend.charAt(0).toUpperCase() + backend.slice(1);
508
+ return `${backendName} Task`;
509
+ }
510
+
511
+ /**
512
+ * Cli2SdkSession - Backend session using cli2sdk providers (all backends including codex).
513
+ */
514
+ class Cli2SdkSession {
515
+ constructor(backend, options = {}) {
516
+ this.backend = backend;
517
+ this.options = options;
518
+
519
+ // For codex, pass threadOptions as config
520
+ const config = backend === "codex"
521
+ ? { provider: backend, codex: options.threadOptions }
522
+ : { provider: backend };
523
+
524
+ this.sdk = cli2sdk(config);
525
+ this.sessionId = options.resumeSessionId || `${backend}-${Date.now()}`;
526
+ this.history = Array.isArray(options.initialHistory) ? [...options.initialHistory] : [];
527
+ }
528
+
529
+ get threadId() {
530
+ return this.sessionId;
531
+ }
532
+
533
+ get threadOptions() {
534
+ return this.backend === "codex" ? this.options.threadOptions : { model: this.backend };
535
+ }
536
+
537
+ async runTurn(promptText, { useInitialImages = false } = {}) {
538
+ if (!promptText || !promptText.trim()) {
539
+ return {
540
+ text: "",
541
+ usage: null,
542
+ items: [],
543
+ events: [],
544
+ };
545
+ }
546
+
547
+ log(`[${this.backend}] Running prompt: ${promptText.slice(0, 100)}...`);
548
+
549
+ try {
550
+ if (this.backend === "codex" && useInitialImages && this.options.initialImages?.length > 0) {
551
+ log("[codex] Initial images are not supported via codex CLI; ignoring images.");
552
+ }
553
+
554
+ const cwd =
555
+ this.backend === "codex" && this.options.threadOptions?.workingDirectory
556
+ ? this.options.threadOptions.workingDirectory
557
+ : this.options.cwd;
558
+
559
+ const result = await this.sdk.run(promptText, {
560
+ provider: this.backend,
561
+ history: this.history,
562
+ cwd,
563
+ });
564
+
565
+ // Update history for multi-turn conversations
566
+ this.history.push({ role: "user", content: promptText });
567
+ this.history.push({ role: "assistant", content: result.text });
568
+
569
+ log(`[${this.backend}] Response received: ${result.text.slice(0, 100)}...`);
570
+
571
+ return {
572
+ text: result.text || "",
573
+ usage: result.usage || null,
574
+ items: result.metadata?.items || [],
575
+ events: result.metadata?.events || [],
576
+ provider: this.backend,
577
+ metadata: result.metadata,
578
+ };
579
+ } catch (error) {
580
+ log(`[${this.backend}] Error: ${error.message}`);
581
+ throw error;
582
+ }
583
+ }
584
+ }
585
+
586
+ class ConductorClient {
587
+ constructor(client, transport, options = {}) {
588
+ this.client = client;
589
+ this.transport = transport;
590
+ this.closed = false;
591
+ this.projectPath = options.projectPath || process.cwd();
592
+ }
593
+
594
+ static async connect({ launcher, workingDirectory, extraEnv, projectPath }) {
595
+ if (!fs.existsSync(launcher.args[0])) {
596
+ throw new Error(`Conductor MCP server not found at ${launcher.args[0]}`);
597
+ }
598
+
599
+ const transport = new StdioClientTransport({
600
+ command: launcher.command,
601
+ args: launcher.args,
602
+ env: extraEnv,
603
+ cwd: launcher.cwd || workingDirectory,
604
+ stderr: "pipe",
605
+ });
606
+
607
+ const client = new Client({
608
+ name: CLI_NAME,
609
+ version: pkgJson.version,
610
+ });
611
+
612
+ client.onerror = (err) => {
613
+ log(`MCP client error: ${err.message}`);
614
+ };
615
+
616
+ const stderr = transport.stderr;
617
+ if (stderr) {
618
+ stderr.setEncoding("utf-8");
619
+ stderr.on("data", (chunk) => {
620
+ const text = chunk.toString();
621
+ const ts = new Date().toLocaleString("sv-SE", { timeZone: "Asia/Shanghai" }).replace(" ", "T");
622
+ text
623
+ .split(/\r?\n/)
624
+ .filter(Boolean)
625
+ .forEach((line) => process.stderr.write(`[conductor ${ts}] ${line}\n`));
626
+ });
627
+ }
628
+
629
+ await client.connect(transport);
630
+ return new ConductorClient(client, transport, { projectPath });
631
+ }
632
+
633
+ async close() {
634
+ if (this.closed) {
635
+ return;
636
+ }
637
+ this.closed = true;
638
+ await this.client.close();
639
+ await this.transport.close();
640
+ }
641
+
642
+ injectProjectPath(payload = {}) {
643
+ if (!this.projectPath || payload.project_path) {
644
+ return payload;
645
+ }
646
+ return { ...payload, project_path: this.projectPath };
647
+ }
648
+
649
+ async createTaskSession(payload) {
650
+ const args = payload ? { ...payload } : {};
651
+ return this.callTool("create_task_session", this.injectProjectPath(args));
652
+ }
653
+
654
+ async sendMessage(taskId, content, metadata) {
655
+ return this.callTool("send_message", {
656
+ task_id: taskId,
657
+ content,
658
+ metadata,
659
+ });
660
+ }
661
+
662
+ async receiveMessages(taskId, limit = 20) {
663
+ return this.callTool("receive_messages", {
664
+ task_id: taskId,
665
+ limit,
666
+ });
667
+ }
668
+
669
+ async ackMessages(taskId, ackToken) {
670
+ if (!ackToken) {
671
+ return;
672
+ }
673
+ await this.callTool("ack_messages", { task_id: taskId, ack_token: ackToken });
674
+ }
675
+
676
+ async listProjects() {
677
+ return this.callTool("list_projects", {});
678
+ }
679
+
680
+ async createProject(name, description, metadata) {
681
+ return this.callTool("create_project", {
682
+ name,
683
+ description,
684
+ metadata,
685
+ });
686
+ }
687
+
688
+ async getLocalProjectRecord() {
689
+ return this.callTool("get_local_project_id", this.injectProjectPath({}));
690
+ }
691
+
692
+ async callTool(name, args) {
693
+ const result = await this.client.callTool({
694
+ name,
695
+ arguments: args || {},
696
+ });
697
+ if (result?.isError) {
698
+ const message = extractFirstText(result?.content) || `tool ${name} failed`;
699
+ throw new Error(message);
700
+ }
701
+ const structured = result?.structuredContent;
702
+ if (structured && Object.keys(structured).length > 0) {
703
+ return structured;
704
+ }
705
+ const textPayload = extractFirstText(result?.content);
706
+ if (!textPayload) {
707
+ return undefined;
708
+ }
709
+ try {
710
+ return JSON.parse(textPayload);
711
+ } catch {
712
+ return textPayload;
713
+ }
714
+ }
715
+ }
716
+
717
+ function extractFirstText(blocks) {
718
+ if (!Array.isArray(blocks)) {
719
+ return "";
720
+ }
721
+ for (const block of blocks) {
722
+ if (block?.type === "text" && typeof block.text === "string") {
723
+ const trimmed = block.text.trim();
724
+ if (trimmed) {
725
+ return trimmed;
726
+ }
727
+ }
728
+ }
729
+ return "";
730
+ }
731
+
732
+ class BridgeRunner {
733
+ constructor({
734
+ backendSession,
735
+ conductor,
736
+ taskId,
737
+ pollIntervalMs,
738
+ initialPrompt,
739
+ includeInitialImages,
740
+ cliArgs,
741
+ backendName,
742
+ }) {
743
+ this.backendSession = backendSession;
744
+ this.conductor = conductor;
745
+ this.taskId = taskId;
746
+ this.pollIntervalMs = pollIntervalMs;
747
+ this.initialPrompt = initialPrompt;
748
+ this.includeInitialImages = includeInitialImages;
749
+ this.cliArgs = cliArgs;
750
+ this.backendName = backendName || "codex";
751
+ this.stopped = false;
752
+ this.runningTurn = false;
753
+ }
754
+
755
+ async start(abortSignal) {
756
+ abortSignal?.addEventListener("abort", () => {
757
+ this.stopped = true;
758
+ });
759
+
760
+ if (this.initialPrompt) {
761
+ await this.handleSyntheticMessage(this.initialPrompt, {
762
+ includeImages: this.includeInitialImages,
763
+ });
764
+ }
765
+
766
+ while (!this.stopped) {
767
+ let processed = false;
768
+ try {
769
+ processed = await this.processIncomingBatch();
770
+ } catch (error) {
771
+ log(`Error while processing messages: ${error.message}`);
772
+ await this.reportError(`处理任务失败: ${error.message}`);
773
+ }
774
+ if (!processed) {
775
+ await delay(this.pollIntervalMs);
776
+ }
777
+ }
778
+ }
779
+
780
+ async processIncomingBatch() {
781
+ const result = await this.conductor.receiveMessages(this.taskId, 20);
782
+ const messages = Array.isArray(result?.messages) ? result.messages : [];
783
+ if (messages.length === 0) {
784
+ return false;
785
+ }
786
+
787
+ for (const message of messages) {
788
+ if (!this.shouldRespond(message)) {
789
+ continue;
790
+ }
791
+ await this.respondToMessage(message);
792
+ }
793
+
794
+ const ackToken = result.next_ack_token || result.nextAckToken;
795
+ if (ackToken) {
796
+ await this.conductor.ackMessages(this.taskId, ackToken);
797
+ }
798
+ const hasMore = Boolean(result?.has_more);
799
+ return hasMore;
800
+ }
801
+
802
+ shouldRespond(message) {
803
+ if (!message || typeof message !== "object") {
804
+ return false;
805
+ }
806
+ const role = String(message.role || "").toLowerCase();
807
+ return role === "user" || role === "action";
808
+ }
809
+
810
+ async respondToMessage(message) {
811
+ const content = String(message.content || "").trim();
812
+ if (!content) {
813
+ return;
814
+ }
815
+ const replyTo = message.message_id;
816
+ log(`Processing message ${replyTo} (${message.role})`);
817
+ try {
818
+ const result = await this.backendSession.runTurn(content);
819
+ const responseText =
820
+ result.text || extractAgentTextFromItems(result.items) || `(${this.backendName} 未返回任何文本)`;
821
+ logBackendReply(this.backendName, responseText, { usage: result.usage, replyTo: replyTo || "latest" });
822
+ await this.conductor.sendMessage(this.taskId, responseText, {
823
+ model: this.backendSession.threadOptions?.model || this.backendName,
824
+ backend: this.backendName,
825
+ usage: result.usage || null,
826
+ thread_id: this.backendSession.threadId,
827
+ items: result.items,
828
+ reply_to: replyTo,
829
+ cli_args: this.cliArgs,
830
+ });
831
+ } catch (error) {
832
+ await this.reportError(`${this.backendName} 处理失败: ${error.message}`, replyTo);
833
+ }
834
+ }
835
+
836
+ async handleSyntheticMessage(content, { includeImages }) {
837
+ try {
838
+ const result = await this.backendSession.runTurn(content, {
839
+ useInitialImages: includeImages,
840
+ });
841
+ const backendLabel = this.backendName.charAt(0).toUpperCase() + this.backendName.slice(1);
842
+ const intro = `${backendLabel} 已根据初始提示给出回复:`;
843
+ const replyText = result.text || extractAgentTextFromItems(result.items);
844
+ const text = replyText ? `${intro}\n\n${replyText}` : intro;
845
+ logBackendReply(this.backendName, replyText || "(无文本输出)", {
846
+ usage: result.usage,
847
+ replyTo: "initial",
848
+ });
849
+ await this.conductor.sendMessage(this.taskId, text, {
850
+ model: this.backendSession.threadOptions?.model || this.backendName,
851
+ backend: this.backendName,
852
+ usage: result.usage || null,
853
+ thread_id: this.backendSession.threadId,
854
+ cli_args: this.cliArgs,
855
+ synthetic: true,
856
+ });
857
+ } catch (error) {
858
+ await this.reportError(`初始提示执行失败: ${error.message}`);
859
+ }
860
+ }
861
+
862
+ async reportError(message, replyTo) {
863
+ try {
864
+ await this.conductor.sendMessage(this.taskId, message, {
865
+ severity: "error",
866
+ backend: this.backendName,
867
+ reply_to: replyTo,
868
+ cli_args: this.cliArgs,
869
+ });
870
+ } catch (err) {
871
+ log(`Failed to report error to Conductor: ${err.message}`);
872
+ }
873
+ }
874
+ }
875
+
876
+ function extractAgentTextFromItems(items) {
877
+ if (!Array.isArray(items)) {
878
+ return "";
879
+ }
880
+ for (let index = items.length - 1; index >= 0; index -= 1) {
881
+ const item = items[index];
882
+ if (item?.type === "agent_message" && typeof item.text === "string" && item.text.trim()) {
883
+ return item.text.trim();
884
+ }
885
+ }
886
+ return "";
887
+ }
888
+
889
+ function log(message) {
890
+ const ts = new Date().toLocaleString("sv-SE", { timeZone: "Asia/Shanghai" }).replace(" ", "T");
891
+ process.stdout.write(`[${CLI_NAME} ${ts}] ${message}\n`);
892
+ }
893
+
894
+ function logBackendReply(backend, text, { usage, replyTo }) {
895
+ const usageSuffix = usage?.total_tokens ? ` (tokens: ${usage.total_tokens})` : "";
896
+ log(`${backend} reply (${replyTo}): ${text}${usageSuffix}`);
897
+ }
898
+
899
+ main().catch((error) => {
900
+ const ts = new Date().toLocaleString("sv-SE", { timeZone: "Asia/Shanghai" }).replace(" ", "T");
901
+ process.stderr.write(`[${CLI_NAME} ${ts}] failed: ${error.stack || error.message}\n`);
902
+ process.exitCode = 1;
903
+ });