cofluxd 2.14.0 → 2.16.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/coflux.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // coflux:账号与本地、跨设备业务操作;不负责宿主生命周期。
3
3
  import { entityHandle, handlesAccountCommand, runAccountCommand } from "./account-client.mjs";
4
+ import { error as printError, fail, info, success } from "./output.mjs";
4
5
  import { randomUUID } from "node:crypto";
5
6
  import { parseArgs } from "node:util";
6
7
  import { spawnSync } from "node:child_process";
@@ -19,22 +20,23 @@ if (process.argv[2] === "agent" || process.argv[2] === "secret" || process.argv[
19
20
  ? join(process.env.COFLUX_AGENT_BUNDLE, "coflux")
20
21
  : join(HOME, "bin", "coflux");
21
22
  if (!existsSync(native)) {
22
- console.error("Coflux integration is unavailable. Update this device with cofluxd update.");
23
- process.exit(1);
23
+ fail("This command is not available on this device.", "Update Coflux on this device with cofluxd update, then try again.");
24
24
  }
25
25
  const result = spawnSync(native, process.argv.slice(2), { stdio: "inherit" });
26
- if (result.error) console.error(result.error.message);
26
+ if (result.error) printError(result.error.message, "Update Coflux on this device with cofluxd update, then try again.");
27
27
  process.exit(result.status ?? 1);
28
28
  }
29
29
  const DEFAULT_LOCAL_GATEWAY_PORT = 8788;
30
- const die = (message) => { console.error("✗ " + message); process.exit(1); };
30
+ /** Fallback next step for an error the daemon or the server relays without one. */
31
+ const HELP_NEXT = "Run coflux --help for usage.";
32
+ const die = (message, next = HELP_NEXT) => fail(message, next);
31
33
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
32
34
  function localGatewayPort() {
33
35
  const raw = process.env.COFLUX_LOCAL_GATEWAY_PORT;
34
36
  if (raw === undefined || raw === "") return { ok: true, port: DEFAULT_LOCAL_GATEWAY_PORT };
35
37
  const port = Number(raw);
36
38
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
37
- return { ok: false, error: `COFLUX_LOCAL_GATEWAY_PORT=${raw} 无法定位固定监听端口` };
39
+ return { ok: false, error: `COFLUX_LOCAL_GATEWAY_PORT=${raw} is not a valid port` };
38
40
  }
39
41
  return { ok: true, port };
40
42
  }
