cofluxd 0.15.0 → 1.0.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 +34 -67
- package/account-client.mjs +101 -0
- package/coflux.mjs +399 -0
- package/cofluxd.mjs +9 -362
- package/package.json +6 -3
- package/skills/coflux/SKILL.md +63 -91
package/README.md
CHANGED
|
@@ -1,90 +1,57 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Coflux CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Operate local and remote terminals from one account. This package provides two distinct commands:
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
| Command | Purpose |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| `cofluxd` | Manage a headless device: install, start, stop, diagnose, and update its runtime |
|
|
8
|
+
| `coflux` | Sign in and operate devices, workspaces, and terminals |
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
npm i -g cofluxd
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
## 用法
|
|
12
|
-
|
|
13
|
-
```sh
|
|
14
|
-
cofluxd # 首次=up(起服务后打印浏览器授权链接),之后=看状态
|
|
15
|
-
cofluxd up # 幂等:零参数即可装/起;已装则按当前配置重装服务并重启
|
|
16
|
-
cofluxd status # 服务器/登记(含"等待授权")/服务/连接状态
|
|
17
|
-
cofluxd doctor # 中心网络 + gateway/grant/loopback + daemon 状态分层自检
|
|
18
|
-
cofluxd logs -f # 看 daemon 日志
|
|
19
|
-
cofluxd update # 下载新二进制(不重启;supervisor 有变化时提示用 restart 应用)
|
|
20
|
-
cofluxd restart # 重启 daemon 应用新 supervisor(⚠ 结束本机所有活会话)
|
|
21
|
-
cofluxd down # 停止
|
|
22
|
-
cofluxd uninstall [--purge] # 卸载(--purge 连二进制/配置/凭证一并删)
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
从 `cofluxd@0.12.0` 起,远端安装/更新会用 npm 包内置的 ed25519 公钥同时验证
|
|
26
|
-
supervisor 与 worker 的 version/target/size/sha256/release statement;两个文件全部通过后才替换。
|
|
27
|
-
CLI 还会取自身与 worker 的持久 release floor 较大值拒绝远端降级。`--bin-dir` 仍是本机管理员
|
|
28
|
-
显式信任本地产物的开发/救援入口。
|
|
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.
|
|
29
11
|
|
|
30
|
-
|
|
12
|
+
## Install
|
|
31
13
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
跑在 coflux 终端里的 claude/codex 可以用下面几条,把工作外化成用户在 web/手机上**看得见、能接管**的东西——而不是在自己的 Bash 里后台起一个谁也看不见的进程:
|
|
14
|
+
Requires Node.js 20 or later.
|
|
35
15
|
|
|
36
16
|
```sh
|
|
37
|
-
|
|
38
|
-
cofluxd
|
|
39
|
-
cofluxd terminal list # 本工作区的终端 + 状态/退出码
|
|
40
|
-
cofluxd terminal read <taskId> [--lines N] # 读终端内容(纯文本,已退出也能读)
|
|
41
|
-
cofluxd terminal wait <taskId> [--timeout <秒>] # 阻塞到退出,打印退出码
|
|
42
|
-
cofluxd terminal send <taskId> --text "y" --enter # 往终端输入;用户正在接管时被拒
|
|
43
|
-
cofluxd progress "复现了,正在定位" # 播报进度:显示在工作区卡片上
|
|
44
|
-
cofluxd notify "需要你定一下用哪个方案" # 叫人:侧栏转「等待交互」
|
|
45
|
-
cofluxd ports # 端口 + 可直接打开的预览 URL
|
|
17
|
+
npm install -g cofluxd
|
|
18
|
+
cofluxd up
|
|
46
19
|
```
|
|
47
20
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
不需要任何凭证:daemon 用调用方 pid 反查进程树确认它属于哪个会话,**coflux 会话之外的进程一律拒绝**,权限也天然限定在该会话所属的工作区内。**local-first**:send/read/wait/notify/progress 在 daemon 本地闭环、不经中心(归属与退出码来自 daemon 自己的会话账本,内容来自本地命令日志或 sessiond 快照);只有 new/list/ports 由 daemon 代问中心——Task 要落库广播、预览 URL 由中心生成。早于 daemon 升级开出来的终端缺归属信息,本地命令会明确拒绝,重开即可。
|
|
51
|
-
|
|
52
|
-
每个 coflux 开出来的 PTY 会话里还注入了一组 `COFLUX_*` 环境变量(由 supervisor 组装,中心只下发 id):`COFLUX_DEVICE_ID` / `COFLUX_PROJECT_ID`(目录工作区为空串)/ `COFLUX_WORKSPACE_ID` / `COFLUX_TASK_ID` / `COFLUX_SESSION_ID` / `COFLUX_MCP_URL`。agent 读它们就知道自己在哪台设备、哪个项目/工作区/终端,值与中心 MCP `list_*` 返回的 id 完全一致,可直接传给 MCP tools。本地命令与 MCP 的分工只有一条规则:**本地能闭环的一律本地命令**(本工作区内的开终端/读/等/输入/播报/叫人/端口);只有跨出本工作区——开子工作区、跨工作区/跨设备读写、或从 coflux 之外接入——才用中心的 `coflux` MCP(`claude mcp add --transport http coflux "$COFLUX_MCP_URL"`,一次 OAuth 授权)。supervisor 不走热升级,旧机器要 `cofluxd update && cofluxd restart` 之后会话里才有这些变量;skill 里写了变量为空时的降级分支。
|
|
21
|
+
Follow the authorization link to connect the host to your account. Use `cofluxd status`, `cofluxd doctor`, or `cofluxd logs -f` to inspect it.
|
|
53
22
|
|
|
54
|
-
|
|
23
|
+
## Operate your workspaces
|
|
55
24
|
|
|
56
25
|
```sh
|
|
57
|
-
|
|
26
|
+
# Supply the password through stdin, not in a command-line argument.
|
|
27
|
+
coflux login --username <account> --password-stdin
|
|
28
|
+
coflux device list
|
|
29
|
+
coflux workspace list
|
|
30
|
+
coflux terminal new --workspace <id> --cmd 'git status'
|
|
31
|
+
coflux terminal read <terminal-id> --remote
|
|
58
32
|
```
|
|
59
33
|
|
|
60
|
-
|
|
34
|
+
Account commands return JSON. Inside a Coflux terminal, local commands automatically use the current workspace:
|
|
61
35
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
中心:DNS → TCP → TLS → WebSocket
|
|
70
|
-
本地:gateway bind → 持久 grant/Origin → loopback WebSocket
|
|
71
|
-
状态:daemon → 中心的实际连接状态
|
|
36
|
+
```sh
|
|
37
|
+
coflux terminal new --title 'Tests' --cmd 'pnpm test'
|
|
38
|
+
coflux terminal list
|
|
39
|
+
coflux terminal read <terminal-id>
|
|
40
|
+
coflux terminal wait <terminal-id>
|
|
41
|
+
coflux progress 'Reviewing the changes.'
|
|
42
|
+
coflux notify 'Ready for your review.'
|
|
72
43
|
```
|
|
73
44
|
|
|
74
|
-
|
|
75
|
-
- 中心项失败:已经加载、已经配对且 cached direct 可用的页面仍能控制存活 session;刷新/冷启动不保证。
|
|
76
|
-
- 网络层都通但 daemon 未连接:查看 `cofluxd logs`,通常是认证、版本或服务进程问题。
|
|
45
|
+
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).
|
|
77
46
|
|
|
78
|
-
|
|
79
|
-
device token 或其它凭证。它的 loopback 检查只做主机侧 WebSocket upgrade;浏览器自身的 LNA/permission
|
|
80
|
-
仍以 Chrome/Safari/Firefox 页面实测为准。
|
|
47
|
+
## Upgrades and terminal lifetime
|
|
81
48
|
|
|
82
|
-
|
|
49
|
+
Updating this npm package does not end running terminals. `cofluxd update` downloads runtime artifacts without restarting the Supervisor that owns the PTYs. Apply that update with `cofluxd restart` after your tasks finish.
|
|
83
50
|
|
|
84
|
-
|
|
51
|
+
`cofluxd restart` and `cofluxd down` end local terminal processes. The desktop app stays online in the background; fully quitting or signing out ends its local terminals after confirmation.
|
|
85
52
|
|
|
86
|
-
|
|
53
|
+
Starting with 1.0, operation commands such as `cofluxd terminal` and `cofluxd login` have been removed. Use `coflux terminal` and `coflux login`. The MCP interface has also been removed in favor of the CLI.
|
|
87
54
|
|
|
88
|
-
|
|
55
|
+
[GitHub](https://github.com/myWsq/coflux) · [Releases](https://github.com/myWsq/coflux/releases) · [Issues](https://github.com/myWsq/coflux/issues)
|
|
89
56
|
|
|
90
|
-
|
|
57
|
+
MIT © 2026 Shuaiqi Wang.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// 账号客户端与桌面共用中心操作层;本文件不依赖 MCP 或 Node 常驻进程。
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
function origin(raw) {
|
|
7
|
+
const url = new URL(raw.replace(/^wss:/, "https:").replace(/^ws:/, "http:"));
|
|
8
|
+
if (url.username || url.password) throw new Error("服务器地址不能包含凭据");
|
|
9
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) throw new Error("服务器必须使用 HTTPS(本机开发可用 HTTP)");
|
|
10
|
+
return url.origin;
|
|
11
|
+
}
|
|
12
|
+
function unwrap(body) {
|
|
13
|
+
if (body?.ok !== true) throw new Error(body?.error || "账号请求失败");
|
|
14
|
+
return body.value;
|
|
15
|
+
}
|
|
16
|
+
async function request(server, path, token, body, timeout) {
|
|
17
|
+
const response = await fetch(server + path, { method: "POST", redirect: "error", headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body), signal: AbortSignal.timeout(timeout) });
|
|
18
|
+
return unwrap(await response.json());
|
|
19
|
+
}
|
|
20
|
+
function broker(home, body, timeout) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const socket = net.createConnection(join(home, "client.sock"));
|
|
23
|
+
let text = "";
|
|
24
|
+
socket.setEncoding("utf8");
|
|
25
|
+
socket.setTimeout(timeout, () => socket.destroy(new Error("账号请求超时,请查询操作结果")));
|
|
26
|
+
socket.on("error", (error) => reject(error.code === "ENOENT" || error.code === "ECONNREFUSED" ? new Error("请先登录 Coflux 应用或运行 coflux login") : error));
|
|
27
|
+
socket.on("connect", () => socket.write(JSON.stringify(body) + "\n"));
|
|
28
|
+
socket.on("data", (chunk) => { text += chunk; if (Buffer.byteLength(text) > 8 * 1024 * 1024) socket.destroy(new Error("响应过大")); });
|
|
29
|
+
socket.on("end", () => { try { resolve(unwrap(JSON.parse(text))); } catch (error) { reject(error); } });
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export function handlesAccountCommand(positionals, flags, home) {
|
|
33
|
+
const [command, sub] = positionals;
|
|
34
|
+
if (["login", "logout", "whoami", "device", "project"].includes(command)) return true;
|
|
35
|
+
if (command === "workspace") return ["list", "new", "rename", "remove"].includes(sub);
|
|
36
|
+
return ["terminal", "ports"].includes(command) && (flags.remote || flags.workspace || flags.device || (!process.env.COFLUX_TASK_ID && (fs.existsSync(join(home, "cli-session.json")) || fs.existsSync(join(home, "client.sock")))));
|
|
37
|
+
}
|
|
38
|
+
export async function runAccountCommand(positionals, flags, home) {
|
|
39
|
+
const [command, sub = "list", id] = positionals;
|
|
40
|
+
const sessionPath = join(home, "cli-session.json");
|
|
41
|
+
const required = (key) => { if (!flags[key]) throw new Error(`缺少 --${key}`); return flags[key]; };
|
|
42
|
+
const target = () => { if (!id) throw new Error("缺少目标 ID"); return id; };
|
|
43
|
+
const print = (value) => console.log(JSON.stringify(value));
|
|
44
|
+
if (command === "login") {
|
|
45
|
+
const server = origin(flags.server || "https://api.coflux.dev");
|
|
46
|
+
const username = required("username");
|
|
47
|
+
if (!flags["password-stdin"]) throw new Error("用 --password-stdin 从标准输入读取密码;密码不进入命令参数或配置文件");
|
|
48
|
+
let password = "";
|
|
49
|
+
for await (const chunk of process.stdin) { password += chunk; if (Buffer.byteLength(password) > 4096) throw new Error("密码过长"); if (password.includes("\n")) break; }
|
|
50
|
+
const value = await request(server, "/api/client/login", null, { protocolVersion: 1, username, password: password.split("\n", 1)[0].replace(/\r$/, "") }, 30000);
|
|
51
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
52
|
+
fs.chmodSync(home, 0o700);
|
|
53
|
+
const temp = `${sessionPath}.${process.pid}.tmp`;
|
|
54
|
+
try { fs.writeFileSync(temp, JSON.stringify({ server, token: value.token, accountId: value.accountId }), { mode: 0o600, flag: "wx" }); fs.renameSync(temp, sessionPath); }
|
|
55
|
+
finally { fs.rmSync(temp, { force: true }); }
|
|
56
|
+
print({ accountId: value.accountId, server });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const session = fs.existsSync(sessionPath) ? JSON.parse(fs.readFileSync(sessionPath, "utf8")) : null;
|
|
60
|
+
const call = (operation) => {
|
|
61
|
+
const body = { protocolVersion: 1, command: operation };
|
|
62
|
+
const timeout = operation.op === "terminal.wait" ? 610000 : 40000;
|
|
63
|
+
if (!session) {
|
|
64
|
+
if (flags.server) throw new Error("请先登录指定服务器");
|
|
65
|
+
return broker(home, body, timeout);
|
|
66
|
+
}
|
|
67
|
+
const server = origin(session.server);
|
|
68
|
+
if (flags.server && origin(flags.server) !== server) throw new Error("目标服务器与登录记录不一致,请先登录目标服务器");
|
|
69
|
+
if (!session.token) throw new Error("请先登录");
|
|
70
|
+
return request(server, "/api/client/command", session.token, body, timeout);
|
|
71
|
+
};
|
|
72
|
+
if (command === "logout") {
|
|
73
|
+
if (!session) throw new Error("此 CLI 未单独登录;应用账号请在 Coflux 中退出");
|
|
74
|
+
await call({ op: "logout" });
|
|
75
|
+
fs.rmSync(sessionPath);
|
|
76
|
+
print({ loggedOut: true });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
let operation;
|
|
80
|
+
if (command === "workspace") {
|
|
81
|
+
if (sub === "new") operation = { op: "workspace.new", projectId: required("project"), branch: required("branch"), createNew: !flags["existing-branch"], ...(flags.name ? { name: flags.name } : {}) };
|
|
82
|
+
if (sub === "rename") operation = { op: "workspace.rename", workspaceId: target(), name: required("name") };
|
|
83
|
+
if (sub === "remove") operation = { op: "workspace.remove", workspaceId: target() };
|
|
84
|
+
}
|
|
85
|
+
if (command === "terminal") {
|
|
86
|
+
if (sub === "new") operation = { op: "terminal.new", workspaceId: required("workspace"), title: flags.title || "", command: flags.cmd || "" };
|
|
87
|
+
if (sub === "read") operation = { op: "terminal.read", terminalId: target(), lines: Number(flags.lines ?? 200) };
|
|
88
|
+
if (sub === "send") operation = { op: "terminal.send", terminalId: target(), text: required("text"), enter: !!flags.enter };
|
|
89
|
+
if (sub === "wait") operation = { op: "terminal.wait", terminalId: target(), timeout: Number(flags.timeout ?? 30) };
|
|
90
|
+
if (["stop", "remove"].includes(sub)) operation = { op: `terminal.${sub}`, terminalId: target() };
|
|
91
|
+
}
|
|
92
|
+
if (!operation && command !== "whoami" && command !== "ports" && sub !== "list") throw new Error("未知账号命令");
|
|
93
|
+
if (operation && (flags.device || (flags.workspace && operation.op !== "terminal.new"))) throw new Error("目标 ID 已确定作用范围,请不要附加设备或工作区筛选参数");
|
|
94
|
+
let value = await call(operation || { op: "snapshot" });
|
|
95
|
+
if (command === "whoami") value = { accountId: value.accountId };
|
|
96
|
+
else if (sub === "list" || command === "ports") {
|
|
97
|
+
const field = { device: "devices", project: "projects", workspace: "workspaces", terminal: "terminals", ports: "ports" }[command];
|
|
98
|
+
value = value[field].filter((item) => (!flags.device || item.daemonId === flags.device) && (!flags.workspace || item.workspaceId === flags.workspace || (command === "workspace" && item.id === flags.workspace)));
|
|
99
|
+
}
|
|
100
|
+
print(value);
|
|
101
|
+
}
|
package/coflux.mjs
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// coflux:账号与本地、跨设备业务操作;不负责宿主生命周期。
|
|
3
|
+
import { handlesAccountCommand, runAccountCommand } from "./account-client.mjs";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
const HOME = process.env.COFLUX_HOME || join(homedir(), ".coflux");
|
|
8
|
+
const DEFAULT_LOCAL_GATEWAY_PORT = 8788;
|
|
9
|
+
const die = (message) => { console.error("✗ " + message); process.exit(1); };
|
|
10
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
11
|
+
function localGatewayPort() {
|
|
12
|
+
const raw = process.env.COFLUX_LOCAL_GATEWAY_PORT;
|
|
13
|
+
if (raw === undefined || raw === "") return { ok: true, port: DEFAULT_LOCAL_GATEWAY_PORT };
|
|
14
|
+
const port = Number(raw);
|
|
15
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
16
|
+
return { ok: false, error: `COFLUX_LOCAL_GATEWAY_PORT=${raw} 无法定位固定监听端口` };
|
|
17
|
+
}
|
|
18
|
+
return { ok: true, port };
|
|
19
|
+
}
|
|
20
|
+
/* ------------------------------ hook:agent 事件信使 ------------------------------ */
|
|
21
|
+
// agent hook 的上报信使:用户在 claude/codex 的 hook 配置里指向本命令,事件发生时它把
|
|
22
|
+
// 事件名转发给本机 worker 的固定 gateway(POST /hook),供活动状态判定。
|
|
23
|
+
//
|
|
24
|
+
// 输入两种形态都收:claude 与 codex hooks 引擎走 stdin JSON;codex 旧式 notify 把 payload
|
|
25
|
+
// 作为最后一个 argv 传入。只转发事件名 + notification 类型 + agent 会话 id + 本进程
|
|
26
|
+
// pid/ppid——payload 里的 prompt / 回答原文 / 通知正文一律不出机(隐私边界)。
|
|
27
|
+
//
|
|
28
|
+
// 契约(worker 侧将来实现 /hook 时依赖):请求保持到收到响应才退出——worker 在处理期间
|
|
29
|
+
// 用上报的 pid 反查进程树归属哪个 session,本进程活着扫描才有效。
|
|
30
|
+
//
|
|
31
|
+
// 纪律:本命令绝不能干扰 agent 本体——任何失败(daemon 不在/端口不通/payload 畸形)都
|
|
32
|
+
// 静默退出 0(claude 把 Stop hook 的非零退出码解释为"阻止收尾");绝不写 stdout(claude
|
|
33
|
+
// 会把 hook 的 stdout 当决策 JSON 解析),调试信息走 stderr(COFLUX_HOOK_DEBUG=1 开启)。
|
|
34
|
+
const HOOK_STDIN_TIMEOUT_MS = 300; // stdin 没有数据时不能干等(notify 形态下 stdin 是继承的 TTY/空管道)
|
|
35
|
+
const HOOK_POST_TIMEOUT_MS = 2000;
|
|
36
|
+
|
|
37
|
+
const hookDebug = (...args) => { if (process.env.COFLUX_HOOK_DEBUG) console.error("[coflux hook]", ...args); };
|
|
38
|
+
|
|
39
|
+
async function readStdinJson() {
|
|
40
|
+
if (process.stdin.isTTY) return null;
|
|
41
|
+
const chunks = [];
|
|
42
|
+
const drained = (async () => { for await (const chunk of process.stdin) chunks.push(chunk); })().catch(() => {});
|
|
43
|
+
await Promise.race([drained, sleep(HOOK_STDIN_TIMEOUT_MS)]);
|
|
44
|
+
process.stdin.destroy(); // 超时后放掉 stdin,否则 for await 会吊着进程不退出
|
|
45
|
+
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
46
|
+
if (!raw) return null;
|
|
47
|
+
try { return JSON.parse(raw); } catch { return null; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function cmdHook() {
|
|
51
|
+
try {
|
|
52
|
+
const agent = positionals[1];
|
|
53
|
+
if (agent !== "claude" && agent !== "codex") {
|
|
54
|
+
hookDebug(`未知 agent: ${agent ?? "(缺参)"}(需 claude|codex)`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
let payload = null;
|
|
58
|
+
if (positionals[2]) {
|
|
59
|
+
try { payload = JSON.parse(positionals[2]); } catch { /* 非 JSON 的多余参数,忽略 */ }
|
|
60
|
+
}
|
|
61
|
+
if (!payload) payload = await readStdinJson();
|
|
62
|
+
if (!payload || typeof payload !== "object") {
|
|
63
|
+
hookDebug("无有效 payload,忽略");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// claude/codex hooks 引擎用 hook_event_name;codex notify 用 type
|
|
67
|
+
const event = payload.hook_event_name || payload.type;
|
|
68
|
+
if (typeof event !== "string" || !event) {
|
|
69
|
+
hookDebug("payload 缺事件名,忽略");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const portResult = localGatewayPort();
|
|
73
|
+
if (!portResult.ok) {
|
|
74
|
+
hookDebug(portResult.error);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const notification = payload.notification_type ?? payload.notificationType;
|
|
78
|
+
const body = {
|
|
79
|
+
agent,
|
|
80
|
+
event,
|
|
81
|
+
pid: process.pid,
|
|
82
|
+
ppid: process.ppid,
|
|
83
|
+
// agent 自身的会话标识(claude: session_id / codex notify: thread-id),供 worker 去重与调试
|
|
84
|
+
agentSessionId: payload.session_id ?? payload["thread-id"] ?? undefined,
|
|
85
|
+
// Claude Notification 的类型枚举(permission_prompt / agent_needs_input …),不含正文
|
|
86
|
+
notification: typeof notification === "string" && notification ? notification : undefined,
|
|
87
|
+
// Stop/SubagentStop 独有:本会话在飞的后台工作(shell/subagent/monitor/workflow)。claude
|
|
88
|
+
// 官方设它就是为了让 hook 区分「真做完了」与「挂起等后台把自己叫醒」。只传条数——条目里的
|
|
89
|
+
// description 是自由文本(含路径与代码片段),按本命令的隐私边界不出机。
|
|
90
|
+
backgroundTasks: Array.isArray(payload.background_tasks) ? payload.background_tasks.length : undefined,
|
|
91
|
+
};
|
|
92
|
+
hookDebug("POST /hook", JSON.stringify(body));
|
|
93
|
+
const res = await fetch(`http://127.0.0.1:${portResult.port}/hook`, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: { "content-type": "application/json" },
|
|
96
|
+
body: JSON.stringify(body),
|
|
97
|
+
signal: AbortSignal.timeout(HOOK_POST_TIMEOUT_MS),
|
|
98
|
+
});
|
|
99
|
+
hookDebug(`响应 ${res.status}`);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
hookDebug(error?.message || String(error));
|
|
102
|
+
} finally {
|
|
103
|
+
process.exit(0); // 无论成败都干净退出:不给 agent 留非零退出码,也不让残留句柄吊住进程
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* --------------------- agent 协同控制(plan 074) --------------------- */
|
|
108
|
+
// 跑在 coflux 终端里的 claude/codex 用这组命令,把自己的工作外化成用户在 web/手机上
|
|
109
|
+
// **看得见、能接管**的 coflux 实体——而不是在自己的 Bash 里后台起一个谁也看不见的进程。
|
|
110
|
+
//
|
|
111
|
+
// 不需要任何凭证:daemon 用调用方 pid 反查进程树确认它属于哪个会话,树外一律拒。
|
|
112
|
+
// local-first(plan 094):send/read/wait/notify/progress 在 daemon 本地闭环,不经中心;只有
|
|
113
|
+
// new/list/ports 由 daemon 代问中心(Task 要落库广播、预览 URL 由中心生成)。
|
|
114
|
+
// 跟随 cwd(plan 102):请求都带 process.cwd(),agent 挪进同设备另一个 coflux 工作区后,这些
|
|
115
|
+
// 命令就对那个工作区办事(notify/progress/ports 除外,它们挂在本会话上,与工作区无关)。
|
|
116
|
+
// 与 `hook` 子命令的约定**相反**:这些命令必须写 stdout——输出就是给 agent 读的返回值。
|
|
117
|
+
// 也刻意不做自动重试:terminal new 有副作用,重试会开出两个终端,失败就把错误交给 agent。
|
|
118
|
+
|
|
119
|
+
const AGENT_TIMEOUT_MS = 30_000;
|
|
120
|
+
/** 调用方能收窄单次 `/agent` 等待的下限;再低就只够覆盖 node 自己的启动,等于必然超时。 */
|
|
121
|
+
const MIN_AGENT_TIMEOUT_MS = 200;
|
|
122
|
+
const DEFAULT_READ_LINES = 200;
|
|
123
|
+
// wait 的循环必须在 CLI 侧:单次 agentPost 有 25 秒的 loopback 应答上限。默认 30 分钟——编码任务
|
|
124
|
+
// 常跑很久;轮询走 terminal.status(daemon 本地账本直接答,不经中心),3 秒一次对本机 loopback
|
|
125
|
+
// 是零负担。
|
|
126
|
+
const DEFAULT_WAIT_TIMEOUT_S = 1800;
|
|
127
|
+
const WAIT_POLL_MS = 3000;
|
|
128
|
+
|
|
129
|
+
// 每条请求都带调用方 cwd(plan 102):agent 可以经 `/cd` 或 EnterWorktree 把活着的会话挪进同
|
|
130
|
+
// 设备的另一个 coflux 工作区,daemon 据此把本次请求的**目标**解析到 cwd 所在的工作区(会话的
|
|
131
|
+
// 归属工作区不变)。目录被删掉时 process.cwd() 会抛,按"报不出来"处理,daemon 退回归属工作区。
|
|
132
|
+
function callerCwd() {
|
|
133
|
+
try { return process.cwd(); } catch { return ""; }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 调用方可以用 COFLUX_AGENT_TIMEOUT_MS 收窄单次请求的等待上限(plan 104)。默认 30 秒是为
|
|
137
|
+
// agent 定的——它等得起;hook 脚本等不起:宿主按秒杀 hook(SessionStart 只给几秒),而经中心的
|
|
138
|
+
// 动作最坏要等 daemon 的 20 秒中心超时。被宿主杀在半路比拿不到答案坏得多(连坐标块都印不出来),
|
|
139
|
+
// 所以这类调用方自报一个更小的预算,到点干净失败、让脚本走回退。
|
|
140
|
+
// 只允许收窄不允许放宽:上限仍是 AGENT_TIMEOUT_MS,畸形值一律按默认处理。
|
|
141
|
+
function agentTimeoutMs() {
|
|
142
|
+
const raw = Number(process.env.COFLUX_AGENT_TIMEOUT_MS);
|
|
143
|
+
if (!Number.isFinite(raw) || raw <= 0) return AGENT_TIMEOUT_MS;
|
|
144
|
+
return Math.min(Math.max(Math.floor(raw), MIN_AGENT_TIMEOUT_MS), AGENT_TIMEOUT_MS);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function agentPost(body) {
|
|
148
|
+
const portResult = localGatewayPort();
|
|
149
|
+
if (!portResult.ok) die(portResult.error);
|
|
150
|
+
let res;
|
|
151
|
+
try {
|
|
152
|
+
res = await fetch(`http://127.0.0.1:${portResult.port}/agent`, {
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers: { "content-type": "application/json" },
|
|
155
|
+
body: JSON.stringify({ ...body, pid: process.pid, ppid: process.ppid, cwd: callerCwd() }),
|
|
156
|
+
signal: AbortSignal.timeout(agentTimeoutMs()),
|
|
157
|
+
});
|
|
158
|
+
} catch (error) {
|
|
159
|
+
die(`连不上本机 daemon:${error?.message || error}(daemon 没在跑?查看 Coflux.app 或 cofluxd status)`);
|
|
160
|
+
}
|
|
161
|
+
let parsed = null;
|
|
162
|
+
try { parsed = await res.json(); } catch { /* 非 JSON 响应按下面的兜底报错处理 */ }
|
|
163
|
+
if (!res.ok || !parsed?.ok) die(parsed?.error || `daemon 返回 ${res.status}`);
|
|
164
|
+
return parsed;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 剥掉 ANSI/OSC 转义与 C0 控制字符,保留 \t 与 \n——snapshot 是给终端渲染的字节流,
|
|
168
|
+
// agent 要的是能读的纯文本。去转义放在 CLI 侧:daemon 的 snapshot 同时是 checkpoint 的
|
|
169
|
+
// 数据来源,不为 agent 的可读性改它的语义。
|
|
170
|
+
const ANSI_RE =
|
|
171
|
+
/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PR-TZcf-ntqry=><~]|[\u0000-\u0008\u000b-\u001f\u007f]/g;
|
|
172
|
+
|
|
173
|
+
function stripAnsi(raw) {
|
|
174
|
+
return String(raw ?? "").replace(ANSI_RE, "");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 取最后 n 行并去掉尾部空行——VT snapshot 的下半屏通常是成片空行,对 agent 是纯噪音。 */
|
|
178
|
+
function tailLines(text, n) {
|
|
179
|
+
const lines = text.split("\n");
|
|
180
|
+
while (lines.length && lines[lines.length - 1].trim() === "") lines.pop();
|
|
181
|
+
return lines.slice(-n).join("\n");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function cmdTerminal(values) {
|
|
185
|
+
const sub = positionals[1];
|
|
186
|
+
if (sub === "new") {
|
|
187
|
+
// 两种终端只看「命令是否为空」(plan 101):--cmd 缺省与 --cmd= 空白等价,都开会话终端
|
|
188
|
+
// ——工作区目录下的默认登录 shell,stdin/stdout 都是真 tty,不自动退出。带命令的仍是作业
|
|
189
|
+
// 终端:命令跑完终端退出并带退出码,语义一字不变。空白在这里统一收敛成空串,好让中心的
|
|
190
|
+
// 「默认标题取命令首行」落到它自己的兜底。
|
|
191
|
+
const command = (values.cmd ?? "").trim() ? values.cmd : "";
|
|
192
|
+
const result = await agentPost({ action: "terminal.new", title: values.title || "", command });
|
|
193
|
+
console.log(`已开终端 ${result.taskId}(用户可在 coflux 侧栏看到并随时接管)`);
|
|
194
|
+
if (command) {
|
|
195
|
+
console.log(`看输出:coflux terminal read ${result.taskId}`);
|
|
196
|
+
} else {
|
|
197
|
+
console.log(`会话终端:常驻的登录 shell(全 tty),不会自己退出`);
|
|
198
|
+
console.log(`先等提示符:coflux terminal read ${result.taskId}`);
|
|
199
|
+
console.log(`再输命令:coflux terminal send ${result.taskId} --text "<命令>" --enter(送 exit 才结束)`);
|
|
200
|
+
}
|
|
201
|
+
} else if (sub === "list") {
|
|
202
|
+
const { terminals } = await agentPost({ action: "terminal.list" });
|
|
203
|
+
if (!terminals.length) return void console.log("本工作区暂无终端");
|
|
204
|
+
for (const t of terminals) {
|
|
205
|
+
const exit = t.exitCode === undefined || t.exitCode === null ? "" : ` exit=${t.exitCode}`;
|
|
206
|
+
console.log(`${t.taskId} ${t.status}${exit} ${t.title}`);
|
|
207
|
+
}
|
|
208
|
+
} else if (sub === "read") {
|
|
209
|
+
const taskId = positionals[2];
|
|
210
|
+
if (!taskId) die("terminal read 需要 <taskId>(用 coflux terminal list 查)");
|
|
211
|
+
const requested = Number(values.lines);
|
|
212
|
+
const lines = Number.isInteger(requested) && requested > 0 ? requested : DEFAULT_READ_LINES;
|
|
213
|
+
const result = await agentPost({ action: "terminal.read", taskId });
|
|
214
|
+
const exit = result.exitCode === undefined || result.exitCode === null ? "" : ` exit=${result.exitCode}`;
|
|
215
|
+
console.log(`# ${result.status}${exit}`);
|
|
216
|
+
const text = tailLines(stripAnsi(result.ansi), lines);
|
|
217
|
+
console.log(text || "(暂无输出)");
|
|
218
|
+
} else if (sub === "send") {
|
|
219
|
+
const taskId = positionals[2];
|
|
220
|
+
if (!taskId) die("terminal send 需要 <taskId>(用 coflux terminal list 查)");
|
|
221
|
+
const text = values.text ?? "";
|
|
222
|
+
if (!text && !values.enter) die(`terminal send 需要 --text "<文本>"(或至少 --enter 发一个回车)`);
|
|
223
|
+
await agentPost({ action: "terminal.send", taskId, text, enter: Boolean(values.enter) });
|
|
224
|
+
console.log(`已写入终端 ${taskId}(用 coflux terminal read ${taskId} 核对效果)`);
|
|
225
|
+
} else if (sub === "wait") {
|
|
226
|
+
const taskId = positionals[2];
|
|
227
|
+
if (!taskId) die("terminal wait 需要 <taskId>(用 coflux terminal list 查)");
|
|
228
|
+
const requested = Number(values.timeout);
|
|
229
|
+
const timeoutSec = Number.isFinite(requested) && requested > 0 ? requested : DEFAULT_WAIT_TIMEOUT_S;
|
|
230
|
+
const deadline = Date.now() + timeoutSec * 1000;
|
|
231
|
+
for (;;) {
|
|
232
|
+
// 按 taskId 直接问本地账本;目标不存在/不在本工作区时 daemon 回可读错误,agentPost 直接 die。
|
|
233
|
+
const t = await agentPost({ action: "terminal.status", taskId });
|
|
234
|
+
if (t.status === "exited") {
|
|
235
|
+
const exit = t.exitCode === undefined || t.exitCode === null ? "" : ` exit=${t.exitCode}`;
|
|
236
|
+
return void console.log(`# exited${exit}`);
|
|
237
|
+
}
|
|
238
|
+
if (Date.now() >= deadline) {
|
|
239
|
+
die(`等待超时(${timeoutSec}s):终端 ${taskId} 仍是 ${t.status}。可加大 --timeout,或 coflux terminal read ${taskId} 看现场`);
|
|
240
|
+
}
|
|
241
|
+
await sleep(WAIT_POLL_MS);
|
|
242
|
+
}
|
|
243
|
+
} else {
|
|
244
|
+
die(`terminal 需要子命令:new | list | read | wait | send`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function cmdNotify() {
|
|
249
|
+
const message = positionals.slice(1).join(" ").trim();
|
|
250
|
+
if (!message) die(`notify 需要一句话,例如:coflux notify "两个方案拿不准,需要你定"`);
|
|
251
|
+
await agentPost({ action: "notify", message });
|
|
252
|
+
console.log("已通知用户(工作区在侧栏转为「等待交互」)");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function cmdProgress() {
|
|
256
|
+
const message = positionals.slice(1).join(" ").trim();
|
|
257
|
+
if (!message) die(`progress 需要一句话,例如:coflux progress "复现了,正在定位 relay 重连"`);
|
|
258
|
+
await agentPost({ action: "progress", message });
|
|
259
|
+
console.log("已更新进度(显示在工作区卡片上,被下一条覆盖)");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 「我在哪」与「跟着我搬」(plan 102 / 103)。三条都打一行 JSON,字段稳定——插件脚本按它比对,
|
|
263
|
+
// agent 也直接读。
|
|
264
|
+
//
|
|
265
|
+
// coflux workspace 只读:cwd 所在的有效工作区 + 本终端的归属工作区
|
|
266
|
+
// coflux workspace locate [path] 把本终端的**归属**搬到 path 所属的工作区(未登记先登记)
|
|
267
|
+
// coflux workspace forget <path> 该 worktree 已被删掉:其下终端搬回主工作区、记录消失
|
|
268
|
+
//
|
|
269
|
+
// locate/forget 是插件在 SessionStart / PostToolUse(EnterWorktree|ExitWorktree) / WorktreeRemove
|
|
270
|
+
// 上调的,同样零凭证(daemon 按进程树认身份)。daemon 旧到不认识这两个动作时它会回
|
|
271
|
+
// 「未知 action …」,agentPost 原样报错并非零退出——脚本据此静默放弃,不干扰会话。
|
|
272
|
+
async function cmdWorkspace() {
|
|
273
|
+
const sub = positionals[1];
|
|
274
|
+
if (!sub) {
|
|
275
|
+
const result = await agentPost({ action: "workspace.current" });
|
|
276
|
+
return void console.log(JSON.stringify({
|
|
277
|
+
workspaceId: result.workspaceId,
|
|
278
|
+
path: result.path,
|
|
279
|
+
owningWorkspaceId: result.owningWorkspaceId,
|
|
280
|
+
moved: Boolean(result.moved),
|
|
281
|
+
}));
|
|
282
|
+
}
|
|
283
|
+
if (sub === "locate") {
|
|
284
|
+
// 路径缺省取调用方 cwd;插件脚本一律显式传 hook 载荷里的 cwd(hook 在会话当前目录执行,
|
|
285
|
+
// 与载荷里的 cwd 未必相同)。
|
|
286
|
+
const path = positionals[2] || callerCwd();
|
|
287
|
+
if (!path) die("workspace locate 需要 <path>(取不到当前目录)");
|
|
288
|
+
const result = await agentPost({ action: "workspace.locate", path });
|
|
289
|
+
return void console.log(JSON.stringify({
|
|
290
|
+
workspaceId: result.workspaceId,
|
|
291
|
+
path: result.path,
|
|
292
|
+
branch: result.branch,
|
|
293
|
+
created: Boolean(result.created),
|
|
294
|
+
moved: Boolean(result.moved),
|
|
295
|
+
}));
|
|
296
|
+
}
|
|
297
|
+
if (sub === "forget") {
|
|
298
|
+
const path = positionals[2];
|
|
299
|
+
if (!path) die("workspace forget 需要 <path>(被删掉的 worktree 目录)");
|
|
300
|
+
const result = await agentPost({ action: "workspace.forget", path });
|
|
301
|
+
return void console.log(JSON.stringify({
|
|
302
|
+
workspaceId: result.workspaceId,
|
|
303
|
+
fallbackWorkspaceId: result.fallbackWorkspaceId,
|
|
304
|
+
movedTerminals: result.movedTerminals ?? 0,
|
|
305
|
+
removed: Boolean(result.removed),
|
|
306
|
+
}));
|
|
307
|
+
}
|
|
308
|
+
die(`workspace 的子命令只有 locate | forget(不带子命令 = 报出我在哪)`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function cmdPorts() {
|
|
312
|
+
const { ports } = await agentPost({ action: "ports" });
|
|
313
|
+
if (!ports.length) return void console.log("本工作区暂无监听端口");
|
|
314
|
+
for (const p of ports) console.log(`${p.port} ${p.url}`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const HELP = `coflux —— 账号与终端操作
|
|
318
|
+
coflux hook <claude|codex> [agent hook 信使] 读 stdin/argv 的事件 JSON,转发给本机 daemon
|
|
319
|
+
(在 claude/codex 的 hook 配置里指向本命令;失败静默,不干扰 agent)
|
|
320
|
+
|
|
321
|
+
以下几条供**跑在 coflux 终端里的 agent** 调用,把工作变成用户看得见、能接管的东西:
|
|
322
|
+
|
|
323
|
+
coflux terminal new [--cmd "<命令>"] [--title "<标题>"]
|
|
324
|
+
开一个真实终端,用户在 coflux 侧栏能看到并随时接管
|
|
325
|
+
带 --cmd = 作业终端:命令在登录 shell 里跑完即退出并带退出码,输出另
|
|
326
|
+
落一份日志供 read 回读(代价:stdout 是管道,不是 tty)
|
|
327
|
+
不带 --cmd = 会话终端:工作区目录下的常驻登录 shell,stdin/stdout 都是
|
|
328
|
+
真 tty(能跑 vim/htop、有颜色),先 read 等提示符再 send,送 exit 才结束
|
|
329
|
+
coflux terminal list 列出本工作区的终端(含 status / 退出码)
|
|
330
|
+
coflux terminal read <taskId> [--lines N]
|
|
331
|
+
读某个终端的内容(纯文本,默认最后 200 行;终端已退出也能读)
|
|
332
|
+
coflux terminal wait <taskId> [--timeout <秒>]
|
|
333
|
+
阻塞等到该终端退出,打印退出码(默认超时 30 分钟)
|
|
334
|
+
coflux terminal send <taskId> --text "<文本>" [--enter]
|
|
335
|
+
往终端里输入文本(--enter 追加回车)。用户正在接管时会被拒
|
|
336
|
+
coflux notify "<一句话>" 叫人:工作区在侧栏转为「等待交互」并显示这句话
|
|
337
|
+
coflux progress "<一句话>" 播报进度:显示在工作区卡片上,被下一条覆盖(不打扰用户)
|
|
338
|
+
coflux ports 列出本工作区的监听端口及可直接打开的预览 URL
|
|
339
|
+
coflux workspace 一行 JSON 报出「我在哪」:workspaceId(cwd 所在的有效工作区,本地命令
|
|
340
|
+
都落在它上面)、path、owningWorkspaceId(本终端此刻归属哪个工作区)、
|
|
341
|
+
moved。用 /cd 挪进另一个 coflux 工作区后用它确认目标,跨工作区操作时也传这个
|
|
342
|
+
workspaceId
|
|
343
|
+
coflux workspace locate [path]
|
|
344
|
+
把本终端的**归属**搬到 path(缺省=当前目录)所属的工作区:进入/离开
|
|
345
|
+
worktree 后 coflux 跟着走,未登记的同仓库 worktree 先登记出一个子工作区。
|
|
346
|
+
插件自动调,一般不用手敲
|
|
347
|
+
coflux workspace forget <path>
|
|
348
|
+
该 worktree 已被删掉:其下所有终端搬回项目主工作区、工作区记录消失
|
|
349
|
+
(不执行 git worktree remove)
|
|
350
|
+
|
|
351
|
+
agent 命令的环境变量:COFLUX_AGENT_TIMEOUT_MS 收窄单次请求的等待上限(默认 30000,只能调小),
|
|
352
|
+
供有硬超时的 hook 脚本用——到点干净失败,好过被宿主杀在半路。
|
|
353
|
+
|
|
354
|
+
账号命令(JSON 输出):
|
|
355
|
+
coflux login --username <账号> --password-stdin [--server https://…]
|
|
356
|
+
coflux whoami | logout
|
|
357
|
+
coflux device list | project list | workspace list
|
|
358
|
+
coflux workspace new --project <id> --branch <分支> [--existing-branch]
|
|
359
|
+
coflux workspace rename <id> --name <名称> | workspace remove <id>
|
|
360
|
+
coflux terminal new --workspace <id> [--cmd <命令>]
|
|
361
|
+
coflux terminal read|send|wait|stop|remove <id> --remote
|
|
362
|
+
coflux terminal list [--device <id>] [--workspace <id>]
|
|
363
|
+
coflux ports --remote
|
|
364
|
+
已登录的 Coflux 应用可供 CLI 直接使用;独立 CLI 可自行登录。
|
|
365
|
+
`;
|
|
366
|
+
const { values, positionals } = parseArgs({
|
|
367
|
+
allowPositionals: true,
|
|
368
|
+
options: {
|
|
369
|
+
username: { type: "string" },
|
|
370
|
+
workspace: { type: "string" },
|
|
371
|
+
device: { type: "string" },
|
|
372
|
+
project: { type: "string" },
|
|
373
|
+
branch: { type: "string" },
|
|
374
|
+
remote: { type: "boolean" },
|
|
375
|
+
json: { type: "boolean" },
|
|
376
|
+
"password-stdin": { type: "boolean" },
|
|
377
|
+
"existing-branch": { type: "boolean" },
|
|
378
|
+
server: { type: "string" },
|
|
379
|
+
name: { type: "string" },
|
|
380
|
+
title: { type: "string" },
|
|
381
|
+
cmd: { type: "string" },
|
|
382
|
+
lines: { type: "string" },
|
|
383
|
+
timeout: { type: "string" },
|
|
384
|
+
text: { type: "string" },
|
|
385
|
+
enter: { type: "boolean", default: false },
|
|
386
|
+
help: { type: "boolean", short: "h", default: false },
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const cmd = positionals[0];
|
|
391
|
+
if (values.help || cmd === "help" || !cmd) { console.log(HELP); process.exit(0); }
|
|
392
|
+
if (handlesAccountCommand(positionals, values, HOME)) {
|
|
393
|
+
try { await runAccountCommand(positionals, values, HOME); } catch (error) { die(error.message); }
|
|
394
|
+
process.exit(0);
|
|
395
|
+
}
|
|
396
|
+
const handlers = { hook: cmdHook, terminal: cmdTerminal, notify: cmdNotify, progress: cmdProgress, ports: cmdPorts, workspace: cmdWorkspace };
|
|
397
|
+
const handler = handlers[cmd];
|
|
398
|
+
if (!handler) die(`未知命令: ${cmd}\n本机宿主请使用 Coflux.app 或 cofluxd。\n\n${HELP}`);
|
|
399
|
+
await handler(values);
|