cofluxd 2.15.0 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -2
- package/account-client.mjs +69 -45
- package/coflux.mjs +226 -150
- package/cofluxd.mjs +885 -427
- package/output.mjs +93 -0
- package/package.json +3 -2
- package/release-trust.mjs +44 -36
- package/service-unit.mjs +52 -13
- package/skills/coflux/SKILL.md +6 -4
package/coflux.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// coflux:账号与本地、跨设备业务操作;不负责宿主生命周期。
|
|
3
3
|
import { entityHandle, handlesAccountCommand, runAccountCommand } from "./account-client.mjs";
|
|
4
|
+
import { error as printError, fail, info, success } from "./output.mjs";
|
|
4
5
|
import { randomUUID } from "node:crypto";
|
|
5
6
|
import { parseArgs } from "node:util";
|
|
6
7
|
import { spawnSync } from "node:child_process";
|
|
@@ -19,22 +20,23 @@ if (process.argv[2] === "agent" || process.argv[2] === "secret" || process.argv[
|
|
|
19
20
|
? join(process.env.COFLUX_AGENT_BUNDLE, "coflux")
|
|
20
21
|
: join(HOME, "bin", "coflux");
|
|
21
22
|
if (!existsSync(native)) {
|
|
22
|
-
|
|
23
|
-
process.exit(1);
|
|
23
|
+
fail("This command is not available on this device.", "Update Coflux on this device with cofluxd update, then try again.");
|
|
24
24
|
}
|
|
25
25
|
const result = spawnSync(native, process.argv.slice(2), { stdio: "inherit" });
|
|
26
|
-
if (result.error)
|
|
26
|
+
if (result.error) printError(result.error.message, "Update Coflux on this device with cofluxd update, then try again.");
|
|
27
27
|
process.exit(result.status ?? 1);
|
|
28
28
|
}
|
|
29
29
|
const DEFAULT_LOCAL_GATEWAY_PORT = 8788;
|
|
30
|
-
|
|
30
|
+
/** Fallback next step for an error the daemon or the server relays without one. */
|
|
31
|
+
const HELP_NEXT = "Run coflux --help for usage.";
|
|
32
|
+
const die = (message, next = HELP_NEXT) => fail(message, next);
|
|
31
33
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
32
34
|
function localGatewayPort() {
|
|
33
35
|
const raw = process.env.COFLUX_LOCAL_GATEWAY_PORT;
|
|
34
36
|
if (raw === undefined || raw === "") return { ok: true, port: DEFAULT_LOCAL_GATEWAY_PORT };
|
|
35
37
|
const port = Number(raw);
|
|
36
38
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
37
|
-
return { ok: false, error: `COFLUX_LOCAL_GATEWAY_PORT=${raw}
|
|
39
|
+
return { ok: false, error: `COFLUX_LOCAL_GATEWAY_PORT=${raw} is not a valid port` };
|
|
38
40
|
}
|
|
39
41
|
return { ok: true, port };
|
|
40
42
|
}
|
|
@@ -46,7 +48,7 @@ function localGatewayPort() {
|
|
|
46
48
|
// only then is COFLUX_LOCAL_GATEWAY_PORT read. A reply from the socket, refusals included, is final
|
|
47
49
|
// and never retried over TCP; a connect refused for any other reason (a sandbox's EPERM/EACCES)
|
|
48
50
|
// fails hard and names the socket. Mirrors `local_post` in crates/cli/src/gateway.rs.
|
|
49
|
-
// The path mirrors `SOCKET_FILE` in crates/
|
|
51
|
+
// The path mirrors `SOCKET_FILE` in crates/runtime/src/agent_socket.rs.
|
|
50
52
|
const AGENT_SOCKET = join(HOME, "ipc", "agent.sock");
|
|
51
53
|
/** The worker never binds a longer socket path (sun_path limits), so a longer one is absent. */
|
|
52
54
|
const MAX_SOCKET_PATH_BYTES = 100;
|
|
@@ -83,7 +85,7 @@ function socketPost(path, payload, timeoutMs) {
|
|
|
83
85
|
);
|
|
84
86
|
const timer = setTimeout(() => {
|
|
85
87
|
timedOut = true;
|
|
86
|
-
req.destroy(new Error("
|
|
88
|
+
req.destroy(new Error("request timed out"));
|
|
87
89
|
}, timeoutMs);
|
|
88
90
|
req.on("error", (error) => {
|
|
89
91
|
if (!timedOut && error?.syscall === "connect") {
|
|
@@ -91,7 +93,8 @@ function socketPost(path, payload, timeoutMs) {
|
|
|
91
93
|
return finish({
|
|
92
94
|
ok: false,
|
|
93
95
|
kind: "denied",
|
|
94
|
-
message: `
|
|
96
|
+
message: `This process is not allowed to reach the Coflux service at ${AGENT_SOCKET}: ${error.code || error.message}`,
|
|
97
|
+
next: "If this runs in a sandbox, allow access to that socket.",
|
|
95
98
|
});
|
|
96
99
|
}
|
|
97
100
|
finish({ ok: false, kind: "transport", message: error?.message || String(error) });
|
|
@@ -110,9 +113,9 @@ async function localPost(path, body, timeoutMs) {
|
|
|
110
113
|
const { pid: _pid, ppid: _ppid, ...rest } = body;
|
|
111
114
|
const viaSocket = await socketPost(path, JSON.stringify(rest), timeoutMs);
|
|
112
115
|
if (viaSocket.ok || viaSocket.kind === "transport") return viaSocket;
|
|
113
|
-
if (viaSocket.kind === "denied") return { ok: false, kind: "refused", message: viaSocket.message };
|
|
116
|
+
if (viaSocket.kind === "denied") return { ok: false, kind: "refused", message: viaSocket.message, next: viaSocket.next };
|
|
114
117
|
const portResult = localGatewayPort();
|
|
115
|
-
if (!portResult.ok) return { ok: false, kind: "refused", message: portResult.error };
|
|
118
|
+
if (!portResult.ok) return { ok: false, kind: "refused", message: portResult.error, next: "Unset COFLUX_LOCAL_GATEWAY_PORT, or set it to a port number." };
|
|
116
119
|
try {
|
|
117
120
|
const res = await fetch(`http://127.0.0.1:${portResult.port}${path}`, {
|
|
118
121
|
method: "POST",
|
|
@@ -159,7 +162,7 @@ async function cmdHook() {
|
|
|
159
162
|
try {
|
|
160
163
|
const agent = positionals[1];
|
|
161
164
|
if (agent !== "claude" && agent !== "codex") {
|
|
162
|
-
hookDebug(
|
|
165
|
+
hookDebug(`unknown agent: ${agent ?? "(missing)"}; expected claude or codex`);
|
|
163
166
|
return;
|
|
164
167
|
}
|
|
165
168
|
let payload = null;
|
|
@@ -168,13 +171,13 @@ async function cmdHook() {
|
|
|
168
171
|
}
|
|
169
172
|
if (!payload) payload = await readStdinJson();
|
|
170
173
|
if (!payload || typeof payload !== "object") {
|
|
171
|
-
hookDebug("
|
|
174
|
+
hookDebug("no valid payload; ignored");
|
|
172
175
|
return;
|
|
173
176
|
}
|
|
174
177
|
// claude/codex hooks 引擎用 hook_event_name;codex notify 用 type
|
|
175
178
|
const event = payload.hook_event_name || payload.type;
|
|
176
179
|
if (typeof event !== "string" || !event) {
|
|
177
|
-
hookDebug("payload
|
|
180
|
+
hookDebug("payload has no event name; ignored");
|
|
178
181
|
return;
|
|
179
182
|
}
|
|
180
183
|
const notification = payload.notification_type ?? payload.notificationType;
|
|
@@ -195,7 +198,7 @@ async function cmdHook() {
|
|
|
195
198
|
hookDebug("POST /hook", JSON.stringify(body));
|
|
196
199
|
// The agent socket first; the gateway port is resolved only when the socket is absent.
|
|
197
200
|
const res = await localPost("/hook", body, HOOK_POST_TIMEOUT_MS);
|
|
198
|
-
hookDebug(res.ok ?
|
|
201
|
+
hookDebug(res.ok ? `answered ${res.status}` : res.message);
|
|
199
202
|
} catch (error) {
|
|
200
203
|
hookDebug(error?.message || String(error));
|
|
201
204
|
} finally {
|
|
@@ -249,18 +252,29 @@ async function agentPostResult(body) {
|
|
|
249
252
|
const res = await localPost("/agent", { ...body, cwd: callerCwd() }, agentTimeoutMs());
|
|
250
253
|
if (!res.ok) {
|
|
251
254
|
if (res.kind === "refused") return res;
|
|
252
|
-
return {
|
|
255
|
+
return {
|
|
256
|
+
ok: false,
|
|
257
|
+
kind: "transport",
|
|
258
|
+
message: `Cannot reach the Coflux service on this device: ${res.message}`,
|
|
259
|
+
next: SERVICE_NEXT,
|
|
260
|
+
};
|
|
253
261
|
}
|
|
254
262
|
let parsed = null;
|
|
255
|
-
try { parsed = JSON.parse(res.text); } catch { /*
|
|
256
|
-
const
|
|
257
|
-
if (!
|
|
263
|
+
try { parsed = JSON.parse(res.text); } catch { /* a non-JSON answer is reported below */ }
|
|
264
|
+
const ok = res.status >= 200 && res.status < 300;
|
|
265
|
+
if (!ok || !parsed?.ok) return { ok: false, kind: "refused", message: parsed?.error || `The Coflux service answered with HTTP ${res.status}.` };
|
|
258
266
|
return { ok: true, value: parsed };
|
|
259
267
|
}
|
|
260
268
|
|
|
261
|
-
|
|
269
|
+
/** Next step when the local service cannot be reached at all. */
|
|
270
|
+
const SERVICE_NEXT = "Check that Coflux is running on this device: open Coflux.app, or run cofluxd status.";
|
|
271
|
+
/** Next step for a refusal about a terminal. */
|
|
272
|
+
const TERMINAL_NEXT = "Run coflux terminal list to check the terminal.";
|
|
273
|
+
|
|
274
|
+
/** `next` is shown under a refusal the daemon sent without one. */
|
|
275
|
+
async function agentPost(body, next = HELP_NEXT) {
|
|
262
276
|
const result = await agentPostResult(body);
|
|
263
|
-
if (!result.ok) die(result.message);
|
|
277
|
+
if (!result.ok) die(result.message, result.next || next);
|
|
264
278
|
return result.value;
|
|
265
279
|
}
|
|
266
280
|
|
|
@@ -299,8 +313,14 @@ function commandSuffix(t) {
|
|
|
299
313
|
|
|
300
314
|
/** "do script": type a command into a terminal once its shell signalled prompt readiness. */
|
|
301
315
|
async function runCommand(taskId, command) {
|
|
302
|
-
const result = await agentPost({ action: "terminal.run", taskId, command });
|
|
303
|
-
|
|
316
|
+
const result = await agentPost({ action: "terminal.run", taskId, command }, TERMINAL_NEXT);
|
|
317
|
+
success(`Sent command #${result.commandSeq}`);
|
|
318
|
+
info(`Wait for it with coflux terminal wait ${taskId}, and read its output with coflux terminal read ${taskId}.`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** A subcommand that needs a terminal id got none. */
|
|
322
|
+
function missingTaskId(sub, usage = "") {
|
|
323
|
+
die("Missing terminal id.", `Run coflux terminal list to find it, then coflux terminal ${sub} <taskId>${usage}.`);
|
|
304
324
|
}
|
|
305
325
|
|
|
306
326
|
async function cmdTerminal(values) {
|
|
@@ -311,57 +331,59 @@ async function cmdTerminal(values) {
|
|
|
311
331
|
// the command any other way. --cmd missing and --cmd= blank are the same: nothing is typed.
|
|
312
332
|
const command = (values.cmd ?? "").trim() ? values.cmd : "";
|
|
313
333
|
const result = await agentPost({ action: "terminal.new", title: values.title || "" });
|
|
314
|
-
|
|
334
|
+
success(`Opened terminal ${result.taskId}`);
|
|
315
335
|
if (command) {
|
|
316
336
|
await runCommand(result.taskId, command);
|
|
317
337
|
} else {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
338
|
+
info("It is a login shell the user can see and take over. It stays open until you close it.");
|
|
339
|
+
info(`Run a command: coflux terminal run ${result.taskId} --cmd="<command>"`);
|
|
340
|
+
info(`Read its output: coflux terminal read ${result.taskId}`);
|
|
341
|
+
info(`Close it: coflux terminal close ${result.taskId}`);
|
|
321
342
|
}
|
|
322
343
|
} else if (sub === "run") {
|
|
323
344
|
const taskId = positionals[2];
|
|
324
|
-
if (!taskId)
|
|
345
|
+
if (!taskId) missingTaskId("run", ' --cmd="<command>"');
|
|
325
346
|
const command = (values.cmd ?? "").trim() ? values.cmd : "";
|
|
326
|
-
if (!command) die(`terminal run
|
|
347
|
+
if (!command) die("Missing command.", `Pass it with --cmd: coflux terminal run ${taskId} --cmd="<command>".`);
|
|
327
348
|
await runCommand(taskId, command);
|
|
328
349
|
} else if (sub === "close") {
|
|
329
350
|
const taskId = positionals[2];
|
|
330
|
-
if (!taskId)
|
|
331
|
-
const result = await agentPost({ action: "terminal.close", taskId });
|
|
351
|
+
if (!taskId) missingTaskId("close");
|
|
352
|
+
const result = await agentPost({ action: "terminal.close", taskId }, TERMINAL_NEXT);
|
|
332
353
|
if (result.exited) {
|
|
333
354
|
const exit = result.exitCode === undefined || result.exitCode === null ? "" : ` exit=${result.exitCode}`;
|
|
334
|
-
|
|
355
|
+
success(`Closed terminal ${taskId}${exit}`);
|
|
335
356
|
} else {
|
|
336
|
-
|
|
357
|
+
info(`Closing terminal ${taskId}. Its shell is still exiting; check it with coflux terminal list.`);
|
|
337
358
|
}
|
|
338
359
|
} else if (sub === "list") {
|
|
339
360
|
const { terminals } = await agentPost({ action: "terminal.list" });
|
|
340
|
-
if (!terminals.length) return void
|
|
361
|
+
if (!terminals.length) return void info("No terminals in this workspace.");
|
|
341
362
|
for (const t of terminals) {
|
|
342
363
|
const exit = t.exitCode === undefined || t.exitCode === null ? "" : ` exit=${t.exitCode}`;
|
|
343
364
|
console.log(`${rowHandle(t)} ${t.status}${exit}${commandSuffix(t)} ${t.title}`);
|
|
344
365
|
}
|
|
345
366
|
} else if (sub === "read") {
|
|
346
367
|
const taskId = positionals[2];
|
|
347
|
-
if (!taskId)
|
|
368
|
+
if (!taskId) missingTaskId("read");
|
|
348
369
|
const requested = Number(values.lines);
|
|
349
370
|
const lines = Number.isInteger(requested) && requested > 0 ? requested : DEFAULT_READ_LINES;
|
|
350
|
-
const result = await agentPost({ action: "terminal.read", taskId });
|
|
371
|
+
const result = await agentPost({ action: "terminal.read", taskId }, TERMINAL_NEXT);
|
|
351
372
|
const exit = result.exitCode === undefined || result.exitCode === null ? "" : ` exit=${result.exitCode}`;
|
|
352
373
|
console.log(`# ${result.status}${exit}`);
|
|
353
374
|
const text = tailLines(stripAnsi(result.ansi), lines);
|
|
354
|
-
console.log(text || "
|
|
375
|
+
console.log(text || "(no output yet)");
|
|
355
376
|
} else if (sub === "send") {
|
|
356
377
|
const taskId = positionals[2];
|
|
357
|
-
if (!taskId)
|
|
378
|
+
if (!taskId) missingTaskId("send", ' --text="<text>"');
|
|
358
379
|
const text = values.text ?? "";
|
|
359
|
-
if (!text && !values.enter) die(
|
|
360
|
-
await agentPost({ action: "terminal.send", taskId, text, enter: Boolean(values.enter) });
|
|
361
|
-
|
|
380
|
+
if (!text && !values.enter) die("Nothing to send.", 'Pass --text="<text>", or --enter to send a single Enter.');
|
|
381
|
+
await agentPost({ action: "terminal.send", taskId, text, enter: Boolean(values.enter) }, TERMINAL_NEXT);
|
|
382
|
+
success(`Sent input to terminal ${taskId}`);
|
|
383
|
+
info(`Check the result with coflux terminal read ${taskId}.`);
|
|
362
384
|
} else if (sub === "wait") {
|
|
363
385
|
const taskId = positionals[2];
|
|
364
|
-
if (!taskId)
|
|
386
|
+
if (!taskId) missingTaskId("wait");
|
|
365
387
|
const requested = Number(values.timeout);
|
|
366
388
|
const timeoutSec = Number.isFinite(requested) && requested > 0 ? requested : DEFAULT_WAIT_TIMEOUT_S;
|
|
367
389
|
const seq = Number(values.seq);
|
|
@@ -371,33 +393,38 @@ async function cmdTerminal(values) {
|
|
|
371
393
|
// Each round blocks inside the daemon (its command-state watch wakes it the moment the
|
|
372
394
|
// command ends); a `running` answer only means the round elapsed.
|
|
373
395
|
const roundMs = Math.min(WAIT_ROUND_MS, Math.max(1, deadline - Date.now()));
|
|
374
|
-
const t = await agentPost({ action: "terminal.wait", taskId, commandSeq, timeoutMs: roundMs });
|
|
396
|
+
const t = await agentPost({ action: "terminal.wait", taskId, commandSeq, timeoutMs: roundMs }, TERMINAL_NEXT);
|
|
375
397
|
if (t.state !== "running") {
|
|
376
398
|
const exit = t.exitCode === undefined || t.exitCode === null ? "" : ` exit=${t.exitCode}`;
|
|
377
399
|
return void console.log(`# ${t.state === "exited" ? "exited" : "finished"}${exit}`);
|
|
378
400
|
}
|
|
379
401
|
if (Date.now() >= deadline) {
|
|
380
|
-
die(
|
|
402
|
+
die(
|
|
403
|
+
`Timed out after ${timeoutSec}s: command #${t.commandSeq} in terminal ${taskId} is still running.`,
|
|
404
|
+
`Wait longer with --timeout, or look at the screen with coflux terminal read ${taskId}.`,
|
|
405
|
+
);
|
|
381
406
|
}
|
|
382
407
|
}
|
|
383
408
|
} else {
|
|
384
|
-
die(`terminal
|
|
409
|
+
die(sub ? `Unknown terminal command: ${sub}.` : "Missing terminal command.", "Use one of: new, run, list, read, wait, send, close.");
|
|
385
410
|
}
|
|
386
411
|
}
|
|
387
412
|
|
|
388
413
|
async function cmdNotify() {
|
|
389
414
|
const message = positionals.slice(1).join(" ").trim();
|
|
390
|
-
if (!message) die(
|
|
415
|
+
if (!message) die("Missing message.", 'Usage: coflux notify "<message>"');
|
|
391
416
|
const result = await agentPost({ action: "notify", notificationId: randomUUID(), message });
|
|
392
|
-
if (!result.notificationId)
|
|
393
|
-
|
|
417
|
+
if (!result.notificationId) {
|
|
418
|
+
die("The notification was not confirmed: this device's Coflux is too old to save it.", "Update Coflux on this device, then send it again.");
|
|
419
|
+
}
|
|
420
|
+
success("Notification sent");
|
|
394
421
|
}
|
|
395
422
|
|
|
396
423
|
async function cmdProgress() {
|
|
397
424
|
const message = positionals.slice(1).join(" ").trim();
|
|
398
|
-
if (!message) die(
|
|
425
|
+
if (!message) die("Missing message.", 'Usage: coflux progress "<message>"');
|
|
399
426
|
await agentPost({ action: "progress", message });
|
|
400
|
-
|
|
427
|
+
success("Progress updated");
|
|
401
428
|
}
|
|
402
429
|
|
|
403
430
|
// 「我在哪」与「跟着我搬」(plan 102 / 103)。三条都打一行 JSON,字段稳定——插件脚本按它比对,
|
|
@@ -429,7 +456,7 @@ async function cmdWorkspace() {
|
|
|
429
456
|
// 路径缺省取调用方 cwd;插件脚本一律显式传 hook 载荷里的 cwd(hook 在会话当前目录执行,
|
|
430
457
|
// 与载荷里的 cwd 未必相同)。
|
|
431
458
|
const path = positionals[2] || callerCwd();
|
|
432
|
-
if (!path) die("workspace locate
|
|
459
|
+
if (!path) die("Could not determine the current directory.", "Pass the path: coflux workspace locate <path>.");
|
|
433
460
|
const result = await agentPost({ action: "workspace.locate", path });
|
|
434
461
|
return void console.log(JSON.stringify({
|
|
435
462
|
workspaceId: result.workspaceId,
|
|
@@ -441,7 +468,7 @@ async function cmdWorkspace() {
|
|
|
441
468
|
}
|
|
442
469
|
if (sub === "forget") {
|
|
443
470
|
const path = positionals[2];
|
|
444
|
-
if (!path) die("workspace forget
|
|
471
|
+
if (!path) die("Missing path.", "Usage: coflux workspace forget <path>");
|
|
445
472
|
const result = await agentPost({ action: "workspace.forget", path });
|
|
446
473
|
return void console.log(JSON.stringify({
|
|
447
474
|
workspaceId: result.workspaceId,
|
|
@@ -450,12 +477,12 @@ async function cmdWorkspace() {
|
|
|
450
477
|
removed: Boolean(result.removed),
|
|
451
478
|
}));
|
|
452
479
|
}
|
|
453
|
-
die(`workspace
|
|
480
|
+
die(`Unknown workspace command: ${sub}.`, "Use enter, locate or forget, or no subcommand to print the current workspace.");
|
|
454
481
|
}
|
|
455
482
|
|
|
456
483
|
async function cmdPorts() {
|
|
457
484
|
const { ports } = await agentPost({ action: "ports" });
|
|
458
|
-
if (!ports.length) return void
|
|
485
|
+
if (!ports.length) return void info("No listening ports in this workspace.");
|
|
459
486
|
for (const p of ports) console.log(`${p.port} ${p.url}`);
|
|
460
487
|
}
|
|
461
488
|
|
|
@@ -496,25 +523,31 @@ function executorChangedFiles(status) {
|
|
|
496
523
|
function renderExecutorSuccess(status) {
|
|
497
524
|
const out = ["# succeeded"];
|
|
498
525
|
const summary = String(status?.summary ?? "").trim();
|
|
499
|
-
out.push(summary || "
|
|
526
|
+
out.push(summary || "(the executor left no final reply)");
|
|
500
527
|
const files = executorChangedFiles(status);
|
|
501
|
-
if (!files.length) out.push("
|
|
502
|
-
else { out.push(
|
|
503
|
-
out.push("executor
|
|
528
|
+
if (!files.length) out.push("Changed files: none");
|
|
529
|
+
else { out.push(`Changed files (${files.length}):`); out.push(...files); }
|
|
530
|
+
out.push("The executor never commits. Review the changes and commit them yourself.");
|
|
504
531
|
return out.join("\n");
|
|
505
532
|
}
|
|
506
533
|
|
|
507
|
-
/**
|
|
534
|
+
/** The error for a non-success terminal state: state, reason, and what already changed. */
|
|
508
535
|
function renderExecutorFailure(status) {
|
|
509
536
|
const terminal = String(status?.terminal ?? "") || "unknown";
|
|
510
|
-
const reason = String(status?.error ?? "").trim() || String(status?.note ?? "").trim() || "
|
|
537
|
+
const reason = String(status?.error ?? "").trim() || String(status?.note ?? "").trim() || "no reason given";
|
|
511
538
|
const files = executorChangedFiles(status);
|
|
512
|
-
const tail = files.length ?
|
|
513
|
-
return
|
|
539
|
+
const tail = files.length ? `. Files already changed (${files.length}): ${files.join(" ")}` : "";
|
|
540
|
+
return {
|
|
541
|
+
message: `Executor run ended as ${terminal}: ${reason}${tail}`,
|
|
542
|
+
next: files.length ? "Review the changes it left, then send a new run." : "Adjust the prompt and send a new run.",
|
|
543
|
+
};
|
|
514
544
|
}
|
|
515
545
|
|
|
516
546
|
function renderExecutorTimeout(timeoutSec, runId, phase) {
|
|
517
|
-
return
|
|
547
|
+
return {
|
|
548
|
+
message: `Timed out after ${timeoutSec}s: executor run ${runId} was still ${phase} and has been cancelled.`,
|
|
549
|
+
next: "Send it again with a larger --timeout.",
|
|
550
|
+
};
|
|
518
551
|
}
|
|
519
552
|
|
|
520
553
|
/** Submit. A transport failure retries with the same submissionId; a refusal is reported verbatim.
|
|
@@ -526,19 +559,19 @@ async function executorSubmit(prompt, write, title) {
|
|
|
526
559
|
const result = await agentPostResult({ action: "executor.submit", submissionId: submission, prompt, write, title });
|
|
527
560
|
if (result.ok) {
|
|
528
561
|
const runId = String(result.value?.runId ?? "");
|
|
529
|
-
if (!runId) die("
|
|
562
|
+
if (!runId) die("The Coflux service did not return a run id.", "Update Coflux on this device, then try again.");
|
|
530
563
|
return runId;
|
|
531
564
|
}
|
|
532
|
-
if (result.kind === "refused" || attempt >= EXECUTOR_SUBMIT_RETRIES) die(result.message);
|
|
565
|
+
if (result.kind === "refused" || attempt >= EXECUTOR_SUBMIT_RETRIES) die(result.message, result.next || HELP_NEXT);
|
|
533
566
|
await sleep(EXECUTOR_POLL_MS);
|
|
534
567
|
}
|
|
535
568
|
}
|
|
536
569
|
|
|
537
570
|
async function cmdExecutor(values) {
|
|
538
|
-
if (positionals[1] !== "run") die(
|
|
571
|
+
if (positionals[1] !== "run") die("Unknown executor command.", 'Usage: coflux executor run --prompt="<task>" [--title="<title>"] [--write]');
|
|
539
572
|
const prompt = String(values.prompt ?? "").trim();
|
|
540
573
|
if (!prompt) {
|
|
541
|
-
die(
|
|
574
|
+
die("Missing prompt.", 'Describe the task with --prompt, for example --prompt="Fix the clippy warnings in crates/worker".');
|
|
542
575
|
}
|
|
543
576
|
const write = Boolean(values.write);
|
|
544
577
|
const title = String(values.title ?? "").trim();
|
|
@@ -553,104 +586,143 @@ async function cmdExecutor(values) {
|
|
|
553
586
|
// `succeeded` is about the *task*; the envelope's top-level `ok` only says the request itself
|
|
554
587
|
// was accepted.
|
|
555
588
|
if (status.succeeded) return void console.log(renderExecutorSuccess(status));
|
|
556
|
-
|
|
589
|
+
const failure = renderExecutorFailure(status);
|
|
590
|
+
die(failure.message, failure.next);
|
|
557
591
|
}
|
|
558
592
|
if (Date.now() >= deadline) {
|
|
559
593
|
// Cancel before reporting: an unwatched write job still editing files in the background is
|
|
560
594
|
// far worse than the timeout itself.
|
|
561
595
|
await agentPostResult({ action: "executor.cancel", runId });
|
|
562
|
-
|
|
596
|
+
const timeout = renderExecutorTimeout(timeoutSec, runId, status.phase);
|
|
597
|
+
die(timeout.message, timeout.next);
|
|
563
598
|
}
|
|
564
599
|
await sleep(EXECUTOR_POLL_MS);
|
|
565
600
|
}
|
|
566
601
|
}
|
|
567
602
|
|
|
568
|
-
const HELP = `
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
coflux terminal new [--title
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
coflux terminal run <taskId> --cmd
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
603
|
+
const HELP = `Work with Coflux terminals, workspaces and your account.
|
|
604
|
+
|
|
605
|
+
Usage:
|
|
606
|
+
coflux <command> [subcommand] [flags]
|
|
607
|
+
|
|
608
|
+
Commands for agents running in a Coflux terminal:
|
|
609
|
+
coflux terminal new [--title=<title>] [--cmd=<command>]
|
|
610
|
+
Open a terminal: a login shell on a real tty in the workspace directory. The user sees
|
|
611
|
+
it in the sidebar and can take it over. It stays open until you close it. With --cmd,
|
|
612
|
+
the command is typed in once the prompt is ready, the same as new followed by run.
|
|
613
|
+
coflux terminal run <taskId> --cmd=<command>
|
|
614
|
+
Type a command into the terminal once its prompt is ready. Refused while the previous
|
|
615
|
+
command is still running.
|
|
616
|
+
coflux terminal wait <taskId> [--timeout=<seconds>] [--seq=<N>]
|
|
617
|
+
Wait for the current (or Nth) command to finish and print "# finished exit=<code>",
|
|
618
|
+
or "# exited exit=<code>" when the shell itself ended. Default timeout: 30 minutes.
|
|
619
|
+
coflux terminal read <taskId> [--lines=<N>]
|
|
620
|
+
Print the end of the terminal's scrollback as plain text. Default: the last 200 lines.
|
|
621
|
+
coflux terminal send <taskId> --text=<text> [--enter]
|
|
622
|
+
Type text into the terminal; --enter adds Enter. Refused while the user has taken over.
|
|
623
|
+
coflux terminal list
|
|
624
|
+
List this workspace's terminals with their status and exit code. Live ones also show
|
|
625
|
+
busy or idle and the exit code of their last command.
|
|
588
626
|
coflux terminal close <taskId>
|
|
589
|
-
|
|
590
|
-
coflux notify "
|
|
591
|
-
|
|
592
|
-
coflux
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
627
|
+
End the terminal.
|
|
628
|
+
coflux notify "<message>"
|
|
629
|
+
Send the user a notification. Confirmed once the server has saved it.
|
|
630
|
+
coflux progress "<message>"
|
|
631
|
+
Show a progress line on the workspace card. The next one replaces it.
|
|
632
|
+
coflux ports
|
|
633
|
+
List this workspace's listening ports and their preview URLs.
|
|
634
|
+
coflux secret ask NAME --reason "<why>" [--timeout <seconds>]
|
|
635
|
+
coflux secret exec NAME [NAME…] -- <cmd> [args…]
|
|
636
|
+
coflux secret inject NAME --file <path> [--key KEY]
|
|
637
|
+
Get a secret (API key, password) from the user on their Coflux desktop without the
|
|
638
|
+
value entering your context: ask prints only provided | declined | cancelled; exec
|
|
639
|
+
runs a command with the value in a same-name environment variable and shows it as ***
|
|
640
|
+
in the output; inject writes KEY=value into a dotenv file in this workspace.
|
|
641
|
+
Details: coflux secret help
|
|
642
|
+
coflux annotations list [--json]
|
|
643
|
+
coflux annotations watch [--timeout <seconds>] [--json]
|
|
644
|
+
coflux annotations resolve <id> --note "<what you changed>"
|
|
645
|
+
Browser annotations: elements the user marked in Coflux's built-in browser for this
|
|
646
|
+
workspace, with their comment, component names and source location when known,
|
|
647
|
+
selector, and screenshot/reference image paths. list prints the pending ones as
|
|
648
|
+
markdown; watch blocks until there are some (default 30 minutes); after implementing
|
|
649
|
+
one, resolve it with a note the user reads to review the change.
|
|
650
|
+
coflux executor run --prompt=<task> [--title=<title>] [--write] [--timeout <seconds>]
|
|
651
|
+
Hand a well-bounded sub-task to the built-in executor and wait for its final reply and
|
|
652
|
+
the files it changed. Each run is one-shot. Read-only unless --write, with one writing
|
|
653
|
+
run per workspace at a time. It has no network and never commits. --title names the
|
|
654
|
+
progress card the user sees on this terminal. Needs Coflux.app on this machine.
|
|
655
|
+
coflux workspace
|
|
656
|
+
Print one JSON line: workspaceId (where your local commands land), path,
|
|
657
|
+
owningWorkspaceId (the workspace this terminal belongs to) and moved.
|
|
606
658
|
coflux workspace enter <path>
|
|
607
|
-
|
|
608
|
-
|
|
659
|
+
Enter a workspace of the same repository and move this terminal there. Use the
|
|
660
|
+
returned path explicitly in later tool calls.
|
|
609
661
|
coflux workspace locate [path]
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
插件自动调,一般不用手敲
|
|
662
|
+
Move this terminal to the workspace that owns path (default: the current directory),
|
|
663
|
+
registering a worktree of the same repository when needed. The plugin calls this.
|
|
613
664
|
coflux workspace forget <path>
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
供有硬超时的 hook 脚本用——到点干净失败,好过被宿主杀在半路。
|
|
624
|
-
|
|
625
|
-
账号命令(JSON 输出):
|
|
626
|
-
coflux login --username <账号> --password-stdin [--server https://…]
|
|
665
|
+
The worktree at path was deleted: move its terminals back to the main workspace and
|
|
666
|
+
drop its record. Does not run git worktree remove.
|
|
667
|
+
coflux hook <claude|codex>
|
|
668
|
+
Forward an agent hook event from stdin or argv to this device. Never fails the agent.
|
|
669
|
+
|
|
670
|
+
Account commands (JSON output):
|
|
671
|
+
coflux login [--server <url>]
|
|
672
|
+
Sign in through the browser. Over SSH, or with COFLUX_LOGIN_PASTE=1, paste a code.
|
|
673
|
+
coflux login --username <name> --password-stdin [--server <url>]
|
|
627
674
|
coflux whoami | logout
|
|
628
675
|
coflux device list | project list | workspace list
|
|
629
|
-
coflux device exec <deviceId> --cmd
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
coflux
|
|
646
|
-
coflux
|
|
647
|
-
coflux terminal
|
|
648
|
-
coflux terminal run|read|send|wait|stop|remove <id> --remote
|
|
649
|
-
coflux terminal list [--device <id>] [--workspace <id>](跑着的终端带 busy / lastCommandExitCode,经 checkpoint 滞后 ≤2 秒)
|
|
676
|
+
coflux device exec <deviceId> --cmd=<command> [--cwd=<dir>] [--timeout=<seconds>]
|
|
677
|
+
Run one command on another device, like ssh host "cmd": it runs under sh -c, stdout
|
|
678
|
+
and stderr come back separately, the last line is "# exit=<code>", and the exit code
|
|
679
|
+
is the remote one (255 when this command itself fails). Not a terminal: no PTY,
|
|
680
|
+
nothing in the sidebar, no stdin. --cwd defaults to the device user's home and must be
|
|
681
|
+
absolute or start with ~. --timeout defaults to 60 seconds, at most 600. For
|
|
682
|
+
passwords, TUIs or long work the user should see, use coflux terminal new.
|
|
683
|
+
coflux project import <path> [--device <id>] [--name <name>]
|
|
684
|
+
Turn a git repository on a device into a project with its main workspace and print
|
|
685
|
+
one JSON line: projectId, name, repoPath, defaultBranch, workspaceId, path,
|
|
686
|
+
alreadyImported. <path> is resolved on the device and must be absolute or start with
|
|
687
|
+
~; to import the current directory, pass "$PWD". --device defaults to
|
|
688
|
+
COFLUX_DEVICE_ID. Importing the same repository again returns the existing project
|
|
689
|
+
with alreadyImported=true.
|
|
690
|
+
coflux workspace new --project <id> --branch <branch> [--existing-branch]
|
|
691
|
+
coflux workspace rename <id> --name <name> | workspace remove <id>
|
|
692
|
+
coflux terminal new --workspace <id> [--cmd <command>] [--title <title>]
|
|
693
|
+
coflux terminal list [--device <id>] [--workspace <id>]
|
|
694
|
+
coflux terminal run|read|wait|send|stop|remove <id> --remote
|
|
650
695
|
coflux ports --remote
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
696
|
+
When the Coflux app is signed in, these use its account; otherwise run coflux login.
|
|
697
|
+
|
|
698
|
+
Ids and handles:
|
|
699
|
+
Wherever an id is accepted you can pass a handle, coflux:<kind>:<first 8 of id>, for
|
|
700
|
+
example coflux:workspace:3f2a1b7c (case-insensitive). Results carry it as ref. A prefix
|
|
701
|
+
that matches several entities asks for the full id; a handle of the wrong kind is an error.
|
|
702
|
+
|
|
703
|
+
Flags:
|
|
704
|
+
-h, --help Show this help
|
|
705
|
+
|
|
706
|
+
Environment:
|
|
707
|
+
COFLUX_AGENT_TIMEOUT_MS Lower the wait for one local request (default 30000), for hook
|
|
708
|
+
scripts with a hard time limit
|
|
709
|
+
NO_COLOR Turn off colour
|
|
710
|
+
|
|
711
|
+
To manage this device, use Coflux.app or cofluxd.`;
|
|
712
|
+
/** parseArgs errors in the same words as the Rust CLI (crates/cli/src/args.rs). */
|
|
713
|
+
function argumentError(error) {
|
|
714
|
+
const message = String(error?.message ?? error);
|
|
715
|
+
const first = message.split(". ")[0];
|
|
716
|
+
const missing = /^Option '(.+)' argument missing/.exec(first);
|
|
717
|
+
if (missing) return `Option '${missing[1]}' needs a value`;
|
|
718
|
+
const unexpected = /^Option '(.+)' does not take an argument/.exec(first);
|
|
719
|
+
if (unexpected) return `Option '${unexpected[1]}' does not take a value`;
|
|
720
|
+
return first;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
let parsedArgs;
|
|
724
|
+
try {
|
|
725
|
+
parsedArgs = parseArgs({
|
|
654
726
|
allowPositionals: true,
|
|
655
727
|
options: {
|
|
656
728
|
username: { type: "string" },
|
|
@@ -679,15 +751,19 @@ const { values, positionals } = parseArgs({
|
|
|
679
751
|
enter: { type: "boolean", default: false },
|
|
680
752
|
help: { type: "boolean", short: "h", default: false },
|
|
681
753
|
},
|
|
682
|
-
});
|
|
754
|
+
});
|
|
755
|
+
} catch (error) {
|
|
756
|
+
die(argumentError(error));
|
|
757
|
+
}
|
|
758
|
+
const { values, positionals } = parsedArgs;
|
|
683
759
|
|
|
684
760
|
const cmd = positionals[0];
|
|
685
761
|
if (values.help || cmd === "help" || !cmd) { console.log(HELP); process.exit(0); }
|
|
686
762
|
if (handlesAccountCommand(positionals, values, HOME)) {
|
|
687
|
-
try { await runAccountCommand(positionals, values, HOME); } catch (error) { die(error.message); }
|
|
763
|
+
try { await runAccountCommand(positionals, values, HOME); } catch (error) { die(error.message, error.next); }
|
|
688
764
|
process.exit(0);
|
|
689
765
|
}
|
|
690
766
|
const handlers = { hook: cmdHook, terminal: cmdTerminal, notify: cmdNotify, progress: cmdProgress, ports: cmdPorts, executor: cmdExecutor, workspace: cmdWorkspace };
|
|
691
767
|
const handler = handlers[cmd];
|
|
692
|
-
if (!handler) die(
|
|
768
|
+
if (!handler) die(`Unknown command: ${cmd}`, "Run coflux --help to see the commands. To manage this device, use Coflux.app or cofluxd.");
|
|
693
769
|
await handler(values);
|