@love-moon/conductor-cli 0.3.2 → 0.4.0
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 +18 -0
- package/bin/conductor-config.js +26 -0
- 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 +219 -10
- package/src/native-deps.js +54 -0
- package/src/runtime-backends.js +13 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @love-moon/conductor-cli
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 4ecc359: Publish the chat-web browser runtime and wire it into the CLI and AI SDK for
|
|
8
|
+
ChatGPT and Gemini web sessions, including provider error handling and local
|
|
9
|
+
development installation support.
|
|
10
|
+
|
|
11
|
+
Ship app SDK realtime history catch-up and the CLI/AI SDK goal-mode and custom
|
|
12
|
+
command runtime updates included in this release.
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- 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.
|
|
17
|
+
- Updated dependencies [4ecc359]
|
|
18
|
+
- @love-moon/ai-sdk@0.4.0
|
|
19
|
+
- @love-moon/conductor-sdk@0.4.0
|
|
20
|
+
|
|
3
21
|
## 0.3.2
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
package/bin/conductor-config.js
CHANGED
|
@@ -42,6 +42,14 @@ const DEFAULT_CLIs = {
|
|
|
42
42
|
execArgs: "",
|
|
43
43
|
description: "GitHub Copilot (built in via SDK)"
|
|
44
44
|
},
|
|
45
|
+
"chat-web": {
|
|
46
|
+
command: "chat-web",
|
|
47
|
+
// Add `--model gemini` to use Google AI Studio's Gemini instead of
|
|
48
|
+
// the default ChatGPT. The CLI parses --model from this entry and
|
|
49
|
+
// forwards it to ai-sdk; it doesn't actually exec the command line.
|
|
50
|
+
execArgs: "",
|
|
51
|
+
description: "Chat web automation (ChatGPT / Gemini via @love-moon/chat-web)"
|
|
52
|
+
},
|
|
45
53
|
};
|
|
46
54
|
|
|
47
55
|
const backendUrl =
|
|
@@ -75,6 +83,13 @@ function isBuiltInCopilotAvailable() {
|
|
|
75
83
|
);
|
|
76
84
|
}
|
|
77
85
|
|
|
86
|
+
function isBuiltInChatWebAvailable() {
|
|
87
|
+
return Boolean(
|
|
88
|
+
packageJson?.dependencies?.["@love-moon/chat-web"] ||
|
|
89
|
+
packageJson?.optionalDependencies?.["@love-moon/chat-web"],
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
78
93
|
function buildConfigEntryLines(cli, info, { commented = false } = {}) {
|
|
79
94
|
const fullCommand = info.execArgs
|
|
80
95
|
? `${info.command} ${info.execArgs}`
|
|
@@ -86,6 +101,13 @@ function buildConfigEntryLines(cli, info, { commented = false } = {}) {
|
|
|
86
101
|
if (cli === "opencode") {
|
|
87
102
|
lines.push(`${commentPrefix}opencode runs via ai-sdk server mode with permission=allow`);
|
|
88
103
|
}
|
|
104
|
+
if (cli === "chat-web") {
|
|
105
|
+
// chat-web defaults to its `chatgpt` sub-provider. The CLI extracts
|
|
106
|
+
// `--model` from this line and forwards it to ai-sdk; the command
|
|
107
|
+
// itself is never spawned (chat-web is an in-process SDK).
|
|
108
|
+
lines.push(`${commentPrefix}chat-web drives a real Chromium browser via @love-moon/chat-web`);
|
|
109
|
+
lines.push(`${commentPrefix}defaults to ChatGPT; use \`chat-web --model gemini\` for AI Studio (Gemini)`);
|
|
110
|
+
}
|
|
89
111
|
|
|
90
112
|
lines.push(`${entryPrefix}${cli}: ${fullCommand}`);
|
|
91
113
|
return lines;
|
|
@@ -294,6 +316,10 @@ function detectInstalledCLIs() {
|
|
|
294
316
|
detected.push(key);
|
|
295
317
|
continue;
|
|
296
318
|
}
|
|
319
|
+
if (key === "chat-web" && isBuiltInChatWebAvailable()) {
|
|
320
|
+
detected.push(key);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
297
323
|
if (isCommandAvailable(info.command)) {
|
|
298
324
|
detected.push(key);
|
|
299
325
|
}
|
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.0",
|
|
4
|
+
"gitCommitId": "2d1526c",
|
|
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.0",
|
|
27
|
+
"@love-moon/conductor-sdk": "0.4.0",
|
|
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.0"
|
|
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;
|
|
@@ -1965,7 +2004,12 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1965
2004
|
"x-conductor-backends": SUPPORTED_BACKENDS.join(","),
|
|
1966
2005
|
"x-conductor-version": cliVersion,
|
|
1967
2006
|
};
|
|
1968
|
-
const advertisedCapabilities = [
|
|
2007
|
+
const advertisedCapabilities = [
|
|
2008
|
+
"project_path_validation",
|
|
2009
|
+
"restart_daemon",
|
|
2010
|
+
"refresh_session_inplace",
|
|
2011
|
+
CUSTOM_COMMANDS_CAPABILITY,
|
|
2012
|
+
];
|
|
1969
2013
|
if (ptyTaskCapabilityEnabled) {
|
|
1970
2014
|
advertisedCapabilities.push("pty_task", "terminal_snapshot");
|
|
1971
2015
|
}
|
|
@@ -1973,6 +2017,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1973
2017
|
extraHeaders["x-conductor-capabilities"] = advertisedCapabilities.join(",");
|
|
1974
2018
|
}
|
|
1975
2019
|
const aiManagerHandlers = createAiManagerHandlers({ configPath: config.CONFIG_FILE });
|
|
2020
|
+
const customCommandHandlers = createCustomCommandHandlers({ configPath: config.CONFIG_FILE });
|
|
1976
2021
|
|
|
1977
2022
|
const client = createWebSocketClient(sdkConfig, {
|
|
1978
2023
|
extraHeaders,
|
|
@@ -1992,6 +2037,13 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1992
2037
|
sendAgentResume(isReconnect).catch((error) => {
|
|
1993
2038
|
logError(`sendAgentResume failed: ${error?.message || error}`);
|
|
1994
2039
|
});
|
|
2040
|
+
// RFC 0029: regardless of first connect / reconnect, immediately
|
|
2041
|
+
// declare which tasks this daemon believes are still alive so the
|
|
2042
|
+
// backend can revoke any `daemon_disconnected` killed-flags it set
|
|
2043
|
+
// while the ws was silent. We deliberately fire-and-forget — a stale
|
|
2044
|
+
// backend that doesn't understand the event just ignores it, and the
|
|
2045
|
+
// existing reconcile loop catches anything this push misses.
|
|
2046
|
+
void pushAgentAliveTasks(isReconnect ? "agent_reconnect" : "agent_first_connect");
|
|
1995
2047
|
if (!didRecoverStaleTasks) {
|
|
1996
2048
|
didRecoverStaleTasks = true;
|
|
1997
2049
|
recoverStaleTasks().catch((error) => {
|
|
@@ -2342,7 +2394,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2342
2394
|
switch (pm) {
|
|
2343
2395
|
case "pnpm":
|
|
2344
2396
|
cmd = "pnpm";
|
|
2345
|
-
args = ["add", "-g", pkgSpec];
|
|
2397
|
+
args = ["add", "-g", ...buildPnpmAllowBuildArgs(["node-pty"]), pkgSpec];
|
|
2346
2398
|
break;
|
|
2347
2399
|
case "yarn":
|
|
2348
2400
|
cmd = "yarn";
|
|
@@ -2798,6 +2850,34 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2798
2850
|
}
|
|
2799
2851
|
}
|
|
2800
2852
|
|
|
2853
|
+
// RFC 0029: after every (re)connect push an agent_alive_tasks snapshot so
|
|
2854
|
+
// the backend can pre-emptively revoke `killed_reason=daemon_disconnected`
|
|
2855
|
+
// for tasks whose fire processes never actually died (e.g. host sleep, ws
|
|
2856
|
+
// half-close). Fires raise their own ws to backend independently — this is
|
|
2857
|
+
// the daemon's *own* belief about which tasks it is hosting. The backend
|
|
2858
|
+
// arbitrates between this push and the persisted task state.
|
|
2859
|
+
function pushAgentAliveTasks(reason) {
|
|
2860
|
+
const aliveTaskIds = getActiveTaskIds();
|
|
2861
|
+
if (aliveTaskIds.length === 0) {
|
|
2862
|
+
return Promise.resolve();
|
|
2863
|
+
}
|
|
2864
|
+
return client
|
|
2865
|
+
.sendJson({
|
|
2866
|
+
type: "agent_alive_tasks",
|
|
2867
|
+
payload: {
|
|
2868
|
+
agent_host: AGENT_NAME || os.hostname(),
|
|
2869
|
+
alive_task_ids: aliveTaskIds,
|
|
2870
|
+
reason: typeof reason === "string" && reason ? reason : "agent_reconnect",
|
|
2871
|
+
reported_at: new Date().toISOString(),
|
|
2872
|
+
},
|
|
2873
|
+
})
|
|
2874
|
+
.catch((err) => {
|
|
2875
|
+
logError(
|
|
2876
|
+
`Failed to push agent_alive_tasks (${aliveTaskIds.length} task(s)): ${err?.message || err}`,
|
|
2877
|
+
);
|
|
2878
|
+
});
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2801
2881
|
async function sendAgentResume(isReconnect = false) {
|
|
2802
2882
|
await client.sendJson({
|
|
2803
2883
|
type: "agent_resume",
|
|
@@ -4053,6 +4133,21 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4053
4133
|
});
|
|
4054
4134
|
return;
|
|
4055
4135
|
}
|
|
4136
|
+
if (event.type === "reclaim_task") {
|
|
4137
|
+
// Reject the reclaim during shutdown so the backend immediately
|
|
4138
|
+
// falls back to spawn restart instead of waiting out the 60s ack
|
|
4139
|
+
// timeout. We never touch the task state — backend owns the next
|
|
4140
|
+
// step.
|
|
4141
|
+
const requestId = event?.payload?.request_id ? String(event.payload.request_id) : "";
|
|
4142
|
+
const taskId = event?.payload?.task_id ? String(event.payload.task_id) : "";
|
|
4143
|
+
sendAgentCommandAck({
|
|
4144
|
+
requestId,
|
|
4145
|
+
taskId,
|
|
4146
|
+
eventType: "reclaim_task",
|
|
4147
|
+
accepted: false,
|
|
4148
|
+
}).catch(() => {});
|
|
4149
|
+
return;
|
|
4150
|
+
}
|
|
4056
4151
|
if (event.type === "create_pty_task") {
|
|
4057
4152
|
rejectCreatePtyTaskDuringShutdown(event.payload);
|
|
4058
4153
|
return;
|
|
@@ -4086,6 +4181,14 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4086
4181
|
void handleRestartTask(event.payload);
|
|
4087
4182
|
return;
|
|
4088
4183
|
}
|
|
4184
|
+
if (event.type === "reclaim_task") {
|
|
4185
|
+
// RFC 0029: backend asks us to confirm an "assumed killed" fire is
|
|
4186
|
+
// actually still alive so it can avoid spawning a new one.
|
|
4187
|
+
void handleReclaimTask(event.payload).catch((error) => {
|
|
4188
|
+
logError(`Unhandled reclaim_task failure: ${error?.message || error}`);
|
|
4189
|
+
});
|
|
4190
|
+
return;
|
|
4191
|
+
}
|
|
4089
4192
|
if (event.type === "create_pty_task") {
|
|
4090
4193
|
void handleCreatePtyTask(event.payload);
|
|
4091
4194
|
return;
|
|
@@ -4130,6 +4233,11 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4130
4233
|
logError(`Unhandled ai_manager_request failure: ${error?.message || error}`);
|
|
4131
4234
|
});
|
|
4132
4235
|
}
|
|
4236
|
+
if (event.type === "custom_commands_request") {
|
|
4237
|
+
handleCustomCommandsRequest(client, customCommandHandlers, event.payload).catch((error) => {
|
|
4238
|
+
logError(`Unhandled custom_commands_request failure: ${error?.message || error}`);
|
|
4239
|
+
});
|
|
4240
|
+
}
|
|
4133
4241
|
if (event.type === "restart_daemon") {
|
|
4134
4242
|
void handleRestartDaemon(event.payload).catch((error) => {
|
|
4135
4243
|
logError(`Unhandled restart_daemon failure: ${error?.message || error}`);
|
|
@@ -4449,6 +4557,110 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4449
4557
|
return Boolean(record?.forceDaemonTerminalStatusReport) || !Boolean(record?.managedByFireBridge);
|
|
4450
4558
|
}
|
|
4451
4559
|
|
|
4560
|
+
// RFC 0029: confirm that a task the backend believes is dead is actually
|
|
4561
|
+
// still hosting a live fire on this daemon. The check is intentionally
|
|
4562
|
+
// conservative:
|
|
4563
|
+
// * activeTaskProcesses must hold an entry for the task — that's the
|
|
4564
|
+
// daemon's own bookkeeping of fires it spawned and hasn't reaped;
|
|
4565
|
+
// * for tmux-mode fires (the common case) we re-probe `tmux has-session`
|
|
4566
|
+
// so we don't accept stale entries left over from a previously crashed
|
|
4567
|
+
// reaper. The activeTaskProcesses entry is updated on the spot if the
|
|
4568
|
+
// session has gone away;
|
|
4569
|
+
// * for non-tmux fires (rare; only used in dev) we accept as long as the
|
|
4570
|
+
// child process record still exists. The exit handler removes the
|
|
4571
|
+
// entry on real exit, so a present entry implies a live pid.
|
|
4572
|
+
//
|
|
4573
|
+
// We do NOT validate `expected_session_id` or `expected_backend_type`
|
|
4574
|
+
// against the in-tmux fire — the daemon has no IPC into a detached fire
|
|
4575
|
+
// process, so that comparison can only happen on the backend or via fire's
|
|
4576
|
+
// own ws back to the backend (the agent_alive_tasks push in this RFC).
|
|
4577
|
+
// What we *do* check is that they were sent (purely defensive — old web
|
|
4578
|
+
// clients won't include them, and that's fine).
|
|
4579
|
+
async function handleReclaimTask(payload) {
|
|
4580
|
+
const taskId = payload?.task_id ? String(payload.task_id) : "";
|
|
4581
|
+
const requestId = payload?.request_id ? String(payload.request_id) : "";
|
|
4582
|
+
const expectedSessionId = payload?.expected_session_id
|
|
4583
|
+
? String(payload.expected_session_id)
|
|
4584
|
+
: "";
|
|
4585
|
+
const expectedBackendType = payload?.expected_backend_type
|
|
4586
|
+
? String(payload.expected_backend_type)
|
|
4587
|
+
: "";
|
|
4588
|
+
|
|
4589
|
+
if (!taskId) {
|
|
4590
|
+
logError(`Invalid reclaim_task payload: ${JSON.stringify(payload)}`);
|
|
4591
|
+
if (requestId) {
|
|
4592
|
+
sendAgentCommandAck({
|
|
4593
|
+
requestId,
|
|
4594
|
+
taskId,
|
|
4595
|
+
eventType: "reclaim_task",
|
|
4596
|
+
accepted: false,
|
|
4597
|
+
}).catch(() => {});
|
|
4598
|
+
}
|
|
4599
|
+
return;
|
|
4600
|
+
}
|
|
4601
|
+
|
|
4602
|
+
if (requestId && !markRequestSeen(requestId)) {
|
|
4603
|
+
log(
|
|
4604
|
+
`Duplicate reclaim_task ignored for ${taskId} (request_id=${requestId})`,
|
|
4605
|
+
);
|
|
4606
|
+
sendAgentCommandAck({
|
|
4607
|
+
requestId,
|
|
4608
|
+
taskId,
|
|
4609
|
+
eventType: "reclaim_task",
|
|
4610
|
+
accepted: completedCommandRequestAckResults.get(requestId) ?? false,
|
|
4611
|
+
}).catch(() => {});
|
|
4612
|
+
return;
|
|
4613
|
+
}
|
|
4614
|
+
|
|
4615
|
+
const finalize = (accepted, summary) => {
|
|
4616
|
+
if (requestId) {
|
|
4617
|
+
rememberCommandRequestAckResult(requestId, accepted);
|
|
4618
|
+
}
|
|
4619
|
+
sendAgentCommandAck({
|
|
4620
|
+
requestId,
|
|
4621
|
+
taskId,
|
|
4622
|
+
eventType: "reclaim_task",
|
|
4623
|
+
accepted,
|
|
4624
|
+
}).catch((err) => {
|
|
4625
|
+
logError(
|
|
4626
|
+
`Failed to report agent_command_ack(reclaim_task) for ${taskId}: ${err?.message || err}`,
|
|
4627
|
+
);
|
|
4628
|
+
});
|
|
4629
|
+
log(
|
|
4630
|
+
`reclaim_task ${taskId} -> ${accepted ? "alive" : "stale"}${
|
|
4631
|
+
summary ? ` (${summary})` : ""
|
|
4632
|
+
}${expectedSessionId ? ` expected_session=${expectedSessionId}` : ""}${
|
|
4633
|
+
expectedBackendType ? ` expected_backend=${expectedBackendType}` : ""
|
|
4634
|
+
}`,
|
|
4635
|
+
);
|
|
4636
|
+
};
|
|
4637
|
+
|
|
4638
|
+
let entry = activeTaskProcesses.get(taskId);
|
|
4639
|
+
if (entry?.tmuxMode && entry.tmuxSession) {
|
|
4640
|
+
const alive = await tmuxSessionExists(entry.tmuxSession);
|
|
4641
|
+
if (!alive) {
|
|
4642
|
+
if (activeTaskProcesses.get(taskId) === entry) {
|
|
4643
|
+
if (entry.stopForceKillTimer) {
|
|
4644
|
+
clearTimeout(entry.stopForceKillTimer);
|
|
4645
|
+
entry.stopForceKillTimer = null;
|
|
4646
|
+
}
|
|
4647
|
+
activeTaskProcesses.delete(taskId);
|
|
4648
|
+
}
|
|
4649
|
+
entry = undefined;
|
|
4650
|
+
}
|
|
4651
|
+
} else if (entry?.child && entry.child.exitCode !== null) {
|
|
4652
|
+
// Non-tmux fire has already exited; treat as stale.
|
|
4653
|
+
entry = undefined;
|
|
4654
|
+
}
|
|
4655
|
+
|
|
4656
|
+
if (!entry) {
|
|
4657
|
+
finalize(false, "no live fire");
|
|
4658
|
+
return;
|
|
4659
|
+
}
|
|
4660
|
+
|
|
4661
|
+
finalize(true, entry.tmuxMode ? `tmux=${entry.tmuxSession}` : `pid=${entry.child?.pid ?? "unknown"}`);
|
|
4662
|
+
}
|
|
4663
|
+
|
|
4452
4664
|
function handleStopTask(payload) {
|
|
4453
4665
|
const taskId = payload?.task_id;
|
|
4454
4666
|
if (!taskId) return;
|
|
@@ -4920,14 +5132,11 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4920
5132
|
logPath = path.join(taskDir, "conductor.log");
|
|
4921
5133
|
}
|
|
4922
5134
|
|
|
4923
|
-
const args =
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
args.push("--prefill", initialContent);
|
|
4929
|
-
}
|
|
4930
|
-
args.push("--");
|
|
5135
|
+
const args = buildFireSpawnArgs({
|
|
5136
|
+
selectedBackend,
|
|
5137
|
+
initialContent,
|
|
5138
|
+
launchConfig,
|
|
5139
|
+
});
|
|
4931
5140
|
|
|
4932
5141
|
const env = {
|
|
4933
5142
|
...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
|
|