@love-moon/conductor-cli 0.2.19 → 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.
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import process from "node:process";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ import yargs from "yargs/yargs";
10
+ import { hideBin } from "yargs/helpers";
11
+ import { loadConfig } from "@love-moon/conductor-sdk";
12
+
13
+ const isMainModule = (() => {
14
+ const currentFile = fileURLToPath(import.meta.url);
15
+ const entryFile = process.argv[1] ? path.resolve(process.argv[1]) : "";
16
+ return entryFile === currentFile;
17
+ })();
18
+
19
+ function resolveDefaultConfigPath(env = process.env) {
20
+ const home = env.HOME || env.USERPROFILE || os.homedir();
21
+ return path.join(home, ".conductor", "config.yaml");
22
+ }
23
+
24
+ function readTextFile(filePath) {
25
+ return fs.readFileSync(filePath, "utf8");
26
+ }
27
+
28
+ function ensureFeishuChannelConfig(config) {
29
+ const feishu = config?.channels?.feishu;
30
+ if (!feishu?.appId || !feishu?.appSecret || !feishu?.verificationToken) {
31
+ throw new Error("config.yaml is missing channels.feishu.app_id/app_secret/verification_token");
32
+ }
33
+ return feishu;
34
+ }
35
+
36
+ function formatErrorBody(text) {
37
+ const normalized = typeof text === "string" ? text.trim() : "";
38
+ if (!normalized) return "";
39
+ try {
40
+ const parsed = JSON.parse(normalized);
41
+ if (parsed && typeof parsed === "object" && typeof parsed.error === "string") {
42
+ return parsed.error;
43
+ }
44
+ } catch {
45
+ // ignore
46
+ }
47
+ return normalized;
48
+ }
49
+
50
+ export async function connectFeishuChannel(options = {}) {
51
+ const env = options.env ?? process.env;
52
+ const fetchImpl = options.fetchImpl ?? global.fetch;
53
+ if (typeof fetchImpl !== "function") {
54
+ throw new Error("fetch is not available");
55
+ }
56
+
57
+ const resolvedConfigPath = path.resolve(options.configFile || resolveDefaultConfigPath(env));
58
+ const rawYaml = readTextFile(resolvedConfigPath);
59
+ const config = loadConfig(resolvedConfigPath, { env });
60
+ const feishu = ensureFeishuChannelConfig(config);
61
+
62
+ const url = new URL("/api/channel/feishu/config", config.backendUrl);
63
+ const response = await fetchImpl(String(url), {
64
+ method: "POST",
65
+ headers: {
66
+ Authorization: `Bearer ${config.agentToken}`,
67
+ "Content-Type": "application/json",
68
+ Accept: "application/json",
69
+ },
70
+ body: JSON.stringify({ yaml: rawYaml }),
71
+ });
72
+
73
+ const rawText = await response.text();
74
+ if (!response.ok) {
75
+ const detail = formatErrorBody(rawText);
76
+ throw new Error(`Failed to connect Feishu channel (${response.status})${detail ? `: ${detail}` : ""}`);
77
+ }
78
+
79
+ const parsed = rawText ? JSON.parse(rawText) : {};
80
+ return {
81
+ configPath: resolvedConfigPath,
82
+ feishu,
83
+ config: parsed.config ?? null,
84
+ };
85
+ }
86
+
87
+ export async function main(argvInput = hideBin(process.argv), dependencies = {}) {
88
+ const log = dependencies.log ?? console.log;
89
+ const logError = dependencies.logError ?? console.error;
90
+
91
+ await yargs(argvInput)
92
+ .scriptName("conductor channel")
93
+ .command(
94
+ "connect feishu",
95
+ "Upload channels.feishu from config.yaml to Conductor backend",
96
+ (command) => command.option("config-file", {
97
+ type: "string",
98
+ describe: "Path to Conductor config file",
99
+ }),
100
+ async (argv) => {
101
+ try {
102
+ const result = await connectFeishuChannel({
103
+ configFile: argv.configFile,
104
+ });
105
+ const effective = result.config ?? {
106
+ provider: "FEISHU",
107
+ appId: result.feishu.appId,
108
+ verificationToken: result.feishu.verificationToken,
109
+ };
110
+ log(`Connected Feishu channel from ${result.configPath}`);
111
+ log(`app_id: ${effective.appId}`);
112
+ log(`verification_token: ${effective.verificationToken}`);
113
+ log("Configure the same verification_token in Feishu Open Platform webhook settings.");
114
+ } catch (error) {
115
+ logError(error instanceof Error ? error.message : String(error));
116
+ process.exitCode = 1;
117
+ }
118
+ },
119
+ )
120
+ .demandCommand(1)
121
+ .help()
122
+ .parseAsync();
123
+ }
124
+
125
+ if (isMainModule) {
126
+ main().catch((error) => {
127
+ console.error(error instanceof Error ? error.message : String(error));
128
+ process.exit(1);
129
+ });
130
+ }
@@ -23,7 +23,7 @@ const DEFAULT_CLIs = {
23
23
  },
