cofluxd 0.7.0 → 0.9.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 +20 -0
- package/cofluxd.mjs +111 -4
- package/package.json +3 -2
- package/skills/coflux/SKILL.md +90 -0
package/README.md
CHANGED
|
@@ -24,6 +24,26 @@ cofluxd uninstall [--purge] # 卸载(--purge 连二进制/配置/凭证一
|
|
|
24
24
|
|
|
25
25
|
默认连公共服务 `wss://api.coflux.dev/daemon`(自托管用 `--server` 改;已保存的地址继续生效,非默认时会有醒目提示)。
|
|
26
26
|
|
|
27
|
+
## 给 agent 用的命令
|
|
28
|
+
|
|
29
|
+
跑在 coflux 终端里的 claude/codex 可以用下面几条,把工作外化成用户在 web/手机上**看得见、能接管**的东西——而不是在自己的 Bash 里后台起一个谁也看不见的进程:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
cofluxd terminal new --title "跑单测" --cmd "pnpm test" # 开真实终端,用户可接管
|
|
33
|
+
cofluxd terminal list # 本工作区的终端 + 状态/退出码
|
|
34
|
+
cofluxd terminal read <taskId> [--lines N] # 读终端内容(纯文本,已退出也能读)
|
|
35
|
+
cofluxd notify "需要你定一下用哪个方案" # 叫人:侧栏转「等待交互」
|
|
36
|
+
cofluxd ports # 端口 + 可直接打开的预览 URL
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
不需要任何凭证:daemon 用调用方 pid 反查进程树确认它属于哪个会话,**coflux 会话之外的进程一律拒绝**,权限也天然限定在该会话所属的工作区内。
|
|
40
|
+
|
|
41
|
+
配套的 skill 在 `skills/coflux/SKILL.md`(随包分发),装给 Claude Code:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
mkdir -p ~/.claude/skills && ln -sfn "$(npm root -g)/cofluxd/skills/coflux" ~/.claude/skills/coflux
|
|
45
|
+
```
|
|
46
|
+
|
|
27
47
|
`cofluxd up` 起服务后会打印一个一次性授权链接,在浏览器用已登录的账号打开确认即可(链接可在任意设备打开,包括无头设备),无需先去 web 控制台生成密钥。已登记设备重跑 `up` 不会重新触发授权。
|
|
28
48
|
|
|
29
49
|
## 本地优先与 doctor
|
package/cofluxd.mjs
CHANGED
|
@@ -666,11 +666,11 @@ function cmdUninstall(v) {
|
|
|
666
666
|
|
|
667
667
|
/* ------------------------------ hook:agent 事件信使 ------------------------------ */
|
|
668
668
|
// agent hook 的上报信使:用户在 claude/codex 的 hook 配置里指向本命令,事件发生时它把
|
|
669
|
-
// 事件名转发给本机 worker 的固定 gateway(POST /hook
|
|
669
|
+
// 事件名转发给本机 worker 的固定 gateway(POST /hook),供活动状态判定。
|
|
670
670
|
//
|
|
671
671
|
// 输入两种形态都收:claude 与 codex hooks 引擎走 stdin JSON;codex 旧式 notify 把 payload
|
|
672
|
-
// 作为最后一个 argv 传入。只转发事件名 + agent 会话 id + 本进程
|
|
673
|
-
// prompt /
|
|
672
|
+
// 作为最后一个 argv 传入。只转发事件名 + notification 类型 + agent 会话 id + 本进程
|
|
673
|
+
// pid/ppid——payload 里的 prompt / 回答原文 / 通知正文一律不出机(隐私边界)。
|
|
674
674
|
//
|
|
675
675
|
// 契约(worker 侧将来实现 /hook 时依赖):请求保持到收到响应才退出——worker 在处理期间
|
|
676
676
|
// 用上报的 pid 反查进程树归属哪个 session,本进程活着扫描才有效。
|
|
@@ -721,6 +721,7 @@ async function cmdHook() {
|
|
|
721
721
|
hookDebug(portResult.error);
|
|
722
722
|
return;
|
|
723
723
|
}
|
|
724
|
+
const notification = payload.notification_type ?? payload.notificationType;
|
|
724
725
|
const body = {
|
|
725
726
|
agent,
|
|
726
727
|
event,
|
|
@@ -728,6 +729,8 @@ async function cmdHook() {
|
|
|
728
729
|
ppid: process.ppid,
|
|
729
730
|
// agent 自身的会话标识(claude: session_id / codex notify: thread-id),供 worker 去重与调试
|
|
730
731
|
agentSessionId: payload.session_id ?? payload["thread-id"] ?? undefined,
|
|
732
|
+
// Claude Notification 的类型枚举(permission_prompt / agent_needs_input …),不含正文
|
|
733
|
+
notification: typeof notification === "string" && notification ? notification : undefined,
|
|
731
734
|
};
|
|
732
735
|
hookDebug("POST /hook", JSON.stringify(body));
|
|
733
736
|
const res = await fetch(`http://127.0.0.1:${portResult.port}/hook`, {
|
|
@@ -744,6 +747,97 @@ async function cmdHook() {
|
|
|
744
747
|
}
|
|
745
748
|
}
|
|
746
749
|
|
|
750
|
+
/* --------------------- agent 协同控制(plan 074) --------------------- */
|
|
751
|
+
// 跑在 coflux 终端里的 claude/codex 用这组命令,把自己的工作外化成用户在 web/手机上
|
|
752
|
+
// **看得见、能接管**的 coflux 实体——而不是在自己的 Bash 里后台起一个谁也看不见的进程。
|
|
753
|
+
//
|
|
754
|
+
// 不需要任何凭证:daemon 用调用方 pid 反查进程树确认它属于哪个会话,树外一律拒。
|
|
755
|
+
// 与 `hook` 子命令的约定**相反**:这些命令必须写 stdout——输出就是给 agent 读的返回值。
|
|
756
|
+
// 也刻意不做自动重试:terminal new 有副作用,重试会开出两个终端,失败就把错误交给 agent。
|
|
757
|
+
|
|
758
|
+
const AGENT_TIMEOUT_MS = 30_000;
|
|
759
|
+
const DEFAULT_READ_LINES = 200;
|
|
760
|
+
|
|
761
|
+
async function agentPost(body) {
|
|
762
|
+
const portResult = localGatewayPort();
|
|
763
|
+
if (!portResult.ok) die(portResult.error);
|
|
764
|
+
let res;
|
|
765
|
+
try {
|
|
766
|
+
res = await fetch(`http://127.0.0.1:${portResult.port}/agent`, {
|
|
767
|
+
method: "POST",
|
|
768
|
+
headers: { "content-type": "application/json" },
|
|
769
|
+
body: JSON.stringify({ ...body, pid: process.pid, ppid: process.ppid }),
|
|
770
|
+
signal: AbortSignal.timeout(AGENT_TIMEOUT_MS),
|
|
771
|
+
});
|
|
772
|
+
} catch (error) {
|
|
773
|
+
die(`连不上本机 daemon:${error?.message || error}(daemon 没在跑?先看 cofluxd status)`);
|
|
774
|
+
}
|
|
775
|
+
let parsed = null;
|
|
776
|
+
try { parsed = await res.json(); } catch { /* 非 JSON 响应按下面的兜底报错处理 */ }
|
|
777
|
+
if (!res.ok || !parsed?.ok) die(parsed?.error || `daemon 返回 ${res.status}`);
|
|
778
|
+
return parsed;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// 剥掉 ANSI/OSC 转义与 C0 控制字符,保留 \t 与 \n——snapshot 是给终端渲染的字节流,
|
|
782
|
+
// agent 要的是能读的纯文本。去转义放在 CLI 侧:daemon 的 snapshot 同时是 checkpoint 的
|
|
783
|
+
// 数据来源,不为 agent 的可读性改它的语义。
|
|
784
|
+
const ANSI_RE =
|
|
785
|
+
/\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;
|
|
786
|
+
|
|
787
|
+
function stripAnsi(raw) {
|
|
788
|
+
return String(raw ?? "").replace(ANSI_RE, "");
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** 取最后 n 行并去掉尾部空行——VT snapshot 的下半屏通常是成片空行,对 agent 是纯噪音。 */
|
|
792
|
+
function tailLines(text, n) {
|
|
793
|
+
const lines = text.split("\n");
|
|
794
|
+
while (lines.length && lines[lines.length - 1].trim() === "") lines.pop();
|
|
795
|
+
return lines.slice(-n).join("\n");
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async function cmdTerminal(values) {
|
|
799
|
+
const sub = positionals[1];
|
|
800
|
+
if (sub === "new") {
|
|
801
|
+
const command = values.cmd;
|
|
802
|
+
if (!command) die(`terminal new 需要 --cmd "<命令>"`);
|
|
803
|
+
const result = await agentPost({ action: "terminal.new", title: values.title || "", command });
|
|
804
|
+
console.log(`已开终端 ${result.taskId}(用户可在 coflux 侧栏看到并随时接管)`);
|
|
805
|
+
console.log(`看输出:cofluxd terminal read ${result.taskId}`);
|
|
806
|
+
} else if (sub === "list") {
|
|
807
|
+
const { terminals } = await agentPost({ action: "terminal.list" });
|
|
808
|
+
if (!terminals.length) return void console.log("本工作区暂无终端");
|
|
809
|
+
for (const t of terminals) {
|
|
810
|
+
const exit = t.exitCode === undefined || t.exitCode === null ? "" : ` exit=${t.exitCode}`;
|
|
811
|
+
console.log(`${t.taskId} ${t.status}${exit} ${t.title}`);
|
|
812
|
+
}
|
|
813
|
+
} else if (sub === "read") {
|
|
814
|
+
const taskId = positionals[2];
|
|
815
|
+
if (!taskId) die("terminal read 需要 <taskId>(用 cofluxd terminal list 查)");
|
|
816
|
+
const requested = Number(values.lines);
|
|
817
|
+
const lines = Number.isInteger(requested) && requested > 0 ? requested : DEFAULT_READ_LINES;
|
|
818
|
+
const result = await agentPost({ action: "terminal.read", taskId });
|
|
819
|
+
const exit = result.exitCode === undefined || result.exitCode === null ? "" : ` exit=${result.exitCode}`;
|
|
820
|
+
console.log(`# ${result.status}${exit}`);
|
|
821
|
+
const text = tailLines(stripAnsi(result.ansi), lines);
|
|
822
|
+
console.log(text || "(暂无输出)");
|
|
823
|
+
} else {
|
|
824
|
+
die(`terminal 需要子命令:new | list | read`);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async function cmdNotify() {
|
|
829
|
+
const message = positionals.slice(1).join(" ").trim();
|
|
830
|
+
if (!message) die(`notify 需要一句话,例如:cofluxd notify "两个方案拿不准,需要你定"`);
|
|
831
|
+
await agentPost({ action: "notify", message });
|
|
832
|
+
console.log("已通知用户(工作区在侧栏转为「等待交互」)");
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
async function cmdPorts() {
|
|
836
|
+
const { ports } = await agentPost({ action: "ports" });
|
|
837
|
+
if (!ports.length) return void console.log("本工作区暂无监听端口");
|
|
838
|
+
for (const p of ports) console.log(`${p.port} ${p.url}`);
|
|
839
|
+
}
|
|
840
|
+
|
|
747
841
|
const HELP = `cofluxd —— coflux daemon 管理
|
|
748
842
|
|
|
749
843
|
cofluxd 首次=up(打印浏览器授权链接),已配置=status
|
|
@@ -759,6 +853,16 @@ const HELP = `cofluxd —— coflux daemon 管理
|
|
|
759
853
|
cofluxd hook <claude|codex> [agent hook 信使] 读 stdin/argv 的事件 JSON,转发给本机 daemon
|
|
760
854
|
(在 claude/codex 的 hook 配置里指向本命令;失败静默,不干扰 agent)
|
|
761
855
|
|
|
856
|
+
以下几条供**跑在 coflux 终端里的 agent** 调用,把工作变成用户看得见、能接管的东西:
|
|
857
|
+
|
|
858
|
+
cofluxd terminal new --cmd "<命令>" [--title "<标题>"]
|
|
859
|
+
开一个真实终端跑命令,用户在 coflux 侧栏能看到并随时接管
|
|
860
|
+
cofluxd terminal list 列出本工作区的终端(含 status / 退出码)
|
|
861
|
+
cofluxd terminal read <taskId> [--lines N]
|
|
862
|
+
读某个终端的内容(纯文本,默认最后 200 行;终端已退出也能读)
|
|
863
|
+
cofluxd notify "<一句话>" 叫人:工作区在侧栏转为「等待交互」并显示这句话
|
|
864
|
+
cofluxd ports 列出本工作区的监听端口及可直接打开的预览 URL
|
|
865
|
+
|
|
762
866
|
up flags: --server <ws://.../daemon> --name <名> --shell <路径>
|
|
763
867
|
通用: --version <vX|latest>(不传时 up 沿用已有二进制,update 默认 latest) --bin-dir <dir>(用本地 cargo 产物) --no-start
|
|
764
868
|
配置都在 ~/.coflux/settings.json(serverUrl/deviceName/shell),daemon 直接读;改后重跑 cofluxd up 生效。`;
|
|
@@ -776,6 +880,9 @@ const { values, positionals } = parseArgs({
|
|
|
776
880
|
server: { type: "string" },
|
|
777
881
|
name: { type: "string" },
|
|
778
882
|
shell: { type: "string" },
|
|
883
|
+
title: { type: "string" },
|
|
884
|
+
cmd: { type: "string" },
|
|
885
|
+
lines: { type: "string" },
|
|
779
886
|
version: { type: "string" },
|
|
780
887
|
"bin-dir": { type: "string" },
|
|
781
888
|
"no-start": { type: "boolean", default: false },
|
|
@@ -789,7 +896,7 @@ let cmd = positionals[0];
|
|
|
789
896
|
if (values.help || cmd === "help") { console.log(HELP); process.exit(0); }
|
|
790
897
|
if (!cmd) cmd = fs.existsSync(SETTINGS) ? "status" : "up"; // 首次裸跑 → 引导
|
|
791
898
|
|
|
792
|
-
const handlers = { up: cmdUp, update: cmdUpdate, restart: cmdRestart, down: cmdDown, status: cmdStatus, doctor: cmdDoctor, fda: cmdFda, logs: cmdLogs, uninstall: cmdUninstall, hook: cmdHook };
|
|
899
|
+
const handlers = { up: cmdUp, update: cmdUpdate, restart: cmdRestart, down: cmdDown, status: cmdStatus, doctor: cmdDoctor, fda: cmdFda, logs: cmdLogs, uninstall: cmdUninstall, hook: cmdHook, terminal: cmdTerminal, notify: cmdNotify, ports: cmdPorts };
|
|
793
900
|
const h = handlers[cmd];
|
|
794
901
|
if (!h) die(`未知命令: ${cmd}${MIGRATED[cmd] ? `\n${MIGRATED[cmd]}` : ""}\n\n${HELP}`);
|
|
795
902
|
await h(values);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cofluxd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "coflux daemon 管理 CLI:装/起/停/升级 Rust daemon(supervisor + worker)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"cofluxd.mjs",
|
|
11
|
-
"README.md"
|
|
11
|
+
"README.md",
|
|
12
|
+
"skills"
|
|
12
13
|
],
|
|
13
14
|
"engines": {
|
|
14
15
|
"node": ">=20"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: coflux
|
|
3
|
+
description: 当你运行在 coflux 终端里时,把长任务、并行工作和求助外化成用户在 coflux web/手机上看得见、能随时接管的真实终端。适用于跑测试/构建/dev server 等耗时命令、需要用户接管或决策、想给用户一个可点开的预览 URL 的场景。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 在 coflux 里工作
|
|
7
|
+
|
|
8
|
+
你可能正跑在 coflux 的一个终端里。coflux 让用户在浏览器和手机上盯着各台机器上的
|
|
9
|
+
agent 干活,随时接管。这套命令让你把自己的工作**变成用户看得见的东西**。
|
|
10
|
+
|
|
11
|
+
先判断自己在不在 coflux 里:任意一条命令返回「不在 coflux 终端里」就说明不在,
|
|
12
|
+
这时忘掉这个 skill,照常用你自己的工具。
|
|
13
|
+
|
|
14
|
+
## 什么时候用
|
|
15
|
+
|
|
16
|
+
**用 `cofluxd terminal new` 而不是自己后台起进程**——只要这条命令满足任一条:
|
|
17
|
+
|
|
18
|
+
- 要跑超过十几秒(测试、构建、安装依赖、迁移)
|
|
19
|
+
- 会一直跑下去(dev server、watch、日志跟随)
|
|
20
|
+
- 用户可能想接管(需要交互、可能要中途叫停、失败了要人去调)
|
|
21
|
+
|
|
22
|
+
这类工作在你自己的 Bash 里后台跑,用户**什么也看不见**:看不到它在跑、接管不了、
|
|
23
|
+
出问题只能等你转述。开成 coflux 终端,它就是侧栏里一个有标题的条目,用户能点进去、
|
|
24
|
+
能接管、能自己敲命令。
|
|
25
|
+
|
|
26
|
+
**不要用**在一次性的快命令上(`ls`、`grep`、`git status`、读文件)——你自己的工具更快,
|
|
27
|
+
给用户开一堆一秒就结束的终端只是噪音。
|
|
28
|
+
|
|
29
|
+
## 命令
|
|
30
|
+
|
|
31
|
+
### 开终端跑命令
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
cofluxd terminal new --title "跑单测" --cmd "pnpm -C tests test"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`--title` 是用户在侧栏看到的名字,**认真起**:写「跑单测」「起 dev server」,
|
|
38
|
+
别写「terminal 1」。命令在当前工作区目录下、用登录 shell 执行。
|
|
39
|
+
|
|
40
|
+
命令跑完终端就退出,任务转 `exited` 并带上退出码——这是你判断成没成的依据。
|
|
41
|
+
所以别指望在同一个终端里接着跑第二条命令,要么写成 `a && b`,要么再开一个。
|
|
42
|
+
|
|
43
|
+
命令的输出会同时落一份日志供你回读,代价是它的 stdout 是管道而不是 tty——多数程序会因此
|
|
44
|
+
关掉颜色和进度条。极少数程序在非 tty 下行为不同(比如不输出进度、切成 CI 模式),如果你
|
|
45
|
+
依赖那种行为,自己在 Bash 里跑。
|
|
46
|
+
|
|
47
|
+
### 看跑到哪了
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
cofluxd terminal list # 本工作区所有终端:id、状态、退出码、标题
|
|
51
|
+
cofluxd terminal read <taskId> # 某个终端的内容(纯文本,默认最后 200 行)
|
|
52
|
+
cofluxd terminal read <taskId> --lines 50
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`list` 的状态是 `running` / `exited` / `idle`;`exited` 会带 `exit=<码>`。
|
|
56
|
+
**终端已经退出也能 read**——「命令跑完了看输出」正是最常用的场景。
|
|
57
|
+
|
|
58
|
+
内容有最多约 2 秒的延迟(来自中心的定期快照),所以刚 `new` 完立刻 `read` 可能是空的。
|
|
59
|
+
要等一条命令跑完,隔几秒 `list` 一次看它转没转 `exited`,别忙等。
|
|
60
|
+
|
|
61
|
+
### 叫人
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
cofluxd notify "两个方案都能走通,需要你定一下用哪个"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
用户的侧栏里这个工作区会转成「等待交互」并显示这句话——他在手机上也看得到。
|
|
68
|
+
用在你**真的卡住**的时候:需要用户决策、要密码/权限、发现了必须人来判断的问题。
|
|
69
|
+
一句话说清要什么,别写成日志。
|
|
70
|
+
|
|
71
|
+
(你正常的提问和权限请求已经会自动反映到侧栏状态上,不需要额外 notify。
|
|
72
|
+
这条是给「你要说的事,用户光看状态图标猜不出来」准备的。)
|
|
73
|
+
|
|
74
|
+
### 给用户可点开的预览
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
cofluxd ports
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
列出本工作区所有监听端口和对应的公网预览 URL。起了 dev server 之后用它拿 URL 直接
|
|
81
|
+
告诉用户,他点开就能看,不用自己去翻。
|
|
82
|
+
|
|
83
|
+
## 边界
|
|
84
|
+
|
|
85
|
+
- 你**只能开和读**,不能往别人的终端里打字。要交互就 `notify` 让用户接管。
|
|
86
|
+
- 能看到的只有**你自己所在的工作区**,别的工作区和别的机器都看不见也碰不到。
|
|
87
|
+
- 一个工作区同时活着的终端有上限(默认 8,含用户自己开的)。撞上限先 `list` 看看,
|
|
88
|
+
多半是有跑完没收的;真是用户占满了,就 `notify` 告诉他,别硬试。
|
|
89
|
+
- 这些命令都要 daemon 连着中心才能用——毕竟「让用户看见」就是它们的全部意义。
|
|
90
|
+
连不上时会明确报错,不会默默降级。
|