@@ -46,7 +48,7 @@ function localGatewayPort() {
46
48
  // only then is COFLUX_LOCAL_GATEWAY_PORT read. A reply from the socket, refusals included, is final
47
49
  // and never retried over TCP; a connect refused for any other reason (a sandbox's EPERM/EACCES)
48
50
  // fails hard and names the socket. Mirrors `local_post` in crates/cli/src/gateway.rs.
49
- // The path mirrors `SOCKET_FILE` in crates/worker/src/agent_socket.rs.
51
+ // The path mirrors `SOCKET_FILE` in crates/runtime/src/agent_socket.rs.
50
52
  const AGENT_SOCKET = join(HOME, "ipc", "agent.sock");
51
53
  /** The worker never binds a longer socket path (sun_path limits), so a longer one is absent. */
52
54
  const MAX_SOCKET_PATH_BYTES = 100;
@@ -83,7 +85,7 @@ function socketPost(path, payload, timeoutMs) {
83
85
  );
84
86
  const timer = setTimeout(() => {
85
87
  timedOut = true;
86
- req.destroy(new Error("请求超时"));
88
+ req.destroy(new Error("request timed out"));
87
89
  }, timeoutMs);
88
90
  req.on("error", (error) => {
89
91
  if (!timedOut && error?.syscall === "connect") {
@@ -91,7 +93,8 @@ function socketPost(path, payload, timeoutMs) {
91
93
  return finish({
92
94
  ok: false,
93
95
  kind: "denied",
94
- message: `cannot connect to the coflux daemon's agent socket at ${AGENT_SOCKET} (${error.code || error.message}); this process is not allowed to reach it (a sandbox without local network access?)`,
96
+ message: `This process is not allowed to reach the Coflux service at ${AGENT_SOCKET}: ${error.code || error.message}`,
97
+ next: "If this runs in a sandbox, allow access to that socket.",
95
98
  });
96
99
  }
97
100
  finish({ ok: false, kind: "transport", message: error?.message || String(error) });
@@ -110,9 +113,9 @@ async function localPost(path, body, timeoutMs) {
110
113
  const { pid: _pid, ppid: _ppid, ...rest } = body;
111
114
  const viaSocket = await socketPost(path, JSON.stringify(rest), timeoutMs);
112
115
  if (viaSocket.ok || viaSocket.kind === "transport") return viaSocket;
113
- if (viaSocket.kind === "denied") return { ok: false, kind: "refused", message: viaSocket.message };
116
+ if (viaSocket.kind === "denied") return { ok: false, kind: "refused", message: viaSocket.message, next: viaSocket.next };
114
117
  const portResult = localGatewayPort();
115
- if (!portResult.ok) return { ok: false, kind: "refused", message: portResult.error };
118
+ if (!portResult.ok) return { ok: false, kind: "refused", message: portResult.error, next: "Unset COFLUX_LOCAL_GATEWAY_PORT, or set it to a port number." };
116
119
  try {
117
120
  const res = await fetch(`http://127.0.0.1:${portResult.port}${path}`, {
118
121
  method: "POST",
@@ -159,7 +162,7 @@ async function cmdHook() {
159
162
  try {
160
163
  const agent = positionals[1];
161
164
  if (agent !== "claude" && agent !== "codex") {
162
- hookDebug(`未知 agent: ${agent ?? "(缺参)"}(需 claude|codex)`);
165
+ hookDebug(`unknown agent: ${agent ?? "(missing)"}; expected claude or codex`);
163
166
  return;
164
167
  }
165
168
  let payload = null;
@@ -168,13 +171,13 @@ async function cmdHook() {
168
171
  }
169
172
  if (!payload) payload = await readStdinJson();
170
173
  if (!payload || typeof payload !== "object") {
171
- hookDebug("无有效 payload,忽略");
174
+ hookDebug("no valid payload; ignored");
172
175
  return;
173
176
  }
174
177
  // claude/codex hooks 引擎用 hook_event_name;codex notify 用 type
175
178
  const event = payload.hook_event_name || payload.type;
176
179
  if (typeof event !== "string" || !event) {
177
- hookDebug("payload 缺事件名,忽略");
180
+ hookDebug("payload has no event name; ignored");
178
181
  return;
179
182
  }
180
183
  const notification = payload.notification_type ?? payload.notificationType;
@@ -195,7 +198,7 @@ async function cmdHook() {
195
198
  hookDebug("POST /hook", JSON.stringify(body));
196
199
  // The agent socket first; the gateway port is resolved only when the socket is absent.
197
200
  const res = await localPost("/hook", body, HOOK_POST_TIMEOUT_MS);
198
- hookDebug(res.ok ? `响应 ${res.status}` : res.message);
201
+ hookDebug(res.ok ? `answered ${res.status}` : res.message);
199
202
  } catch (error) {
200
203
  hookDebug(error?.message || String(error));
201
204
  } finally {
@@ -249,18 +252,29 @@ async function agentPostResult(body) {
249
252
  const res = await localPost("/agent", { ...body, cwd: callerCwd() }, agentTimeoutMs());
250
253
  if (!res.ok) {
251
254
  if (res.kind === "refused") return res;
252
- return { ok: false, kind: "transport", message: `连不上本机 daemon:${res.message}(daemon 没在跑?查看 Coflux.app 或 cofluxd status)` };
255
+ return {
256
+ ok: false,
257
+ kind: "transport",
258
+ message: `Cannot reach the Coflux service on this device: ${res.message}`,
259
+ next: SERVICE_NEXT,
260
+ };
253
261
  }
254
262
  let parsed = null;
255
- try { parsed = JSON.parse(res.text); } catch { /* 非 JSON 响应按下面的兜底报错处理 */ }
256
- const success = res.status >= 200 && res.status < 300;
257
- if (!success || !parsed?.ok) return { ok: false, kind: "refused", message: parsed?.error || `daemon 返回 ${res.status}` };
263
+ try { parsed = JSON.parse(res.text); } catch { /* a non-JSON answer is reported below */ }
264
+ const ok = res.status >= 200 && res.status < 300;
265
+ if (!ok || !parsed?.ok) return { ok: false, kind: "refused", message: parsed?.error || `The Coflux service answered with HTTP ${res.status}.` };
258
266
  return { ok: true, value: parsed };
259
267
  }
260
268
 
261
- async function agentPost(body) {
269
+ /** Next step when the local service cannot be reached at all. */
270
+ const SERVICE_NEXT = "Check that Coflux is running on this device: open Coflux.app, or run cofluxd status.";
271
+ /** Next step for a refusal about a terminal. */
272
+ const TERMINAL_NEXT = "Run coflux terminal list to check the terminal.";
273
+
274
+ /** `next` is shown under a refusal the daemon sent without one. */
275
+ async function agentPost(body, next = HELP_NEXT) {
262
276
  const result = await agentPostResult(body);
263
- if (!result.ok) die(result.message);
277
+ if (!result.ok) die(result.message, result.next || next);
264
278
  return result.value;
265
279
  }
266
280
 
@@ -299,8 +313,14 @@ function commandSuffix(t) {
299
313
 
300
314
  /** "do script": type a command into a terminal once its shell signalled prompt readiness. */
301
315
  async function runCommand(taskId, command) {
302
- const result = await agentPost({ action: "terminal.run", taskId, command });
303
- console.log(`已打入命令 #${result.commandSeq}(coflux terminal wait ${taskId} 等它结束,coflux terminal read ${taskId} 看输出)`);
316
+ const result = await agentPost({ action: "terminal.run", taskId, command }, TERMINAL_NEXT);
317
+ success(`Sent command #${result.commandSeq}`);
318
+ info(`Wait for it with coflux terminal wait ${taskId}, and read its output with coflux terminal read ${taskId}.`);
319
+ }
320
+
321
+ /** A subcommand that needs a terminal id got none. */
322
+ function missingTaskId(sub, usage = "") {
323
+ die("Missing terminal id.", `Run coflux terminal list to find it, then coflux terminal ${sub} <taskId>${usage}.`);
304
324
  }
305
325
 
306
326
  async function cmdTerminal(values) {
@@ -311,57 +331,59 @@ async function cmdTerminal(values) {
311
331
  // the command any other way. --cmd missing and --cmd= blank are the same: nothing is typed.
312
332
  const command = (values.cmd ?? "").trim() ? values.cmd : "";
313
333
  const result = await agentPost({ action: "terminal.new", title: values.title || "" });
314
- console.log(`已开终端 ${result.taskId}(用户可在 coflux 侧栏看到并随时接管)`);
334
+ success(`Opened terminal ${result.taskId}`);
315
335
  if (command) {
316
336
  await runCommand(result.taskId, command);
317
337
  } else {
318
- console.log(`常驻的登录 shell(全 tty),不会自己退出`);
319
- console.log(`跑命令:coflux terminal run ${result.taskId} --cmd="<命令>"(等提示符就绪后打入,wait 可等它结束)`);
320
- console.log(`看输出:coflux terminal read ${result.taskId};结束:coflux terminal close ${result.taskId}`);
338
+ info("It is a login shell the user can see and take over. It stays open until you close it.");
339
+ info(`Run a command: coflux terminal run ${result.taskId} --cmd="<command>"`);
340
+ info(`Read its output: coflux terminal read ${result.taskId}`);
341
+ info(`Close it: coflux terminal close ${result.taskId}`);
321
342
  }
322
343
  } else if (sub === "run") {
323
344
  const taskId = positionals[2];
324
- if (!taskId) die("terminal run 需要 <taskId>(用 coflux terminal list 查)");
345
+ if (!taskId) missingTaskId("run", ' --cmd="<command>"');
325
346
  const command = (values.cmd ?? "").trim() ? values.cmd : "";
326
- if (!command) die(`terminal run 需要 --cmd="<命令>"`);
347
+ if (!command) die("Missing command.", `Pass it with --cmd: coflux terminal run ${taskId} --cmd="<command>".`);
327
348
  await runCommand(taskId, command);
328
349
  } else if (sub === "close") {
329
350
  const taskId = positionals[2];
330
- if (!taskId) die("terminal close 需要 <taskId>(用 coflux terminal list 查)");
331
- const result = await agentPost({ action: "terminal.close", taskId });
351
+ if (!taskId) missingTaskId("close");
352
+ const result = await agentPost({ action: "terminal.close", taskId }, TERMINAL_NEXT);
332
353
  if (result.exited) {
333
354
  const exit = result.exitCode === undefined || result.exitCode === null ? "" : ` exit=${result.exitCode}`;
334
- console.log(`已关闭终端 ${taskId}(exited${exit})`);
355
+ success(`Closed terminal ${taskId}${exit}`);
335
356
  } else {
336
- console.log(`已请求关闭终端 ${taskId},shell 仍在退出中(coflux terminal list 可查)`);
357
+ info(`Closing terminal ${taskId}. Its shell is still exiting; check it with coflux terminal list.`);
337
358
  }
338
359
  } else if (sub === "list") {
339
360
  const { terminals } = await agentPost({ action: "terminal.list" });
340
- if (!terminals.length) return void console.log("本工作区暂无终端");
361
+ if (!terminals.length) return void info("No terminals in this workspace.");
341
362
  for (const t of terminals) {
342
363
  const exit = t.exitCode === undefined || t.exitCode === null ? "" : ` exit=${t.exitCode}`;
343
364
  console.log(`${rowHandle(t)} ${t.status}${exit}${commandSuffix(t)} ${t.title}`);
344
365
  }
345
366
  } else if (sub === "read") {
346
367
  const taskId = positionals[2];
347
- if (!taskId) die("terminal read 需要 <taskId>(用 coflux terminal list 查)");
368
+ if (!taskId) missingTaskId("read");
348
369
  const requested = Number(values.lines);
349
370
  const lines = Number.isInteger(requested) && requested > 0 ? requested : DEFAULT_READ_LINES;
350
- const result = await agentPost({ action: "terminal.read", taskId });
371
+ const result = await agentPost({ action: "terminal.read", taskId }, TERMINAL_NEXT);
351
372
  const exit = result.exitCode === undefined || result.exitCode === null ? "" : ` exit=${result.exitCode}`;
352
373
  console.log(`# ${result.status}${exit}`);
353
374
  const text = tailLines(stripAnsi(result.ansi), lines);
354
- console.log(text || "(暂无输出)");
375
+ console.log(text || "(no output yet)");
355
376
  } else if (sub === "send") {
356
377
  const taskId = positionals[2];
357
- if (!taskId) die("terminal send 需要 <taskId>(用 coflux terminal list 查)");
378
+ if (!taskId) missingTaskId("send", ' --text="<text>"');
358
379
  const text = values.text ?? "";
359
- if (!text && !values.enter) die(`terminal send 需要 --text "<文本>"(或至少 --enter 发一个回车)`);
360
- await agentPost({ action: "terminal.send", taskId, text, enter: Boolean(values.enter) });
361
- console.log(`已写入终端 ${taskId}(用 coflux terminal read ${taskId} 核对效果)`);
380
+ if (!text && !values.enter) die("Nothing to send.", 'Pass --text="<text>", or --enter to send a single Enter.');
381
+ await agentPost({ action: "terminal.send", taskId, text, enter: Boolean(values.enter) }, TERMINAL_NEXT);
382
+ success(`Sent input to terminal ${taskId}`);
383
+ info(`Check the result with coflux terminal read ${taskId}.`);
362
384
  } else if (sub === "wait") {
363
385
  const taskId = positionals[2];
364
- if (!taskId) die("terminal wait 需要 <taskId>(用 coflux terminal list 查)");
386
+ if (!taskId) missingTaskId("wait");
365
387
  const requested = Number(values.timeout);
366
388
  const timeoutSec = Number.isFinite(requested) && requested > 0 ? requested : DEFAULT_WAIT_TIMEOUT_S;
367
389
  const seq = Number(values.seq);
@@ -371,33 +393,38 @@ async function cmdTerminal(values) {
371
393
  // Each round blocks inside the daemon (its command-state watch wakes it the moment the
372
394
  // command ends); a `running` answer only means the round elapsed.
373
395
  const roundMs = Math.min(WAIT_ROUND_MS, Math.max(1, deadline - Date.now()));
374
- const t = await agentPost({ action: "terminal.wait", taskId, commandSeq, timeoutMs: roundMs });
396
+ const t = await agentPost({ action: "terminal.wait", taskId, commandSeq, timeoutMs: roundMs }, TERMINAL_NEXT);
375
397
  if (t.state !== "running") {
376
398
  const exit = t.exitCode === undefined || t.exitCode === null ? "" : ` exit=${t.exitCode}`;
377
399
  return void console.log(`# ${t.state === "exited" ? "exited" : "finished"}${exit}`);
378
400
  }
379
401
  if (Date.now() >= deadline) {
380
- die(`等待超时(${timeoutSec}s):终端 ${taskId} 的命令 #${t.commandSeq} 仍在运行。可加大 --timeout,或 coflux terminal read ${taskId} 看现场`);
402
+ die(
403
+ `Timed out after ${timeoutSec}s: command #${t.commandSeq} in terminal ${taskId} is still running.`,
404
+ `Wait longer with --timeout, or look at the screen with coflux terminal read ${taskId}.`,
405
+ );
381
406
  }
382
407
  }
383
408
  } else {
384
- die(`terminal 需要子命令:new | run | list | read | wait | send | close`);
409
+ die(sub ? `Unknown terminal command: ${sub}.` : "Missing terminal command.", "Use one of: new, run, list, read, wait, send, close.");
385
410
  }
386
411
  }
387
412
 
388
413
  async function cmdNotify() {
389
414
  const message = positionals.slice(1).join(" ").trim();
390
- if (!message) die(`notify 需要一句话,例如:coflux notify "两个方案拿不准,需要你定"`);
415
+ if (!message) die("Missing message.", 'Usage: coflux notify "<message>"');
391
416
  const result = await agentPost({ action: "notify", notificationId: randomUUID(), message });
392
- if (!result.notificationId) die("daemon 不支持持久通知,请升级;未确认送达");
393
- console.log("通知已发送(已保存到账号通知中心)");
417
+ if (!result.notificationId) {
418
+ die("The notification was not confirmed: this device's Coflux is too old to save it.", "Update Coflux on this device, then send it again.");
419
+ }
420
+ success("Notification sent");
394
421
  }
395
422
 
396
423
  async function cmdProgress() {
397
424
  const message = positionals.slice(1).join(" ").trim();
398
- if (!message) die(`progress 需要一句话,例如:coflux progress "复现了,正在定位 relay 重连"`);
425
+ if (!message) die("Missing message.", 'Usage: coflux progress "<message>"');
399
426
  await agentPost({ action: "progress", message });
400
- console.log("已更新进度(显示在工作区卡片上,被下一条覆盖)");
427
+ success("Progress updated");
401
428
  }
402
429
 
403
430
  // 「我在哪」与「跟着我搬」(plan 102 / 103)。三条都打一行 JSON,字段稳定——插件脚本按它比对,
@@ -429,7 +456,7 @@ async function cmdWorkspace() {
429
456
  // 路径缺省取调用方 cwd;插件脚本一律显式传 hook 载荷里的 cwd(hook 在会话当前目录执行,
430
457
  // 与载荷里的 cwd 未必相同)。
431
458
  const path = positionals[2] || callerCwd();
432
- if (!path) die("workspace locate 需要 <path>(取不到当前目录)");
459
+ if (!path) die("Could not determine the current directory.", "Pass the path: coflux workspace locate <path>.");
433
460
  const result = await agentPost({ action: "workspace.locate", path });
434
461
  return void console.log(JSON.stringify({
435
462
  workspaceId: result.workspaceId,
@@ -441,7 +468,7 @@ async function cmdWorkspace() {
441
468
  }
442
469
  if (sub === "forget") {
443
470
  const path = positionals[2];
444
- if (!path) die("workspace forget 需要 <path>(被删掉的 worktree 目录)");
471
+ if (!path) die("Missing path.", "Usage: coflux workspace forget <path>");
445
472
  const result = await agentPost({ action: "workspace.forget", path });
446
473
  return void console.log(JSON.stringify({
447
474
  workspaceId: result.workspaceId,
@@ -450,12 +477,12 @@ async function cmdWorkspace() {
450
477
  removed: Boolean(result.removed),
451
478
  }));
452
479
  }
453
- die(`workspace 的子命令只有 enter | locate | forget(不带子命令 = 报出我在哪)`);
480
+ die(`Unknown workspace command: ${sub}.`, "Use enter, locate or forget, or no subcommand to print the current workspace.");
454
481
  }
455
482
 
456
483
  async function cmdPorts() {
457
484
  const { ports } = await agentPost({ action: "ports" });
458
- if (!ports.length) return void console.log("本工作区暂无监听端口");
485
+ if (!ports.length) return void info("No listening ports in this workspace.");
459
486
  for (const p of ports) console.log(`${p.port} ${p.url}`);
460
487
  }
461
488
 
@@ -496,25 +523,31 @@ function executorChangedFiles(status) {
496
523
  function renderExecutorSuccess(status) {
497
524
  const out = ["# succeeded"];
498
525
  const summary = String(status?.summary ?? "").trim();
499
- out.push(summary || "(executor 没有留下最终回复)");
526
+ out.push(summary || "(the executor left no final reply)");
500
527
  const files = executorChangedFiles(status);
501
- if (!files.length) out.push("改动文件:无");
502
- else { out.push(`改动文件(${files.length}):`); out.push(...files); }
503
- out.push("executor 不会 git commit:改动请自己 review 后提交。");
528
+ if (!files.length) out.push("Changed files: none");
529
+ else { out.push(`Changed files (${files.length}):`); out.push(...files); }
530
+ out.push("The executor never commits. Review the changes and commit them yourself.");
504
531
  return out.join("\n");
505
532
  }
506
533
 
507
- /** One stderr sentence for a non-success terminal state: state, reason, and what already changed. */
534
+ /** The error for a non-success terminal state: state, reason, and what already changed. */
508
535
  function renderExecutorFailure(status) {
509
536
  const terminal = String(status?.terminal ?? "") || "unknown";
510
- const reason = String(status?.error ?? "").trim() || String(status?.note ?? "").trim() || "executor 没有给出原因";
537
+ const reason = String(status?.error ?? "").trim() || String(status?.note ?? "").trim() || "no reason given";
511
538
  const files = executorChangedFiles(status);
512
- const tail = files.length ? `;已改动 ${files.length} 个文件:${files.join(" ")}` : "";
513
- return `executor 任务未成功(${terminal}):${reason}${tail}`;
539
+ const tail = files.length ? `. Files already changed (${files.length}): ${files.join(" ")}` : "";
540
+ return {
541
+ message: `Executor run ended as ${terminal}: ${reason}${tail}`,
542
+ next: files.length ? "Review the changes it left, then send a new run." : "Adjust the prompt and send a new run.",
543
+ };
514
544
  }
515
545
 
516
546
  function renderExecutorTimeout(timeoutSec, runId, phase) {
517
- return `等待超时(${timeoutSec}s):executor 任务 ${runId} 仍是 ${phase},已请求取消。可加大 --timeout 后重发`;
547
+ return {
548
+ message: `Timed out after ${timeoutSec}s: executor run ${runId} was still ${phase} and has been cancelled.`,
549
+ next: "Send it again with a larger --timeout.",
550
+ };
518
551
  }
519
552
 
520
553
  /** Submit. A transport failure retries with the same submissionId; a refusal is reported verbatim.
@@ -526,19 +559,19 @@ async function executorSubmit(prompt, write, title) {
526
559
  const result = await agentPostResult({ action: "executor.submit", submissionId: submission, prompt, write, title });
527
560
  if (result.ok) {
528
561
  const runId = String(result.value?.runId ?? "");
529
- if (!runId) die("daemon 没有返回 runId(版本太旧?)");
562
+ if (!runId) die("The Coflux service did not return a run id.", "Update Coflux on this device, then try again.");
530
563
  return runId;
531
564
  }
532
- if (result.kind === "refused" || attempt >= EXECUTOR_SUBMIT_RETRIES) die(result.message);
565
+ if (result.kind === "refused" || attempt >= EXECUTOR_SUBMIT_RETRIES) die(result.message, result.next || HELP_NEXT);
533
566
  await sleep(EXECUTOR_POLL_MS);
534
567
  }
535
568
  }
536
569
 
537
570
  async function cmdExecutor(values) {
538
- if (positionals[1] !== "run") die(`executor 的子命令只有 run:coflux executor run --prompt="<任务>" [--title="<标题>"] [--write]`);
571
+ if (positionals[1] !== "run") die("Unknown executor command.", 'Usage: coflux executor run --prompt="<task>" [--title="<title>"] [--write]');
539
572
  const prompt = String(values.prompt ?? "").trim();
540
573
  if (!prompt) {
541
- die(`executor run 需要 --prompt="<任务>"(一句把边界说清的任务描述,例如 --prompt="把 crates/worker 的 clippy 警告清掉")`);
574
+ die("Missing prompt.", 'Describe the task with --prompt, for example --prompt="Fix the clippy warnings in crates/worker".');
542
575
  }
543
576
  const write = Boolean(values.write);
544
577
  const title = String(values.title ?? "").trim();
@@ -553,104 +586,143 @@ async function cmdExecutor(values) {
553
586
  // `succeeded` is about the *task*; the envelope's top-level `ok` only says the request itself
554
587
  // was accepted.
555
588
  if (status.succeeded) return void console.log(renderExecutorSuccess(status));
556
- die(renderExecutorFailure(status));
589
+ const failure = renderExecutorFailure(status);
590
+ die(failure.message, failure.next);
557
591
  }
558
592
  if (Date.now() >= deadline) {
559
593
  // Cancel before reporting: an unwatched write job still editing files in the background is
560
594
  // far worse than the timeout itself.
561
595
  await agentPostResult({ action: "executor.cancel", runId });
562
- die(renderExecutorTimeout(timeoutSec, runId, status.phase));
596
+ const timeout = renderExecutorTimeout(timeoutSec, runId, status.phase);
597
+ die(timeout.message, timeout.next);
563
598
  }
564
599
  await sleep(EXECUTOR_POLL_MS);
565
600
  }
566
601
  }
567
602
 
568
- const HELP = `coflux —— 账号与终端操作
569
- coflux hook <claude|codex> [agent hook 信使] 读 stdin/argv 的事件 JSON,转发给本机 daemon
570
- (在 claude/codex 的 hook 配置里指向本命令;失败静默,不干扰 agent)
571
-
572
- 以下几条供**跑在 coflux 终端里的 agent** 调用,把工作变成用户看得见、能接管的东西:
573
-
574
- coflux terminal new [--title="<标题>"] [--cmd="<命令>"]
575
- 开一个真实终端:工作区目录下的常驻登录 shell,stdin/stdout 都是真 tty,
576
- 用户在 coflux 侧栏能看到并随时接管,直到输入 exit 或 close 才结束
577
- 带 --cmd = 等 shell 提示符就绪后把命令打进去(终端继续活着),等于 new + run
578
- coflux terminal run <taskId> --cmd="<命令>"
579
- 往已开的终端里打一条命令(提示符就绪后才打入;上一条还在跑时拒绝)
580
- coflux terminal wait <taskId> [--timeout=<秒>] [--seq=<N>]
581
- 阻塞等到当前(或第 N 条)命令结束,打印它的退出码:# finished exit=<code>;
582
- shell 自己退出则打印 # exited exit=<code>(默认超时 30 分钟)
583
- coflux terminal read <taskId> [--lines=N]
584
- 读终端滚动缓冲的尾部(纯文本,默认最后 200 行,可远超一屏)
585
- coflux terminal send <taskId> --text="<文本>" [--enter]
586
- 往终端里输入文本(--enter 追加回车)。用户正在接管时会被拒
587
- coflux terminal list 列出本工作区的终端(含 status / 退出码,跑着的还带 busy|idle 与上一条命令的退出码)
603
+ const HELP = `Work with Coflux terminals, workspaces and your account.
604
+
605
+ Usage:
606
+ coflux <command> [subcommand] [flags]
607
+
608
+ Commands for agents running in a Coflux terminal:
609
+ coflux terminal new [--title=<title>] [--cmd=<command>]
610
+ Open a terminal: a login shell on a real tty in the workspace directory. The user sees
611
+ it in the sidebar and can take it over. It stays open until you close it. With --cmd,
612
+ the command is typed in once the prompt is ready, the same as new followed by run.
613
+ coflux terminal run <taskId> --cmd=<command>
614
+ Type a command into the terminal once its prompt is ready. Refused while the previous
615
+ command is still running.
616
+ coflux terminal wait <taskId> [--timeout=<seconds>] [--seq=<N>]
617
+ Wait for the current (or Nth) command to finish and print "# finished exit=<code>",
618
+ or "# exited exit=<code>" when the shell itself ended. Default timeout: 30 minutes.
619
+ coflux terminal read <taskId> [--lines=<N>]
620
+ Print the end of the terminal's scrollback as plain text. Default: the last 200 lines.
621
+ coflux terminal send <taskId> --text=<text> [--enter]
622
+ Type text into the terminal; --enter adds Enter. Refused while the user has taken over.
623
+ coflux terminal list
624
+ List this workspace's terminals with their status and exit code. Live ones also show
625
+ busy or idle and the exit code of their last command.
588
626
  coflux terminal close <taskId>
589
- 结束该终端(等价账号 CLI 的 stop)
590
- coflux notify "<一句话>" 发送站内通知;服务器保存后确认送达
591
- coflux progress "<一句话>" 播报进度:显示在工作区卡片上,被下一条覆盖(不打扰用户)
592
- coflux ports 列出本工作区的监听端口及可直接打开的预览 URL
593
- coflux executor run --prompt="<任务>" [--title="<标题>"] [--write] [--timeout <秒>]
594
- 把一个边界清楚的子任务甩给内置的轻量 executor(由本机 Coflux.app
595
- 执行),阻塞到跑完并打印它的最终回复与改动文件。一次性:没有会话、
596
- 不续聊,要改就再发一次。入参只有任务描述与读写模式——模型由用户在
597
- Coflux.app 里全局配一次。默认只读;--write 才允许改文件(同一工作区
598
- 同时只允许一个写任务)。它被内核级沙箱锁在本工作区目录内,**不联网**
599
- (先把依赖装好再甩),也**不会 git commit**(改动由你自己 review 提交)
600
- --title 给这次运行起个短标题:用户在本终端上会看到一张进度小卡
601
- 只有装了 Coflux.app 的这台机器能用
602
- coflux workspace 一行 JSON 报出「我在哪」:workspaceId(cwd 所在的有效工作区,本地命令
603
- 都落在它上面)、path、owningWorkspaceId(本终端此刻归属哪个工作区)、
604
- moved。用 /cd 挪进另一个 coflux 工作区后用它确认目标,跨工作区操作时也传这个
605
- workspaceId
627
+ End the terminal.
628
+ coflux notify "<message>"
629
+ Send the user a notification. Confirmed once the server has saved it.
630
+ coflux progress "<message>"
631
+ Show a progress line on the workspace card. The next one replaces it.
632
+ coflux ports
633
+ List this workspace's listening ports and their preview URLs.
634
+ coflux secret ask NAME --reason "<why>" [--timeout <seconds>]
635
+ coflux secret exec NAME [NAME…] -- <cmd> [args…]
636
+ coflux secret inject NAME --file <path> [--key KEY]
637
+ Get a secret (API key, password) from the user on their Coflux desktop without the
638
+ value entering your context: ask prints only provided | declined | cancelled; exec
639
+ runs a command with the value in a same-name environment variable and shows it as ***
640
+ in the output; inject writes KEY=value into a dotenv file in this workspace.
641
+ Details: coflux secret help
642
+ coflux annotations list [--json]
643
+ coflux annotations watch [--timeout <seconds>] [--json]
644
+ coflux annotations resolve <id> --note "<what you changed>"
645
+ Browser annotations: elements the user marked in Coflux's built-in browser for this
646
+ workspace, with their comment, component names and source location when known,
647
+ selector, and screenshot/reference image paths. list prints the pending ones as
648
+ markdown; watch blocks until there are some (default 30 minutes); after implementing
649
+ one, resolve it with a note the user reads to review the change.
650
+ coflux executor run --prompt=<task> [--title=<title>] [--write] [--timeout <seconds>]
651
+ Hand a well-bounded sub-task to the built-in executor and wait for its final reply and
652
+ the files it changed. Each run is one-shot. Read-only unless --write, with one writing
653
+ run per workspace at a time. It has no network and never commits. --title names the
654
+ progress card the user sees on this terminal. Needs Coflux.app on this machine.
655
+ coflux workspace
656
+ Print one JSON line: workspaceId (where your local commands land), path,
657
+ owningWorkspaceId (the workspace this terminal belongs to) and moved.
606
658
  coflux workspace enter <path>
607
- 进入同仓库工作区并迁移当前终端;受管 Codex 会话记住选择供恢复/压缩使用。
608
- 后续工具必须显式使用返回路径;不会改变宿主默认 cwd 或沙箱权限
659
+ Enter a workspace of the same repository and move this terminal there. Use the
660
+ returned path explicitly in later tool calls.
609
661
  coflux workspace locate [path]
610
- 把本终端的**归属**搬到 path(缺省=当前目录)所属的工作区:进入/离开
611
- worktree 后 coflux 跟着走,未登记的同仓库 worktree 先登记出一个子工作区。
612
- 插件自动调,一般不用手敲
662
+ Move this terminal to the workspace that owns path (default: the current directory),
663
+ registering a worktree of the same repository when needed. The plugin calls this.
613
664
  coflux workspace forget <path>
614
- 该 worktree 已被删掉:其下所有终端搬回项目主工作区、工作区记录消失
615
- (不执行 git worktree remove)
616
-
617
- 实体标识:设备 / 项目 / 工作区 / 终端的 ID 都可以写成 coflux:<kind>:<ID 前 8 位>,例如
618
- coflux:workspace:3f2a1b7c。凡是收 ID 的地方都收标识(大小写不敏感),返回实体的地方都带一个
619
- ref 字段给出它的标识。前缀在范围内撞车时会让你改用完整 ID;标识类型与命令要的不一致会直接报错,
620
- 不会去动旁边那个实体。
621
-
622
- agent 命令的环境变量:COFLUX_AGENT_TIMEOUT_MS 收窄单次请求的等待上限(默认 30000,只能调小),
623
- 供有硬超时的 hook 脚本用——到点干净失败,好过被宿主杀在半路。
624
-
625
- 账号命令(JSON 输出):
626
- coflux login --username <账号> --password-stdin [--server https://…]
665
+ The worktree at path was deleted: move its terminals back to the main workspace and
666
+ drop its record. Does not run git worktree remove.
667
+ coflux hook <claude|codex>
668
+ Forward an agent hook event from stdin or argv to this device. Never fails the agent.
669
+
670
+ Account commands (JSON output):
671
+ coflux login [--server <url>]
672
+ Sign in through the browser. Over SSH, or with COFLUX_LOGIN_PASTE=1, paste a code.
673
+ coflux login --username <name> --password-stdin [--server <url>]
627
674
  coflux whoami | logout
628
675
  coflux device list | project list | workspace list
629
- coflux device exec <deviceId> --cmd="<命令>" [--cwd=<目录>] [--timeout=<秒>]
630
- 在另一台设备上跑一条命令并拿回结果,语义同 ssh host "cmd":命令交给远端
631
- sh -c(管道、&&、重定向、通配、$VAR 都有效),stdout 与 stderr 分开回带,
632
- 最后一行是 # exit=<code>,进程退出码透传远端(本命令自己失败时为 255)。
633
- **这不是终端**:没有 PTY、不进用户侧栏、不占工作区的终端并发额度、不需要
634
- 任何工作区。--cwd 默认 daemon 用户的 HOME,只接受绝对路径或 ~ 开头的路径;
635
- --timeout 默认 60 秒、最长 600 秒;没有 stdin。要输密码、驱动 TUI,或想让
636
- 用户看见过程并能接管的长任务,用 coflux terminal new,不要用它
637
- coflux project import <path> [--device <id>] [--name <名称>]
638
- 把设备上的一个 git 仓库目录变成项目(路径在仓库里就导入仓库根),并
639
- 建好它的主工作区;打印一行 JSON:projectId / name / repoPath /
640
- defaultBranch / workspaceId / path / alreadyImported。<path> 必填,
641
- 只接受绝对路径或 ~ 开头的路径(它在目标设备上解析)——导入当前目录写
642
- coflux project import "$PWD"。--device 缺省取 COFLUX_DEVICE_ID。
643
- 同一个仓库根导入第二次不会多出一个项目:返回已有的那个,
644
- alreadyImported=true
645
- coflux workspace new --project <id> --branch <分支> [--existing-branch]
646
- coflux workspace rename <id> --name <名称> | workspace remove <id>
647
- coflux terminal new --workspace <id> [--cmd <命令>]
648
- coflux terminal run|read|send|wait|stop|remove <id> --remote
649
- coflux terminal list [--device <id>] [--workspace <id>](跑着的终端带 busy / lastCommandExitCode,经 checkpoint 滞后 ≤2 秒)
676
+ coflux device exec <deviceId> --cmd=<command> [--cwd=<dir>] [--timeout=<seconds>]
677
+ Run one command on another device, like ssh host "cmd": it runs under sh -c, stdout
678
+ and stderr come back separately, the last line is "# exit=<code>", and the exit code
679
+ is the remote one (255 when this command itself fails). Not a terminal: no PTY,
680
+ nothing in the sidebar, no stdin. --cwd defaults to the device user's home and must be
681
+ absolute or start with ~. --timeout defaults to 60 seconds, at most 600. For
682
+ passwords, TUIs or long work the user should see, use coflux terminal new.
683
+ coflux project import <path> [--device <id>] [--name <name>]
684
+ Turn a git repository on a device into a project with its main workspace and print
685
+ one JSON line: projectId, name, repoPath, defaultBranch, workspaceId, path,
686
+ alreadyImported. <path> is resolved on the device and must be absolute or start with
687
+ ~; to import the current directory, pass "$PWD". --device defaults to
688
+ COFLUX_DEVICE_ID. Importing the same repository again returns the existing project
689
+ with alreadyImported=true.
690
+ coflux workspace new --project <id> --branch <branch> [--existing-branch]
691
+ coflux workspace rename <id> --name <name> | workspace remove <id>
692
+ coflux terminal new --workspace <id> [--cmd <command>] [--title <title>]
693
+ coflux terminal list [--device <id>] [--workspace <id>]
694
+ coflux terminal run|read|wait|send|stop|remove <id> --remote
650
695
  coflux ports --remote
651
- 已登录的 Coflux 应用可供 CLI 直接使用;独立 CLI 可自行登录。
652
- `;
653
- const { values, positionals } = parseArgs({
696
+ When the Coflux app is signed in, these use its account; otherwise run coflux login.
697
+
698
+ Ids and handles:
699
+ Wherever an id is accepted you can pass a handle, coflux:<kind>:<first 8 of id>, for
700
+ example coflux:workspace:3f2a1b7c (case-insensitive). Results carry it as ref. A prefix
701
+ that matches several entities asks for the full id; a handle of the wrong kind is an error.
702
+
703
+ Flags:
704
+ -h, --help Show this help
705
+
706
+ Environment:
707
+ COFLUX_AGENT_TIMEOUT_MS Lower the wait for one local request (default 30000), for hook
708
+ scripts with a hard time limit
709
+ NO_COLOR Turn off colour
710
+
711
+ To manage this device, use Coflux.app or cofluxd.`;
712
+ /** parseArgs errors in the same words as the Rust CLI (crates/cli/src/args.rs). */
713
+ function argumentError(error) {
714
+ const message = String(error?.message ?? error);
715
+ const first = message.split(". ")[0];
716
+ const missing = /^Option '(.+)' argument missing/.exec(first);
717
+ if (missing) return `Option '${missing[1]}' needs a value`;
718
+ const unexpected = /^Option '(.+)' does not take an argument/.exec(first);
719
+ if (unexpected) return `Option '${unexpected[1]}' does not take a value`;
720
+ return first;
721
+ }
722
+
723
+ let parsedArgs;
724
+ try {
725
+ parsedArgs = parseArgs({
654
726
  allowPositionals: true,
655
727
  options: {
656
728
  username: { type: "string" },
@@ -679,15 +751,19 @@ const { values, positionals } = parseArgs({
679
751
  enter: { type: "boolean", default: false },
680
752
  help: { type: "boolean", short: "h", default: false },
681
753
  },
682
- });
754
+ });
755
+ } catch (error) {
756
+ die(argumentError(error));
757
+ }
758
+ const { values, positionals } = parsedArgs;
683
759
 
684
760
  const cmd = positionals[0];
685
761
  if (values.help || cmd === "help" || !cmd) { console.log(HELP); process.exit(0); }
686
762
  if (handlesAccountCommand(positionals, values, HOME)) {
687
- try { await runAccountCommand(positionals, values, HOME); } catch (error) { die(error.message); }
763
+ try { await runAccountCommand(positionals, values, HOME); } catch (error) { die(error.message, error.next); }
688
764
  process.exit(0);
689
765
  }
690
766
  const handlers = { hook: cmdHook, terminal: cmdTerminal, notify: cmdNotify, progress: cmdProgress, ports: cmdPorts, executor: cmdExecutor, workspace: cmdWorkspace };
691
767
  const handler = handlers[cmd];
692
- if (!handler) die(`未知命令: ${cmd}\n本机宿主请使用 Coflux.app 或 cofluxd。\n\n${HELP}`);
768
+ if (!handler) die(`Unknown command: ${cmd}`, "Run coflux --help to see the commands. To manage this device, use Coflux.app or cofluxd.");
693
769
  await handler(values);