@iamlbccc/tdxd 1.1.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/lib/ui.mjs ADDED
@@ -0,0 +1,102 @@
1
+ // 零依赖交互式提示(跨平台):ask / confirm / select
2
+ // TTY:方向键选择;非 TTY(管道/CI):自动降级为编号输入
3
+ import readline from "node:readline";
4
+ import readlinePromises from "node:readline/promises";
5
+ import { once } from "node:events";
6
+ import process from "node:process";
7
+
8
+ const isTTY = process.stdin.isTTY && process.stdout.isTTY;
9
+ const C = { cyan: (s) => `\x1b[36m${s}\x1b[0m`, green: (s) => `\x1b[32m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m` };
10
+
11
+ export function out(s = "") { process.stdout.write(s + "\n"); }
12
+
13
+ // TTY:共享单个 readline 逐问读取
14
+ let sharedRl = null;
15
+ function getRl() {
16
+ return sharedRl ??= readlinePromises.createInterface({ input: process.stdin, output: process.stdout });
17
+ }
18
+ export function closePrompt() { sharedRl?.close(); sharedRl = null; }
19
+
20
+ // 非TTY(管道/CI):readline 管道模式在首个应答后会暂停输入流,顺序提问必挂;
21
+ // 改为启动即挂常驻 line 监听,一次性收完全部输入,提问从队列按序取
22
+ let lineQueue = null;
23
+ let queueReady = null;
24
+ function warmupNonTty() {
25
+ if (!queueReady) {
26
+ const lines = [];
27
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
28
+ rl.on("line", (l) => lines.push(l));
29
+ queueReady = once(rl, "close").then(() => { lineQueue = lines; });
30
+ }
31
+ return queueReady;
32
+ }
33
+
34
+ async function question(prompt) {
35
+ if (!isTTY) {
36
+ process.stdout.write(prompt);
37
+ await warmupNonTty();
38
+ return (lineQueue.shift() ?? "").trim();
39
+ }
40
+ const rl = getRl();
41
+ try { return ((await rl.question(prompt)) ?? "").trim(); }
42
+ catch { return ""; }
43
+ }
44
+
45
+ export async function ask(message, def = "") {
46
+ const suffix = def !== "" && def != null ? C.dim(` (${def})`) : "";
47
+ const v = await question(`${C.cyan("?")} ${message}${suffix}: `);
48
+ return v === "" ? String(def ?? "") : v;
49
+ }
50
+
51
+ export async function confirm(message, def = true) {
52
+ const hint = def ? "Y/n" : "y/N";
53
+ const v = (await question(`${C.cyan("?")} ${message} ${C.dim(`(${hint})`)}: `)).toLowerCase();
54
+ if (v === "") return def;
55
+ return v.startsWith("y");
56
+ }
57
+
58
+ /** 选项选择:TTY 用 ↑↓+回车;非 TTY 用编号。返回 { value, label } */
59
+ export async function select(message, options, defIndex = 0) {
60
+ if (!isTTY) {
61
+ out(`${C.cyan("?")} ${message}`);
62
+ options.forEach((o, i) => out(` ${C.bold(i + 1)}. ${o.label}${o.description ? C.dim(` — ${o.description}`) : ""}`));
63
+ const v = await question(` 输入编号 ${C.dim(`[1-${options.length}, 默认 ${defIndex + 1}]`)}: `);
64
+ const i = Number(v) - 1;
65
+ return options[Number.isInteger(i) && i >= 0 && i < options.length ? i : defIndex];
66
+ }
67
+ return new Promise((resolve) => {
68
+ let idx = defIndex;
69
+ const render = () => {
70
+ readline.cursorTo(process.stdout, 0, 0);
71
+ readline.clearScreenDown(process.stdout);
72
+ out(`${C.cyan("?")} ${message} ${C.dim("(↑↓ 选择, 回车确认)")}`);
73
+ options.forEach((o, i) => out(` ${i === idx ? C.green("❯") : " "} ${i === idx ? C.green(o.label) : o.label}${o.description ? C.dim(` — ${o.description}`) : ""}`));
74
+ };
75
+ readline.emitKeypressEvents(process.stdin);
76
+ process.stdin.setRawMode(true);
77
+ const rawCols = process.stdout.columns;
78
+ process.stdout.rows = options.length + 2;
79
+ if (!rawCols) process.stdout.columns = 80;
80
+ render();
81
+ process.stdin.resume();
82
+ const onData = (_, key) => {
83
+ if (key.name === "up") { idx = (idx - 1 + options.length) % options.length; render(); }
84
+ else if (key.name === "down") { idx = (idx + 1) % options.length; render(); }
85
+ else if (key.name === "return" || key.name === "space") {
86
+ cleanup(); resolve(options[idx]);
87
+ } else if (key.name === "c" && key.ctrl) {
88
+ cleanup(); process.exit(130);
89
+ } else if (key.sequence === "3") { /* 忽略 */ }
90
+ };
91
+ const origCols = rawCols;
92
+ function cleanup() {
93
+ process.stdin.removeListener("keypress", onData);
94
+ process.stdin.setRawMode(false);
95
+ process.stdin.pause();
96
+ process.stdout.columns = origCols;
97
+ readline.cursorTo(process.stdout, 0, options.length + 1);
98
+ out(C.dim(` 已选: ${options[idx].label}`));
99
+ }
100
+ process.stdin.on("keypress", onData);
101
+ });
102
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@iamlbccc/tdxd",
3
+ "version": "1.1.0",
4
+ "description": "TDX-Daemon — remote daemon for Paperclip TDX-Adapter: drives ACP agents (opencode) with session-per-process, permission approval loop, usage metering; ships cross-platform tdxd-ctl (init/scaffold/start/stop/status)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=24"
9
+ },
10
+ "bin": {
11
+ "tdxd": "./tdxd.mjs",
12
+ "tdxd-ctl": "./tdxd-ctl.mjs"
13
+ },
14
+ "files": [
15
+ "tdxd.mjs",
16
+ "tdxd-ctl.mjs",
17
+ "lib/",
18
+ "config.example.json",
19
+ "README.md"
20
+ ],
21
+ "keywords": [
22
+ "paperclip",
23
+ "acp",
24
+ "agent",
25
+ "opencode",
26
+ "daemon",
27
+ "cli"
28
+ ]
29
+ }
package/tdxd-ctl.mjs ADDED
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env node
2
+ // tdxd-ctl(Node 跨平台版)—— TDX-Daemon 实例管理器
3
+ // 布局(agent-first):$TDXD_HOME/agents/<name>/{config.json, secret.env, log/tdxd.log, run/pid}
4
+ // 命令:init | list | start | stop | restart | status [name|all]
5
+ // init 支持交互式(TTY 方向键选择);非 TTY / --yes 降级为默认值,可脚本化
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import os from "node:os";
9
+ import crypto from "node:crypto";
10
+ import { spawn } from "node:child_process";
11
+ import { fileURLToPath } from "node:url";
12
+ import { ask, confirm, select, out, closePrompt } from "./lib/ui.mjs";
13
+ import { configTpl, opencodeJsonTpl, agentsMdTpl } from "./lib/tpl.mjs";
14
+
15
+ const HOME = process.env.TDXD_HOME || path.join(os.homedir(), ".tdx");
16
+ const AGENTS_DIR = path.join(HOME, "agents");
17
+ const DAEMON_BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), "tdxd.mjs");
18
+
19
+ const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
20
+
21
+ // ---------- 基础设施 ----------
22
+ function listAgents() {
23
+ if (!fs.existsSync(AGENTS_DIR)) return [];
24
+ return fs.readdirSync(AGENTS_DIR)
25
+ .map((name) => {
26
+ const dir = path.join(AGENTS_DIR, name);
27
+ const cfgPath = path.join(dir, "config.json");
28
+ if (!fs.statSync(dir).isDirectory() || !fs.existsSync(cfgPath)) return null;
29
+ try { return { name, dir, cfgPath, cfg: JSON.parse(fs.readFileSync(cfgPath, "utf8")) }; }
30
+ catch { return { name, dir, cfgPath, cfg: null }; }
31
+ })
32
+ .filter(Boolean);
33
+ }
34
+
35
+ function pick(target) {
36
+ const agents = listAgents();
37
+ if (agents.length === 0) { out(`[ctl] 无实例(${AGENTS_DIR}/agents? 查 ${AGENTS_DIR})`); process.exit(0); }
38
+ if (target === "all") return agents;
39
+ const a = agents.find((x) => x.name === target);
40
+ if (!a) { out(`[ctl] ✗ 未找到实例 "${target}"。现有: ${agents.map((x) => x.name).join(", ")}`); process.exit(1); }
41
+ return [a];
42
+ }
43
+
44
+ function readSecret(dir) {
45
+ const f = path.join(dir, "secret.env");
46
+ const sec = {};
47
+ if (fs.existsSync(f)) {
48
+ for (const line of fs.readFileSync(f, "utf8").split("\n")) {
49
+ const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
50
+ if (m) sec[m[1]] = m[2];
51
+ }
52
+ }
53
+ return sec;
54
+ }
55
+
56
+ function pidFile(a) { return path.join(a.dir, "run", "pid"); }
57
+
58
+ function pidOf(a) {
59
+ try {
60
+ const pid = Number(fs.readFileSync(pidFile(a), "utf8").trim());
61
+ if (!Number.isInteger(pid) || pid <= 0) return null;
62
+ process.kill(pid, 0); // 存活探测(权限/不存在都会 throw)
63
+ return pid;
64
+ } catch (e) { if (e.code === "EPERM") return Number(fs.readFileSync(pidFile(a), "utf8")); return null; }
65
+ }
66
+
67
+ async function healthOf(a, timeoutMs = 2500) {
68
+ const sec = readSecret(a.dir);
69
+ if (!sec.TDXD_TOKEN || !a.cfg?.port) return { code: 0, err: "缺 token/port" };
70
+ try {
71
+ const r = await fetch(`http://127.0.0.1:${a.cfg.port}/health`, {
72
+ headers: { authorization: `Bearer ${sec.TDXD_TOKEN}` },
73
+ signal: AbortSignal.timeout(timeoutMs),
74
+ });
75
+ const j = await r.json().catch(() => null);
76
+ return { code: r.status, json: j };
77
+ } catch { return { code: 0, err: "不可达" }; }
78
+ }
79
+
80
+ function lanIp() {
81
+ for (const ifaces of Object.values(os.networkInterfaces())) {
82
+ for (const i of ifaces ?? []) {
83
+ if (i.family === "IPv4" && !i.internal) return i.address;
84
+ }
85
+ }
86
+ return "127.0.0.1";
87
+ }
88
+
89
+ function suggestPort() {
90
+ const used = new Set(listAgents().map((a) => a.cfg?.port).filter(Boolean));
91
+ for (let p = 18200; p < 18300; p++) if (!used.has(p)) return p;
92
+ return 18300;
93
+ }
94
+
95
+ // ---------- init ----------
96
+ async function cmdInit(flags, positionalName) {
97
+ out();
98
+ out("\x1b[1mTDX-Daemon Agent 初始化\x1b[0m");
99
+ out("\x1b[2m──────────────────────────\x1b[0m");
100
+ const y = flags.yes;
101
+
102
+ let name = flags.name || positionalName;
103
+ if (!name && !y) name = await ask("Agent 标识(slug,小写字母/数字/-)");
104
+ if (!name) { out("[ctl] ✗ 缺 name(--name 或位置参数,或交互输入)"); process.exit(1); }
105
+ if (!NAME_RE.test(name)) { out(`[ctl] ✗ 非法 name "${name}"(须匹配 ${NAME_RE})`); process.exit(1); }
106
+ const dir = path.join(AGENTS_DIR, name);
107
+ if (fs.existsSync(path.join(dir, "config.json")) && !flags.force) {
108
+ out(`[ctl] ✗ 实例已存在:${dir}(--force 覆盖)`); process.exit(1);
109
+ }
110
+
111
+ const displayName = flags.display || (y ? name : await ask("显示名", name));
112
+ const persona = flags.persona || (y ? "coder" : (await select("角色模板", [
113
+ { value: "coder", label: "开发工程师", description: "读码-改动-验证-汇报" },
114
+ { value: "manager", label: "团队负责人", description: "拆任务/分派/跟踪/汇总" },
115
+ { value: "qa", label: "测试工程师", description: "用例设计/如实报告" },
116
+ { value: "blank", label: "空白模板", description: "自己写 AGENTS.md" },
117
+ ])).value);
118
+ const model = flags.model || (y ? "kimi-for-coding/kimi-for-coding" : await ask("模型(provider/model)", "kimi-for-coding/kimi-for-coding"));
119
+ const homeWs = path.join(os.homedir(), "workspaces", name);
120
+ const workspace = path.resolve(flags.workspace || (y ? homeWs : await ask("workspace 路径", homeWs)));
121
+ const port = Number(flags.port || (y ? suggestPort() : Number(await ask("端口", String(suggestPort())))));
122
+ const url = flags.url || (y ? "http://127.0.0.1:3100" : await ask("Paperclip 平台地址", "http://127.0.0.1:3100"));
123
+
124
+ let token = flags.token;
125
+ if (!token) {
126
+ const auto = y ? true : await confirm("自动生成共享 token?", true);
127
+ token = auto ? `tok-${crypto.randomBytes(16).toString("hex")}` : await ask("自定义 token");
128
+ if (!token) { out("[ctl] ✗ token 不能为空"); process.exit(1); }
129
+ }
130
+
131
+ // 落盘
132
+ fs.mkdirSync(path.join(dir, "log"), { recursive: true });
133
+ fs.mkdirSync(path.join(dir, "run"), { recursive: true });
134
+ fs.writeFileSync(path.join(dir, "config.json"), configTpl({ name, displayName, port, workspace, model, url }));
135
+ fs.writeFileSync(path.join(dir, "secret.env"), `TDXD_TOKEN=${token}\n# 平台回调用 agent key(UI 铸造后填入,选填)\n# TDXD_PAPERCLIP_KEY=pcp_xxx\n`, { mode: 0o600 });
136
+ fs.mkdirSync(path.join(workspace, ".opencode"), { recursive: true });
137
+ const oc = path.join(workspace, ".opencode", "opencode.json");
138
+ if (!fs.existsSync(oc)) fs.writeFileSync(oc, opencodeJsonTpl(model));
139
+ const am = path.join(workspace, "AGENTS.md");
140
+ if (!fs.existsSync(am)) fs.writeFileSync(am, agentsMdTpl(persona, displayName));
141
+
142
+ out();
143
+ out("\x1b[32m✓ 实例就绪\x1b[0m");
144
+ out(` ${dir}`);
145
+ out(` ${workspace}`);
146
+ out();
147
+ out("\x1b[1m接下来:\x1b[0m");
148
+ out(` 1. 启动并自检: \x1b[36mtdxd-ctl start ${name} && tdxd-ctl status ${name}\x1b[0m`);
149
+ out(" 2. 平台 UI hire Agent(TDX-Adapter),粘贴以下参数:");
150
+ out(` adapterType: tdx_adapter`);
151
+ out(` endpoint: http://${lanIp()}:${port}`);
152
+ out(` \x1b[33mtoken: ${token}\x1b[0m \x1b[2m(明文只显示这一次)\x1b[0m`);
153
+ out(` 3. 验收三步: /health 探活 → 真实唤醒一轮 → 第二轮日志确认 resume=true`);
154
+ }
155
+
156
+ // ---------- 生命周期 ----------
157
+ function startOne(a) {
158
+ const pid = pidOf(a);
159
+ if (pid) {
160
+ out(`[ctl] ${a.name} 已在运行(pid ${pid})`);
161
+ return "running";
162
+ }
163
+ const sec = readSecret(a.dir);
164
+ if (!sec.TDXD_TOKEN) { out(`[ctl] ✗ ${a.name} 缺 secret.env TDXD_TOKEN`); return false; }
165
+ if (!fs.existsSync(a.cfg.workspace)) { out(`[ctl] ✗ ${a.name} workspace 不存在: ${a.cfg.workspace}`); return false; }
166
+ fs.mkdirSync(path.dirname(pidFile(a)), { recursive: true });
167
+ const logFd = fs.openSync(path.join(a.dir, "log", "tdxd.log"), "a");
168
+ const child = spawn(process.execPath, [DAEMON_BIN, "--config", a.cfgPath], {
169
+ detached: true, stdio: ["ignore", logFd, logFd], windowsHide: true,
170
+ env: { ...process.env, ...sec },
171
+ });
172
+ child.unref();
173
+ fs.writeFileSync(pidFile(a), String(child.pid));
174
+ return "started";
175
+ }
176
+
177
+ async function waitHealth(a, ms = 15000) {
178
+ const t0 = Date.now();
179
+ while (Date.now() - t0 < ms) {
180
+ const h = await healthOf(a);
181
+ if (h.code === 200) return h;
182
+ await new Promise((r) => setTimeout(r, 800));
183
+ }
184
+ return { code: 0 };
185
+ }
186
+
187
+ async function stopOneAsync(a) {
188
+ const pid = pidOf(a);
189
+ if (!pid) { out(`[ctl] ${a.name} 无 pid 记录(未运行或由旧方式启动)`); return; }
190
+ try { process.kill(pid, "SIGTERM"); } catch { /* 已退 */ }
191
+ for (let i = 0; i < 25; i++) {
192
+ try { process.kill(pid, 0); } catch { break; }
193
+ await new Promise((r) => setTimeout(r, 200));
194
+ }
195
+ try { process.kill(pid, 0); process.kill(pid, "SIGKILL"); } catch { /* 已退 */ }
196
+ try { fs.unlinkSync(pidFile(a)); } catch { /* 无 */ }
197
+ out(`[ctl] ✓ ${a.name} 已停止`);
198
+ }
199
+
200
+ async function cmdStart(target) {
201
+ let ok = true;
202
+ for (const a of pick(target)) {
203
+ const r = startOne(a);
204
+ if (r === "running") continue;
205
+ if (!r) { ok = false; continue; }
206
+ const h = await waitHealth(a);
207
+ if (h.code === 200) out(`[ctl] ✓ ${a.name} 启动成功 :${a.cfg.port}(日志 ${path.join(a.dir, "log", "tdxd.log")})`);
208
+ else { out(`[ctl] ✗ ${a.name} 健康检查未过(health=${h.code}),查日志: tail -20 ${path.join(a.dir, "log", "tdxd.log")}`); ok = false; }
209
+ }
210
+ if (!ok) process.exitCode = 1;
211
+ }
212
+
213
+ async function cmdRestart(target) {
214
+ for (const a of pick(target)) await stopOneAsync(a);
215
+ await cmdStart(target);
216
+ }
217
+
218
+ async function cmdStatus(target) {
219
+ for (const a of pick(target)) {
220
+ const pid = pidOf(a);
221
+ const h = await healthOf(a, 2000);
222
+ const sec = readSecret(a.dir);
223
+ const cb = sec.TDXD_PAPERCLIP_KEY ? "回调:启用" : "回调:未配置⚠";
224
+ const run = pid ? `运行(pid ${pid})` : "停";
225
+ const ver = h.json?.version ? ` v${h.json.version}` : "";
226
+ out(`${a.name}: ${run} | :${a.cfg?.port ?? "?"} health=${h.code}${ver} | ${cb}`);
227
+ }
228
+ }
229
+
230
+ // ---------- 入口 ----------
231
+ function parseFlags(argv) {
232
+ const flags = {};
233
+ for (let i = 0; i < argv.length; i++) {
234
+ if (!argv[i].startsWith("--")) continue;
235
+ const k = argv[i].slice(2);
236
+ const boolOnly = ["yes", "force"];
237
+ if (boolOnly.includes(k)) flags[k] = true;
238
+ else flags[k] = argv[i + 1]?.startsWith("--") ? true : argv[++i];
239
+ }
240
+ return flags;
241
+ }
242
+
243
+ const [cmd = "status", targetArg, ...rest] = process.argv.slice(2);
244
+ const target = targetArg ?? "all"; // init 场景须用 targetArg(可能为 undefined),不能吃这个默认值
245
+ const flags = parseFlags(rest);
246
+ switch (cmd) {
247
+ case "init": try { await cmdInit(flags, targetArg); } finally { closePrompt(); } break;
248
+ case "list": for (const a of listAgents()) out(`${a.name}\t:${a.cfg?.port}\t${a.cfg?.workspace}`); break;
249
+ case "start": await cmdStart(target); break;
250
+ case "stop": for (const a of pick(target)) await stopOneAsync(a); break;
251
+ case "restart": await cmdRestart(target); break;
252
+ case "status": await cmdStatus(target); break;
253
+ default:
254
+ out("用法: tdxd-ctl init|list|start|stop|restart|status [name|all] [--yes --name --display --workspace --model --persona --port --token --url --force]");
255
+ out(`实例目录: ${AGENTS_DIR}`);
256
+ process.exit(1);
257
+ }