@love-moon/conductor-cli 0.13.1 → 0.13.3

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,85 @@
1
1
  # @love-moon/conductor-cli
2
2
 
3
+ ## 0.13.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 9858ad5: Fix Update daemon deleting the running install when the upgrade failed. Any
8
+ failed `npm install -g` (a timeout on a slow registry, a node-gyp error) used to
9
+ trigger an uninstall plus `rm -rf` of the global package before one retry. When
10
+ the retry also failed, the daemon kept running with its files gone and every new
11
+ task died at Fire launch with `MODULE_NOT_FOUND`. The clear-and-retry now only
12
+ runs on `ENOTEMPTY`, and it moves the old install aside and restores it if the
13
+ retry fails.
14
+ - Updated dependencies [0859251]
15
+ - Updated dependencies [c1b66d1]
16
+ - Updated dependencies [f6dbd6b]
17
+ - @love-moon/ai-sdk@0.13.3
18
+ - @love-moon/conductor-sdk@0.13.3
19
+
20
+ ## 0.13.2
21
+
22
+ ### Patch Changes
23
+
24
+ - 6547b2c: Add read-only daemon queries and archived-task search to the CLI:
25
+ `conductor daemon list [--all]` (online daemons, version, AI backends),
26
+ `conductor daemon tools <host>` (installed AI tools and reachability),
27
+ `conductor daemon quota <host> [--tool] [--refresh]` (usage windows / balance
28
+ per tool), and `conductor task list --archived [--search] [--all-projects]
29
+ [--page]`. The SDK's `BackendApiClient` gains `listAgents`,
30
+ `getAiManagerStatus`, `getAiManagerQuota` and `listAchievedTasks` over the
31
+ existing web API routes; no server change.
32
+ - 8c2833d: Support `/compact [focus instructions]` in task chats. Fire detects the command
33
+ per message (like `/goal`), runs the backend's native context compaction
34
+ instead of sending the text to the model, and posts one confirmation with the
35
+ token savings. Sessions advertise `capabilities.compact` and implement the new
36
+ optional `runCompact()`: claude (native `/compact`), codex app-server
37
+ (`thread/compact/start`), copilot (`session.history.compact`), kimi wire and
38
+ legacy print (built-in `/compact`), opencode (`session.summarize`) and dsh
39
+ (summary turn, then a fresh session seeded with the summary). Backends without
40
+ the capability (chat-web, codex exec, Kimi Code prompt mode) reply with a
41
+ not-supported notice. Opencode compaction summaries no longer surface as chat
42
+ replies, and dsh clears its "compacting context" status when automatic
43
+ compaction ends.
44
+ - c3a55b8: Fix tmux-mode Fire launches that failed before Fire started. The daemon now
45
+ drops an inherited `TMUX`/`TMUX_PANE` at startup, so a daemon launched from
46
+ another user's tmux pane (e.g. `su` from root's session) no longer aims every
47
+ tmux call at that user's socket and fails with `error connecting to
48
+ /tmp/tmux-0/default (Permission denied)`. A launch whose tmux argv would exceed
49
+ tmux's 16KB command limit (`command too long`), such as a persistent-round
50
+ prompt with a long summary, now runs through a self-deleting 0600 launch script
51
+ instead of `bash -c <command>`.
52
+ - 8e3a4c3: Report how many model tokens each turn consumed. After every turn (or `/goal`)
53
+ `conductor fire` sends the turn's token count (fresh input + cache reads/writes +
54
+ output) over the new `task_turn_usage` websocket event, and the server adds it
55
+ to the task's running total shown in the task detail card. A persistent task's
56
+ total restarts with each round.
57
+
58
+ Failed or interrupted turns and `/compact` are counted too; when a turn's usage
59
+ is unknown the fire reports `null`, which clears the task's last-turn count.
60
+
61
+ - `@love-moon/conductor-sdk`: new `ConductorClient.sendTurnUsage(taskId, { tokens })`.
62
+ - `@love-moon/ai-sdk`: the Codex app-server provider's turn/goal `usage` now
63
+ carries `turnTotalTokens`, the turn's share of Codex's thread-cumulative total.
64
+ Claude and Codex turn errors carry the tokens spent before the failure as
65
+ `error.usage` (Claude sums streamed usage when an interrupted query ends
66
+ without a result).
67
+ - Claude and Codex backends are counted; other backends report nothing yet.
68
+
69
+ - 6989999: `conductor task list --include-moved` also lists tasks moved into the project
70
+ from other projects (`project_id == P OR second_project_id == P`), with a MOVED
71
+ column naming the origin/target project. `--json` output now carries
72
+ `secondProjectId`. The SDK gains `TasksApi.listTasks({ includeMoved })`,
73
+ `BackendApiClient.listTasks({ projectScope: 'display' })` and
74
+ `Task/TaskSummary.secondProjectId`; it merges the server's existing real and
75
+ display scopes, so no server change is needed.
76
+ - Updated dependencies [6547b2c]
77
+ - Updated dependencies [8c2833d]
78
+ - Updated dependencies [8e3a4c3]
79
+ - Updated dependencies [6989999]
80
+ - @love-moon/conductor-sdk@0.13.2
81
+ - @love-moon/ai-sdk@0.13.2
82
+
3
83
  ## 0.13.1
4
84
 
5
85
  ### Patch 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();
@@ -1581,6 +1581,43 @@ export function parseGoalDirectiveFromMessage(content) {
1581
1581
  return objective ? { objective } : null;
1582
1582
  }
1583
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
+
1584
1621
  export function injectResolvedTaskId(taskId, env = process.env) {
1585
1622
  const normalizedTaskId = normalizeTaskId(taskId);
1586
1623
  if (!normalizedTaskId) {
@@ -3340,6 +3377,7 @@ export class BridgeRunner {
3340
3377
  useInitialImages,
3341
3378
  media,
3342
3379
  contextFiles,
3380
+ replyTo,
3343
3381
  onProgress: (payload) => {
3344
3382
  void this.reportRuntimeStatus(payload, replyTo);
3345
3383
  },
@@ -3547,10 +3585,17 @@ export class BridgeRunner {
3547
3585
  // best-effort
3548
3586
  }
3549
3587
 
3588
+ const compactDirective = hasAttachmentInputs ? null : parseCompactDirectiveFromMessage(content);
3589
+ if (compactDirective) {
3590
+ return this.runCompactCommand(compactDirective, options);
3591
+ }
3592
+
3550
3593
  if (willRunGoal) {
3551
- const goalResult = await this.backendSession.runGoal(
3552
- { objective: goalDirective.objective, source: { type: "manual" } },
3553
- options,
3594
+ const goalResult = await this.runWithTurnUsage(() =>
3595
+ this.backendSession.runGoal(
3596
+ { objective: goalDirective.objective, source: { type: "manual" } },
3597
+ options,
3598
+ ),
3554
3599
  );
3555
3600
  // Goal SDK is expected to return `{ text, goal, usage, metadata }`. We
3556
3601
  // normalize back into the runTurn-compatible shape downstream consumers
@@ -3567,7 +3612,81 @@ export class BridgeRunner {
3567
3612
  },
3568
3613
  };
3569
3614
  }
3570
- 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
+ };
3571
3690
  }
3572
3691
 
3573
3692
  async handlePrePromptMessage(content) {
@@ -3637,6 +3756,7 @@ export class BridgeRunner {
3637
3756
  try {
3638
3757
  const result = await this.dispatchBackendTurn(content, {
3639
3758
  useInitialImages: Boolean(includeImages),
3759
+ replyTo: replyTarget,
3640
3760
  onProgress: (payload) => {
3641
3761
  void this.reportRuntimeStatus(payload, replyTarget);
3642
3762
  },
@@ -3752,6 +3872,24 @@ function log(message) {
3752
3872
  appendFireLocalLog(line);
3753
3873
  }
3754
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
+
3755
3893
  function logBackendReply(backend, text, { usage, replyTo }) {
3756
3894
  const usageSuffix = usage?.total_tokens ? ` (tokens: ${usage.total_tokens})` : "";
3757
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.1",
4
- "gitCommitId": "b82ba1c",
3
+ "version": "0.13.3",
4
+ "gitCommitId": "d61c070",
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.1",
28
- "@love-moon/conductor-sdk": "0.13.1",
27
+ "@love-moon/ai-sdk": "0.13.3",
28
+ "@love-moon/conductor-sdk": "0.13.3",
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.1"
41
+ "@love-moon/chat-web": "0.13.3"
42
42
  },
43
43
  "pnpm": {
44
44
  "onlyBuiltDependencies": [
@@ -272,17 +272,6 @@ function buildInstallCommand(packageManager, pkgSpec) {
272
272
  }
273
273
  }
274
274
 
275
- function buildUninstallCommand(packageManager, packageName) {
276
- switch (packageManager) {
277
- case "pnpm":
278
- return { command: "pnpm", args: ["remove", "-g", packageName] };
279
- case "yarn":
280
- return { command: "yarn", args: ["global", "remove", packageName] };
281
- default:
282
- return { command: "npm", args: ["uninstall", "-g", packageName] };
283
- }
284
- }
285
-
286
275
  /**
287
276
  * `npm install -g` targets whichever npm wins the PATH lookup, which is not
288
277
  * necessarily the one that installed us. Pin it to the prefix the running
@@ -320,6 +309,7 @@ export async function runDaemonUpdate(params = {}, deps = {}) {
320
309
  const killFn = deps.kill || process.kill.bind(process);
321
310
  const readFileSync = deps.readFileSync || fs.readFileSync;
322
311
  const rmSync = deps.rmSync || fs.rmSync;
312
+ const renameSync = deps.renameSync || fs.renameSync;
323
313
  const sleep = deps.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
324
314
  const now = deps.now || (() => Date.now());
325
315
  const writeStatus = deps.writeStatus || writeDaemonUpdateStatus;
@@ -422,25 +412,43 @@ export async function runDaemonUpdate(params = {}, deps = {}) {
422
412
 
423
413
  log(`${install.command} ${install.args.join(" ")}`);
424
414
  let installResult = await runInstall();
425
- if (!installResult.success) {
426
- // A half-removed global install makes npm fail with ENOTEMPTY forever.
427
- // Clear it out and try once more before giving up.
428
- log(`install failed (exit ${installResult.code}); removing the broken install and retrying`);
415
+ // A half-removed global install makes npm fail with ENOTEMPTY forever. Move
416
+ // it aside and try once more. Any other failure (timeout, network, build)
417
+ // leaves the install untouched: the daemon and every fire it spawns run
418
+ // from that directory.
419
+ if (!installResult.success && /ENOTEMPTY/.test(`${installResult.stderr}${installResult.stdout}`)) {
420
+ log(`install failed (exit ${installResult.code}); moving the broken install aside and retrying`);
429
421
  log(String(installResult.stderr || installResult.stdout || "").trim().slice(-800));
430
- const uninstall = buildUninstallCommand(packageManager, packageName);
431
- await runCommand(uninstall.command, uninstall.args, { env: installEnv, timeoutMs: INSTALL_TIMEOUT_MS });
422
+ let packageDirectory = null;
423
+ let backupDirectory = null;
432
424
  try {
433
- const packageDirectory = await resolveGlobalPackageDirectory({
425
+ packageDirectory = await resolveGlobalPackageDirectory({
434
426
  packageManager,
435
427
  packageName,
436
428
  runCommand: (command, args, options) => runCommand(command, args, { env: installEnv, ...options }),
437
429
  });
438
- rmSync(packageDirectory, { recursive: true, force: true });
439
- log(`removed ${packageDirectory}`);
430
+ const backup = path.join(path.dirname(packageDirectory), `.${path.basename(packageDirectory)}-update-backup`);
431
+ rmSync(backup, { recursive: true, force: true });
432
+ renameSync(packageDirectory, backup);
433
+ backupDirectory = backup;
434
+ log(`moved ${packageDirectory} aside`);
440
435
  } catch (error) {
441
- log(`could not remove the global package directory: ${error?.message || error}`);
436
+ log(`could not move the global package directory aside: ${error?.message || error}`);
442
437
  }
443
438
  installResult = await runInstall();
439
+ if (backupDirectory) {
440
+ try {
441
+ if (installResult.success) {
442
+ rmSync(backupDirectory, { recursive: true, force: true });
443
+ } else {
444
+ rmSync(packageDirectory, { recursive: true, force: true });
445
+ renameSync(backupDirectory, packageDirectory);
446
+ log(`restored ${packageDirectory}`);
447
+ }
448
+ } catch (error) {
449
+ log(`could not clean up ${backupDirectory}: ${error?.message || error}`);
450
+ }
451
+ }
444
452
  }
445
453
  if (!installResult.success) {
446
454
  return fail(
package/src/daemon.js CHANGED
@@ -1188,6 +1188,13 @@ export function startDaemon(config = {}, deps = {}) {
1188
1188
  // below after we verify tmux is installed; if tmux is missing we log a
1189
1189
  // warning and silently fall back to direct spawn rather than failing every
1190
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;
1191
1198
  const FIRE_TMUX_MODE_ENABLED = getFireTmuxModeEnabled(userConfig);
1192
1199
  // RFC 0035: a guest daemon runs on someone else's machine as a different
1193
1200
  // account. It keeps the ordinary daemon code path but drops the capabilities
@@ -1625,6 +1632,9 @@ export function startDaemon(config = {}, deps = {}) {
1625
1632
  return { attach, append, tail };
1626
1633
  }
1627
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
+
1628
1638
  // Spawn the Fire CLI either directly (default) or inside a detached tmux
1629
1639
  // session (when FIRE_TMUX_MODE_ACTIVE). In tmux mode the returned `child`
1630
1640
  // is the short-lived `tmux new-session` client; once it exits with code 0
@@ -1740,7 +1750,21 @@ export function startDaemon(config = {}, deps = {}) {
1740
1750
  "-c",
1741
1751
  shellCommand,
1742
1752
  ];
1743
- 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
+ );
1744
1768
  const child = spawnFn("tmux", tmuxArgs, {
1745
1769
  cwd,
1746
1770
  env,
@@ -1750,6 +1774,18 @@ export function startDaemon(config = {}, deps = {}) {
1750
1774
  if (typeof child.unref === "function") {
1751
1775
  child.unref();
1752
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
+ }
1753
1789
  return { child, tmuxSession: sessionName, exitMarkerToken };
1754
1790
  }
1755
1791