cofluxd 1.1.1 → 2.0.1

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/README.md CHANGED
@@ -9,6 +9,36 @@ Operate local and remote terminals from one account. This package provides two d
9
9
 
10
10
  Desktop, CLI, and runtime releases share the same version. The macOS desktop app includes its own native `coflux` binary and does not require this npm package or Node.js.
11
11
 
12
+ ## Codex skill discovery in Coflux terminals
13
+
14
+ Interactive `codex`, `codex resume`, and `codex fork` invocations use a private
15
+ Codex app-server. The native CLI registers the invocation's immutable Coflux skill
16
+ directory through `skills/extraRoots/set` before connecting the TUI over a private
17
+ Unix socket. Coflux appears in `/skills` and the `$` skill selector, and Codex loads
18
+ the skill body on demand. The session hook continues to supply current terminal
19
+ and workspace coordinates.
20
+
21
+ No skill is installed in the user's skill directories and no plugin or marketplace
22
+ registration is written to their configuration. The extra root belongs only to
23
+ this app-server process; concurrent invocations keep their own skill versions.
24
+ The launcher monitors the terminal UI, and a lifetime-pipe watchdog cleans up the
25
+ backend and socket when the launcher exits, including when the terminal is killed.
26
+
27
+ This requires a Codex version supporting `--remote unix://PATH` and
28
+ `skills/extraRoots/set` (verified with Codex CLI 0.154.0). If startup or skill
29
+ discovery fails, the launcher reports the error instead of claiming integration
30
+ is ready. `COFLUX_AGENT_INTEGRATION=off codex` bypasses managed integration.
31
+ Profile-selected invocations (`--profile` / `-p`) retain the native runtime and
32
+ print an explanatory notice: Codex app-server cannot load profiles, and using the
33
+ remote TUI would lose profile fields such as `developer_instructions`. Explicit
34
+ `--remote` endpoints, administrative commands, and noninteractive commands
35
+ such as `codex exec` retain the existing launch path; they do not receive this
36
+ process-local skill registration. Existing hook injection remains unchanged.
37
+
38
+ For remote resume/fork, `--yolo`, `--sandbox`, `--ask-for-approval`, and permission
39
+ config overrides are applied to the private backend; Codex rejects these options
40
+ on the remote TUI itself. New sessions retain the native TUI permission flags.
41
+
12
42
  ## Install
13
43
 
14
44
  Requires Node.js 20 or later.
@@ -34,14 +64,20 @@ coflux terminal read <terminal-id> --remote
34
64
  Account commands return JSON. Inside a Coflux terminal, local commands automatically use the current workspace:
35
65
 
36
66
  ```sh
37
- coflux terminal new --title 'Tests' --cmd 'pnpm test'
67
+ coflux terminal new --title 'Tests' --cmd 'pnpm test' # a persistent shell; the command is typed in once its prompt is ready
68
+ coflux terminal wait <terminal-id> # blocks until that command finishes and prints its exit code
69
+ coflux terminal run <terminal-id> --cmd 'pnpm lint' # type another command into the same shell
70
+ coflux terminal read <terminal-id> # the tail of the terminal's scrollback
38
71
  coflux terminal list
39
- coflux terminal read <terminal-id>
40
- coflux terminal wait <terminal-id>
72
+ coflux terminal close <terminal-id>
41
73
  coflux progress 'Reviewing the changes.'
42
74
  coflux notify 'Ready for your review.'
43
75
  ```
44
76
 
77
+ Every terminal is the workspace's default login shell on a real tty, alive until `exit` or `close`; `--cmd` and `run` only type a command in after the shell has signalled that its prompt is ready, and `wait` reports that command's exit code while the terminal stays open.
78
+
79
+ `coflux notify` sends a persistent notification to your account inbox from the owning terminal. It needs a server connection and reports success only after the server saves it. Desktop shows an in-app hint in the foreground and additionally requests a system notification in the background. Reading a notification clears its unread state, while history survives hooks, terminal exit, and source deletion.
80
+
45
81
  The CLI bundled with the desktop app can reuse the app's login through a local channel. Independently installed CLIs can sign in themselves. See `coflux --help`, `cofluxd --help`, and the [agent skill](skills/coflux/SKILL.md).
46
82
 
47
83
  ## Upgrades and terminal lifetime
@@ -29,6 +29,11 @@ function broker(home, body, timeout) {
29
29
  socket.on("end", () => { try { resolve(unwrap(JSON.parse(text))); } catch (error) { reject(error); } });
30
30
  });
31
31
  }
