@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.
- package/bin/conductor-channel.js +130 -0
- package/bin/conductor-config.js +1 -1
- package/bin/conductor-diagnose.js +25 -0
- package/bin/conductor-fire.js +223 -1
- package/bin/conductor.js +5 -1
- package/package.json +11 -4
- package/src/daemon.js +472 -2
|
@@ -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
|
+
}
|
package/bin/conductor-config.js
CHANGED
|
@@ -23,7 +23,7 @@ const DEFAULT_CLIs = {
|
|
|
23
23
|
},
|
|
24
24
|
codex: {
|
|
25
25
|
command: "codex",
|
|
26
|
-
execArgs: "
|
|
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;
|
package/bin/conductor-fire.js
CHANGED
|
@@ -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:
|
|
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.
|
|
4
|
-
"gitCommitId": "
|
|
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.
|
|
21
|
-
"@love-moon/conductor-sdk": "0.2.
|
|
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
|
-
|
|
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
|
-
|
|
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()
|