@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 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
@@ -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
- console.log(` • ${colorize(info.command, "cyan")} - ${info.description}`);
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
- if (!RUNTIME_SUPPORTED_BACKENDS.includes(key)) {
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 (key === "copilot" && isBuiltInCopilotAvailable()) {
330
+ if (runtimeBackend === "chat-web" && isBuiltInChatWebAvailable()) {
294
331
  detected.push(key);
295
332
  continue;
296
333
  }
@@ -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 = process.cwd()) {
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
- : process.cwd();
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.backendSession.runTurn(content, {
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.backendSession.runTurn(content, {
3365
+ const result = await this.dispatchBackendTurn(content, {
3173
3366
  useInitialImages: Boolean(includeImages),
3174
3367
  onProgress: (payload) => {
3175
3368
  void this.reportRuntimeStatus(payload, replyTarget);
@@ -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.3.2",
4
- "gitCommitId": "519f104",
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-manager": "0.3.2",
27
- "@love-moon/ai-sdk": "0.3.2",
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/ai-manager": "file:../modules/ai-manager",
50
- "@love-moon/conductor-sdk": "file:../modules/conductor-sdk"
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 module.
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-manager";
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 ai-manager's default.
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 [tool, result] of entries) {
75
- out[tool] = result;
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 && ["codex", "claude", "kimi", "copilot"].includes(tool)) return new Set([tool]);
173
- return new Set(["codex", "claude", "kimi", "copilot"]);
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 = ["project_path_validation", "restart_daemon", "refresh_session_inplace"];
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
- if (selectedBackend) {
4925
- args.push("--backend", selectedBackend);
4926
- }
4927
- if (initialContent) {
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,
@@ -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
  });
@@ -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