@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 +78 -0
- package/bin/conductor-daemon-query.js +185 -0
- package/bin/conductor-daemon.js +11 -0
- package/bin/conductor-fire.js +319 -13
- package/bin/conductor-task.js +90 -6
- package/bin/conductor.js +2 -1
- package/package.json +5 -5
- package/src/daemon.js +141 -4
- package/src/serve-ai/index.js +17 -2
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
|
+
}
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
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
|
-
|
|
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.
|
|
3386
|
-
|
|
3387
|
-
|
|
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}`);
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/serve-ai/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|