@love-moon/conductor-cli 0.13.1 → 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 +63 -0
- package/bin/conductor-daemon-query.js +185 -0
- package/bin/conductor-daemon.js +11 -0
- package/bin/conductor-fire.js +142 -4
- package/bin/conductor-task.js +90 -6
- package/bin/conductor.js +2 -1
- package/package.json +5 -5
- package/src/daemon.js +37 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,68 @@
|
|
|
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
|
+
|
|
3
66
|
## 0.13.1
|
|
4
67
|
|
|
5
68
|
### 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
|
+
}
|
package/bin/conductor-daemon.js
CHANGED
|
@@ -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();
|
package/bin/conductor-fire.js
CHANGED
|
@@ -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.
|
|
3552
|
-
|
|
3553
|
-
|
|
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}`);
|
package/bin/conductor-task.js
CHANGED
|
@@ -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
|
-
|
|
241
|
-
|
|
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
|
|
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.
|
|
4
|
-
"gitCommitId": "
|
|
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.
|
|
28
|
-
"@love-moon/conductor-sdk": "0.13.
|
|
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.
|
|
41
|
+
"@love-moon/chat-web": "0.13.2"
|
|
42
42
|
},
|
|
43
43
|
"pnpm": {
|
|
44
44
|
"onlyBuiltDependencies": [
|
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
|
-
|
|
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
|
|