@love-moon/conductor-cli 0.3.1 → 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 CHANGED
@@ -1,5 +1,34 @@
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
+
21
+ ## 0.3.2
22
+
23
+ ### Patch Changes
24
+
25
+ - 8e1d4a8: Prefer the bundled Copilot platform executable before the JS entrypoint so Node
26
+ 20 installs do not fail with `ERR_UNKNOWN_BUILTIN_MODULE: node:sqlite`.
27
+ - Updated dependencies [8e1d4a8]
28
+ - @love-moon/ai-sdk@0.3.2
29
+ - @love-moon/ai-manager@0.3.2
30
+ - @love-moon/conductor-sdk@0.3.2
31
+
3
32
  ## 0.3.1
4
33
 
5
34
  ### Patch Changes
@@ -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
  }
@@ -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.1",
4
- "gitCommitId": "03b4582",
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-manager": "0.3.1",
27
- "@love-moon/ai-sdk": "0.3.1",
28
- "@love-moon/conductor-sdk": "0.3.1",
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/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;
@@ -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 = ["project_path_validation", "restart_daemon", "refresh_session_inplace"];
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}`);
@@ -4221,6 +4329,7 @@ export function startDaemon(config = {}, deps = {}) {
4221
4329
  repoRoot: null,
4222
4330
  worktreeBranch: null,
4223
4331
  lastCommit: null,
4332
+ lastCommitAt: null,
4224
4333
  fileCount: null,
4225
4334
  error: null,
4226
4335
  errorCode: null,
@@ -4263,6 +4372,10 @@ export function startDaemon(config = {}, deps = {}) {
4263
4372
  typeof snapshot?.lastCommit === "string" && snapshot.lastCommit.trim()
4264
4373
  ? snapshot.lastCommit.trim()
4265
4374
  : null,
4375
+ lastCommitAt:
4376
+ typeof snapshot?.lastCommitAt === "string" && snapshot.lastCommitAt.trim()
4377
+ ? snapshot.lastCommitAt.trim()
4378
+ : null,
4266
4379
  gitRemoteUrl:
4267
4380
  typeof snapshot?.gitRemoteUrl === "string" && snapshot.gitRemoteUrl.trim()
4268
4381
  ? snapshot.gitRemoteUrl.trim()
@@ -4291,6 +4404,7 @@ export function startDaemon(config = {}, deps = {}) {
4291
4404
  repo_root: result.repoRoot,
4292
4405
  worktree_branch: result.worktreeBranch,
4293
4406
  last_commit: result.lastCommit,
4407
+ last_commit_at: result.lastCommitAt,
4294
4408
  git_remote_url: result.gitRemoteUrl,
4295
4409
  file_count: result.fileCount,
4296
4410
  error: result.error,
@@ -4443,6 +4557,110 @@ export function startDaemon(config = {}, deps = {}) {
4443
4557
  return Boolean(record?.forceDaemonTerminalStatusReport) || !Boolean(record?.managedByFireBridge);
4444
4558
  }
4445
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
+
4446
4664
  function handleStopTask(payload) {
4447
4665
  const taskId = payload?.task_id;
4448
4666
  if (!taskId) return;
@@ -4914,14 +5132,11 @@ export function startDaemon(config = {}, deps = {}) {
4914
5132
  logPath = path.join(taskDir, "conductor.log");
4915
5133
  }
4916
5134
 
4917
- const args = [];
4918
- if (selectedBackend) {
4919
- args.push("--backend", selectedBackend);
4920
- }
4921
- if (initialContent) {
4922
- args.push("--prefill", initialContent);
4923
- }
4924
- args.push("--");
5135
+ const args = buildFireSpawnArgs({
5136
+ selectedBackend,
5137
+ initialContent,
5138
+ launchConfig,
5139
+ });
4925
5140
 
4926
5141
  const env = {
4927
5142
  ...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