@iamlbccc/tdxd 1.1.1 → 1.2.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/tdxd-ctl.mjs CHANGED
@@ -6,11 +6,12 @@
6
6
  import fs from "node:fs";
7
7
  import path from "node:path";
8
8
  import os from "node:os";
9
+ import net from "node:net";
9
10
  import crypto from "node:crypto";
10
11
  import { spawn } from "node:child_process";
11
12
  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";
13
+ import { out, ok, fail, warn, note, head, cell, table, tGreen, tRed, tYellow, tDim, tCyan, tBold, dispWidth } from "./lib/ui.mjs";
14
+ import { configTpl, detectModel } from "./lib/tpl.mjs";
14
15
 
15
16
  const HOME = process.env.TDXD_HOME || path.join(os.homedir(), ".tdx");
16
17
  const AGENTS_DIR = path.join(HOME, "agents");
@@ -34,10 +35,10 @@ function listAgents() {
34
35
 
35
36
  function pick(target) {
36
37
  const agents = listAgents();
37
- if (agents.length === 0) { out(`[ctl] 无实例(查 ${AGENTS_DIR}/;初始化用 tdxd-ctl init <name>)`); process.exit(0); }
38
+ if (agents.length === 0) { warn(`无实例(查 ${AGENTS_DIR}/;初始化用 tdxd-ctl init <name>)`); process.exit(0); }
38
39
  if (target === "all") return agents;
39
40
  const a = agents.find((x) => x.name === target);
40
- if (!a) { out(`[ctl] ✗ 未找到实例 "${target}"。现有: ${agents.map((x) => x.name).join(", ")}`); process.exit(1); }
41
+ if (!a) { fail(`未找到实例 "${target}"。现有: ${agents.map((x) => x.name).join(", ")}`); process.exit(1); }
41
42
  return [a];
42
43
  }
43
44
 
@@ -86,83 +87,328 @@ function lanIp() {
86
87
  return "127.0.0.1";
87
88
  }
88
89
 
89
- function suggestPort() {
90
+ /** shell 式 ~ 展开(Node 不会自动做;交互输入与 flags 均经过此处) */
91
+ function expandHome(p) {
92
+ if (p === "~") return os.homedir();
93
+ if (p.startsWith("~/") || p.startsWith("~\\")) return path.join(os.homedir(), p.slice(2));
94
+ return p;
95
+ }
96
+
97
+ /** 实际绑定探测:防"孤儿 daemon(目录已删进程还在)"占用导致 start 阶段 EADDRINUSE */
98
+ function canBind(port, host = "0.0.0.0") {
99
+ return new Promise((res) => {
100
+ const srv = net.createServer();
101
+ srv.once("error", () => res(false));
102
+ srv.once("listening", () => srv.close(() => res(true)));
103
+ srv.listen(port, host);
104
+ });
105
+ }
106
+
107
+ async function suggestPort() {
90
108
  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;
109
+ for (let p = 18200; p < 18300; p++) if (!used.has(p) && (await canBind(p))) return p;
92
110
  return 18300;
93
111
  }
94
112
 
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); }
113
+ // ---------- skills(快照随包内置,发布时从上游 paperclip 仓库 vendor;重复 --add = 覆盖升级) ----------
114
+ const SKILLS_SRC = path.join(path.dirname(fileURLToPath(import.meta.url)), "skills");
115
+
116
+ function wsSkillsDir(a) { return path.join(a.cfg.workspace, ".opencode", "skills"); }
117
+
118
+ function doAddSkill(a, name) {
119
+ const bundled = fs.existsSync(SKILLS_SRC) ? fs.readdirSync(SKILLS_SRC).filter((n) => fs.existsSync(path.join(SKILLS_SRC, n, "SKILL.md"))) : [];
120
+ if (!bundled.includes(name)) {
121
+ fail(`无内置 skill "${name}"。可选: ${bundled.join(", ")}`);
122
+ return false;
123
+ }
124
+ const dst = path.join(wsSkillsDir(a), name);
125
+ fs.mkdirSync(wsSkillsDir(a), { recursive: true });
126
+ fs.rmSync(dst, { recursive: true, force: true });
127
+ fs.cpSync(path.join(SKILLS_SRC, name), dst, { recursive: true });
128
+ return true; // 静默:成功输出由调用方决定
129
+ }
130
+
131
+ function cmdSkills(target, flags) {
132
+ const [a] = pick(target);
133
+ if (!a) return;
134
+ const bundled = fs.existsSync(SKILLS_SRC) ? fs.readdirSync(SKILLS_SRC).filter((n) => fs.existsSync(path.join(SKILLS_SRC, n, "SKILL.md"))) : [];
135
+ const installedDir = wsSkillsDir(a);
136
+ const installed = fs.existsSync(installedDir) ? fs.readdirSync(installedDir).filter((n) => fs.existsSync(path.join(installedDir, n, "SKILL.md"))) : [];
137
+
138
+ if (flags.rm) {
139
+ const d = path.join(installedDir, flags.rm);
140
+ if (!fs.existsSync(d)) { fail(`未安装 skill "${flags.rm}"`); process.exitCode = 1; return; }
141
+ fs.rmSync(d, { recursive: true, force: true });
142
+ ok(`已移除 ${flags.rm} ${tDim(d)}`);
143
+ return;
144
+ }
145
+
146
+ if (flags.add) {
147
+ if (doAddSkill(a, flags.add)) {
148
+ note(`新装 skill 由 opencode 下次会话加载;首问如未生效请 tdxd-ctl restart ${a.name}`);
149
+ } else process.exitCode = 1;
150
+ return;
151
+ }
152
+
153
+ out(`${tBold(a.name)} 的 skills ${tDim(installedDir)}`);
154
+ const all = [...new Set([...installed, ...bundled])];
155
+ if (all.length === 0) { warn("无已装,包内也无内置——异常,请重装 tdxd"); return; }
156
+ table(["SKILL", "状态", "说明"], all.map((s) => [
157
+ cell(s, s === "paperclip" ? "cyan" : undefined),
158
+ cell(installed.includes(s) ? "已装" : "未装", installed.includes(s) ? "green" : "dim"),
159
+ cell(s === "paperclip" ? "核心:平台心跳能力(checkout→干活→PATCH),必装" : (s === "paperclip-converting-plans-to-tasks" ? "计划→子任务→分派" : "(手工放置)"), "dim"),
160
+ ]));
161
+ note(`操作:tdxd-ctl skills ${a.name} --add <skill> | --rm <skill>`);
162
+ }
163
+
164
+ // ---------- key(平台铸造的 agent key 回填,供 daemon 兜底回调) ----------
165
+ function writeAgentKey(dir, key) {
166
+ const f = path.join(dir, "secret.env");
167
+ const lines = fs.existsSync(f) ? fs.readFileSync(f, "utf8").split("\n") : [];
168
+ const kept = lines.filter((l) => !/^TDXD_PAPERCLIP_KEY=/.test(l) && !l.startsWith("# 平台回调") && !/^#\s*TDXD_PAPERCLIP_KEY=/.test(l));
169
+ if (key) kept.splice(1, 0, `TDXD_PAPERCLIP_KEY=${key}`); // 紧跟 token 行
170
+ fs.writeFileSync(f, kept.filter((l, i, arr) => l !== "" || i === arr.length - 1).join("\n").replace(/\n*$/, "\n"), { mode: 0o600 });
171
+ return f;
172
+ }
173
+
174
+ function cmdKey(target, flags) {
175
+ const [a] = pick(target);
176
+ if (!a) return;
177
+ if (!flags.set && !flags.rm) {
178
+ fail("缺参数:--set <pcp_xxx> 或 --rm(AI/脚本调用,无交互)");
179
+ process.exit(1);
180
+ }
181
+ const f = writeAgentKey(a.dir, flags.rm ? "" : flags.set);
182
+ if (flags.rm) { ok(`已移除 ${a.name} 的 agent key(回调将降级为未配置)`); }
183
+ else { ok(`已写入 agent key → ${tDim(f)}`); }
184
+ note(`重启生效:tdxd-ctl restart ${a.name}(status 应显示 回调:启用)`);
185
+ }
186
+
187
+ // ---------- init 核心(cmdInit 与 onboard 共用;纯参数化,无交互无输出副作用) ----------
188
+ async function initCore(flags, name) {
189
+ if (!name) { fail("缺 name:--name <slug> 或位置参数(无交互模式)"); process.exit(1); }
190
+ if (!NAME_RE.test(name)) { fail(`非法 name "${name}"(须匹配 ${NAME_RE})`); process.exit(1); }
106
191
  const dir = path.join(AGENTS_DIR, name);
107
192
  if (fs.existsSync(path.join(dir, "config.json")) && !flags.force) {
108
- out(`[ctl] ✗ 实例已存在:${dir}(--force 覆盖)`); process.exit(1);
193
+ fail(`实例已存在:${dir}(--force 覆盖)`); process.exit(1);
109
194
  }
110
195
 
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"));
196
+ const displayName = flags.display || name;
119
197
  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
- }
198
+ const workspace = path.resolve(expandHome(flags.workspace || homeWs));
199
+ let port;
200
+ if (flags.port) {
201
+ port = Number(flags.port);
202
+ if (!(await canBind(port))) warn(`端口 ${port} 当前被占用(可能孤儿进程),start 阶段可能失败`);
203
+ } else port = await suggestPort();
204
+ const url = flags.url || "http://127.0.0.1:3100";
205
+ const clash = listAgents().find((x) => x.name !== name && x.cfg?.port === port);
206
+ if (clash) warn(`端口 ${port} 与实例 "${clash.name}" 冲突,同机共存将互相干扰`);
207
+
208
+ const token = flags.token || `tok-${crypto.randomBytes(16).toString("hex")}`;
209
+ const model = detectModel(workspace);
210
+ const wsExists = fs.existsSync(workspace);
130
211
 
131
- // 落盘
132
212
  fs.mkdirSync(path.join(dir, "log"), { recursive: true });
133
213
  fs.mkdirSync(path.join(dir, "run"), { recursive: true });
134
- fs.writeFileSync(path.join(dir, "config.json"), configTpl({ name, displayName, port, workspace, model, url }));
214
+ fs.writeFileSync(path.join(dir, "config.json"), configTpl({ displayName, port, workspace, url }));
135
215
  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
216
 
217
+ // skills 静默装配:已存在保留,缺失补装
218
+ const skillList = flags.skills === "none" ? [] : String(flags.skills || "paperclip,paperclip-converting-plans-to-tasks").split(",").map((x) => x.trim()).filter(Boolean);
219
+ const skillResults = [];
220
+ for (const s of skillList) {
221
+ const dst = path.join(workspace, ".opencode", "skills", s);
222
+ if (fs.existsSync(path.join(dst, "SKILL.md"))) { skillResults.push(`${s}(已存在,保留)`); continue; }
223
+ skillResults.push(doAddSkill({ cfg: { workspace } }, s) ? s : s);
224
+ }
225
+ return { name, displayName, dir, workspace, port, url, token, model, wsExists, skillResults };
226
+ }
227
+
228
+ // ---------- init(纯 flags,无交互——面向 AI 调用/脚本化) ----------
229
+ async function cmdInit(flags, positionalName) {
230
+ // 全流程地图:首次使用者(人或 AI)一眼看清 4 步接线和验收在哪一步
142
231
  out();
143
- out("\x1b[32m✓ 实例就绪\x1b[0m");
144
- out(` ${dir}`);
145
- out(` ${workspace}`);
232
+ head("TDX-Daemon Agent 接入");
233
+ out(` ${tGreen("[1]")} init 创建实例 + 装配 skills ${tDim("← 本命令,现在执行")}`);
234
+ out(` ${tDim("[2]")} start 启动并自检`);
235
+ out(` ${tDim("[3]")} 平台 UI hire(endpoint + token)`);
236
+ out(` ${tDim("[4]")} 铸 agent key 回填(tdxd-ctl key / onboard 自动)`);
237
+ out(` ${tDim("验收:/health 探活 → 真实唤醒一轮 → 第二轮日志 resume=true")}`);
146
238
  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`);
239
+
240
+ const ctx = await initCore(flags, flags.name || positionalName);
241
+ const { name, displayName, dir, workspace, port, token, model, wsExists, skillResults } = ctx;
242
+
243
+ // ---- 结果呈现(分段式,无边框——窄 TTY 安全)----
244
+ const kv = (label, value) => out(` ${tDim(label)}${" ".repeat(Math.max(2, 12 - dispWidth(label)))}${value}`);
245
+ const step = (n, title, details) => {
246
+ out(` ${tGreen(`${n}/4`)} ${tBold(title)}`);
247
+ for (const d of details) out(` ${d}`);
248
+ };
249
+ out();
250
+ ok(`1/4 完成 实例就绪 ${tBold(tCyan(name))}`);
251
+ if (!wsExists) warn("workspace 不存在,start 前请就位(daemon 不代建)");
252
+ out();
253
+ kv("实例目录", tDim(dir));
254
+ kv("workspace", workspace);
255
+ kv("模型", model ? `沿用 workspace 配置(${model})` : tDim("opencode 默认(未探测到 .opencode/opencode.json)"));
256
+ kv("skills", skillResults.length ? skillResults.join("、") : tDim("未装配(--skills none)"));
257
+ kv("endpoint", `http://${lanIp()}:${port}`);
258
+ out();
259
+ out(` ${tDim("── 接下来 " + "─".repeat(30))}`);
260
+ out();
261
+ step(2, "启动并自检", [tCyan(`tdxd-ctl start ${name} && tdxd-ctl status ${name}`)]);
262
+ step(3, "平台 UI hire(TDX-Adapter)", [
263
+ `${tDim("endpoint")} http://${lanIp()}:${port}`,
264
+ `${tDim("token")} ${tYellow(token)} ${tDim("← 明文只显示这一次")}`,
265
+ ]);
266
+ step(4, "铸造 agent key 并回填(建议,兜底回调用)", [
267
+ `平台 Agent 页 → API Keys → 生成,然后:`,
268
+ `${tCyan(`tdxd-ctl key ${name} --set pcp_xxx && tdxd-ctl restart ${name}`)}`,
269
+ ]);
270
+ out(` ${tDim("验收")} /health 探活 → 真实唤醒一轮 → 第二轮日志 resume=true`);
271
+ }
272
+
273
+ // ---------- onboard(一条命令自助接入:init→start→join→等批准→claim→回填→READY) ----------
274
+ async function fetchJson(url, opts = {}, timeoutMs = 10000) {
275
+ try {
276
+ const r = await fetch(url, {
277
+ method: opts.method ?? "GET",
278
+ headers: { "content-type": "application/json", ...(opts.headers ?? {}) },
279
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
280
+ signal: AbortSignal.timeout(timeoutMs),
281
+ });
282
+ const text = await r.text();
283
+ let json = null; try { json = JSON.parse(text); } catch { /* 非 JSON */ }
284
+ return { code: r.status, json, text };
285
+ } catch (e) {
286
+ return { code: 0, err: e.message };
287
+ }
288
+ }
289
+
290
+ function readJson600(f) { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return null; } }
291
+ function writeJson600(f, obj) { fs.writeFileSync(f, JSON.stringify(obj, null, 2), { mode: 0o600 }); }
292
+
293
+ async function cmdOnboard(flags) {
294
+ // 阶段0:解析邀请 + 平台可达
295
+ if (!flags.invite) { fail("缺 --invite <url>(平台 UI 生成邀请后的 onboarding.txt 地址)"); process.exit(1); }
296
+ const m = String(flags.invite).match(/^(https?:\/\/[^/]+)\/api\/invites\/([^/?#]+)/);
297
+ if (!m) { fail(`无法解析邀请 URL:${flags.invite}(期望形如 http://host:3100/api/invites/<token>/onboarding.txt)`); process.exit(1); }
298
+ const [, base, inviteToken] = m;
299
+ const hp = await fetchJson(`${base}/api/health`);
300
+ if (hp.code !== 200) { fail(`平台不可达(${base}/api/health → ${hp.code || hp.err})`); process.exit(1); }
301
+
302
+ const workspace = path.resolve(expandHome(flags.workspace || process.cwd()));
303
+ const name = flags.name || path.basename(workspace).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
304
+ const dir = path.join(AGENTS_DIR, name);
305
+ const obFile = path.join(dir, "run", "onboard.json");
306
+ let ob = readJson600(obFile);
307
+
308
+ head(`TDX-Daemon 自助接入 ${tBold(name)}`);
309
+
310
+ // 阶段1:init(有断点状态则跳过)
311
+ if (!ob) {
312
+ if (fs.existsSync(path.join(dir, "config.json"))) {
313
+ fail(`实例已存在且无接入状态:${dir}(如需重接先删除该目录)`); process.exit(1);
314
+ }
315
+ const ctx = await initCore({ ...flags, workspace }, name);
316
+ ok(`1/4 init 完成(端口 ${ctx.port},skills 就绪)`);
317
+ ob = { stage: "init_done" };
318
+ writeJson600(obFile, ob);
319
+ } else note(`1/4 断点续跑(stage=${ob.stage})`);
320
+
321
+ // 阶段2:start(幂等)
322
+ const a = { name, dir, cfgPath: path.join(dir, "config.json"), cfg: JSON.parse(fs.readFileSync(path.join(dir, "config.json"), "utf8")) };
323
+ const sec = readSecret(dir);
324
+ const r0 = startOne(a);
325
+ if (r0 === false) process.exit(1);
326
+ const h = await waitHealth(a);
327
+ if (h.code !== 200) { fail(`2/4 daemon 健康未过(${h.code}):tail -20 ${path.join(dir, "log", "tdxd.log")}`); process.exit(1); }
328
+ ok(`2/4 daemon 运行 :${a.cfg.port} v${h.json?.version ?? "?"}`);
329
+
330
+ // 阶段3:join 提交(accept)
331
+ if (ob.stage === "init_done") {
332
+ const body = {
333
+ requestType: "agent",
334
+ agentName: a.cfg.name,
335
+ adapterType: "tdx_adapter",
336
+ capabilities: flags.capabilities || `opencode ACP agent "${name}" on ${os.hostname()}`,
337
+ agentDefaultsPayload: {
338
+ endpoint: `http://${lanIp()}:${a.cfg.port}`,
339
+ token: sec.TDXD_TOKEN,
340
+ timeoutMs: 420000,
341
+ },
342
+ };
343
+ const acc = await fetchJson(`${base}/api/invites/${inviteToken}/accept`, { method: "POST", body });
344
+ if (acc.code !== 202 || !acc.json?.id || !acc.json?.claimSecret) {
345
+ fail(`3/4 join 提交失败(HTTP ${acc.code}):${(acc.json?.error ?? acc.text ?? "").slice(0, 200)}`); process.exit(1);
346
+ }
347
+ ob = { stage: "joined", base, requestId: acc.json.id, claimSecret: acc.json.claimSecret };
348
+ writeJson600(obFile, ob);
349
+ ok(`3/4 join 已提交(requestId ${acc.json.id.slice(0, 8)}…)`);
350
+ } else if (ob.stage === "joined") note(`3/4 join 已存在(requestId ${String(ob.requestId).slice(0, 8)}…),继续等批准`);
351
+
352
+ // 阶段4+5:等批准(claim 即轮询:409 not-approved → 继续;201 → 过闸)
353
+ if (ob.stage === "joined") {
354
+ const waitMin = Number(flags["wait-min"] || 30);
355
+ const deadline = Date.now() + waitMin * 60_000;
356
+ out(` ${tYellow("[等待]")} 4/4 等待 board 批准(平台 UI → 入职/Join 请求 → Approve "${a.cfg.name}";最长 ${waitMin} 分钟,每 15s 轮询)`);
357
+ for (;;) {
358
+ const c = await fetchJson(`${base}/api/join-requests/${ob.requestId}/claim-api-key`, { method: "POST", body: { claimSecret: ob.claimSecret } });
359
+ if (c.code === 201 && c.json?.token) {
360
+ writeAgentKey(dir, c.json.token); // 完整 key 只落文件,不打印
361
+ ob = { ...ob, stage: "claimed", agentId: c.json.agentId, keyId: c.json.keyId, claimedAt: new Date().toISOString() };
362
+ delete ob.claimSecret; // 单次秘密,用后即焚
363
+ writeJson600(obFile, ob);
364
+ ok(`4/4 key 已领取并写入 secret.env(${c.json.token.slice(0, 8)}…,agentId ${String(c.json.agentId).slice(0, 8)}…)`);
365
+ break;
366
+ }
367
+ const msg = String(c.json?.error ?? c.text ?? "");
368
+ if (c.code === 409 && /approved/i.test(msg)) {
369
+ if (Date.now() > deadline) {
370
+ fail(`等待批准超时(${waitMin} 分钟)。状态已保存,批准后重跑同命令即可续`); process.exit(1);
371
+ }
372
+ await new Promise((res) => setTimeout(res, 15_000));
373
+ continue;
374
+ }
375
+ if (c.code === 409 && /already claimed/i.test(msg)) {
376
+ fail("该 join 请求的 key 已被领取过(claimSecret 单次)。如丢失 key,请在平台重新铸 key 后用 tdxd-ctl key 回填"); process.exit(1);
377
+ }
378
+ if (c.code === 409 && /expired/i.test(msg)) {
379
+ fail("claimSecret 已过期。请操作员重新生成邀请后重跑(实例与 join 状态保留)"); process.exit(1);
380
+ }
381
+ fail(`claim 失败(HTTP ${c.code}):${msg.slice(0, 200)}`); process.exit(1);
382
+ }
383
+ } else note(`4/4 已完成(claimed @ ${ob.claimedAt ?? "?"})`);
384
+
385
+ // 阶段6:restart + 终检 + READY
386
+ await stopOneAsync(a);
387
+ const r2 = startOne(a);
388
+ if (r2 === false) process.exit(1);
389
+ const h2 = await waitHealth(a);
390
+ const sec2 = readSecret(dir);
391
+ out();
392
+ if (h2.code === 200 && sec2.TDXD_PAPERCLIP_KEY) {
393
+ ok(`READY ${tBold(name)} health=${h2.code} v${h2.json?.version} 回调:启用`);
394
+ out(` endpoint http://${lanIp()}:${a.cfg.port}`);
395
+ out(` ${tDim("验收:向该 Agent 派一个测试任务 → 观察心跳执行 → 第二轮 resume=true")}`);
396
+ } else {
397
+ fail(`终检未过(health=${h2.code},回调${sec2.TDXD_PAPERCLIP_KEY ? "启用" : "未配置"}),查日志 ${path.join(dir, "log", "tdxd.log")}`);
398
+ process.exit(1);
399
+ }
154
400
  }
155
401
 
156
402
  // ---------- 生命周期 ----------
157
403
  function startOne(a) {
158
404
  const pid = pidOf(a);
159
405
  if (pid) {
160
- out(`[ctl] ${a.name} 已在运行(pid ${pid})`);
406
+ note(`${a.name} 已在运行(pid ${pid})`);
161
407
  return "running";
162
408
  }
163
409
  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; }
410
+ if (!sec.TDXD_TOKEN) { fail(`${a.name} 缺 secret.env TDXD_TOKEN`); return false; }
411
+ if (!fs.existsSync(a.cfg.workspace)) { fail(`${a.name} workspace 不存在: ${a.cfg.workspace}`); return false; }
166
412
  fs.mkdirSync(path.dirname(pidFile(a)), { recursive: true });
167
413
  const logFd = fs.openSync(path.join(a.dir, "log", "tdxd.log"), "a");
168
414
  const child = spawn(process.execPath, [DAEMON_BIN, "--config", a.cfgPath], {
@@ -186,7 +432,7 @@ async function waitHealth(a, ms = 15000) {
186
432
 
187
433
  async function stopOneAsync(a) {
188
434
  const pid = pidOf(a);
189
- if (!pid) { out(`[ctl] ${a.name} 无 pid 记录(未运行或由旧方式启动)`); return; }
435
+ if (!pid) { note(`${a.name} 无 pid 记录(未运行或由旧方式启动)`); return; }
190
436
  try { process.kill(pid, "SIGTERM"); } catch { /* 已退 */ }
191
437
  for (let i = 0; i < 25; i++) {
192
438
  try { process.kill(pid, 0); } catch { break; }
@@ -194,20 +440,22 @@ async function stopOneAsync(a) {
194
440
  }
195
441
  try { process.kill(pid, 0); process.kill(pid, "SIGKILL"); } catch { /* 已退 */ }
196
442
  try { fs.unlinkSync(pidFile(a)); } catch { /* 无 */ }
197
- out(`[ctl] ✓ ${a.name} 已停止`);
443
+ ok(`${a.name} 已停止`);
198
444
  }
199
445
 
200
446
  async function cmdStart(target) {
201
- let ok = true;
447
+ let allOk = true;
202
448
  for (const a of pick(target)) {
203
449
  const r = startOne(a);
204
450
  if (r === "running") continue;
205
- if (!r) { ok = false; continue; }
451
+ if (!r) { allOk = false; continue; }
452
+ note(`${a.name} 启动中 :${a.cfg.port}(等待 health ≤15s)…`);
206
453
  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; }
454
+ const ver = h.json?.version ? ` v${h.json.version}` : "";
455
+ if (h.code === 200) ok(`${a.name} 已启动 :${a.cfg.port}${ver}`);
456
+ else { fail(`${a.name} 健康检查未过(health=${h.code}),查日志: tail -20 ${path.join(a.dir, "log", "tdxd.log")}`); allOk = false; }
209
457
  }
210
- if (!ok) process.exitCode = 1;
458
+ if (!allOk) process.exitCode = 1;
211
459
  }
212
460
 
213
461
  async function cmdRestart(target) {
@@ -215,15 +463,35 @@ async function cmdRestart(target) {
215
463
  await cmdStart(target);
216
464
  }
217
465
 
466
+ /** 孤儿探测:18200-18299 区间有监听但无实例配置认领 → 目录已删进程还在的 daemon */
467
+ async function findOrphanPorts() {
468
+ const claimed = new Set(listAgents().map((a) => a.cfg?.port).filter(Boolean));
469
+ const ports = [];
470
+ for (let p = 18200; p <= 18299; p++) ports.push(p);
471
+ const checks = await Promise.all(ports.map(async (p) => ({ p, free: await canBind(p) })));
472
+ return checks.filter((c) => !c.free && !claimed.has(c.p)).map((c) => c.p);
473
+ }
474
+
218
475
  async function cmdStatus(target) {
219
- for (const a of pick(target)) {
476
+ const rows = await Promise.all(pick(target).map(async (a) => {
220
477
  const pid = pidOf(a);
221
478
  const h = await healthOf(a, 2000);
222
479
  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}`);
480
+ return [
481
+ cell(a.name, "bold"),
482
+ cell(pid ? `运行 ${pid}` : "", pid ? "green" : "red"),
483
+ cell(`:${a.cfg?.port ?? "?"}`),
484
+ cell(h.code === 200 ? "200" : String(h.code || "—"), h.code === 200 ? "green" : "red"),
485
+ cell(h.json?.version ? `v${h.json.version}` : "—", "dim"),
486
+ cell(sec.TDXD_PAPERCLIP_KEY ? "启用" : "未配置", sec.TDXD_PAPERCLIP_KEY ? "green" : "yellow"),
487
+ ];
488
+ }));
489
+ table(["实例", "状态", "端口", "HEALTH", "版本", "回调"], rows);
490
+ note(`${tDim("实例目录")} ${AGENTS_DIR}`);
491
+ const orphans = await findOrphanPorts();
492
+ if (orphans.length) {
493
+ warn(`疑似孤儿 daemon 端口: ${orphans.join(", ")}(实例目录已删但进程仍在,占用端口资源)`);
494
+ out(` 处置:ss -tlnp | grep -E '${orphans.join("|")}' 找 pid → kill <pid>`);
227
495
  }
228
496
  }
229
497
 
@@ -241,17 +509,38 @@ function parseFlags(argv) {
241
509
  }
242
510
 
243
511
  const [cmd = "status", targetArg, ...rest] = process.argv.slice(2);
512
+ process.stdout?.on?.("error", (e) => { if (e.code === "EPIPE") process.exit(0); }); // | head 等提前关管道时静默退出
244
513
  const target = targetArg ?? "all"; // init 场景须用 targetArg(可能为 undefined),不能吃这个默认值
245
514
  const flags = parseFlags(rest);
246
515
  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;
516
+ case "init": await cmdInit(flags, targetArg); break;
517
+ case "skills": cmdSkills(target, flags); break;
518
+ case "key": cmdKey(target, flags); break;
519
+ case "onboard": await cmdOnboard(parseFlags(process.argv.slice(3))); break; // 无位置参数:从原始 argv 解析,防首 flag 被误当 target
520
+ case "list": {
521
+ const agents = listAgents();
522
+ if (agents.length === 0) { warn(`无实例(查 ${AGENTS_DIR}/;初始化用 tdxd-ctl init <name>)`); break; }
523
+ table(["实例", "端口", "WORKSPACE"], agents.map((a) => [
524
+ cell(a.name, "bold"), `:${a.cfg?.port ?? "?"}`, cell(a.cfg?.workspace ?? "—", "dim"),
525
+ ]));
526
+ break;
527
+ }
249
528
  case "start": await cmdStart(target); break;
250
529
  case "stop": for (const a of pick(target)) await stopOneAsync(a); break;
251
530
  case "restart": await cmdRestart(target); break;
252
531
  case "status": await cmdStatus(target); break;
253
532
  default:
254
- out("用法: tdxd-ctl init|list|start|stop|restart|status [name|all] [--yes --name --display --workspace --model --persona --port --token --url --force]");
533
+ out("用法: tdxd-ctl init|onboard|list|skills|key|start|stop|restart|status [name|all]");
534
+ out(" (全命令无交互、纯 flags——面向 AI 调用与脚本化;缺参即报错,由调用方补参重试)");
535
+ out(" init <name> [--workspace <p>] [--port <n>] [--url <u>] [--token <t>]");
536
+ out(" [--display <s>] [--skills <a,b>|none] [--force]");
537
+ out(" skills <name> [--add <skill>|--rm <skill>] # 内置: paperclip(核心), paperclip-converting-plans-to-tasks");
538
+ out(" key <name> --set pcp_xxx | --rm # 平台 agent key 回填(兜底回调),写后 restart 生效");
539
+ out(" onboard --invite <url> [--name] [--workspace] [--capabilities] [--wait-min 30]");
540
+ out(" # 一条命令自助接入:init→start→join→等批准(轮询)→claim key→回填→READY");
255
541
  out(`实例目录: ${AGENTS_DIR}`);
256
542
  process.exit(1);
257
543
  }
544
+ // 显式退出:被 TUN/防火墙黑洞的端口,fetch abort 只解除 Promise,
545
+ // undici 底层 connect 定时器(10s)仍挂着事件循环导致命令迟迟不返回
546
+ process.exit(process.exitCode ?? 0);
package/tdxd.mjs CHANGED
@@ -227,7 +227,7 @@ const server = http.createServer((req, res) => {
227
227
  if (req.method === "GET" && req.url === "/health") {
228
228
  if (!okAuth(req)) { res.writeHead(401); res.end(); return; }
229
229
  res.writeHead(200, { "content-type": "application/json" });
230
- res.end(JSON.stringify({ ok: true, name: cfg.name, workspace: cfg.workspace, version: "1.1.1",
230
+ res.end(JSON.stringify({ ok: true, name: cfg.name, workspace: cfg.workspace, version: "1.2.0",
231
231
  activeSessions: router.sessions.size }));
232
232
  return;
233
233
  }
@@ -358,7 +358,7 @@ async function handleRun(wake, req, res) {
358
358
  }
359
359
 
360
360
  server.listen(cfg.port, cfg.bind, () => {
361
- console.log(`[tdxd] ${cfg.name} v1.1.1 就绪 ${cfg.bind}:${cfg.port}`);
361
+ console.log(`[tdxd] ${cfg.name} v1.2.0 就绪 ${cfg.bind}:${cfg.port}`);
362
362
  console.log(`[tdxd] workspace=${cfg.workspace} command=${cfg.command} ${cfg.commandArgs.join(" ")}`);
363
363
  console.log(`[tdxd] idleTTL=${cfg.idleTtlMin}min maxSessions=${cfg.maxSessions} 回调=${cfg.paperclip.url ? "启用" : "未配置"}`);
364
364
  });