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 +39 -3
- package/account-client.mjs +35 -1
- package/coflux.mjs +228 -52
- package/cofluxd.mjs +18 -6
- package/package.json +1 -1
- package/release-trust.mjs +53 -4
- package/skills/coflux/SKILL.md +247 -110
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
|
|
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
|
package/account-client.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
11
|
-
if (process.argv[2] === "agent") {
|
|
12
|
-
const native =
|
|
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
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
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
|
|
137
|
-
//
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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)
|
|
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
|
-
//
|
|
201
|
-
//
|
|
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 || ""
|
|
229
|
+
const result = await agentPost({ action: "terminal.new", title: values.title || "" });
|
|
206
230
|
console.log(`已开终端 ${result.taskId}(用户可在 coflux 侧栏看到并随时接管)`);
|
|
207
231
|
if (command) {
|
|
208
|
-
|
|
232
|
+
await runCommand(result.taskId, command);
|
|
209
233
|
} else {
|
|
210
|
-
console.log(
|
|
211
|
-
console.log(
|
|
212
|
-
console.log(
|
|
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
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
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}
|
|
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
|
-
|
|
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 [--
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
coflux terminal
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
coflux terminal
|
|
346
|
-
|
|
347
|
-
coflux terminal send <taskId> --text
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
377
|
+
installNativeRelease(staged);
|
|
366
378
|
} catch (error) {
|
|
367
379
|
failure = error;
|
|
368
380
|
} finally {
|
package/package.json
CHANGED
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
|
|
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
|
+
}
|
package/skills/coflux/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: coflux
|
|
3
|
-
description: Use coflux to open
|
|
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,
|
|
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, `
|
|
21
|
-
local daemon and never touch the center; `new`/`list`/`ports` are relayed to the
|
|
22
|
-
daemon on your behalf (terminals
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
###
|
|
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
|
|
136
|
-
open, take over and type into, whose
|
|
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
|
|
173
|
+
worth it when the user's view of the process, or the user's hands, matter:
|
|
139
174
|
|
|
140
|
-
- the
|
|
141
|
-
|
|
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
|
|
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="
|
|
157
|
-
coflux terminal new --title="Debug shell"
|
|
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
|
-
"
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
###
|
|
212
|
+
### Run a command in it
|
|
200
213
|
|
|
201
214
|
```sh
|
|
202
|
-
coflux terminal
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
225
|
+
### Wait for the command to finish
|
|
214
226
|
|
|
215
227
|
```sh
|
|
216
|
-
coflux terminal wait <taskId> # block until
|
|
217
|
-
coflux terminal wait <taskId> --timeout
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
**
|
|
234
|
-
call of your own:
|
|
251
|
+
**The flow for a step only the human can do:**
|
|
235
252
|
|
|
236
|
-
1. `coflux terminal new --title="
|
|
237
|
-
2.
|
|
238
|
-
3.
|
|
239
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
|
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
|
|
252
|
-
|
|
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
|
|
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` =
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
289
|
-
|
|
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;
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
`
|
|
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.
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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,
|
|
342
|
-
|
|
343
|
-
taking over at any time displaces you. Do not fight a human for
|
|
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. `
|
|
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
|