24
24
  codex: {
25
25
  command: "codex",
26
- execArgs: "exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check",
26
+ execArgs: "--dangerously-bypass-approvals-and-sandbox --ask-for-approval never",
27
27
  description: "OpenAI Codex CLI"
28
28
  },
29
29
  // gemini: {
@@ -244,6 +244,7 @@ function printReport(taskId, report) {
244
244
  const diagnosis = payload?.diagnosis || {};
245
245
  const task = payload?.task || {};
246
246
  const realtime = payload?.realtime || {};
247
+ const ptyTransport = payload?.pty_transport || payload?.ptyTransport || {};
247
248
  const messages = payload?.messages || {};
248
249
 
249
250
  process.stdout.write(`Task: ${task?.id || taskId}\n`);
@@ -273,6 +274,25 @@ function printReport(taskId, report) {
273
274
  );
274
275
  }
275
276
  }
277
+ const latestLatency = ptyTransport?.latest_latency_sample || ptyTransport?.latestLatencySample;
278
+ if (latestLatency) {
279
+ process.stdout.write(
280
+ `- pty.latest_latency: client->server=${formatLatencyMs(
281
+ latestLatency.client_input_to_server_received_ms ?? latestLatency.clientInputToServerReceivedMs,
282
+ )}, server->daemon=${formatLatencyMs(
283
+ latestLatency.server_received_to_daemon_received_ms ?? latestLatency.serverReceivedToDaemonReceivedMs,
284
+ )}, daemon->first_output=${formatLatencyMs(
285
+ latestLatency.daemon_input_to_first_output_ms ?? latestLatency.daemonInputToFirstOutputMs,
286
+ )}, client->first_output=${formatLatencyMs(
287
+ latestLatency.client_input_to_first_output_ms ?? latestLatency.clientInputToFirstOutputMs,
288
+ )}\n`,
289
+ );
290
+ process.stdout.write(
291
+ `- pty.primary_bottleneck: ${String(
292
+ ptyTransport?.primary_bottleneck || ptyTransport?.primaryBottleneck || "unknown",
293
+ )}\n`,
294
+ );
295
+ }
276
296
 
277
297
  const reasons = Array.isArray(diagnosis.reasons) ? diagnosis.reasons : [];
