@love-moon/conductor-cli 0.13.0 → 0.13.2

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,83 @@
1
1
  # @love-moon/conductor-cli
2
2
 
3
+ ## 0.13.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 6547b2c: Add read-only daemon queries and archived-task search to the CLI:
8
+ `conductor daemon list [--all]` (online daemons, version, AI backends),
9
+ `conductor daemon tools <host>` (installed AI tools and reachability),
10
+ `conductor daemon quota <host> [--tool] [--refresh]` (usage windows / balance
11
+ per tool), and `conductor task list --archived [--search] [--all-projects]
12
+ [--page]`. The SDK's `BackendApiClient` gains `listAgents`,
13
+ `getAiManagerStatus`, `getAiManagerQuota` and `listAchievedTasks` over the
14
+ existing web API routes; no server change.
15
+ - 8c2833d: Support `/compact [focus instructions]` in task chats. Fire detects the command
16
+ per message (like `/goal`), runs the backend's native context compaction
17
+ instead of sending the text to the model, and posts one confirmation with the
18
+ token savings. Sessions advertise `capabilities.compact` and implement the new
19
+ optional `runCompact()`: claude (native `/compact`), codex app-server
20
+ (`thread/compact/start`), copilot (`session.history.compact`), kimi wire and
21
+ legacy print (built-in `/compact`), opencode (`session.summarize`) and dsh
22
+ (summary turn, then a fresh session seeded with the summary). Backends without
23
+ the capability (chat-web, codex exec, Kimi Code prompt mode) reply with a
24
+ not-supported notice. Opencode compaction summaries no longer surface as chat
25
+ replies, and dsh clears its "compacting context" status when automatic
26
+ compaction ends.
27
+ - c3a55b8: Fix tmux-mode Fire launches that failed before Fire started. The daemon now
28
+ drops an inherited `TMUX`/`TMUX_PANE` at startup, so a daemon launched from
29
+ another user's tmux pane (e.g. `su` from root's session) no longer aims every
30
+ tmux call at that user's socket and fails with `error connecting to
31
+ /tmp/tmux-0/default (Permission denied)`. A launch whose tmux argv would exceed
32
+ tmux's 16KB command limit (`command too long`), such as a persistent-round
33
+ prompt with a long summary, now runs through a self-deleting 0600 launch script
34
+ instead of `bash -c <command>`.
35
+ - 8e3a4c3: Report how many model tokens each turn consumed. After every turn (or `/goal`)
36
+ `conductor fire` sends the turn's token count (fresh input + cache reads/writes +
37
+ output) over the new `task_turn_usage` websocket event, and the server adds it
38
+ to the task's running total shown in the task detail card. A persistent task's
39
+ total restarts with each round.
40
+
41
+ Failed or interrupted turns and `/compact` are counted too; when a turn's usage
42
+ is unknown the fire reports `null`, which clears the task's last-turn count.
43
+
44
+ - `@love-moon/conductor-sdk`: new `ConductorClient.sendTurnUsage(taskId, { tokens })`.
45
+ - `@love-moon/ai-sdk`: the Codex app-server provider's turn/goal `usage` now
46
+ carries `turnTotalTokens`, the turn's share of Codex's thread-cumulative total.
47
+ Claude and Codex turn errors carry the tokens spent before the failure as
48
+ `error.usage` (Claude sums streamed usage when an interrupted query ends
49
+ without a result).
50
+ - Claude and Codex backends are counted; other backends report nothing yet.
51
+
52
+ - 6989999: `conductor task list --include-moved` also lists tasks moved into the project
53
+ from other projects (`project_id == P OR second_project_id == P`), with a MOVED
54
+ column naming the origin/target project. `--json` output now carries
55
+ `secondProjectId`. The SDK gains `TasksApi.listTasks({ includeMoved })`,
56
+ `BackendApiClient.listTasks({ projectScope: 'display' })` and
57
+ `Task/TaskSummary.secondProjectId`; it merges the server's existing real and
58
+ display scopes, so no server change is needed.
59
+ - Updated dependencies [6547b2c]
60
+ - Updated dependencies [8c2833d]
61
+ - Updated dependencies [8e3a4c3]
62
+ - Updated dependencies [6989999]
63
+ - @love-moon/conductor-sdk@0.13.2
64
+ - @love-moon/ai-sdk@0.13.2
65
+
66
+ ## 0.13.1
67
+
68
+ ### Patch Changes
69
+
70
+ - 3948125: Support persistent task rounds (RFC 0039). A `create_task` carrying
71
+ `replace_existing_fire: true` releases the task's previous fire before starting
72
+ the new round instead of ignoring it as a duplicate: tmux sessions are probed
73
+ (and killed if still alive), child processes are stopped and awaited, the old
74
+ fire's terminal status is suppressed, and its undelivered KILLED/COMPLETED
75
+ events are purged from the reused directory. The tmux liveness reaper also stops
76
+ reporting a dead session for a task whose new round is still starting. The
77
+ daemon advertises this as the `persistent_round_v1` capability.
78
+ - @love-moon/conductor-sdk@0.13.1
79
+ - @love-moon/ai-sdk@0.13.1
80
+
3
81
  ## 0.13.0
4
82
 
5
83
  ### Minor Changes
@@ -0,0 +1,185 @@
1
+ /**
2
+ * conductor daemon list|tools|quota — read-only queries about daemons.
3
+ *
4
+ * list [--all] Online daemons, CLI version, AI backends
5
+ * tools <host> AI tools installed on a daemon + network
6
+ * quota <host> [--tool t] [--refresh] Usage windows / balance per AI tool
7
+ *
8
+ * Dispatched from conductor-daemon.js before any daemon startup logic.
9
+ */
10
+
11
+ import process from "node:process";
12
+
13
+ import yargs from "yargs/yargs";
14
+
15
+ import { EXIT, buildApis, pad, printJson, printPretty, reportError } from "../src/entity-helpers.js";
16
+
17
+ export const DAEMON_QUERY_VERBS = new Set(["list", "tools", "quota"]);
18
+
19
+ // Ephemeral per-task `conductor fire` processes also register as agents.
20
+ const FIRE_HOST_PREFIX = "conductor-fire-";
21
+
22
+ const WINDOW_LABELS = { fiveHour: "5h", weekly: "weekly", weeklySonnet: "weekly-sonnet" };
23
+
24
+ function printTable(stream, header, rows) {
25
+ const widths = header
26
+ .slice(0, -1)
27
+ .map((title, i) => Math.max(title.length, ...rows.map((row) => String(row[i]).length)));
28
+ for (const row of [header, ...rows]) {
29
+ printPretty(stream, row.map((cell, i) => (i < widths.length ? pad(cell, widths[i]) : cell)).join(" "));
30
+ }
31
+ }
32
+
33
+ function formatReset(window) {
34
+ if (typeof window.resetAt !== "number") return window.resetOnDate ?? "";
35
+ const date = new Date(window.resetAt * 1000);
36
+ const two = (n) => String(n).padStart(2, "0");
37
+ return `${two(date.getMonth() + 1)}-${two(date.getDate())} ${two(date.getHours())}:${two(date.getMinutes())}`;
38
+ }
39
+
40
+ function formatQuota(quota) {
41
+ if (!quota || typeof quota !== "object") return "";
42
+ if (quota.error) {
43
+ // Provider errors can embed multi-line response bodies; --json keeps the full text.
44
+ const text = String(quota.error).replace(/\s+/g, " ").trim();
45
+ return `error: ${text.length > 160 ? `${text.slice(0, 157)}...` : text}`;
46
+ }
47
+ const parts = [];
48
+ for (const [key, window] of Object.entries(quota)) {
49
+ // Copilot's `primary` duplicates one of its named windows.
50
+ if (key === "primary" || typeof window?.usedPercent !== "number") continue;
51
+ const reset = formatReset(window);
52
+ parts.push(`${WINDOW_LABELS[key] ?? key} ${Math.round(window.usedPercent)}% used${reset ? ` (resets ${reset})` : ""}`);
53
+ }
54
+ if (quota.credits?.unlimited) parts.push("credits unlimited");
55
+ else if (quota.credits?.balance != null) parts.push(`credits ${quota.credits.balance}`);
56
+ if (quota.primaryBalance) {
57
+ parts.push(`balance ${quota.primaryBalance.totalBalance} ${quota.primaryBalance.currency}`);
58
+ }
59
+ if (quota.overage?.status) {
60
+ const reason = quota.overage.disabledReason ? ` (${quota.overage.disabledReason})` : "";
61
+ parts.push(`overage ${quota.overage.status}${reason}`);
62
+ }
63
+ return parts.join(" · ") || "(no usage data)";
64
+ }
65
+
66
+ async function handleList(argv, deps) {
67
+ const { apiClient } = await buildApis(deps);
68
+ const agents = (await apiClient.listAgents())
69
+ .filter((agent) => argv.all || !String(agent.host).startsWith(FIRE_HOST_PREFIX));
70
+ if (argv.json) {
71
+ printJson(deps.stdout, agents);
72
+ return EXIT.OK;
73
+ }
74
+ if (agents.length === 0) {
75
+ printPretty(deps.stdout, "(no daemons online)");
76
+ return EXIT.OK;
77
+ }
78
+ printTable(deps.stdout, ["HOST", "VERSION", "BACKENDS"], agents.map((agent) => [
79
+ agent.shared ? `${agent.host} (shared by ${agent.ownerLabel})` : agent.host,
80
+ agent.version ?? "",
81
+ (agent.supportedBackends ?? []).join(","),
82
+ ]));
83
+ return EXIT.OK;
84
+ }
85
+
86
+ async function handleTools(argv, deps) {
87
+ const { apiClient } = await buildApis(deps);
88
+ const status = await apiClient.getAiManagerStatus(argv.host);
89
+ if (argv.json) {
90
+ printJson(deps.stdout, status);
91
+ return EXIT.OK;
92
+ }
93
+ const install = status?.install ?? {};
94
+ const network = status?.network ?? {};
95
+ printTable(deps.stdout, ["TOOL", "INSTALLED", "VERSION", "NETWORK"], Object.keys(install).map((tool) => {
96
+ const net = network[tool];
97
+ const reach = !install[tool]?.installed || !net
98
+ ? ""
99
+ : net.reachable
100
+ ? `ok${net.latencyMs != null ? ` (${net.latencyMs}ms)` : ""}`
101
+ : `unreachable${net.error ? `: ${net.error}` : ""}`;
102
+ return [tool, install[tool]?.installed ? "yes" : "no", install[tool]?.version ?? "", reach];
103
+ }));
104
+ return EXIT.OK;
105
+ }
106
+
107
+ async function handleQuota(argv, deps) {
108
+ const { apiClient } = await buildApis(deps);
109
+ const quota = await apiClient.getAiManagerQuota(argv.host, {
110
+ tool: argv.tool,
111
+ forceRefresh: argv.refresh,
112
+ });
113
+ if (argv.json) {
114
+ printJson(deps.stdout, quota);
115
+ return EXIT.OK;
116
+ }
117
+ const { external, ...tools } = quota ?? {};
118
+ const entries = [...Object.entries(tools), ...Object.entries(external ?? {})];
119
+ if (entries.length === 0) {
120
+ printPretty(deps.stdout, "(no quota data)");
121
+ return EXIT.OK;
122
+ }
123
+ printTable(deps.stdout, ["TOOL", "SOURCE", "QUOTA"], entries.map(([tool, entry]) => [
124
+ tool,
125
+ entry?.source ?? "",
126
+ formatQuota(entry),
127
+ ]));
128
+ return EXIT.OK;
129
+ }
130
+
131
+ export async function main(argvInput, deps = {}) {
132
+ const stdout = deps.stdout || process.stdout;
133
+ const stderr = deps.stderr || process.stderr;
134
+ const consoleErr = { error: (msg) => stderr.write(`${msg}\n`) };
135
+ const handlerDeps = { ...deps, stdout, stderr, env: deps.env || process.env, cwd: deps.cwd || process.cwd() };
136
+ let exitCode = EXIT.OK;
137
+ const run = (handler) => async (argv) => {
138
+ exitCode = await handler(argv, { ...handlerDeps, configFile: argv.configFile });
139
+ };
140
+
141
+ try {
142
+ await yargs(argvInput)
143
+ .scriptName("conductor daemon")
144
+ .strict()
145
+ .help()
146
+ .option("json", { type: "boolean", default: false })
147
+ .option("config-file", { type: "string", describe: "Path to Conductor config file" })
148
+ .command(
149
+ "list",
150
+ "List online daemons with their CLI version and AI backends",
151
+ (cmd) => cmd.option("all", {
152
+ type: "boolean",
153
+ default: false,
154
+ describe: `Include ephemeral ${FIRE_HOST_PREFIX}* hosts`,
155
+ }),
156
+ run(handleList),
157
+ )
158
+ .command(
159
+ "tools <host>",
160
+ "Show which AI tools are installed on a daemon and whether they are reachable",
161
+ (cmd) => cmd.positional("host", { type: "string", describe: "Daemon host name" }),
162
+ run(handleTools),
163
+ )
164
+ .command(
165
+ "quota <host>",
166
+ "Show usage windows / balance for each AI tool on a daemon",
167
+ (cmd) => cmd
168
+ .positional("host", { type: "string", describe: "Daemon host name" })
169
+ .option("tool", { type: "string", describe: "Only this tool, e.g. claude or codex" })
170
+ .option("refresh", { type: "boolean", default: false, describe: "Bypass the daemon's quota cache" }),
171
+ run(handleQuota),
172
+ )
173
+ .demandCommand(1)
174
+ .fail((msg, err) => {
175
+ // Throw so the command handler does not run after a validation error
176
+ // (e.g. a missing <host>); yargs keeps going if fail() returns.
177
+ if (err) throw err;
178
+ throw Object.assign(new Error(msg), { code: "ARGS" });
179
+ })
180
+ .parseAsync();
181
+ } catch (err) {
182
+ exitCode = reportError(consoleErr, err);
183
+ }
184
+ return exitCode;
185
+ }
@@ -20,9 +20,19 @@ import {
20
20
  describeForceRestartRefusal,
21
21
  parseDaemonLockState,
22
22
  } from "../src/daemon-lock.js";
23
+ import { DAEMON_QUERY_VERBS, main as runDaemonQuery } from "./conductor-daemon-query.js";
23
24
 
24
25
  const argv = hideBin(process.argv);
25
26
 
27
+ // `conductor daemon list|tools|quota` are read-only queries, not a daemon launch.
28
+ // Exit (so startup below never runs) only after stdout drains: a piped
29
+ // --json payload over 64KB would otherwise be cut off.
30
+ if (DAEMON_QUERY_VERBS.has(argv[0])) {
31
+ const code = await runDaemonQuery(argv);
32
+ await new Promise((resolve) => process.stdout.write("", resolve));
33
+ process.exit(code);
34
+ }
35
+
26
36
  const CLI_NAME = process.env.CONDUCTOR_CLI_NAME || "conductor-daemon";
27
37
  const CONDUCTOR_HOME = resolveConductorHome();
28
38
 
@@ -170,6 +180,7 @@ const args = yargs(argv)
170
180
  .example(`$0 --config-file ${resolveConductorConfigPath()}`, "Run with daemon_name from config")
171
181
  .example("$0 --nohup", "Run daemon in background with logfile")
172
182
  .example("$0 --nohup --force", "Restart daemon in background by stopping the existing one")
183
+ .example("$0 list | tools <host> | quota <host>", "Query online daemons, their AI tools and quota")
173
184
  .help()
174
185
  .strict()
175
186
  .parse();
@@ -28,6 +28,7 @@ import {
28
28
  } from "../src/fire/resume.js";
29
29
  import {
30
30
  filterRuntimeSupportedAllowCliList,
31
+ inferBuiltInRuntimeBackendFromCommand,
31
32
  isBuiltInRuntimeBackend,
32
33
  isCommandOptionalBuiltInRuntimeBackend,
33
34
  listAdvertisedBackends,
@@ -218,7 +219,22 @@ export function resolveAiSessionCommandLine(backend, allowCliList, env = process
218
219
  const daemonCommand =
219
220
  typeof env?.CONDUCTOR_CLI_COMMAND === "string" ? env.CONDUCTOR_CLI_COMMAND.trim() : "";
220
221
 
221
- const resolvedCommand = configuredCommand || daemonCommand;
222
+ // CONDUCTOR_CLI_COMMAND may be stale: a tmux-mode Fire inherits the tmux
223
+ // SERVER's global environment for variables the daemon did not explicitly
224
+ // override, so a value left by another backend's task (e.g. `claude --model
225
+ // opus` reaching a dsh task) can leak in. Only honor the fallback when the
226
+ // command does not provably belong to a different built-in backend. Commands
227
+ // whose executable is not a known backend name (custom wrappers) are kept.
228
+ const inferredDaemonBackend = daemonCommand ? inferBuiltInRuntimeBackendFromCommand(daemonCommand) : "";
229
+ const usableDaemonCommand =
230
+ daemonCommand &&
231
+ (!inferredDaemonBackend ||
232
+ inferredDaemonBackend === normalizedSessionBackend ||
233
+ inferredDaemonBackend === normalizedBackend)
234
+ ? daemonCommand
235
+ : "";
236
+
237
+ const resolvedCommand = configuredCommand || usableDaemonCommand;
222
238
  if (!resolvedCommand) {
223
239
  return "";
224
240
  }