32
+ /** 写到底再继续:`device exec` 之后要用远端退出码退出,而 process.exit 会截断写向管道的输出。 */
33
+ function writeAll(stream, text) {
34
+ if (!text) return Promise.resolve();
35
+ return new Promise((resolve) => stream.write(text, resolve));
36
+ }
32
37
  export function handlesAccountCommand(positionals, flags, home) {
33
38
  const [command, sub] = positionals;
34
39
  if (["login", "logout", "whoami", "device", "project"].includes(command)) return true;
@@ -59,7 +64,8 @@ export async function runAccountCommand(positionals, flags, home) {
59
64
  const session = fs.existsSync(sessionPath) ? JSON.parse(fs.readFileSync(sessionPath, "utf8")) : null;
60
65
  const call = (operation) => {
61
66
  const body = { protocolVersion: 1, command: operation };
62
- const timeout = operation.op === "terminal.wait" ? 610000 : 40000;
67
+ // `terminal.wait` `device.exec` 都可能在中心侧阻塞到 600 秒;其余账号操作 40 秒足够。
68
+ const timeout = operation.op === "terminal.wait" || operation.op === "device.exec" ? 610000 : 40000;
63
69
  if (!session) {
64
70
  if (flags.server) throw new Error("请先登录指定服务器");
65
71
  return broker(home, body, timeout);
@@ -76,6 +82,32 @@ export async function runAccountCommand(positionals, flags, home) {
76
82
  print({ loggedOut: true });
77
83
  return;
78
84
  }
85
+ // `device exec`: one-shot cross-device execution, ssh semantics — not a Terminal, so the output is
86
+ // not JSON either. stdout goes to stdout, stderr to stderr (always separate), the last line is
87
+ // `# exit=<code>`, and the process exit code is the **remote** one. This CLI's own failures
88
+ // (device offline, capability missing, bad cwd, timeout, bad arguments) all exit 255, so a caller's
89
+ // shell test can tell "the remote command returned 1" from "it never ran".
90
+ if (command === "device" && sub === "exec") {
91
+ const fail = async (message) => { await writeAll(process.stderr, `✗ ${message}\n`); process.exit(255); };
92
+ let value;
93
+ try {
94
+ if (!id) throw new Error("缺少设备 ID(coflux device list 可以看到)");
95
+ const cmd = required("cmd");
96
+ const timeout = Number(flags.timeout ?? 60);
97
+ // 上限在这里就说清楚,别让中心的入参校验回一句「请求失败」。
98
+ if (!Number.isInteger(timeout)) throw new Error("--timeout 必须是整数秒");
99
+ if (timeout < 1 || timeout > 600) throw new Error("--timeout 取 1-600 秒;更久、或需要用户看见的长任务请改用 coflux terminal new");
100
+ value = await call({ op: "device.exec", deviceId: id, command: cmd, cwd: flags.cwd ?? "", timeout });
101
+ } catch (error) {
102
+ await fail(error.message);
103
+ }
104
+ const exitCode = Number(value?.exitCode);
105
+ if (!Number.isInteger(exitCode)) await fail("设备回执缺少退出码(中心版本过旧?)");
106
+ await writeAll(process.stdout, String(value.stdout ?? ""));
107
+ await writeAll(process.stderr, String(value.stderr ?? ""));
108
+ await writeAll(process.stdout, `# exit=${exitCode}\n`);
109
+ process.exit(exitCode);
110
+ }
79
111
  let operation;
80
112
  if (command === "workspace") {
81
113
  if (sub === "new") operation = { op: "workspace.new", projectId: required("project"), branch: required("branch"), createNew: !flags["existing-branch"], ...(flags.name ? { name: flags.name } : {}) };
@@ -83,7 +115,9 @@ export async function runAccountCommand(positionals, flags, home) {
83
115
  if (sub === "remove") operation = { op: "workspace.remove", workspaceId: target() };
84
116
  }
85
117
  if (command === "terminal") {
118
+ // `--cmd` is "do script": typed into the new terminal once its shell is at the prompt; the terminal stays open.
86
119
  if (sub === "new") operation = { op: "terminal.new", workspaceId: required("workspace"), title: flags.title || "", command: flags.cmd || "" };
120
+ if (sub === "run") operation = { op: "terminal.run", terminalId: target(), command: required("cmd") };
87
121
  if (sub === "read") operation = { op: "terminal.read", terminalId: target(), lines: Number(flags.lines ?? 200) };
88
122
  if (sub === "send") operation = { op: "terminal.send", terminalId: target(), text: required("text"), enter: !!flags.enter };
89
123
  if (sub === "wait") operation = { op: "terminal.wait", terminalId: target(), timeout: Number(flags.timeout ?? 30) };
package/coflux.mjs CHANGED
@@ -1,15 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  // coflux:账号与本地、跨设备业务操作;不负责宿主生命周期。
3
3
  import { handlesAccountCommand, runAccountCommand } from "./account-client.mjs";
4
+ import { randomUUID } from "node:crypto";
4
5
  import { parseArgs } from "node:util";
5
6
  import { spawnSync } from "node:child_process";
6
7
  import { existsSync } from "node:fs";
7
8
  import { homedir } from "node:os";
8
9
  import { join } from "node:path";
9
10
  const HOME = process.env.COFLUX_HOME || join(homedir(), ".coflux");
10
- // Delegate integration to the native CLI shipped with this device's runtime.
11
- if (process.argv[2] === "agent") {
12
- const native = join(HOME, "bin", "coflux");
11
+ // Native integration owns explicit workspace selection and conversation state.
12
+ if (process.argv[2] === "agent" || (process.argv[2] === "workspace" && process.argv[3] === "enter")) {
13
+ const native = process.env.COFLUX_AGENT_BUNDLE
14
+ ? join(process.env.COFLUX_AGENT_BUNDLE, "coflux")
15
+ : join(HOME, "bin", "coflux");
13
16
  if (!existsSync(native)) {
14
17
  console.error("Coflux integration is unavailable. Update this device with cofluxd update.");
15
18
  process.exit(1);
@@ -122,10 +125,9 @@ async function cmdHook() {
122
125
  // **看得见、能接管**的 coflux 实体——而不是在自己的 Bash 里后台起一个谁也看不见的进程。
123
126
  //
124
127
  // 不需要任何凭证:daemon 用调用方 pid 反查进程树确认它属于哪个会话,树外一律拒。
125
- // local-first(plan 094):send/read/wait/notify/progress daemon 本地闭环,不经中心;只有
126
- // new/list/ports daemon 代问中心(Task 要落库广播、预览 URL 由中心生成)。
127
- // 跟随 cwd(plan 102):请求都带 process.cwd(),agent 挪进同设备另一个 coflux 工作区后,这些
128
- // 命令就对那个工作区办事(notify/progress/ports 除外,它们挂在本会话上,与工作区无关)。
128
+ // Local read/send/run/wait/close/progress stay on the daemon. Notify, new/list/ports and ownership
129
+ // changes require a server acknowledgement. Scope follows cwd except terminal-owned
130
+ // notify/progress/ports, whose source remains the owning session.
129
131
  // 与 `hook` 子命令的约定**相反**:这些命令必须写 stdout——输出就是给 agent 读的返回值。
130
132
  // 也刻意不做自动重试:terminal new 有副作用,重试会开出两个终端,失败就把错误交给 agent。
131
133
 
@@ -133,11 +135,11 @@ const AGENT_TIMEOUT_MS = 30_000;
133
135
  /** 调用方能收窄单次 `/agent` 等待的下限;再低就只够覆盖 node 自己的启动,等于必然超时。 */
134
136
  const MIN_AGENT_TIMEOUT_MS = 200;
135
137
  const DEFAULT_READ_LINES = 200;
136
- // wait 的循环必须在 CLI 侧:单次 agentPost 25 秒的 loopback 应答上限。默认 30 分钟——编码任务
137
- // 常跑很久;轮询走 terminal.status(daemon 本地账本直接答,不经中心),3 秒一次对本机 loopback
138
- // 是零负担。
138
+ // wait loops here because one agentPost round-trip is capped at 25 s on the loopback endpoint; each
139
+ // round the daemon blocks up to WAIT_ROUND_MS on its command-state watch and answers the moment the
140
+ // command finishes, so the loop never hammers it. Default 30 minutes overall.
139
141
  const DEFAULT_WAIT_TIMEOUT_S = 1800;
140
- const WAIT_POLL_MS = 3000;
142
+ const WAIT_ROUND_MS = 20_000;
141
143
 
142
144
  // 每条请求都带调用方 cwd(plan 102):agent 可以经 `/cd` 或 EnterWorktree 把活着的会话挪进同
143
145
  // 设备的另一个 coflux 工作区,daemon 据此把本次请求的**目标**解析到 cwd 所在的工作区(会话的
@@ -157,9 +159,12 @@ function agentTimeoutMs() {
157
159
  return Math.min(Math.max(Math.floor(raw), MIN_AGENT_TIMEOUT_MS), AGENT_TIMEOUT_MS);
158
160
  }
159
161
 
160
- async function agentPost(body) {
162
+ // The two kinds of `/agent` failure, separated for the executor's submit path: only a "transport"
163
+ // failure may be re-sent with the same submissionId (the daemon deduplicates on it); re-sending a
164
+ // "refused" request accomplishes nothing. Mirrors `AgentError` in crates/cli/src/gateway.rs.
165
+ async function agentPostResult(body) {
161
166
  const portResult = localGatewayPort();
162
- if (!portResult.ok) die(portResult.error);
167
+ if (!portResult.ok) return { ok: false, kind: "refused", message: portResult.error };
163
168
  let res;
164
169
  try {
165
170
  res = await fetch(`http://127.0.0.1:${portResult.port}/agent`, {
@@ -169,12 +174,19 @@ async function agentPost(body) {
169
174
  signal: AbortSignal.timeout(agentTimeoutMs()),
170
175
  });
171
176
  } catch (error) {
172
- die(`连不上本机 daemon:${error?.message || error}(daemon 没在跑?查看 Coflux.app 或 cofluxd status)`);
177
+ const detail = error?.message || error;
178
+ return { ok: false, kind: "transport", message: `连不上本机 daemon:${detail}(daemon 没在跑?查看 Coflux.app 或 cofluxd status)` };
173
179
  }
174
180
  let parsed = null;
175
181
  try { parsed = await res.json(); } catch { /* 非 JSON 响应按下面的兜底报错处理 */ }
176
- if (!res.ok || !parsed?.ok) die(parsed?.error || `daemon 返回 ${res.status}`);
177
- return parsed;
182
+ if (!res.ok || !parsed?.ok) return { ok: false, kind: "refused", message: parsed?.error || `daemon 返回 ${res.status}` };
183
+ return { ok: true, value: parsed };
184
+ }
185
+
186
+ async function agentPost(body) {
187
+ const result = await agentPostResult(body);
188
+ if (!result.ok) die(result.message);
189
+ return result.value;
178
190
  }
179
191
 
180
192
  // 剥掉 ANSI/OSC 转义与 C0 控制字符,保留 \t 与 \n——snapshot 是给终端渲染的字节流,
@@ -194,29 +206,57 @@ function tailLines(text, n) {
194
206
  return lines.slice(-n).join("\n");
195
207
  }
196
208
 
209
+ /** ` busy` / ` idle` plus ` last=<code>` for a live, instrumented terminal; nothing otherwise. */
210
+ function commandSuffix(t) {
211
+ if (!t.integrated) return "";
212
+ const last = t.lastCommandExitCode === undefined || t.lastCommandExitCode === null ? "" : ` last=${t.lastCommandExitCode}`;
213
+ return `${t.busy ? " busy" : " idle"}${last}`;
214
+ }
215
+
216
+ /** "do script": type a command into a terminal once its shell signalled prompt readiness. */
217
+ async function runCommand(taskId, command) {
218
+ const result = await agentPost({ action: "terminal.run", taskId, command });
219
+ console.log(`已打入命令 #${result.commandSeq}(coflux terminal wait ${taskId} 等它结束,coflux terminal read ${taskId} 看输出)`);
220
+ }
221
+
197
222
  async function cmdTerminal(values) {
198
223
  const sub = positionals[1];
199
224
  if (sub === "new") {
200
- // 两种终端只看「命令是否为空」(plan 101):--cmd 缺省与 --cmd= 空白等价,都开会话终端
201
- // ——工作区目录下的默认登录 shell,stdin/stdout 都是真 tty,不自动退出。带命令的仍是作业
202
- // 终端:命令跑完终端退出并带退出码,语义一字不变。空白在这里统一收敛成空串,好让中心的
203
- // 「默认标题取命令首行」落到它自己的兜底。
225
+ // Open first, then "do script": the terminal exists (and is reported) even when the command
226
+ // cannot be typed — an old daemon refuses terminal.run as an unknown action and never runs
227
+ // the command any other way. --cmd missing and --cmd= blank are the same: nothing is typed.
204
228
  const command = (values.cmd ?? "").trim() ? values.cmd : "";
205
- const result = await agentPost({ action: "terminal.new", title: values.title || "", command });
229
+ const result = await agentPost({ action: "terminal.new", title: values.title || "" });
206
230
  console.log(`已开终端 ${result.taskId}(用户可在 coflux 侧栏看到并随时接管)`);
207
231
  if (command) {
208
- console.log(`看输出:coflux terminal read ${result.taskId}`);
232
+ await runCommand(result.taskId, command);
209
233
  } else {
210
- console.log(`会话终端:常驻的登录 shell(全 tty),不会自己退出`);
211
- console.log(`先等提示符:coflux terminal read ${result.taskId}`);
212
- console.log(`再输命令:coflux terminal send ${result.taskId} --text "<命令>" --enter(送 exit 才结束)`);
234
+ console.log(`常驻的登录 shell(全 tty),不会自己退出`);
235
+ console.log(`跑命令:coflux terminal run ${result.taskId} --cmd="<命令>"(等提示符就绪后打入,wait 可等它结束)`);
236
+ console.log(`看输出:coflux terminal read ${result.taskId};结束:coflux terminal close ${result.taskId}`);
237
+ }
238
+ } else if (sub === "run") {
239
+ const taskId = positionals[2];
240
+ if (!taskId) die("terminal run 需要 <taskId>(用 coflux terminal list 查)");
241
+ const command = (values.cmd ?? "").trim() ? values.cmd : "";
242
+ if (!command) die(`terminal run 需要 --cmd="<命令>"`);
243
+ await runCommand(taskId, command);
244
+ } else if (sub === "close") {
245
+ const taskId = positionals[2];
246
+ if (!taskId) die("terminal close 需要 <taskId>(用 coflux terminal list 查)");
247
+ const result = await agentPost({ action: "terminal.close", taskId });
248
+ if (result.exited) {
249
+ const exit = result.exitCode === undefined || result.exitCode === null ? "" : ` exit=${result.exitCode}`;
250
+ console.log(`已关闭终端 ${taskId}(exited${exit})`);
251
+ } else {
252
+ console.log(`已请求关闭终端 ${taskId},shell 仍在退出中(coflux terminal list 可查)`);
213
253
  }
214
254
  } else if (sub === "list") {
215
255
  const { terminals } = await agentPost({ action: "terminal.list" });
216
256
  if (!terminals.length) return void console.log("本工作区暂无终端");
217
257
  for (const t of terminals) {
218
258
  const exit = t.exitCode === undefined || t.exitCode === null ? "" : ` exit=${t.exitCode}`;
219
- console.log(`${t.taskId} ${t.status}${exit} ${t.title}`);
259
+ console.log(`${t.taskId} ${t.status}${exit}${commandSuffix(t)} ${t.title}`);
220
260
  }
221
261
  } else if (sub === "read") {
222
262
  const taskId = positionals[2];
@@ -240,29 +280,33 @@ async function cmdTerminal(values) {
240
280
  if (!taskId) die("terminal wait 需要 <taskId>(用 coflux terminal list 查)");
241
281
  const requested = Number(values.timeout);
242
282
  const timeoutSec = Number.isFinite(requested) && requested > 0 ? requested : DEFAULT_WAIT_TIMEOUT_S;
283
+ const seq = Number(values.seq);
284
+ const commandSeq = Number.isInteger(seq) && seq > 0 ? seq : 0;
243
285
  const deadline = Date.now() + timeoutSec * 1000;
244
286
  for (;;) {
245
- // taskId 直接问本地账本;目标不存在/不在本工作区时 daemon 回可读错误,agentPost 直接 die。
246
- const t = await agentPost({ action: "terminal.status", taskId });
247
- if (t.status === "exited") {
287
+ // Each round blocks inside the daemon (its command-state watch wakes it the moment the
288
+ // command ends); a `running` answer only means the round elapsed.
289
+ const roundMs = Math.min(WAIT_ROUND_MS, Math.max(1, deadline - Date.now()));
290
+ const t = await agentPost({ action: "terminal.wait", taskId, commandSeq, timeoutMs: roundMs });
291
+ if (t.state !== "running") {
248
292
  const exit = t.exitCode === undefined || t.exitCode === null ? "" : ` exit=${t.exitCode}`;
249
- return void console.log(`# exited${exit}`);
293
+ return void console.log(`# ${t.state === "exited" ? "exited" : "finished"}${exit}`);
250
294
  }
251
295
  if (Date.now() >= deadline) {
252
- die(`等待超时(${timeoutSec}s):终端 ${taskId} 仍是 ${t.status}。可加大 --timeout,或 coflux terminal read ${taskId} 看现场`);
296
+ die(`等待超时(${timeoutSec}s):终端 ${taskId} 的命令 #${t.commandSeq} 仍在运行。可加大 --timeout,或 coflux terminal read ${taskId} 看现场`);
253
297
  }
254
- await sleep(WAIT_POLL_MS);
255
298
  }
256
299
  } else {
257
- die(`terminal 需要子命令:new | list | read | wait | send`);
300
+ die(`terminal 需要子命令:new | run | list | read | wait | send | close`);
258
301
  }
259
302
  }
260
303
 
261
304
  async function cmdNotify() {
262
305
  const message = positionals.slice(1).join(" ").trim();
263
306
  if (!message) die(`notify 需要一句话,例如:coflux notify "两个方案拿不准,需要你定"`);
264
- await agentPost({ action: "notify", message });
265
- console.log("已通知用户(工作区在侧栏转为「等待交互」)");
307
+ const result = await agentPost({ action: "notify", notificationId: randomUUID(), message });
308
+ if (!result.notificationId) die("daemon 不支持持久通知,请升级;未确认送达");
309
+ console.log("通知已发送(已保存到账号通知中心)");
266
310
  }
267
311
 
268
312
  async function cmdProgress() {
@@ -318,7 +362,7 @@ async function cmdWorkspace() {
318
362
  removed: Boolean(result.removed),
319
363
  }));
320
364
  }
321
- die(`workspace 的子命令只有 locate | forget(不带子命令 = 报出我在哪)`);
365
+ die(`workspace 的子命令只有 enter | locate | forget(不带子命令 = 报出我在哪)`);
322
366
  }
323
367
 
324
368
  async function cmdPorts() {
@@ -327,32 +371,149 @@ async function cmdPorts() {
327
371
  for (const p of ports) console.log(`${p.port} ${p.url}`);
328
372
  }
329
373
 
374
+ /* -------------------------------- executor ------------------------------- */
375
+ // `coflux executor run`: hand one well-bounded sub-task to the built-in executor. Request bodies,
376
+ // stdout phrases and exit codes are aligned command-for-command with `run_executor` in
377
+ // crates/cli/src/commands.rs.
378
+ //
379
+ // Three phases: **submit** returns a runId (answered immediately, deduplicated by `submissionId`)
380
+ // -> the CLI **polls** status (a single `/agent` reply is capped at 25 seconds, so a long run can
381
+ // never hang off one request) -> the terminal state is rendered. On wait timeout a cancel goes out
382
+ // before the error: leaving an unwatched write job editing files is worse than the timeout itself.
383
+
384
+ // Tighter than WAIT_POLL_MS: the executor's terminal state is a return value someone is blocked on.
385
+ const EXECUTOR_POLL_MS = 2000;
386
+ const DEFAULT_EXECUTOR_TIMEOUT_S = 1800;
387
+ // How many times a submission may be re-sent after a *transport* failure. The retry reuses the same
388
+ // submissionId and the daemon deduplicates on it — "never blindly resubmit" forbids a second id,
389
+ // not a second attempt.
390
+ const EXECUTOR_SUBMIT_RETRIES = 2;
391
+
392
+ /** This process's stable submission id: pid plus the nanosecond it was minted. Generated once. */
393
+ function submissionId() {
394
+ const nanos = BigInt(Date.now()) * 1000000n + (process.hrtime.bigint() % 1000000n);
395
+ return `sub-${process.pid}-${nanos}`;
396
+ }
397
+
398
+ function executorTimeoutSecs(raw) {
399
+ const requested = Number(raw);
400
+ return Number.isFinite(requested) && requested > 0 ? requested : DEFAULT_EXECUTOR_TIMEOUT_S;
401
+ }
402
+
403
+ function executorChangedFiles(status) {
404
+ return Array.isArray(status?.changedFiles) ? status.changedFiles.filter((f) => typeof f === "string") : [];
405
+ }
406
+
407
+ /** stdout for a success: a machine-readable status line, the final reply, then the changed files. */
408
+ function renderExecutorSuccess(status) {
409
+ const out = ["# succeeded"];
410
+ const summary = String(status?.summary ?? "").trim();
411
+ out.push(summary || "(executor 没有留下最终回复)");
412
+ const files = executorChangedFiles(status);
413
+ if (!files.length) out.push("改动文件:无");
414
+ else { out.push(`改动文件(${files.length}):`); out.push(...files); }
415
+ out.push("executor 不会 git commit:改动请自己 review 后提交。");
416
+ return out.join("\n");
417
+ }
418
+
419
+ /** One stderr sentence for a non-success terminal state: state, reason, and what already changed. */
420
+ function renderExecutorFailure(status) {
421
+ const terminal = String(status?.terminal ?? "") || "unknown";
422
+ const reason = String(status?.error ?? "").trim() || String(status?.note ?? "").trim() || "executor 没有给出原因";
423
+ const files = executorChangedFiles(status);
424
+ const tail = files.length ? `;已改动 ${files.length} 个文件:${files.join(" ")}` : "";
425
+ return `executor 任务未成功(${terminal}):${reason}${tail}`;
426
+ }
427
+
428
+ function renderExecutorTimeout(timeoutSec, runId, phase) {
429
+ return `等待超时(${timeoutSec}s):executor 任务 ${runId} 仍是 ${phase},已请求取消。可加大 --timeout 后重发`;
430
+ }
431
+
432
+ /** Submit. A transport failure retries with the same submissionId; a refusal is reported verbatim. */
433
+ async function executorSubmit(prompt, write) {
434
+ const submission = submissionId();
435
+ for (let attempt = 0; ; attempt += 1) {
436
+ const result = await agentPostResult({ action: "executor.submit", submissionId: submission, prompt, write });
437
+ if (result.ok) {
438
+ const runId = String(result.value?.runId ?? "");
439
+ if (!runId) die("daemon 没有返回 runId(版本太旧?)");
440
+ return runId;
441
+ }
442
+ if (result.kind === "refused" || attempt >= EXECUTOR_SUBMIT_RETRIES) die(result.message);
443
+ await sleep(EXECUTOR_POLL_MS);
444
+ }
445
+ }
446
+
447
+ async function cmdExecutor(values) {
448
+ if (positionals[1] !== "run") die(`executor 的子命令只有 run:coflux executor run --prompt="<任务>" [--write]`);
449
+ const prompt = String(values.prompt ?? "").trim();
450
+ if (!prompt) {
451
+ die(`executor run 需要 --prompt="<任务>"(一句把边界说清的任务描述,例如 --prompt="把 crates/worker 的 clippy 警告清掉")`);
452
+ }
453
+ const write = Boolean(values.write);
454
+ const timeoutSec = executorTimeoutSecs(values.timeout);
455
+ const deadline = Date.now() + timeoutSec * 1000;
456
+ const runId = await executorSubmit(prompt, write);
457
+ for (;;) {
458
+ // The first poll does not sleep: a rejection (write lock taken, model not configured) has to
459
+ // surface immediately instead of costing the caller a whole poll interval.
460
+ const status = await agentPost({ action: "executor.status", runId });
461
+ if (status.phase === "done") {
462
+ // `succeeded` is about the *task*; the envelope's top-level `ok` only says the request itself
463
+ // was accepted.
464
+ if (status.succeeded) return void console.log(renderExecutorSuccess(status));
465
+ die(renderExecutorFailure(status));
466
+ }
467
+ if (Date.now() >= deadline) {
468
+ // Cancel before reporting: an unwatched write job still editing files in the background is
469
+ // far worse than the timeout itself.
470
+ await agentPostResult({ action: "executor.cancel", runId });
471
+ die(renderExecutorTimeout(timeoutSec, runId, status.phase));
472
+ }
473
+ await sleep(EXECUTOR_POLL_MS);
474
+ }
475
+ }
476
+
330
477
  const HELP = `coflux —— 账号与终端操作
331
478
  coflux hook <claude|codex> [agent hook 信使] 读 stdin/argv 的事件 JSON,转发给本机 daemon
332
479
  (在 claude/codex 的 hook 配置里指向本命令;失败静默,不干扰 agent)
333
480
 
334
481
  以下几条供**跑在 coflux 终端里的 agent** 调用,把工作变成用户看得见、能接管的东西:
335
482
 
336
- coflux terminal new [--cmd "<命令>"] [--title "<标题>"]
337
- 开一个真实终端,用户在 coflux 侧栏能看到并随时接管
338
- --cmd = 作业终端:命令在登录 shell 里跑完即退出并带退出码,输出另
339
- 落一份日志供 read 回读(代价:stdout 是管道,不是 tty)
340
- 不带 --cmd = 会话终端:工作区目录下的常驻登录 shell,stdin/stdout 都是
341
- 真 tty(能跑 vim/htop、有颜色),先 read 等提示符再 send,送 exit 才结束
342
- coflux terminal list 列出本工作区的终端(含 status / 退出码)
343
- coflux terminal read <taskId> [--lines N]
344
- 读某个终端的内容(纯文本,默认最后 200 行;终端已退出也能读)
345
- coflux terminal wait <taskId> [--timeout <秒>]
346
- 阻塞等到该终端退出,打印退出码(默认超时 30 分钟)
347
- coflux terminal send <taskId> --text "<文本>" [--enter]
483
+ coflux terminal new [--title="<标题>"] [--cmd="<命令>"]
484
+ 开一个真实终端:工作区目录下的常驻登录 shell,stdin/stdout 都是真 tty,
485
+ 用户在 coflux 侧栏能看到并随时接管,直到输入 exit close 才结束
486
+ --cmd = shell 提示符就绪后把命令打进去(终端继续活着),等于 new + run
487
+ coflux terminal run <taskId> --cmd="<命令>"
488
+ 往已开的终端里打一条命令(提示符就绪后才打入;上一条还在跑时拒绝)
489
+ coflux terminal wait <taskId> [--timeout=<秒>] [--seq=<N>]
490
+ 阻塞等到当前(或第 N 条)命令结束,打印它的退出码:# finished exit=<code>;
491
+ shell 自己退出则打印 # exited exit=<code>(默认超时 30 分钟)
492
+ coflux terminal read <taskId> [--lines=N]
493
+ 读终端滚动缓冲的尾部(纯文本,默认最后 200 行,可远超一屏)
494
+ coflux terminal send <taskId> --text="<文本>" [--enter]
348
495
  往终端里输入文本(--enter 追加回车)。用户正在接管时会被拒
349
- coflux notify "<一句话>" 叫人:工作区在侧栏转为「等待交互」并显示这句话
496
+ coflux terminal list 列出本工作区的终端(含 status / 退出码,跑着的还带 busy|idle 与上一条命令的退出码)
497
+ coflux terminal close <taskId>
498
+ 结束该终端(等价账号 CLI 的 stop)
499
+ coflux notify "<一句话>" 发送站内通知;服务器保存后确认送达
350
500
  coflux progress "<一句话>" 播报进度:显示在工作区卡片上,被下一条覆盖(不打扰用户)
351
501
  coflux ports 列出本工作区的监听端口及可直接打开的预览 URL
502
+ coflux executor run --prompt="<任务>" [--write] [--timeout <秒>]
503
+ 把一个边界清楚的子任务甩给内置的轻量 executor(由本机 Coflux.app
504
+ 执行),阻塞到跑完并打印它的最终回复与改动文件。一次性:没有会话、
505
+ 不续聊,要改就再发一次。入参只有任务描述与读写模式——模型由用户在
506
+ Coflux.app 里全局配一次。默认只读;--write 才允许改文件(同一工作区
507
+ 同时只允许一个写任务)。它被内核级沙箱锁在本工作区目录内,**不联网**
508
+ (先把依赖装好再甩),也**不会 git commit**(改动由你自己 review 提交)
509
+ 只有装了 Coflux.app 的这台机器能用
352
510
  coflux workspace 一行 JSON 报出「我在哪」:workspaceId(cwd 所在的有效工作区,本地命令
353
511
  都落在它上面)、path、owningWorkspaceId(本终端此刻归属哪个工作区)、
354
512
  moved。用 /cd 挪进另一个 coflux 工作区后用它确认目标,跨工作区操作时也传这个
355
513
  workspaceId
514
+ coflux workspace enter <path>
515
+ 进入同仓库工作区并迁移当前终端;受管 Codex 会话记住选择供恢复/压缩使用。
516
+ 后续工具必须显式使用返回路径;不会改变宿主默认 cwd 或沙箱权限
356
517
  coflux workspace locate [path]
357
518
  把本终端的**归属**搬到 path(缺省=当前目录)所属的工作区:进入/离开
358
519
  worktree 后 coflux 跟着走,未登记的同仓库 worktree 先登记出一个子工作区。
@@ -368,11 +529,19 @@ agent 命令的环境变量:COFLUX_AGENT_TIMEOUT_MS 收窄单次请求的等
368
529
  coflux login --username <账号> --password-stdin [--server https://…]
369
530
  coflux whoami | logout
370
531
  coflux device list | project list | workspace list
532
+ coflux device exec <deviceId> --cmd="<命令>" [--cwd=<目录>] [--timeout=<秒>]
533
+ 在另一台设备上跑一条命令并拿回结果,语义同 ssh host "cmd":命令交给远端
534
+ sh -c(管道、&&、重定向、通配、$VAR 都有效),stdout 与 stderr 分开回带,
535
+ 最后一行是 # exit=<code>,进程退出码透传远端(本命令自己失败时为 255)。
536
+ **这不是终端**:没有 PTY、不进用户侧栏、不占工作区的终端并发额度、不需要
537
+ 任何工作区。--cwd 默认 daemon 用户的 HOME,只接受绝对路径或 ~ 开头的路径;
538
+ --timeout 默认 60 秒、最长 600 秒;没有 stdin。要输密码、驱动 TUI,或想让
539
+ 用户看见过程并能接管的长任务,用 coflux terminal new,不要用它
371
540
  coflux workspace new --project <id> --branch <分支> [--existing-branch]
372
541
  coflux workspace rename <id> --name <名称> | workspace remove <id>
373
542
  coflux terminal new --workspace <id> [--cmd <命令>]
374
- coflux terminal read|send|wait|stop|remove <id> --remote
375
- coflux terminal list [--device <id>] [--workspace <id>]
543
+ coflux terminal run|read|send|wait|stop|remove <id> --remote
544
+ coflux terminal list [--device <id>] [--workspace <id>](跑着的终端带 busy / lastCommandExitCode,经 checkpoint 滞后 ≤2 秒)
376
545
  coflux ports --remote
377
546
  已登录的 Coflux 应用可供 CLI 直接使用;独立 CLI 可自行登录。
378
547
  `;
@@ -394,7 +563,14 @@ const { values, positionals } = parseArgs({
394
563
  cmd: { type: "string" },
395
564
  lines: { type: "string" },
396
565
  timeout: { type: "string" },
566
+ seq: { type: "string" },
567
+ // `device exec`: the working directory on the remote device (absolute, or a `~` prefix).
568
+ cwd: { type: "string" },
397
569
  text: { type: "string" },
570
+ // executor: the only free-form input is the prompt; the model is configured once in Coflux.app.
571
+ prompt: { type: "string" },
572
+ // executor: read-only by default; --write is the only mode switch.
573
+ write: { type: "boolean", default: false },
398
574
  enter: { type: "boolean", default: false },
399
575
  help: { type: "boolean", short: "h", default: false },
400
576
  },
@@ -406,7 +582,7 @@ if (handlesAccountCommand(positionals, values, HOME)) {
406
582
  try { await runAccountCommand(positionals, values, HOME); } catch (error) { die(error.message); }
407
583
  process.exit(0);
408
584
  }
409
- const handlers = { hook: cmdHook, terminal: cmdTerminal, notify: cmdNotify, progress: cmdProgress, ports: cmdPorts, workspace: cmdWorkspace };
585
+ const handlers = { hook: cmdHook, terminal: cmdTerminal, notify: cmdNotify, progress: cmdProgress, ports: cmdPorts, executor: cmdExecutor, workspace: cmdWorkspace };
410
586
  const handler = handlers[cmd];
411
587
  if (!handler) die(`未知命令: ${cmd}\n本机宿主请使用 Coflux.app 或 cofluxd。\n\n${HELP}`);
412
588
  await handler(values);
package/cofluxd.mjs CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  assertReleaseVersion,
18
18
  compareReleaseVersions,
19
19
  createReleasePublicKey,
20
- installStagedPair,
20
+ installNativeRelease,
21
21
  parseReleaseManifestEntry,
22
22
  verifyReleaseArtifact,
23
23
  } from "./release-trust.mjs";
@@ -254,7 +254,7 @@ async function resolveLatestTag() {
254
254
  async function ensureBinaries({ version, binDir, skipIfPresent }) {
255
255
  fs.mkdirSync(BIN_DIR, { recursive: true });
256
256
  if (binDir) {
257
- const localArtifacts = ["coflux-supervisor", "coflux-worker", ...(fs.existsSync(join(binDir, "coflux")) ? ["coflux"] : [])].map((name) => ({
257
+ const localArtifacts = ["coflux-supervisor", "coflux-worker", ...(fs.existsSync(join(binDir, "coflux")) ? ["coflux"] : []), ...(fs.existsSync(join(binDir, "coflux-transport")) ? ["coflux-transport"] : [])].map((name) => ({
258
258
  name,
259
259
  path: join(binDir, name),
260
260
  }));
@@ -275,7 +275,12 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
275
275
  staged.push({ source, destination });
276
276
  }
277
277
  resignMacBinaries(staged.map(({ source }) => source));
278
- installStagedPair(staged);
278
+ if (localArtifacts.some(artifact => artifact.name === "coflux-transport")) {
279
+ const source = join(stageDir, "TRANSPORT-NOTICES.txt");
280
+ fs.copyFileSync(join(binDir, "TRANSPORT-NOTICES.txt"), source);
281
+ staged.push({ source, destination: join(BIN_DIR, "TRANSPORT-NOTICES.txt") });
282
+ }
283
+ installNativeRelease(staged);
279
284
  } catch (error) {
280
285
  localFailure = error;
281
286
  } finally {
@@ -336,7 +341,7 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
336
341
  }
337
342
  const publicKey = loadReleasePublicKey();
338
343
  const staged = [];
339
- for (const component of ["supervisor", "worker", ...(manifest.cli ? ["cli"] : [])]) {
344
+ for (const component of ["supervisor", "worker", ...(manifest.cli ? ["cli"] : []), ...(manifest.transport ? ["transport"] : [])]) {
340
345
  const entry = parseReleaseManifestEntry(manifest, component, releaseVersion, target);
341
346
  const artifactName = `coflux-${component}-${target}`;
342
347
  process.stdout.write(`下载并验签 ${artifactName} … `);
@@ -351,18 +356,25 @@ async function ensureBinaries({ version, binDir, skipIfPresent }) {
351
356
  fs.chmodSync(source, 0o755);
352
357
  staged.push({
353
358
  source,
354
- destination: component === "cli" ? join(BIN_DIR, "coflux") : component === "supervisor" ? SUP_BIN : WRK_BIN,
359
+ destination: component === "cli" ? join(BIN_DIR, "coflux") : component === "supervisor" ? SUP_BIN : component === "transport" ? join(BIN_DIR, "coflux-transport") : WRK_BIN,
355
360
  });
356
361
  console.log("✓");
357
362
  }
358
363
  // 两个远端产物均通过同一发布根的验签后,才允许做本地平台变换与替换。
359
364
  resignMacBinaries(staged.map(({ source }) => source));
365
+ if (manifest.transport) {
366
+ const source = join(stageDir, "TRANSPORT-NOTICES.txt");
367
+ const notices = await fetchBounded(`${base}/coflux-transport-NOTICES-${target}.txt`, 2 * 1024 * 1024, "native transport notices");
368
+ if (notices.length === 0) throw new Error("Native transport notices are empty");
369
+ fs.writeFileSync(source, notices, { mode: 0o644 });
370
+ staged.push({ source, destination: join(BIN_DIR, "TRANSPORT-NOTICES.txt") });
371
+ }
360
372
  // floor 先于 pair 提交:若其后进程崩溃,最多是旧二进制配更高 floor;重跑同版仍允许,
361
373
  // 但任何旧的合法 release 都不能趁窗口降级。
362
374
  if (!releaseFloor || compareReleaseVersions(releaseVersion, releaseFloor) > 0) {
363
375
  persistCliReleaseFloor(releaseVersion);
364
376
  }
365
- installStagedPair(staged);
377
+ installNativeRelease(staged);
366
378
  } catch (error) {
367
379
  failure = error;
368
380
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cofluxd",
3
- "version": "1.1.1",
3
+ "version": "2.0.1",
4
4
  "description": "Coflux 无界面宿主(cofluxd)与统一操作工具(coflux)",
5
5
  "type": "module",
6
6
  "bin": {
package/release-trust.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import { Buffer } from "node:buffer";
3
3
  import fs from "node:fs";
4
+ import path from "node:path";
4
5
 
5
6
  export const WORKER_RELEASE_STATEMENT_DOMAIN = Buffer.from(
6
7
  "coflux-worker-release-v1\0",
@@ -13,6 +14,9 @@ export const SUPERVISOR_RELEASE_STATEMENT_DOMAIN = Buffer.from(
13
14
 
14
15
  export const CLI_RELEASE_STATEMENT_DOMAIN = Buffer.from("coflux-cli-release-v1\0", "utf8");
15
16
 
17
+ export const TRANSPORT_RELEASE_STATEMENT_DOMAIN = Buffer.from("coflux-transport-release-v1\0", "utf8");
18
+ export function transportReleaseStatement(metadata) { return artifactReleaseStatement(TRANSPORT_RELEASE_STATEMENT_DOMAIN, metadata); }
19
+
16
20
  const STRICT_RELEASE_VERSION = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
17
21
  const SHA256_HEX = /^[0-9a-f]{64}$/i;
18
22
  const ED25519_SIGNATURE_HEX = /^[0-9a-f]{128}$/i;
@@ -142,7 +146,7 @@ function isRecord(value) {
142
146
  */
143
147
  export function parseReleaseManifestEntry(manifest, component, version, target) {
144
148
  assertReleaseVersion(version);
145
- if (!["worker", "supervisor", "cli"].includes(component)) {
149
+ if (!["worker", "supervisor", "cli", "transport"].includes(component)) {
146
150
  throw new Error(`未知 release component: ${JSON.stringify(component)}`);
147
151
  }
148
152
  if (!isRecord(manifest) || manifest.schemaVersion !== 2 || manifest.version !== version) {
@@ -198,7 +202,7 @@ export function verifyReleaseArtifact({ component, version, entry, data, publicK
198
202
  const metadata = { version, target: entry.target, sha256, size: data.byteLength };
199
203
  const statement = component === "worker"
200
204
  ? workerReleaseStatement(metadata)
201
- : component === "cli" ? cliReleaseStatement(metadata) : supervisorReleaseStatement(metadata);
205
+ : component === "cli" ? cliReleaseStatement(metadata) : component === "transport" ? transportReleaseStatement(metadata) : supervisorReleaseStatement(metadata);
202
206
  if (!crypto.verify(null, statement, publicKey, Buffer.from(entry.releaseSignature, "hex"))) {
203
207
  throw new Error(`${component} 产物 release Ed25519 签名无效`);
204
208
  }
@@ -211,11 +215,11 @@ export function verifyReleaseArtifact({ component, version, entry, data, publicK
211
215
  export function installStagedPair(staged) {
212
216
  if (
213
217
  !Array.isArray(staged) ||
214
- ![2, 3].includes(staged.length) ||
218
+ ![2, 3, 4, 5].includes(staged.length) ||
215
219
  staged.some(({ source, destination }) =>
216
220
  typeof source !== "string" || !source || typeof destination !== "string" || !destination)
217
221
  ) {
218
- throw new Error("daemon installation requires two or three valid staged artifacts");
222
+ throw new Error("daemon installation requires two to five valid staged artifacts");
219
223
  }
220
224
  const installed = [];
221
225
  const backups = [];
@@ -244,3 +248,48 @@ export function installStagedPair(staged) {
244
248
  throw error;
245
249
  }
246
250
  }
251
+
252
+ /** Publish a complete native runtime before changing any executable entry point.
253
+ * A worker reached through a bin symlink resolves current_exe into its immutable
254
+ * release directory, so interruption between entry-point updates cannot pair
255
+ * that worker with a helper from another release. */
256
+ export function installNativeRelease(staged) {
257
+ if (!staged.some(({ destination }) => path.basename(destination) === "coflux-transport")) return installStagedPair(staged);
258
+ const binDir = path.dirname(staged[0].destination);
259
+ if (staged.some(({ destination }) => path.dirname(destination) !== binDir)) throw new Error("Native release destinations must share one binary directory");
260
+ const digest = crypto.createHash("sha256");
261
+ const entries = staged.map(({ source, destination }) => ({ source, destination, name: path.basename(destination) })).sort((a, b) => a.name.localeCompare(b.name));
262
+ if (new Set(entries.map(entry => entry.name)).size !== entries.length) throw new Error("Duplicate native release entry point");
263
+ for (const entry of entries) digest.update(entry.name).update("\0").update(fs.readFileSync(entry.source));
264
+ const id = digest.digest("hex"), releases = path.join(binDir, "releases"), directory = path.join(releases, id);
265
+ fs.mkdirSync(releases, { recursive: true, mode: 0o700 });
266
+ const sync = name => { const fd = fs.openSync(name, "r"); try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } };
267
+ if (!fs.existsSync(directory)) {
268
+ const temporary = fs.mkdtempSync(path.join(releases, ".staging-"));
269
+ try {
270
+ for (const entry of entries) {
271
+ const output = path.join(temporary, entry.name);
272
+ fs.copyFileSync(entry.source, output);
273
+ fs.chmodSync(output, entry.name.endsWith(".txt") ? 0o644 : 0o755);
274
+ sync(output);
275
+ }
276
+ sync(temporary); fs.renameSync(temporary, directory); sync(releases);
277
+ } finally { fs.rmSync(temporary, { recursive: true, force: true }); }
278
+ }
279
+ const actual = crypto.createHash("sha256");
280
+ for (const entry of entries) {
281
+ const file = path.join(directory, entry.name), stat = fs.lstatSync(file);
282
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Invalid immutable native release member");
283
+ actual.update(entry.name).update("\0").update(fs.readFileSync(file));
284
+ }
285
+ if (actual.digest("hex") !== id) throw new Error("Immutable native release digest mismatch");
286
+ const links = fs.mkdtempSync(path.join(binDir, ".native-links-"));
287
+ try {
288
+ const entryPoints = entries.map(entry => {
289
+ const source = path.join(links, entry.name);
290
+ fs.symlinkSync(path.join(directory, entry.name), source);
291
+ return { source, destination: entry.destination };
292
+ });
293
+ installStagedPair(entryPoints); sync(binDir);
294
+ } finally { fs.rmSync(links, { recursive: true, force: true }); }
295
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: coflux
3
- description: Use coflux to open visible terminals, read, wait, type, report progress, notify the user and obtain preview URLs. Prefer zero-credential local commands in the current workspace; use the account CLI across workspaces and devices. Coordinates arrive through coflux-session or COFLUX_* variables.
3
+ description: Use coflux to enter workspaces, open terminals the user can see and take over, run commands in them, wait for those commands, read their scrollback, type into them, run one-shot commands on another device in the account and get their output, report progress, notify the user, obtain preview URLs, and hand a bounded mechanical sub-task to the built-in executor instead of spending your own context on it. Prefer zero-credential local commands in the current workspace; use the account CLI across workspaces and devices. Coordinates arrive through coflux-session or COFLUX_* variables.
4
4
  ---
5
5
 
6
6
  # Working inside coflux
@@ -14,13 +14,13 @@ and a way to operate the other workspaces and devices under the account when you
14
14
 
15
15
  | Track | Credentials | Reach | Use for |
16
16
  |---|---|---|---|
17
- | Local commands `coflux terminal/progress/notify/ports` | none (the daemon identifies you by process tree) | **the workspace your cwd is in** | open, read, wait, send, report progress, call the user, preview URLs: the default, fastest, no network dependency |
17
+ | Local commands `coflux terminal/progress/notify/ports/executor` | none (the daemon identifies you by process tree) | **the workspace your cwd is in** | open, run, wait, read, send, close, report progress, call the user, preview URLs, hand a bounded sub-task to the built-in executor: the default; some actions require a server connection |
18
18
  | Account CLI | app login or `coflux login` | all devices and workspaces in the account | child workspaces and remote terminals; JSON output |
19
19
 
20
- Of the local commands, `send`/`read`/`wait`/`notify`/`progress` complete entirely inside the
21
- local daemon and never touch the center; `new`/`list`/`ports` are relayed to the center by the
22
- daemon on your behalf (terminals must appear in the user's sidebar, preview URLs are minted by
23
- the center). You only ever talk to the local daemon.
20
+ Of the local commands, `run`/`wait`/`read`/`send`/`close`/`progress`/`executor` complete
21
+ entirely inside the local daemon and never touch the center; `new`/`list`/`ports`/`notify` are relayed to the
22
+ center by the daemon on your behalf (terminals and notification history are persisted centrally; preview URLs are
23
+ minted by the center). You only ever talk to the local daemon.
24
24
 
25
25
  ## Managed terminal integration
26
26
 
@@ -71,10 +71,9 @@ env | grep '^COFLUX_'
71
71
  - **Effective workspace** = the workspace **your current working directory is inside**. This is what
72
72
  every local command acts on.
73
73
 
74
- They are the same until your cwd wanders off. A plain `cd <path>` moves a *live* session — same
75
- conversation, no restart and a coflux child workspace is a normal registered git worktree, so a
76
- session whose terminal belongs to workspace A can end up working inside workspace B. From that
77
- moment, in B:
74
+ They are the same until your cwd wanders off. A coflux child workspace is a normal registered Git
75
+ worktree. Running a command with cwd B (or `cd <path>` inside that command) changes that command's
76
+ effective workspace, but does not move the terminal or change later tools' default cwd. In B:
78
77
 
79
78
  - `coflux terminal new` opens the terminal **in B**, under B in the user's sidebar, running in B's
80
79
  directory, counting against B's terminal cap;
@@ -91,7 +90,40 @@ A terminal opened before the daemon was upgraded is the one case with no owning
91
90
  its local commands are refused with "predates the daemon upgrade" whatever your cwd is, because the
92
91
  daemon never guesses ownership from a directory. Open a new terminal.
93
92
 
94
- ### coflux follows you into a git worktree
93
+ ### Explicitly enter a workspace
94
+
95
+ Use this workflow when the task should move into another worktree, particularly in Codex, which
96
+ does not provide Claude's `EnterWorktree` tool:
97
+
98
+ 1. Select an existing worktree from `coflux workspace list --device <deviceId>`, or create one with
99
+ `coflux workspace new --project <projectId> --branch <branch>`. Use the returned workspace path.
100
+ 2. Run `coflux workspace enter <path>`. This uses the existing workspace-locate operation: the
101
+ daemon verifies the same Git repository, registers an unknown worktree, and moves the current
102
+ terminal's owning workspace. The terminal, PTY and conversation continue without restarting.
103
+ 3. Use the returned absolute `path` explicitly as `workdir`/`cwd` for subsequent task commands,
104
+ including local `coflux` commands. Use absolute file paths for tools with no working-directory
105
+ argument. Read the target directory's applicable `AGENTS.md` before editing. Run `coflux workspace`
106
+ **in that directory** and use its workspace ID for account operations.
107
+
108
+ `hostCwdChanged: false` is intentional: neither this command nor a one-off shell `cd` can change
109
+ Codex's default directory or sandbox permissions. A successful terminal move is not proof that
110
+ later tools operate there. Keep the selected path in task context and verify it before editing.
111
+ Use the same enter command with the original path to return; returning does not delete a worktree.
112
+ Temporary reads, checks or commands elsewhere do not change the explicit selection.
113
+
114
+ For a device-managed Codex session, `resumeSupported: true` means the selection is saved by native
115
+ conversation ID. Hooks inject the selected path after the switch and restore it after compaction
116
+ or resume, including resume in another Coflux terminal of the same project and device. A new
117
+ conversation does not inherit the selection. If the saved path is missing or cannot be verified,
118
+ the hook reports it; do not silently resume edits in the original directory. Select a valid
119
+ workspace explicitly, or retry after connectivity returns. Plain terminals and Claude receive
120
+ `resumeSupported: false`; Claude's native directory tools provide its session directory behavior.
121
+
122
+ The enter command requires the current native CLI. The npm CLI delegates it to the pinned native
123
+ integration, or the installed device CLI outside a managed agent. If unavailable, update the
124
+ device; do not claim that a shell `cd` supplied the missing persistence behavior.
125
+
126
+ ### Claude: coflux follows you into a git worktree
95
127
 
96
128
  `EnterWorktree` switches this live session into a git worktree (its own, or an existing one you point
97
129
  it at), `ExitWorktree` switches back, and resuming a session that had entered one puts you straight
@@ -132,133 +164,144 @@ after a `cd`? Run `coflux workspace` first** and use the `workspaceId` it prints
132
164
 
133
165
  ## When to open a terminal
134
166
 
135
- A coflux terminal is a process the user can see: a titled entry in their sidebar that they can
136
- open, take over and type into, whose output you can read back at any time.
167
+ A coflux terminal is a real, persistent shell the user can see: a titled entry in their sidebar
168
+ that they can open, take over and type into, whose scrollback you can read back at any time. It is
169
+ modelled on Terminal.app's scripting surface: you open a shell, you *do script* into it, you ask
170
+ whether it is busy, you read its contents, you close it.
171
+
137
172
  Whether a command runs in your own Bash or in a coflux terminal is your call; a coflux terminal is
138
- worth it when the user's view of the process matters:
173
+ worth it when the user's view of the process, or the user's hands, matter:
139
174
 
140
- - the user may want to step in: interactive steps, confirmations, something they may need to stop
141
- midway or rescue when it fails
175
+ - **a step only the human can do**: typing a password (`ssh -t user@host "su - root -c '...'"`,
176
+ `sudo`), confirming a prompt, driving a TUI, picking an option in an installer
142
177
  - it keeps running and the user will want to find it later (dev server, watch mode, log tailing)
143
178
  - you want to hand the user something to look at (a test run they asked to watch, a build they are
144
179
  waiting on)
145
180
 
146
181
  **Do not use it** for quick one-shot commands (`ls`, `grep`, `git status`, reading files): your
147
- own tools are faster, and a pile of one-second terminals is just noise to the user.
182
+ own tools are faster, and a pile of throwaway terminals is just noise to the user.
148
183
 
149
184
  ## Local commands
150
185
 
151
186
  ### Open a terminal
152
187
 
153
- There are two kinds, told apart by one single thing: **whether you pass a command**.
154
-
155
188
  ```sh
156
- coflux terminal new --title="Run unit tests" --cmd="pnpm -C tests test" # job terminal
157
- coflux terminal new --title="Debug shell" # session terminal
189
+ coflux terminal new --title="Root ssh" --cmd="ssh -t user@host" # open a shell and type the command in
190
+ coflux terminal new --title="Debug shell" # open a shell, type nothing yet
158
191
  ```
159
192
 
193
+ Every terminal is the same thing: the workspace's default login shell on a real tty (stdin **and**
194
+ stdout), started in the directory of the workspace your cwd is in (which is not always the one this
195
+ terminal was opened in; see "owning and effective"), alive until `exit` is typed into it or you
196
+ `close` it. It runs nothing by itself and never exits on its own.
197
+
160
198
  `--title` is the name the user sees in the sidebar; **name it properly**: "Run unit tests",
161
- "Start dev server", never "terminal 1". Either kind runs in the directory of the workspace your cwd
162
- is in (which is not always the one this terminal was opened in — see "owning and effective").
163
-
164
- Always write `--cmd=<value>` and `--title=<value>` with the `=`, never separated by a space: a
165
- value that starts with `-` is otherwise taken for another option and the call fails outright.
166
-
167
- **Job terminal — with `--cmd=...`.** It runs that one command under the login shell (command line
168
- capped at 16 KB). The terminal exits when the command finishes and the task becomes `exited` with
169
- the exit code: that is how you tell success from failure. So do not expect to run a second command
170
- in the same terminal: write `a && b`, or open another one. The output is also written to a local
171
- log for you to read back (roughly the last 1 MB is kept). The cost is that the command's stdout is
172
- a pipe rather than a tty: most programs turn off colors and progress bars, full-screen programs
173
- (vim, htop, less) do not work at all, and a few switch to a different "CI" behavior.
174
-
175
- **Session terminal — no `--cmd` at all.** You get exactly what the user gets by clicking "new
176
- terminal" in the sidebar: the default login shell in the workspace directory, with stdin **and**
177
- stdout on a real tty. It runs nothing by itself and **never exits on its own** — it lives until
178
- `exit` is typed into it (by you with `send`, or by the user), or the user stops it. Reach for it
179
- when you need several commands in the same shell, a TUI or a program whose colors and progress
180
- bars matter, or simply a terminal the user can step into and keep using. There is no command log
181
- for it: `read` returns the current screen (one screenful, no history), so you judge how it went
182
- from what is on screen, and `wait` is only meaningful after you have sent `exit`.
183
-
184
- Driving a session terminal:
185
-
186
- 1. `coflux terminal new --title="Debug shell"` → prints a taskId.
187
- 2. `coflux terminal read <taskId>` until you see the shell prompt. The shell needs a moment to
188
- start and the first read can come back empty — **never `send` before you have seen a prompt**.
189
- 3. `coflux terminal send <taskId> --text="pnpm build" --enter`, then `read` again to see what
190
- happened. One send per command; nothing signals you when a command finished, so read until the
191
- prompt is back. To make that unambiguous, end the command with a marker of your own
192
- (`pnpm build; echo DONE-$?`) and read until the marker shows up.
193
- 4. `coflux terminal send <taskId> --text="exit" --enter` when you are done; the terminal then
194
- becomes `exited` with the shell's exit code.
199
+ "Root ssh", never "terminal 1".
200
+
201
+ `--cmd=...` is *do script*: the command is typed into the shell **after the shell has signalled that
202
+ its prompt is ready**, then the shell keeps living. It is exactly `new` followed by `run`. The
203
+ command line is capped at 64 KB. Always write `--cmd=<value>` and `--title=<value>` with the `=`,
204
+ never separated by a space: a value that starts with `-` is otherwise taken for another option.
205
+
206
+ `new` prints the terminal id on its first line whatever happens next; when the command could not
207
+ be typed it says so on the following line (see "Errors").
195
208
 
196
209
  The new terminal has the same `COFLUX_*` variables (pointing at its own task/session ids, same
197
210
  workspace as you).
198
211
 
199
- ### See how far it got
212
+ ### Run a command in it
200
213
 
201
214
  ```sh
202
- coflux terminal list # every terminal in the workspace your cwd is in: id, state, exit code, title
203
- coflux terminal read <taskId> # a terminal's content (plain text, last 200 lines by default)
204
- coflux terminal read <taskId> --lines 50
215
+ coflux terminal run <taskId> --cmd="pnpm build"
205
216
  ```
206
217
 
207
- `list` states are `running` / `exited` / `idle`; `exited` carries `exit=<code>`.
208
- **An exited terminal can still be read**: "the command finished, look at the output" is the most
209
- common case. `read` reads the local log of a job terminal; terminals that have no log (session
210
- terminals, and the ones the user opened) return the current screen instead one screenful, no
211
- history, and empty for the first moments after opening. Both are immediate.
218
+ Types the command (plus Enter) into the shell and prints the command's number, `#N`. It never types
219
+ blind: the daemon waits for the shell's prompt-ready mark first (up to ten seconds), so you do not
220
+ have to read the screen before running. It is refused readably while a previous command is still
221
+ running ("busy": `wait` for it or `read` the screen first), while the user is taking the terminal
222
+ over (humans first), and on a shell coflux cannot instrument (see "Errors"; `send` is the fallback).
223
+ One `run` = one command line; chain with `a && b` when you need several.
212
224
 
213
- ### Wait for a command to finish
225
+ ### Wait for the command to finish
214
226
 
215
227
  ```sh
216
- coflux terminal wait <taskId> # block until that terminal exits and print the exit code (default cap 30 minutes)
217
- coflux terminal wait <taskId> --timeout 300 # custom timeout in seconds; a timeout fails loudly with a non-zero exit
228
+ coflux terminal wait <taskId> # block until the current command finishes; prints its exit code
229
+ coflux terminal wait <taskId> --timeout=300 # custom timeout in seconds (default 30 minutes)
230
+ coflux terminal wait <taskId> --seq=2 # wait for command #2 specifically
218
231
  ```
219
232
 
220
- To wait for a command use `wait`; **do not write your own polling loop**. One command blocks
221
- until done and hands you the exit code. A timeout does not mean the command failed, only that it
222
- is still running: `read` to see where it is, then decide whether to keep waiting or act.
233
+ `wait` is command-scoped, like Terminal.app's `busy`. It targets the most recently started command
234
+ (the one the last `run` printed) unless `--seq` names another, and it cannot lose a completion: a
235
+ command that finished before you called `wait` answers immediately with its stored exit code. The
236
+ answer is one line:
237
+
238
+ - `# finished exit=<code>`: the command ended; the terminal is still open at its prompt.
239
+ - `# exited exit=<code>`: the shell itself ended (someone typed `exit`, or `close`); the code is the
240
+ shell's.
223
241
 
224
- `wait` **always exits 0** once the terminal is done: it reports that the command finished, not
225
- whether it succeeded. Read the result off its output line `# exited exit=<code>` (`list` shows the
226
- same). A non-zero exit from `wait` itself means the wait timed out or the id was wrong.
242
+ Completion comes from the shell's own integration marks, so it survives the user typing into the
243
+ running command (a password into `ssh`, `y` into a prompt), and a nested shell, `ssh` session or
244
+ TUI counts as one long command from the outside; marks from a remote host never end it early.
227
245
 
228
- **Do not `wait` on a session terminal** unless you have already sent it `exit`: it never finishes
229
- by itself, so the wait can only end in the 30-minute timeout a timeout there means the shell is
230
- still sitting at its prompt, nothing more. Its exit code, when it finally exits, is the shell's and
231
- not any command's: check what a command did by reading the screen.
246
+ A timeout is a non-zero exit with a readable message, not a failure of the command: `read` to see
247
+ where it is, then decide. `wait` on a terminal that has not run any command yet returns readably
248
+ instead of blocking. `wait`, `read`, `list` and `close` are never refused because the user has
249
+ taken the terminal over.
232
250
 
233
- **Keep working, and be woken up when it finishes.** `wait` blocks, so run it as a backgrounded Bash
234
- call of your own:
251
+ **The flow for a step only the human can do:**
235
252
 
236
- 1. `coflux terminal new --title="Run the test suite" --cmd="pnpm -C tests test"` prints a taskId.
237
- 2. Run `coflux terminal wait <taskId>` as a backgrounded Bash call, then go do something else.
238
- 3. The host wakes you when that call exits. Check its output for `# exited exit=<code>`, then
239
- `coflux terminal read <taskId>` to see what actually happened.
253
+ 1. `coflux terminal new --title="Root ssh" --cmd="ssh -t user@host \"su - root -c '/opt/deploy.sh'\""`
254
+ 2. `coflux notify "Please type the root password in the Root ssh terminal"`
255
+ 3. Run `coflux terminal wait <taskId>` as a backgrounded Bash call and go do something else.
256
+ 4. The host wakes you when that call exits with `# finished exit=<code>`; `coflux terminal read
257
+ <taskId>` shows what the remote script printed, and the terminal is still there for the user.
240
258
 
241
- That gets you both halves at once: the user watches (and can take over) a real terminal, and you are
242
- still told the moment it is over, instead of blocking or polling for it.
259
+ **Do not write your own polling loop**: `wait` blocks and wakes the moment the command ends.
260
+
261
+ ### See how far it got
262
+
263
+ ```sh
264
+ coflux terminal list # every terminal in the workspace your cwd is in
265
+ coflux terminal read <taskId> # the last 200 lines of the scrollback, plain text
266
+ coflux terminal read <taskId> --lines=50
267
+ ```
268
+
269
+ `list` rows are `<taskId> <state>[ exit=<code>][ busy|idle][ last=<code>] <title>`: `running` /
270
+ `exited` / `idle` is the terminal, `busy` or `idle` says whether a command is running in it right
271
+ now, and `last=<code>` is the exit code of the last command that finished. `read` returns the tail
272
+ of the terminal's **full scrollback** (well beyond one screen, up to the daemon's history limit),
273
+ ANSI stripped, with a `# running` / `# exited exit=<code>` header. A freshly opened terminal can
274
+ read back empty for a moment while the shell starts. Once the shell has exited only the last
275
+ screen the center cached remains.
243
276
 
244
277
  ### Type into a terminal
245
278
 
246
279
  ```sh
247
- coflux terminal send <taskId> --text "y" --enter # type a line and press Enter
280
+ coflux terminal send <taskId> --text="y" --enter # type a line and press Enter
248
281
  coflux terminal send <taskId> --enter # just press Enter
249
282
  ```
250
283
 
251
- For interactive confirmations (y/N, menus), or to add a command in the same shell after the
252
- previous one finished. Discipline:
284
+ For interactive answers (y/N, menus, a line a program is waiting for), and the fallback way to type a
285
+ command when `run` cannot (see "Errors"). Discipline:
253
286
 
254
287
  - **`read` before `send`**: see what the terminal is waiting for before typing; never type blind.
255
- On a freshly opened session terminal this also means waiting for the shell prompt to appear.
288
+ On a freshly opened terminal this means waiting for the shell prompt to appear.
256
289
  - **Refused while the user is taking over**: that is not an error, it is by design; humans always
257
290
  win. Stop when refused; use `notify` to communicate, do not retry.
258
291
  - **After a send timeout do not resend right away**: `read` first to check whether the input
259
292
  actually landed; duplicated input is worse than lost input.
260
293
  - A single text is capped at 64 KB; this is an interactive input channel, not a file transfer.
261
294
 
295
+ ### Close a terminal
296
+
297
+ ```sh
298
+ coflux terminal close <taskId>
299
+ ```
300
+
301
+ Ends the terminal (the shell and whatever runs in it) and reports `exited exit=<code>`; the same
302
+ effect as the account CLI's `stop`. Close the terminals you opened for a one-off step once you have
303
+ read what you needed; leave the ones the user is meant to keep (a dev server, a shell they are using).
304
+
262
305
  ### Report progress
263
306
 
264
307
  ```sh
@@ -270,7 +313,7 @@ next one. Update it at milestones: reproduced, located, fixed and verifying, stu
270
313
  **does not interrupt the user**; it is a different channel from `notify`:
271
314
 
272
315
  - `progress` = broadcast (the user glances and knows the state, no response needed)
273
- - `notify` = call the user (the workspace turns "waiting for interaction", the user should come and look)
316
+ - `notify` = send a persistent account notification asking the user to look or respond
274
317
 
275
318
  If unsure: when the user does not have to do anything, use `progress`.
276
319
 
@@ -280,13 +323,19 @@ If unsure: when the user does not have to do anything, use `progress`.
280
323
  coflux notify "Both approaches work; I need you to pick one"
281
324
  ```
282
325
 
283
- The user's sidebar switches this workspace to "waiting for interaction" and shows this sentence;
284
- they see it on the phone too. Use it when you are **really stuck**: a decision is needed, a
285
- password or a permission, a problem only a human can judge. One sentence saying what you need;
286
- do not write a log.
326
+ This creates an unread notification in the account inbox, with this terminal and its owning
327
+ workspace as the source. It works from any owned Coflux shell, even without an agent process.
328
+ The desktop shows an in-app hint while foregrounded, including when the source workspace is
329
+ already selected; when backgrounded it also requests a system notification. Clicking opens the
330
+ source terminal and marks the notification read. History remains after reading, hooks, agent
331
+ exit, or source deletion. An application that is fully quit syncs history on next launch.
287
332
 
288
- (Your normal questions and permission prompts already show up in the sidebar state; they need no
289
- extra notify. This is for "what you have to say cannot be guessed from the status icon".)
333
+ Success means the server has saved the notification. A disconnected daemon, unsupported server,
334
+ or timeout reports failure rather than silently falling back to workspace state. A transport retry
335
+ of the same request is deduplicated; a separate invocation is a new notification. Keep the message
336
+ concise (at most 2000 characters). Use this for decisions, blocked work, or review requests; use
337
+ `progress` for updates that need no response. Automatic approval/question indicators remain
338
+ separate and do not create inbox entries.
290
339
 
291
340
  ### Hand the user a clickable preview
292
341
 
@@ -297,16 +346,61 @@ coflux ports
297
346
  Lists every listening port in this workspace with its public preview URL. After starting a dev
298
347
  server, use it to get the URL and tell the user directly; they click it and nobody has to dig.
299
348
 
349
+ ### Hand a bounded sub-task to the executor
350
+
351
+ ```sh
352
+ coflux executor run --prompt="Fix every clippy warning in crates/worker" --write
353
+ coflux executor run --prompt="Find why the relay reconnect test flakes and report back"
354
+ ```
355
+
356
+ The executor is a small agent built into coflux. Give it one self-contained job and it works in
357
+ **the workspace your cwd is in** while you keep your own context for the main thread. The command
358
+ blocks until the job ends, then prints the executor's final report and the files it changed.
359
+
360
+ Reach for it when a job is mechanical, bounded and verbose — chasing a failing test suite, a
361
+ repetitive refactor across many files, a search that would cost you many tool calls. Keep the work
362
+ yourself when it needs the conversation's context, the user's judgment, or decisions the prompt
363
+ cannot carry.
364
+
365
+ **One-shot: there is no session and no follow-up.** Each run starts clean and ends when it ends;
366
+ to change something, send a new run with a new prompt. So **write the prompt as a brief, not a
367
+ hint**: the executor gets that one string and nothing else — no conversation history, no way to ask
368
+ you what you meant. Say what done looks like and how to check it.
369
+
370
+ Its boundaries, enforced by a kernel sandbox — count on them, and tell it what it needs up front:
371
+
372
+ - **Only the originating workspace is writable.** Writes anywhere outside it are refused by the
373
+ kernel. Reads are not restricted, so it can still see system files, toolchains and the rest of the
374
+ machine — the sandbox stops it from changing things, not from looking. Without `--write` even that
375
+ workspace is read-only, which is the right mode for investigations.
376
+ - **Git metadata is read-only, so it never commits.** It leaves changes in the working tree;
377
+ reviewing and committing them is yours. `git status` and `git diff` work fine for it.
378
+ - **Its tool processes have no network.** `npm install`, `cargo fetch` and friends fail. Install
379
+ what the job needs before handing it over.
380
+ - **One writing executor per workspace at a time.** A second `--write` in the same workspace is
381
+ refused outright rather than queued; read-only runs may go in parallel up to a small cap.
382
+
383
+ It runs inside the user's desktop app, so it only exists on the machine that app is on, and a run
384
+ ends if the user quits the app, signs out, or stops the machine's terminals (you get a definite
385
+ failure, never a hang). `--timeout <seconds>` caps how long you wait; the default is 30 minutes and
386
+ a timeout cancels the run before failing. The model comes from the user's desktop settings; if they
387
+ have not configured one, the command says so in one line — relay that to the user instead of
388
+ retrying.
389
+
300
390
  ### Errors from local commands
301
391
 
302
392
  Errors are one readable sentence; do what they say: "not inside a coflux terminal" = you are not
303
393
  in a coflux session; "terminal is not in this workspace or does not exist" = check the id with
304
394
  `list`, and if you moved into another workspace that is exactly what a terminal of the other one
305
395
  looks like (`coflux workspace` to confirm, `cd` back to reach it); "predates the daemon upgrade" =
306
- that terminal was opened before the daemon upgrade, open a new one; a `new` without `--cmd` refused for a missing command = this machine's daemon is older
307
- than session terminals, tell the user to run `cofluxd update && cofluxd restart` (or pass a command
308
- and use a job terminal); "daemon is not connected to the center" only appears on
309
- `new`/`list`/`ports`, retry once it reconnects.
396
+ that terminal was opened before the daemon upgrade, open a new one; "never signalled prompt
397
+ readiness" = this terminal's shell is not zsh, bash or fish started through coflux's rc chain (or
398
+ the integration was bypassed), so the command was **not** typed and `wait` cannot observe commands
399
+ there: the terminal is open all the same, use `read` and `send` with it; "busy" = a command is
400
+ still running in that terminal, `wait` for it or `read` first; "unknown action terminal.run" = this
401
+ machine's daemon is older than the CLI, tell the user to run `cofluxd update && cofluxd restart`
402
+ (the terminal was opened as a plain shell, nothing was run); "daemon is not connected to the
403
+ center" only appears on `new`/`list`/`ports`/`notify`, retry once it reconnects.
310
404
 
311
405
  ## Account CLI: across workspaces and devices
312
406
 
@@ -316,10 +410,12 @@ never as a command argument. Account commands return JSON. A workspace ID identi
316
410
 
317
411
  ```sh
318
412
  coflux device list
413
+ coflux device exec <deviceId> --cmd="<command>" [--cwd=<dir>] [--timeout=<seconds>]
319
414
  coflux project list --device <deviceId>
320
415
  coflux workspace list --device <deviceId>
321
416
  coflux workspace new --project <projectId> --branch <branch>
322
417
  coflux terminal new --workspace <workspaceId> --title <title> [--cmd <command>]
418
+ coflux terminal run <terminalId> --remote --cmd <command>
323
419
  coflux terminal list --workspace <workspaceId>
324
420
  coflux terminal read <terminalId> --remote
325
421
  coflux terminal send <terminalId> --remote --text <text> [--enter]
@@ -331,24 +427,65 @@ coflux workspace remove <workspaceId>
331
427
  coflux ports --remote --device <deviceId>
332
428
  ```
333
429
 
334
- `--remote` selects account access, including other workspaces on this machine. Read before sending;
335
- stop immediately when the user takes over. If a write times out, inspect the result before retrying.
336
- Exiting the CLI does not stop its terminals. Delete workspaces through `coflux workspace remove`
337
- so the filesystem and workspace records stay consistent.
430
+ `--remote` selects account access, including other workspaces on this machine. The semantics are the
431
+ same as the local commands: `--cmd` and `run` type a command in after the prompt is ready, `wait`
432
+ returns `finished` with the command's exit code (or `exited` with the shell's) and `timedOut` on the
433
+ deadline, `list` shows `busy` and `lastCommandExitCode` for live terminals (fed by the device's
434
+ checkpoints, so this view lags a couple of seconds behind reality; the local `coflux terminal list`
435
+ is immediate), and `stop` is `close`.
436
+ Read before sending; stop immediately when the user takes over. If a write times out, inspect the
437
+ result before retrying. Exiting the CLI does not stop its terminals. Delete workspaces through
438
+ `coflux workspace remove` so the filesystem and workspace records stay consistent.
439
+
440
+ ### Run one command on another machine
441
+
442
+ ```sh
443
+ coflux device exec <deviceId> --cmd="cd /opt/app && git rev-parse HEAD"
444
+ coflux device exec <deviceId> --cmd="systemctl is-active caddy" --cwd=/etc --timeout=10
445
+ ```
446
+
447
+ This is `ssh host "cmd"` for the devices in this account, and it is **not a Terminal**: no PTY on
448
+ the device, nothing in the user's sidebar, no draw on any workspace's terminal cap, and no workspace
449
+ required — a device whose directories were never registered as coflux workspaces is still reachable.
450
+ The command string is handed to the remote `sh -c`, so pipes, `&&`, redirection, globs and `$VAR` all
451
+ work, and quoting is yours to get right.
452
+
453
+ Its output is not JSON: stdout goes to stdout, stderr goes to stderr (always separate, and each one
454
+ carries an explicit marker if it had to be truncated), and the last line is `# exit=<code>`. **The
455
+ CLI's exit code is the remote command's**, so an ordinary shell test around it works. The CLI's own
456
+ failures — device offline, that device's daemon too old (`cofluxd update && cofluxd restart` there),
457
+ `--cwd` missing or not a directory, the timeout elapsing — are one readable sentence and exit **255**,
458
+ so a remote exit 1 is never confused with "it never ran".
459
+
460
+ `--cwd` is the only addressing: an absolute path or a `~` prefix, defaulting to the daemon user's
461
+ HOME. To run inside a workspace, pass the `path` from `coflux workspace list --device <deviceId>`.
462
+ There is no `--workspace`, and there is **no stdin**.
463
+
464
+ **`device exec` or a terminal?** A division of labour, not a limitation:
465
+
466
+ - **`device exec`** when the command is short and what you want is its result: a version check, a
467
+ config grep, a service status, a one-line remote fix. `--timeout` defaults to 60 seconds and is
468
+ capped at 600; a timeout kills the remote process and is a definite failure, never a partial
469
+ answer. Output is buffered, so nothing appears until the command ends.
470
+ - **`coflux terminal new --workspace <workspaceId>`** when the job is long, when the user should be
471
+ able to watch it and take it over, or when a human has to type something into it (a `sudo`
472
+ password, a confirmation, a TUI). That is what a Terminal is for, and exec deliberately cannot do
473
+ it.
338
474
 
339
475
  ## Boundaries
340
476
 
341
- - You can open, read, wait and type, but **typing is a restricted write with humans first**: you
342
- cannot write into a terminal the user is taking over (you are refused explicitly), and the user
343
- taking over at any time displaces you. Do not fight a human for a terminal.
477
+ - You can open, run, wait, read, type and close, but **typing is a restricted write with humans
478
+ first**: neither `run` nor `send` can write into a terminal the user is taking over (you are
479
+ refused explicitly), and the user taking over at any time displaces you. Do not fight a human for
480
+ a terminal; `wait` and `read` keep working while they have it.
344
481
  - Local commands only see **the workspace your cwd is in** (`coflux workspace` says which one);
345
482
  use the account CLI for other workspaces and machines in the same account.
346
483
  - A workspace has a cap on concurrently live terminals (default 8, including the user's own).
347
484
  On hitting the cap, `list` first: usually some finished terminals were never collected. If the
348
485
  user really filled it up, `notify` them instead of forcing it.
349
- - `new`/`list`/`ports` and account commands need the daemon connected to the center; "letting the
350
- user see" is their whole point. `send`/`read`/`wait`/`notify`/`progress` do not depend on the
351
- center. When disconnected they fail loudly rather than degrade silently.
486
+ - `new`/`list`/`ports`/`notify` and account commands need the daemon connected to the center; "letting the
487
+ user see" is their whole point. `run`/`wait`/`read`/`send`/`close`/`progress` do not
488
+ depend on the center. When disconnected they fail loudly rather than degrade silently.
352
489
  - `COFLUX_*` variables exist only in PTYs opened by coflux; exporting or changing them yourself
353
490
  has no effect, the center only trusts the ids it issued. `COFLUX_WORKSPACE_ID` always means the
354
491
  workspace this terminal was **opened** in and goes stale the moment coflux follows you into a