@love-moon/conductor-cli 0.3.2 → 0.4.1
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/CHANGELOG.md +29 -0
- package/bin/conductor-config.js +40 -3
- package/bin/conductor-fire.js +198 -5
- package/bin/conductor-update.js +2 -1
- package/package.json +10 -9
- package/src/ai-manager-handlers.js +44 -7
- package/src/custom-command-handlers.js +338 -0
- package/src/daemon.js +225 -10
- package/src/native-deps.js +54 -0
- package/src/runtime-backends.js +13 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# @love-moon/conductor-cli
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- aada753: Add explicit ChatGPT and Gemini web backend aliases, expose project icon
|
|
8
|
+
configuration in generated CLI settings, and default browser-backed session
|
|
9
|
+
checks to headed mode for reliable authenticated detection.
|
|
10
|
+
- Updated dependencies [aada753]
|
|
11
|
+
- @love-moon/ai-sdk@0.4.1
|
|
12
|
+
- @love-moon/conductor-sdk@0.4.1
|
|
13
|
+
|
|
14
|
+
## 0.4.0
|
|
15
|
+
|
|
16
|
+
### Minor Changes
|
|
17
|
+
|
|
18
|
+
- 4ecc359: Publish the chat-web browser runtime and wire it into the CLI and AI SDK for
|
|
19
|
+
ChatGPT and Gemini web sessions, including provider error handling and local
|
|
20
|
+
development installation support.
|
|
21
|
+
|
|
22
|
+
Ship app SDK realtime history catch-up and the CLI/AI SDK goal-mode and custom
|
|
23
|
+
command runtime updates included in this release.
|
|
24
|
+
|
|
25
|
+
### Patch Changes
|
|
26
|
+
|
|
27
|
+
- d83cb65: Fix pnpm-installed daemon PTY support by allowing the `node-pty` build script during pnpm CLI updates and by failing native dependency repair with a clear error when pnpm has already recorded `node-pty` under ignored builds.
|
|
28
|
+
- Updated dependencies [4ecc359]
|
|
29
|
+
- @love-moon/ai-sdk@0.4.0
|
|
30
|
+
- @love-moon/conductor-sdk@0.4.0
|
|
31
|
+
|
|
3
32
|
## 0.3.2
|
|
4
33
|
|
|
5
34
|
### Patch Changes
|
package/bin/conductor-config.js
CHANGED
|
@@ -42,6 +42,22 @@ const DEFAULT_CLIs = {
|
|
|
42
42
|
execArgs: "",
|
|
43
43
|
description: "GitHub Copilot (built in via SDK)"
|
|
44
44
|
},
|
|
45
|
+
// chat-web is the runtime backend (an in-process Chromium driver, not a CLI
|
|
46
|
+
// binary). It has multiple sub-providers selected via --model. Each user-
|
|
47
|
+
// facing alias below resolves to the chat-web runtime; advertising the bare
|
|
48
|
+
// `chat-web` backend would be ambiguous, so we emit two explicit aliases.
|
|
49
|
+
"web-chatgpt": {
|
|
50
|
+
command: "chat-web",
|
|
51
|
+
execArgs: "--model chatgpt",
|
|
52
|
+
description: "Chat web (ChatGPT) via @love-moon/chat-web",
|
|
53
|
+
runtimeBackend: "chat-web"
|
|
54
|
+
},
|
|
55
|
+
"web-gemini": {
|
|
56
|
+
command: "chat-web",
|
|
57
|
+
execArgs: "--model gemini",
|
|
58
|
+
description: "Chat web (Google AI Studio / Gemini) via @love-moon/chat-web",
|
|
59
|
+
runtimeBackend: "chat-web"
|
|
60
|
+
},
|
|
45
61
|
};
|
|
46
62
|
|
|
47
63
|
const backendUrl =
|
|
@@ -75,6 +91,13 @@ function isBuiltInCopilotAvailable() {
|
|
|
75
91
|
);
|
|
76
92
|
}
|
|
77
93
|
|
|
94
|
+
function isBuiltInChatWebAvailable() {
|
|
95
|
+
return Boolean(
|
|
96
|
+
packageJson?.dependencies?.["@love-moon/chat-web"] ||
|
|
97
|
+
packageJson?.optionalDependencies?.["@love-moon/chat-web"],
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
78
101
|
function buildConfigEntryLines(cli, info, { commented = false } = {}) {
|
|
79
102
|
const fullCommand = info.execArgs
|
|
80
103
|
? `${info.command} ${info.execArgs}`
|
|
@@ -86,6 +109,12 @@ function buildConfigEntryLines(cli, info, { commented = false } = {}) {
|
|
|
86
109
|
if (cli === "opencode") {
|
|
87
110
|
lines.push(`${commentPrefix}opencode runs via ai-sdk server mode with permission=allow`);
|
|
88
111
|
}
|
|
112
|
+
if (cli === "web-chatgpt") {
|
|
113
|
+
// web-chatgpt / web-gemini both resolve to the chat-web runtime backend
|
|
114
|
+
// (an in-process Chromium driver via @love-moon/chat-web). The command
|
|
115
|
+
// line is not executed; CLI parses --model from it.
|
|
116
|
+
lines.push(`${commentPrefix}web-chatgpt / web-gemini drive a real Chromium browser via @love-moon/chat-web`);
|
|
117
|
+
}
|
|
89
118
|
|
|
90
119
|
lines.push(`${entryPrefix}${cli}: ${fullCommand}`);
|
|
91
120
|
return lines;
|
|
@@ -213,7 +242,10 @@ async function main() {
|
|
|
213
242
|
console.log(colorize("✓ Detected the following coding CLIs:", "green"));
|
|
214
243
|
detectedCLIs.forEach((cli) => {
|
|
215
244
|
const info = DEFAULT_CLIs[cli];
|
|
216
|
-
|
|
245
|
+
// Display the alias name (key) so backends like web-chatgpt and
|
|
246
|
+
// web-gemini that share the chat-web runtime are not collapsed to a
|
|
247
|
+
// single duplicate "chat-web" line.
|
|
248
|
+
console.log(` • ${colorize(cli, "cyan")} - ${info.description}`);
|
|
217
249
|
});
|
|
218
250
|
console.log("");
|
|
219
251
|
}
|
|
@@ -287,10 +319,15 @@ function detectInstalledCLIs() {
|
|
|
287
319
|
const detected = [];
|
|
288
320
|
|
|
289
321
|
for (const [key, info] of Object.entries(DEFAULT_CLIs)) {
|
|
290
|
-
|
|
322
|
+
const runtimeBackend = info.runtimeBackend || key;
|
|
323
|
+
if (!RUNTIME_SUPPORTED_BACKENDS.includes(runtimeBackend)) {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (runtimeBackend === "copilot" && isBuiltInCopilotAvailable()) {
|
|
327
|
+
detected.push(key);
|
|
291
328
|
continue;
|
|
292
329
|
}
|
|
293
|
-
if (
|
|
330
|
+
if (runtimeBackend === "chat-web" && isBuiltInChatWebAvailable()) {
|
|
294
331
|
detected.push(key);
|
|
295
332
|
continue;
|
|
296
333
|
}
|
package/bin/conductor-fire.js
CHANGED
|
@@ -926,6 +926,19 @@ async function main() {
|
|
|
926
926
|
const currentRefreshSessionRequest = pendingRefreshSessionRequest;
|
|
927
927
|
let runnerError = null;
|
|
928
928
|
|
|
929
|
+
// Per-message /goal detection: sessions for goal-capable backends
|
|
930
|
+
// (claude, codex) must boot with `goalMode: true` so the underlying
|
|
931
|
+
// transport advertises `capabilities.goal === true`. Codex specifically
|
|
932
|
+
// needs `--enable goals` at boot — it cannot be flipped on later. At
|
|
933
|
+
// dispatch time, BridgeRunner.dispatchBackendTurn parses each user
|
|
934
|
+
// message for a leading `/goal` directive and routes to runGoal vs
|
|
935
|
+
// runTurn accordingly.
|
|
936
|
+
const backendForGoalCheck = String(
|
|
937
|
+
cliArgs.sessionBackend || cliArgs.backend || "",
|
|
938
|
+
)
|
|
939
|
+
.trim()
|
|
940
|
+
.toLowerCase();
|
|
941
|
+
const enableGoalsForBackend = GOAL_CAPABLE_BACKENDS.includes(backendForGoalCheck);
|
|
929
942
|
backendSession = createAiSession(cliArgs.sessionBackend || cliArgs.backend, {
|
|
930
943
|
initialImages: cliArgs.initialImages,
|
|
931
944
|
cwd: runtimeProjectPath,
|
|
@@ -933,6 +946,7 @@ async function main() {
|
|
|
933
946
|
configFile: cliArgs.configFile,
|
|
934
947
|
...(cliArgs.sessionOptions || {}),
|
|
935
948
|
...(sessionCommandLine ? { commandLine: sessionCommandLine } : {}),
|
|
949
|
+
...(enableGoalsForBackend ? { goalMode: true } : {}),
|
|
936
950
|
logger: { log },
|
|
937
951
|
sessionStoreKey: taskContext.taskId ? `task-${taskContext.taskId}` : undefined,
|
|
938
952
|
resumePersistedSession: Boolean(!nextResumeSessionId && taskContext.taskId),
|
|
@@ -1392,14 +1406,84 @@ function normalizeTaskId(value) {
|
|
|
1392
1406
|
return value.trim();
|
|
1393
1407
|
}
|
|
1394
1408
|
|
|
1395
|
-
function resolveFireStateDir(workingDirectory
|
|
1409
|
+
function resolveFireStateDir(workingDirectory) {
|
|
1410
|
+
// Priority:
|
|
1411
|
+
// 1. CONDUCTOR_FIRE_STATE_DIR env override (used by tests to isolate the
|
|
1412
|
+
// marker dir into a tmpdir; also handy for ops to relocate state).
|
|
1413
|
+
// 2. Explicit `workingDirectory` argument (legacy callers).
|
|
1414
|
+
// 3. ~/.conductor/state — matches the conductor convention used by the
|
|
1415
|
+
// session bootstrap locks (see line ~311).
|
|
1416
|
+
//
|
|
1417
|
+
// We deliberately do NOT default to process.cwd(), which would pollute
|
|
1418
|
+
// every project directory a user runs `conductor fire` from and cause tests
|
|
1419
|
+
// to leak marker files into the repo.
|
|
1420
|
+
const envOverride =
|
|
1421
|
+
typeof process.env.CONDUCTOR_FIRE_STATE_DIR === "string" &&
|
|
1422
|
+
process.env.CONDUCTOR_FIRE_STATE_DIR.trim()
|
|
1423
|
+
? process.env.CONDUCTOR_FIRE_STATE_DIR.trim()
|
|
1424
|
+
: "";
|
|
1425
|
+
if (envOverride) {
|
|
1426
|
+
return path.resolve(envOverride);
|
|
1427
|
+
}
|
|
1396
1428
|
const baseDir =
|
|
1397
1429
|
typeof workingDirectory === "string" && workingDirectory.trim()
|
|
1398
1430
|
? path.resolve(workingDirectory.trim())
|
|
1399
|
-
:
|
|
1431
|
+
: os.homedir();
|
|
1400
1432
|
return path.join(baseDir, ".conductor", "state");
|
|
1401
1433
|
}
|
|
1402
1434
|
|
|
1435
|
+
/**
|
|
1436
|
+
* Backends that ship a native goal protocol via the ai-sdk. Sessions for
|
|
1437
|
+
* these backends MUST boot with `goalMode: true` so the underlying transport
|
|
1438
|
+
* advertises `capabilities.goal === true`. Per-message `/goal` detection then
|
|
1439
|
+
* routes to runGoal vs runTurn at dispatch time.
|
|
1440
|
+
*
|
|
1441
|
+
* Codex specifically requires `--enable goals` at boot — it cannot be flipped
|
|
1442
|
+
* on dynamically — so we always enable goals on session creation for these
|
|
1443
|
+
* backends regardless of whether the first user message contains `/goal`.
|
|
1444
|
+
*
|
|
1445
|
+
* Sibling lists that must be kept in sync:
|
|
1446
|
+
* - `web/src/app/api/issues/goal.ts` → `GOAL_CAPABLE_BACKENDS` Set
|
|
1447
|
+
* - `modules/ai-sdk/src/providers/*.js` → `static capabilities.goal`
|
|
1448
|
+
*
|
|
1449
|
+
* If you add a new goal-capable backend, update ALL THREE locations and run
|
|
1450
|
+
* `cd cli && pnpm test` + `cd web && pnpm test`.
|
|
1451
|
+
*/
|
|
1452
|
+
export const GOAL_CAPABLE_BACKENDS = ["claude", "codex"];
|
|
1453
|
+
|
|
1454
|
+
/**
|
|
1455
|
+
* Per-message `/goal` detector. Matches the semantics of
|
|
1456
|
+
* `web/src/app/api/issues/goal.ts::parseGoalDirective`:
|
|
1457
|
+
*
|
|
1458
|
+
* - First non-empty line is `/goal` (case-insensitive), optionally with
|
|
1459
|
+
* inline text after the keyword and a space.
|
|
1460
|
+
* - Objective = inline text + "\n\n" + remaining body, trimmed.
|
|
1461
|
+
* - Returns null if no directive or empty objective.
|
|
1462
|
+
*/
|
|
1463
|
+
export function parseGoalDirectiveFromMessage(content) {
|
|
1464
|
+
if (typeof content !== "string") {
|
|
1465
|
+
return null;
|
|
1466
|
+
}
|
|
1467
|
+
const lines = content.split("\n");
|
|
1468
|
+
let i = 0;
|
|
1469
|
+
while (i < lines.length && lines[i].trim() === "") {
|
|
1470
|
+
i += 1;
|
|
1471
|
+
}
|
|
1472
|
+
if (i >= lines.length) {
|
|
1473
|
+
return null;
|
|
1474
|
+
}
|
|
1475
|
+
const firstLine = lines[i];
|
|
1476
|
+
const match = firstLine.trim().match(/^\/goal(?:\s+(.*))?$/i);
|
|
1477
|
+
if (!match) {
|
|
1478
|
+
return null;
|
|
1479
|
+
}
|
|
1480
|
+
const inline = (match[1] || "").trim();
|
|
1481
|
+
const rest = lines.slice(i + 1).join("\n").trim();
|
|
1482
|
+
const parts = [inline, rest].filter(Boolean);
|
|
1483
|
+
const objective = parts.join("\n\n").trim();
|
|
1484
|
+
return objective ? { objective } : null;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1403
1487
|
export function injectResolvedTaskId(taskId, env = process.env) {
|
|
1404
1488
|
const normalizedTaskId = normalizeTaskId(taskId);
|
|
1405
1489
|
if (!normalizedTaskId) {
|
|
@@ -1882,6 +1966,7 @@ export class BridgeRunner {
|
|
|
1882
1966
|
this.pendingInterruptRetryTimers = new Map();
|
|
1883
1967
|
this.activeTurnReplyTo = "";
|
|
1884
1968
|
this.sessionAnnouncementSent = false;
|
|
1969
|
+
this.sessionAnnouncementSubscribed = false;
|
|
1885
1970
|
this.boundSessionId = "";
|
|
1886
1971
|
this.errorLoop = null;
|
|
1887
1972
|
this.errorLoopWindowMs = getBoundedEnvInt(
|
|
@@ -1927,6 +2012,30 @@ export class BridgeRunner {
|
|
|
1927
2012
|
log(`[copilot-debug] task=${this.taskId} ${message}`);
|
|
1928
2013
|
}
|
|
1929
2014
|
|
|
2015
|
+
/**
|
|
2016
|
+
* Listen for follow-up `session` events from the backend so that a
|
|
2017
|
+
* provider with a deferred session id (currently only chat-web) can
|
|
2018
|
+
* tell us "the real conversation id is ready, please announce now".
|
|
2019
|
+
*
|
|
2020
|
+
* Idempotent — repeated calls only register the listener once.
|
|
2021
|
+
* Single-use semantics on the daemon side: after the first announce
|
|
2022
|
+
* succeeds (sessionAnnouncementSent flips true), subsequent session
|
|
2023
|
+
* events are ignored by `announceBackendSession()`.
|
|
2024
|
+
*/
|
|
2025
|
+
subscribeToBackendSessionUpdates() {
|
|
2026
|
+
if (this.sessionAnnouncementSubscribed) return;
|
|
2027
|
+
if (!this.backendSession || typeof this.backendSession.on !== "function") return;
|
|
2028
|
+
this.sessionAnnouncementSubscribed = true;
|
|
2029
|
+
this.backendSession.on("session", () => {
|
|
2030
|
+
if (this.sessionAnnouncementSent) return;
|
|
2031
|
+
void this.announceBackendSession().catch((error) => {
|
|
2032
|
+
this.copilotLog(
|
|
2033
|
+
`session re-announce failed: ${sanitizeForLog(error?.message || error, 160)}`,
|
|
2034
|
+
);
|
|
2035
|
+
});
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
2038
|
+
|
|
1930
2039
|
async announceBackendSession() {
|
|
1931
2040
|
if (this.sessionAnnouncementSent) {
|
|
1932
2041
|
return;
|
|
@@ -1941,6 +2050,17 @@ export class BridgeRunner {
|
|
|
1941
2050
|
this.copilotLog(`session announce skipped: ${sanitizeForLog(error?.message || error, 160)}`);
|
|
1942
2051
|
return;
|
|
1943
2052
|
}
|
|
2053
|
+
// chat-web's session id is the provider-side conversation id
|
|
2054
|
+
// (e.g. ChatGPT /c/{uuid}), which only materialises AFTER the first
|
|
2055
|
+
// turn. The backend exposes `sessionIdDeferred: true` to ask us to
|
|
2056
|
+
// hold the "session started" message until the real id lands.
|
|
2057
|
+
// `subscribeToBackendSessionUpdates()` arms a one-shot listener that
|
|
2058
|
+
// re-runs this announce once the real id is emitted.
|
|
2059
|
+
if (sessionInfo && sessionInfo.sessionIdDeferred === true) {
|
|
2060
|
+
this.subscribeToBackendSessionUpdates();
|
|
2061
|
+
this.copilotLog("session announce deferred: backend reports sessionIdDeferred=true");
|
|
2062
|
+
return;
|
|
2063
|
+
}
|
|
1944
2064
|
const discoveredSessionId = String(sessionInfo?.sessionId || "").trim();
|
|
1945
2065
|
const fallbackSessionId = this.resumeSessionId;
|
|
1946
2066
|
const sessionId = discoveredSessionId || fallbackSessionId;
|
|
@@ -2618,8 +2738,10 @@ export class BridgeRunner {
|
|
|
2618
2738
|
const statusLine = payload.status_line ? String(payload.status_line) : "";
|
|
2619
2739
|
const statusDoneLine = payload.status_done_line ? String(payload.status_done_line) : "";
|
|
2620
2740
|
const replyPreview = payload.reply_preview ? String(payload.reply_preview) : "";
|
|
2741
|
+
const aiModeRaw = typeof payload.aiMode === "string" ? payload.aiMode.trim().toLowerCase() : "";
|
|
2742
|
+
const aiMode = aiModeRaw === "goal" || aiModeRaw === "turn" ? aiModeRaw : "";
|
|
2621
2743
|
|
|
2622
|
-
if (!state && !phase && !statusLine && !statusDoneLine && !replyPreview) {
|
|
2744
|
+
if (!state && !phase && !statusLine && !statusDoneLine && !replyPreview && !aiMode) {
|
|
2623
2745
|
return null;
|
|
2624
2746
|
}
|
|
2625
2747
|
|
|
@@ -2633,6 +2755,7 @@ export class BridgeRunner {
|
|
|
2633
2755
|
reply_preview: truncateText(replyPreview, 240) || undefined,
|
|
2634
2756
|
reply_to: replyTo,
|
|
2635
2757
|
backend: this.backendName,
|
|
2758
|
+
aiMode: aiMode || undefined,
|
|
2636
2759
|
thread_id:
|
|
2637
2760
|
String(
|
|
2638
2761
|
payload.thread_id || payload.session_id || runtimeContext?.session_id || "",
|
|
@@ -2948,7 +3071,7 @@ export class BridgeRunner {
|
|
|
2948
3071
|
);
|
|
2949
3072
|
}
|
|
2950
3073
|
|
|
2951
|
-
const turnPromise = this.
|
|
3074
|
+
const turnPromise = this.dispatchBackendTurn(content, {
|
|
2952
3075
|
useInitialImages,
|
|
2953
3076
|
onProgress: (payload) => {
|
|
2954
3077
|
void this.reportRuntimeStatus(payload, replyTo);
|
|
@@ -3105,6 +3228,76 @@ export class BridgeRunner {
|
|
|
3105
3228
|
});
|
|
3106
3229
|
}
|
|
3107
3230
|
|
|
3231
|
+
/**
|
|
3232
|
+
* Emit a runtime-status update carrying the per-turn aiMode so the frontend
|
|
3233
|
+
* can show "goal" vs "normal" in real time. Best-effort: failure to emit must
|
|
3234
|
+
* not block the actual dispatch.
|
|
3235
|
+
*/
|
|
3236
|
+
async emitAiModeStatus(mode) {
|
|
3237
|
+
const normalized = mode === "goal" ? "goal" : "turn";
|
|
3238
|
+
try {
|
|
3239
|
+
await this.reportRuntimeStatus({ aiMode: normalized });
|
|
3240
|
+
} catch (error) {
|
|
3241
|
+
log(`[ai-mode] failed to emit aiMode=${normalized}: ${error?.message || error}`);
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
|
|
3245
|
+
/**
|
|
3246
|
+
* Execute the next turn against the backend session. Each user message is
|
|
3247
|
+
* inspected for a leading `/goal` directive (see
|
|
3248
|
+
* `parseGoalDirectiveFromMessage`). When present AND the underlying session
|
|
3249
|
+
* advertises `capabilities.goal === true`, we route through
|
|
3250
|
+
* `session.runGoal()`; otherwise we fall back to `session.runTurn()` so a
|
|
3251
|
+
* `/goal` typed at a non-goal-capable backend degrades gracefully into a
|
|
3252
|
+
* normal message.
|
|
3253
|
+
*
|
|
3254
|
+
* Returns the same `{ text, items, usage, metadata, ... }` shape as runTurn
|
|
3255
|
+
* so callers can keep their existing event-stream / persistence logic.
|
|
3256
|
+
*/
|
|
3257
|
+
async dispatchBackendTurn(content, options = {}) {
|
|
3258
|
+
const goalDirective = parseGoalDirectiveFromMessage(content);
|
|
3259
|
+
const snapshot =
|
|
3260
|
+
typeof this.backendSession?.getSnapshot === "function"
|
|
3261
|
+
? this.backendSession.getSnapshot()
|
|
3262
|
+
: null;
|
|
3263
|
+
const goalCapable = Boolean(
|
|
3264
|
+
snapshot && snapshot.capabilities && snapshot.capabilities.goal === true,
|
|
3265
|
+
);
|
|
3266
|
+
const willRunGoal =
|
|
3267
|
+
goalDirective != null &&
|
|
3268
|
+
goalCapable &&
|
|
3269
|
+
typeof this.backendSession?.runGoal === "function";
|
|
3270
|
+
|
|
3271
|
+
// Best-effort runtime-status emit. Failure must NOT block the dispatch.
|
|
3272
|
+
try {
|
|
3273
|
+
await this.emitAiModeStatus(willRunGoal ? "goal" : "turn");
|
|
3274
|
+
} catch {
|
|
3275
|
+
// best-effort
|
|
3276
|
+
}
|
|
3277
|
+
|
|
3278
|
+
if (willRunGoal) {
|
|
3279
|
+
const goalResult = await this.backendSession.runGoal(
|
|
3280
|
+
{ objective: goalDirective.objective, source: { type: "manual" } },
|
|
3281
|
+
options,
|
|
3282
|
+
);
|
|
3283
|
+
// Goal SDK is expected to return `{ text, goal, usage, metadata }`. We
|
|
3284
|
+
// normalize back into the runTurn-compatible shape downstream consumers
|
|
3285
|
+
// expect (text, items, usage, metadata, provider, events).
|
|
3286
|
+
return {
|
|
3287
|
+
text: goalResult?.text || "",
|
|
3288
|
+
items: Array.isArray(goalResult?.items) ? goalResult.items : [],
|
|
3289
|
+
usage: goalResult?.usage || null,
|
|
3290
|
+
provider: goalResult?.provider || this.backendName,
|
|
3291
|
+
events: Array.isArray(goalResult?.events) ? goalResult.events : [],
|
|
3292
|
+
metadata: {
|
|
3293
|
+
...(goalResult?.metadata || {}),
|
|
3294
|
+
...(goalResult?.goal ? { goal: goalResult.goal } : {}),
|
|
3295
|
+
},
|
|
3296
|
+
};
|
|
3297
|
+
}
|
|
3298
|
+
return this.backendSession.runTurn(content, options);
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3108
3301
|
async handlePrePromptMessage(content) {
|
|
3109
3302
|
const text = typeof content === "string" ? content.trim() : "";
|
|
3110
3303
|
if (!text) {
|
|
@@ -3169,7 +3362,7 @@ export class BridgeRunner {
|
|
|
3169
3362
|
}
|
|
3170
3363
|
}
|
|
3171
3364
|
try {
|
|
3172
|
-
const result = await this.
|
|
3365
|
+
const result = await this.dispatchBackendTurn(content, {
|
|
3173
3366
|
useInitialImages: Boolean(includeImages),
|
|
3174
3367
|
onProgress: (payload) => {
|
|
3175
3368
|
void this.reportRuntimeStatus(payload, replyTarget);
|
package/bin/conductor-update.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
detectPackageManager,
|
|
20
20
|
} from "../src/version-check.js";
|
|
21
21
|
import {
|
|
22
|
+
buildPnpmAllowBuildArgs,
|
|
22
23
|
ensurePnpmOnlyBuiltDependencies,
|
|
23
24
|
repairAndVerifyGlobalNodePty,
|
|
24
25
|
} from "../src/native-deps.js";
|
|
@@ -177,7 +178,7 @@ async function performUpdate() {
|
|
|
177
178
|
switch (packageManager) {
|
|
178
179
|
case "pnpm":
|
|
179
180
|
cmd = "pnpm";
|
|
180
|
-
args = ["add", "-g", `${PACKAGE_NAME}@latest`];
|
|
181
|
+
args = ["add", "-g", ...buildPnpmAllowBuildArgs(["node-pty"]), `${PACKAGE_NAME}@latest`];
|
|
181
182
|
break;
|
|
182
183
|
case "yarn":
|
|
183
184
|
cmd = "yarn";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@love-moon/conductor-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"gitCommitId": "
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"gitCommitId": "ff00acf",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/lovemoon-ai/conductor.git"
|
|
@@ -23,9 +23,8 @@
|
|
|
23
23
|
"test": "node --test test/*.test.js"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@love-moon/ai-
|
|
27
|
-
"@love-moon/
|
|
28
|
-
"@love-moon/conductor-sdk": "0.3.2",
|
|
26
|
+
"@love-moon/ai-sdk": "0.4.1",
|
|
27
|
+
"@love-moon/conductor-sdk": "0.4.1",
|
|
29
28
|
"@github/copilot-sdk": "^0.2.2",
|
|
30
29
|
"chrome-launcher": "^1.2.1",
|
|
31
30
|
"chrome-remote-interface": "^0.33.0",
|
|
@@ -37,17 +36,19 @@
|
|
|
37
36
|
"yargs": "^17.7.2"
|
|
38
37
|
},
|
|
39
38
|
"optionalDependencies": {
|
|
40
|
-
"@roamhq/wrtc": "^0.10.0"
|
|
39
|
+
"@roamhq/wrtc": "^0.10.0",
|
|
40
|
+
"@love-moon/chat-web": "0.4.1"
|
|
41
41
|
},
|
|
42
42
|
"pnpm": {
|
|
43
43
|
"onlyBuiltDependencies": [
|
|
44
44
|
"node-pty",
|
|
45
|
-
"@roamhq/wrtc"
|
|
45
|
+
"@roamhq/wrtc",
|
|
46
|
+
"playwright"
|
|
46
47
|
],
|
|
47
48
|
"overrides": {
|
|
48
49
|
"@love-moon/ai-sdk": "file:../modules/ai-sdk",
|
|
49
|
-
"@love-moon/
|
|
50
|
-
"@love-moon/
|
|
50
|
+
"@love-moon/conductor-sdk": "file:../modules/conductor-sdk",
|
|
51
|
+
"@love-moon/chat-web": "file:../modules/chat-web"
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
// Daemon-side glue between the realtime WebSocket and the @love-moon/ai-manager
|
|
1
|
+
// Daemon-side glue between the realtime WebSocket and the @love-moon/ai-sdk manager facade.
|
|
2
2
|
// The web backend sends `ai_manager_request`; we dispatch by `action`, run it, and
|
|
3
3
|
// reply with `ai_manager_response` carrying the same `request_id`.
|
|
4
4
|
|
|
5
|
-
import { AiManager } from "@love-moon/ai-
|
|
5
|
+
import { AiManager } from "@love-moon/ai-sdk";
|
|
6
6
|
|
|
7
7
|
const VALID_ACTIONS = new Set(["status", "quota", "list_accounts", "switch_account"]);
|
|
8
|
+
const BASE_QUOTA_TOOLS = ["codex", "claude", "kimi", "copilot"];
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* @param {object} opts
|
|
11
|
-
* @param {string} [opts.configPath] Path to ~/.conductor/config.yaml. Defaults to
|
|
12
|
+
* @param {string} [opts.configPath] Path to ~/.conductor/config.yaml. Defaults to the manager facade default.
|
|
12
13
|
*/
|
|
13
14
|
export function createAiManagerHandlers(opts = {}) {
|
|
14
15
|
const manager = new AiManager(opts.configPath ? { configPath: opts.configPath } : undefined);
|
|
@@ -69,10 +70,30 @@ export function createAiManagerHandlers(opts = {}) {
|
|
|
69
70
|
addJob("copilot", () => manager.getCopilotQuota({
|
|
70
71
|
forceRefresh,
|
|
71
72
|
}));
|
|
73
|
+
const externalBackends = pickExternalQuotaBackends(args);
|
|
74
|
+
for (const backend of externalBackends) {
|
|
75
|
+
if (!tools.has(backend)) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
jobs.push((async () => {
|
|
79
|
+
try {
|
|
80
|
+
const result = await manager.getExternalQuotaList({ backend, forceRefresh });
|
|
81
|
+
return ["external", backend, result];
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return ["external", backend, { backend, error: errMsg(err), source: "unknown" }];
|
|
84
|
+
}
|
|
85
|
+
})());
|
|
86
|
+
}
|
|
72
87
|
|
|
73
88
|
const entries = await Promise.all(jobs);
|
|
74
|
-
for (const
|
|
75
|
-
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
const [kind, key, result] = entry;
|
|
91
|
+
if (kind === "external") {
|
|
92
|
+
out.external = out.external || {};
|
|
93
|
+
out.external[key] = result;
|
|
94
|
+
} else {
|
|
95
|
+
out[kind] = key;
|
|
96
|
+
}
|
|
76
97
|
}
|
|
77
98
|
return out;
|
|
78
99
|
}
|
|
@@ -169,8 +190,24 @@ export async function handleAiManagerRequest(client, handlers, payload) {
|
|
|
169
190
|
|
|
170
191
|
function pickToolFilter(args = {}) {
|
|
171
192
|
const tool = typeof args.tool === "string" ? args.tool.trim().toLowerCase() : "";
|
|
172
|
-
if (tool
|
|
173
|
-
return new Set([
|
|
193
|
+
if (tool) return new Set([tool]);
|
|
194
|
+
return new Set([...BASE_QUOTA_TOOLS, ...pickExternalQuotaBackends(args)]);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function pickExternalQuotaBackends(args = {}) {
|
|
198
|
+
const fromArray = Array.isArray(args.externalQuotaBackends) ? args.externalQuotaBackends : [];
|
|
199
|
+
const fromString = typeof args.externalQuotaBackends === "string"
|
|
200
|
+
? args.externalQuotaBackends.split(",")
|
|
201
|
+
: [];
|
|
202
|
+
const backends = [...fromArray, ...fromString]
|
|
203
|
+
.map((item) => String(item || "").trim().toLowerCase())
|
|
204
|
+
.filter(Boolean)
|
|
205
|
+
.filter((backend) => !BASE_QUOTA_TOOLS.includes(backend));
|
|
206
|
+
const tool = typeof args.tool === "string" ? args.tool.trim().toLowerCase() : "";
|
|
207
|
+
if (tool && !BASE_QUOTA_TOOLS.includes(tool)) {
|
|
208
|
+
backends.push(tool);
|
|
209
|
+
}
|
|
210
|
+
return [...new Set(backends)];
|
|
174
211
|
}
|
|
175
212
|
|
|
176
213
|
function errMsg(err) {
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { promises as fsp } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
import yaml from "js-yaml";
|
|
9
|
+
|
|
10
|
+
const VALID_ACTIONS = new Set(["list", "run", "status"]);
|
|
11
|
+
const MAX_TAIL_CHARS = 12_000;
|
|
12
|
+
const MAX_RUNS = 200;
|
|
13
|
+
export const CUSTOM_COMMANDS_CAPABILITY = "custom_commands";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {{
|
|
17
|
+
* key: string;
|
|
18
|
+
* scriptPath: string;
|
|
19
|
+
* }} CustomCommand
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} opts
|
|
24
|
+
* @param {string} [opts.configPath]
|
|
25
|
+
* @param {typeof spawn} [opts.spawnFn]
|
|
26
|
+
*/
|
|
27
|
+
export function createCustomCommandHandlers(opts = {}) {
|
|
28
|
+
const configPath = opts.configPath || path.join(os.homedir(), ".conductor", "config.yaml");
|
|
29
|
+
const spawnFn = opts.spawnFn || spawn;
|
|
30
|
+
const runs = new Map();
|
|
31
|
+
const runningByKey = new Map();
|
|
32
|
+
|
|
33
|
+
async function list() {
|
|
34
|
+
const commands = await loadCustomCommands(configPath);
|
|
35
|
+
return {
|
|
36
|
+
commands: commands.map((command) => {
|
|
37
|
+
const runId = runningByKey.get(command.key) || null;
|
|
38
|
+
return {
|
|
39
|
+
key: command.key,
|
|
40
|
+
running: Boolean(runId),
|
|
41
|
+
...(runId ? { runId } : {}),
|
|
42
|
+
};
|
|
43
|
+
}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function run(args = {}) {
|
|
48
|
+
const key = normalizeCommandKey(args.key);
|
|
49
|
+
if (!key) {
|
|
50
|
+
throw new Error("run requires a `key` string");
|
|
51
|
+
}
|
|
52
|
+
const existingRunId = runningByKey.get(key);
|
|
53
|
+
if (existingRunId) {
|
|
54
|
+
const existing = runs.get(existingRunId);
|
|
55
|
+
throw new Error(`custom command ${key} is already running as ${existing?.runId || existingRunId}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const commands = await loadCustomCommands(configPath);
|
|
59
|
+
const command = commands.find((entry) => entry.key === key);
|
|
60
|
+
if (!command) {
|
|
61
|
+
throw new Error(`custom command not found: ${key}`);
|
|
62
|
+
}
|
|
63
|
+
await validateScript(command.scriptPath);
|
|
64
|
+
|
|
65
|
+
const runId = randomUUID();
|
|
66
|
+
const startedAt = new Date().toISOString();
|
|
67
|
+
const runState = {
|
|
68
|
+
runId,
|
|
69
|
+
key,
|
|
70
|
+
status: "running",
|
|
71
|
+
pid: null,
|
|
72
|
+
exitCode: null,
|
|
73
|
+
signal: null,
|
|
74
|
+
stdoutTail: "",
|
|
75
|
+
stderrTail: "",
|
|
76
|
+
error: null,
|
|
77
|
+
startedAt,
|
|
78
|
+
finishedAt: null,
|
|
79
|
+
};
|
|
80
|
+
rememberRun(runs, runState);
|
|
81
|
+
runningByKey.set(key, runId);
|
|
82
|
+
|
|
83
|
+
const child = spawnFn(command.scriptPath, [], {
|
|
84
|
+
cwd: path.dirname(command.scriptPath),
|
|
85
|
+
env: buildScriptEnv(configPath, key),
|
|
86
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
runState.pid = typeof child.pid === "number" ? child.pid : null;
|
|
90
|
+
|
|
91
|
+
child.stdout?.on("data", (chunk) => {
|
|
92
|
+
runState.stdoutTail = appendTail(runState.stdoutTail, chunk);
|
|
93
|
+
});
|
|
94
|
+
child.stderr?.on("data", (chunk) => {
|
|
95
|
+
runState.stderrTail = appendTail(runState.stderrTail, chunk);
|
|
96
|
+
});
|
|
97
|
+
child.on("error", (error) => {
|
|
98
|
+
finishRun(runState, runningByKey, "failed", {
|
|
99
|
+
error: error instanceof Error ? error.message : String(error),
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
child.on("close", (code, signal) => {
|
|
103
|
+
finishRun(runState, runningByKey, code === 0 ? "completed" : "failed", {
|
|
104
|
+
exitCode: typeof code === "number" ? code : null,
|
|
105
|
+
signal: signal || null,
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return toPublicRun(runState, { includeOutput: false, started: true });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function status(args = {}) {
|
|
113
|
+
const runId = typeof args.runId === "string" ? args.runId.trim() : "";
|
|
114
|
+
if (!runId) {
|
|
115
|
+
throw new Error("status requires a `runId` string");
|
|
116
|
+
}
|
|
117
|
+
const runState = runs.get(runId);
|
|
118
|
+
if (!runState) {
|
|
119
|
+
throw new Error(`custom command run not found: ${runId}`);
|
|
120
|
+
}
|
|
121
|
+
return toPublicRun(runState, { includeOutput: true });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Run a single action and return a `result` object, never throwing.
|
|
126
|
+
* @param {{action:string,args?:object}} payload
|
|
127
|
+
*/
|
|
128
|
+
async function dispatch(payload) {
|
|
129
|
+
const action = payload?.action;
|
|
130
|
+
if (!VALID_ACTIONS.has(action)) {
|
|
131
|
+
return { error: `unknown action: ${action}` };
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
switch (action) {
|
|
135
|
+
case "list":
|
|
136
|
+
return { result: await list() };
|
|
137
|
+
case "run":
|
|
138
|
+
return { result: await run(payload?.args ?? {}) };
|
|
139
|
+
case "status":
|
|
140
|
+
return { result: await status(payload?.args ?? {}) };
|
|
141
|
+
default:
|
|
142
|
+
return { error: `unhandled action: ${action}` };
|
|
143
|
+
}
|
|
144
|
+
} catch (err) {
|
|
145
|
+
return { error: errMsg(err) };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { dispatch, configPath, runs, runningByKey };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {object} client
|
|
154
|
+
* @param {ReturnType<typeof createCustomCommandHandlers>} handlers
|
|
155
|
+
* @param {object} payload
|
|
156
|
+
*/
|
|
157
|
+
export async function handleCustomCommandsRequest(client, handlers, payload) {
|
|
158
|
+
const requestId = payload?.request_id ? String(payload.request_id) : "";
|
|
159
|
+
const action = payload?.action ? String(payload.action) : "";
|
|
160
|
+
if (!requestId) {
|
|
161
|
+
return { error: "missing request_id" };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const response = await handlers.dispatch({
|
|
165
|
+
action,
|
|
166
|
+
args: payload?.args && typeof payload.args === "object" ? payload.args : {},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const outgoing = {
|
|
170
|
+
type: "custom_commands_response",
|
|
171
|
+
payload: {
|
|
172
|
+
request_id: requestId,
|
|
173
|
+
action,
|
|
174
|
+
...(response?.error ? { error: response.error } : { result: response?.result }),
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
await client.sendJson(outgoing).catch(() => {});
|
|
178
|
+
return response;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* @param {string} configPath
|
|
183
|
+
* @returns {Promise<CustomCommand[]>}
|
|
184
|
+
*/
|
|
185
|
+
export async function loadCustomCommands(configPath) {
|
|
186
|
+
let raw = "";
|
|
187
|
+
try {
|
|
188
|
+
raw = await fsp.readFile(configPath, "utf8");
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error?.code === "ENOENT") {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const parsed = yaml.load(raw);
|
|
197
|
+
return parseCustomCommandsConfig(parsed, configPath);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param {unknown} config
|
|
202
|
+
* @param {string} configPath
|
|
203
|
+
* @returns {CustomCommand[]}
|
|
204
|
+
*/
|
|
205
|
+
export function parseCustomCommandsConfig(config, configPath = path.join(os.homedir(), ".conductor", "config.yaml")) {
|
|
206
|
+
const root = config && typeof config === "object" ? config : {};
|
|
207
|
+
const rawCommands = root.custom_commands;
|
|
208
|
+
if (rawCommands === undefined || rawCommands === null) {
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
if (Array.isArray(rawCommands) || typeof rawCommands !== "object") {
|
|
212
|
+
throw new Error("custom_commands must be a mapping from key to script path");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const commands = [];
|
|
216
|
+
const seen = new Set();
|
|
217
|
+
for (const [rawKey, rawValue] of Object.entries(rawCommands)) {
|
|
218
|
+
const key = normalizeCommandKey(rawKey);
|
|
219
|
+
if (!key) {
|
|
220
|
+
throw new Error("custom_commands contains an invalid empty key");
|
|
221
|
+
}
|
|
222
|
+
if (seen.has(key)) {
|
|
223
|
+
throw new Error(`duplicate custom command key: ${key}`);
|
|
224
|
+
}
|
|
225
|
+
seen.add(key);
|
|
226
|
+
if (typeof rawValue !== "string" || !rawValue.trim()) {
|
|
227
|
+
throw new Error(`custom command ${key} must point to a script path string`);
|
|
228
|
+
}
|
|
229
|
+
commands.push({
|
|
230
|
+
key,
|
|
231
|
+
scriptPath: resolveScriptPath(rawValue, configPath),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return commands;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function normalizeCommandKey(value) {
|
|
238
|
+
if (typeof value !== "string") {
|
|
239
|
+
return "";
|
|
240
|
+
}
|
|
241
|
+
const key = value.trim();
|
|
242
|
+
if (!key || key.length > 80 || /[\x00-\x1F\x7F/\\]/.test(key)) {
|
|
243
|
+
return "";
|
|
244
|
+
}
|
|
245
|
+
return key;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function resolveScriptPath(value, configPath) {
|
|
249
|
+
const raw = value.trim();
|
|
250
|
+
const expanded = raw === "~" || raw.startsWith("~/")
|
|
251
|
+
? path.join(os.homedir(), raw.slice(2))
|
|
252
|
+
: raw;
|
|
253
|
+
if (path.isAbsolute(expanded)) {
|
|
254
|
+
return path.normalize(expanded);
|
|
255
|
+
}
|
|
256
|
+
return path.resolve(path.dirname(configPath), expanded);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function validateScript(scriptPath) {
|
|
260
|
+
let stat;
|
|
261
|
+
try {
|
|
262
|
+
stat = await fsp.stat(scriptPath);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (error?.code === "ENOENT") {
|
|
265
|
+
throw new Error(`custom command script not found: ${scriptPath}`);
|
|
266
|
+
}
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
if (!stat.isFile()) {
|
|
270
|
+
throw new Error(`custom command script is not a file: ${scriptPath}`);
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
await fsp.access(scriptPath, fs.constants.X_OK);
|
|
274
|
+
} catch {
|
|
275
|
+
throw new Error(`custom command script is not executable: ${scriptPath}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function rememberRun(runs, runState) {
|
|
280
|
+
runs.set(runState.runId, runState);
|
|
281
|
+
while (runs.size > MAX_RUNS) {
|
|
282
|
+
const oldest = [...runs.values()].find((entry) => entry.status !== "running")?.runId;
|
|
283
|
+
if (!oldest) break;
|
|
284
|
+
runs.delete(oldest);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function finishRun(runState, runningByKey, status, updates = {}) {
|
|
289
|
+
if (runState.status !== "running") {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
runState.status = status;
|
|
293
|
+
runState.finishedAt = new Date().toISOString();
|
|
294
|
+
runState.exitCode = updates.exitCode ?? runState.exitCode;
|
|
295
|
+
runState.signal = updates.signal ?? runState.signal;
|
|
296
|
+
runState.error = updates.error ?? runState.error;
|
|
297
|
+
runningByKey.delete(runState.key);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function appendTail(current, chunk) {
|
|
301
|
+
const next = current + String(chunk);
|
|
302
|
+
return next.length > MAX_TAIL_CHARS ? next.slice(next.length - MAX_TAIL_CHARS) : next;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function buildScriptEnv(configPath, key) {
|
|
306
|
+
const env = { ...process.env };
|
|
307
|
+
for (const name of Object.keys(env)) {
|
|
308
|
+
if (name.startsWith("CONDUCTOR_")) {
|
|
309
|
+
delete env[name];
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
env.CONDUCTOR_CONFIG_FILE = configPath;
|
|
313
|
+
env.CONDUCTOR_CUSTOM_COMMAND_KEY = key;
|
|
314
|
+
return env;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function toPublicRun(runState, { includeOutput, started = false } = {}) {
|
|
318
|
+
return {
|
|
319
|
+
...(started ? { started: true } : {}),
|
|
320
|
+
runId: runState.runId,
|
|
321
|
+
key: runState.key,
|
|
322
|
+
status: runState.status,
|
|
323
|
+
pid: runState.pid,
|
|
324
|
+
exitCode: runState.exitCode,
|
|
325
|
+
signal: runState.signal,
|
|
326
|
+
error: runState.error,
|
|
327
|
+
startedAt: runState.startedAt,
|
|
328
|
+
finishedAt: runState.finishedAt,
|
|
329
|
+
...(includeOutput ? {
|
|
330
|
+
stdoutTail: runState.stdoutTail,
|
|
331
|
+
stderrTail: runState.stderrTail,
|
|
332
|
+
} : {}),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function errMsg(err) {
|
|
337
|
+
return err?.message ?? String(err);
|
|
338
|
+
}
|
package/src/daemon.js
CHANGED
|
@@ -17,6 +17,11 @@ import {
|
|
|
17
17
|
} from "@love-moon/conductor-sdk";
|
|
18
18
|
import { DaemonLogCollector } from "./log-collector.js";
|
|
19
19
|
import { createAiManagerHandlers, handleAiManagerRequest } from "./ai-manager-handlers.js";
|
|
20
|
+
import {
|
|
21
|
+
CUSTOM_COMMANDS_CAPABILITY,
|
|
22
|
+
createCustomCommandHandlers,
|
|
23
|
+
handleCustomCommandsRequest,
|
|
24
|
+
} from "./custom-command-handlers.js";
|
|
20
25
|
import { resolveResumeContext } from "./fire/resume.js";
|
|
21
26
|
import {
|
|
22
27
|
filterRuntimeSupportedAllowCliList,
|
|
@@ -39,6 +44,7 @@ import {
|
|
|
39
44
|
resolveInstallMethod,
|
|
40
45
|
} from "./version-check.js";
|
|
41
46
|
import {
|
|
47
|
+
buildPnpmAllowBuildArgs,
|
|
42
48
|
ensurePnpmOnlyBuiltDependencies,
|
|
43
49
|
repairAndVerifyGlobalNodePty,
|
|
44
50
|
} from "./native-deps.js";
|
|
@@ -482,6 +488,39 @@ function normalizeLaunchConfig(value) {
|
|
|
482
488
|
return value;
|
|
483
489
|
}
|
|
484
490
|
|
|
491
|
+
/**
|
|
492
|
+
* Build the argv passed to the `conductor-fire` child process. Centralized so
|
|
493
|
+
* both production daemon code and tests can exercise the same logic.
|
|
494
|
+
*
|
|
495
|
+
* Per-message `/goal` detection lives inside fire itself
|
|
496
|
+
* (`parseGoalDirectiveFromMessage`), so the daemon never appends a `--goal`
|
|
497
|
+
* flag. When `launch_config.goal.objective` is present we still prefer it over
|
|
498
|
+
* the bare `initialContent` for backward-compat with older envelopes; the new
|
|
499
|
+
* web envelope already prefixes the objective with `/goal\n` so fire's
|
|
500
|
+
* per-message detector kicks in on the first turn.
|
|
501
|
+
*/
|
|
502
|
+
export function buildFireSpawnArgs({ selectedBackend, initialContent, launchConfig } = {}) {
|
|
503
|
+
const args = [];
|
|
504
|
+
if (selectedBackend) {
|
|
505
|
+
args.push("--backend", String(selectedBackend));
|
|
506
|
+
}
|
|
507
|
+
const goalObjective =
|
|
508
|
+
launchConfig &&
|
|
509
|
+
typeof launchConfig === "object" &&
|
|
510
|
+
launchConfig.goal &&
|
|
511
|
+
typeof launchConfig.goal === "object" &&
|
|
512
|
+
typeof launchConfig.goal.objective === "string"
|
|
513
|
+
? launchConfig.goal.objective.trim()
|
|
514
|
+
: "";
|
|
515
|
+
const prefill = typeof initialContent === "string" ? initialContent : "";
|
|
516
|
+
const effectivePrefill = prefill || goalObjective;
|
|
517
|
+
if (effectivePrefill) {
|
|
518
|
+
args.push("--prefill", effectivePrefill);
|
|
519
|
+
}
|
|
520
|
+
args.push("--");
|
|
521
|
+
return args;
|
|
522
|
+
}
|
|
523
|
+
|
|
485
524
|
function normalizeBooleanFlag(value) {
|
|
486
525
|
if (typeof value === "boolean") {
|
|
487
526
|
return value;
|
|
@@ -1208,6 +1247,12 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1208
1247
|
}
|
|
1209
1248
|
|
|
1210
1249
|
const PROJECT_SETTINGS_TEMPLATE = [
|
|
1250
|
+
"# icon: \"🚀\"",
|
|
1251
|
+
"# Optional icon shown on the project card in the web project list.",
|
|
1252
|
+
"# Accepts an emoji, an http(s):// URL, or a path to a local image",
|
|
1253
|
+
"# (svg/png/jpg/gif/webp/ico/avif) relative to this .conductor/ directory.",
|
|
1254
|
+
"# Remove this line to use the default folder icon.",
|
|
1255
|
+
"",
|
|
1211
1256
|
"worktree:",
|
|
1212
1257
|
" sync_branch: false",
|
|
1213
1258
|
" symlink: []",
|
|
@@ -1965,7 +2010,12 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1965
2010
|
"x-conductor-backends": SUPPORTED_BACKENDS.join(","),
|
|
1966
2011
|
"x-conductor-version": cliVersion,
|
|
1967
2012
|
};
|
|
1968
|
-
const advertisedCapabilities = [
|
|
2013
|
+
const advertisedCapabilities = [
|
|
2014
|
+
"project_path_validation",
|
|
2015
|
+
"restart_daemon",
|
|
2016
|
+
"refresh_session_inplace",
|
|
2017
|
+
CUSTOM_COMMANDS_CAPABILITY,
|
|
2018
|
+
];
|
|
1969
2019
|
if (ptyTaskCapabilityEnabled) {
|
|
1970
2020
|
advertisedCapabilities.push("pty_task", "terminal_snapshot");
|
|
1971
2021
|
}
|
|
@@ -1973,6 +2023,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1973
2023
|
extraHeaders["x-conductor-capabilities"] = advertisedCapabilities.join(",");
|
|
1974
2024
|
}
|
|
1975
2025
|
const aiManagerHandlers = createAiManagerHandlers({ configPath: config.CONFIG_FILE });
|
|
2026
|
+
const customCommandHandlers = createCustomCommandHandlers({ configPath: config.CONFIG_FILE });
|
|
1976
2027
|
|
|
1977
2028
|
const client = createWebSocketClient(sdkConfig, {
|
|
1978
2029
|
extraHeaders,
|
|
@@ -1992,6 +2043,13 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1992
2043
|
sendAgentResume(isReconnect).catch((error) => {
|
|
1993
2044
|
logError(`sendAgentResume failed: ${error?.message || error}`);
|
|
1994
2045
|
});
|
|
2046
|
+
// RFC 0029: regardless of first connect / reconnect, immediately
|
|
2047
|
+
// declare which tasks this daemon believes are still alive so the
|
|
2048
|
+
// backend can revoke any `daemon_disconnected` killed-flags it set
|
|
2049
|
+
// while the ws was silent. We deliberately fire-and-forget — a stale
|
|
2050
|
+
// backend that doesn't understand the event just ignores it, and the
|
|
2051
|
+
// existing reconcile loop catches anything this push misses.
|
|
2052
|
+
void pushAgentAliveTasks(isReconnect ? "agent_reconnect" : "agent_first_connect");
|
|
1995
2053
|
if (!didRecoverStaleTasks) {
|
|
1996
2054
|
didRecoverStaleTasks = true;
|
|
1997
2055
|
recoverStaleTasks().catch((error) => {
|
|
@@ -2342,7 +2400,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2342
2400
|
switch (pm) {
|
|
2343
2401
|
case "pnpm":
|
|
2344
2402
|
cmd = "pnpm";
|
|
2345
|
-
args = ["add", "-g", pkgSpec];
|
|
2403
|
+
args = ["add", "-g", ...buildPnpmAllowBuildArgs(["node-pty"]), pkgSpec];
|
|
2346
2404
|
break;
|
|
2347
2405
|
case "yarn":
|
|
2348
2406
|
cmd = "yarn";
|
|
@@ -2798,6 +2856,34 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2798
2856
|
}
|
|
2799
2857
|
}
|
|
2800
2858
|
|
|
2859
|
+
// RFC 0029: after every (re)connect push an agent_alive_tasks snapshot so
|
|
2860
|
+
// the backend can pre-emptively revoke `killed_reason=daemon_disconnected`
|
|
2861
|
+
// for tasks whose fire processes never actually died (e.g. host sleep, ws
|
|
2862
|
+
// half-close). Fires raise their own ws to backend independently — this is
|
|
2863
|
+
// the daemon's *own* belief about which tasks it is hosting. The backend
|
|
2864
|
+
// arbitrates between this push and the persisted task state.
|
|
2865
|
+
function pushAgentAliveTasks(reason) {
|
|
2866
|
+
const aliveTaskIds = getActiveTaskIds();
|
|
2867
|
+
if (aliveTaskIds.length === 0) {
|
|
2868
|
+
return Promise.resolve();
|
|
2869
|
+
}
|
|
2870
|
+
return client
|
|
2871
|
+
.sendJson({
|
|
2872
|
+
type: "agent_alive_tasks",
|
|
2873
|
+
payload: {
|
|
2874
|
+
agent_host: AGENT_NAME || os.hostname(),
|
|
2875
|
+
alive_task_ids: aliveTaskIds,
|
|
2876
|
+
reason: typeof reason === "string" && reason ? reason : "agent_reconnect",
|
|
2877
|
+
reported_at: new Date().toISOString(),
|
|
2878
|
+
},
|
|
2879
|
+
})
|
|
2880
|
+
.catch((err) => {
|
|
2881
|
+
logError(
|
|
2882
|
+
`Failed to push agent_alive_tasks (${aliveTaskIds.length} task(s)): ${err?.message || err}`,
|
|
2883
|
+
);
|
|
2884
|
+
});
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2801
2887
|
async function sendAgentResume(isReconnect = false) {
|
|
2802
2888
|
await client.sendJson({
|
|
2803
2889
|
type: "agent_resume",
|
|
@@ -4053,6 +4139,21 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4053
4139
|
});
|
|
4054
4140
|
return;
|
|
4055
4141
|
}
|
|
4142
|
+
if (event.type === "reclaim_task") {
|
|
4143
|
+
// Reject the reclaim during shutdown so the backend immediately
|
|
4144
|
+
// falls back to spawn restart instead of waiting out the 60s ack
|
|
4145
|
+
// timeout. We never touch the task state — backend owns the next
|
|
4146
|
+
// step.
|
|
4147
|
+
const requestId = event?.payload?.request_id ? String(event.payload.request_id) : "";
|
|
4148
|
+
const taskId = event?.payload?.task_id ? String(event.payload.task_id) : "";
|
|
4149
|
+
sendAgentCommandAck({
|
|
4150
|
+
requestId,
|
|
4151
|
+
taskId,
|
|
4152
|
+
eventType: "reclaim_task",
|
|
4153
|
+
accepted: false,
|
|
4154
|
+
}).catch(() => {});
|
|
4155
|
+
return;
|
|
4156
|
+
}
|
|
4056
4157
|
if (event.type === "create_pty_task") {
|
|
4057
4158
|
rejectCreatePtyTaskDuringShutdown(event.payload);
|
|
4058
4159
|
return;
|
|
@@ -4086,6 +4187,14 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4086
4187
|
void handleRestartTask(event.payload);
|
|
4087
4188
|
return;
|
|
4088
4189
|
}
|
|
4190
|
+
if (event.type === "reclaim_task") {
|
|
4191
|
+
// RFC 0029: backend asks us to confirm an "assumed killed" fire is
|
|
4192
|
+
// actually still alive so it can avoid spawning a new one.
|
|
4193
|
+
void handleReclaimTask(event.payload).catch((error) => {
|
|
4194
|
+
logError(`Unhandled reclaim_task failure: ${error?.message || error}`);
|
|
4195
|
+
});
|
|
4196
|
+
return;
|
|
4197
|
+
}
|
|
4089
4198
|
if (event.type === "create_pty_task") {
|
|
4090
4199
|
void handleCreatePtyTask(event.payload);
|
|
4091
4200
|
return;
|
|
@@ -4130,6 +4239,11 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4130
4239
|
logError(`Unhandled ai_manager_request failure: ${error?.message || error}`);
|
|
4131
4240
|
});
|
|
4132
4241
|
}
|
|
4242
|
+
if (event.type === "custom_commands_request") {
|
|
4243
|
+
handleCustomCommandsRequest(client, customCommandHandlers, event.payload).catch((error) => {
|
|
4244
|
+
logError(`Unhandled custom_commands_request failure: ${error?.message || error}`);
|
|
4245
|
+
});
|
|
4246
|
+
}
|
|
4133
4247
|
if (event.type === "restart_daemon") {
|
|
4134
4248
|
void handleRestartDaemon(event.payload).catch((error) => {
|
|
4135
4249
|
logError(`Unhandled restart_daemon failure: ${error?.message || error}`);
|
|
@@ -4449,6 +4563,110 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4449
4563
|
return Boolean(record?.forceDaemonTerminalStatusReport) || !Boolean(record?.managedByFireBridge);
|
|
4450
4564
|
}
|
|
4451
4565
|
|
|
4566
|
+
// RFC 0029: confirm that a task the backend believes is dead is actually
|
|
4567
|
+
// still hosting a live fire on this daemon. The check is intentionally
|
|
4568
|
+
// conservative:
|
|
4569
|
+
// * activeTaskProcesses must hold an entry for the task — that's the
|
|
4570
|
+
// daemon's own bookkeeping of fires it spawned and hasn't reaped;
|
|
4571
|
+
// * for tmux-mode fires (the common case) we re-probe `tmux has-session`
|
|
4572
|
+
// so we don't accept stale entries left over from a previously crashed
|
|
4573
|
+
// reaper. The activeTaskProcesses entry is updated on the spot if the
|
|
4574
|
+
// session has gone away;
|
|
4575
|
+
// * for non-tmux fires (rare; only used in dev) we accept as long as the
|
|
4576
|
+
// child process record still exists. The exit handler removes the
|
|
4577
|
+
// entry on real exit, so a present entry implies a live pid.
|
|
4578
|
+
//
|
|
4579
|
+
// We do NOT validate `expected_session_id` or `expected_backend_type`
|
|
4580
|
+
// against the in-tmux fire — the daemon has no IPC into a detached fire
|
|
4581
|
+
// process, so that comparison can only happen on the backend or via fire's
|
|
4582
|
+
// own ws back to the backend (the agent_alive_tasks push in this RFC).
|
|
4583
|
+
// What we *do* check is that they were sent (purely defensive — old web
|
|
4584
|
+
// clients won't include them, and that's fine).
|
|
4585
|
+
async function handleReclaimTask(payload) {
|
|
4586
|
+
const taskId = payload?.task_id ? String(payload.task_id) : "";
|
|
4587
|
+
const requestId = payload?.request_id ? String(payload.request_id) : "";
|
|
4588
|
+
const expectedSessionId = payload?.expected_session_id
|
|
4589
|
+
? String(payload.expected_session_id)
|
|
4590
|
+
: "";
|
|
4591
|
+
const expectedBackendType = payload?.expected_backend_type
|
|
4592
|
+
? String(payload.expected_backend_type)
|
|
4593
|
+
: "";
|
|
4594
|
+
|
|
4595
|
+
if (!taskId) {
|
|
4596
|
+
logError(`Invalid reclaim_task payload: ${JSON.stringify(payload)}`);
|
|
4597
|
+
if (requestId) {
|
|
4598
|
+
sendAgentCommandAck({
|
|
4599
|
+
requestId,
|
|
4600
|
+
taskId,
|
|
4601
|
+
eventType: "reclaim_task",
|
|
4602
|
+
accepted: false,
|
|
4603
|
+
}).catch(() => {});
|
|
4604
|
+
}
|
|
4605
|
+
return;
|
|
4606
|
+
}
|
|
4607
|
+
|
|
4608
|
+
if (requestId && !markRequestSeen(requestId)) {
|
|
4609
|
+
log(
|
|
4610
|
+
`Duplicate reclaim_task ignored for ${taskId} (request_id=${requestId})`,
|
|
4611
|
+
);
|
|
4612
|
+
sendAgentCommandAck({
|
|
4613
|
+
requestId,
|
|
4614
|
+
taskId,
|
|
4615
|
+
eventType: "reclaim_task",
|
|
4616
|
+
accepted: completedCommandRequestAckResults.get(requestId) ?? false,
|
|
4617
|
+
}).catch(() => {});
|
|
4618
|
+
return;
|
|
4619
|
+
}
|
|
4620
|
+
|
|
4621
|
+
const finalize = (accepted, summary) => {
|
|
4622
|
+
if (requestId) {
|
|
4623
|
+
rememberCommandRequestAckResult(requestId, accepted);
|
|
4624
|
+
}
|
|
4625
|
+
sendAgentCommandAck({
|
|
4626
|
+
requestId,
|
|
4627
|
+
taskId,
|
|
4628
|
+
eventType: "reclaim_task",
|
|
4629
|
+
accepted,
|
|
4630
|
+
}).catch((err) => {
|
|
4631
|
+
logError(
|
|
4632
|
+
`Failed to report agent_command_ack(reclaim_task) for ${taskId}: ${err?.message || err}`,
|
|
4633
|
+
);
|
|
4634
|
+
});
|
|
4635
|
+
log(
|
|
4636
|
+
`reclaim_task ${taskId} -> ${accepted ? "alive" : "stale"}${
|
|
4637
|
+
summary ? ` (${summary})` : ""
|
|
4638
|
+
}${expectedSessionId ? ` expected_session=${expectedSessionId}` : ""}${
|
|
4639
|
+
expectedBackendType ? ` expected_backend=${expectedBackendType}` : ""
|
|
4640
|
+
}`,
|
|
4641
|
+
);
|
|
4642
|
+
};
|
|
4643
|
+
|
|
4644
|
+
let entry = activeTaskProcesses.get(taskId);
|
|
4645
|
+
if (entry?.tmuxMode && entry.tmuxSession) {
|
|
4646
|
+
const alive = await tmuxSessionExists(entry.tmuxSession);
|
|
4647
|
+
if (!alive) {
|
|
4648
|
+
if (activeTaskProcesses.get(taskId) === entry) {
|
|
4649
|
+
if (entry.stopForceKillTimer) {
|
|
4650
|
+
clearTimeout(entry.stopForceKillTimer);
|
|
4651
|
+
entry.stopForceKillTimer = null;
|
|
4652
|
+
}
|
|
4653
|
+
activeTaskProcesses.delete(taskId);
|
|
4654
|
+
}
|
|
4655
|
+
entry = undefined;
|
|
4656
|
+
}
|
|
4657
|
+
} else if (entry?.child && entry.child.exitCode !== null) {
|
|
4658
|
+
// Non-tmux fire has already exited; treat as stale.
|
|
4659
|
+
entry = undefined;
|
|
4660
|
+
}
|
|
4661
|
+
|
|
4662
|
+
if (!entry) {
|
|
4663
|
+
finalize(false, "no live fire");
|
|
4664
|
+
return;
|
|
4665
|
+
}
|
|
4666
|
+
|
|
4667
|
+
finalize(true, entry.tmuxMode ? `tmux=${entry.tmuxSession}` : `pid=${entry.child?.pid ?? "unknown"}`);
|
|
4668
|
+
}
|
|
4669
|
+
|
|
4452
4670
|
function handleStopTask(payload) {
|
|
4453
4671
|
const taskId = payload?.task_id;
|
|
4454
4672
|
if (!taskId) return;
|
|
@@ -4920,14 +5138,11 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4920
5138
|
logPath = path.join(taskDir, "conductor.log");
|
|
4921
5139
|
}
|
|
4922
5140
|
|
|
4923
|
-
const args =
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
args.push("--prefill", initialContent);
|
|
4929
|
-
}
|
|
4930
|
-
args.push("--");
|
|
5141
|
+
const args = buildFireSpawnArgs({
|
|
5142
|
+
selectedBackend,
|
|
5143
|
+
initialContent,
|
|
5144
|
+
launchConfig,
|
|
5145
|
+
});
|
|
4931
5146
|
|
|
4932
5147
|
const env = {
|
|
4933
5148
|
...process.env,
|
package/src/native-deps.js
CHANGED
|
@@ -87,6 +87,44 @@ export function mergeBuiltDependencies(existing, required) {
|
|
|
87
87
|
return [...merged];
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
export function parsePnpmIgnoredBuildsOutput(value) {
|
|
91
|
+
const ignored = [];
|
|
92
|
+
for (const rawLine of String(value || "").split(/\r?\n/)) {
|
|
93
|
+
const line = rawLine
|
|
94
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
95
|
+
.trim();
|
|
96
|
+
if (!line || line.toLowerCase().includes("none")) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const match = line.match(/^(?:[-*]\s*)?(@?[^@\s][^\s@]*)(?:@[\w.-]+)?$/);
|
|
100
|
+
if (!match) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const name = match[1].trim();
|
|
104
|
+
if (name && !ignored.includes(name)) {
|
|
105
|
+
ignored.push(name);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return ignored;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function detectPnpmIgnoredBuilds({
|
|
112
|
+
runCommand = defaultRunCommand,
|
|
113
|
+
cwd = process.cwd(),
|
|
114
|
+
} = {}) {
|
|
115
|
+
const result = await runCommand("pnpm", ["ignored-builds"], { cwd });
|
|
116
|
+
if (!result.success) {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
return parsePnpmIgnoredBuildsOutput(result.stdout);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function buildPnpmAllowBuildArgs(dependencies = ["node-pty"]) {
|
|
123
|
+
return normalizeBuiltDependencyList(dependencies).flatMap((dependency) => [
|
|
124
|
+
`--allow-build=${dependency}`,
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
127
|
+
|
|
90
128
|
export async function ensurePnpmOnlyBuiltDependencies({
|
|
91
129
|
runCommand = defaultRunCommand,
|
|
92
130
|
dependencies = ["node-pty"],
|
|
@@ -322,6 +360,22 @@ export async function repairAndVerifyGlobalNodePty({
|
|
|
322
360
|
});
|
|
323
361
|
|
|
324
362
|
if (packageManager === "pnpm") {
|
|
363
|
+
const ignoredBuilds = await detectPnpmIgnoredBuilds({
|
|
364
|
+
runCommand,
|
|
365
|
+
cwd: packageDirectory,
|
|
366
|
+
});
|
|
367
|
+
const blockedDependencies = normalizeBuiltDependencyList(dependencies).filter((dependency) =>
|
|
368
|
+
ignoredBuilds.includes(dependency),
|
|
369
|
+
);
|
|
370
|
+
if (blockedDependencies.length > 0) {
|
|
371
|
+
throw new Error(
|
|
372
|
+
`pnpm ignored native build scripts for ${blockedDependencies.join(
|
|
373
|
+
", ",
|
|
374
|
+
)}. Reinstall Conductor with pnpm's build allowlist enabled, for example: pnpm add -g ${buildPnpmAllowBuildArgs(
|
|
375
|
+
blockedDependencies,
|
|
376
|
+
).join(" ")} ${packageName}@latest`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
325
379
|
const rebuildResult = await runCommand("pnpm", ["rebuild", ...dependencies], {
|
|
326
380
|
cwd: packageDirectory,
|
|
327
381
|
});
|
package/src/runtime-backends.js
CHANGED
|
@@ -8,8 +8,20 @@ import { BUILT_IN_BACKENDS as AI_SDK_BUILT_IN_BACKENDS } from "@love-moon/ai-sdk
|
|
|
8
8
|
// CLI display order for built-in backends. ai-sdk owns the canonical set of
|
|
9
9
|
// built-in backends; CLI just picks an ordering for "Supported Backends:" log
|
|
10
10
|
// output. The self-check below ensures this list always matches ai-sdk's set.
|
|
11
|
-
const BUILT_IN_RUNTIME_BACKENDS = ["codex", "claude", "kimi", "opencode", "copilot"];
|
|
11
|
+
const BUILT_IN_RUNTIME_BACKENDS = ["codex", "claude", "kimi", "opencode", "copilot", "chat-web"];
|
|
12
12
|
const BUILT_IN_RUNTIME_BACKEND_SET = new Set(BUILT_IN_RUNTIME_BACKENDS);
|
|
13
|
+
// Backends that don't shell out to a CLI binary AND should be advertised
|
|
14
|
+
// without any user-side allow_cli_list entry. `copilot` is the only such
|
|
15
|
+
// backend today — it ships with @github/copilot-sdk as a hard dep so
|
|
16
|
+
// every install gets it for free.
|
|
17
|
+
//
|
|
18
|
+
// chat-web is intentionally NOT here even though it's also command-optional
|
|
19
|
+
// (it drives a Chromium browser, not a CLI). The reason: chat-web has
|
|
20
|
+
// configurable sub-providers (chatgpt / gemini / deepseek) selected via
|
|
21
|
+
// `--model`, and surfacing a bare `chat-web` backend alongside user
|
|
22
|
+
// aliases like `web-chatgpt` / `web-gemini` is just confusing — the alias
|
|
23
|
+
// IS the sub-provider choice. Users who want chat-web must declare an
|
|
24
|
+
// explicit allow_cli_list entry (which is also where `--model` lives).
|
|
13
25
|
const COMMAND_OPTIONAL_BUILT_IN_RUNTIME_BACKENDS = ["copilot"];
|
|
14
26
|
const COMMAND_OPTIONAL_BUILT_IN_RUNTIME_BACKEND_SET = new Set(COMMAND_OPTIONAL_BUILT_IN_RUNTIME_BACKENDS);
|
|
15
27
|
|