@love-moon/conductor-cli 0.2.11 → 0.2.12
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/bin/conductor-fire.js +735 -162
- package/package.json +3 -3
- package/src/daemon.js +66 -0
- package/src/fire/history.js +109 -0
package/bin/conductor-fire.js
CHANGED
|
@@ -11,6 +11,7 @@ import fs from "node:fs";
|
|
|
11
11
|
import os from "node:os";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import process from "node:process";
|
|
14
|
+
import readline from "node:readline";
|
|
14
15
|
import { setTimeout as delay } from "node:timers/promises";
|
|
15
16
|
import { fileURLToPath } from "node:url";
|
|
16
17
|
|
|
@@ -19,18 +20,13 @@ import { hideBin } from "yargs/helpers";
|
|
|
19
20
|
import yaml from "js-yaml";
|
|
20
21
|
import { TuiDriver, claudeCodeProfile, codexProfile, copilotProfile } from "@love-moon/tui-driver";
|
|
21
22
|
import { ConductorClient, loadConfig } from "@love-moon/conductor-sdk";
|
|
22
|
-
import {
|
|
23
|
-
loadHistoryFromSpec,
|
|
24
|
-
parseFromSpec,
|
|
25
|
-
selectHistorySession,
|
|
26
|
-
SUPPORTED_FROM_PROVIDERS,
|
|
27
|
-
} from "../src/fire/history.js";
|
|
23
|
+
import { findSessionPath } from "../src/fire/history.js";
|
|
28
24
|
|
|
29
25
|
const __filename = fileURLToPath(import.meta.url);
|
|
30
26
|
const __dirname = path.dirname(__filename);
|
|
31
27
|
const PKG_ROOT = path.join(__dirname, "..");
|
|
32
|
-
const
|
|
33
|
-
const FIRE_LOG_PATH = path.join(
|
|
28
|
+
const INITIAL_CLI_PROJECT_PATH = process.cwd();
|
|
29
|
+
const FIRE_LOG_PATH = path.join(INITIAL_CLI_PROJECT_PATH, "conductor.log");
|
|
34
30
|
const ENABLE_FIRE_LOCAL_LOG = !process.env.CONDUCTOR_CLI_COMMAND;
|
|
35
31
|
|
|
36
32
|
const pkgJson = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf-8"));
|
|
@@ -121,6 +117,7 @@ function appendFireLocalLog(line) {
|
|
|
121
117
|
|
|
122
118
|
async function main() {
|
|
123
119
|
const cliArgs = parseCliArgs();
|
|
120
|
+
let runtimeProjectPath = process.cwd();
|
|
124
121
|
|
|
125
122
|
if (cliArgs.showHelp) {
|
|
126
123
|
return;
|
|
@@ -147,48 +144,22 @@ async function main() {
|
|
|
147
144
|
return;
|
|
148
145
|
}
|
|
149
146
|
|
|
150
|
-
let
|
|
151
|
-
if (cliArgs.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const selected = await selectHistorySession(provider);
|
|
160
|
-
if (!selected) {
|
|
161
|
-
log("No session selected. Starting a new conversation.");
|
|
162
|
-
resolvedSpec = null;
|
|
163
|
-
} else {
|
|
164
|
-
resolvedSpec = parseFromSpec(`${provider}:${selected.sessionId}`);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if (resolvedSpec) {
|
|
169
|
-
if (cliArgs.backend !== resolvedSpec.provider) {
|
|
170
|
-
log(
|
|
171
|
-
`Ignoring --from ${resolvedSpec.provider}:${resolvedSpec.sessionId} because backend is ${cliArgs.backend}`,
|
|
172
|
-
);
|
|
173
|
-
} else {
|
|
174
|
-
fromHistory = await loadHistoryFromSpec(resolvedSpec);
|
|
175
|
-
if (fromHistory.warning) {
|
|
176
|
-
log(fromHistory.warning);
|
|
177
|
-
} else {
|
|
178
|
-
log(
|
|
179
|
-
`Loaded ${fromHistory.history.length} history messages from ${fromHistory.provider} session ${fromHistory.sessionId}`,
|
|
180
|
-
);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
}
|
|
147
|
+
let resumeContext = null;
|
|
148
|
+
if (cliArgs.resumeSessionId) {
|
|
149
|
+
resumeContext = await resolveResumeContext(cliArgs.backend, cliArgs.resumeSessionId);
|
|
150
|
+
log(
|
|
151
|
+
`Validated --resume ${resumeContext.sessionId} (${resumeContext.provider}) at ${resumeContext.sessionPath}`,
|
|
152
|
+
);
|
|
153
|
+
log(`Resume will run backend from ${resumeContext.cwd}`);
|
|
154
|
+
runtimeProjectPath = await applyWorkingDirectory(resumeContext.cwd);
|
|
155
|
+
log(`Switched working directory to ${runtimeProjectPath} before Conductor connect`);
|
|
184
156
|
}
|
|
185
157
|
|
|
186
158
|
// Create backend session using tui-driver
|
|
187
159
|
const backendSession = new TuiDriverSession(cliArgs.backend, {
|
|
188
160
|
initialImages: cliArgs.initialImages,
|
|
189
|
-
cwd:
|
|
190
|
-
|
|
191
|
-
resumeSessionId: fromHistory.sessionId,
|
|
161
|
+
cwd: runtimeProjectPath,
|
|
162
|
+
resumeSessionId: cliArgs.resumeSessionId,
|
|
192
163
|
configFile: cliArgs.configFile,
|
|
193
164
|
});
|
|
194
165
|
|
|
@@ -198,6 +169,7 @@ async function main() {
|
|
|
198
169
|
const launchedByDaemon = Boolean(process.env.CONDUCTOR_CLI_COMMAND);
|
|
199
170
|
let reconnectRunner = null;
|
|
200
171
|
let reconnectTaskId = null;
|
|
172
|
+
let pendingRemoteStopEvent = null;
|
|
201
173
|
let conductor = null;
|
|
202
174
|
let reconnectResumeInFlight = false;
|
|
203
175
|
|
|
@@ -234,6 +206,21 @@ async function main() {
|
|
|
234
206
|
})();
|
|
235
207
|
};
|
|
236
208
|
|
|
209
|
+
const handleStopTaskCommand = async (event) => {
|
|
210
|
+
if (!event || typeof event !== "object") {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const taskId = typeof event.taskId === "string" ? event.taskId : "";
|
|
214
|
+
if (reconnectTaskId && taskId && taskId !== reconnectTaskId) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (reconnectRunner && typeof reconnectRunner.requestStopFromRemote === "function") {
|
|
218
|
+
await reconnectRunner.requestStopFromRemote(event);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
pendingRemoteStopEvent = event;
|
|
222
|
+
};
|
|
223
|
+
|
|
237
224
|
if (cliArgs.configFile) {
|
|
238
225
|
env.CONDUCTOR_CONFIG = cliArgs.configFile;
|
|
239
226
|
}
|
|
@@ -252,111 +239,159 @@ async function main() {
|
|
|
252
239
|
// Ignore config loading errors, rely on env vars or defaults
|
|
253
240
|
}
|
|
254
241
|
|
|
255
|
-
conductor = await ConductorClient.connect({
|
|
256
|
-
projectPath: CLI_PROJECT_PATH,
|
|
257
|
-
extraEnv: env,
|
|
258
|
-
configFile: cliArgs.configFile,
|
|
259
|
-
onConnected: scheduleReconnectRecovery,
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
const taskContext = await ensureTaskContext(conductor, {
|
|
263
|
-
initialPrompt: cliArgs.initialPrompt,
|
|
264
|
-
requestedProjectId: process.env.CONDUCTOR_PROJECT_ID,
|
|
265
|
-
providedTaskId: process.env.CONDUCTOR_TASK_ID,
|
|
266
|
-
requestedTitle: cliArgs.taskTitle || process.env.CONDUCTOR_TASK_TITLE,
|
|
267
|
-
backend: cliArgs.backend,
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
log(
|
|
271
|
-
`Attached to Conductor task ${taskContext.taskId}${
|
|
272
|
-
taskContext.appUrl ? ` (${taskContext.appUrl})` : ""
|
|
273
|
-
}`,
|
|
274
|
-
);
|
|
275
|
-
reconnectTaskId = taskContext.taskId;
|
|
276
|
-
|
|
277
242
|
try {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
243
|
+
const requestedTaskTitle = resolveRequestedTaskTitle({
|
|
244
|
+
cliTaskTitle: cliArgs.taskTitle,
|
|
245
|
+
hasExplicitTaskTitle: cliArgs.hasExplicitTaskTitle,
|
|
246
|
+
envTaskTitle: process.env.CONDUCTOR_TASK_TITLE,
|
|
247
|
+
runtimeProjectPath,
|
|
282
248
|
});
|
|
283
|
-
} catch (error) {
|
|
284
|
-
log(`Failed to report agent resume: ${error?.message || error}`);
|
|
285
|
-
}
|
|
286
249
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
cliArgs: cliArgs.rawBackendArgs,
|
|
295
|
-
backendName: cliArgs.backend,
|
|
296
|
-
});
|
|
297
|
-
reconnectRunner = runner;
|
|
250
|
+
conductor = await ConductorClient.connect({
|
|
251
|
+
projectPath: runtimeProjectPath,
|
|
252
|
+
extraEnv: env,
|
|
253
|
+
configFile: cliArgs.configFile,
|
|
254
|
+
onConnected: scheduleReconnectRecovery,
|
|
255
|
+
onStopTask: handleStopTaskCommand,
|
|
256
|
+
});
|
|
298
257
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
258
|
+
const taskContext = await ensureTaskContext(conductor, {
|
|
259
|
+
initialPrompt: cliArgs.initialPrompt,
|
|
260
|
+
requestedProjectId: process.env.CONDUCTOR_PROJECT_ID,
|
|
261
|
+
providedTaskId: process.env.CONDUCTOR_TASK_ID,
|
|
262
|
+
requestedTitle: requestedTaskTitle,
|
|
263
|
+
backend: cliArgs.backend,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
log(
|
|
267
|
+
`Attached to Conductor task ${taskContext.taskId}${
|
|
268
|
+
taskContext.appUrl ? ` (${taskContext.appUrl})` : ""
|
|
269
|
+
}`,
|
|
270
|
+
);
|
|
271
|
+
reconnectTaskId = taskContext.taskId;
|
|
311
272
|
|
|
312
|
-
if (!launchedByDaemon) {
|
|
313
273
|
try {
|
|
314
|
-
await conductor.
|
|
315
|
-
|
|
274
|
+
await conductor.sendAgentResume({
|
|
275
|
+
active_tasks: [taskContext.taskId],
|
|
276
|
+
source: "conductor-fire",
|
|
277
|
+
metadata: { reconnect: false },
|
|
316
278
|
});
|
|
317
279
|
} catch (error) {
|
|
318
|
-
log(`Failed to report
|
|
280
|
+
log(`Failed to report agent resume: ${error?.message || error}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const runner = new BridgeRunner({
|
|
284
|
+
backendSession,
|
|
285
|
+
conductor,
|
|
286
|
+
taskId: taskContext.taskId,
|
|
287
|
+
pollIntervalMs: Math.max(cliArgs.pollIntervalMs, 500),
|
|
288
|
+
initialPrompt: taskContext.shouldProcessInitialPrompt ? cliArgs.initialPrompt : "",
|
|
289
|
+
includeInitialImages: Boolean(cliArgs.initialPrompt && cliArgs.initialImages.length),
|
|
290
|
+
cliArgs: cliArgs.rawBackendArgs,
|
|
291
|
+
backendName: cliArgs.backend,
|
|
292
|
+
resumeMode: Boolean(cliArgs.resumeSessionId),
|
|
293
|
+
});
|
|
294
|
+
reconnectRunner = runner;
|
|
295
|
+
if (pendingRemoteStopEvent) {
|
|
296
|
+
await runner.requestStopFromRemote(pendingRemoteStopEvent);
|
|
297
|
+
pendingRemoteStopEvent = null;
|
|
319
298
|
}
|
|
320
|
-
}
|
|
321
299
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
300
|
+
const signals = new AbortController();
|
|
301
|
+
let shutdownSignal = null;
|
|
302
|
+
let backendShutdownRequested = false;
|
|
303
|
+
const requestBackendShutdown = (source) => {
|
|
304
|
+
if (backendShutdownRequested) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
backendShutdownRequested = true;
|
|
308
|
+
void (async () => {
|
|
309
|
+
try {
|
|
310
|
+
await backendSession.close();
|
|
311
|
+
} catch (error) {
|
|
312
|
+
log(`Failed to close backend session after ${source}: ${error?.message || error}`);
|
|
313
|
+
}
|
|
314
|
+
})();
|
|
315
|
+
};
|
|
316
|
+
const onSigint = () => {
|
|
317
|
+
shutdownSignal = shutdownSignal || "SIGINT";
|
|
318
|
+
signals.abort();
|
|
319
|
+
requestBackendShutdown("SIGINT");
|
|
320
|
+
};
|
|
321
|
+
const onSigterm = () => {
|
|
322
|
+
shutdownSignal = shutdownSignal || "SIGTERM";
|
|
323
|
+
signals.abort();
|
|
324
|
+
requestBackendShutdown("SIGTERM");
|
|
325
|
+
};
|
|
326
|
+
process.on("SIGINT", onSigint);
|
|
327
|
+
process.on("SIGTERM", onSigterm);
|
|
328
|
+
|
|
331
329
|
if (!launchedByDaemon) {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
:
|
|
330
|
+
try {
|
|
331
|
+
await conductor.sendTaskStatus(taskContext.taskId, {
|
|
332
|
+
status: "RUNNING",
|
|
333
|
+
});
|
|
334
|
+
} catch (error) {
|
|
335
|
+
log(`Failed to report task status (RUNNING): ${error?.message || error}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
let runnerError = null;
|
|
340
|
+
try {
|
|
341
|
+
await runner.start(signals.signal);
|
|
342
|
+
} catch (error) {
|
|
343
|
+
runnerError = error;
|
|
344
|
+
throw error;
|
|
345
|
+
} finally {
|
|
346
|
+
process.off("SIGINT", onSigint);
|
|
347
|
+
process.off("SIGTERM", onSigterm);
|
|
348
|
+
if (!launchedByDaemon) {
|
|
349
|
+
const remoteStopSummary = typeof runner.getRemoteStopSummary === "function" ? runner.getRemoteStopSummary() : null;
|
|
350
|
+
const finalStatus = shutdownSignal
|
|
338
351
|
? {
|
|
339
352
|
status: "KILLED",
|
|
340
|
-
summary: `
|
|
353
|
+
summary: `terminated by ${shutdownSignal}`,
|
|
341
354
|
}
|
|
342
|
-
:
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
355
|
+
: runnerError
|
|
356
|
+
? {
|
|
357
|
+
status: "KILLED",
|
|
358
|
+
summary: `conductor fire failed: ${runnerError?.message || runnerError}`,
|
|
359
|
+
}
|
|
360
|
+
: remoteStopSummary
|
|
361
|
+
? {
|
|
362
|
+
status: "KILLED",
|
|
363
|
+
summary: remoteStopSummary,
|
|
364
|
+
}
|
|
365
|
+
: {
|
|
366
|
+
status: "COMPLETED",
|
|
367
|
+
summary: "conductor fire exited",
|
|
368
|
+
};
|
|
369
|
+
try {
|
|
370
|
+
await conductor.sendTaskStatus(taskContext.taskId, finalStatus);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
log(`Failed to report task status (${finalStatus.status}): ${error?.message || error}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (shutdownSignal === "SIGINT") {
|
|
376
|
+
process.exitCode = 130;
|
|
377
|
+
} else if (shutdownSignal === "SIGTERM") {
|
|
378
|
+
process.exitCode = 143;
|
|
350
379
|
}
|
|
351
380
|
}
|
|
381
|
+
} finally {
|
|
352
382
|
if (typeof backendSession.close === "function") {
|
|
353
|
-
|
|
383
|
+
try {
|
|
384
|
+
await backendSession.close();
|
|
385
|
+
} catch (error) {
|
|
386
|
+
log(`Failed to close backend session: ${error?.message || error}`);
|
|
387
|
+
}
|
|
354
388
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
389
|
+
if (conductor && typeof conductor.close === "function") {
|
|
390
|
+
try {
|
|
391
|
+
await conductor.close();
|
|
392
|
+
} catch (error) {
|
|
393
|
+
log(`Failed to close conductor connection: ${error?.message || error}`);
|
|
394
|
+
}
|
|
360
395
|
}
|
|
361
396
|
}
|
|
362
397
|
}
|
|
@@ -374,6 +409,65 @@ function extractConfigFileFromArgv(argv) {
|
|
|
374
409
|
return undefined;
|
|
375
410
|
}
|
|
376
411
|
|
|
412
|
+
function hasLegacyFromFlags(argv = []) {
|
|
413
|
+
return argv.some(
|
|
414
|
+
(arg) =>
|
|
415
|
+
arg === "--from" ||
|
|
416
|
+
arg.startsWith("--from=") ||
|
|
417
|
+
arg === "--from-provider" ||
|
|
418
|
+
arg.startsWith("--from-provider="),
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const CONDUCTOR_BOOLEAN_FLAGS = new Set([
|
|
423
|
+
"--list-backends",
|
|
424
|
+
"--version",
|
|
425
|
+
"-v",
|
|
426
|
+
"--help",
|
|
427
|
+
"-h",
|
|
428
|
+
]);
|
|
429
|
+
|
|
430
|
+
const CONDUCTOR_VALUE_FLAGS = new Set([
|
|
431
|
+
"--backend",
|
|
432
|
+
"-b",
|
|
433
|
+
"--config-file",
|
|
434
|
+
"--poll-interval",
|
|
435
|
+
"--title",
|
|
436
|
+
"-t",
|
|
437
|
+
"--resume",
|
|
438
|
+
"--prefill",
|
|
439
|
+
]);
|
|
440
|
+
|
|
441
|
+
function stripConductorArgsFromArgv(argv = []) {
|
|
442
|
+
const backendArgs = [];
|
|
443
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
444
|
+
const raw = String(argv[i] ?? "");
|
|
445
|
+
if (!raw) {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (raw === "--") {
|
|
449
|
+
backendArgs.push(...argv.slice(i + 1));
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const eqIndex = raw.indexOf("=");
|
|
454
|
+
const flag = eqIndex > 0 ? raw.slice(0, eqIndex) : raw;
|
|
455
|
+
|
|
456
|
+
if (CONDUCTOR_BOOLEAN_FLAGS.has(flag)) {
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (CONDUCTOR_VALUE_FLAGS.has(flag)) {
|
|
460
|
+
if (eqIndex < 0) {
|
|
461
|
+
i += 1;
|
|
462
|
+
}
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
backendArgs.push(raw);
|
|
467
|
+
}
|
|
468
|
+
return backendArgs;
|
|
469
|
+
}
|
|
470
|
+
|
|
377
471
|
export function parseCliArgs(argvInput = process.argv) {
|
|
378
472
|
const rawArgv = Array.isArray(argvInput) ? argvInput : process.argv;
|
|
379
473
|
const argv = hideBin(rawArgv);
|
|
@@ -381,12 +475,15 @@ export function parseCliArgs(argvInput = process.argv) {
|
|
|
381
475
|
|
|
382
476
|
// When no separator, parse all args first to check for conductor-specific options
|
|
383
477
|
const conductorArgv = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex);
|
|
384
|
-
const backendArgv = separatorIndex === -1 ? argv : argv.slice(separatorIndex + 1);
|
|
478
|
+
const backendArgv = separatorIndex === -1 ? stripConductorArgsFromArgv(argv) : argv.slice(separatorIndex + 1);
|
|
385
479
|
|
|
386
480
|
// Check for version/list-backends/help without separator
|
|
387
481
|
const versionWithoutSeparator = separatorIndex === -1 && (argv.includes("--version") || argv.includes("-v"));
|
|
388
482
|
const listBackendsWithoutSeparator = separatorIndex === -1 && argv.includes("--list-backends");
|
|
389
483
|
const helpWithoutSeparator = separatorIndex === -1 && (argv.includes("--help") || argv.includes("-h"));
|
|
484
|
+
if (hasLegacyFromFlags(conductorArgv)) {
|
|
485
|
+
throw new Error("--from and --from-provider were removed. Use --resume <session-id>.");
|
|
486
|
+
}
|
|
390
487
|
|
|
391
488
|
const configFileFromArgs = extractConfigFileFromArgv(argv);
|
|
392
489
|
const allowCliList = loadAllowCliList(configFileFromArgs);
|
|
@@ -419,15 +516,9 @@ export function parseCliArgs(argvInput = process.argv) {
|
|
|
419
516
|
type: "string",
|
|
420
517
|
describe: "Optional task title shown in the app task list",
|
|
421
518
|
})
|
|
422
|
-
.option("
|
|
423
|
-
alias: "f",
|
|
519
|
+
.option("resume", {
|
|
424
520
|
type: "string",
|
|
425
|
-
describe: "Resume from
|
|
426
|
-
})
|
|
427
|
-
.option("from-provider", {
|
|
428
|
-
type: "string",
|
|
429
|
-
describe: "Provider for --from picker (defaults to --backend)",
|
|
430
|
-
choices: SUPPORTED_FROM_PROVIDERS,
|
|
521
|
+
describe: "Resume from a backend session ID",
|
|
431
522
|
})
|
|
432
523
|
.option("prefill", {
|
|
433
524
|
type: "string",
|
|
@@ -460,8 +551,7 @@ Options:
|
|
|
460
551
|
--config-file <path> Path to Conductor config file
|
|
461
552
|
--poll-interval <ms> Polling interval when waiting for Conductor messages
|
|
462
553
|
-t, --title <text> Optional task title shown in the app task list
|
|
463
|
-
|
|
464
|
-
--from-provider <p> Provider for --from picker (codex or claude)
|
|
554
|
+
--resume <id> Resume from an existing backend session
|
|
465
555
|
-v, --version Show Conductor CLI version and exit
|
|
466
556
|
-h, --help Show this help message
|
|
467
557
|
|
|
@@ -475,6 +565,7 @@ Examples:
|
|
|
475
565
|
${CLI_NAME} -- "fix the bug" # Use default backend
|
|
476
566
|
${CLI_NAME} --backend claude -- "fix the bug" # Use Claude CLI backend
|
|
477
567
|
${CLI_NAME} --backend copilot -- "fix the bug" # Use GitHub Copilot CLI backend
|
|
568
|
+
${CLI_NAME} --backend codex --resume <id> # Resume Codex session
|
|
478
569
|
${CLI_NAME} --list-backends # Show configured backends
|
|
479
570
|
${CLI_NAME} --config-file ~/.conductor/config.yaml -- "fix the bug"
|
|
480
571
|
|
|
@@ -504,11 +595,13 @@ Environment:
|
|
|
504
595
|
? Number(conductorArgs.pollInterval)
|
|
505
596
|
: DEFAULT_POLL_INTERVAL_MS;
|
|
506
597
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
}
|
|
511
|
-
|
|
598
|
+
const resumeRaw = conductorArgs.resume;
|
|
599
|
+
if (resumeRaw === true) {
|
|
600
|
+
throw new Error("--resume requires a session id");
|
|
601
|
+
}
|
|
602
|
+
const resumeSessionId = typeof resumeRaw === "string" ? resumeRaw.trim() : "";
|
|
603
|
+
if (resumeRaw !== undefined && !resumeSessionId) {
|
|
604
|
+
throw new Error("--resume requires a session id");
|
|
512
605
|
}
|
|
513
606
|
|
|
514
607
|
return {
|
|
@@ -517,22 +610,34 @@ Environment:
|
|
|
517
610
|
initialImages,
|
|
518
611
|
pollIntervalMs,
|
|
519
612
|
rawBackendArgs: backendArgv,
|
|
520
|
-
taskTitle:
|
|
613
|
+
taskTitle: typeof conductorArgs.title === "string" ? conductorArgs.title.trim() : "",
|
|
614
|
+
hasExplicitTaskTitle: typeof conductorArgs.title === "string" && Boolean(conductorArgs.title.trim()),
|
|
521
615
|
configFile: conductorArgs.configFile,
|
|
522
|
-
|
|
616
|
+
resumeSessionId,
|
|
523
617
|
showVersion: Boolean(conductorArgs.version) || versionWithoutSeparator,
|
|
524
618
|
listBackends: Boolean(conductorArgs.listBackends) || listBackendsWithoutSeparator,
|
|
525
619
|
};
|
|
526
620
|
}
|
|
527
621
|
|
|
528
|
-
function resolveTaskTitle(titleFlag) {
|
|
622
|
+
export function resolveTaskTitle(titleFlag, defaultPath = process.cwd()) {
|
|
529
623
|
if (typeof titleFlag === "string" && titleFlag.trim()) {
|
|
530
624
|
return titleFlag.trim();
|
|
531
625
|
}
|
|
532
|
-
const
|
|
626
|
+
const targetPath = typeof defaultPath === "string" ? defaultPath.trim() : "";
|
|
627
|
+
const cwdName = path.basename(targetPath || process.cwd());
|
|
533
628
|
return cwdName || "";
|
|
534
629
|
}
|
|
535
630
|
|
|
631
|
+
export function resolveRequestedTaskTitle({
|
|
632
|
+
cliTaskTitle,
|
|
633
|
+
hasExplicitTaskTitle,
|
|
634
|
+
envTaskTitle,
|
|
635
|
+
runtimeProjectPath,
|
|
636
|
+
}) {
|
|
637
|
+
const explicit = hasExplicitTaskTitle ? cliTaskTitle : envTaskTitle;
|
|
638
|
+
return resolveTaskTitle(explicit, runtimeProjectPath);
|
|
639
|
+
}
|
|
640
|
+
|
|
536
641
|
function normalizeArray(value) {
|
|
537
642
|
if (!value) {
|
|
538
643
|
return [];
|
|
@@ -655,6 +760,7 @@ function deriveTaskTitle(prompt, explicit, backend = "codex") {
|
|
|
655
760
|
|
|
656
761
|
const BACKEND_PROFILE_MAP = {
|
|
657
762
|
codex: "codex",
|
|
763
|
+
code: "codex",
|
|
658
764
|
claude: "claude-code",
|
|
659
765
|
"claude-code": "claude-code",
|
|
660
766
|
copilot: "copilot",
|
|
@@ -677,6 +783,228 @@ function parseCommandParts(commandLine) {
|
|
|
677
783
|
};
|
|
678
784
|
}
|
|
679
785
|
|
|
786
|
+
export function buildResumeArgsForBackend(backend, sessionId) {
|
|
787
|
+
const resumeSessionId = typeof sessionId === "string" ? sessionId.trim() : "";
|
|
788
|
+
if (!resumeSessionId) {
|
|
789
|
+
return [];
|
|
790
|
+
}
|
|
791
|
+
const normalizedBackend = String(backend || "").trim().toLowerCase();
|
|
792
|
+
if (normalizedBackend === "codex" || normalizedBackend === "code") {
|
|
793
|
+
return ["resume", resumeSessionId];
|
|
794
|
+
}
|
|
795
|
+
if (normalizedBackend === "claude" || normalizedBackend === "claude-code") {
|
|
796
|
+
return ["--resume", resumeSessionId];
|
|
797
|
+
}
|
|
798
|
+
if (normalizedBackend === "copilot") {
|
|
799
|
+
return [`--resume=${resumeSessionId}`];
|
|
800
|
+
}
|
|
801
|
+
throw new Error(`--resume is not supported for backend "${backend}"`);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
export function resumeProviderForBackend(backend) {
|
|
805
|
+
const normalizedBackend = String(backend || "").trim().toLowerCase();
|
|
806
|
+
if (normalizedBackend === "codex" || normalizedBackend === "code") {
|
|
807
|
+
return "codex";
|
|
808
|
+
}
|
|
809
|
+
if (normalizedBackend === "claude" || normalizedBackend === "claude-code") {
|
|
810
|
+
return "claude";
|
|
811
|
+
}
|
|
812
|
+
if (normalizedBackend === "copilot") {
|
|
813
|
+
return "copilot";
|
|
814
|
+
}
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
export async function resolveSessionRunDirectory(sessionPath) {
|
|
819
|
+
const normalizedPath = typeof sessionPath === "string" ? sessionPath.trim() : "";
|
|
820
|
+
if (!normalizedPath) {
|
|
821
|
+
throw new Error("Invalid session path");
|
|
822
|
+
}
|
|
823
|
+
let stats;
|
|
824
|
+
try {
|
|
825
|
+
stats = await fs.promises.stat(normalizedPath);
|
|
826
|
+
} catch {
|
|
827
|
+
throw new Error(`Session path does not exist: ${normalizedPath}`);
|
|
828
|
+
}
|
|
829
|
+
return stats.isDirectory() ? normalizedPath : path.dirname(normalizedPath);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
async function isExistingDirectory(targetPath) {
|
|
833
|
+
const normalizedPath = typeof targetPath === "string" ? targetPath.trim() : "";
|
|
834
|
+
if (!normalizedPath) {
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
try {
|
|
838
|
+
const stats = await fs.promises.stat(normalizedPath);
|
|
839
|
+
return stats.isDirectory();
|
|
840
|
+
} catch {
|
|
841
|
+
return false;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function extractCodexResumeCwd(sessionPath) {
|
|
846
|
+
if (!sessionPath.endsWith(".jsonl")) {
|
|
847
|
+
return null;
|
|
848
|
+
}
|
|
849
|
+
const rl = readline.createInterface({
|
|
850
|
+
input: fs.createReadStream(sessionPath),
|
|
851
|
+
crlfDelay: Infinity,
|
|
852
|
+
});
|
|
853
|
+
for await (const line of rl) {
|
|
854
|
+
const trimmed = line.trim();
|
|
855
|
+
if (!trimmed) {
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
let entry;
|
|
859
|
+
try {
|
|
860
|
+
entry = JSON.parse(trimmed);
|
|
861
|
+
} catch {
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
const maybeCwd = entry?.type === "session_meta" ? entry?.payload?.cwd : null;
|
|
865
|
+
if (typeof maybeCwd === "string" && maybeCwd.trim()) {
|
|
866
|
+
return maybeCwd.trim();
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async function extractClaudeResumeCwd(sessionPath, sessionId) {
|
|
873
|
+
if (!sessionPath.endsWith(".jsonl")) {
|
|
874
|
+
return null;
|
|
875
|
+
}
|
|
876
|
+
const rl = readline.createInterface({
|
|
877
|
+
input: fs.createReadStream(sessionPath),
|
|
878
|
+
crlfDelay: Infinity,
|
|
879
|
+
});
|
|
880
|
+
for await (const line of rl) {
|
|
881
|
+
const trimmed = line.trim();
|
|
882
|
+
if (!trimmed) {
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
let entry;
|
|
886
|
+
try {
|
|
887
|
+
entry = JSON.parse(trimmed);
|
|
888
|
+
} catch {
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
const idMatches = String(entry?.sessionId || "").trim() === sessionId;
|
|
892
|
+
const maybeCwd = entry?.cwd;
|
|
893
|
+
if (idMatches && typeof maybeCwd === "string" && maybeCwd.trim()) {
|
|
894
|
+
return maybeCwd.trim();
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
return null;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
async function extractCopilotResumeCwd(sessionPath) {
|
|
901
|
+
let stats;
|
|
902
|
+
try {
|
|
903
|
+
stats = await fs.promises.stat(sessionPath);
|
|
904
|
+
} catch {
|
|
905
|
+
return null;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (stats.isDirectory()) {
|
|
909
|
+
const workspaceYamlPath = path.join(sessionPath, "workspace.yaml");
|
|
910
|
+
try {
|
|
911
|
+
const yamlContent = await fs.promises.readFile(workspaceYamlPath, "utf8");
|
|
912
|
+
const parsed = yaml.load(yamlContent);
|
|
913
|
+
const maybeCwd = parsed && typeof parsed === "object" ? parsed.cwd : null;
|
|
914
|
+
if (typeof maybeCwd === "string" && maybeCwd.trim()) {
|
|
915
|
+
return maybeCwd.trim();
|
|
916
|
+
}
|
|
917
|
+
} catch {
|
|
918
|
+
return null;
|
|
919
|
+
}
|
|
920
|
+
return null;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (!sessionPath.endsWith(".jsonl")) {
|
|
924
|
+
return null;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const rl = readline.createInterface({
|
|
928
|
+
input: fs.createReadStream(sessionPath),
|
|
929
|
+
crlfDelay: Infinity,
|
|
930
|
+
});
|
|
931
|
+
for await (const line of rl) {
|
|
932
|
+
const trimmed = line.trim();
|
|
933
|
+
if (!trimmed) {
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
let entry;
|
|
937
|
+
try {
|
|
938
|
+
entry = JSON.parse(trimmed);
|
|
939
|
+
} catch {
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
const maybeCwd = entry?.data?.context?.cwd || entry?.data?.cwd;
|
|
943
|
+
if (typeof maybeCwd === "string" && maybeCwd.trim()) {
|
|
944
|
+
return maybeCwd.trim();
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
return null;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
async function extractResumeCwdFromSession(provider, sessionPath, sessionId) {
|
|
951
|
+
if (provider === "codex") {
|
|
952
|
+
return extractCodexResumeCwd(sessionPath);
|
|
953
|
+
}
|
|
954
|
+
if (provider === "claude") {
|
|
955
|
+
return extractClaudeResumeCwd(sessionPath, sessionId);
|
|
956
|
+
}
|
|
957
|
+
if (provider === "copilot") {
|
|
958
|
+
return extractCopilotResumeCwd(sessionPath);
|
|
959
|
+
}
|
|
960
|
+
return null;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
export async function resolveResumeContext(backend, sessionId, options = {}) {
|
|
964
|
+
const normalizedSessionId = typeof sessionId === "string" ? sessionId.trim() : "";
|
|
965
|
+
if (!normalizedSessionId) {
|
|
966
|
+
throw new Error("--resume requires a session id");
|
|
967
|
+
}
|
|
968
|
+
const provider = resumeProviderForBackend(backend);
|
|
969
|
+
if (!provider) {
|
|
970
|
+
throw new Error(`--resume is not supported for backend "${backend}"`);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const sessionPath = await findSessionPath(provider, normalizedSessionId, options);
|
|
974
|
+
if (!sessionPath) {
|
|
975
|
+
throw new Error(`Invalid --resume session id for ${provider}: ${normalizedSessionId}`);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const cwdFromSession = await extractResumeCwdFromSession(provider, sessionPath, normalizedSessionId);
|
|
979
|
+
const fallbackCwd = await resolveSessionRunDirectory(sessionPath);
|
|
980
|
+
const cwd = cwdFromSession || fallbackCwd;
|
|
981
|
+
if (!(await isExistingDirectory(cwd))) {
|
|
982
|
+
throw new Error(`Resume workspace path does not exist: ${cwd}`);
|
|
983
|
+
}
|
|
984
|
+
return {
|
|
985
|
+
provider,
|
|
986
|
+
sessionId: normalizedSessionId,
|
|
987
|
+
sessionPath,
|
|
988
|
+
cwd,
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
export async function applyWorkingDirectory(targetPath) {
|
|
993
|
+
const normalizedPath = typeof targetPath === "string" ? targetPath.trim() : "";
|
|
994
|
+
if (!normalizedPath) {
|
|
995
|
+
throw new Error("Cannot switch working directory: empty path");
|
|
996
|
+
}
|
|
997
|
+
if (!(await isExistingDirectory(normalizedPath))) {
|
|
998
|
+
throw new Error(`Cannot switch working directory: ${normalizedPath}`);
|
|
999
|
+
}
|
|
1000
|
+
try {
|
|
1001
|
+
process.chdir(normalizedPath);
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
throw new Error(`Cannot switch working directory to ${normalizedPath}: ${error?.message || error}`);
|
|
1004
|
+
}
|
|
1005
|
+
return process.cwd();
|
|
1006
|
+
}
|
|
1007
|
+
|
|
680
1008
|
function truncateText(value, maxLen = 240) {
|
|
681
1009
|
if (!value) return "";
|
|
682
1010
|
const text = String(value).trim();
|
|
@@ -690,6 +1018,10 @@ function isTruthyEnv(value) {
|
|
|
690
1018
|
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
691
1019
|
}
|
|
692
1020
|
|
|
1021
|
+
function isSessionClosedError(error) {
|
|
1022
|
+
return Boolean(error && typeof error === "object" && error.reason === "session_closed");
|
|
1023
|
+
}
|
|
1024
|
+
|
|
693
1025
|
function sanitizeForLog(value, maxLen = 180) {
|
|
694
1026
|
if (!value) return "";
|
|
695
1027
|
return truncateText(String(value).replace(/\s+/g, " ").trim(), maxLen);
|
|
@@ -705,7 +1037,12 @@ class TuiDriverSession {
|
|
|
705
1037
|
constructor(backend, options = {}) {
|
|
706
1038
|
this.backend = backend;
|
|
707
1039
|
this.options = options;
|
|
708
|
-
this.
|
|
1040
|
+
this.cwd =
|
|
1041
|
+
typeof options.cwd === "string" && options.cwd.trim()
|
|
1042
|
+
? options.cwd.trim()
|
|
1043
|
+
: INITIAL_CLI_PROJECT_PATH;
|
|
1044
|
+
const resumeSessionId = typeof options.resumeSessionId === "string" ? options.resumeSessionId.trim() : "";
|
|
1045
|
+
this.sessionId = resumeSessionId || `${backend}-${Date.now()}`;
|
|
709
1046
|
this.history = Array.isArray(options.initialHistory) ? [...options.initialHistory] : [];
|
|
710
1047
|
this.pendingHistorySeed = this.history.length > 0;
|
|
711
1048
|
|
|
@@ -715,8 +1052,9 @@ class TuiDriverSession {
|
|
|
715
1052
|
if (!command) {
|
|
716
1053
|
throw new Error(`Invalid command for backend "${backend}"`);
|
|
717
1054
|
}
|
|
1055
|
+
const resumeArgs = buildResumeArgsForBackend(backend, resumeSessionId);
|
|
718
1056
|
this.command = command;
|
|
719
|
-
this.args = args;
|
|
1057
|
+
this.args = [...args, ...resumeArgs];
|
|
720
1058
|
this.tuiDebug = isTruthyEnv(process.env.CONDUCTOR_TUI_DEBUG);
|
|
721
1059
|
this.tuiTrace = this.tuiDebug || isTruthyEnv(process.env.CONDUCTOR_TUI_TRACE);
|
|
722
1060
|
this.tuiTraceLines = Number.isFinite(Number.parseInt(process.env.CONDUCTOR_TUI_TRACE_LINES || "", 10))
|
|
@@ -725,6 +1063,9 @@ class TuiDriverSession {
|
|
|
725
1063
|
this.lastSignalSignature = "";
|
|
726
1064
|
this.lastPollSignature = "";
|
|
727
1065
|
this.lastSnapshotHash = "";
|
|
1066
|
+
this.closeRequested = false;
|
|
1067
|
+
this.closed = false;
|
|
1068
|
+
this.closeWaiters = new Set();
|
|
728
1069
|
|
|
729
1070
|
const profileName = profileNameForBackend(backend);
|
|
730
1071
|
if (!profileName) {
|
|
@@ -745,7 +1086,7 @@ class TuiDriverSession {
|
|
|
745
1086
|
log(`Using proxy: ${proxyEnv.http_proxy || proxyEnv.https_proxy || proxyEnv.all_proxy}`);
|
|
746
1087
|
}
|
|
747
1088
|
|
|
748
|
-
log(`Using TUI command for ${backend}: ${
|
|
1089
|
+
log(`Using TUI command for ${backend}: ${[this.command, ...this.args].join(" ")} (cwd: ${this.cwd})`);
|
|
749
1090
|
if (this.tuiTrace) {
|
|
750
1091
|
log(
|
|
751
1092
|
`[${this.backend}] [tui-trace] profile=${baseProfile.name} command=${this.command} args=${JSON.stringify(this.args)}`,
|
|
@@ -766,6 +1107,7 @@ class TuiDriverSession {
|
|
|
766
1107
|
...cliEnv,
|
|
767
1108
|
},
|
|
768
1109
|
},
|
|
1110
|
+
cwd: this.cwd,
|
|
769
1111
|
debug: this.tuiDebug,
|
|
770
1112
|
onSnapshot: this.tuiTrace
|
|
771
1113
|
? (snapshot, state) => {
|
|
@@ -797,7 +1139,57 @@ class TuiDriverSession {
|
|
|
797
1139
|
return { model: this.backend };
|
|
798
1140
|
}
|
|
799
1141
|
|
|
1142
|
+
createSessionClosedError() {
|
|
1143
|
+
const error = new Error("TUI session closed");
|
|
1144
|
+
error.reason = "session_closed";
|
|
1145
|
+
return error;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
createCloseGuard() {
|
|
1149
|
+
if (this.closeRequested) {
|
|
1150
|
+
return {
|
|
1151
|
+
promise: Promise.reject(this.createSessionClosedError()),
|
|
1152
|
+
cleanup: () => {},
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
let waiter = null;
|
|
1156
|
+
const promise = new Promise((_, reject) => {
|
|
1157
|
+
waiter = () => {
|
|
1158
|
+
reject(this.createSessionClosedError());
|
|
1159
|
+
};
|
|
1160
|
+
this.closeWaiters.add(waiter);
|
|
1161
|
+
});
|
|
1162
|
+
return {
|
|
1163
|
+
promise,
|
|
1164
|
+
cleanup: () => {
|
|
1165
|
+
if (waiter) {
|
|
1166
|
+
this.closeWaiters.delete(waiter);
|
|
1167
|
+
}
|
|
1168
|
+
},
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
flushCloseWaiters() {
|
|
1173
|
+
if (!this.closeWaiters || this.closeWaiters.size === 0) {
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
for (const waiter of this.closeWaiters) {
|
|
1177
|
+
try {
|
|
1178
|
+
waiter();
|
|
1179
|
+
} catch {
|
|
1180
|
+
// best effort
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
this.closeWaiters.clear();
|
|
1184
|
+
}
|
|
1185
|
+
|
|
800
1186
|
async close() {
|
|
1187
|
+
if (this.closed) {
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
this.closed = true;
|
|
1191
|
+
this.closeRequested = true;
|
|
1192
|
+
this.flushCloseWaiters();
|
|
801
1193
|
if (this.driver) {
|
|
802
1194
|
this.driver.kill();
|
|
803
1195
|
}
|
|
@@ -902,6 +1294,10 @@ class TuiDriverSession {
|
|
|
902
1294
|
}
|
|
903
1295
|
|
|
904
1296
|
async runTurn(promptText, { useInitialImages = false, onProgress } = {}) {
|
|
1297
|
+
if (this.closeRequested) {
|
|
1298
|
+
throw this.createSessionClosedError();
|
|
1299
|
+
}
|
|
1300
|
+
|
|
905
1301
|
const effectivePrompt = this.buildPrompt(promptText, { useInitialImages });
|
|
906
1302
|
if (!effectivePrompt) {
|
|
907
1303
|
return {
|
|
@@ -958,8 +1354,19 @@ class TuiDriverSession {
|
|
|
958
1354
|
signalTimer.unref();
|
|
959
1355
|
}
|
|
960
1356
|
|
|
1357
|
+
const previousCwd = process.cwd();
|
|
1358
|
+
const shouldSwitchCwd = this.cwd && this.cwd !== previousCwd;
|
|
1359
|
+
if (shouldSwitchCwd) {
|
|
1360
|
+
try {
|
|
1361
|
+
process.chdir(this.cwd);
|
|
1362
|
+
} catch (error) {
|
|
1363
|
+
throw new Error(`Failed to switch backend cwd to ${this.cwd}: ${error?.message || error}`);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
const closeGuard = this.createCloseGuard();
|
|
1367
|
+
|
|
961
1368
|
try {
|
|
962
|
-
const result = await this.driver.ask(effectivePrompt);
|
|
1369
|
+
const result = await Promise.race([this.driver.ask(effectivePrompt), closeGuard.promise]);
|
|
963
1370
|
const answer = String(result.answer || result.replyText || "").trim();
|
|
964
1371
|
this.trace(
|
|
965
1372
|
`runTurn finished success=${Boolean(result.success)} elapsedMs=${result.elapsedMs} answerLen=${answer.length} state=${this.driver.state}`,
|
|
@@ -1003,6 +1410,10 @@ class TuiDriverSession {
|
|
|
1003
1410
|
} catch (error) {
|
|
1004
1411
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1005
1412
|
const errorReason = error?.reason || "unknown";
|
|
1413
|
+
if (errorReason === "session_closed") {
|
|
1414
|
+
this.trace("runTurn interrupted because backend session is closing");
|
|
1415
|
+
throw error instanceof Error ? error : new Error(errorMessage);
|
|
1416
|
+
}
|
|
1006
1417
|
|
|
1007
1418
|
// 特殊处理登录和权限错误
|
|
1008
1419
|
if (errorReason === "login_required") {
|
|
@@ -1037,21 +1448,34 @@ class TuiDriverSession {
|
|
|
1037
1448
|
log(`[${this.backend}] Error: ${errorMessage}`);
|
|
1038
1449
|
}
|
|
1039
1450
|
|
|
1040
|
-
|
|
1451
|
+
let latestSignals = {};
|
|
1452
|
+
try {
|
|
1453
|
+
latestSignals = this.driver.getSignals();
|
|
1454
|
+
} catch {
|
|
1455
|
+
// driver may already be disposed while closing
|
|
1456
|
+
}
|
|
1041
1457
|
const summary = this.formatSignalSummary(latestSignals);
|
|
1042
1458
|
this.trace(
|
|
1043
1459
|
`runTurn exception state=${this.driver.state} error="${sanitizeForLog(errorMessage, 220)}" status="${summary.status || ""}" done="${summary.done || ""}" preview="${summary.replyPreview || ""}"`,
|
|
1044
1460
|
);
|
|
1045
1461
|
throw error instanceof Error ? error : new Error(errorMessage);
|
|
1046
1462
|
} finally {
|
|
1463
|
+
if (shouldSwitchCwd) {
|
|
1464
|
+
try {
|
|
1465
|
+
process.chdir(previousCwd);
|
|
1466
|
+
} catch (error) {
|
|
1467
|
+
log(`Failed to restore cwd to ${previousCwd}: ${error?.message || error}`);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1047
1470
|
clearInterval(signalTimer);
|
|
1048
1471
|
this.driver.off("stateChange", handleStateChange);
|
|
1472
|
+
closeGuard.cleanup();
|
|
1049
1473
|
this.trace(`runTurn cleanup state=${this.driver.state}`);
|
|
1050
1474
|
}
|
|
1051
1475
|
}
|
|
1052
1476
|
}
|
|
1053
1477
|
|
|
1054
|
-
class BridgeRunner {
|
|
1478
|
+
export class BridgeRunner {
|
|
1055
1479
|
constructor({
|
|
1056
1480
|
backendSession,
|
|
1057
1481
|
conductor,
|
|
@@ -1061,6 +1485,7 @@ class BridgeRunner {
|
|
|
1061
1485
|
includeInitialImages,
|
|
1062
1486
|
cliArgs,
|
|
1063
1487
|
backendName,
|
|
1488
|
+
resumeMode,
|
|
1064
1489
|
}) {
|
|
1065
1490
|
this.backendSession = backendSession;
|
|
1066
1491
|
this.conductor = conductor;
|
|
@@ -1070,17 +1495,22 @@ class BridgeRunner {
|
|
|
1070
1495
|
this.includeInitialImages = includeInitialImages;
|
|
1071
1496
|
this.cliArgs = cliArgs;
|
|
1072
1497
|
this.backendName = backendName || "codex";
|
|
1498
|
+
this.resumeMode = Boolean(resumeMode);
|
|
1073
1499
|
this.isCopilotBackend = String(this.backendName).toLowerCase() === "copilot";
|
|
1500
|
+
this.copilotDebug =
|
|
1501
|
+
this.isCopilotBackend &&
|
|
1502
|
+
(isTruthyEnv(process.env.CONDUCTOR_COPILOT_DEBUG) || isTruthyEnv(process.env.CONDUCTOR_DEBUG));
|
|
1074
1503
|
this.stopped = false;
|
|
1075
1504
|
this.runningTurn = false;
|
|
1076
1505
|
this.processedMessageIds = new Set();
|
|
1077
1506
|
this.lastRuntimeStatusSignature = null;
|
|
1078
1507
|
this.lastRuntimeStatusPayload = null;
|
|
1079
1508
|
this.needsReconnectRecovery = false;
|
|
1509
|
+
this.remoteStopInfo = null;
|
|
1080
1510
|
}
|
|
1081
1511
|
|
|
1082
1512
|
copilotLog(message) {
|
|
1083
|
-
if (!this.
|
|
1513
|
+
if (!this.copilotDebug) {
|
|
1084
1514
|
return;
|
|
1085
1515
|
}
|
|
1086
1516
|
log(`[copilot-debug] task=${this.taskId} ${message}`);
|
|
@@ -1090,6 +1520,9 @@ class BridgeRunner {
|
|
|
1090
1520
|
abortSignal?.addEventListener("abort", () => {
|
|
1091
1521
|
this.stopped = true;
|
|
1092
1522
|
});
|
|
1523
|
+
if (this.stopped) {
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1093
1526
|
|
|
1094
1527
|
if (this.initialPrompt) {
|
|
1095
1528
|
this.copilotLog("processing initial prompt");
|
|
@@ -1097,8 +1530,20 @@ class BridgeRunner {
|
|
|
1097
1530
|
includeImages: this.includeInitialImages,
|
|
1098
1531
|
});
|
|
1099
1532
|
}
|
|
1533
|
+
if (this.stopped) {
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
if (this.resumeMode) {
|
|
1537
|
+
await this.drainBufferedMessagesForResume();
|
|
1538
|
+
}
|
|
1539
|
+
if (this.stopped) {
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1100
1542
|
this.copilotLog("running startup backfill");
|
|
1101
1543
|
await this.backfillPendingUserMessages();
|
|
1544
|
+
if (this.stopped) {
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1102
1547
|
|
|
1103
1548
|
while (!this.stopped) {
|
|
1104
1549
|
if (this.needsReconnectRecovery && !this.runningTurn) {
|
|
@@ -1108,10 +1553,13 @@ class BridgeRunner {
|
|
|
1108
1553
|
try {
|
|
1109
1554
|
processed = await this.processIncomingBatch();
|
|
1110
1555
|
} catch (error) {
|
|
1556
|
+
if (this.stopped && (this.remoteStopInfo || isSessionClosedError(error))) {
|
|
1557
|
+
break;
|
|
1558
|
+
}
|
|
1111
1559
|
log(`Error while processing messages: ${error.message}`);
|
|
1112
1560
|
await this.reportError(`处理任务失败: ${error.message}`);
|
|
1113
1561
|
}
|
|
1114
|
-
if (!processed) {
|
|
1562
|
+
if (!processed && !this.stopped) {
|
|
1115
1563
|
await delay(this.pollIntervalMs);
|
|
1116
1564
|
}
|
|
1117
1565
|
}
|
|
@@ -1121,6 +1569,42 @@ class BridgeRunner {
|
|
|
1121
1569
|
this.needsReconnectRecovery = true;
|
|
1122
1570
|
}
|
|
1123
1571
|
|
|
1572
|
+
getRemoteStopSummary() {
|
|
1573
|
+
if (!this.remoteStopInfo) {
|
|
1574
|
+
return null;
|
|
1575
|
+
}
|
|
1576
|
+
const reason = this.remoteStopInfo.reason || "killed by app";
|
|
1577
|
+
return `task stopped by app: ${reason}`;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
async requestStopFromRemote(event = {}) {
|
|
1581
|
+
const taskId = typeof event.taskId === "string" ? event.taskId.trim() : "";
|
|
1582
|
+
if (taskId && taskId !== this.taskId) {
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
const requestId = typeof event.requestId === "string" ? event.requestId.trim() : "";
|
|
1586
|
+
const reason = typeof event.reason === "string" ? event.reason.trim() : "";
|
|
1587
|
+
if (!this.remoteStopInfo) {
|
|
1588
|
+
this.remoteStopInfo = {
|
|
1589
|
+
requestId: requestId || null,
|
|
1590
|
+
reason: reason || null,
|
|
1591
|
+
};
|
|
1592
|
+
log(
|
|
1593
|
+
`Received stop_task for ${this.taskId}${
|
|
1594
|
+
reason ? ` (${reason})` : ""
|
|
1595
|
+
}; stopping conductor fire`,
|
|
1596
|
+
);
|
|
1597
|
+
}
|
|
1598
|
+
this.stopped = true;
|
|
1599
|
+
if (typeof this.backendSession?.close === "function") {
|
|
1600
|
+
try {
|
|
1601
|
+
await this.backendSession.close();
|
|
1602
|
+
} catch (error) {
|
|
1603
|
+
log(`Failed to stop backend session for ${this.taskId}: ${error?.message || error}`);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1124
1608
|
async recoverAfterReconnect() {
|
|
1125
1609
|
if (!this.needsReconnectRecovery) {
|
|
1126
1610
|
return;
|
|
@@ -1137,6 +1621,41 @@ class BridgeRunner {
|
|
|
1137
1621
|
await this.replayLastRuntimeStatus();
|
|
1138
1622
|
}
|
|
1139
1623
|
|
|
1624
|
+
async drainBufferedMessagesForResume() {
|
|
1625
|
+
let drainedCount = 0;
|
|
1626
|
+
let drainedBatches = 0;
|
|
1627
|
+
|
|
1628
|
+
while (!this.stopped) {
|
|
1629
|
+
const result = await this.conductor.receiveMessages(this.taskId, 50);
|
|
1630
|
+
const messages = Array.isArray(result?.messages) ? result.messages : [];
|
|
1631
|
+
if (messages.length === 0) {
|
|
1632
|
+
break;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
drainedBatches += 1;
|
|
1636
|
+
for (const message of messages) {
|
|
1637
|
+
const replyTo = message?.message_id ? String(message.message_id) : "";
|
|
1638
|
+
if (replyTo) {
|
|
1639
|
+
this.processedMessageIds.add(replyTo);
|
|
1640
|
+
}
|
|
1641
|
+
drainedCount += 1;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
const ackToken = result.next_ack_token || result.nextAckToken;
|
|
1645
|
+
if (ackToken) {
|
|
1646
|
+
await this.conductor.ackMessages(this.taskId, ackToken);
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
if (drainedCount > 0) {
|
|
1651
|
+
log(
|
|
1652
|
+
`Resume startup skipped ${drainedCount} buffered message(s) in ${drainedBatches} batch(es) for task ${this.taskId}`,
|
|
1653
|
+
);
|
|
1654
|
+
} else {
|
|
1655
|
+
this.copilotLog("resume startup found no buffered messages to skip");
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1140
1659
|
async processIncomingBatch() {
|
|
1141
1660
|
const result = await this.conductor.receiveMessages(this.taskId, 20);
|
|
1142
1661
|
const messages = Array.isArray(result?.messages) ? result.messages : [];
|
|
@@ -1151,6 +1670,9 @@ class BridgeRunner {
|
|
|
1151
1670
|
);
|
|
1152
1671
|
|
|
1153
1672
|
for (const message of messages) {
|
|
1673
|
+
if (this.stopped) {
|
|
1674
|
+
break;
|
|
1675
|
+
}
|
|
1154
1676
|
if (!this.shouldRespond(message)) {
|
|
1155
1677
|
this.copilotLog(`skip message role=${String(message?.role || "unknown").toLowerCase()}`);
|
|
1156
1678
|
continue;
|
|
@@ -1211,12 +1733,37 @@ class BridgeRunner {
|
|
|
1211
1733
|
}
|
|
1212
1734
|
}
|
|
1213
1735
|
|
|
1736
|
+
const historyUserIds = history
|
|
1737
|
+
.filter((item) => String(item?.role || "").toLowerCase() === "user")
|
|
1738
|
+
.map((item) => (item?.id ? String(item.id) : ""))
|
|
1739
|
+
.filter(Boolean);
|
|
1740
|
+
|
|
1741
|
+
const handledUserIds = history
|
|
1742
|
+
.slice(0, lastSdkIndex + 1)
|
|
1743
|
+
.filter((item) => String(item?.role || "").toLowerCase() === "user")
|
|
1744
|
+
.map((item) => (item?.id ? String(item.id) : ""))
|
|
1745
|
+
.filter(Boolean);
|
|
1746
|
+
|
|
1747
|
+
for (const handledId of handledUserIds) {
|
|
1748
|
+
this.processedMessageIds.add(handledId);
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1214
1751
|
const pendingUserMessages = history
|
|
1215
1752
|
.slice(lastSdkIndex + 1)
|
|
1216
1753
|
.filter((item) => String(item?.role || "").toLowerCase() === "user")
|
|
1217
1754
|
.filter((item) => typeof item?.content === "string" && item.content.trim());
|
|
1755
|
+
|
|
1756
|
+
if (this.resumeMode) {
|
|
1757
|
+
for (const historyId of historyUserIds) {
|
|
1758
|
+
this.processedMessageIds.add(historyId);
|
|
1759
|
+
}
|
|
1760
|
+
this.copilotLog(
|
|
1761
|
+
`resume mode: seeded processed ids=${historyUserIds.length}, skip startup backfill replay`,
|
|
1762
|
+
);
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1218
1765
|
this.copilotLog(
|
|
1219
|
-
`backfill loaded history=${history.length} pendingUsers=${pendingUserMessages.length} lastSdkIndex=${lastSdkIndex}`,
|
|
1766
|
+
`backfill loaded history=${history.length} handledUsers=${handledUserIds.length} pendingUsers=${pendingUserMessages.length} lastSdkIndex=${lastSdkIndex}`,
|
|
1220
1767
|
);
|
|
1221
1768
|
|
|
1222
1769
|
for (const pending of pendingUserMessages) {
|
|
@@ -1388,6 +1935,12 @@ class BridgeRunner {
|
|
|
1388
1935
|
this.copilotLog(`sdk_message sent replyTo=${replyTo || "latest"} responseLen=${responseText.length}`);
|
|
1389
1936
|
} catch (error) {
|
|
1390
1937
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1938
|
+
if (this.stopped && (this.remoteStopInfo || isSessionClosedError(error))) {
|
|
1939
|
+
this.copilotLog(
|
|
1940
|
+
`turn interrupted by stop_task replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt}`,
|
|
1941
|
+
);
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1391
1944
|
this.copilotLog(
|
|
1392
1945
|
`turn failed replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt} error="${sanitizeForLog(errorMessage, 200)}"`,
|
|
1393
1946
|
);
|
|
@@ -1446,6 +1999,10 @@ class BridgeRunner {
|
|
|
1446
1999
|
});
|
|
1447
2000
|
this.copilotLog(`synthetic sdk_message sent responseLen=${text.length}`);
|
|
1448
2001
|
} catch (error) {
|
|
2002
|
+
if (this.stopped && (this.remoteStopInfo || isSessionClosedError(error))) {
|
|
2003
|
+
this.copilotLog(`synthetic turn interrupted by stop_task elapsedMs=${Date.now() - startedAt}`);
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
1449
2006
|
this.copilotLog(
|
|
1450
2007
|
`synthetic turn failed elapsedMs=${Date.now() - startedAt} error="${sanitizeForLog(error?.message || error, 200)}"`,
|
|
1451
2008
|
);
|
|
@@ -1519,6 +2076,22 @@ function logBackendReply(backend, text, { usage, replyTo }) {
|
|
|
1519
2076
|
log(`${backend} reply (${replyTo}): ${text}${usageSuffix}`);
|
|
1520
2077
|
}
|
|
1521
2078
|
|
|
2079
|
+
export function formatFatalError(error, opts = {}) {
|
|
2080
|
+
const showStack =
|
|
2081
|
+
typeof opts.showStack === "boolean"
|
|
2082
|
+
? opts.showStack
|
|
2083
|
+
: isTruthyEnv(process.env.CONDUCTOR_CLI_SHOW_STACK) ||
|
|
2084
|
+
isTruthyEnv(process.env.CONDUCTOR_DEBUG);
|
|
2085
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2086
|
+
if (!showStack) {
|
|
2087
|
+
return message;
|
|
2088
|
+
}
|
|
2089
|
+
if (error instanceof Error && typeof error.stack === "string" && error.stack.trim()) {
|
|
2090
|
+
return error.stack;
|
|
2091
|
+
}
|
|
2092
|
+
return message;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
1522
2095
|
function isDirectRun() {
|
|
1523
2096
|
try {
|
|
1524
2097
|
if (!process.argv[1]) {
|
|
@@ -1535,7 +2108,7 @@ function isDirectRun() {
|
|
|
1535
2108
|
if (isDirectRun()) {
|
|
1536
2109
|
main().catch((error) => {
|
|
1537
2110
|
const ts = new Date().toLocaleString("sv-SE", { timeZone: "Asia/Shanghai" }).replace(" ", "T");
|
|
1538
|
-
const line = `[${CLI_NAME} ${ts}] failed: ${error
|
|
2111
|
+
const line = `[${CLI_NAME} ${ts}] failed: ${formatFatalError(error)}\n`;
|
|
1539
2112
|
process.stderr.write(line);
|
|
1540
2113
|
appendFireLocalLog(line);
|
|
1541
2114
|
process.exitCode = 1;
|