@@ -246,6 +262,12 @@ const DEFAULT_POLL_INTERVAL_MS = parseInt(
246
262
  const DEFAULT_ERROR_LOOP_WINDOW_MS = 2 * 60 * 1000;
247
263
  const DEFAULT_ERROR_LOOP_BACKOFF_MS = 3 * 60 * 1000;
248
264
  const DEFAULT_ERROR_LOOP_THRESHOLD = 3;
265
+ // A running turn that has sent no runtime status for this long reports the
266
+ // provider's current tool so the app does not mistake a long tool for a hang.
267
+ const DEFAULT_RUNTIME_HEARTBEAT_MS = 60 * 1000;
268
+ const RUNTIME_HEARTBEAT_QUERY_TIMEOUT_MS = 5000;
269
+ // Keep the line within the web chat status pill.
270
+ const RUNTIME_HEARTBEAT_LINE_MAX_CHARS = 100;
249
271
  // Runtime backend tokens (first word of the resolved command line) that mean
250
272
  // "this task drives a chat-web Chromium browser". Mirrors ai-sdk's chat-web
251
273
  // aliases; user-facing aliases like `web-chatgpt` resolve to one of these.
@@ -738,6 +760,11 @@ async function main() {
738
760
  return await pendingRemoteInterruptQueue.enqueue(event);
739
761
  };
740
762
 
763
+ const handleReportRuntimeStatusCommand = async (event) => {
764
+ fireWatchdog.onInbound();
765
+ await reconnectRunner?.requestRuntimeStatusFromRemote?.(event || {});
766
+ };
767
+
741
768
  const rememberCompletedRefreshSessionRequest = (requestId, accepted) => {
742
769
  if (!requestId) {
743
770
  return accepted;
@@ -828,6 +855,7 @@ async function main() {
828
855
  onStopTask: handleStopTaskCommand,
829
856
  onInterruptTurn: handleInterruptTurnCommand,
830
857
  onRefreshSession: handleRefreshSessionCommand,
858
+ onReportRuntimeStatus: handleReportRuntimeStatusCommand,
831
859
  });
832
860
 
833
861
  const taskContext = await ensureTaskContext(conductor, {
@@ -1553,6 +1581,43 @@ export function parseGoalDirectiveFromMessage(content) {
1553
1581
  return objective ? { objective } : null;
1554
1582
  }
1555
1583
 
1584
+ /**
1585
+ * Per-message `/compact` detector: first non-empty line is `/compact`
1586
+ * (case-insensitive), optionally followed by focus instructions; any remaining
1587
+ * lines are appended to them. Returns null when there is no directive.
1588
+ */
1589
+ export function parseCompactDirectiveFromMessage(content) {
1590
+ if (typeof content !== "string") {
1591
+ return null;
1592
+ }
1593
+ const lines = content.split("\n");
1594
+ let i = 0;
1595
+ while (i < lines.length && lines[i].trim() === "") {
1596
+ i += 1;
1597
+ }
1598
+ const match = i < lines.length ? lines[i].trim().match(/^\/compact(?:\s+(.*))?$/i) : null;
1599
+ if (!match) {
1600
+ return null;
1601
+ }
1602
+ const rest = lines.slice(i + 1).join("\n").trim();
1603
+ return { instructions: [(match[1] || "").trim(), rest].filter(Boolean).join("\n\n") };
1604
+ }
1605
+
1606
+ export function formatCompactReply(backendName, compact, instructions = "") {
1607
+ if (compact?.status !== "compacted") {
1608
+ return `${backendName} 当前没有可压缩的上下文。`;
1609
+ }
1610
+ const count = (value) => Number(value).toLocaleString("en-US");
1611
+ const detail =
1612
+ Number.isFinite(compact.preTokens) && Number.isFinite(compact.postTokens)
1613
+ ? `(约 ${count(compact.preTokens)} → ${count(compact.postTokens)} tokens)`
1614
+ : Number.isFinite(compact.tokensRemoved)
1615
+ ? `(释放约 ${count(compact.tokensRemoved)} tokens)`
1616
+ : "";
1617
+ const ignored = instructions && !compact.instructionsApplied ? `\n${backendName} 不支持压缩附加说明,已忽略。` : "";
1618
+ return `${backendName} 上下文已压缩${detail}。${ignored}`;
1619
+ }
1620
+
1556
1621
  export function injectResolvedTaskId(taskId, env = process.env) {
1557
1622
  const normalizedTaskId = normalizeTaskId(taskId);
1558
1623
  if (!normalizedTaskId) {
@@ -1806,15 +1871,28 @@ async function resolveDefaultProjectId(conductor) {
1806
1871
  }
1807
1872
  }
1808
1873
 
1809
- function deriveTaskTitle(prompt, explicit, backend = "codex") {
1874
+ // Keep in sync with web/src/features/tasks/utils/default-task-title.ts: first clause,
1875
+ // capped at 20 display columns (CJK/full-width/emoji = 2) so it fits the mobile header.
1876
+ export function deriveTaskTitle(prompt, explicit, backend = "codex") {
1810
1877
  if (explicit && explicit.trim()) {
1811
1878
  return explicit.trim();
1812
1879
  }
1813
- if (prompt) {
1814
- const compact = prompt.replace(/\s+/g, " ").trim();
1815
- if (compact) {
1816
- return compact.slice(0, 80);
1880
+ const text = (prompt || "").trim();
1881
+ const breakIndex = text.search(/[,。!?;\n]|[,.!?;](?=\s|$)/);
1882
+ const clause = (breakIndex > 0 ? text.slice(0, breakIndex) : text).replace(/\s+/g, " ");
1883
+ let title = "";
1884
+ let width = 0;
1885
+ for (const char of clause) {
1886
+ width += char.codePointAt(0) >= 0x2e80 ? 2 : 1;
1887
+ if (width > 20) {
1888
+ // Never cut an English word in half; drop the partial word unless it is the only one.
1889
+ if (/\w$/.test(title) && /\w/.test(char)) title = title.replace(/\s*\w+$/, "") || title;
1890
+ break;
1817
1891
  }
1892
+ title += char;
1893
+ }
1894
+ if (title.trim()) {
1895
+ return title.trim();
1818
1896
  }
1819
1897
  const backendName = backend.charAt(0).toUpperCase() + backend.slice(1);
1820
1898
  return `${backendName} Task`;
@@ -1917,6 +1995,14 @@ function isTruthyEnv(value) {
1917
1995
  return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
1918
1996
  }
1919
1997
 
1998
+ function formatElapsedShort(ms) {
1999
+ const seconds = Math.max(0, Math.floor(ms / 1000));
2000
+ if (seconds < 60) return `${seconds}s`;
2001
+ const minutes = Math.floor(seconds / 60);
2002
+ if (minutes < 60) return `${minutes}m`;
2003
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
2004
+ }
2005
+
1920
2006
  function isSessionClosedError(error) {
1921
2007
  return Boolean(error && typeof error === "object" && error.reason === "session_closed");
1922
2008
  }
@@ -2018,6 +2104,14 @@ export class BridgeRunner {
2018
2104
  500,
2019
2105
  60 * 1000,
2020
2106
  );
2107
+ this.runtimeHeartbeatMs = getBoundedEnvInt(
2108
+ "CONDUCTOR_RUNTIME_HEARTBEAT_MS",
2109
+ DEFAULT_RUNTIME_HEARTBEAT_MS,
2110
+ 1000,
2111
+ 10 * 60 * 1000,
2112
+ );
2113
+ this.runtimeHeartbeatTimer = null;
2114
+ this.turnStartedAt = 0;
2021
2115
  this.useSessionFileReplyStream =
2022
2116
  Boolean(this.backendSession) &&
2023
2117
  typeof this.backendSession.usesSessionFileReplyStream === "function" &&
@@ -2853,7 +2947,7 @@ export class BridgeRunner {
2853
2947
  };
2854
2948
  }
2855
2949
 
2856
- async reportRuntimeStatus(payload, replyTo) {
2950
+ async reportRuntimeStatus(payload, replyTo, { force = false } = {}) {
2857
2951
  const runtimeContext = await this.resolveRuntimeContext();
2858
2952
  const runtime = this.createRuntimeStatus(payload, replyTo, runtimeContext);
2859
2953
  if (!runtime) {
@@ -2861,10 +2955,14 @@ export class BridgeRunner {
2861
2955
  }
2862
2956
 
2863
2957
  const signature = JSON.stringify(runtime);
2864
- if (signature === this.lastRuntimeStatusSignature) {
2958
+ if (!force && signature === this.lastRuntimeStatusSignature) {
2865
2959
  return;
2866
2960
  }
2867
- this.lastRuntimeStatusSignature = signature;
2961
+ // Heartbeats keep the provider's last signature, so its unchanged periodic
2962
+ // status (e.g. claude tool_progress) does not replace the heartbeat line.
2963
+ if (!force) {
2964
+ this.lastRuntimeStatusSignature = signature;
2965
+ }
2868
2966
  this.lastRuntimeStatusPayload = {
2869
2967
  ...runtime,
2870
2968
  };
@@ -2882,6 +2980,109 @@ export class BridgeRunner {
2882
2980
  } catch (error) {
2883
2981
  log(`Failed to report runtime status: ${error?.message || error}`);
2884
2982
  }
2983
+ this.scheduleRuntimeHeartbeat();
2984
+ }
2985
+
2986
+ /** (Re)start the silence timer; a no-op that just clears it outside a turn. */
2987
+ scheduleRuntimeHeartbeat() {
2988
+ clearTimeout(this.runtimeHeartbeatTimer);
2989
+ this.runtimeHeartbeatTimer = null;
2990
+ if (!this.runningTurn || this.stopped) {
2991
+ return;
2992
+ }
2993
+ this.runtimeHeartbeatTimer = setTimeout(() => {
2994
+ this.runtimeHeartbeatTimer = null;
2995
+ void this.reportRuntimeHeartbeat();
2996
+ }, this.runtimeHeartbeatMs);
2997
+ this.runtimeHeartbeatTimer.unref?.();
2998
+ }
2999
+
3000
+ async fetchBackendTurnStatus() {
3001
+ const session = this.backendSession;
3002
+ if (typeof session?.fetchCurrentTurnStatus === "function") {
3003
+ let timer = null;
3004
+ try {
3005
+ return await Promise.race([
3006
+ session.fetchCurrentTurnStatus(),
3007
+ new Promise((_, reject) => {
3008
+ timer = setTimeout(() => reject(new Error("turn status query timed out")), RUNTIME_HEARTBEAT_QUERY_TIMEOUT_MS);
3009
+ }),
3010
+ ]);
3011
+ } catch {
3012
+ // fall back to the last status the session pushed
3013
+ } finally {
3014
+ clearTimeout(timer);
3015
+ }
3016
+ }
3017
+ try {
3018
+ return session?.getCurrentTurnStatus?.() ?? null;
3019
+ } catch {
3020
+ return null;
3021
+ }
3022
+ }
3023
+
3024
+ formatRuntimeHeartbeatLine(status, now = Date.now()) {
3025
+ const clip = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
3026
+ const tool = status?.active_tool;
3027
+ const startedAt = Date.parse(tool?.started_at || "") || this.turnStartedAt || now;
3028
+ const elapsed = formatElapsedShort(now - startedAt);
3029
+ // Elapsed time goes before the tool input so it survives truncation.
3030
+ if (tool?.name) {
3031
+ const summary = tool.summary ? `: ${tool.summary}` : "";
3032
+ return clip(`${this.backendName} running ${tool.name} (${elapsed})${summary}`, RUNTIME_HEARTBEAT_LINE_MAX_CHARS);
3033
+ }
3034
+ const base = status?.status_line || `${this.backendName} is working`;
3035
+ return `${clip(base, RUNTIME_HEARTBEAT_LINE_MAX_CHARS - elapsed.length - 3)} (${elapsed})`;
3036
+ }
3037
+
3038
+ async reportRuntimeHeartbeat() {
3039
+ if (!this.runningTurn || this.stopped) {
3040
+ return;
3041
+ }
3042
+ const lastSent = this.lastRuntimeStatusPayload;
3043
+ const status = await this.fetchBackendTurnStatus();
3044
+ // Skip if the turn ended, the provider settled it, or a newer frame (e.g.
3045
+ // the terminal one) went out while querying: never re-open a settled turn.
3046
+ if (
3047
+ !this.runningTurn ||
3048
+ this.stopped ||
3049
+ status?.reply_in_progress === false ||
3050
+ this.lastRuntimeStatusPayload !== lastSent
3051
+ ) {
3052
+ return;
3053
+ }
3054
+ await this.reportRuntimeStatus(
3055
+ {
3056
+ state: status?.state,
3057
+ phase: status?.phase || "heartbeat",
3058
+ reply_in_progress: true,
3059
+ status_line: this.formatRuntimeHeartbeatLine(status),
3060
+ },
3061
+ status?.replyTo || this.activeTurnReplyTo,
3062
+ // Forced so an unchanged line still goes out and re-arms the timer.
3063
+ { force: true },
3064
+ );
3065
+ }
3066
+
3067
+ /** App asked (page load / stale composer) for the current runtime status. */
3068
+ async requestRuntimeStatusFromRemote(event = {}) {
3069
+ const taskId = typeof event.taskId === "string" ? event.taskId.trim() : "";
3070
+ if (taskId && taskId !== this.taskId) {
3071
+ return;
3072
+ }
3073
+ if (this.runningTurn) {
3074
+ await this.reportRuntimeHeartbeat();
3075
+ return;
3076
+ }
3077
+ if (this.lastRuntimeStatusPayload?.reply_in_progress) {
3078
+ // No turn is running, so an in-progress frame is stale; settle it.
3079
+ this.lastRuntimeStatusPayload = {
3080
+ ...this.lastRuntimeStatusPayload,
3081
+ reply_in_progress: false,
3082
+ status_line: undefined,
3083
+ };
3084
+ }
3085
+ await this.replayLastRuntimeStatus();
2885
3086
  }
2886
3087
 
2887
3088
  async replayLastRuntimeStatus() {
@@ -3134,6 +3335,7 @@ export class BridgeRunner {
3134
3335
  this.runningTurn = true;
3135
3336
  this.activeTurnReplyTo = this.normalizeReplyTarget(replyTo);
3136
3337
  const turnStartedAt = Date.now();
3338
+ this.turnStartedAt = turnStartedAt;
3137
3339
  let turnWatchdog = null;
3138
3340
  if (this.isCopilotBackend) {
3139
3341
  turnWatchdog = setInterval(() => {
@@ -3175,6 +3377,7 @@ export class BridgeRunner {
3175
3377
  useInitialImages,
3176
3378
  media,
3177
3379
  contextFiles,
3380
+ replyTo,
3178
3381
  onProgress: (payload) => {
3179
3382
  void this.reportRuntimeStatus(payload, replyTo);
3180
3383
  },
@@ -3316,6 +3519,7 @@ export class BridgeRunner {
3316
3519
  `turn end replyTo=${replyTo || "latest"} elapsedMs=${Date.now() - turnStartedAt} processedIds=${this.processedMessageIds.size}`,
3317
3520
  );
3318
3521
  this.runningTurn = false;
3522
+ this.scheduleRuntimeHeartbeat();
3319
3523
  }
3320
3524
  }
3321
3525
 
@@ -3381,10 +3585,17 @@ export class BridgeRunner {
3381
3585
  // best-effort
3382
3586
  }
3383
3587
 
3588
+ const compactDirective = hasAttachmentInputs ? null : parseCompactDirectiveFromMessage(content);
3589
+ if (compactDirective) {
3590
+ return this.runCompactCommand(compactDirective, options);
3591
+ }
3592
+
3384
3593
  if (willRunGoal) {
3385
- const goalResult = await this.backendSession.runGoal(
3386
- { objective: goalDirective.objective, source: { type: "manual" } },
3387
- options,
3594
+ const goalResult = await this.runWithTurnUsage(() =>
3595
+ this.backendSession.runGoal(
3596
+ { objective: goalDirective.objective, source: { type: "manual" } },
3597
+ options,
3598
+ ),
3388
3599
  );
3389
3600
  // Goal SDK is expected to return `{ text, goal, usage, metadata }`. We
3390
3601
  // normalize back into the runTurn-compatible shape downstream consumers
@@ -3401,7 +3612,81 @@ export class BridgeRunner {
3401
3612
  },
3402
3613
  };
3403
3614
  }
3404
- return this.backendSession.runTurn(content, options);
3615
+ return this.runWithTurnUsage(() => this.backendSession.runTurn(content, options));
3616
+ }
3617
+
3618
+ /** Runs one backend turn and reports its token count, including failed or interrupted turns. */
3619
+ async runWithTurnUsage(runTurn) {
3620
+ let result;
3621
+ try {
3622
+ result = await runTurn();
3623
+ } catch (error) {
3624
+ this.reportTurnUsage(error?.usage, { failed: true });
3625
+ throw error;
3626
+ }
3627
+ this.reportTurnUsage(result?.usage);
3628
+ return result;
3629
+ }
3630
+
3631
+ /**
3632
+ * Best-effort: report a turn's token count to the server. A failed turn with
3633
+ * unknown usage reports `null` so the task card drops the previous turn's count.
3634
+ */
3635
+ reportTurnUsage(usage, { failed = false } = {}) {
3636
+ const tokens = countTurnTokens(usage);
3637
+ if ((tokens === null && !failed) || typeof this.conductor?.sendTurnUsage !== "function") {
3638
+ return;
3639
+ }
3640
+ Promise.resolve()
3641
+ .then(() => this.conductor.sendTurnUsage(this.taskId, { tokens }))
3642
+ .catch((error) => {
3643
+ log(`Failed to report turn usage: ${error?.message || error}`);
3644
+ });
3645
+ }
3646
+
3647
+ /**
3648
+ * `/compact`: compact the backend's context instead of sending the message
3649
+ * to the model. Providers stay silent while compacting, so fire posts the
3650
+ * single confirmation itself; backends without the capability get a notice
3651
+ * rather than having `/compact` forwarded as prose.
3652
+ */
3653
+ async runCompactCommand({ instructions }, { onProgress, replyTo = "" } = {}) {
3654
+ const snapshot =
3655
+ typeof this.backendSession?.getSnapshot === "function" ? this.backendSession.getSnapshot() : null;
3656
+ const compactCapable =
3657
+ snapshot?.capabilities?.compact === true && typeof this.backendSession?.runCompact === "function";
3658
+ let result = null;
3659
+ let text;
3660
+ if (compactCapable) {
3661
+ onProgress?.({
3662
+ phase: "context_compaction",
3663
+ reply_in_progress: true,
3664
+ status_line: `${this.backendName} compacting context`,
3665
+ });
3666
+ result = await this.runWithTurnUsage(() => this.backendSession.runCompact({ instructions }, { onProgress }));
3667
+ text = formatCompactReply(this.backendName, result?.compact, instructions);
3668
+ } else {
3669
+ text = `${this.backendName} 不支持 /compact,未执行压缩。`;
3670
+ }
3671
+ // Fire owns this reply, so it also settles the status: providers' noop
3672
+ // paths return without emitting any progress of their own.
3673
+ onProgress?.({ phase: "turn_completed", reply_in_progress: false, status_done_line: text });
3674
+ log(`[compact] backend=${this.backendName} capable=${compactCapable} status=${result?.compact?.status || "unsupported"}`);
3675
+ if (this.useSessionFileReplyStream && !this.stopped) {
3676
+ try {
3677
+ await this.sendSessionStreamMessage({ text, replyTo });
3678
+ } catch (error) {
3679
+ log(`[compact] failed to post confirmation: ${error?.message || error}`);
3680
+ }
3681
+ }
3682
+ return {
3683
+ text,
3684
+ items: [],
3685
+ usage: result?.usage || null,
3686
+ provider: this.backendName,
3687
+ events: [],
3688
+ metadata: { ...(result?.metadata || {}), compact: result?.compact || null },
3689
+ };
3405
3690
  }
3406
3691
 
3407
3692
  async handlePrePromptMessage(content) {
@@ -3442,6 +3727,7 @@ export class BridgeRunner {
3442
3727
  this.lastRuntimeStatusSignature = null;
3443
3728
  this.runningTurn = true;
3444
3729
  const startedAt = Date.now();
3730
+ this.turnStartedAt = startedAt;
3445
3731
  if (
3446
3732
  this.useSessionFileReplyStream &&
3447
3733
  typeof this.backendSession?.setSessionReplyTarget === "function"
@@ -3470,6 +3756,7 @@ export class BridgeRunner {
3470
3756
  try {
3471
3757
  const result = await this.dispatchBackendTurn(content, {
3472
3758
  useInitialImages: Boolean(includeImages),
3759
+ replyTo: replyTarget,
3473
3760
  onProgress: (payload) => {
3474
3761
  void this.reportRuntimeStatus(payload, replyTarget);
3475
3762
  },
@@ -3523,6 +3810,7 @@ export class BridgeRunner {
3523
3810
  } finally {
3524
3811
  this.copilotLog(`${logTag} turn end elapsedMs=${Date.now() - startedAt}`);
3525
3812
  this.runningTurn = false;
3813
+ this.scheduleRuntimeHeartbeat();
3526
3814
  }
3527
3815
  }
3528
3816
 
@@ -3584,6 +3872,24 @@ function log(message) {
3584
3872
  appendFireLocalLog(line);
3585
3873
  }
3586
3874
 
3875
+ /**
3876
+ * Tokens one turn consumed: everything the model read (fresh input plus cache
3877
+ * reads/writes) and wrote. Claude reports per-turn usage; the Codex provider
3878
+ * derives `turnTotalTokens` from thread totals. Other shapes → null.
3879
+ */
3880
+ export function countTurnTokens(usage) {
3881
+ if (!usage || typeof usage !== "object") {
3882
+ return null;
3883
+ }
3884
+ if (Number.isFinite(usage.turnTotalTokens)) {
3885
+ return usage.turnTotalTokens;
3886
+ }
3887
+ const counts = ["input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens", "output_tokens"]
3888
+ .map((key) => Number(usage[key]))
3889
+ .filter(Number.isFinite);
3890
+ return counts.length > 0 ? counts.reduce((sum, count) => sum + count, 0) : null;
3891
+ }
3892
+
3587
3893
  function logBackendReply(backend, text, { usage, replyTo }) {
3588
3894
  const usageSuffix = usage?.total_tokens ? ` (tokens: ${usage.total_tokens})` : "";
3589
3895
  log(`${backend} reply (${replyTo}): ${text}${usageSuffix}`);
@@ -4,7 +4,8 @@
4
4
  * conductor task — entity-oriented task management.
5
5
  *
6
6
  * Subcommands:
7
- * list [--project ...] [--issue <id>] [--status ...]
7
+ * list [--project ...] [--issue <id>] [--status ...] [--include-moved]
8
+ * list --archived [--search <text>] [--all-projects] [--page N]
8
9
  * create --title <title> [--prompt <prompt>] [--backend <backend>]
9
10
  * [--parent-task-id <id>] [--project ...]
10
11
  * show <id>
@@ -59,6 +60,7 @@ function taskAsObject(task) {
59
60
  return {
60
61
  id: task.id,
61
62
  projectId: task.projectId,
63
+ secondProjectId: task.secondProjectId ?? null,
62
64
  issueId: task.issueId ?? null,
63
65
  title: task.title,
64
66
  status: task.status,
@@ -220,13 +222,83 @@ function formatContentPreview(value) {
220
222
  return `${text.slice(0, 57)}...`;
221
223
  }
222
224
 
225
+ // "from <project>" for tasks moved into `projectId`, "to <project>" for tasks
226
+ // moved out of it, "" otherwise. Project names are only fetched when needed.
227
+ async function buildMovedLabels(apis, projectId, tasks) {
228
+ const moved = tasks.map((task) => {
229
+ if (task.secondProjectId === projectId && task.projectId !== projectId) {
230
+ return { dir: "from", id: task.projectId };
231
+ }
232
+ if (task.projectId === projectId && task.secondProjectId && task.secondProjectId !== projectId) {
233
+ return { dir: "to", id: task.secondProjectId };
234
+ }
235
+ return null;
236
+ });
237
+ const names = new Map();
238
+ if (moved.some(Boolean)) {
239
+ for (const p of await apis.projects.listProjects({ includeHidden: true })) {
240
+ names.set(p.id, p.daemonHost ? `${p.name ?? p.id}@${p.daemonHost}` : (p.name ?? p.id));
241
+ }
242
+ }
243
+ return moved.map((m) => (m ? `${m.dir} ${names.get(m.id) ?? m.id}` : ""));
244
+ }
245
+
246
+ // Archived (achieved) tasks come from a separate search endpoint: 10 per page,
247
+ // matched against title and transcript, newest-archived first.
248
+ async function handleArchivedList(argv, deps, apis) {
249
+ const project = argv.allProjects
250
+ ? null
251
+ : await resolveProject(apis, { env: deps.env, cwd: deps.cwd, project: argv.project });
252
+ const result = await apis.apiClient.listAchievedTasks({
253
+ query: argv.search,
254
+ projectId: project?.id,
255
+ page: argv.page,
256
+ });
257
+ if (argv.json) {
258
+ printJson(deps.stdout, result);
259
+ return EXIT.OK;
260
+ }
261
+ if (result.tasks.length === 0) {
262
+ printPretty(deps.stdout, "(no archived tasks)");
263
+ return EXIT.OK;
264
+ }
265
+ const projectWidth = Math.max(7, ...result.tasks.map((task) => String(task.projectName ?? "").length));
266
+ printPretty(deps.stdout, `${pad("ID", 36)} ${pad("ARCHIVED", 10)} ${pad("PROJECT", projectWidth)} TITLE`);
267
+ for (const task of result.tasks) {
268
+ printPretty(
269
+ deps.stdout,
270
+ `${pad(task.id, 36)} ${pad(String(task.achievedAt ?? "").slice(0, 10), 10)} ${pad(task.projectName ?? "", projectWidth)} ${task.title ?? ""}`,
271
+ );
272
+ if (task.snippet) printPretty(deps.stdout, ` ${task.snippet}`);
273
+ }
274
+ if (result.totalPages > 1) {
275
+ printPretty(deps.stdout, `Page ${result.page}/${result.totalPages} (${result.total} archived tasks); use --page for more.`);
276
+ }
277
+ return EXIT.OK;
278
+ }
279
+
223
280
  async function handleList(argv, deps) {
281
+ // Validated here rather than with yargs implies/conflicts: those count the
282
+ // boolean defaults as "set", and the parser's fail() does not stop the handler.
283
+ let argsProblem = null;
284
+ if (!argv.archived && (argv.search !== undefined || argv.allProjects || argv.page !== undefined)) {
285
+ argsProblem = "--search, --all-projects and --page require --archived";
286
+ } else if (argv.archived && (argv.issue || argv.status || argv.includeMoved || (argv.allProjects && argv.project))) {
287
+ argsProblem = "--archived cannot be combined with --issue, --status, --include-moved, or --project with --all-projects";
288
+ }
289
+ if (argsProblem) {
290
+ const err = new Error(argsProblem);
291
+ err.code = "ARGS";
292
+ throw err;
293
+ }
224
294
  const apis = await buildApis(deps);
295
+ if (argv.archived) return handleArchivedList(argv, deps, apis);
225
296
  const project = await resolveProject(apis, { env: deps.env, cwd: deps.cwd, project: argv.project });
226
297
  const list = await apis.tasks.listTasks({
227
298
  projectId: project.id,
228
299
  issueId: argv.issue ? String(argv.issue) : undefined,
229
300
  status: parseStatusList(argv.status),
301
+ includeMoved: argv.includeMoved,
230
302
  });
231
303
  const objects = (Array.isArray(list) ? list : []).map(taskAsObject);
232
304
  if (argv.json) {
@@ -237,13 +309,16 @@ async function handleList(argv, deps) {
237
309
  printPretty(deps.stdout, "(no tasks)");
238
310
  return EXIT.OK;
239
311
  }
240
- printPretty(deps.stdout, `${pad("ID", 24)} ${pad("STATUS", 12)} TITLE`);
241
- for (const task of objects) {
312
+ const movedLabels = argv.includeMoved ? await buildMovedLabels(apis, project.id, objects) : null;
313
+ const movedWidth = movedLabels ? Math.max(5, ...movedLabels.map((label) => label.length)) : 0;
314
+ const movedCol = (label) => (movedLabels ? `${pad(label, movedWidth)} ` : "");
315
+ printPretty(deps.stdout, `${pad("ID", 24)} ${pad("STATUS", 12)} ${movedCol("MOVED")}TITLE`);
316
+ objects.forEach((task, i) => {
242
317
  printPretty(
243
318
  deps.stdout,
244
- `${pad(task.id, 24)} ${pad(task.status, 12)} ${task.title ?? ""}`,
319
+ `${pad(task.id, 24)} ${pad(task.status, 12)} ${movedCol(movedLabels?.[i] ?? "")}${task.title ?? ""}`,
245
320
  );
246
- }
321
+ });
247
322
  return EXIT.OK;
248
323
  }
249
324
 
@@ -571,7 +646,16 @@ export async function main(argvInput = hideBin(process.argv), deps = {}) {
571
646
  "List tasks in a project",
572
647
  (cmd) => cmd
573
648
  .option("issue", { type: "string", describe: "Filter by linked issue id" })
574
- .option("status", { type: "string", describe: "Comma-separated status filter" }),
649
+ .option("status", { type: "string", describe: "Comma-separated status filter" })
650
+ .option("include-moved", {
651
+ type: "boolean",
652
+ default: false,
653
+ describe: "Also list tasks moved into this project from other projects (adds a MOVED column)",
654
+ })
655
+ .option("archived", { type: "boolean", describe: "List archived tasks instead of active ones" })
656
+ .option("search", { type: "string", describe: "With --archived: match title or transcript text" })
657
+ .option("all-projects", { type: "boolean", describe: "With --archived: search every project" })
658
+ .option("page", { type: "number", describe: "With --archived: result page (10 per page)" }),
575
659
  async (argv) => {
576
660
  exitCode = await handleList(argv, { ...handlerDeps, configFile: argv.configFile });
577
661
  },
package/bin/conductor.js CHANGED
@@ -127,7 +127,7 @@ Usage: conductor <subcommand> [options]
127
127
 
128
128
  Subcommands:
129
129
  fire Run AI coding agents with Conductor integration
130
- daemon Start long-running daemon for task orchestration
130
+ daemon Start long-running daemon (or: daemon list/tools/quota to query daemons)
131
131
  config Interactive configuration setup
132
132
  update Update the CLI to the latest version
133
133
  diagnose Diagnose a task and print likely root cause
@@ -151,6 +151,7 @@ Examples:
151
151
  conductor fire -- "fix the bug"
152
152
  conductor fire --backend claude -- "add feature"
153
153
  conductor daemon --config-file ~/.conductor/config.yaml
154
+ conductor daemon quota macmini --tool claude
154
155
  conductor diagnose <task-id>
155
156
  conductor send-file ./screenshot.png
156
157
  conductor channel connect feishu
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-cli",
3
- "version": "0.13.0",
4
- "gitCommitId": "e1e507f",
3
+ "version": "0.13.2",
4
+ "gitCommitId": "d60a6a2",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/lovemoon-ai/conductor.git"
@@ -24,8 +24,8 @@
24
24
  "test": "node --test test/*.test.js"
25
25
  },
26
26
  "dependencies": {
27
- "@love-moon/ai-sdk": "0.13.0",
28
- "@love-moon/conductor-sdk": "0.13.0",
27
+ "@love-moon/ai-sdk": "0.13.2",
28
+ "@love-moon/conductor-sdk": "0.13.2",
29
29
  "@github/copilot-sdk": "^0.3.0",
30
30
  "chrome-launcher": "^1.2.1",
31
31
  "chrome-remote-interface": "^0.33.0",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "optionalDependencies": {
40
40
  "@roamhq/wrtc": "^0.10.0",
41
- "@love-moon/chat-web": "0.13.0"
41
+ "@love-moon/chat-web": "0.13.2"
42
42
  },
43
43
  "pnpm": {
44
44
  "onlyBuiltDependencies": [
package/src/daemon.js CHANGED
@@ -891,6 +891,27 @@ const PTY_TASK_SCOPED_ENV_KEYS = [
891
891
  "CONDUCTOR_RESUME_CWD",
892
892
  ];
893
893
 
894
+ // Task-scoped env vars a Fire must NEVER inherit from anywhere but this
895
+ // daemon's explicit choice. In tmux mode the new session merges the tmux
896
+ // SERVER's global environment (inherited from whatever task first started the
897
+ // server), so a variable the daemon sets only conditionally — e.g.
898
+ // CONDUCTOR_CLI_COMMAND, which command-optional backends (dsh/copilot) leave
899
+ // unset — would leak a stale value from an earlier task (2026-09-17: a dsh
900
+ // task inherited `claude --model opus` and forwarded model=opus to the
901
+ // DeepSeek API).
902
+ //
903
+ // The invariant is enforced at the single choke point `spawnFireProcess`:
904
+ // every key in this list is passed to `tmux new-session` explicitly — either
905
+ // with the daemon's value or cleared as `-e KEY=` (empty is our "unset"
906
+ // convention; every consumer trims/negates before use). RULE: any new
907
+ // task-scoped env var added to a Fire spawn must be registered here.
908
+ const FIRE_TASK_SCOPED_ENV_KEYS = [
909
+ ...PTY_TASK_SCOPED_ENV_KEYS,
910
+ "CONDUCTOR_AGENT_TOKEN",
911
+ "CONDUCTOR_BACKEND_URL",
912
+ "CONDUCTOR_DAEMON_NAME",
913
+ ];
914
+
894
915
  function stripPtyTaskScopedEnv(source) {
895
916
  const env = {
896
917
  ...(source && typeof source === "object" ? source : {}),
@@ -1167,6 +1188,13 @@ export function startDaemon(config = {}, deps = {}) {
1167
1188
  // below after we verify tmux is installed; if tmux is missing we log a
1168
1189
  // warning and silently fall back to direct spawn rather than failing every
1169
1190
  // create_task with ENOENT.
1191
+ //
1192
+ // The daemon is not inside any tmux pane, so drop the launching shell's
1193
+ // TMUX/TMUX_PANE: tmux prefers $TMUX over the per-uid default socket, and a
1194
+ // daemon started via `su` from root's tmux pointed every spawn/probe/kill at
1195
+ // /tmp/tmux-0/default ("Permission denied").
1196
+ delete process.env.TMUX;
1197
+ delete process.env.TMUX_PANE;
1170
1198
  const FIRE_TMUX_MODE_ENABLED = getFireTmuxModeEnabled(userConfig);
1171
1199
  // RFC 0035: a guest daemon runs on someone else's machine as a different
1172
1200
  // account. It keeps the ordinary daemon code path but drops the capabilities
@@ -1604,6 +1632,9 @@ export function startDaemon(config = {}, deps = {}) {
1604
1632
  return { attach, append, tail };
1605
1633
  }
1606
1634
 
1635
+ // Headroom under tmux's 16384-byte imsg limit for a client command's argv.
1636
+ const TMUX_ARGV_BYTE_BUDGET = 12 * 1024;
1637
+
1607
1638
  // Spawn the Fire CLI either directly (default) or inside a detached tmux
1608
1639
  // session (when FIRE_TMUX_MODE_ACTIVE). In tmux mode the returned `child`
1609
1640
  // is the short-lived `tmux new-session` client; once it exits with code 0
@@ -1692,6 +1723,17 @@ export function startDaemon(config = {}, deps = {}) {
1692
1723
  tmuxEnvFlags.push("-e", `${key}=${stringValue}`);
1693
1724
  }
1694
1725
 
1726
+ // Clear every task-scoped variable the daemon did NOT set for this task,
1727
+ // or the session would inherit a stale value from the tmux server's
1728
+ // global environment (see FIRE_TASK_SCOPED_ENV_KEYS). `-e KEY=` (empty)
1729
+ // works on old tmux versions that lack the `-e KEY` unset form, and all
1730
+ // our consumers treat "" as unset.
1731
+ for (const scopedKey of FIRE_TASK_SCOPED_ENV_KEYS) {
1732
+ if (env[scopedKey] === undefined || env[scopedKey] === null) {
1733
+ tmuxEnvFlags.push("-e", `${scopedKey}=`);
1734
+ }
1735
+ }
1736
+
1695
1737
  // Use a non-login `bash -c` here: node and the CLI script are passed by
1696
1738
  // absolute path, so we don't need PATH from a login shell, and `-l`
1697
1739
  // would slow startup and could leak unexpected stderr from user shell
@@ -1708,7 +1750,21 @@ export function startDaemon(config = {}, deps = {}) {
1708
1750
  "-c",
1709
1751
  shellCommand,
1710
1752
  ];
1711
- log(`Spawning Fire via tmux: session=${sessionName} cwd=${cwd}`);
1753
+ // tmux rejects a command whose argv exceeds its 16KB message limit
1754
+ // ("command too long"). The -e flags alone can take ~8KB on GPU hosts, so
1755
+ // a long prompt (e.g. a persistent-round summary) no longer fits: hand
1756
+ // bash a self-deleting 0600 script instead of `-c <command>`.
1757
+ let launchScriptPath = "";
1758
+ if (tmuxArgs.reduce((n, arg) => n + Buffer.byteLength(arg) + 1, 0) > TMUX_ARGV_BYTE_BUDGET) {
1759
+ launchScriptPath = path.join(FIRE_SESSION_REGISTRY_DIR, `${sessionName}.sh`);
1760
+ mkdirSyncFn(FIRE_SESSION_REGISTRY_DIR, { recursive: true });
1761
+ writeFileSyncFn(launchScriptPath, `rm -f -- "$0"\n${shellCommand}\n`, { mode: 0o600 });
1762
+ tmuxArgs.splice(-2, 2, launchScriptPath);
1763
+ }
1764
+ log(
1765
+ `Spawning Fire via tmux: session=${sessionName} cwd=${cwd}` +
1766
+ (launchScriptPath ? ` script=${launchScriptPath}` : ""),
1767
+ );
1712
1768
  const child = spawnFn("tmux", tmuxArgs, {
1713
1769
  cwd,
1714
1770
  env,
@@ -1718,6 +1774,18 @@ export function startDaemon(config = {}, deps = {}) {
1718
1774
  if (typeof child.unref === "function") {
1719
1775
  child.unref();
1720
1776
  }
1777
+ if (launchScriptPath) {
1778
+ // Without a session nothing will run (and delete) the script.
1779
+ const removeLaunchScript = () => {
1780
+ try {
1781
+ unlinkSyncFn(launchScriptPath);
1782
+ } catch {
1783
+ // already gone
1784
+ }
1785
+ };
1786
+ child.once("exit", (code) => code !== 0 && removeLaunchScript());
1787
+ child.once("error", removeLaunchScript);
1788
+ }
1721
1789
  return { child, tmuxSession: sessionName, exitMarkerToken };
1722
1790
  }
1723
1791
 
@@ -2001,7 +2069,7 @@ export function startDaemon(config = {}, deps = {}) {
2001
2069
  // *live* replacement as killed, using the previous run's log tail. Worse,
2002
2070
  // the daemon's own map would say running, so reconcile would never repair
2003
2071
  // it. Re-check that the task is still absent before speaking.
2004
- if (activeTaskProcesses.has(taskId)) {
2072
+ if (activeTaskProcesses.has(taskId) || pendingTaskStarts.has(taskId)) {
2005
2073
  log(
2006
2074
  `Task ${taskId} was restarted while its dead tmux session was being reported; dropping the stale terminal status`,
2007
2075
  );
@@ -3815,6 +3883,7 @@ export function startDaemon(config = {}, deps = {}) {
3815
3883
  "project_agents_registry",
3816
3884
  "restart_daemon",
3817
3885
  "refresh_session_inplace",
3886
+ "persistent_round_v1",
3818
3887
  "task_attachments_v1",
3819
3888
  "backend_session_list",
3820
3889
  CUSTOM_COMMANDS_CAPABILITY,
@@ -6947,6 +7016,42 @@ export function startDaemon(config = {}, deps = {}) {
6947
7016
  return true;
6948
7017
  }
6949
7018
 
7019
+ // RFC 0039: make sure the previous round's fire is gone before a new round
7020
+ // reuses its task id. The server has normally stopped it already, so what is
7021
+ // left is a still-exiting child or a tmux record the liveness reaper has not
7022
+ // swept yet; a fire that is somehow still alive is stopped here. Its terminal
7023
+ // status is suppressed because the task row now belongs to the new round.
7024
+ async function releaseReplacedFire(taskId) {
7025
+ const record = activeTaskProcesses.get(taskId);
7026
+ if (!recordHasLiveFire(record)) {
7027
+ return true;
7028
+ }
7029
+ if (record.tmuxMode) {
7030
+ const { alive, conclusive } = await probeTmuxSession(record.tmuxSession);
7031
+ if (activeTaskProcesses.get(taskId) !== record) {
7032
+ return !recordHasLiveFire(activeTaskProcesses.get(taskId));
7033
+ }
7034
+ if (!conclusive) {
7035
+ return false;
7036
+ }
7037
+ if (!alive) {
7038
+ activeTaskProcesses.delete(taskId);
7039
+ forgetFireSessionRecord(record);
7040
+ return true;
7041
+ }
7042
+ stopActiveTaskProcess(taskId, { reason: "persistent_new_round", suppressExitStatusReport: true });
7043
+ // The tmux stop path retires the record synchronously and never consumes the flag.
7044
+ suppressedExitStatusReports.delete(taskId);
7045
+ return !activeTaskProcesses.has(taskId);
7046
+ }
7047
+ stopActiveTaskProcess(taskId, { reason: "persistent_new_round", suppressExitStatusReport: true });
7048
+ const stopped = await waitForTaskToStop(taskId);
7049
+ if (!stopped) {
7050
+ suppressedExitStatusReports.delete(taskId);
7051
+ }
7052
+ return stopped;
7053
+ }
7054
+
6950
7055
  async function waitForTaskToStop(taskId, timeoutMs = DAEMON_FORCE_STOP_GRACE_MS) {
6951
7056
  const deadline = Date.now() + Math.max(timeoutMs, 0);
6952
7057
 
@@ -7537,6 +7642,23 @@ export function startDaemon(config = {}, deps = {}) {
7537
7642
  return;
7538
7643
  }
7539
7644
 
7645
+ // RFC 0039: a persistent task's new round reuses the task id. Release the
7646
+ // previous round's fire first instead of swallowing the round as a duplicate.
7647
+ const replacesPreviousRound = payload?.replace_existing_fire === true;
7648
+ if (
7649
+ replacesPreviousRound &&
7650
+ !pendingTaskStarts.has(taskId) &&
7651
+ !(await releaseReplacedFire(taskId))
7652
+ ) {
7653
+ reportCreateTaskFailure({
7654
+ taskId,
7655
+ projectId,
7656
+ requestId,
7657
+ error: new Error("the previous round is still running on this daemon"),
7658
+ });
7659
+ return;
7660
+ }
7661
+
7540
7662
  const existingTaskRecord = activeTaskProcesses.get(taskId);
7541
7663
  if (recordHasLiveFire(existingTaskRecord) || pendingTaskStarts.has(taskId)) {
7542
7664
  log(
@@ -7679,7 +7801,13 @@ export function startDaemon(config = {}, deps = {}) {
7679
7801
  // even when the daemon was named through the config file.
7680
7802
  CONDUCTOR_DAEMON_NAME: AGENT_NAME,
7681
7803
  CONDUCTOR_LAUNCHED_BY_DAEMON: "1",
7682
- ...(cliCommand ? { CONDUCTOR_CLI_COMMAND: cliCommand } : {}),
7804
+ // Always set CONDUCTOR_CLI_COMMAND — even to "" — so a tmux-mode Fire
7805
+ // gets an explicit `-e CONDUCTOR_CLI_COMMAND=` that overrides any stale
7806
+ // value in the tmux SERVER's global environment (inherited from whatever
7807
+ // task first started the server). Without the override, a commandless
7808
+ // backend (dsh/copilot) would see e.g. `claude --model opus` here and
7809
+ // mis-apply its flags. Fire treats "" as unset.
7810
+ CONDUCTOR_CLI_COMMAND: cliCommand,
7683
7811
  };
7684
7812
  if (AGENT_TOKEN) {
7685
7813
  env.CONDUCTOR_AGENT_TOKEN = AGENT_TOKEN;
@@ -7688,6 +7816,12 @@ export function startDaemon(config = {}, deps = {}) {
7688
7816
  env.CONDUCTOR_BACKEND_URL = BACKEND_HTTP;
7689
7817
  }
7690
7818
 
7819
+ // A new round may reuse the previous round's directory, and with it that
7820
+ // fire's undelivered KILLED/COMPLETED event (see restart below).
7821
+ if (replacesPreviousRound) {
7822
+ dropSupersededTerminalStatusEvents(taskDir, taskId);
7823
+ }
7824
+
7691
7825
  // Sampled before the spawn so it is a true "everything after this is
7692
7826
  // ours" watermark.
7693
7827
  const logStartOffset = readLogSize(logPath);
@@ -8422,7 +8556,10 @@ export function startDaemon(config = {}, deps = {}) {
8422
8556
  CONDUCTOR_TASK_ID: normalizedTargetTaskId,
8423
8557
  CONDUCTOR_DAEMON_NAME: AGENT_NAME,
8424
8558
  CONDUCTOR_LAUNCHED_BY_DAEMON: "1",
8425
- ...(cliCommand ? { CONDUCTOR_CLI_COMMAND: cliCommand } : {}),
8559
+ // Always set CONDUCTOR_CLI_COMMAND — even to "" — so a tmux-mode Fire
8560
+ // gets an explicit `-e CONDUCTOR_CLI_COMMAND=` that overrides any stale
8561
+ // value in the tmux SERVER's global environment (see create_task).
8562
+ CONDUCTOR_CLI_COMMAND: cliCommand,
8426
8563
  };
8427
8564
  env.CONDUCTOR_RESUME_CWD = resolvedResumeCwd;
8428
8565
  if (AGENT_TOKEN) {
@@ -11,6 +11,7 @@ import { loadServeAiRuntimeConfig } from "./config.js";
11
11
  import { sendJson, sendOpenAiError } from "./errors.js";
12
12
  import { materializeImageInputs } from "./image-handler.js";
13
13
  import {
14
+ inferBuiltInRuntimeBackendFromCommand,
14
15
  listAdvertisedBackends,
15
16
  normalizeRuntimeBackendAlias,
16
17
  normalizeRuntimeBackendName,
@@ -124,7 +125,7 @@ function extractModelOptionFromCommandLine(commandLine) {
124
125
  return "";
125
126
  }
126
127
 
127
- function resolveAiSessionCommandLine(backend, allowCliList, env = process.env, sessionBackend = backend) {
128
+ export function resolveAiSessionCommandLine(backend, allowCliList, env = process.env, sessionBackend = backend) {
128
129
  const normalizedBackend = normalizeRuntimeBackendName(backend);
129
130
  const normalizedSessionBackend = normalizeRuntimeBackendName(sessionBackend);
130
131
  const envKeyByBackend = {
@@ -148,7 +149,21 @@ function resolveAiSessionCommandLine(backend, allowCliList, env = process.env, s
148
149
  : "";
149
150
  const daemonCommand =
150
151
  typeof env?.CONDUCTOR_CLI_COMMAND === "string" ? env.CONDUCTOR_CLI_COMMAND.trim() : "";
151
- const resolvedCommand = configuredCommand || daemonCommand;
152
+
153
+ // CONDUCTOR_CLI_COMMAND may be stale (e.g. leaked from a tmux server's
154
+ // global environment, left by another backend's task). Only honor the
155
+ // fallback when the command does not provably belong to a different built-in
156
+ // backend. Mirrors the same guard in bin/conductor-fire.js.
157
+ const inferredDaemonBackend = daemonCommand ? inferBuiltInRuntimeBackendFromCommand(daemonCommand) : "";
158
+ const usableDaemonCommand =
159
+ daemonCommand &&
160
+ (!inferredDaemonBackend ||
161
+ inferredDaemonBackend === normalizedSessionBackend ||
162
+ inferredDaemonBackend === normalizedBackend)
163
+ ? daemonCommand
164
+ : "";
165
+
166
+ const resolvedCommand = configuredCommand || usableDaemonCommand;
152
167
  if (!resolvedCommand) {
153
168
  return "";
154
169
  }