@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 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 `![](picture.png)` / `![](recording.mp4)`. 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
+ }
@@ -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
- // claude exits 1 with `--dangerously-skip-permissions` under root, so never
26
- // write a flag the user's own machine cannot run. Same root check the
27
- // daemon and the ai-sdk session use.
28
- execArgs: isClaudeRootPermissionRestricted()
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
- "# remote_file: true",
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, pad, printJson, printPretty, reportError } from "../src/entity-helpers.js";
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
- await yargs(argvInput)
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
@@ -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` are read-only queries, not a daemon launch.
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();
@@ -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
- const targetReplyTo = this.normalizeReplyTarget(event.targetReplyTo);
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
- for (const message of messages) {
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, {