@love-moon/conductor-cli 0.2.18 → 0.2.19
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-config.js +28 -29
- package/bin/conductor-fire.js +77 -8
- package/package.json +5 -4
- package/src/daemon.js +642 -27
- package/src/runtime-backends.js +31 -0
package/bin/conductor-config.js
CHANGED
|
@@ -8,6 +8,7 @@ import readline from "node:readline/promises";
|
|
|
8
8
|
import { execSync } from "node:child_process";
|
|
9
9
|
import yargs from "yargs/yargs";
|
|
10
10
|
import { hideBin } from "yargs/helpers";
|
|
11
|
+
import { RUNTIME_SUPPORTED_BACKENDS } from "../src/runtime-backends.js";
|
|
11
12
|
|
|
12
13
|
const CONFIG_DIR = path.join(os.homedir(), ".conductor");
|
|
13
14
|
const CONFIG_FILE = path.join(CONFIG_DIR, "config.yaml");
|
|
@@ -25,21 +26,16 @@ const DEFAULT_CLIs = {
|
|
|
25
26
|
execArgs: "exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check",
|
|
26
27
|
description: "OpenAI Codex CLI"
|
|
27
28
|
},
|
|
28
|
-
// copilot: {
|
|
29
|
-
// command: "copilot",
|
|
30
|
-
// execArgs: "--allow-all-paths --allow-all-tools",
|
|
31
|
-
// description: "GitHub Copilot CLI"
|
|
32
|
-
// },
|
|
33
29
|
// gemini: {
|
|
34
30
|
// command: "gemini",
|
|
35
31
|
// execArgs: "",
|
|
36
32
|
// description: "Google Gemini CLI"
|
|
37
33
|
// },
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
34
|
+
opencode: {
|
|
35
|
+
command: "opencode",
|
|
36
|
+
execArgs: "",
|
|
37
|
+
description: "OpenCode CLI (Conductor runs opencode serve with permission=allow)"
|
|
38
|
+
},
|
|
43
39
|
// kimi: {
|
|
44
40
|
// command: "kimi",
|
|
45
41
|
// execArgs: "--yolo --print --prompt",
|
|
@@ -66,6 +62,22 @@ function colorize(text, color) {
|
|
|
66
62
|
return `${COLORS[color] || ""}${text}${COLORS.reset}`;
|
|
67
63
|
}
|
|
68
64
|
|
|
65
|
+
function buildConfigEntryLines(cli, info, { commented = false } = {}) {
|
|
66
|
+
const fullCommand = info.execArgs
|
|
67
|
+
? `${info.command} ${info.execArgs}`
|
|
68
|
+
: info.command;
|
|
69
|
+
const entryPrefix = commented ? " # " : " ";
|
|
70
|
+
const commentPrefix = commented ? " # " : " # ";
|
|
71
|
+
const lines = [];
|
|
72
|
+
|
|
73
|
+
if (cli === "opencode") {
|
|
74
|
+
lines.push(`${commentPrefix}opencode runs via ai-sdk server mode with permission=allow`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
lines.push(`${entryPrefix}${cli}: ${fullCommand}`);
|
|
78
|
+
return lines;
|
|
79
|
+
}
|
|
80
|
+
|
|
69
81
|
async function main() {
|
|
70
82
|
// 解析命令行参数
|
|
71
83
|
const argv = yargs(hideBin(process.argv))
|
|
@@ -170,19 +182,13 @@ async function main() {
|
|
|
170
182
|
if (detectedCLIs.length > 0) {
|
|
171
183
|
detectedCLIs.forEach(cli => {
|
|
172
184
|
const info = DEFAULT_CLIs[cli];
|
|
173
|
-
|
|
174
|
-
? `${info.command} ${info.execArgs}`
|
|
175
|
-
: info.command;
|
|
176
|
-
lines.push(` ${cli}: ${fullCommand}`);
|
|
185
|
+
lines.push(...buildConfigEntryLines(cli, info));
|
|
177
186
|
});
|
|
178
187
|
} else {
|
|
179
188
|
// 如果没有检测到任何 CLI,添加示例注释
|
|
180
189
|
lines.push(" # No CLI detected. Add your installed CLI here:");
|
|
181
|
-
Object.entries(DEFAULT_CLIs).
|
|
182
|
-
|
|
183
|
-
? `${info.command} ${info.execArgs}`
|
|
184
|
-
: info.command;
|
|
185
|
-
lines.push(` # ${key}: ${fullCommand}`);
|
|
190
|
+
Object.entries(DEFAULT_CLIs).forEach(([key, info]) => {
|
|
191
|
+
lines.push(...buildConfigEntryLines(key, info, { commented: true }));
|
|
186
192
|
});
|
|
187
193
|
}
|
|
188
194
|
|
|
@@ -213,6 +219,9 @@ function detectInstalledCLIs() {
|
|
|
213
219
|
const detected = [];
|
|
214
220
|
|
|
215
221
|
for (const [key, info] of Object.entries(DEFAULT_CLIs)) {
|
|
222
|
+
if (!RUNTIME_SUPPORTED_BACKENDS.includes(key)) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
216
225
|
if (isCommandAvailable(info.command)) {
|
|
217
226
|
detected.push(key);
|
|
218
227
|
}
|
|
@@ -282,16 +291,6 @@ function checkAlternativeInstallations(command) {
|
|
|
282
291
|
);
|
|
283
292
|
}
|
|
284
293
|
|
|
285
|
-
// // 特殊检查:Copilot CLI 可能是 gh copilot 扩展
|
|
286
|
-
// if (command === "copilot" || command === "copilot-chat") {
|
|
287
|
-
// try {
|
|
288
|
-
// execSync("gh copilot --help", { stdio: "pipe", timeout: 5000 });
|
|
289
|
-
// return true;
|
|
290
|
-
// } catch {
|
|
291
|
-
// // gh copilot 未安装
|
|
292
|
-
// }
|
|
293
|
-
// }
|
|
294
|
-
|
|
295
294
|
// 检查文件是否存在
|
|
296
295
|
for (const checkPath of commonPaths) {
|
|
297
296
|
if (fs.existsSync(checkPath)) {
|
package/bin/conductor-fire.js
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
resolveSessionRunDirectory as resolveCliSessionRunDirectory,
|
|
27
27
|
resumeProviderForBackend as resumeProviderForCliBackend,
|
|
28
28
|
} from "../src/fire/resume.js";
|
|
29
|
+
import { filterRuntimeSupportedAllowCliList, normalizeRuntimeBackendName } from "../src/runtime-backends.js";
|
|
29
30
|
|
|
30
31
|
const __filename = fileURLToPath(import.meta.url);
|
|
31
32
|
const __dirname = path.dirname(__filename);
|
|
@@ -50,7 +51,7 @@ function loadAllowCliList(configFilePath) {
|
|
|
50
51
|
const content = fs.readFileSync(configPath, "utf8");
|
|
51
52
|
const parsed = yaml.load(content);
|
|
52
53
|
if (parsed && typeof parsed === "object" && parsed.allow_cli_list) {
|
|
53
|
-
return parsed.allow_cli_list;
|
|
54
|
+
return filterRuntimeSupportedAllowCliList(parsed.allow_cli_list);
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
} catch (error) {
|
|
@@ -59,6 +60,35 @@ function loadAllowCliList(configFilePath) {
|
|
|
59
60
|
return {};
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
export function resolveAiSessionCommandLine(backend, allowCliList, env = process.env) {
|
|
64
|
+
const normalizedBackend = normalizeRuntimeBackendName(backend);
|
|
65
|
+
if (normalizedBackend !== "opencode") {
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const opencodeEnvCommand =
|
|
70
|
+
typeof env?.CONDUCTOR_OPENCODE_COMMAND === "string" ? env.CONDUCTOR_OPENCODE_COMMAND.trim() : "";
|
|
71
|
+
if (opencodeEnvCommand) {
|
|
72
|
+
return opencodeEnvCommand;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const configuredCommand =
|
|
76
|
+
allowCliList && typeof allowCliList === "object" && typeof allowCliList.opencode === "string"
|
|
77
|
+
? allowCliList.opencode.trim()
|
|
78
|
+
: "";
|
|
79
|
+
if (configuredCommand) {
|
|
80
|
+
return configuredCommand;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const daemonCommand =
|
|
84
|
+
typeof env?.CONDUCTOR_CLI_COMMAND === "string" ? env.CONDUCTOR_CLI_COMMAND.trim() : "";
|
|
85
|
+
if (daemonCommand) {
|
|
86
|
+
return daemonCommand;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return "";
|
|
90
|
+
}
|
|
91
|
+
|
|
62
92
|
const DEFAULT_POLL_INTERVAL_MS = parseInt(
|
|
63
93
|
process.env.CONDUCTOR_CLI_POLL_INTERVAL_MS || process.env.CCODEX_POLL_INTERVAL_MS || "2000",
|
|
64
94
|
10,
|
|
@@ -180,7 +210,7 @@ async function main() {
|
|
|
180
210
|
|
|
181
211
|
if (cliArgs.listBackends) {
|
|
182
212
|
if (supportedBackends.length === 0) {
|
|
183
|
-
process.stdout.write(`No backends configured.\n\nAdd allow_cli_list to your config file (~/.conductor/config.yaml):\n allow_cli_list:\n codex: codex --dangerously-bypass-approvals-and-sandbox\n claude: claude --dangerously-skip-permissions\n
|
|
213
|
+
process.stdout.write(`No supported backends configured.\n\nAdd allow_cli_list to your config file (~/.conductor/config.yaml):\n allow_cli_list:\n codex: codex --dangerously-bypass-approvals-and-sandbox\n claude: claude --dangerously-skip-permissions\n opencode: opencode\n`);
|
|
184
214
|
} else {
|
|
185
215
|
process.stdout.write(`Supported backends (from config):\n`);
|
|
186
216
|
for (const [name, command] of Object.entries(allowCliList)) {
|
|
@@ -333,11 +363,14 @@ async function main() {
|
|
|
333
363
|
|
|
334
364
|
const resolvedResumeSessionId = cliArgs.resumeSessionId;
|
|
335
365
|
|
|
366
|
+
const sessionCommandLine = resolveAiSessionCommandLine(cliArgs.backend, allowCliList, process.env);
|
|
367
|
+
|
|
336
368
|
backendSession = createAiSession(cliArgs.backend, {
|
|
337
369
|
initialImages: cliArgs.initialImages,
|
|
338
370
|
cwd: runtimeProjectPath,
|
|
339
371
|
resumeSessionId: resolvedResumeSessionId,
|
|
340
372
|
configFile: cliArgs.configFile,
|
|
373
|
+
...(sessionCommandLine ? { commandLine: sessionCommandLine } : {}),
|
|
341
374
|
logger: { log },
|
|
342
375
|
});
|
|
343
376
|
|
|
@@ -359,6 +392,7 @@ async function main() {
|
|
|
359
392
|
taskId: taskContext.taskId,
|
|
360
393
|
pollIntervalMs: Math.max(cliArgs.pollIntervalMs, 500),
|
|
361
394
|
initialPrompt: taskContext.shouldProcessInitialPrompt ? cliArgs.initialPrompt : "",
|
|
395
|
+
initialPromptDelivery: taskContext.initialPromptDelivery || "none",
|
|
362
396
|
includeInitialImages: Boolean(cliArgs.initialPrompt && cliArgs.initialImages.length),
|
|
363
397
|
cliArgs: cliArgs.rawBackendArgs,
|
|
364
398
|
backendName: cliArgs.backend,
|
|
@@ -578,7 +612,6 @@ export function parseCliArgs(argvInput = process.argv) {
|
|
|
578
612
|
alias: "b",
|
|
579
613
|
type: "string",
|
|
580
614
|
describe: `Backend to use (loaded from config: ${supportedBackends.join(", ") || "none configured"})`,
|
|
581
|
-
...(supportedBackends.length > 0 ? { choices: supportedBackends } : {}),
|
|
582
615
|
})
|
|
583
616
|
.option("list-backends", {
|
|
584
617
|
type: "boolean",
|
|
@@ -640,12 +673,12 @@ Config file format (~/.conductor/config.yaml):
|
|
|
640
673
|
allow_cli_list:
|
|
641
674
|
codex: codex --dangerously-bypass-approvals-and-sandbox
|
|
642
675
|
claude: claude --dangerously-skip-permissions
|
|
643
|
-
|
|
676
|
+
opencode: opencode
|
|
644
677
|
|
|
645
678
|
Examples:
|
|
646
679
|
${CLI_NAME} -- "fix the bug" # Use default backend
|
|
647
680
|
${CLI_NAME} --backend claude -- "fix the bug" # Use Claude CLI backend
|
|
648
|
-
${CLI_NAME} --backend
|
|
681
|
+
${CLI_NAME} --backend opencode -- "fix the bug" # Use OpenCode backend
|
|
649
682
|
${CLI_NAME} --backend codex --resume <id> # Resume Codex session
|
|
650
683
|
${CLI_NAME} --list-backends # Show configured backends
|
|
651
684
|
${CLI_NAME} --config-file ~/.conductor/config.yaml -- "fix the bug"
|
|
@@ -668,7 +701,22 @@ Environment:
|
|
|
668
701
|
})
|
|
669
702
|
.parse();
|
|
670
703
|
|
|
671
|
-
const backend = conductorArgs.backend
|
|
704
|
+
const backend = conductorArgs.backend
|
|
705
|
+
? normalizeRuntimeBackendName(conductorArgs.backend)
|
|
706
|
+
: supportedBackends[0];
|
|
707
|
+
const shouldRequireBackend =
|
|
708
|
+
!Boolean(conductorArgs.listBackends) &&
|
|
709
|
+
!listBackendsWithoutSeparator &&
|
|
710
|
+
!Boolean(conductorArgs.version) &&
|
|
711
|
+
!versionWithoutSeparator;
|
|
712
|
+
if (backend && !supportedBackends.includes(backend) && shouldRequireBackend) {
|
|
713
|
+
throw new Error(
|
|
714
|
+
`Unsupported backend "${backend}". Supported backends: ${supportedBackends.join(", ") || "none configured"}.`,
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
if (!backend && shouldRequireBackend) {
|
|
718
|
+
throw new Error("No supported backends configured. Add codex, claude, or opencode to allow_cli_list.");
|
|
719
|
+
}
|
|
672
720
|
|
|
673
721
|
const prompt = (backendArgs._ || []).map((part) => String(part)).join(" ").trim();
|
|
674
722
|
const initialImages = normalizeArray(backendArgs.image || backendArgs.i).map((img) => String(img));
|
|
@@ -806,6 +854,7 @@ async function ensureTaskContext(conductor, opts) {
|
|
|
806
854
|
taskId: opts.providedTaskId,
|
|
807
855
|
appUrl: null,
|
|
808
856
|
shouldProcessInitialPrompt: Boolean(opts.initialPrompt),
|
|
857
|
+
initialPromptDelivery: opts.initialPrompt ? "synthetic" : "none",
|
|
809
858
|
};
|
|
810
859
|
}
|
|
811
860
|
|
|
@@ -837,6 +886,7 @@ async function ensureTaskContext(conductor, opts) {
|
|
|
837
886
|
taskId: session.task_id,
|
|
838
887
|
appUrl: session.app_url || null,
|
|
839
888
|
shouldProcessInitialPrompt: Boolean(opts.initialPrompt),
|
|
889
|
+
initialPromptDelivery: opts.initialPrompt ? "queued" : "none",
|
|
840
890
|
};
|
|
841
891
|
}
|
|
842
892
|
|
|
@@ -1016,6 +1066,7 @@ export class BridgeRunner {
|
|
|
1016
1066
|
taskId,
|
|
1017
1067
|
pollIntervalMs,
|
|
1018
1068
|
initialPrompt,
|
|
1069
|
+
initialPromptDelivery,
|
|
1019
1070
|
includeInitialImages,
|
|
1020
1071
|
cliArgs,
|
|
1021
1072
|
backendName,
|
|
@@ -1027,6 +1078,10 @@ export class BridgeRunner {
|
|
|
1027
1078
|
this.taskId = taskId;
|
|
1028
1079
|
this.pollIntervalMs = pollIntervalMs;
|
|
1029
1080
|
this.initialPrompt = initialPrompt;
|
|
1081
|
+
this.initialPromptDelivery =
|
|
1082
|
+
typeof initialPromptDelivery === "string" && initialPromptDelivery.trim()
|
|
1083
|
+
? initialPromptDelivery.trim()
|
|
1084
|
+
: "none";
|
|
1030
1085
|
this.includeInitialImages = includeInitialImages;
|
|
1031
1086
|
this.cliArgs = cliArgs;
|
|
1032
1087
|
this.backendName = backendName || "codex";
|
|
@@ -1042,6 +1097,10 @@ export class BridgeRunner {
|
|
|
1042
1097
|
this.runningTurn = false;
|
|
1043
1098
|
this.processedMessageIds = new Set();
|
|
1044
1099
|
this.inFlightMessageIds = new Set();
|
|
1100
|
+
this.pendingInitialPrompt =
|
|
1101
|
+
this.initialPromptDelivery === "queued" && typeof initialPrompt === "string" && initialPrompt.trim()
|
|
1102
|
+
? initialPrompt.trim()
|
|
1103
|
+
: "";
|
|
1045
1104
|
this.sessionStreamReplyCounts = new Map();
|
|
1046
1105
|
this.lastRuntimeStatusSignature = null;
|
|
1047
1106
|
this.lastRuntimeStatusPayload = null;
|
|
@@ -1230,8 +1289,8 @@ export class BridgeRunner {
|
|
|
1230
1289
|
return;
|
|
1231
1290
|
}
|
|
1232
1291
|
|
|
1233
|
-
if (this.initialPrompt) {
|
|
1234
|
-
this.copilotLog("processing initial prompt");
|
|
1292
|
+
if (this.initialPrompt && this.initialPromptDelivery === "synthetic") {
|
|
1293
|
+
this.copilotLog("processing initial prompt via synthetic attach flow");
|
|
1235
1294
|
await this.handleSyntheticMessage(this.initialPrompt, {
|
|
1236
1295
|
includeImages: this.includeInitialImages,
|
|
1237
1296
|
});
|
|
@@ -1239,6 +1298,7 @@ export class BridgeRunner {
|
|
|
1239
1298
|
if (this.stopped) {
|
|
1240
1299
|
return;
|
|
1241
1300
|
}
|
|
1301
|
+
|
|
1242
1302
|
while (!this.stopped) {
|
|
1243
1303
|
if (this.needsReconnectRecovery && !this.runningTurn) {
|
|
1244
1304
|
await this.recoverAfterReconnect();
|
|
@@ -1783,6 +1843,11 @@ export class BridgeRunner {
|
|
|
1783
1843
|
if (replyTo) {
|
|
1784
1844
|
this.inFlightMessageIds.add(replyTo);
|
|
1785
1845
|
}
|
|
1846
|
+
const isQueuedInitialPromptMessage =
|
|
1847
|
+
Boolean(this.pendingInitialPrompt) &&
|
|
1848
|
+
String(message.role || "").toLowerCase() === "user" &&
|
|
1849
|
+
content === this.pendingInitialPrompt;
|
|
1850
|
+
const useInitialImages = isQueuedInitialPromptMessage && this.includeInitialImages;
|
|
1786
1851
|
if (
|
|
1787
1852
|
this.useSessionFileReplyStream &&
|
|
1788
1853
|
typeof this.backendSession?.setSessionReplyTarget === "function"
|
|
@@ -1830,6 +1895,7 @@ export class BridgeRunner {
|
|
|
1830
1895
|
}
|
|
1831
1896
|
|
|
1832
1897
|
const result = await this.backendSession.runTurn(content, {
|
|
1898
|
+
useInitialImages,
|
|
1833
1899
|
onProgress: (payload) => {
|
|
1834
1900
|
void this.reportRuntimeStatus(payload, replyTo);
|
|
1835
1901
|
},
|
|
@@ -1873,6 +1939,9 @@ export class BridgeRunner {
|
|
|
1873
1939
|
if (replyTo) {
|
|
1874
1940
|
this.processedMessageIds.add(replyTo);
|
|
1875
1941
|
}
|
|
1942
|
+
if (isQueuedInitialPromptMessage) {
|
|
1943
|
+
this.pendingInitialPrompt = "";
|
|
1944
|
+
}
|
|
1876
1945
|
this.resetErrorLoop();
|
|
1877
1946
|
if (this.useSessionFileReplyStream) {
|
|
1878
1947
|
this.copilotLog(`session_file turn settled replyTo=${replyTo || "latest"}`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@love-moon/conductor-cli",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"gitCommitId": "
|
|
3
|
+
"version": "0.2.19",
|
|
4
|
+
"gitCommitId": "346e048",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"conductor": "bin/conductor.js"
|
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
"test": "node --test test/*.test.js"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@love-moon/ai-sdk": "0.2.
|
|
21
|
-
"@love-moon/conductor-sdk": "0.2.
|
|
20
|
+
"@love-moon/ai-sdk": "0.2.19",
|
|
21
|
+
"@love-moon/conductor-sdk": "0.2.19",
|
|
22
22
|
"dotenv": "^16.4.5",
|
|
23
23
|
"enquirer": "^2.4.1",
|
|
24
24
|
"js-yaml": "^4.1.1",
|
|
25
|
+
"node-pty": "^1.0.0",
|
|
25
26
|
"ws": "^8.18.0",
|
|
26
27
|
"yargs": "^17.7.2",
|
|
27
28
|
"chrome-launcher": "^1.2.1",
|
package/src/daemon.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import os from "node:os";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
4
5
|
import { spawn } from "node:child_process";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
|
|
@@ -9,11 +10,13 @@ import yaml from "js-yaml";
|
|
|
9
10
|
|
|
10
11
|
import { ConductorWebSocketClient, ConductorConfig, loadConfig, ConfigFileNotFound } from "@love-moon/conductor-sdk";
|
|
11
12
|
import { DaemonLogCollector } from "./log-collector.js";
|
|
13
|
+
import { filterRuntimeSupportedAllowCliList, normalizeRuntimeBackendName } from "./runtime-backends.js";
|
|
12
14
|
|
|
13
15
|
dotenv.config();
|
|
14
16
|
|
|
15
17
|
const __filename = fileURLToPath(import.meta.url);
|
|
16
18
|
const __dirname = path.dirname(__filename);
|
|
19
|
+
const moduleRequire = createRequire(import.meta.url);
|
|
17
20
|
const CLI_PATH = path.resolve(__dirname, "..", "bin", "conductor-fire.js");
|
|
18
21
|
const DAEMON_LOG_DIR = path.join(os.homedir(), ".conductor", "logs");
|
|
19
22
|
const DAEMON_LOG_PATH = path.join(DAEMON_LOG_DIR, "conductor-daemon.log");
|
|
@@ -22,6 +25,10 @@ const PLAN_LIMIT_MESSAGES = {
|
|
|
22
25
|
app_active_task: "Free plan limit reached: only 1 active app task is allowed.",
|
|
23
26
|
daemon_active_connection: "Free plan limit reached: only 1 active daemon connection is allowed.",
|
|
24
27
|
};
|
|
28
|
+
const DEFAULT_TERMINAL_COLS = 120;
|
|
29
|
+
const DEFAULT_TERMINAL_ROWS = 40;
|
|
30
|
+
const DEFAULT_TERMINAL_RING_BUFFER_MAX_BYTES = 2 * 1024 * 1024;
|
|
31
|
+
let nodePtySpawnPromise = null;
|
|
25
32
|
|
|
26
33
|
function appendDaemonLog(line) {
|
|
27
34
|
try {
|
|
@@ -112,16 +119,118 @@ function getPlanLimitMessage(payload) {
|
|
|
112
119
|
const DEFAULT_CLI_LIST = {
|
|
113
120
|
codex: "codex --dangerously-bypass-approvals-and-sandbox",
|
|
114
121
|
claude: "claude --dangerously-skip-permissions",
|
|
122
|
+
opencode: "opencode",
|
|
115
123
|
};
|
|
116
124
|
|
|
117
125
|
function getAllowCliList(userConfig) {
|
|
118
126
|
// If user has configured allow_cli_list, use it; otherwise use defaults
|
|
119
127
|
if (userConfig.allow_cli_list && typeof userConfig.allow_cli_list === "object") {
|
|
120
|
-
return userConfig.allow_cli_list;
|
|
128
|
+
return filterRuntimeSupportedAllowCliList(userConfig.allow_cli_list);
|
|
121
129
|
}
|
|
122
130
|
return DEFAULT_CLI_LIST;
|
|
123
131
|
}
|
|
124
132
|
|
|
133
|
+
async function defaultCreatePty(command, args, options) {
|
|
134
|
+
if (!nodePtySpawnPromise) {
|
|
135
|
+
const spawnHelperInfo = ensureNodePtySpawnHelperExecutable();
|
|
136
|
+
if (spawnHelperInfo?.updated) {
|
|
137
|
+
log(`Enabled execute permission on node-pty spawn-helper: ${spawnHelperInfo.helperPath}`);
|
|
138
|
+
}
|
|
139
|
+
nodePtySpawnPromise = import("node-pty").then((mod) => {
|
|
140
|
+
if (typeof mod.spawn === "function") {
|
|
141
|
+
return mod.spawn;
|
|
142
|
+
}
|
|
143
|
+
if (mod.default && typeof mod.default.spawn === "function") {
|
|
144
|
+
return mod.default.spawn.bind(mod.default);
|
|
145
|
+
}
|
|
146
|
+
throw new Error("node-pty spawn export not found");
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
const spawnPty = await nodePtySpawnPromise;
|
|
150
|
+
return spawnPty(command, args, options);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function ensureNodePtySpawnHelperExecutable(deps = {}) {
|
|
154
|
+
const platform = deps.platform || process.platform;
|
|
155
|
+
if (platform === "win32") {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const arch = deps.arch || process.arch;
|
|
160
|
+
const existsSyncFn = deps.existsSync || fs.existsSync;
|
|
161
|
+
const statSyncFn = deps.statSync || fs.statSync;
|
|
162
|
+
const chmodSyncFn = deps.chmodSync || fs.chmodSync;
|
|
163
|
+
let packageJsonPath = deps.packageJsonPath || null;
|
|
164
|
+
|
|
165
|
+
if (!packageJsonPath) {
|
|
166
|
+
try {
|
|
167
|
+
packageJsonPath = moduleRequire.resolve("node-pty/package.json");
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const packageDir = path.dirname(packageJsonPath);
|
|
174
|
+
const helperCandidates = [
|
|
175
|
+
path.join(packageDir, "build", "Release", "spawn-helper"),
|
|
176
|
+
path.join(packageDir, "build", "Debug", "spawn-helper"),
|
|
177
|
+
path.join(packageDir, "prebuilds", `${platform}-${arch}`, "spawn-helper"),
|
|
178
|
+
];
|
|
179
|
+
const helperPath = helperCandidates.find((candidate) => existsSyncFn(candidate));
|
|
180
|
+
if (!helperPath) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const currentMode = statSyncFn(helperPath).mode & 0o777;
|
|
185
|
+
if ((currentMode & 0o111) !== 0) {
|
|
186
|
+
return { helperPath, updated: false };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const nextMode = currentMode | 0o111;
|
|
190
|
+
chmodSyncFn(helperPath, nextMode);
|
|
191
|
+
return { helperPath, updated: true };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function normalizeOptionalString(value) {
|
|
195
|
+
if (typeof value !== "string") {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const normalized = value.trim();
|
|
199
|
+
return normalized || null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function normalizePositiveInt(value, fallback) {
|
|
203
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
204
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
205
|
+
return parsed;
|
|
206
|
+
}
|
|
207
|
+
return fallback;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function normalizeLaunchConfig(value) {
|
|
211
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
212
|
+
return {};
|
|
213
|
+
}
|
|
214
|
+
return value;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function normalizeTerminalEnv(value) {
|
|
218
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
219
|
+
return {};
|
|
220
|
+
}
|
|
221
|
+
const env = {};
|
|
222
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
223
|
+
if (typeof raw === "string") {
|
|
224
|
+
env[key] = raw;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (typeof raw === "number" || typeof raw === "boolean") {
|
|
228
|
+
env[key] = String(raw);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return env;
|
|
232
|
+
}
|
|
233
|
+
|
|
125
234
|
export function startDaemon(config = {}, deps = {}) {
|
|
126
235
|
const exitFn = deps.exit || process.exit;
|
|
127
236
|
const killFn = deps.kill || process.kill;
|
|
@@ -254,6 +363,10 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
254
363
|
process.env.CONDUCTOR_DAEMON_WATCHDOG_MAX_SELF_HEALS,
|
|
255
364
|
3,
|
|
256
365
|
);
|
|
366
|
+
const TERMINAL_RING_BUFFER_MAX_BYTES = parsePositiveInt(
|
|
367
|
+
config.TERMINAL_RING_BUFFER_MAX_BYTES || process.env.CONDUCTOR_TERMINAL_RING_BUFFER_MAX_BYTES,
|
|
368
|
+
DEFAULT_TERMINAL_RING_BUFFER_MAX_BYTES,
|
|
369
|
+
);
|
|
257
370
|
|
|
258
371
|
try {
|
|
259
372
|
mkdirSyncFn(WORKSPACE_ROOT, { recursive: true });
|
|
@@ -389,6 +502,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
389
502
|
let didRecoverStaleTasks = false;
|
|
390
503
|
let daemonShuttingDown = false;
|
|
391
504
|
const activeTaskProcesses = new Map();
|
|
505
|
+
const activePtySessions = new Map();
|
|
392
506
|
const suppressedExitStatusReports = new Set();
|
|
393
507
|
const seenCommandRequestIds = new Set();
|
|
394
508
|
let lastConnectedAt = null;
|
|
@@ -406,10 +520,12 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
406
520
|
let watchdogAwaitingHealthySignalAt = null;
|
|
407
521
|
let watchdogTimer = null;
|
|
408
522
|
const logCollector = createLogCollector(BACKEND_HTTP);
|
|
523
|
+
const createPtyFn = deps.createPty || defaultCreatePty;
|
|
409
524
|
const client = createWebSocketClient(sdkConfig, {
|
|
410
525
|
extraHeaders: {
|
|
411
526
|
"x-conductor-host": AGENT_NAME,
|
|
412
527
|
"x-conductor-backends": SUPPORTED_BACKENDS.join(","),
|
|
528
|
+
"x-conductor-capabilities": "pty_task",
|
|
413
529
|
},
|
|
414
530
|
onConnected: ({ isReconnect, connectedAt } = { isReconnect: false, connectedAt: Date.now() }) => {
|
|
415
531
|
wsConnected = true;
|
|
@@ -626,6 +742,10 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
626
742
|
}
|
|
627
743
|
}
|
|
628
744
|
|
|
745
|
+
const getActiveTaskIds = () => [
|
|
746
|
+
...new Set([...activeTaskProcesses.keys(), ...activePtySessions.keys()]),
|
|
747
|
+
];
|
|
748
|
+
|
|
629
749
|
async function recoverStaleTasks() {
|
|
630
750
|
try {
|
|
631
751
|
const response = await fetchFn(`${BACKEND_HTTP}/api/tasks`, {
|
|
@@ -701,7 +821,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
701
821
|
if (!Array.isArray(tasks)) {
|
|
702
822
|
return;
|
|
703
823
|
}
|
|
704
|
-
const localTaskIds = new Set(
|
|
824
|
+
const localTaskIds = new Set(getActiveTaskIds());
|
|
705
825
|
const assigned = tasks.filter((task) => {
|
|
706
826
|
const agentHost = String(task?.agent_host || "").trim();
|
|
707
827
|
const status = String(task?.status || "").trim().toLowerCase();
|
|
@@ -746,7 +866,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
746
866
|
await client.sendJson({
|
|
747
867
|
type: "agent_resume",
|
|
748
868
|
payload: {
|
|
749
|
-
active_tasks:
|
|
869
|
+
active_tasks: getActiveTaskIds(),
|
|
750
870
|
source: "conductor-daemon",
|
|
751
871
|
metadata: { is_reconnect: Boolean(isReconnect) },
|
|
752
872
|
},
|
|
@@ -781,6 +901,442 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
781
901
|
});
|
|
782
902
|
}
|
|
783
903
|
|
|
904
|
+
function sendTerminalEvent(type, payload) {
|
|
905
|
+
return client.sendJson({
|
|
906
|
+
type,
|
|
907
|
+
payload,
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function resolvePtyLaunchSpec(launchConfig, fallbackCwd) {
|
|
912
|
+
const normalizedLaunchConfig = normalizeLaunchConfig(launchConfig);
|
|
913
|
+
const entrypointType =
|
|
914
|
+
normalizeOptionalString(normalizedLaunchConfig.entrypoint_type) ||
|
|
915
|
+
normalizeOptionalString(normalizedLaunchConfig.entrypointType) ||
|
|
916
|
+
(normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
|
|
917
|
+
normalizeOptionalString(normalizedLaunchConfig.toolPreset)
|
|
918
|
+
? "tool_preset"
|
|
919
|
+
: "shell");
|
|
920
|
+
const preferredShell =
|
|
921
|
+
normalizeOptionalString(normalizedLaunchConfig.shell) ||
|
|
922
|
+
process.env.SHELL ||
|
|
923
|
+
"/bin/zsh";
|
|
924
|
+
const cwd =
|
|
925
|
+
normalizeOptionalString(normalizedLaunchConfig.cwd) ||
|
|
926
|
+
fallbackCwd;
|
|
927
|
+
const env = normalizeTerminalEnv(normalizedLaunchConfig.env);
|
|
928
|
+
const cols = normalizePositiveInt(
|
|
929
|
+
normalizedLaunchConfig.cols ?? normalizedLaunchConfig.columns,
|
|
930
|
+
DEFAULT_TERMINAL_COLS,
|
|
931
|
+
);
|
|
932
|
+
const rows = normalizePositiveInt(
|
|
933
|
+
normalizedLaunchConfig.rows,
|
|
934
|
+
DEFAULT_TERMINAL_ROWS,
|
|
935
|
+
);
|
|
936
|
+
|
|
937
|
+
if (entrypointType === "tool_preset") {
|
|
938
|
+
const toolPreset =
|
|
939
|
+
normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
|
|
940
|
+
normalizeOptionalString(normalizedLaunchConfig.toolPreset) ||
|
|
941
|
+
SUPPORTED_BACKENDS[0] ||
|
|
942
|
+
"codex";
|
|
943
|
+
const cliCommand = ALLOW_CLI_LIST[toolPreset];
|
|
944
|
+
if (!cliCommand) {
|
|
945
|
+
throw new Error(`Unsupported tool preset: ${toolPreset}`);
|
|
946
|
+
}
|
|
947
|
+
return {
|
|
948
|
+
entrypointType,
|
|
949
|
+
toolPreset,
|
|
950
|
+
command: preferredShell,
|
|
951
|
+
args: ["-lc", cliCommand],
|
|
952
|
+
shell: preferredShell,
|
|
953
|
+
cwd,
|
|
954
|
+
env,
|
|
955
|
+
cols,
|
|
956
|
+
rows,
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
if (entrypointType === "custom") {
|
|
961
|
+
const command = normalizeOptionalString(normalizedLaunchConfig.command);
|
|
962
|
+
if (!command) {
|
|
963
|
+
throw new Error("launch_config.command is required for custom entrypoint");
|
|
964
|
+
}
|
|
965
|
+
const args = Array.isArray(normalizedLaunchConfig.args)
|
|
966
|
+
? normalizedLaunchConfig.args.filter((value) => typeof value === "string")
|
|
967
|
+
: [];
|
|
968
|
+
return {
|
|
969
|
+
entrypointType,
|
|
970
|
+
toolPreset: null,
|
|
971
|
+
command,
|
|
972
|
+
args,
|
|
973
|
+
shell: preferredShell,
|
|
974
|
+
cwd,
|
|
975
|
+
env,
|
|
976
|
+
cols,
|
|
977
|
+
rows,
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
return {
|
|
982
|
+
entrypointType: "shell",
|
|
983
|
+
toolPreset: null,
|
|
984
|
+
command: preferredShell,
|
|
985
|
+
args: ["-l"],
|
|
986
|
+
shell: preferredShell,
|
|
987
|
+
cwd,
|
|
988
|
+
env,
|
|
989
|
+
cols,
|
|
990
|
+
rows,
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function getTerminalChunkByteLength(data) {
|
|
995
|
+
return Buffer.byteLength(data, "utf8");
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function trimTerminalChunkToTailBytes(data, maxBytes) {
|
|
999
|
+
const encoded = Buffer.from(data, "utf8");
|
|
1000
|
+
if (encoded.length <= maxBytes) {
|
|
1001
|
+
return data;
|
|
1002
|
+
}
|
|
1003
|
+
const tail = encoded.subarray(encoded.length - maxBytes);
|
|
1004
|
+
let start = 0;
|
|
1005
|
+
while (start < tail.length && (tail[start] & 0b1100_0000) === 0b1000_0000) {
|
|
1006
|
+
start += 1;
|
|
1007
|
+
}
|
|
1008
|
+
return tail.subarray(start).toString("utf8");
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function bufferTerminalOutput(record, data) {
|
|
1012
|
+
record.outputSeq += 1;
|
|
1013
|
+
let bufferedData = typeof data === "string" ? data : String(data ?? "");
|
|
1014
|
+
let byteLength = getTerminalChunkByteLength(bufferedData);
|
|
1015
|
+
if (byteLength > TERMINAL_RING_BUFFER_MAX_BYTES) {
|
|
1016
|
+
bufferedData = trimTerminalChunkToTailBytes(bufferedData, TERMINAL_RING_BUFFER_MAX_BYTES);
|
|
1017
|
+
byteLength = getTerminalChunkByteLength(bufferedData);
|
|
1018
|
+
}
|
|
1019
|
+
record.ringBuffer.push({ seq: record.outputSeq, data: bufferedData, byteLength });
|
|
1020
|
+
record.ringBufferByteLength += byteLength;
|
|
1021
|
+
while (record.ringBufferByteLength > TERMINAL_RING_BUFFER_MAX_BYTES && record.ringBuffer.length > 0) {
|
|
1022
|
+
const removed = record.ringBuffer.shift();
|
|
1023
|
+
record.ringBufferByteLength -= removed?.byteLength ?? 0;
|
|
1024
|
+
}
|
|
1025
|
+
return record.outputSeq;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function attachPtyStreamHandlers(taskId, record) {
|
|
1029
|
+
const writeLogChunk = (chunk) => {
|
|
1030
|
+
if (record.logStream) {
|
|
1031
|
+
record.logStream.write(chunk);
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1035
|
+
record.pty.onData((data) => {
|
|
1036
|
+
writeLogChunk(data);
|
|
1037
|
+
const seq = bufferTerminalOutput(record, data);
|
|
1038
|
+
sendTerminalEvent("terminal_output", {
|
|
1039
|
+
task_id: taskId,
|
|
1040
|
+
project_id: record.projectId,
|
|
1041
|
+
pty_session_id: record.ptySessionId,
|
|
1042
|
+
seq,
|
|
1043
|
+
data,
|
|
1044
|
+
}).catch((err) => {
|
|
1045
|
+
logError(`Failed to report terminal_output for ${taskId}: ${err?.message || err}`);
|
|
1046
|
+
});
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
record.pty.onExit(({ exitCode, signal }) => {
|
|
1050
|
+
if (record.stopForceKillTimer) {
|
|
1051
|
+
clearTimeout(record.stopForceKillTimer);
|
|
1052
|
+
}
|
|
1053
|
+
activePtySessions.delete(taskId);
|
|
1054
|
+
if (record.logStream) {
|
|
1055
|
+
const ts = new Date().toLocaleString("sv-SE", { timeZone: "Asia/Shanghai" }).replace(" ", "T");
|
|
1056
|
+
record.logStream.write(
|
|
1057
|
+
`[daemon ${ts}] pty exited exitCode=${exitCode ?? "null"} signal=${signal ?? "null"}\n`,
|
|
1058
|
+
);
|
|
1059
|
+
record.logStream.end();
|
|
1060
|
+
}
|
|
1061
|
+
const closedAt = new Date().toISOString();
|
|
1062
|
+
log(`PTY task ${taskId} exited with code=${exitCode ?? "null"} signal=${signal ?? "null"}`);
|
|
1063
|
+
sendTerminalEvent("terminal_exit", {
|
|
1064
|
+
task_id: taskId,
|
|
1065
|
+
project_id: record.projectId,
|
|
1066
|
+
pty_session_id: record.ptySessionId,
|
|
1067
|
+
exit_code: exitCode ?? null,
|
|
1068
|
+
signal: signal ?? null,
|
|
1069
|
+
seq: record.outputSeq,
|
|
1070
|
+
closed_at: closedAt,
|
|
1071
|
+
}).catch((err) => {
|
|
1072
|
+
logError(`Failed to report terminal_exit for ${taskId}: ${err?.message || err}`);
|
|
1073
|
+
});
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function resizePty(record, cols, rows) {
|
|
1078
|
+
const nextCols = normalizePositiveInt(cols, record.cols || DEFAULT_TERMINAL_COLS);
|
|
1079
|
+
const nextRows = normalizePositiveInt(rows, record.rows || DEFAULT_TERMINAL_ROWS);
|
|
1080
|
+
record.cols = nextCols;
|
|
1081
|
+
record.rows = nextRows;
|
|
1082
|
+
if (typeof record.pty.resize === "function") {
|
|
1083
|
+
record.pty.resize(nextCols, nextRows);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
async function handleCreatePtyTask(payload) {
|
|
1088
|
+
const taskId = payload?.task_id ? String(payload.task_id) : "";
|
|
1089
|
+
const projectId = payload?.project_id ? String(payload.project_id) : "";
|
|
1090
|
+
const ptySessionId = payload?.pty_session_id ? String(payload.pty_session_id) : "";
|
|
1091
|
+
const requestId = payload?.request_id ? String(payload.request_id) : "";
|
|
1092
|
+
const launchConfig = normalizeLaunchConfig(payload?.launch_config);
|
|
1093
|
+
|
|
1094
|
+
if (!taskId || !projectId || !ptySessionId) {
|
|
1095
|
+
logError(`Invalid create_pty_task payload: ${JSON.stringify(payload)}`);
|
|
1096
|
+
sendAgentCommandAck({
|
|
1097
|
+
requestId,
|
|
1098
|
+
taskId,
|
|
1099
|
+
eventType: "create_pty_task",
|
|
1100
|
+
accepted: false,
|
|
1101
|
+
}).catch(() => {});
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
if (requestId && !markRequestSeen(requestId)) {
|
|
1106
|
+
log(`Duplicate create_pty_task ignored for ${taskId} (request_id=${requestId})`);
|
|
1107
|
+
sendAgentCommandAck({
|
|
1108
|
+
requestId,
|
|
1109
|
+
taskId,
|
|
1110
|
+
eventType: "create_pty_task",
|
|
1111
|
+
accepted: true,
|
|
1112
|
+
}).catch(() => {});
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
if (activeTaskProcesses.has(taskId) || activePtySessions.has(taskId)) {
|
|
1117
|
+
log(`Duplicate create_pty_task ignored for ${taskId}: task already active`);
|
|
1118
|
+
sendAgentCommandAck({
|
|
1119
|
+
requestId,
|
|
1120
|
+
taskId,
|
|
1121
|
+
eventType: "create_pty_task",
|
|
1122
|
+
accepted: true,
|
|
1123
|
+
}).catch(() => {});
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
let boundPath = await getProjectLocalPath(projectId);
|
|
1128
|
+
let taskDir = normalizeOptionalString(launchConfig.cwd) || boundPath;
|
|
1129
|
+
if (!taskDir) {
|
|
1130
|
+
const now = new Date();
|
|
1131
|
+
const dayDir = path.join(WORKSPACE_ROOT, formatWorkspaceDate(now));
|
|
1132
|
+
const runTimestampPart = formatWorkspaceRunTimestamp(now);
|
|
1133
|
+
const taskSuffix = taskId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 8) || String(process.pid);
|
|
1134
|
+
// PTY login shells can exit immediately if their cwd is renamed right after spawn.
|
|
1135
|
+
const pendingRunDir = `${runTimestampPart}_pty_${taskSuffix}`;
|
|
1136
|
+
taskDir = path.join(dayDir, pendingRunDir);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
try {
|
|
1140
|
+
mkdirSyncFn(taskDir, { recursive: true });
|
|
1141
|
+
} catch (err) {
|
|
1142
|
+
logError(`Failed to create PTY workspace ${taskDir}: ${err?.message || err}`);
|
|
1143
|
+
sendAgentCommandAck({
|
|
1144
|
+
requestId,
|
|
1145
|
+
taskId,
|
|
1146
|
+
eventType: "create_pty_task",
|
|
1147
|
+
accepted: false,
|
|
1148
|
+
}).catch(() => {});
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
let launchSpec;
|
|
1153
|
+
try {
|
|
1154
|
+
launchSpec = resolvePtyLaunchSpec(launchConfig, taskDir);
|
|
1155
|
+
} catch (error) {
|
|
1156
|
+
logError(`Failed to resolve PTY launch config for ${taskId}: ${error?.message || error}`);
|
|
1157
|
+
sendAgentCommandAck({
|
|
1158
|
+
requestId,
|
|
1159
|
+
taskId,
|
|
1160
|
+
eventType: "create_pty_task",
|
|
1161
|
+
accepted: false,
|
|
1162
|
+
}).catch(() => {});
|
|
1163
|
+
sendTerminalEvent("terminal_error", {
|
|
1164
|
+
task_id: taskId,
|
|
1165
|
+
project_id: projectId,
|
|
1166
|
+
pty_session_id: ptySessionId,
|
|
1167
|
+
message: error?.message || String(error),
|
|
1168
|
+
}).catch(() => {});
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
sendAgentCommandAck({
|
|
1173
|
+
requestId,
|
|
1174
|
+
taskId,
|
|
1175
|
+
eventType: "create_pty_task",
|
|
1176
|
+
accepted: true,
|
|
1177
|
+
}).catch((err) => {
|
|
1178
|
+
logError(`Failed to report agent_command_ack(create_pty_task) for ${taskId}: ${err?.message || err}`);
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
const env = {
|
|
1182
|
+
...process.env,
|
|
1183
|
+
...launchSpec.env,
|
|
1184
|
+
CONDUCTOR_PROJECT_ID: projectId,
|
|
1185
|
+
CONDUCTOR_TASK_ID: taskId,
|
|
1186
|
+
CONDUCTOR_PTY_SESSION_ID: ptySessionId,
|
|
1187
|
+
};
|
|
1188
|
+
if (config.CONFIG_FILE) {
|
|
1189
|
+
env.CONDUCTOR_CONFIG = config.CONFIG_FILE;
|
|
1190
|
+
}
|
|
1191
|
+
if (AGENT_TOKEN) {
|
|
1192
|
+
env.CONDUCTOR_AGENT_TOKEN = AGENT_TOKEN;
|
|
1193
|
+
}
|
|
1194
|
+
if (BACKEND_HTTP) {
|
|
1195
|
+
env.CONDUCTOR_BACKEND_URL = BACKEND_HTTP;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const logPath = path.join(launchSpec.cwd, "conductor-terminal.log");
|
|
1199
|
+
let logStream;
|
|
1200
|
+
try {
|
|
1201
|
+
logStream = createWriteStreamFn(logPath, { flags: "a" });
|
|
1202
|
+
if (logStream && typeof logStream.on === "function") {
|
|
1203
|
+
const logPathSnapshot = logPath;
|
|
1204
|
+
logStream.on("error", (err) => {
|
|
1205
|
+
logError(`Terminal log stream error (${logPathSnapshot}): ${err?.message || err}`);
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
} catch (err) {
|
|
1209
|
+
logError(`Failed to open PTY log file ${logPath}: ${err?.message || err}`);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
try {
|
|
1213
|
+
const pty = await createPtyFn(launchSpec.command, launchSpec.args, {
|
|
1214
|
+
name: "xterm-256color",
|
|
1215
|
+
cols: launchSpec.cols,
|
|
1216
|
+
rows: launchSpec.rows,
|
|
1217
|
+
cwd: launchSpec.cwd,
|
|
1218
|
+
env,
|
|
1219
|
+
});
|
|
1220
|
+
const resolvedLogPath = path.join(taskDir, "conductor-terminal.log");
|
|
1221
|
+
|
|
1222
|
+
const startedAt = new Date().toISOString();
|
|
1223
|
+
const record = {
|
|
1224
|
+
kind: "pty",
|
|
1225
|
+
pty,
|
|
1226
|
+
ptySessionId,
|
|
1227
|
+
projectId,
|
|
1228
|
+
taskDir,
|
|
1229
|
+
logPath: resolvedLogPath,
|
|
1230
|
+
logStream,
|
|
1231
|
+
cols: launchSpec.cols,
|
|
1232
|
+
rows: launchSpec.rows,
|
|
1233
|
+
shell: launchSpec.shell,
|
|
1234
|
+
startedAt,
|
|
1235
|
+
outputSeq: 0,
|
|
1236
|
+
ringBuffer: [],
|
|
1237
|
+
ringBufferByteLength: 0,
|
|
1238
|
+
stopForceKillTimer: null,
|
|
1239
|
+
};
|
|
1240
|
+
activePtySessions.set(taskId, record);
|
|
1241
|
+
attachPtyStreamHandlers(taskId, record);
|
|
1242
|
+
|
|
1243
|
+
log(`Created PTY task ${taskId} (${launchSpec.entrypointType}) cwd=${launchSpec.cwd}`);
|
|
1244
|
+
sendTerminalEvent("terminal_opened", {
|
|
1245
|
+
task_id: taskId,
|
|
1246
|
+
project_id: projectId,
|
|
1247
|
+
pty_session_id: ptySessionId,
|
|
1248
|
+
pid: Number.isInteger(pty?.pid) ? pty.pid : null,
|
|
1249
|
+
cwd: taskDir,
|
|
1250
|
+
shell: launchSpec.shell,
|
|
1251
|
+
cols: launchSpec.cols,
|
|
1252
|
+
rows: launchSpec.rows,
|
|
1253
|
+
started_at: startedAt,
|
|
1254
|
+
}).catch((err) => {
|
|
1255
|
+
logError(`Failed to report terminal_opened for ${taskId}: ${err?.message || err}`);
|
|
1256
|
+
});
|
|
1257
|
+
} catch (error) {
|
|
1258
|
+
if (logStream) {
|
|
1259
|
+
logStream.end();
|
|
1260
|
+
}
|
|
1261
|
+
logError(`Failed to create PTY task ${taskId}: ${error?.message || error}`);
|
|
1262
|
+
sendTerminalEvent("terminal_error", {
|
|
1263
|
+
task_id: taskId,
|
|
1264
|
+
project_id: projectId,
|
|
1265
|
+
pty_session_id: ptySessionId,
|
|
1266
|
+
message: error?.message || String(error),
|
|
1267
|
+
}).catch(() => {});
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
async function handleTerminalAttach(payload) {
|
|
1272
|
+
const taskId = payload?.task_id ? String(payload.task_id) : "";
|
|
1273
|
+
if (!taskId) return;
|
|
1274
|
+
const record = activePtySessions.get(taskId);
|
|
1275
|
+
if (!record) {
|
|
1276
|
+
sendTerminalEvent("terminal_error", {
|
|
1277
|
+
task_id: taskId,
|
|
1278
|
+
pty_session_id: payload?.pty_session_id ? String(payload.pty_session_id) : null,
|
|
1279
|
+
message: "terminal session not found",
|
|
1280
|
+
}).catch(() => {});
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
if (payload?.cols || payload?.rows) {
|
|
1285
|
+
resizePty(record, payload?.cols, payload?.rows);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
await sendTerminalEvent("terminal_opened", {
|
|
1289
|
+
task_id: taskId,
|
|
1290
|
+
project_id: record.projectId,
|
|
1291
|
+
pty_session_id: record.ptySessionId,
|
|
1292
|
+
pid: Number.isInteger(record.pty?.pid) ? record.pty.pid : null,
|
|
1293
|
+
cwd: record.taskDir,
|
|
1294
|
+
shell: record.shell,
|
|
1295
|
+
cols: record.cols,
|
|
1296
|
+
rows: record.rows,
|
|
1297
|
+
started_at: record.startedAt,
|
|
1298
|
+
}).catch((err) => {
|
|
1299
|
+
logError(`Failed to report terminal_opened on attach for ${taskId}: ${err?.message || err}`);
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
const lastSeq = normalizePositiveInt(payload?.last_seq ?? payload?.lastSeq, 0);
|
|
1303
|
+
for (const chunk of record.ringBuffer) {
|
|
1304
|
+
if (chunk.seq <= lastSeq) continue;
|
|
1305
|
+
await sendTerminalEvent("terminal_output", {
|
|
1306
|
+
task_id: taskId,
|
|
1307
|
+
project_id: record.projectId,
|
|
1308
|
+
pty_session_id: record.ptySessionId,
|
|
1309
|
+
seq: chunk.seq,
|
|
1310
|
+
data: chunk.data,
|
|
1311
|
+
}).catch((err) => {
|
|
1312
|
+
logError(`Failed to replay terminal_output for ${taskId}: ${err?.message || err}`);
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function handleTerminalInput(payload) {
|
|
1318
|
+
const taskId = payload?.task_id ? String(payload.task_id) : "";
|
|
1319
|
+
const data = typeof payload?.data === "string" ? payload.data : "";
|
|
1320
|
+
if (!taskId || !data) return;
|
|
1321
|
+
const record = activePtySessions.get(taskId);
|
|
1322
|
+
if (!record || typeof record.pty.write !== "function") {
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
record.pty.write(data);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function handleTerminalResize(payload) {
|
|
1329
|
+
const taskId = payload?.task_id ? String(payload.task_id) : "";
|
|
1330
|
+
if (!taskId) return;
|
|
1331
|
+
const record = activePtySessions.get(taskId);
|
|
1332
|
+
if (!record) return;
|
|
1333
|
+
resizePty(record, payload?.cols, payload?.rows);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function handleTerminalDetach(_payload) {
|
|
1337
|
+
// PTY sessions stay alive without viewers. Detach is currently a no-op.
|
|
1338
|
+
}
|
|
1339
|
+
|
|
784
1340
|
function handleEvent(event) {
|
|
785
1341
|
const receivedAt = Date.now();
|
|
786
1342
|
lastInboundAt = receivedAt;
|
|
@@ -805,10 +1361,30 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
805
1361
|
handleCreateTask(event.payload);
|
|
806
1362
|
return;
|
|
807
1363
|
}
|
|
1364
|
+
if (event.type === "create_pty_task") {
|
|
1365
|
+
void handleCreatePtyTask(event.payload);
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
808
1368
|
if (event.type === "stop_task") {
|
|
809
1369
|
handleStopTask(event.payload);
|
|
810
1370
|
return;
|
|
811
1371
|
}
|
|
1372
|
+
if (event.type === "terminal_attach") {
|
|
1373
|
+
void handleTerminalAttach(event.payload);
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
if (event.type === "terminal_input") {
|
|
1377
|
+
handleTerminalInput(event.payload);
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
if (event.type === "terminal_resize") {
|
|
1381
|
+
handleTerminalResize(event.payload);
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
if (event.type === "terminal_detach") {
|
|
1385
|
+
handleTerminalDetach(event.payload);
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
812
1388
|
if (event.type === "collect_logs") {
|
|
813
1389
|
void handleCollectLogs(event.payload);
|
|
814
1390
|
}
|
|
@@ -922,8 +1498,9 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
922
1498
|
});
|
|
923
1499
|
};
|
|
924
1500
|
|
|
925
|
-
const
|
|
926
|
-
|
|
1501
|
+
const processRecord = activeTaskProcesses.get(taskId);
|
|
1502
|
+
const ptyRecord = activePtySessions.get(taskId);
|
|
1503
|
+
if ((!processRecord || !processRecord.child) && !ptyRecord) {
|
|
927
1504
|
log(`Stop requested for task ${taskId}, but no active process found`);
|
|
928
1505
|
sendStopAck(false);
|
|
929
1506
|
return;
|
|
@@ -934,36 +1511,58 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
934
1511
|
|
|
935
1512
|
sendStopAck(true);
|
|
936
1513
|
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1514
|
+
const activeRecord = processRecord || ptyRecord;
|
|
1515
|
+
if (activeRecord?.stopForceKillTimer) {
|
|
1516
|
+
clearTimeout(activeRecord.stopForceKillTimer);
|
|
1517
|
+
activeRecord.stopForceKillTimer = null;
|
|
940
1518
|
}
|
|
941
1519
|
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1520
|
+
if (processRecord?.child) {
|
|
1521
|
+
try {
|
|
1522
|
+
if (typeof processRecord.child.kill === "function") {
|
|
1523
|
+
processRecord.child.kill("SIGTERM");
|
|
1524
|
+
}
|
|
1525
|
+
} catch (error) {
|
|
1526
|
+
logError(`Failed to stop task ${taskId}: ${error?.message || error}`);
|
|
1527
|
+
}
|
|
1528
|
+
} else if (ptyRecord?.pty) {
|
|
1529
|
+
try {
|
|
1530
|
+
if (typeof ptyRecord.pty.kill === "function") {
|
|
1531
|
+
ptyRecord.pty.kill("SIGTERM");
|
|
1532
|
+
}
|
|
1533
|
+
} catch (error) {
|
|
1534
|
+
logError(`Failed to stop PTY task ${taskId}: ${error?.message || error}`);
|
|
945
1535
|
}
|
|
946
|
-
} catch (error) {
|
|
947
|
-
logError(`Failed to stop task ${taskId}: ${error?.message || error}`);
|
|
948
1536
|
}
|
|
949
1537
|
|
|
950
|
-
|
|
951
|
-
const
|
|
952
|
-
|
|
1538
|
+
activeRecord.stopForceKillTimer = setTimeout(() => {
|
|
1539
|
+
const latestProcess = activeTaskProcesses.get(taskId);
|
|
1540
|
+
const latestPty = activePtySessions.get(taskId);
|
|
1541
|
+
if (latestProcess?.child && processRecord?.child && latestProcess.child === processRecord.child) {
|
|
1542
|
+
try {
|
|
1543
|
+
if (typeof latestProcess.child.kill === "function") {
|
|
1544
|
+
log(`Task ${taskId} did not exit after SIGTERM, sending SIGKILL`);
|
|
1545
|
+
latestProcess.child.kill("SIGKILL");
|
|
1546
|
+
}
|
|
1547
|
+
} catch (error) {
|
|
1548
|
+
logError(`Failed to SIGKILL task ${taskId}: ${error?.message || error}`);
|
|
1549
|
+
}
|
|
953
1550
|
return;
|
|
954
1551
|
}
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1552
|
+
if (latestPty?.pty && ptyRecord?.pty && latestPty.pty === ptyRecord.pty) {
|
|
1553
|
+
try {
|
|
1554
|
+
if (typeof latestPty.pty.kill === "function") {
|
|
1555
|
+
log(`PTY task ${taskId} did not exit after SIGTERM, sending SIGKILL`);
|
|
1556
|
+
latestPty.pty.kill("SIGKILL");
|
|
1557
|
+
}
|
|
1558
|
+
} catch (error) {
|
|
1559
|
+
logError(`Failed to SIGKILL PTY task ${taskId}: ${error?.message || error}`);
|
|
959
1560
|
}
|
|
960
|
-
} catch (error) {
|
|
961
|
-
logError(`Failed to SIGKILL task ${taskId}: ${error?.message || error}`);
|
|
962
1561
|
}
|
|
963
1562
|
}, STOP_FORCE_KILL_TIMEOUT_MS);
|
|
964
1563
|
|
|
965
|
-
if (typeof
|
|
966
|
-
|
|
1564
|
+
if (typeof activeRecord.stopForceKillTimer?.unref === "function") {
|
|
1565
|
+
activeRecord.stopForceKillTimer.unref();
|
|
967
1566
|
}
|
|
968
1567
|
}
|
|
969
1568
|
|
|
@@ -1067,7 +1666,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1067
1666
|
}
|
|
1068
1667
|
|
|
1069
1668
|
// Validate and get CLI command for the backend
|
|
1070
|
-
const effectiveBackend = backendType || SUPPORTED_BACKENDS[0];
|
|
1669
|
+
const effectiveBackend = normalizeRuntimeBackendName(backendType || SUPPORTED_BACKENDS[0]);
|
|
1071
1670
|
if (!SUPPORTED_BACKENDS.includes(effectiveBackend)) {
|
|
1072
1671
|
logError(`Unsupported backend: ${effectiveBackend}. Supported: ${SUPPORTED_BACKENDS.join(", ")}`);
|
|
1073
1672
|
sendAgentCommandAck({
|
|
@@ -1315,7 +1914,9 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1315
1914
|
clearInterval(watchdogTimer);
|
|
1316
1915
|
watchdogTimer = null;
|
|
1317
1916
|
}
|
|
1318
|
-
const
|
|
1917
|
+
const activeProcessEntries = [...activeTaskProcesses.entries()];
|
|
1918
|
+
const activePtyEntries = [...activePtySessions.entries()];
|
|
1919
|
+
const activeEntries = [...activeProcessEntries, ...activePtyEntries];
|
|
1319
1920
|
if (activeEntries.length > 0) {
|
|
1320
1921
|
log(`Shutdown requested (${reason}); stopping ${activeEntries.length} active task(s)`);
|
|
1321
1922
|
}
|
|
@@ -1343,7 +1944,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1343
1944
|
}),
|
|
1344
1945
|
);
|
|
1345
1946
|
|
|
1346
|
-
for (const [taskId, record] of
|
|
1947
|
+
for (const [taskId, record] of activeProcessEntries) {
|
|
1347
1948
|
if (record?.stopForceKillTimer) {
|
|
1348
1949
|
clearTimeout(record.stopForceKillTimer);
|
|
1349
1950
|
}
|
|
@@ -1356,7 +1957,21 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1356
1957
|
}
|
|
1357
1958
|
}
|
|
1358
1959
|
|
|
1960
|
+
for (const [taskId, record] of activePtyEntries) {
|
|
1961
|
+
if (record?.stopForceKillTimer) {
|
|
1962
|
+
clearTimeout(record.stopForceKillTimer);
|
|
1963
|
+
}
|
|
1964
|
+
try {
|
|
1965
|
+
if (typeof record.pty?.kill === "function") {
|
|
1966
|
+
record.pty.kill("SIGTERM");
|
|
1967
|
+
}
|
|
1968
|
+
} catch (error) {
|
|
1969
|
+
logError(`Failed to stop PTY task ${taskId} on daemon close: ${error?.message || error}`);
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1359
1973
|
activeTaskProcesses.clear();
|
|
1974
|
+
activePtySessions.clear();
|
|
1360
1975
|
|
|
1361
1976
|
try {
|
|
1362
1977
|
await withTimeout(
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const RUNTIME_SUPPORTED_BACKENDS = ["codex", "claude", "opencode"];
|
|
2
|
+
|
|
3
|
+
export function normalizeRuntimeBackendName(backend) {
|
|
4
|
+
return String(backend || "").trim().toLowerCase();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function isRuntimeSupportedBackend(backend) {
|
|
8
|
+
return RUNTIME_SUPPORTED_BACKENDS.includes(normalizeRuntimeBackendName(backend));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function filterRuntimeSupportedAllowCliList(allowCliList) {
|
|
12
|
+
if (!allowCliList || typeof allowCliList !== "object") {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const filtered = {};
|
|
17
|
+
for (const [backend, command] of Object.entries(allowCliList)) {
|
|
18
|
+
const normalizedBackend = normalizeRuntimeBackendName(backend);
|
|
19
|
+
if (!RUNTIME_SUPPORTED_BACKENDS.includes(normalizedBackend)) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (typeof command !== "string" || !command.trim()) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (filtered[normalizedBackend] !== undefined) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
filtered[normalizedBackend] = command;
|
|
29
|
+
}
|
|
30
|
+
return filtered;
|
|
31
|
+
}
|