@love-moon/conductor-cli 0.2.18 → 0.2.20

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/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,11 @@ 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
+ const DEFAULT_RTC_MODULE_CANDIDATES = ["@roamhq/wrtc", "wrtc"];
32
+ let nodePtySpawnPromise = null;
25
33
 
26
34
  function appendDaemonLog(line) {
27
35
  try {
@@ -112,16 +120,137 @@ function getPlanLimitMessage(payload) {
112
120
  const DEFAULT_CLI_LIST = {
113
121
  codex: "codex --dangerously-bypass-approvals-and-sandbox",
114
122
  claude: "claude --dangerously-skip-permissions",
123
+ opencode: "opencode",
115
124
  };
116
125
 
117
126
  function getAllowCliList(userConfig) {
118
127
  // If user has configured allow_cli_list, use it; otherwise use defaults
119
128
  if (userConfig.allow_cli_list && typeof userConfig.allow_cli_list === "object") {
120
- return userConfig.allow_cli_list;
129
+ return filterRuntimeSupportedAllowCliList(userConfig.allow_cli_list);
121
130
  }
122
131
  return DEFAULT_CLI_LIST;
123
132
  }
124
133
 
134
+ async function defaultCreatePty(command, args, options) {
135
+ if (!nodePtySpawnPromise) {
136
+ const spawnHelperInfo = ensureNodePtySpawnHelperExecutable();
137
+ if (spawnHelperInfo?.updated) {
138
+ log(`Enabled execute permission on node-pty spawn-helper: ${spawnHelperInfo.helperPath}`);
139
+ }
140
+ nodePtySpawnPromise = import("node-pty").then((mod) => {
141
+ if (typeof mod.spawn === "function") {
142
+ return mod.spawn;
143
+ }
144
+ if (mod.default && typeof mod.default.spawn === "function") {
145
+ return mod.default.spawn.bind(mod.default);
146
+ }
147
+ throw new Error("node-pty spawn export not found");
148
+ });
149
+ }
150
+ const spawnPty = await nodePtySpawnPromise;
151
+ return spawnPty(command, args, options);
152
+ }
153
+
154
+ export function ensureNodePtySpawnHelperExecutable(deps = {}) {
155
+ const platform = deps.platform || process.platform;
156
+ if (platform === "win32") {
157
+ return null;
158
+ }
159
+
160
+ const arch = deps.arch || process.arch;
161
+ const existsSyncFn = deps.existsSync || fs.existsSync;
162
+ const statSyncFn = deps.statSync || fs.statSync;
163
+ const chmodSyncFn = deps.chmodSync || fs.chmodSync;
164
+ let packageJsonPath = deps.packageJsonPath || null;
165
+
166
+ if (!packageJsonPath) {
167
+ try {
168
+ packageJsonPath = moduleRequire.resolve("node-pty/package.json");
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ const packageDir = path.dirname(packageJsonPath);
175
+ const helperCandidates = [
176
+ path.join(packageDir, "build", "Release", "spawn-helper"),
177
+ path.join(packageDir, "build", "Debug", "spawn-helper"),
178
+ path.join(packageDir, "prebuilds", `${platform}-${arch}`, "spawn-helper"),
179
+ ];
180
+ const helperPath = helperCandidates.find((candidate) => existsSyncFn(candidate));
181
+ if (!helperPath) {
182
+ return null;
183
+ }
184
+
185
+ const currentMode = statSyncFn(helperPath).mode & 0o777;
186
+ if ((currentMode & 0o111) !== 0) {
187
+ return { helperPath, updated: false };
188
+ }
189
+
190
+ const nextMode = currentMode | 0o111;
191
+ chmodSyncFn(helperPath, nextMode);
192
+ return { helperPath, updated: true };
193
+ }
194
+
195
+ function normalizeOptionalString(value) {
196
+ if (typeof value !== "string") {
197
+ return null;
198
+ }
199
+ const normalized = value.trim();
200
+ return normalized || null;
201
+ }
202
+
203
+ function normalizePositiveInt(value, fallback) {
204
+ const parsed = Number.parseInt(String(value ?? ""), 10);
205
+ if (Number.isFinite(parsed) && parsed > 0) {
206
+ return parsed;
207
+ }
208
+ return fallback;
209
+ }
210
+
211
+ function normalizeNonNegativeInt(value, fallback = null) {
212
+ const parsed = Number.parseInt(String(value ?? ""), 10);
213
+ if (Number.isFinite(parsed) && parsed >= 0) {
214
+ return parsed;
215
+ }
216
+ return fallback;
217
+ }
218
+
219
+ function normalizeIsoTimestamp(value) {
220
+ if (typeof value !== "string") {
221
+ return null;
222
+ }
223
+ const normalized = value.trim();
224
+ if (!normalized) {
225
+ return null;
226
+ }
227
+ return Number.isNaN(Date.parse(normalized)) ? null : normalized;
228
+ }
229
+
230
+ function normalizeLaunchConfig(value) {
231
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
232
+ return {};
233
+ }
234
+ return value;
235
+ }
236
+
237
+ function normalizeTerminalEnv(value) {
238
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
239
+ return {};
240
+ }
241
+ const env = {};
242
+ for (const [key, raw] of Object.entries(value)) {
243
+ if (typeof raw === "string") {
244
+ env[key] = raw;
245
+ continue;
246
+ }
247
+ if (typeof raw === "number" || typeof raw === "boolean") {
248
+ env[key] = String(raw);
249
+ }
250
+ }
251
+ return env;
252
+ }
253
+
125
254
  export function startDaemon(config = {}, deps = {}) {
126
255
  const exitFn = deps.exit || process.exit;
127
256
  const killFn = deps.kill || process.kill;
@@ -210,10 +339,14 @@ export function startDaemon(config = {}, deps = {}) {
210
339
  const renameSyncFn = deps.renameSync || fs.renameSync;
211
340
  const createWriteStreamFn = deps.createWriteStream || fs.createWriteStream;
212
341
  const fetchFn = deps.fetch || fetch;
342
+ const createRtcPeerConnection = deps.createRtcPeerConnection || null;
343
+ const importOptionalModule = deps.importOptionalModule || ((moduleName) => import(moduleName));
213
344
  const createWebSocketClient =
214
345
  deps.createWebSocketClient ||
215
346
  ((clientConfig, options) => new ConductorWebSocketClient(clientConfig, options));
216
347
  const createLogCollector = deps.createLogCollector || ((backendUrl) => new DaemonLogCollector(backendUrl));
348
+ const RTC_MODULE_CANDIDATES = resolveRtcModuleCandidates(process.env.CONDUCTOR_PTY_RTC_MODULES);
349
+ const RTC_DIRECT_DISABLED = parseBooleanEnv(process.env.CONDUCTOR_DISABLE_PTY_DIRECT_RTC);
217
350
  const PROJECT_PATH_LOOKUP_TIMEOUT_MS = parsePositiveInt(
218
351
  process.env.CONDUCTOR_PROJECT_PATH_LOOKUP_TIMEOUT_MS,
219
352
  1500,
@@ -254,6 +387,10 @@ export function startDaemon(config = {}, deps = {}) {
254
387
  process.env.CONDUCTOR_DAEMON_WATCHDOG_MAX_SELF_HEALS,
255
388
  3,
256
389
  );
390
+ const TERMINAL_RING_BUFFER_MAX_BYTES = parsePositiveInt(
391
+ config.TERMINAL_RING_BUFFER_MAX_BYTES || process.env.CONDUCTOR_TERMINAL_RING_BUFFER_MAX_BYTES,
392
+ DEFAULT_TERMINAL_RING_BUFFER_MAX_BYTES,
393
+ );
257
394
 
258
395
  try {
259
396
  mkdirSyncFn(WORKSPACE_ROOT, { recursive: true });
@@ -389,6 +526,8 @@ export function startDaemon(config = {}, deps = {}) {
389
526
  let didRecoverStaleTasks = false;
390
527
  let daemonShuttingDown = false;
391
528
  const activeTaskProcesses = new Map();
529
+ const activePtySessions = new Map();
530
+ const activePtyRtcTransports = new Map();
392
531
  const suppressedExitStatusReports = new Set();
393
532
  const seenCommandRequestIds = new Set();
394
533
  let lastConnectedAt = null;
@@ -405,11 +544,15 @@ export function startDaemon(config = {}, deps = {}) {
405
544
  let watchdogLastPresenceMismatchAt = 0;
406
545
  let watchdogAwaitingHealthySignalAt = null;
407
546
  let watchdogTimer = null;
547
+ let rtcImplementationPromise = null;
548
+ let rtcAvailabilityLogKey = null;
408
549
  const logCollector = createLogCollector(BACKEND_HTTP);
550
+ const createPtyFn = deps.createPty || defaultCreatePty;
409
551
  const client = createWebSocketClient(sdkConfig, {
410
552
  extraHeaders: {
411
553
  "x-conductor-host": AGENT_NAME,
412
554
  "x-conductor-backends": SUPPORTED_BACKENDS.join(","),
555
+ "x-conductor-capabilities": "pty_task",
413
556
  },
414
557
  onConnected: ({ isReconnect, connectedAt } = { isReconnect: false, connectedAt: Date.now() }) => {
415
558
  wsConnected = true;
@@ -626,6 +769,10 @@ export function startDaemon(config = {}, deps = {}) {
626
769
  }
627
770
  }
628
771
 
772
+ const getActiveTaskIds = () => [
773
+ ...new Set([...activeTaskProcesses.keys(), ...activePtySessions.keys()]),
774
+ ];
775
+
629
776
  async function recoverStaleTasks() {
630
777
  try {
631
778
  const response = await fetchFn(`${BACKEND_HTTP}/api/tasks`, {
@@ -701,7 +848,7 @@ export function startDaemon(config = {}, deps = {}) {
701
848
  if (!Array.isArray(tasks)) {
702
849
  return;
703
850
  }
704
- const localTaskIds = new Set(activeTaskProcesses.keys());
851
+ const localTaskIds = new Set(getActiveTaskIds());
705
852
  const assigned = tasks.filter((task) => {
706
853
  const agentHost = String(task?.agent_host || "").trim();
707
854
  const status = String(task?.status || "").trim().toLowerCase();
@@ -746,7 +893,7 @@ export function startDaemon(config = {}, deps = {}) {
746
893
  await client.sendJson({
747
894
  type: "agent_resume",
748
895
  payload: {
749
- active_tasks: [...activeTaskProcesses.keys()],
896
+ active_tasks: getActiveTaskIds(),
750
897
  source: "conductor-daemon",
751
898
  metadata: { is_reconnect: Boolean(isReconnect) },
752
899
  },
@@ -781,6 +928,858 @@ export function startDaemon(config = {}, deps = {}) {
781
928
  });
782
929
  }
783
930
 
931
+ function sendTerminalEvent(type, payload) {
932
+ return client.sendJson({
933
+ type,
934
+ payload,
935
+ });
936
+ }
937
+
938
+ function sendPtyTransportStatus(payload) {
939
+ return client.sendJson({
940
+ type: "pty_transport_status",
941
+ payload,
942
+ });
943
+ }
944
+
945
+ function sendPtyTransportSignal(payload) {
946
+ return client.sendJson({
947
+ type: "pty_transport_signal",
948
+ payload,
949
+ });
950
+ }
951
+
952
+ function logRtcAvailabilityOnce(key, message) {
953
+ if (rtcAvailabilityLogKey === key) {
954
+ return;
955
+ }
956
+ rtcAvailabilityLogKey = key;
957
+ log(message);
958
+ }
959
+
960
+ async function resolveRtcImplementation() {
961
+ if (RTC_DIRECT_DISABLED) {
962
+ logRtcAvailabilityOnce(
963
+ "disabled",
964
+ "PTY direct RTC runtime disabled by CONDUCTOR_DISABLE_PTY_DIRECT_RTC=1; relay fallback only",
965
+ );
966
+ return null;
967
+ }
968
+
969
+ if (createRtcPeerConnection) {
970
+ logRtcAvailabilityOnce("ready:deps", "PTY direct RTC runtime ready via injected peer connection");
971
+ return {
972
+ source: "deps.createRtcPeerConnection",
973
+ createPeerConnection: (...args) => createRtcPeerConnection(...args),
974
+ };
975
+ }
976
+
977
+ if (typeof globalThis.RTCPeerConnection === "function") {
978
+ logRtcAvailabilityOnce("ready:global", "PTY direct RTC runtime ready via globalThis.RTCPeerConnection");
979
+ return {
980
+ source: "globalThis.RTCPeerConnection",
981
+ createPeerConnection: (...args) => new globalThis.RTCPeerConnection(...args),
982
+ };
983
+ }
984
+
985
+ if (!rtcImplementationPromise) {
986
+ rtcImplementationPromise = (async () => {
987
+ for (const moduleName of RTC_MODULE_CANDIDATES) {
988
+ try {
989
+ const mod = await importOptionalModule(moduleName);
990
+ const PeerConnectionCtor =
991
+ mod?.RTCPeerConnection ||
992
+ mod?.default?.RTCPeerConnection ||
993
+ mod?.default;
994
+ if (typeof PeerConnectionCtor === "function") {
995
+ return {
996
+ source: moduleName,
997
+ createPeerConnection: (...args) => new PeerConnectionCtor(...args),
998
+ };
999
+ }
1000
+ } catch {
1001
+ // Try next implementation.
1002
+ }
1003
+ }
1004
+ return null;
1005
+ })();
1006
+ }
1007
+
1008
+ const rtc = await rtcImplementationPromise;
1009
+ if (rtc) {
1010
+ logRtcAvailabilityOnce(`ready:${rtc.source}`, `PTY direct RTC runtime ready via ${rtc.source}`);
1011
+ return rtc;
1012
+ }
1013
+
1014
+ logRtcAvailabilityOnce(
1015
+ "unavailable",
1016
+ `PTY direct RTC runtime unavailable; install optional dependency ${DEFAULT_RTC_MODULE_CANDIDATES[0]} or keep relay fallback`,
1017
+ );
1018
+ return null;
1019
+ }
1020
+
1021
+ function cleanupPtyRtcTransport(taskId, expectedSessionId = null) {
1022
+ const current = activePtyRtcTransports.get(taskId);
1023
+ if (!current) {
1024
+ return;
1025
+ }
1026
+ if (expectedSessionId && current.sessionId !== expectedSessionId) {
1027
+ return;
1028
+ }
1029
+ try {
1030
+ current.channel?.close?.();
1031
+ } catch {}
1032
+ try {
1033
+ current.peer?.close?.();
1034
+ } catch {}
1035
+ activePtyRtcTransports.delete(taskId);
1036
+ }
1037
+
1038
+ async function startPtyRtcNegotiation(taskId, sessionId, connectionId, offerDescription) {
1039
+ const record = activePtySessions.get(taskId);
1040
+ if (!record) {
1041
+ return { ok: false, reason: "terminal_session_not_found" };
1042
+ }
1043
+
1044
+ const rtc = await resolveRtcImplementation();
1045
+ if (!rtc) {
1046
+ return { ok: false, reason: "direct_transport_not_supported" };
1047
+ }
1048
+
1049
+ cleanupPtyRtcTransport(taskId);
1050
+
1051
+ try {
1052
+ const peer = rtc.createPeerConnection();
1053
+ const transport = {
1054
+ taskId,
1055
+ sessionId,
1056
+ connectionId,
1057
+ peer,
1058
+ channel: null,
1059
+ };
1060
+ activePtyRtcTransports.set(taskId, transport);
1061
+
1062
+ peer.ondatachannel = (event) => {
1063
+ transport.channel = event?.channel || null;
1064
+ if (transport.channel) {
1065
+ transport.channel.onmessage = (messageEvent) => {
1066
+ try {
1067
+ const raw =
1068
+ typeof messageEvent?.data === "string"
1069
+ ? messageEvent.data
1070
+ : Buffer.isBuffer(messageEvent?.data)
1071
+ ? messageEvent.data.toString("utf8")
1072
+ : String(messageEvent?.data ?? "");
1073
+ const parsed = JSON.parse(raw);
1074
+ handleDirectTransportPayload(taskId, sessionId, connectionId, parsed);
1075
+ } catch (error) {
1076
+ logError(`Failed to handle PTY direct channel message for ${taskId}: ${error?.message || error}`);
1077
+ }
1078
+ };
1079
+ transport.channel.onopen = () => {
1080
+ sendPtyTransportStatus({
1081
+ task_id: taskId,
1082
+ session_id: sessionId,
1083
+ connection_id: connectionId,
1084
+ transport_state: "direct",
1085
+ transport_policy: "direct_preferred",
1086
+ writer_connection_id: connectionId,
1087
+ direct_candidate: true,
1088
+ }).catch((err) => {
1089
+ logError(`Failed to report direct PTY transport status for ${taskId}: ${err?.message || err}`);
1090
+ });
1091
+ };
1092
+ transport.channel.onclose = () => {
1093
+ sendPtyTransportStatus({
1094
+ task_id: taskId,
1095
+ session_id: sessionId,
1096
+ connection_id: connectionId,
1097
+ transport_state: "fallback_relay",
1098
+ transport_policy: "direct_preferred",
1099
+ writer_connection_id: connectionId,
1100
+ direct_candidate: false,
1101
+ reason: "direct_channel_closed",
1102
+ }).catch((err) => {
1103
+ logError(`Failed to report PTY transport fallback for ${taskId}: ${err?.message || err}`);
1104
+ });
1105
+ cleanupPtyRtcTransport(taskId, sessionId);
1106
+ };
1107
+ }
1108
+ };
1109
+
1110
+ peer.onicecandidate = (event) => {
1111
+ if (!event?.candidate) {
1112
+ return;
1113
+ }
1114
+ sendPtyTransportSignal({
1115
+ task_id: taskId,
1116
+ session_id: sessionId,
1117
+ connection_id: connectionId,
1118
+ signal_type: "ice_candidate",
1119
+ candidate: typeof event.candidate.toJSON === "function" ? event.candidate.toJSON() : event.candidate,
1120
+ }).catch((err) => {
1121
+ logError(`Failed to report PTY ICE candidate for ${taskId}: ${err?.message || err}`);
1122
+ });
1123
+ };
1124
+
1125
+ await peer.setRemoteDescription({
1126
+ type: "offer",
1127
+ sdp: offerDescription.sdp,
1128
+ });
1129
+ const answer = await peer.createAnswer();
1130
+ await peer.setLocalDescription(answer);
1131
+
1132
+ await sendPtyTransportSignal({
1133
+ task_id: taskId,
1134
+ session_id: sessionId,
1135
+ connection_id: connectionId,
1136
+ signal_type: "answer",
1137
+ description: {
1138
+ type: answer.type,
1139
+ sdp: answer.sdp,
1140
+ },
1141
+ });
1142
+ await sendPtyTransportStatus({
1143
+ task_id: taskId,
1144
+ session_id: sessionId,
1145
+ connection_id: connectionId,
1146
+ transport_state: "negotiating",
1147
+ transport_policy: "direct_preferred",
1148
+ writer_connection_id: connectionId,
1149
+ direct_candidate: true,
1150
+ });
1151
+
1152
+ return { ok: true };
1153
+ } catch (error) {
1154
+ cleanupPtyRtcTransport(taskId, sessionId);
1155
+ return {
1156
+ ok: false,
1157
+ reason: error?.message || "rtc_negotiation_failed",
1158
+ };
1159
+ }
1160
+ }
1161
+
1162
+ function resolvePtyLaunchSpec(launchConfig, fallbackCwd) {
1163
+ const normalizedLaunchConfig = normalizeLaunchConfig(launchConfig);
1164
+ const entrypointType =
1165
+ normalizeOptionalString(normalizedLaunchConfig.entrypoint_type) ||
1166
+ normalizeOptionalString(normalizedLaunchConfig.entrypointType) ||
1167
+ (normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
1168
+ normalizeOptionalString(normalizedLaunchConfig.toolPreset)
1169
+ ? "tool_preset"
1170
+ : "shell");
1171
+ const preferredShell =
1172
+ normalizeOptionalString(normalizedLaunchConfig.shell) ||
1173
+ process.env.SHELL ||
1174
+ "/bin/zsh";
1175
+ const cwd =
1176
+ normalizeOptionalString(normalizedLaunchConfig.cwd) ||
1177
+ fallbackCwd;
1178
+ const env = normalizeTerminalEnv(normalizedLaunchConfig.env);
1179
+ const cols = normalizePositiveInt(
1180
+ normalizedLaunchConfig.cols ?? normalizedLaunchConfig.columns,
1181
+ DEFAULT_TERMINAL_COLS,
1182
+ );
1183
+ const rows = normalizePositiveInt(
1184
+ normalizedLaunchConfig.rows,
1185
+ DEFAULT_TERMINAL_ROWS,
1186
+ );
1187
+
1188
+ if (entrypointType === "tool_preset") {
1189
+ const toolPreset =
1190
+ normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
1191
+ normalizeOptionalString(normalizedLaunchConfig.toolPreset) ||
1192
+ SUPPORTED_BACKENDS[0] ||
1193
+ "codex";
1194
+ const cliCommand = ALLOW_CLI_LIST[toolPreset];
1195
+ if (!cliCommand) {
1196
+ throw new Error(`Unsupported tool preset: ${toolPreset}`);
1197
+ }
1198
+ return {
1199
+ entrypointType,
1200
+ toolPreset,
1201
+ command: preferredShell,
1202
+ args: ["-lc", cliCommand],
1203
+ shell: preferredShell,
1204
+ cwd,
1205
+ env,
1206
+ cols,
1207
+ rows,
1208
+ };
1209
+ }
1210
+
1211
+ if (entrypointType === "custom") {
1212
+ const command = normalizeOptionalString(normalizedLaunchConfig.command);
1213
+ if (!command) {
1214
+ throw new Error("launch_config.command is required for custom entrypoint");
1215
+ }
1216
+ const args = Array.isArray(normalizedLaunchConfig.args)
1217
+ ? normalizedLaunchConfig.args.filter((value) => typeof value === "string")
1218
+ : [];
1219
+ return {
1220
+ entrypointType,
1221
+ toolPreset: null,
1222
+ command,
1223
+ args,
1224
+ shell: preferredShell,
1225
+ cwd,
1226
+ env,
1227
+ cols,
1228
+ rows,
1229
+ };
1230
+ }
1231
+
1232
+ return {
1233
+ entrypointType: "shell",
1234
+ toolPreset: null,
1235
+ command: preferredShell,
1236
+ args: ["-l"],
1237
+ shell: preferredShell,
1238
+ cwd,
1239
+ env,
1240
+ cols,
1241
+ rows,
1242
+ };
1243
+ }
1244
+
1245
+ function getTerminalChunkByteLength(data) {
1246
+ return Buffer.byteLength(data, "utf8");
1247
+ }
1248
+
1249
+ function trimTerminalChunkToTailBytes(data, maxBytes) {
1250
+ const encoded = Buffer.from(data, "utf8");
1251
+ if (encoded.length <= maxBytes) {
1252
+ return data;
1253
+ }
1254
+ const tail = encoded.subarray(encoded.length - maxBytes);
1255
+ let start = 0;
1256
+ while (start < tail.length && (tail[start] & 0b1100_0000) === 0b1000_0000) {
1257
+ start += 1;
1258
+ }
1259
+ return tail.subarray(start).toString("utf8");
1260
+ }
1261
+
1262
+ function bufferTerminalOutput(record, data) {
1263
+ record.outputSeq += 1;
1264
+ let bufferedData = typeof data === "string" ? data : String(data ?? "");
1265
+ let byteLength = getTerminalChunkByteLength(bufferedData);
1266
+ if (byteLength > TERMINAL_RING_BUFFER_MAX_BYTES) {
1267
+ bufferedData = trimTerminalChunkToTailBytes(bufferedData, TERMINAL_RING_BUFFER_MAX_BYTES);
1268
+ byteLength = getTerminalChunkByteLength(bufferedData);
1269
+ }
1270
+ record.ringBuffer.push({ seq: record.outputSeq, data: bufferedData, byteLength });
1271
+ record.ringBufferByteLength += byteLength;
1272
+ while (record.ringBufferByteLength > TERMINAL_RING_BUFFER_MAX_BYTES && record.ringBuffer.length > 0) {
1273
+ const removed = record.ringBuffer.shift();
1274
+ record.ringBufferByteLength -= removed?.byteLength ?? 0;
1275
+ }
1276
+ return record.outputSeq;
1277
+ }
1278
+
1279
+ function sendDirectPtyPayload(taskId, payload) {
1280
+ const transport = activePtyRtcTransports.get(taskId);
1281
+ const channel = transport?.channel;
1282
+ if (!channel || channel.readyState !== "open" || typeof channel.send !== "function") {
1283
+ return false;
1284
+ }
1285
+ try {
1286
+ channel.send(JSON.stringify(payload));
1287
+ return true;
1288
+ } catch (error) {
1289
+ logError(`Failed to send PTY direct payload for ${taskId}: ${error?.message || error}`);
1290
+ if (transport) {
1291
+ sendPtyTransportStatus({
1292
+ task_id: taskId,
1293
+ session_id: transport.sessionId,
1294
+ connection_id: transport.connectionId,
1295
+ transport_state: "fallback_relay",
1296
+ transport_policy: "direct_preferred",
1297
+ writer_connection_id: transport.connectionId,
1298
+ direct_candidate: false,
1299
+ reason: "direct_channel_send_failed",
1300
+ }).catch((err) => {
1301
+ logError(`Failed to report PTY direct send fallback for ${taskId}: ${err?.message || err}`);
1302
+ });
1303
+ }
1304
+ cleanupPtyRtcTransport(taskId);
1305
+ return false;
1306
+ }
1307
+ }
1308
+
1309
+ function handleDirectTransportPayload(taskId, sessionId, connectionId, payload) {
1310
+ const transport = activePtyRtcTransports.get(taskId);
1311
+ if (
1312
+ !transport ||
1313
+ transport.sessionId !== sessionId ||
1314
+ transport.connectionId !== connectionId
1315
+ ) {
1316
+ return;
1317
+ }
1318
+ if (payload?.type === "terminal_input" && payload.payload) {
1319
+ handleTerminalInput(payload.payload);
1320
+ return;
1321
+ }
1322
+ if (payload?.type === "terminal_resize" && payload.payload) {
1323
+ handleTerminalResize(payload.payload);
1324
+ }
1325
+ }
1326
+
1327
+ function attachPtyStreamHandlers(taskId, record) {
1328
+ const writeLogChunk = (chunk) => {
1329
+ if (record.logStream) {
1330
+ record.logStream.write(chunk);
1331
+ }
1332
+ };
1333
+
1334
+ record.pty.onData((data) => {
1335
+ writeLogChunk(data);
1336
+ const seq = bufferTerminalOutput(record, data);
1337
+ const latencySample = record.pendingLatencySample
1338
+ ? {
1339
+ client_input_seq: record.pendingLatencySample.clientInputSeq ?? undefined,
1340
+ client_sent_at: record.pendingLatencySample.clientSentAt ?? undefined,
1341
+ server_received_at: record.pendingLatencySample.serverReceivedAt ?? undefined,
1342
+ daemon_received_at: record.pendingLatencySample.daemonReceivedAt,
1343
+ first_output_at: new Date().toISOString(),
1344
+ daemon_input_to_first_output_ms: Math.max(0, Date.now() - record.pendingLatencySample.daemonReceivedAtMs),
1345
+ }
1346
+ : undefined;
1347
+ record.pendingLatencySample = null;
1348
+ const outputPayload = {
1349
+ task_id: taskId,
1350
+ project_id: record.projectId,
1351
+ pty_session_id: record.ptySessionId,
1352
+ seq,
1353
+ data,
1354
+ ...(latencySample ? { latency_sample: latencySample } : {}),
1355
+ };
1356
+ sendDirectPtyPayload(taskId, {
1357
+ type: "terminal_output",
1358
+ payload: outputPayload,
1359
+ });
1360
+ sendTerminalEvent("terminal_output", outputPayload).catch((err) => {
1361
+ logError(`Failed to report terminal_output for ${taskId}: ${err?.message || err}`);
1362
+ });
1363
+ });
1364
+
1365
+ record.pty.onExit(({ exitCode, signal }) => {
1366
+ if (record.stopForceKillTimer) {
1367
+ clearTimeout(record.stopForceKillTimer);
1368
+ }
1369
+ cleanupPtyRtcTransport(taskId);
1370
+ activePtySessions.delete(taskId);
1371
+ if (record.logStream) {
1372
+ const ts = new Date().toLocaleString("sv-SE", { timeZone: "Asia/Shanghai" }).replace(" ", "T");
1373
+ record.logStream.write(
1374
+ `[daemon ${ts}] pty exited exitCode=${exitCode ?? "null"} signal=${signal ?? "null"}\n`,
1375
+ );
1376
+ record.logStream.end();
1377
+ }
1378
+ const closedAt = new Date().toISOString();
1379
+ log(`PTY task ${taskId} exited with code=${exitCode ?? "null"} signal=${signal ?? "null"}`);
1380
+ sendTerminalEvent("terminal_exit", {
1381
+ task_id: taskId,
1382
+ project_id: record.projectId,
1383
+ pty_session_id: record.ptySessionId,
1384
+ exit_code: exitCode ?? null,
1385
+ signal: signal ?? null,
1386
+ seq: record.outputSeq,
1387
+ closed_at: closedAt,
1388
+ }).catch((err) => {
1389
+ logError(`Failed to report terminal_exit for ${taskId}: ${err?.message || err}`);
1390
+ });
1391
+ });
1392
+ }
1393
+
1394
+ function resizePty(record, cols, rows) {
1395
+ const nextCols = normalizePositiveInt(cols, record.cols || DEFAULT_TERMINAL_COLS);
1396
+ const nextRows = normalizePositiveInt(rows, record.rows || DEFAULT_TERMINAL_ROWS);
1397
+ record.cols = nextCols;
1398
+ record.rows = nextRows;
1399
+ if (typeof record.pty.resize === "function") {
1400
+ record.pty.resize(nextCols, nextRows);
1401
+ }
1402
+ }
1403
+
1404
+ async function handleCreatePtyTask(payload) {
1405
+ const taskId = payload?.task_id ? String(payload.task_id) : "";
1406
+ const projectId = payload?.project_id ? String(payload.project_id) : "";
1407
+ const ptySessionId = payload?.pty_session_id ? String(payload.pty_session_id) : "";
1408
+ const requestId = payload?.request_id ? String(payload.request_id) : "";
1409
+ const launchConfig = normalizeLaunchConfig(payload?.launch_config);
1410
+
1411
+ if (!taskId || !projectId || !ptySessionId) {
1412
+ logError(`Invalid create_pty_task payload: ${JSON.stringify(payload)}`);
1413
+ sendAgentCommandAck({
1414
+ requestId,
1415
+ taskId,
1416
+ eventType: "create_pty_task",
1417
+ accepted: false,
1418
+ }).catch(() => {});
1419
+ return;
1420
+ }
1421
+
1422
+ if (requestId && !markRequestSeen(requestId)) {
1423
+ log(`Duplicate create_pty_task ignored for ${taskId} (request_id=${requestId})`);
1424
+ sendAgentCommandAck({
1425
+ requestId,
1426
+ taskId,
1427
+ eventType: "create_pty_task",
1428
+ accepted: true,
1429
+ }).catch(() => {});
1430
+ return;
1431
+ }
1432
+
1433
+ if (activeTaskProcesses.has(taskId) || activePtySessions.has(taskId)) {
1434
+ log(`Duplicate create_pty_task ignored for ${taskId}: task already active`);
1435
+ sendAgentCommandAck({
1436
+ requestId,
1437
+ taskId,
1438
+ eventType: "create_pty_task",
1439
+ accepted: true,
1440
+ }).catch(() => {});
1441
+ return;
1442
+ }
1443
+
1444
+ let boundPath = await getProjectLocalPath(projectId);
1445
+ let taskDir = normalizeOptionalString(launchConfig.cwd) || boundPath;
1446
+ if (!taskDir) {
1447
+ const now = new Date();
1448
+ const dayDir = path.join(WORKSPACE_ROOT, formatWorkspaceDate(now));
1449
+ const runTimestampPart = formatWorkspaceRunTimestamp(now);
1450
+ const taskSuffix = taskId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 8) || String(process.pid);
1451
+ // PTY login shells can exit immediately if their cwd is renamed right after spawn.
1452
+ const pendingRunDir = `${runTimestampPart}_pty_${taskSuffix}`;
1453
+ taskDir = path.join(dayDir, pendingRunDir);
1454
+ }
1455
+
1456
+ try {
1457
+ mkdirSyncFn(taskDir, { recursive: true });
1458
+ } catch (err) {
1459
+ logError(`Failed to create PTY workspace ${taskDir}: ${err?.message || err}`);
1460
+ sendAgentCommandAck({
1461
+ requestId,
1462
+ taskId,
1463
+ eventType: "create_pty_task",
1464
+ accepted: false,
1465
+ }).catch(() => {});
1466
+ return;
1467
+ }
1468
+
1469
+ let launchSpec;
1470
+ try {
1471
+ launchSpec = resolvePtyLaunchSpec(launchConfig, taskDir);
1472
+ } catch (error) {
1473
+ logError(`Failed to resolve PTY launch config for ${taskId}: ${error?.message || error}`);
1474
+ sendAgentCommandAck({
1475
+ requestId,
1476
+ taskId,
1477
+ eventType: "create_pty_task",
1478
+ accepted: false,
1479
+ }).catch(() => {});
1480
+ sendTerminalEvent("terminal_error", {
1481
+ task_id: taskId,
1482
+ project_id: projectId,
1483
+ pty_session_id: ptySessionId,
1484
+ message: error?.message || String(error),
1485
+ }).catch(() => {});
1486
+ return;
1487
+ }
1488
+
1489
+ sendAgentCommandAck({
1490
+ requestId,
1491
+ taskId,
1492
+ eventType: "create_pty_task",
1493
+ accepted: true,
1494
+ }).catch((err) => {
1495
+ logError(`Failed to report agent_command_ack(create_pty_task) for ${taskId}: ${err?.message || err}`);
1496
+ });
1497
+
1498
+ const env = {
1499
+ ...process.env,
1500
+ ...launchSpec.env,
1501
+ CONDUCTOR_PROJECT_ID: projectId,
1502
+ CONDUCTOR_TASK_ID: taskId,
1503
+ CONDUCTOR_PTY_SESSION_ID: ptySessionId,
1504
+ };
1505
+ if (config.CONFIG_FILE) {
1506
+ env.CONDUCTOR_CONFIG = config.CONFIG_FILE;
1507
+ }
1508
+ if (AGENT_TOKEN) {
1509
+ env.CONDUCTOR_AGENT_TOKEN = AGENT_TOKEN;
1510
+ }
1511
+ if (BACKEND_HTTP) {
1512
+ env.CONDUCTOR_BACKEND_URL = BACKEND_HTTP;
1513
+ }
1514
+
1515
+ const logPath = path.join(launchSpec.cwd, "conductor-terminal.log");
1516
+ let logStream;
1517
+ try {
1518
+ logStream = createWriteStreamFn(logPath, { flags: "a" });
1519
+ if (logStream && typeof logStream.on === "function") {
1520
+ const logPathSnapshot = logPath;
1521
+ logStream.on("error", (err) => {
1522
+ logError(`Terminal log stream error (${logPathSnapshot}): ${err?.message || err}`);
1523
+ });
1524
+ }
1525
+ } catch (err) {
1526
+ logError(`Failed to open PTY log file ${logPath}: ${err?.message || err}`);
1527
+ }
1528
+
1529
+ try {
1530
+ const pty = await createPtyFn(launchSpec.command, launchSpec.args, {
1531
+ name: "xterm-256color",
1532
+ cols: launchSpec.cols,
1533
+ rows: launchSpec.rows,
1534
+ cwd: launchSpec.cwd,
1535
+ env,
1536
+ });
1537
+ const resolvedLogPath = path.join(taskDir, "conductor-terminal.log");
1538
+
1539
+ const startedAt = new Date().toISOString();
1540
+ const record = {
1541
+ kind: "pty",
1542
+ pty,
1543
+ ptySessionId,
1544
+ projectId,
1545
+ taskDir,
1546
+ logPath: resolvedLogPath,
1547
+ logStream,
1548
+ cols: launchSpec.cols,
1549
+ rows: launchSpec.rows,
1550
+ shell: launchSpec.shell,
1551
+ startedAt,
1552
+ outputSeq: 0,
1553
+ ringBuffer: [],
1554
+ ringBufferByteLength: 0,
1555
+ pendingLatencySample: null,
1556
+ stopForceKillTimer: null,
1557
+ };
1558
+ activePtySessions.set(taskId, record);
1559
+ attachPtyStreamHandlers(taskId, record);
1560
+
1561
+ log(`Created PTY task ${taskId} (${launchSpec.entrypointType}) cwd=${launchSpec.cwd}`);
1562
+ sendTerminalEvent("terminal_opened", {
1563
+ task_id: taskId,
1564
+ project_id: projectId,
1565
+ pty_session_id: ptySessionId,
1566
+ pid: Number.isInteger(pty?.pid) ? pty.pid : null,
1567
+ cwd: taskDir,
1568
+ shell: launchSpec.shell,
1569
+ cols: launchSpec.cols,
1570
+ rows: launchSpec.rows,
1571
+ started_at: startedAt,
1572
+ }).catch((err) => {
1573
+ logError(`Failed to report terminal_opened for ${taskId}: ${err?.message || err}`);
1574
+ });
1575
+ } catch (error) {
1576
+ if (logStream) {
1577
+ logStream.end();
1578
+ }
1579
+ logError(`Failed to create PTY task ${taskId}: ${error?.message || error}`);
1580
+ sendTerminalEvent("terminal_error", {
1581
+ task_id: taskId,
1582
+ project_id: projectId,
1583
+ pty_session_id: ptySessionId,
1584
+ message: error?.message || String(error),
1585
+ }).catch(() => {});
1586
+ }
1587
+ }
1588
+
1589
+ async function handleTerminalAttach(payload) {
1590
+ const taskId = payload?.task_id ? String(payload.task_id) : "";
1591
+ if (!taskId) return;
1592
+ const record = activePtySessions.get(taskId);
1593
+ if (!record) {
1594
+ sendTerminalEvent("terminal_error", {
1595
+ task_id: taskId,
1596
+ pty_session_id: payload?.pty_session_id ? String(payload.pty_session_id) : null,
1597
+ message: "terminal session not found",
1598
+ }).catch(() => {});
1599
+ return;
1600
+ }
1601
+
1602
+ if (payload?.cols || payload?.rows) {
1603
+ resizePty(record, payload?.cols, payload?.rows);
1604
+ }
1605
+
1606
+ await sendTerminalEvent("terminal_opened", {
1607
+ task_id: taskId,
1608
+ project_id: record.projectId,
1609
+ pty_session_id: record.ptySessionId,
1610
+ pid: Number.isInteger(record.pty?.pid) ? record.pty.pid : null,
1611
+ cwd: record.taskDir,
1612
+ shell: record.shell,
1613
+ cols: record.cols,
1614
+ rows: record.rows,
1615
+ started_at: record.startedAt,
1616
+ }).catch((err) => {
1617
+ logError(`Failed to report terminal_opened on attach for ${taskId}: ${err?.message || err}`);
1618
+ });
1619
+
1620
+ const lastSeq = normalizePositiveInt(payload?.last_seq ?? payload?.lastSeq, 0);
1621
+ for (const chunk of record.ringBuffer) {
1622
+ if (chunk.seq <= lastSeq) continue;
1623
+ await sendTerminalEvent("terminal_output", {
1624
+ task_id: taskId,
1625
+ project_id: record.projectId,
1626
+ pty_session_id: record.ptySessionId,
1627
+ seq: chunk.seq,
1628
+ data: chunk.data,
1629
+ }).catch((err) => {
1630
+ logError(`Failed to replay terminal_output for ${taskId}: ${err?.message || err}`);
1631
+ });
1632
+ }
1633
+ }
1634
+
1635
+ function handleTerminalInput(payload) {
1636
+ const taskId = payload?.task_id ? String(payload.task_id) : "";
1637
+ const data = typeof payload?.data === "string" ? payload.data : "";
1638
+ if (!taskId || !data) return;
1639
+ const record = activePtySessions.get(taskId);
1640
+ if (!record || typeof record.pty.write !== "function") {
1641
+ return;
1642
+ }
1643
+ record.pendingLatencySample = {
1644
+ clientInputSeq: normalizeNonNegativeInt(payload?.client_input_seq ?? payload?.clientInputSeq, null),
1645
+ clientSentAt: normalizeIsoTimestamp(payload?.client_sent_at ?? payload?.clientSentAt),
1646
+ serverReceivedAt: normalizeIsoTimestamp(payload?.server_received_at ?? payload?.serverReceivedAt),
1647
+ daemonReceivedAt: new Date().toISOString(),
1648
+ daemonReceivedAtMs: Date.now(),
1649
+ };
1650
+ record.pty.write(data);
1651
+ }
1652
+
1653
+ function handleTerminalResize(payload) {
1654
+ const taskId = payload?.task_id ? String(payload.task_id) : "";
1655
+ if (!taskId) return;
1656
+ const record = activePtySessions.get(taskId);
1657
+ if (!record) return;
1658
+ resizePty(record, payload?.cols, payload?.rows);
1659
+ }
1660
+
1661
+ function handleTerminalDetach(_payload) {
1662
+ // PTY sessions stay alive without viewers. Detach is currently a no-op.
1663
+ }
1664
+
1665
+ async function handlePtyTransportSignal(payload) {
1666
+ const taskId = payload?.task_id ? String(payload.task_id) : "";
1667
+ const sessionId = payload?.session_id ? String(payload.session_id) : "";
1668
+ const connectionId = payload?.connection_id ? String(payload.connection_id) : "";
1669
+ const signalType = payload?.signal_type ? String(payload.signal_type) : "";
1670
+ if (!taskId || !connectionId || !signalType) {
1671
+ return;
1672
+ }
1673
+
1674
+ const record = activePtySessions.get(taskId);
1675
+ const description =
1676
+ payload?.description && typeof payload.description === "object" && !Array.isArray(payload.description)
1677
+ ? payload.description
1678
+ : null;
1679
+ const candidate =
1680
+ payload?.candidate && typeof payload.candidate === "object" && !Array.isArray(payload.candidate)
1681
+ ? payload.candidate
1682
+ : null;
1683
+
1684
+ if (signalType === "ice_candidate") {
1685
+ if (!sessionId) {
1686
+ return;
1687
+ }
1688
+ const transport = activePtyRtcTransports.get(taskId);
1689
+ if (
1690
+ transport &&
1691
+ transport.sessionId === sessionId &&
1692
+ transport.connectionId === connectionId &&
1693
+ typeof transport.peer?.addIceCandidate === "function" &&
1694
+ candidate
1695
+ ) {
1696
+ try {
1697
+ await transport.peer.addIceCandidate(candidate);
1698
+ } catch (err) {
1699
+ logError(`Failed to apply PTY ICE candidate for ${taskId}: ${err?.message || err}`);
1700
+ }
1701
+ }
1702
+ return;
1703
+ }
1704
+
1705
+ if (signalType === "revoke") {
1706
+ const transport = activePtyRtcTransports.get(taskId);
1707
+ if (transport && transport.connectionId === connectionId) {
1708
+ cleanupPtyRtcTransport(taskId);
1709
+ }
1710
+ return;
1711
+ }
1712
+
1713
+ if (signalType === "offer" && description?.type === "offer" && typeof description.sdp === "string") {
1714
+ if (!sessionId) {
1715
+ return;
1716
+ }
1717
+ const negotiation = await startPtyRtcNegotiation(taskId, sessionId, connectionId, description);
1718
+ if (negotiation.ok) {
1719
+ return;
1720
+ }
1721
+ const reason = negotiation.reason || (record ? "direct_transport_not_supported" : "terminal_session_not_found");
1722
+ sendPtyTransportSignal({
1723
+ task_id: taskId,
1724
+ session_id: sessionId,
1725
+ connection_id: connectionId,
1726
+ signal_type: "answer_placeholder",
1727
+ description: {
1728
+ type: "answer",
1729
+ mode: "placeholder",
1730
+ reason,
1731
+ },
1732
+ }).catch((err) => {
1733
+ logError(`Failed to report pty_transport_signal for ${taskId}: ${err?.message || err}`);
1734
+ });
1735
+ sendPtyTransportStatus({
1736
+ task_id: taskId,
1737
+ session_id: sessionId,
1738
+ connection_id: connectionId,
1739
+ transport_state: "fallback_relay",
1740
+ transport_policy: "relay_only",
1741
+ writer_connection_id: connectionId,
1742
+ direct_candidate: false,
1743
+ reason,
1744
+ }).catch((err) => {
1745
+ logError(`Failed to report pty_transport_status for ${taskId}: ${err?.message || err}`);
1746
+ });
1747
+ return;
1748
+ }
1749
+
1750
+ const reason = record ? "direct_transport_not_supported" : "terminal_session_not_found";
1751
+ if (signalType === "direct_request") {
1752
+ if (!sessionId) {
1753
+ return;
1754
+ }
1755
+ sendPtyTransportSignal({
1756
+ task_id: taskId,
1757
+ session_id: sessionId,
1758
+ connection_id: connectionId,
1759
+ signal_type: "answer_placeholder",
1760
+ description: {
1761
+ type: "answer",
1762
+ mode: "placeholder",
1763
+ reason,
1764
+ },
1765
+ }).catch((err) => {
1766
+ logError(`Failed to report pty_transport_signal for ${taskId}: ${err?.message || err}`);
1767
+ });
1768
+ sendPtyTransportStatus({
1769
+ task_id: taskId,
1770
+ session_id: sessionId,
1771
+ connection_id: connectionId,
1772
+ transport_state: "fallback_relay",
1773
+ transport_policy: "relay_only",
1774
+ writer_connection_id: connectionId,
1775
+ direct_candidate: false,
1776
+ reason,
1777
+ }).catch((err) => {
1778
+ logError(`Failed to report pty_transport_status for ${taskId}: ${err?.message || err}`);
1779
+ });
1780
+ }
1781
+ }
1782
+
784
1783
  function handleEvent(event) {
785
1784
  const receivedAt = Date.now();
786
1785
  lastInboundAt = receivedAt;
@@ -805,10 +1804,34 @@ export function startDaemon(config = {}, deps = {}) {
805
1804
  handleCreateTask(event.payload);
806
1805
  return;
807
1806
  }
1807
+ if (event.type === "create_pty_task") {
1808
+ void handleCreatePtyTask(event.payload);
1809
+ return;
1810
+ }
808
1811
  if (event.type === "stop_task") {
809
1812
  handleStopTask(event.payload);
810
1813
  return;
811
1814
  }
1815
+ if (event.type === "terminal_attach") {
1816
+ void handleTerminalAttach(event.payload);
1817
+ return;
1818
+ }
1819
+ if (event.type === "terminal_input") {
1820
+ handleTerminalInput(event.payload);
1821
+ return;
1822
+ }
1823
+ if (event.type === "terminal_resize") {
1824
+ handleTerminalResize(event.payload);
1825
+ return;
1826
+ }
1827
+ if (event.type === "terminal_detach") {
1828
+ handleTerminalDetach(event.payload);
1829
+ return;
1830
+ }
1831
+ if (event.type === "pty_transport_signal") {
1832
+ void handlePtyTransportSignal(event.payload);
1833
+ return;
1834
+ }
812
1835
  if (event.type === "collect_logs") {
813
1836
  void handleCollectLogs(event.payload);
814
1837
  }
@@ -922,8 +1945,9 @@ export function startDaemon(config = {}, deps = {}) {
922
1945
  });
923
1946
  };
924
1947
 
925
- const record = activeTaskProcesses.get(taskId);
926
- if (!record || !record.child) {
1948
+ const processRecord = activeTaskProcesses.get(taskId);
1949
+ const ptyRecord = activePtySessions.get(taskId);
1950
+ if ((!processRecord || !processRecord.child) && !ptyRecord) {
927
1951
  log(`Stop requested for task ${taskId}, but no active process found`);
928
1952
  sendStopAck(false);
929
1953
  return;
@@ -934,36 +1958,61 @@ export function startDaemon(config = {}, deps = {}) {
934
1958
 
935
1959
  sendStopAck(true);
936
1960
 
937
- if (record.stopForceKillTimer) {
938
- clearTimeout(record.stopForceKillTimer);
939
- record.stopForceKillTimer = null;
1961
+ const activeRecord = processRecord || ptyRecord;
1962
+ if (activeRecord?.stopForceKillTimer) {
1963
+ clearTimeout(activeRecord.stopForceKillTimer);
1964
+ activeRecord.stopForceKillTimer = null;
1965
+ }
1966
+ if (ptyRecord) {
1967
+ cleanupPtyRtcTransport(taskId);
940
1968
  }
941
1969
 
942
- try {
943
- if (typeof record.child.kill === "function") {
944
- record.child.kill("SIGTERM");
1970
+ if (processRecord?.child) {
1971
+ try {
1972
+ if (typeof processRecord.child.kill === "function") {
1973
+ processRecord.child.kill("SIGTERM");
1974
+ }
1975
+ } catch (error) {
1976
+ logError(`Failed to stop task ${taskId}: ${error?.message || error}`);
1977
+ }
1978
+ } else if (ptyRecord?.pty) {
1979
+ try {
1980
+ if (typeof ptyRecord.pty.kill === "function") {
1981
+ ptyRecord.pty.kill("SIGTERM");
1982
+ }
1983
+ } catch (error) {
1984
+ logError(`Failed to stop PTY task ${taskId}: ${error?.message || error}`);
945
1985
  }
946
- } catch (error) {
947
- logError(`Failed to stop task ${taskId}: ${error?.message || error}`);
948
1986
  }
949
1987
 
950
- record.stopForceKillTimer = setTimeout(() => {
951
- const latest = activeTaskProcesses.get(taskId);
952
- if (!latest || latest.child !== record.child) {
1988
+ activeRecord.stopForceKillTimer = setTimeout(() => {
1989
+ const latestProcess = activeTaskProcesses.get(taskId);
1990
+ const latestPty = activePtySessions.get(taskId);
1991
+ if (latestProcess?.child && processRecord?.child && latestProcess.child === processRecord.child) {
1992
+ try {
1993
+ if (typeof latestProcess.child.kill === "function") {
1994
+ log(`Task ${taskId} did not exit after SIGTERM, sending SIGKILL`);
1995
+ latestProcess.child.kill("SIGKILL");
1996
+ }
1997
+ } catch (error) {
1998
+ logError(`Failed to SIGKILL task ${taskId}: ${error?.message || error}`);
1999
+ }
953
2000
  return;
954
2001
  }
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");
2002
+ if (latestPty?.pty && ptyRecord?.pty && latestPty.pty === ptyRecord.pty) {
2003
+ try {
2004
+ if (typeof latestPty.pty.kill === "function") {
2005
+ log(`PTY task ${taskId} did not exit after SIGTERM, sending SIGKILL`);
2006
+ latestPty.pty.kill("SIGKILL");
2007
+ }
2008
+ } catch (error) {
2009
+ logError(`Failed to SIGKILL PTY task ${taskId}: ${error?.message || error}`);
959
2010
  }
960
- } catch (error) {
961
- logError(`Failed to SIGKILL task ${taskId}: ${error?.message || error}`);
962
2011
  }
963
2012
  }, STOP_FORCE_KILL_TIMEOUT_MS);
964
2013
 
965
- if (typeof record.stopForceKillTimer?.unref === "function") {
966
- record.stopForceKillTimer.unref();
2014
+ if (typeof activeRecord.stopForceKillTimer?.unref === "function") {
2015
+ activeRecord.stopForceKillTimer.unref();
967
2016
  }
968
2017
  }
969
2018
 
@@ -1067,7 +2116,7 @@ export function startDaemon(config = {}, deps = {}) {
1067
2116
  }
1068
2117
 
1069
2118
  // Validate and get CLI command for the backend
1070
- const effectiveBackend = backendType || SUPPORTED_BACKENDS[0];
2119
+ const effectiveBackend = normalizeRuntimeBackendName(backendType || SUPPORTED_BACKENDS[0]);
1071
2120
  if (!SUPPORTED_BACKENDS.includes(effectiveBackend)) {
1072
2121
  logError(`Unsupported backend: ${effectiveBackend}. Supported: ${SUPPORTED_BACKENDS.join(", ")}`);
1073
2122
  sendAgentCommandAck({
@@ -1315,7 +2364,9 @@ export function startDaemon(config = {}, deps = {}) {
1315
2364
  clearInterval(watchdogTimer);
1316
2365
  watchdogTimer = null;
1317
2366
  }
1318
- const activeEntries = [...activeTaskProcesses.entries()];
2367
+ const activeProcessEntries = [...activeTaskProcesses.entries()];
2368
+ const activePtyEntries = [...activePtySessions.entries()];
2369
+ const activeEntries = [...activeProcessEntries, ...activePtyEntries];
1319
2370
  if (activeEntries.length > 0) {
1320
2371
  log(`Shutdown requested (${reason}); stopping ${activeEntries.length} active task(s)`);
1321
2372
  }
@@ -1343,7 +2394,7 @@ export function startDaemon(config = {}, deps = {}) {
1343
2394
  }),
1344
2395
  );
1345
2396
 
1346
- for (const [taskId, record] of activeEntries) {
2397
+ for (const [taskId, record] of activeProcessEntries) {
1347
2398
  if (record?.stopForceKillTimer) {
1348
2399
  clearTimeout(record.stopForceKillTimer);
1349
2400
  }
@@ -1356,7 +2407,22 @@ export function startDaemon(config = {}, deps = {}) {
1356
2407
  }
1357
2408
  }
1358
2409
 
2410
+ for (const [taskId, record] of activePtyEntries) {
2411
+ if (record?.stopForceKillTimer) {
2412
+ clearTimeout(record.stopForceKillTimer);
2413
+ }
2414
+ cleanupPtyRtcTransport(taskId);
2415
+ try {
2416
+ if (typeof record.pty?.kill === "function") {
2417
+ record.pty.kill("SIGTERM");
2418
+ }
2419
+ } catch (error) {
2420
+ logError(`Failed to stop PTY task ${taskId} on daemon close: ${error?.message || error}`);
2421
+ }
2422
+ }
2423
+
1359
2424
  activeTaskProcesses.clear();
2425
+ activePtySessions.clear();
1360
2426
 
1361
2427
  try {
1362
2428
  await withTimeout(
@@ -1429,6 +2495,25 @@ function parsePositiveInt(value, fallback) {
1429
2495
  return fallback;
1430
2496
  }
1431
2497
 
2498
+ function parseBooleanEnv(value) {
2499
+ if (typeof value !== "string") {
2500
+ return false;
2501
+ }
2502
+ const normalized = value.trim().toLowerCase();
2503
+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
2504
+ }
2505
+
2506
+ function resolveRtcModuleCandidates(value) {
2507
+ if (typeof value !== "string" || !value.trim()) {
2508
+ return [...DEFAULT_RTC_MODULE_CANDIDATES];
2509
+ }
2510
+ const candidates = value
2511
+ .split(",")
2512
+ .map((entry) => entry.trim())
2513
+ .filter(Boolean);
2514
+ return candidates.length > 0 ? [...new Set(candidates)] : [...DEFAULT_RTC_MODULE_CANDIDATES];
2515
+ }
2516
+
1432
2517
  function formatDisconnectDiagnostics(event) {
1433
2518
  const parts = [];
1434
2519
  const reason = typeof event?.reason === "string" && event.reason.trim()