278
298
  if (reasons.length > 0) {
@@ -364,6 +384,11 @@ function detectExecutionFailureLoopKey(value) {
364
384
  return null;
365
385
  }
366
386
 
387
+ function formatLatencyMs(value) {
388
+ const num = typeof value === "number" ? value : Number(value);
389
+ return Number.isFinite(num) && num >= 0 ? `${Math.round(num)}ms` : "n/a";
390
+ }
391
+
367
392
  function normalizePositiveInt(value, fallback) {
368
393
  const parsed = Number.parseInt(String(value ?? ""), 10);
369
394
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
@@ -98,6 +98,30 @@ const DEFAULT_ERROR_LOOP_BACKOFF_MS = 3 * 60 * 1000;
98
98
  const DEFAULT_ERROR_LOOP_THRESHOLD = 3;
99
99
  const SESSION_BOOTSTRAP_LOCK_TIMEOUT_MS = 15_000;
100
100
  const SESSION_BOOTSTRAP_LOCK_RETRY_MS = 50;
101
+ const FIRE_WATCHDOG_INTERVAL_MS = getBoundedEnvInt(
102
+ "CONDUCTOR_FIRE_WATCHDOG_INTERVAL_MS",
103
+ 10_000,
104
+ 1_000,
105
+ 60_000,
106
+ );
107
+ const FIRE_WATCHDOG_STALE_WS_MS = getBoundedEnvInt(
108
+ "CONDUCTOR_FIRE_WATCHDOG_STALE_WS_MS",
109
+ 45_000,
110
+ 5_000,
111
+ 5 * 60_000,
112
+ );
113
+ const FIRE_WATCHDOG_CONNECT_GRACE_MS = getBoundedEnvInt(
114
+ "CONDUCTOR_FIRE_WATCHDOG_CONNECT_GRACE_MS",
115
+ 15_000,
116
+ 1_000,
117
+ 60_000,
118
+ );
119
+ const FIRE_WATCHDOG_RECONNECT_COOLDOWN_MS = getBoundedEnvInt(
120
+ "CONDUCTOR_FIRE_WATCHDOG_RECONNECT_COOLDOWN_MS",
121
+ 15_000,
122
+ 1_000,
123
+ 2 * 60_000,
124
+ );
101
125
 
102
126
  function sleepSync(ms) {
103
127
  if (!Number.isFinite(ms) || ms <= 0) {
@@ -191,6 +215,176 @@ function appendFireLocalLog(line) {
191
215
  }
192
216
  }
193
217
 
218
+ function formatFireDisconnectDiagnostics(event = {}) {
219
+ const parts = [];
220
+ if (event.reason) {
221
+ parts.push(`reason=${event.reason}`);
222
+ }
223
+ if (typeof event.closeCode === "number") {
224
+ parts.push(`close_code=${event.closeCode}`);
225
+ }
226
+ if (event.closeReason) {
227
+ parts.push(`close_reason=${sanitizeForLog(event.closeReason, 120)}`);
228
+ }
229
+ if (event.socketError) {
230
+ parts.push(`socket_error=${sanitizeForLog(event.socketError, 120)}`);
231
+ }
232
+ if (typeof event.missedPongs === "number" && event.missedPongs > 0) {
233
+ parts.push(`missed_pongs=${event.missedPongs}`);
234
+ }
235
+ return parts.join(" ") || "reason=connection_lost";
236
+ }
237
+
238
+ function formatIsoTimestamp(value) {
239
+ if (!Number.isFinite(value) || value <= 0) {
240
+ return "n/a";
241
+ }
242
+ return new Date(value).toISOString();
243
+ }
244
+
245
+ function formatFireWatchdogState({ connectedAt, lastPongAt, lastInboundAt }) {
246
+ return [
247
+ `connected_at=${formatIsoTimestamp(connectedAt)}`,
248
+ `last_pong_at=${formatIsoTimestamp(lastPongAt)}`,
249
+ `last_inbound_at=${formatIsoTimestamp(lastInboundAt)}`,
250
+ ].join(" ");
251
+ }
252
+
253
+ export class FireWatchdog {
254
+ constructor({
255
+ intervalMs = FIRE_WATCHDOG_INTERVAL_MS,
256
+ staleWsMs = FIRE_WATCHDOG_STALE_WS_MS,
257
+ connectGraceMs = FIRE_WATCHDOG_CONNECT_GRACE_MS,
258
+ reconnectCooldownMs = FIRE_WATCHDOG_RECONNECT_COOLDOWN_MS,
259
+ onForceReconnect,
260
+ logger = () => {},
261
+ now = () => Date.now(),
262
+ } = {}) {
263
+ this.intervalMs = intervalMs;
264
+ this.staleWsMs = staleWsMs;
265
+ this.connectGraceMs = connectGraceMs;
266
+ this.reconnectCooldownMs = reconnectCooldownMs;
267
+ this.onForceReconnect = onForceReconnect;
268
+ this.logger = logger;
269
+ this.now = now;
270
+ this.wsConnected = false;
271
+ this.lastConnectedAt = null;
272
+ this.lastPongAt = null;
273
+ this.lastInboundAt = null;
274
+ this.lastHealAt = 0;
275
+ this.healAttempts = 0;
276
+ this.awaitingHealthySignalAt = null;
277
+ this.timer = null;
278
+ }
279
+
280
+ start() {
281
+ if (this.timer) {
282
+ return;
283
+ }
284
+ this.timer = setInterval(() => {
285
+ void this.runOnce();
286
+ }, this.intervalMs);
287
+ if (typeof this.timer.unref === "function") {
288
+ this.timer.unref();
289
+ }
290
+ }
291
+
292
+ stop() {
293
+ if (!this.timer) {
294
+ return;
295
+ }
296
+ clearInterval(this.timer);
297
+ this.timer = null;
298
+ }
299
+
300
+ onConnected({ isReconnect = false, connectedAt = this.now() } = {}) {
301
+ this.wsConnected = true;
302
+ this.lastConnectedAt = connectedAt;
303
+ this.lastPongAt =
304
+ Number.isFinite(this.lastPongAt) && this.lastPongAt > connectedAt ? this.lastPongAt : connectedAt;
305
+ if (isReconnect && this.healAttempts > 0) {
306
+ this.awaitingHealthySignalAt = connectedAt;
307
+ } else if (!isReconnect) {
308
+ this.awaitingHealthySignalAt = null;
309
+ }
310
+ }
311
+
312
+ onDisconnected() {
313
+ this.wsConnected = false;
314
+ }
315
+
316
+ onPong({ at = this.now() } = {}) {
317
+ this.lastPongAt = at;
318
+ this.markHealthy("pong", at);
319
+ }
320
+
321
+ onInbound(at = this.now()) {
322
+ this.lastInboundAt = at;
323
+ this.markHealthy("inbound", at);
324
+ }
325
+
326
+ markHealthy(signal, at = this.now()) {
327
+ if (!this.awaitingHealthySignalAt || this.healAttempts === 0) {
328
+ return;
329
+ }
330
+ if (at < this.awaitingHealthySignalAt) {
331
+ return;
332
+ }
333
+ this.logger(
334
+ `[watchdog] Backend websocket healthy again after self-heal via ${signal} (${formatFireWatchdogState({
335
+ connectedAt: this.lastConnectedAt,
336
+ lastPongAt: this.lastPongAt,
337
+ lastInboundAt: this.lastInboundAt,
338
+ })})`,
339
+ );
340
+ this.awaitingHealthySignalAt = null;
341
+ this.healAttempts = 0;
342
+ }
343
+
344
+ async runOnce() {
345
+ if (!this.wsConnected || typeof this.onForceReconnect !== "function") {
346
+ return false;
347
+ }
348
+ const now = this.now();
349
+ if (!Number.isFinite(this.lastConnectedAt) || now - this.lastConnectedAt < this.connectGraceMs) {
350
+ return false;
351
+ }
352
+ const lastWsHealthAt = Math.max(this.lastPongAt || 0, this.lastInboundAt || 0, this.lastConnectedAt || 0);
353
+ if (lastWsHealthAt && now - lastWsHealthAt <= this.staleWsMs) {
354
+ return false;
355
+ }
356
+ if (this.lastHealAt && now - this.lastHealAt < this.reconnectCooldownMs) {
357
+ return false;
358
+ }
359
+
360
+ this.lastHealAt = now;
361
+ this.healAttempts += 1;
362
+ this.awaitingHealthySignalAt = null;
363
+ this.wsConnected = false;
364
+ this.logger(
365
+ `[watchdog] stale_ws_health; restarting fire websocket (${this.healAttempts}) (${formatFireWatchdogState({
366
+ connectedAt: this.lastConnectedAt,
367
+ lastPongAt: this.lastPongAt,
368
+ lastInboundAt: this.lastInboundAt,
369
+ })})`,
370
+ );
371
+ await this.onForceReconnect("watchdog:stale_ws_health");
372
+ return true;
373
+ }
374
+
375
+ getDebugState() {
376
+ return {
377
+ wsConnected: this.wsConnected,
378
+ lastConnectedAt: this.lastConnectedAt,
379
+ lastPongAt: this.lastPongAt,
380
+ lastInboundAt: this.lastInboundAt,
381
+ lastHealAt: this.lastHealAt,
382
+ healAttempts: this.healAttempts,
383
+ awaitingHealthySignalAt: this.awaitingHealthySignalAt,
384
+ };
385
+ }
386
+ }
387
+
194
388
  async function main() {
195
389
  const cliArgs = parseCliArgs();
196
390
  let runtimeProjectPath = process.cwd();
@@ -239,6 +433,17 @@ async function main() {
239
433
  let pendingRemoteStopEvent = null;
240
434
  let conductor = null;
241
435
  let reconnectResumeInFlight = false;
436
+ let fireShuttingDown = false;
437
+ const fireWatchdog = new FireWatchdog({
438
+ onForceReconnect: async (reason) => {
439
+ if (!conductor || typeof conductor.forceReconnect !== "function") {
440
+ return;
441
+ }
442
+ await conductor.forceReconnect(reason);
443
+ },
444
+ logger: log,
445
+ });
446
+ fireWatchdog.start();
242
447
 
243
448
  const scheduleReconnectRecovery = ({ isReconnect }) => {
244
449
  if (!isReconnect) {
@@ -274,6 +479,7 @@ async function main() {
274
479
  };
275
480
 
276
481
  const handleStopTaskCommand = async (event) => {
482
+ fireWatchdog.onInbound();
277
483
  if (!event || typeof event !== "object") {
278
484
  return;
279
485
  }
@@ -322,7 +528,19 @@ async function main() {
322
528
  projectPath: runtimeProjectPath,
323
529
  extraEnv: env,
324
530
  configFile: cliArgs.configFile,
325
- onConnected: scheduleReconnectRecovery,
531
+ onConnected: (event) => {
532
+ fireWatchdog.onConnected(event);
533
+ scheduleReconnectRecovery(event);
534
+ },
535
+ onDisconnected: (event) => {
536
+ fireWatchdog.onDisconnected();
537
+ if (!fireShuttingDown) {
538
+ log(`[fire-ws] Disconnected from backend: ${formatFireDisconnectDiagnostics(event)}`);
539
+ }
540
+ },
541
+ onPong: (event) => {
542
+ fireWatchdog.onPong(event);
543
+ },
326
544
  onStopTask: handleStopTaskCommand,
327
545
  });
328
546
 
@@ -425,11 +643,13 @@ async function main() {
425
643
  };
426
644
  const onSigint = () => {
427
645
  shutdownSignal = shutdownSignal || "SIGINT";
646
+ fireShuttingDown = true;
428
647
  signals.abort();
429
648
  requestBackendShutdown("SIGINT");
430
649
  };
431
650
  const onSigterm = () => {
432
651
  shutdownSignal = shutdownSignal || "SIGTERM";
652
+ fireShuttingDown = true;
433
653
  signals.abort();
434
654
  requestBackendShutdown("SIGTERM");
435
655
  };
@@ -494,6 +714,8 @@ async function main() {
494
714
  }
495
715
  }
496
716
  } finally {
717
+ fireShuttingDown = true;
718
+ fireWatchdog.stop();
497
719
  if (backendSession && typeof backendSession.close === "function") {
498
720
  try {
499
721
  await backendSession.close();
package/bin/conductor.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * update - Update the CLI to the latest version
11
11
  * diagnose - Diagnose a task in production/backend
12
12
  * send-file - Upload a local file into a task session
13
+ * channel - Connect user-owned chat channel providers
13
14
  */
14
15
 
15
16
  import { fileURLToPath } from "node:url";
@@ -46,7 +47,7 @@ if (argv[0] === "--version" || argv[0] === "-v") {
46
47
  const subcommand = argv[0];
47
48
 
48
49
  // Valid subcommands
49
- const validSubcommands = ["fire", "daemon", "config", "update", "diagnose", "send-file"];
50
+ const validSubcommands = ["fire", "daemon", "config", "update", "diagnose", "send-file", "channel"];
50
51
 
51
52
  if (!validSubcommands.includes(subcommand)) {
52
53
  console.error(`Error: Unknown subcommand '${subcommand}'`);
@@ -90,6 +91,7 @@ Subcommands:
90
91
  update Update the CLI to the latest version
91
92
  diagnose Diagnose a task and print likely root cause
92
93
  send-file Upload a local file into a task session
94
+ channel Connect user-owned chat channel providers
93
95
 
94
96
  Options:
95
97
  -h, --help Show this help message
@@ -101,6 +103,7 @@ Examples:
101
103
  conductor daemon --config-file ~/.conductor/config.yaml
102
104
  conductor diagnose <task-id>
103
105
  conductor send-file ./screenshot.png
106
+ conductor channel connect feishu
104
107
  conductor config
105
108
  conductor update
106
109
 
@@ -111,6 +114,7 @@ For subcommand-specific help:
111
114
  conductor update --help
112
115
  conductor diagnose --help
113
116
  conductor send-file --help
117
+ conductor channel --help
114
118
 
115
119
  Version: ${pkgJson.version}
116
120
  `);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.2.19",
4
- "gitCommitId": "346e048",
3
+ "version": "0.2.20",
4
+ "gitCommitId": "d622756",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "conductor": "bin/conductor.js"
@@ -17,8 +17,8 @@
17
17
  "test": "node --test test/*.test.js"
18
18
  },
19
19
  "dependencies": {
20
- "@love-moon/ai-sdk": "0.2.19",
21
- "@love-moon/conductor-sdk": "0.2.19",
20
+ "@love-moon/ai-sdk": "0.2.20",
21
+ "@love-moon/conductor-sdk": "0.2.20",
22
22
  "dotenv": "^16.4.5",
23
23
  "enquirer": "^2.4.1",
24
24
  "js-yaml": "^4.1.1",
@@ -28,7 +28,14 @@
28
28
  "chrome-launcher": "^1.2.1",
29
29
  "chrome-remote-interface": "^0.33.0"
30
30
  },
31
+ "optionalDependencies": {
32
+ "@roamhq/wrtc": "^0.10.0"
33
+ },
31
34
  "pnpm": {
35
+ "onlyBuiltDependencies": [
36
+ "node-pty",
37
+ "@roamhq/wrtc"
38
+ ],
32
39
  "overrides": {
33
40
  "@love-moon/ai-sdk": "file:../modules/ai-sdk",
34
41
  "@love-moon/conductor-sdk": "file:../modules/conductor-sdk"
package/src/daemon.js CHANGED
@@ -28,6 +28,7 @@ const PLAN_LIMIT_MESSAGES = {
28
28
  const DEFAULT_TERMINAL_COLS = 120;
29
29
  const DEFAULT_TERMINAL_ROWS = 40;
30
30
  const DEFAULT_TERMINAL_RING_BUFFER_MAX_BYTES = 2 * 1024 * 1024;
31
+ const DEFAULT_RTC_MODULE_CANDIDATES = ["@roamhq/wrtc", "wrtc"];
31
32
  let nodePtySpawnPromise = null;
32
33
 
33
34
  function appendDaemonLog(line) {
@@ -207,6 +208,25 @@ function normalizePositiveInt(value, fallback) {
207
208
  return fallback;
208
209
  }
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
+
210
230
  function normalizeLaunchConfig(value) {
211
231
  if (!value || typeof value !== "object" || Array.isArray(value)) {
212
232
  return {};
@@ -319,10 +339,14 @@ export function startDaemon(config = {}, deps = {}) {
319
339
  const renameSyncFn = deps.renameSync || fs.renameSync;
320
340
  const createWriteStreamFn = deps.createWriteStream || fs.createWriteStream;
321
341
  const fetchFn = deps.fetch || fetch;
342
+ const createRtcPeerConnection = deps.createRtcPeerConnection || null;
343
+ const importOptionalModule = deps.importOptionalModule || ((moduleName) => import(moduleName));
322
344
  const createWebSocketClient =
323
345
  deps.createWebSocketClient ||
324
346
  ((clientConfig, options) => new ConductorWebSocketClient(clientConfig, options));
325
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);
326
350
  const PROJECT_PATH_LOOKUP_TIMEOUT_MS = parsePositiveInt(
327
351
  process.env.CONDUCTOR_PROJECT_PATH_LOOKUP_TIMEOUT_MS,
328
352
  1500,
@@ -503,6 +527,7 @@ export function startDaemon(config = {}, deps = {}) {
503
527
  let daemonShuttingDown = false;
504
528
  const activeTaskProcesses = new Map();
505
529
  const activePtySessions = new Map();
530
+ const activePtyRtcTransports = new Map();
506
531
  const suppressedExitStatusReports = new Set();
507
532
  const seenCommandRequestIds = new Set();
508
533
  let lastConnectedAt = null;
@@ -519,6 +544,8 @@ export function startDaemon(config = {}, deps = {}) {
519
544
  let watchdogLastPresenceMismatchAt = 0;
520
545
  let watchdogAwaitingHealthySignalAt = null;
521
546
  let watchdogTimer = null;
547
+ let rtcImplementationPromise = null;
548
+ let rtcAvailabilityLogKey = null;
522
549
  const logCollector = createLogCollector(BACKEND_HTTP);
523
550
  const createPtyFn = deps.createPty || defaultCreatePty;
524
551
  const client = createWebSocketClient(sdkConfig, {
@@ -908,6 +935,230 @@ export function startDaemon(config = {}, deps = {}) {
908
935
  });
909
936
  }
910
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
+
911
1162
  function resolvePtyLaunchSpec(launchConfig, fallbackCwd) {
912
1163
  const normalizedLaunchConfig = normalizeLaunchConfig(launchConfig);
913
1164
  const entrypointType =
@@ -1025,6 +1276,54 @@ export function startDaemon(config = {}, deps = {}) {
1025
1276
  return record.outputSeq;
1026
1277
  }
1027
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
+
1028
1327
  function attachPtyStreamHandlers(taskId, record) {
1029
1328
  const writeLogChunk = (chunk) => {
1030
1329
  if (record.logStream) {
@@ -1035,13 +1334,30 @@ export function startDaemon(config = {}, deps = {}) {
1035
1334
  record.pty.onData((data) => {
1036
1335
  writeLogChunk(data);
1037
1336
  const seq = bufferTerminalOutput(record, data);
1038
- sendTerminalEvent("terminal_output", {
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 = {
1039
1349
  task_id: taskId,
1040
1350
  project_id: record.projectId,
1041
1351
  pty_session_id: record.ptySessionId,
1042
1352
  seq,
1043
1353
  data,
1044
- }).catch((err) => {
1354
+ ...(latencySample ? { latency_sample: latencySample } : {}),
1355
+ };
1356
+ sendDirectPtyPayload(taskId, {
1357
+ type: "terminal_output",
1358
+ payload: outputPayload,
1359
+ });
1360
+ sendTerminalEvent("terminal_output", outputPayload).catch((err) => {
1045
1361
  logError(`Failed to report terminal_output for ${taskId}: ${err?.message || err}`);
1046
1362
  });
1047
1363
  });
@@ -1050,6 +1366,7 @@ export function startDaemon(config = {}, deps = {}) {
1050
1366
  if (record.stopForceKillTimer) {
1051
1367
  clearTimeout(record.stopForceKillTimer);
1052
1368
  }
1369
+ cleanupPtyRtcTransport(taskId);
1053
1370
  activePtySessions.delete(taskId);
1054
1371
  if (record.logStream) {
1055
1372
  const ts = new Date().toLocaleString("sv-SE", { timeZone: "Asia/Shanghai" }).replace(" ", "T");
@@ -1235,6 +1552,7 @@ export function startDaemon(config = {}, deps = {}) {
1235
1552
  outputSeq: 0,
1236
1553
  ringBuffer: [],
1237
1554
  ringBufferByteLength: 0,
1555
+ pendingLatencySample: null,
1238
1556
  stopForceKillTimer: null,
1239
1557
  };
1240
1558
  activePtySessions.set(taskId, record);
@@ -1322,6 +1640,13 @@ export function startDaemon(config = {}, deps = {}) {
1322
1640
  if (!record || typeof record.pty.write !== "function") {
1323
1641
  return;
1324
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
+ };
1325
1650
  record.pty.write(data);
1326
1651
  }
1327
1652
 
@@ -1337,6 +1662,124 @@ export function startDaemon(config = {}, deps = {}) {
1337
1662
  // PTY sessions stay alive without viewers. Detach is currently a no-op.
1338
1663
  }
1339
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
+
1340
1783
  function handleEvent(event) {
1341
1784
  const receivedAt = Date.now();
1342
1785
  lastInboundAt = receivedAt;
@@ -1385,6 +1828,10 @@ export function startDaemon(config = {}, deps = {}) {
1385
1828
  handleTerminalDetach(event.payload);
1386
1829
  return;
1387
1830
  }
1831
+ if (event.type === "pty_transport_signal") {
1832
+ void handlePtyTransportSignal(event.payload);
1833
+ return;
1834
+ }
1388
1835
  if (event.type === "collect_logs") {
1389
1836
  void handleCollectLogs(event.payload);
1390
1837
  }
@@ -1516,6 +1963,9 @@ export function startDaemon(config = {}, deps = {}) {
1516
1963
  clearTimeout(activeRecord.stopForceKillTimer);
1517
1964
  activeRecord.stopForceKillTimer = null;
1518
1965
  }
1966
+ if (ptyRecord) {
1967
+ cleanupPtyRtcTransport(taskId);
1968
+ }
1519
1969
 
1520
1970
  if (processRecord?.child) {
1521
1971
  try {
@@ -1961,6 +2411,7 @@ export function startDaemon(config = {}, deps = {}) {
1961
2411
  if (record?.stopForceKillTimer) {
1962
2412
  clearTimeout(record.stopForceKillTimer);
1963
2413
  }
2414
+ cleanupPtyRtcTransport(taskId);
1964
2415
  try {
1965
2416
  if (typeof record.pty?.kill === "function") {
1966
2417
  record.pty.kill("SIGTERM");
@@ -2044,6 +2495,25 @@ function parsePositiveInt(value, fallback) {
2044
2495
  return fallback;
2045
2496
  }
2046
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
+
2047
2517
  function formatDisconnectDiagnostics(event) {
2048
2518
  const parts = [];
2049
2519
  const reason = typeof event?.reason === "string" && event.reason.trim()