@love-moon/conductor-cli 0.15.2 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +148 -0
- package/bin/conductor-auth.js +162 -0
- package/bin/conductor-config.js +6 -9
- package/bin/conductor-daemon-query.js +13 -17
- package/bin/conductor-daemon.js +6 -1
- package/bin/conductor-fire.js +91 -2
- package/bin/conductor-issue.js +215 -13
- package/bin/conductor-project.js +840 -27
- package/bin/conductor-search.js +150 -0
- package/bin/conductor-settings.js +608 -0
- package/bin/conductor-task.js +344 -9
- package/bin/conductor-update.js +17 -0
- package/bin/conductor.js +29 -8
- package/package.json +5 -5
- package/src/backend-http.js +246 -0
- package/src/daemon-share-commands.js +528 -0
- package/src/daemon-update.js +3 -0
- package/src/daemon.js +51 -11
- package/src/remote/mcp-launch.js +3 -3
- package/src/remote-file-handlers.js +65 -6
- package/src/task-commands.js +813 -0
- package/src/version-check.js +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,153 @@
|
|
|
1
1
|
# @love-moon/conductor-cli
|
|
2
2
|
|
|
3
|
+
## 0.17.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 88e1ae0: Support `/stop` in task chats. A bare `/stop` message, sent from the web, the
|
|
8
|
+
CLI or a channel, makes the server interrupt the AI's running turn right away
|
|
9
|
+
instead of waiting for fire to read the message once that turn ends. When fire
|
|
10
|
+
does read the queued `/stop`, it posts a confirmation and does not send it to
|
|
11
|
+
the model. `/stop` also drops every message queued before it (except the
|
|
12
|
+
server's end-of-round summary request); resend what you still want. The server
|
|
13
|
+
targets the reply it knows is running, so older fires stop it too;
|
|
14
|
+
`interrupt_turn` no longer needs a reply target, and without one fire
|
|
15
|
+
interrupts the turn it is running, including the initial prompt and pre_prompt
|
|
16
|
+
turns. In the web chat, the ⋯ menu gains `/stop`, `/clear` and `/compact`,
|
|
17
|
+
which send those commands as messages, and Interrupt (Esc) sends `/stop`,
|
|
18
|
+
ignored while an earlier `/stop` is unanswered. The Interrupt action is gone
|
|
19
|
+
from the message toolbar.
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- 79ca552: Claude no longer switches to `acceptEdits` mode when the daemon runs as root.
|
|
24
|
+
That fallback looked like it worked, but in a headless task it silently refused
|
|
25
|
+
every shell command (ssh, rsync, ...) and every read outside the project. Claude
|
|
26
|
+
now keeps `bypassPermissions` everywhere. As root without `IS_SANDBOX=1`, the
|
|
27
|
+
chat shows a warning right after "session started" telling you to add
|
|
28
|
+
`envs: { IS_SANDBOX: "1" }` to the conductor config (or run the daemon as a
|
|
29
|
+
regular user). Terminal tasks print the same hint. An `IS_SANDBOX` set in config
|
|
30
|
+
`envs` now also reaches terminal (PTY) claude tasks. `conductor config` always
|
|
31
|
+
writes `--dangerously-skip-permissions` for claude.
|
|
32
|
+
- c0c1489: `conductor task speak <text> [-o FILE|-]` saves Doubao text-to-speech as an
|
|
33
|
+
.mp3 — the same voice the web chat's new voice-conversation mode reads replies with.
|
|
34
|
+
- 39ee057: `conductor update` and the daemon updater compile node-pty against the headers
|
|
35
|
+
the running Node ships, instead of downloading them from nodejs.org, so an
|
|
36
|
+
update no longer needs that host to be reachable. `conductor update` also stops
|
|
37
|
+
an npm install that has not finished after 15 minutes instead of waiting on a
|
|
38
|
+
stalled registry connection forever.
|
|
39
|
+
- b09b28a: Remove `conductor task create --remote-worktree <host>`; passing it now fails
|
|
40
|
+
with a pointer to the replacement. Running the AI on one daemon while the code
|
|
41
|
+
lives on another is done only through a global AI backend:
|
|
42
|
+
`--global-backend <backend>@<host>` (add `--worktree` for a worktree on the
|
|
43
|
+
code's daemon). Note the host changes meaning: `--remote-worktree` named the
|
|
44
|
+
daemon holding the code, `--global-backend` names the daemon running the AI, so
|
|
45
|
+
create the task in the project on the code's daemon. The server refuses a caller-supplied
|
|
46
|
+
`launch_config.remoteWorktree`.
|
|
47
|
+
|
|
48
|
+
`conductor task create --global-backend` now takes `<backend>@<host>`, the same
|
|
49
|
+
form as `conductor issue start` and the web UI. The older `<host>:<backend>` is
|
|
50
|
+
still accepted.
|
|
51
|
+
|
|
52
|
+
- 68fe7b9: Add `conductor task preview <id> <path>`: open a temporary link to a file on the
|
|
53
|
+
task's daemon (HTML with its CSS/JS/images, Markdown, images, video), the same
|
|
54
|
+
preview the chat opens when you click a file link in an AI reply or shows in
|
|
55
|
+
place for `` / ``. The link expires after
|
|
56
|
+
5 idle minutes (30 minutes at most) and only reaches the file's own directory.
|
|
57
|
+
|
|
58
|
+
The daemon advertises a new `remote_file_preview` capability; previews need a
|
|
59
|
+
daemon of this version or later. `conductor config` now writes
|
|
60
|
+
`remote_file: true` explicitly into a new config file (the default was already
|
|
61
|
+
on; set it to `false` to decline file transfers and previews).
|
|
62
|
+
|
|
63
|
+
- Updated dependencies [d088b31]
|
|
64
|
+
- Updated dependencies [79ca552]
|
|
65
|
+
- Updated dependencies [ccbfeeb]
|
|
66
|
+
- Updated dependencies [88e1ae0]
|
|
67
|
+
- @love-moon/ai-sdk@0.17.0
|
|
68
|
+
- @love-moon/conductor-sdk@0.17.0
|
|
69
|
+
|
|
70
|
+
## 0.16.0
|
|
71
|
+
|
|
72
|
+
### Minor Changes
|
|
73
|
+
|
|
74
|
+
- 0495b79: The CLI can now do what the web app can:
|
|
75
|
+
|
|
76
|
+
- `conductor task`: `stop`, `interrupt`, `restart`, `delete`, `archive`/`unarchive`,
|
|
77
|
+
`rename`, `pin`/`unpin`, `move`, `labels`, `share`/`unshare`, `persistent`,
|
|
78
|
+
`round start|end`, `cleanup-worktree`, `terminal open|show|close`, `resume`,
|
|
79
|
+
`attachment download`, `shared <token|link>` (read a shared task), `transcribe <audio>`
|
|
80
|
+
(speech to text), and `schedule update`. `create` also takes `--daemon-host`, `--agent` (multi-agent group),
|
|
81
|
+
`--global-backend`, `--worktree`, `--remote-worktree` and `--persistent`.
|
|
82
|
+
`send --attach` uploads files, `messages --follow` streams new messages, and
|
|
83
|
+
`list --all-projects` / `--project-ids` lists tasks across projects.
|
|
84
|
+
- `conductor project`: `update`, `refresh`, `delete`, `reorder`, `agents`,
|
|
85
|
+
`collab invite|show-invite|join|leave`, `labels list|add|rename|remove`.
|
|
86
|
+
- `conductor issue`: `delete`, and `list --all-projects` / `--project-ids`.
|
|
87
|
+
- `conductor daemon`: `restart`, `upgrade [--status|--wait]`, `sessions`, `accounts`,
|
|
88
|
+
`switch-account`, `commands list|run|status`, `share create|list|revoke|show-invite|accept`.
|
|
89
|
+
- New `conductor search`, `conductor settings` (global backends, catchphrases, daily
|
|
90
|
+
report, task-list and card-group preferences, generated reports) and `conductor auth`
|
|
91
|
+
(`whoami`, `tokens list|create|revoke`).
|
|
92
|
+
|
|
93
|
+
Fix: `conductor project hide/unhide` (and the SDK's `setProjectHidden`) no longer erase
|
|
94
|
+
the project's metadata (task labels, memos, binding data).
|
|
95
|
+
|
|
96
|
+
On a cross-daemon merged project, `project hide/unhide`, `delete` and
|
|
97
|
+
`update --merge-opt-out` act on every daemon, like the web. `hide/unhide --json`
|
|
98
|
+
still prints the target project, plus `ids` for every project changed.
|
|
99
|
+
`delete --daemon-host <h>` deletes only that copy; a group delete stops at the
|
|
100
|
+
first failure and says which ids were deleted. `update --merge-opt-out` lists the
|
|
101
|
+
same-name projects it also split or re-merged.
|
|
102
|
+
|
|
103
|
+
- b68fadf: `conductor issue` now matches the web issue board:
|
|
104
|
+
`start --daemon <host> [--project <id>]` picks the daemon, or the merged-group
|
|
105
|
+
sibling project, that the task runs on (on another member's shared project the
|
|
106
|
+
server decides). `--priority` takes `P0|P1|P2`, and `--status` uses
|
|
107
|
+
`todo|doing|done`; `create` no longer offers `doing`. `list --status` is sent to
|
|
108
|
+
the server and still applied locally, so an older server that ignores it filters
|
|
109
|
+
too. An invalid argument now exits 2 without sending a request. The SDK's
|
|
110
|
+
`updateIssue` forwards `projectId`, and `listIssues` sends multiple statuses to
|
|
111
|
+
the server and filters the result the same way.
|
|
112
|
+
|
|
113
|
+
The matching web server changes (metadata patches merge instead of replacing,
|
|
114
|
+
`GET /api/issues?status=` filtering) should be deployed before publishing.
|
|
115
|
+
|
|
116
|
+
- 7c45a56: `conductor issue start` can pick the AI for the task it spawns: `--backend <b>`,
|
|
117
|
+
or `--global-backend <backend>@<host>` to run the AI on one of your global AI
|
|
118
|
+
backends while the code stays on the project's daemon (same as the web "Move
|
|
119
|
+
Issue To Doing" dialog). The SDK's `updateIssue` forwards `globalBackend`.
|
|
120
|
+
|
|
121
|
+
Requires a Conductor web server that ships issue global backends (the same
|
|
122
|
+
release): deploy the web server before publishing the CLI/SDK. An older server
|
|
123
|
+
ignores `--global-backend` and starts a plain task on the project's daemon; the
|
|
124
|
+
CLI then prints a warning instead of failing silently.
|
|
125
|
+
|
|
126
|
+
### Patch Changes
|
|
127
|
+
|
|
128
|
+
- 5c81d06: Claude turns that wait on background subagents now report their full token
|
|
129
|
+
usage. Such a turn emits one result per segment and the task card used to show
|
|
130
|
+
only the last segment; it now sums the result's `modelUsage`, which covers
|
|
131
|
+
every segment, every subagent and small side calls. `conductor serve-ai`'s
|
|
132
|
+
`prompt_tokens` / `completion_tokens` come from the same usage, so they now
|
|
133
|
+
include those too. A turn cut off after an earlier segment keeps that
|
|
134
|
+
segment's usage.
|
|
135
|
+
- b69d665: Codex turns that spawn sub-agents now include the sub-agents' tokens in the
|
|
136
|
+
turn usage. Their `thread/tokenUsage/updated` notifications carry the
|
|
137
|
+
sub-agent's own thread id and were dropped with its other events. A
|
|
138
|
+
sub-agent's replayed total is not counted, and whatever it spends after the
|
|
139
|
+
parent turn ends goes to the next turn.
|
|
140
|
+
- 1423475: `conductor task persistent` takes `--auto-end-round` / `--no-auto-end-round`. Persistent
|
|
141
|
+
tasks now end the current round automatically (asking the AI for its summary, like
|
|
142
|
+
End round) after an hour without a reply; on by default.
|
|
143
|
+
- Updated dependencies [5c81d06]
|
|
144
|
+
- Updated dependencies [0495b79]
|
|
145
|
+
- Updated dependencies [b69d665]
|
|
146
|
+
- Updated dependencies [b68fadf]
|
|
147
|
+
- Updated dependencies [7c45a56]
|
|
148
|
+
- @love-moon/ai-sdk@0.16.0
|
|
149
|
+
- @love-moon/conductor-sdk@0.16.0
|
|
150
|
+
|
|
3
151
|
## 0.15.2
|
|
4
152
|
|
|
5
153
|
### Patch Changes
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* conductor auth — who am I, and the API tokens shown on the web settings page.
|
|
5
|
+
*
|
|
6
|
+
* Subcommands:
|
|
7
|
+
* whoami GET /api/auth/me -> { user: { id, email, phone, tokenScope } }
|
|
8
|
+
* tokens list GET /api/auth/tokens -> [{ id, name, token_prefix, created_at, last_used_at }]
|
|
9
|
+
* tokens create [--name N] POST /api/auth/tokens { name } -> { token, tokenId, tokenPrefix, createdAt }
|
|
10
|
+
* tokens revoke <tokenId> POST /api/auth/tokens/:tokenId/revoke -> 204
|
|
11
|
+
*
|
|
12
|
+
* `tokens create` prints the raw token once (the server never lists it again).
|
|
13
|
+
* Login / register / OAuth are intentionally not here: `conductor config`
|
|
14
|
+
* handles signing in.
|
|
15
|
+
*
|
|
16
|
+
* Global flags: --json, --dry-run (write verbs), --config-file.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import process from "node:process";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
import yargs from "yargs/yargs";
|
|
24
|
+
import { hideBin } from "yargs/helpers";
|
|
25
|
+
|
|
26
|
+
import { EXIT, exitCodeForError, printJson, printPretty, reportError } from "../src/entity-helpers.js";
|
|
27
|
+
import { apiPath, buildHttp, formatTable, sendOrPreview } from "../src/backend-http.js";
|
|
28
|
+
|
|
29
|
+
const isMainModule = (() => {
|
|
30
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
31
|
+
const entryFile = process.argv[1] ? path.resolve(process.argv[1]) : "";
|
|
32
|
+
return entryFile === currentFile;
|
|
33
|
+
})();
|
|
34
|
+
|
|
35
|
+
const DEFAULT_TOKEN_NAME = "cli";
|
|
36
|
+
|
|
37
|
+
async function handleWhoami(argv, deps) {
|
|
38
|
+
const http = await buildHttp(deps);
|
|
39
|
+
const result = await http.get(apiPath("auth", "me"));
|
|
40
|
+
if (argv.json) {
|
|
41
|
+
printJson(deps.stdout, result);
|
|
42
|
+
return EXIT.OK;
|
|
43
|
+
}
|
|
44
|
+
const user = result?.user ?? {};
|
|
45
|
+
printPretty(deps.stdout, `id: ${user.id ?? ""}`);
|
|
46
|
+
if (user.email) printPretty(deps.stdout, `email: ${user.email}`);
|
|
47
|
+
if (user.phone) printPretty(deps.stdout, `phone: ${user.phone}`);
|
|
48
|
+
if (user.tokenScope) printPretty(deps.stdout, `scope: ${user.tokenScope}`);
|
|
49
|
+
printPretty(deps.stdout, `backend: ${http.baseUrl}`);
|
|
50
|
+
return EXIT.OK;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function handleTokensList(argv, deps) {
|
|
54
|
+
const http = await buildHttp(deps);
|
|
55
|
+
const result = await http.get(apiPath("auth", "tokens"));
|
|
56
|
+
if (argv.json) {
|
|
57
|
+
printJson(deps.stdout, result);
|
|
58
|
+
return EXIT.OK;
|
|
59
|
+
}
|
|
60
|
+
const tokens = Array.isArray(result) ? result : [];
|
|
61
|
+
if (tokens.length === 0) {
|
|
62
|
+
printPretty(deps.stdout, "(no tokens)");
|
|
63
|
+
return EXIT.OK;
|
|
64
|
+
}
|
|
65
|
+
const rows = tokens.map((token) => [
|
|
66
|
+
token.id, token.token_prefix ?? "", token.created_at ?? "", token.last_used_at ?? "-", token.name ?? "",
|
|
67
|
+
]);
|
|
68
|
+
for (const line of formatTable(["ID", "PREFIX", "CREATED", "LAST USED", "NAME"], rows)) {
|
|
69
|
+
printPretty(deps.stdout, line);
|
|
70
|
+
}
|
|
71
|
+
return EXIT.OK;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function handleTokensCreate(argv, deps) {
|
|
75
|
+
const http = await buildHttp(deps);
|
|
76
|
+
const body = { name: String(argv.name ?? DEFAULT_TOKEN_NAME) };
|
|
77
|
+
const { dryRun, data } = await sendOrPreview(http, argv, deps, "POST", apiPath("auth", "tokens"), body);
|
|
78
|
+
if (dryRun) return EXIT.OK;
|
|
79
|
+
deps.stderr.write("Warning: this token is shown only once. Store it somewhere safe now.\n");
|
|
80
|
+
if (argv.json) {
|
|
81
|
+
printJson(deps.stdout, data);
|
|
82
|
+
return EXIT.OK;
|
|
83
|
+
}
|
|
84
|
+
printPretty(deps.stdout, `Created token ${data?.tokenId ?? ""} (${body.name}, prefix ${data?.tokenPrefix ?? ""})`);
|
|
85
|
+
printPretty(deps.stdout, `token: ${data?.token ?? ""}`);
|
|
86
|
+
return EXIT.OK;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function handleTokensRevoke(argv, deps) {
|
|
90
|
+
const http = await buildHttp(deps);
|
|
91
|
+
const tokenId = String(argv.tokenId);
|
|
92
|
+
const { dryRun } = await sendOrPreview(
|
|
93
|
+
http, argv, deps, "POST", apiPath("auth", "tokens", tokenId, "revoke"), undefined,
|
|
94
|
+
);
|
|
95
|
+
if (dryRun) return EXIT.OK;
|
|
96
|
+
if (argv.json) {
|
|
97
|
+
printJson(deps.stdout, { revoked: true, tokenId });
|
|
98
|
+
return EXIT.OK;
|
|
99
|
+
}
|
|
100
|
+
printPretty(deps.stdout, `Revoked token ${tokenId}`);
|
|
101
|
+
return EXIT.OK;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function main(argvInput = hideBin(process.argv), deps = {}) {
|
|
105
|
+
const stdout = deps.stdout || process.stdout;
|
|
106
|
+
const stderr = deps.stderr || process.stderr;
|
|
107
|
+
const env = deps.env || process.env;
|
|
108
|
+
const cwd = deps.cwd || process.cwd();
|
|
109
|
+
const consoleErr = { error: (msg) => stderr.write(`${msg}\n`) };
|
|
110
|
+
const handlerDeps = { ...deps, stdout, stderr, env, cwd };
|
|
111
|
+
const run = (handler) => async (argv) => {
|
|
112
|
+
exitCode = await handler(argv, { ...handlerDeps, configFile: argv.configFile });
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
let exitCode = EXIT.OK;
|
|
116
|
+
try {
|
|
117
|
+
await yargs(argvInput)
|
|
118
|
+
.scriptName("conductor auth")
|
|
119
|
+
.strict()
|
|
120
|
+
.help()
|
|
121
|
+
.option("json", { type: "boolean", default: false })
|
|
122
|
+
.option("dry-run", { type: "boolean", default: false })
|
|
123
|
+
.option("config-file", { type: "string", describe: "Path to Conductor config file" })
|
|
124
|
+
.command("whoami", "Show the user the configured token belongs to", () => {}, run(handleWhoami))
|
|
125
|
+
.command("tokens", "List, create, or revoke API tokens", (tokens) => tokens
|
|
126
|
+
.command("list", "List active API tokens", () => {}, run(handleTokensList))
|
|
127
|
+
.command(
|
|
128
|
+
"create",
|
|
129
|
+
"Create an API token (the token is printed once)",
|
|
130
|
+
(cmd) => cmd.option("name", { type: "string", describe: `Token label (default "${DEFAULT_TOKEN_NAME}")` }),
|
|
131
|
+
run(handleTokensCreate),
|
|
132
|
+
)
|
|
133
|
+
.command(
|
|
134
|
+
"revoke <tokenId>",
|
|
135
|
+
"Revoke an API token",
|
|
136
|
+
(cmd) => cmd.positional("tokenId", { type: "string", demandOption: true }),
|
|
137
|
+
run(handleTokensRevoke),
|
|
138
|
+
)
|
|
139
|
+
.demandCommand(1))
|
|
140
|
+
.demandCommand(1)
|
|
141
|
+
.fail((msg, err) => {
|
|
142
|
+
if (err) {
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
stderr.write(`${msg}\n`);
|
|
146
|
+
exitCode = EXIT.ARGS;
|
|
147
|
+
})
|
|
148
|
+
.parseAsync();
|
|
149
|
+
} catch (err) {
|
|
150
|
+
exitCode = reportError(consoleErr, err);
|
|
151
|
+
}
|
|
152
|
+
return exitCode;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (isMainModule) {
|
|
156
|
+
main().then((code) => {
|
|
157
|
+
if (code !== 0) process.exit(code);
|
|
158
|
+
}).catch((err) => {
|
|
159
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
160
|
+
process.exit(exitCodeForError(err));
|
|
161
|
+
});
|
|
162
|
+
}
|
package/bin/conductor-config.js
CHANGED
|
@@ -10,7 +10,6 @@ import { createRequire } from "node:module";
|
|
|
10
10
|
import yargs from "yargs/yargs";
|
|
11
11
|
import { hideBin } from "yargs/helpers";
|
|
12
12
|
import { RUNTIME_SUPPORTED_BACKENDS } from "../src/runtime-backends.js";
|
|
13
|
-
import { isClaudeRootPermissionRestricted } from "@love-moon/ai-sdk";
|
|
14
13
|
import { resolveConductorConfigPath } from "../src/conductor-paths.js";
|
|
15
14
|
|
|
16
15
|
const CONFIG_FILE = resolveConductorConfigPath();
|
|
@@ -22,12 +21,10 @@ const packageJson = JSON.parse(
|
|
|
22
21
|
const DEFAULT_CLIs = {
|
|
23
22
|
claude: {
|
|
24
23
|
command: "claude",
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// daemon
|
|
28
|
-
execArgs:
|
|
29
|
-
? "--permission-mode acceptEdits"
|
|
30
|
-
: "--dangerously-skip-permissions",
|
|
24
|
+
// Kept even on root: a headless acceptEdits session silently denies every
|
|
25
|
+
// shell command. Root users opt in with `envs.IS_SANDBOX: "1"`; fire and
|
|
26
|
+
// the daemon tell them so when claude starts.
|
|
27
|
+
execArgs: "--dangerously-skip-permissions",
|
|
31
28
|
description: "Anthropic Claude CLI"
|
|
32
29
|
},
|
|
33
30
|
codex: {
|
|
@@ -387,9 +384,9 @@ async function main() {
|
|
|
387
384
|
"# Env: CONDUCTOR_REMOTE_EXEC",
|
|
388
385
|
"# remote_exec: true",
|
|
389
386
|
"",
|
|
390
|
-
"# Accept `conductor remote cp` file transfers for this host.",
|
|
387
|
+
"# Accept `conductor remote cp` file transfers and file previews for this host.",
|
|
391
388
|
"# Env: CONDUCTOR_REMOTE_FILE",
|
|
392
|
-
"
|
|
389
|
+
"remote_file: true",
|
|
393
390
|
"",
|
|
394
391
|
"# Launch each Fire process inside a detached tmux session so it survives a",
|
|
395
392
|
"# daemon restart. Requires tmux on PATH. Env: CONDUCTOR_FIRE_TMUX_MODE",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* conductor daemon list|tools|quota — read-only queries about daemons
|
|
2
|
+
* conductor daemon list|tools|quota — read-only queries about daemons, plus the
|
|
3
|
+
* remote-control / sharing verbs in src/daemon-share-commands.js (restart,
|
|
4
|
+
* upgrade, sessions, accounts, switch-account, commands, share).
|
|
3
5
|
*
|
|
4
6
|
* list [--all] Online daemons, CLI version, AI backends
|
|
5
7
|
* tools <host> AI tools installed on a daemon + network
|
|
@@ -12,24 +14,16 @@ import process from "node:process";
|
|
|
12
14
|
|
|
13
15
|
import yargs from "yargs/yargs";
|
|
14
16
|
|
|
15
|
-
import { EXIT, buildApis,
|
|
17
|
+
import { EXIT, buildApis, printJson, printPretty, reportError } from "../src/entity-helpers.js";
|
|
18
|
+
import { DAEMON_REMOTE_VERBS, printTable, readOnly, registerDaemonRemoteCommands } from "../src/daemon-share-commands.js";
|
|
16
19
|
|
|
17
|
-
export const DAEMON_QUERY_VERBS = new Set(["list", "tools", "quota"]);
|
|
20
|
+
export const DAEMON_QUERY_VERBS = new Set(["list", "tools", "quota", ...DAEMON_REMOTE_VERBS]);
|
|
18
21
|
|
|
19
22
|
// Ephemeral per-task `conductor fire` processes also register as agents.
|
|
20
23
|
const FIRE_HOST_PREFIX = "conductor-fire-";
|
|
21
24
|
|
|
22
25
|
const WINDOW_LABELS = { fiveHour: "5h", weekly: "weekly", weeklySonnet: "weekly-sonnet" };
|
|
23
26
|
|
|
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
27
|
function formatReset(window) {
|
|
34
28
|
if (typeof window.resetAt !== "number") return window.resetOnDate ?? "";
|
|
35
29
|
const date = new Date(window.resetAt * 1000);
|
|
@@ -139,12 +133,13 @@ export async function main(argvInput, deps = {}) {
|
|
|
139
133
|
};
|
|
140
134
|
|
|
141
135
|
try {
|
|
142
|
-
|
|
136
|
+
const y = yargs(argvInput)
|
|
143
137
|
.scriptName("conductor daemon")
|
|
144
138
|
.strict()
|
|
145
139
|
.help()
|
|
146
140
|
.option("json", { type: "boolean", default: false })
|
|
147
141
|
.option("config-file", { type: "string", describe: "Path to Conductor config file" })
|
|
142
|
+
.option("dry-run", { type: "boolean", default: false, describe: "Print the request a write verb would send, without sending it" })
|
|
148
143
|
.command(
|
|
149
144
|
"list",
|
|
150
145
|
"List online daemons with their CLI version and AI backends",
|
|
@@ -153,13 +148,13 @@ export async function main(argvInput, deps = {}) {
|
|
|
153
148
|
default: false,
|
|
154
149
|
describe: `Include ephemeral ${FIRE_HOST_PREFIX}* hosts`,
|
|
155
150
|
}),
|
|
156
|
-
run(handleList),
|
|
151
|
+
run(readOnly(handleList)),
|
|
157
152
|
)
|
|
158
153
|
.command(
|
|
159
154
|
"tools <host>",
|
|
160
155
|
"Show which AI tools are installed on a daemon and whether they are reachable",
|
|
161
156
|
(cmd) => cmd.positional("host", { type: "string", describe: "Daemon host name" }),
|
|
162
|
-
run(handleTools),
|
|
157
|
+
run(readOnly(handleTools)),
|
|
163
158
|
)
|
|
164
159
|
.command(
|
|
165
160
|
"quota <host>",
|
|
@@ -168,8 +163,9 @@ export async function main(argvInput, deps = {}) {
|
|
|
168
163
|
.positional("host", { type: "string", describe: "Daemon host name" })
|
|
169
164
|
.option("tool", { type: "string", describe: "Only this tool, e.g. claude or codex" })
|
|
170
165
|
.option("refresh", { type: "boolean", default: false, describe: "Bypass the daemon's quota cache" }),
|
|
171
|
-
run(handleQuota),
|
|
172
|
-
)
|
|
166
|
+
run(readOnly(handleQuota)),
|
|
167
|
+
);
|
|
168
|
+
await registerDaemonRemoteCommands(y, run)
|
|
173
169
|
.demandCommand(1)
|
|
174
170
|
.fail((msg, err) => {
|
|
175
171
|
// Throw so the command handler does not run after a validation error
|
package/bin/conductor-daemon.js
CHANGED
|
@@ -24,7 +24,8 @@ import { DAEMON_QUERY_VERBS, main as runDaemonQuery } from "./conductor-daemon-q
|
|
|
24
24
|
|
|
25
25
|
const argv = hideBin(process.argv);
|
|
26
26
|
|
|
27
|
-
// `conductor daemon list|tools|quota`
|
|
27
|
+
// `conductor daemon list|tools|quota|restart|upgrade|sessions|accounts|switch-account|commands|share`
|
|
28
|
+
// talk to the backend about daemons; they are not a daemon launch.
|
|
28
29
|
// Exit (so startup below never runs) only after stdout drains: a piped
|
|
29
30
|
// --json payload over 64KB would otherwise be cut off.
|
|
30
31
|
if (DAEMON_QUERY_VERBS.has(argv[0])) {
|
|
@@ -181,6 +182,10 @@ const args = yargs(argv)
|
|
|
181
182
|
.example("$0 --nohup", "Run daemon in background with logfile")
|
|
182
183
|
.example("$0 --nohup --force", "Restart daemon in background by stopping the existing one")
|
|
183
184
|
.example("$0 list | tools <host> | quota <host>", "Query online daemons, their AI tools and quota")
|
|
185
|
+
.example("$0 restart <host> | upgrade <host> [--wait]", "Restart or upgrade a remote daemon")
|
|
186
|
+
.example("$0 sessions <host> | accounts <host> | switch-account <host> <name>", "Resumable AI sessions, codex accounts")
|
|
187
|
+
.example("$0 commands list|run|status <host> ...", "Run a daemon's custom commands")
|
|
188
|
+
.example("$0 share create|list|revoke|show-invite|accept ...", "Share a daemon with another user")
|
|
184
189
|
.help()
|
|
185
190
|
.strict()
|
|
186
191
|
.parse();
|
package/bin/conductor-fire.js
CHANGED
|
@@ -1648,6 +1648,10 @@ export function formatCompactReply(backendName, compact, instructions = "") {
|
|
|
1648
1648
|
* Per-message `/clear` detector. Only a bare `/clear` (case-insensitive)
|
|
1649
1649
|
* counts: anything after it is more likely an instruction for the model.
|
|
1650
1650
|
*/
|
|
1651
|
+
export function isStopCommand(content) {
|
|
1652
|
+
return typeof content === "string" && /^\/stop$/i.test(content.trim());
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1651
1655
|
export function isClearCommand(content) {
|
|
1652
1656
|
return typeof content === "string" && /^\/clear$/i.test(content.trim());
|
|
1653
1657
|
}
|
|
@@ -2168,6 +2172,8 @@ export class BridgeRunner {
|
|
|
2168
2172
|
this.remoteInterruptsByReplyTo = new Map();
|
|
2169
2173
|
this.pendingInterruptRetryTimers = new Map();
|
|
2170
2174
|
this.activeTurnReplyTo = "";
|
|
2175
|
+
// A `/stop` arrived mid-batch: drop the rest of this batch (all sent before it).
|
|
2176
|
+
this.dropQueuedBeforeStop = false;
|
|
2171
2177
|
this.sessionAnnouncementSent = false;
|
|
2172
2178
|
this.sessionAnnouncementSubscribed = false;
|
|
2173
2179
|
this.boundSessionId = "";
|
|
@@ -2309,6 +2315,7 @@ export class BridgeRunner {
|
|
|
2309
2315
|
});
|
|
2310
2316
|
this.sessionAnnouncementSent = true;
|
|
2311
2317
|
this.copilotLog(hasRealSessionId ? `session announced id=${sessionId}` : "session announced without id");
|
|
2318
|
+
await this.reportBackendNotices();
|
|
2312
2319
|
await this.reportRuntimeStatus(
|
|
2313
2320
|
{
|
|
2314
2321
|
state: this.useSessionFileReplyStream ? undefined : "WAIT_READY",
|
|
@@ -2577,7 +2584,12 @@ export class BridgeRunner {
|
|
|
2577
2584
|
}
|
|
2578
2585
|
const requestId = typeof event.requestId === "string" ? event.requestId.trim() : "";
|
|
2579
2586
|
const reason = typeof event.reason === "string" ? event.reason.trim() : "";
|
|
2580
|
-
|
|
2587
|
+
if (reason === "user_stop" && this.runningTurn) {
|
|
2588
|
+
this.dropQueuedBeforeStop = true;
|
|
2589
|
+
}
|
|
2590
|
+
// No target (`/stop`): interrupt whatever turn is running right now.
|
|
2591
|
+
const targetReplyTo =
|
|
2592
|
+
this.normalizeReplyTarget(event.targetReplyTo) || (this.runningTurn ? this.activeTurnReplyTo : "");
|
|
2581
2593
|
if (!targetReplyTo) {
|
|
2582
2594
|
return false;
|
|
2583
2595
|
}
|
|
@@ -2767,7 +2779,12 @@ export class BridgeRunner {
|
|
|
2767
2779
|
.join(",")}`,
|
|
2768
2780
|
);
|
|
2769
2781
|
|
|
2770
|
-
|
|
2782
|
+
// `/stop` drops every message queued before it; the user resends what they still want.
|
|
2783
|
+
let lastStopIndex = -1;
|
|
2784
|
+
messages.forEach((message, index) => {
|
|
2785
|
+
if (this.isUserStopMessage(message)) lastStopIndex = index;
|
|
2786
|
+
});
|
|
2787
|
+
for (const [index, message] of messages.entries()) {
|
|
2771
2788
|
if (this.stopped) {
|
|
2772
2789
|
break;
|
|
2773
2790
|
}
|
|
@@ -2775,8 +2792,16 @@ export class BridgeRunner {
|
|
|
2775
2792
|
this.copilotLog(`skip message role=${String(message?.role || "unknown").toLowerCase()}`);
|
|
2776
2793
|
continue;
|
|
2777
2794
|
}
|
|
2795
|
+
if (this.isUserStopMessage(message)) {
|
|
2796
|
+
this.dropQueuedBeforeStop = false;
|
|
2797
|
+
} else if ((index < lastStopIndex || this.dropQueuedBeforeStop) && this.isDroppableOnStop(message)) {
|
|
2798
|
+
if (message.message_id) this.processedMessageIds.add(message.message_id);
|
|
2799
|
+
this.copilotLog(`drop message queued before /stop replyTo=${message.message_id || "unknown"}`);
|
|
2800
|
+
continue;
|
|
2801
|
+
}
|
|
2778
2802
|
await this.respondToMessage(message);
|
|
2779
2803
|
}
|
|
2804
|
+
this.dropQueuedBeforeStop = false;
|
|
2780
2805
|
|
|
2781
2806
|
if (ackToken) {
|
|
2782
2807
|
await this.conductor.ackMessages(this.taskId, ackToken);
|
|
@@ -2786,6 +2811,15 @@ export class BridgeRunner {
|
|
|
2786
2811
|
return hasMore;
|
|
2787
2812
|
}
|
|
2788
2813
|
|
|
2814
|
+
isUserStopMessage(message) {
|
|
2815
|
+
return String(message?.role || "").toLowerCase() === "user" && isStopCommand(message.content);
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
// The end-of-round summary request is the server's, not the user's: dropping it would lock the round.
|
|
2819
|
+
isDroppableOnStop(message) {
|
|
2820
|
+
return String(message?.role || "").toLowerCase() === "user" && message.metadata?.kind !== "persistent_round_end";
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2789
2823
|
shouldRespond(message) {
|
|
2790
2824
|
if (!message || typeof message !== "object") {
|
|
2791
2825
|
return false;
|
|
@@ -3664,6 +3698,9 @@ export class BridgeRunner {
|
|
|
3664
3698
|
if (!hasAttachmentInputs && this.createFreshBackendSession && isClearCommand(content)) {
|
|
3665
3699
|
return this.runClearCommand(options);
|
|
3666
3700
|
}
|
|
3701
|
+
if (!hasAttachmentInputs && isStopCommand(content)) {
|
|
3702
|
+
return this.runStopCommand(options);
|
|
3703
|
+
}
|
|
3667
3704
|
|
|
3668
3705
|
if (willRunGoal) {
|
|
3669
3706
|
const goalResult = await this.runWithTurnUsage(() =>
|
|
@@ -3852,6 +3889,24 @@ export class BridgeRunner {
|
|
|
3852
3889
|
return { text, items: [], usage: null, provider: this.backendName, events: [], metadata: {} };
|
|
3853
3890
|
}
|
|
3854
3891
|
|
|
3892
|
+
/**
|
|
3893
|
+
* `/stop`: the server already interrupted the running turn when the message
|
|
3894
|
+
* arrived (fire only reads it once that turn ends), so just confirm it
|
|
3895
|
+
* instead of sending it to the model.
|
|
3896
|
+
*/
|
|
3897
|
+
async runStopCommand({ onProgress, replyTo = "" } = {}) {
|
|
3898
|
+
const text = `${this.backendName} 已停止。`;
|
|
3899
|
+
onProgress?.({ phase: "turn_completed", reply_in_progress: false, status_done_line: text });
|
|
3900
|
+
if (this.useSessionFileReplyStream && !this.stopped) {
|
|
3901
|
+
try {
|
|
3902
|
+
await this.sendSessionStreamMessage({ text, replyTo });
|
|
3903
|
+
} catch (error) {
|
|
3904
|
+
log(`[stop] failed to post confirmation: ${error?.message || error}`);
|
|
3905
|
+
}
|
|
3906
|
+
}
|
|
3907
|
+
return { text, items: [], usage: null, provider: this.backendName, events: [], metadata: {} };
|
|
3908
|
+
}
|
|
3909
|
+
|
|
3855
3910
|
async handlePrePromptMessage(content) {
|
|
3856
3911
|
const text = typeof content === "string" ? content.trim() : "";
|
|
3857
3912
|
if (!text) {
|
|
@@ -3889,6 +3944,8 @@ export class BridgeRunner {
|
|
|
3889
3944
|
}) {
|
|
3890
3945
|
this.lastRuntimeStatusSignature = null;
|
|
3891
3946
|
this.runningTurn = true;
|
|
3947
|
+
// Lets a target-less interrupt (`/stop`) reach this turn.
|
|
3948
|
+
this.activeTurnReplyTo = replyTarget;
|
|
3892
3949
|
// Only this turn's own streamed replies may carry its usage.
|
|
3893
3950
|
this.lastStreamedReplyIds.clear();
|
|
3894
3951
|
const startedAt = Date.now();
|
|
@@ -3963,6 +4020,11 @@ export class BridgeRunner {
|
|
|
3963
4020
|
this.copilotLog(`${logTag} turn interrupted by stop_task elapsedMs=${Date.now() - startedAt}`);
|
|
3964
4021
|
return;
|
|
3965
4022
|
}
|
|
4023
|
+
const interruptInfo = this.remoteInterruptsByReplyTo.get(replyTarget);
|
|
4024
|
+
if (interruptInfo && this.isTurnInterruptedError(error)) {
|
|
4025
|
+
await this.handleInterruptedTurn(replyTarget, interruptInfo);
|
|
4026
|
+
return;
|
|
4027
|
+
}
|
|
3966
4028
|
if (
|
|
3967
4029
|
await this.settleCodexCheckpointUnavailableAfterStream(replyTarget, errorMessage, {
|
|
3968
4030
|
markProcessed: false,
|
|
@@ -3976,11 +4038,38 @@ export class BridgeRunner {
|
|
|
3976
4038
|
await this.reportError(`${errorLabel}执行失败: ${errorMessage}`);
|
|
3977
4039
|
} finally {
|
|
3978
4040
|
this.copilotLog(`${logTag} turn end elapsedMs=${Date.now() - startedAt}`);
|
|
4041
|
+
this.activeTurnReplyTo = "";
|
|
4042
|
+
this.clearInterruptRetryForReplyTarget(replyTarget);
|
|
4043
|
+
this.remoteInterruptsByReplyTo.delete(replyTarget);
|
|
3979
4044
|
this.runningTurn = false;
|
|
3980
4045
|
this.scheduleRuntimeHeartbeat();
|
|
3981
4046
|
}
|
|
3982
4047
|
}
|
|
3983
4048
|
|
|
4049
|
+
// Provider-level warnings the user has to act on (e.g. claude refusing
|
|
4050
|
+
// bypassPermissions as root without IS_SANDBOX=1). Sent right after the
|
|
4051
|
+
// "session started" line so the reason is visible before the turn fails.
|
|
4052
|
+
async reportBackendNotices() {
|
|
4053
|
+
const snapshot =
|
|
4054
|
+
typeof this.backendSession?.getSnapshot === "function" ? this.backendSession.getSnapshot() : null;
|
|
4055
|
+
const notices = Array.isArray(snapshot?.notices)
|
|
4056
|
+
? snapshot.notices.filter((notice) => typeof notice === "string" && notice.trim())
|
|
4057
|
+
: [];
|
|
4058
|
+
for (const notice of notices) {
|
|
4059
|
+
try {
|
|
4060
|
+
await this.conductor.sendMessage(this.taskId, notice, {
|
|
4061
|
+
severity: "warning",
|
|
4062
|
+
backend: this.backendName,
|
|
4063
|
+
cli_args: this.cliArgs,
|
|
4064
|
+
synthetic: true,
|
|
4065
|
+
});
|
|
4066
|
+
this.copilotLog(`backend notice sent: ${sanitizeForLog(notice, 120)}`);
|
|
4067
|
+
} catch (error) {
|
|
4068
|
+
log(`Failed to send backend notice: ${error?.message || error}`);
|
|
4069
|
+
}
|
|
4070
|
+
}
|
|
4071
|
+
}
|
|
4072
|
+
|
|
3984
4073
|
async reportError(message, replyTo) {
|
|
3985
4074
|
try {
|
|
3986
4075
|
await this.conductor.sendMessage(this.taskId, message, {
|