@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/bin/conductor-channel.js +130 -0
- package/bin/conductor-config.js +29 -30
- package/bin/conductor-diagnose.js +25 -0
- package/bin/conductor-fire.js +300 -9
- package/bin/conductor.js +5 -1
- package/package.json +12 -4
- package/src/daemon.js +1112 -27
- package/src/runtime-backends.js +31 -0
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(
|
|
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:
|
|
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
|
|
926
|
-
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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
|
-
|
|
951
|
-
const
|
|
952
|
-
|
|
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
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
|
966
|
-
|
|
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
|
|
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
|
|
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()
|