@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.
@@ -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
- // opencode: {
39
- // command: "opencode",
40
- // execArgs: "",
41
- // description: "OpenCode CLI"
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
- const fullCommand = info.execArgs
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).slice(0, 3).forEach(([key, info]) => {
182
- const fullCommand = info.execArgs
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)) {
@@ -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 copilot: copilot --allow-all-paths --allow-all-tools\n kimi: kimi\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
- copilot: copilot --allow-all-paths --allow-all-tools
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 copilot -- "fix the bug" # Use GitHub Copilot CLI 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 || supportedBackends[0];
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.18",
4
- "gitCommitId": "942418b",
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.18",
21
- "@love-moon/conductor-sdk": "0.2.18",
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(activeTaskProcesses.keys());
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: [...activeTaskProcesses.keys()],
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 record = activeTaskProcesses.get(taskId);
926
- if (!record || !record.child) {
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
- if (record.stopForceKillTimer) {
938
- clearTimeout(record.stopForceKillTimer);
939
- record.stopForceKillTimer = null;
1514
+ const activeRecord = processRecord || ptyRecord;
1515
+ if (activeRecord?.stopForceKillTimer) {
1516
+ clearTimeout(activeRecord.stopForceKillTimer);
1517
+ activeRecord.stopForceKillTimer = null;
940
1518
  }
941
1519
 
942
- try {
943
- if (typeof record.child.kill === "function") {
944
- record.child.kill("SIGTERM");
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
- record.stopForceKillTimer = setTimeout(() => {
951
- const latest = activeTaskProcesses.get(taskId);
952
- if (!latest || latest.child !== record.child) {
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
- try {
956
- if (typeof latest.child.kill === "function") {
957
- log(`Task ${taskId} did not exit after SIGTERM, sending SIGKILL`);
958
- latest.child.kill("SIGKILL");
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 record.stopForceKillTimer?.unref === "function") {
966
- record.stopForceKillTimer.unref();
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 activeEntries = [...activeTaskProcesses.entries()];
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 activeEntries) {
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
+ }