@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/ONBOARD-PROMPT.md +44 -0
- package/README.md +16 -5
- package/lib/tpl.mjs +14 -50
- package/lib/ui.mjs +49 -95
- package/package.json +3 -1
- package/skills/paperclip/SKILL.md +630 -0
- package/skills/paperclip/references/api-reference.md +1511 -0
- package/skills/paperclip/references/artifacts.md +98 -0
- package/skills/paperclip/references/cases.md +295 -0
- package/skills/paperclip/references/company-skills.md +266 -0
- package/skills/paperclip/references/issue-workspaces.md +80 -0
- package/skills/paperclip/references/routines.md +231 -0
- package/skills/paperclip/references/workflows.md +141 -0
- package/skills/paperclip/scripts/paperclip-upload-artifact.sh +371 -0
- package/skills/paperclip-converting-plans-to-tasks/SKILL.md +60 -0
- package/tdxd-ctl.mjs +360 -71
- package/tdxd.mjs +2 -2
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 {
|
|
13
|
-
import { configTpl,
|
|
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) {
|
|
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) {
|
|
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
|
-
|
|
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
|
-
// ----------
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
193
|
+
fail(`实例已存在:${dir}(--force 覆盖)`); process.exit(1);
|
|
109
194
|
}
|
|
110
195
|
|
|
111
|
-
const displayName = flags.display ||
|
|
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 ||
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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({
|
|
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
|
-
|
|
144
|
-
out(` ${
|
|
145
|
-
out(` ${
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
out(`
|
|
153
|
-
|
|
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
|
-
|
|
406
|
+
note(`${a.name} 已在运行(pid ${pid})`);
|
|
161
407
|
return "running";
|
|
162
408
|
}
|
|
163
409
|
const sec = readSecret(a.dir);
|
|
164
|
-
if (!sec.TDXD_TOKEN) {
|
|
165
|
-
if (!fs.existsSync(a.cfg.workspace)) {
|
|
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) {
|
|
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
|
-
|
|
443
|
+
ok(`${a.name} 已停止`);
|
|
198
444
|
}
|
|
199
445
|
|
|
200
446
|
async function cmdStart(target) {
|
|
201
|
-
let
|
|
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) {
|
|
451
|
+
if (!r) { allOk = false; continue; }
|
|
452
|
+
note(`${a.name} 启动中 :${a.cfg.port}(等待 health ≤15s)…`);
|
|
206
453
|
const h = await waitHealth(a);
|
|
207
|
-
|
|
208
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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":
|
|
248
|
-
case "
|
|
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]
|
|
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.
|
|
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.
|
|
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
|
});
|