@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/README.md +49 -0
- package/config.example.json +29 -0
- package/lib/acp.mjs +230 -0
- package/lib/perm.mjs +132 -0
- package/lib/router.mjs +162 -0
- package/lib/tpl.mjs +77 -0
- package/lib/ui.mjs +102 -0
- package/package.json +29 -0
- package/tdxd-ctl.mjs +257 -0
- package/tdxd.mjs +368 -0
package/tdxd.mjs
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// TDX-Daemon(tdxd)v1.1.0(1.1.0:跨平台 ctl + init 脚手架 + BUG-05/11/12 修复)
|
|
3
|
+
// 职责:HTTP 入口(鉴权/唤醒/探活)+ SessionRouter(会话即进程) + ACP 事件→协议信封 + 平台回调
|
|
4
|
+
// 配置:--config config.json 或环境变量覆盖(见 config.example.json)
|
|
5
|
+
import http from "node:http";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { SessionRouter } from "./lib/router.mjs";
|
|
9
|
+
import { makeEvaluator, permKeyOf, coreOfSegment, parseShell } from "./lib/perm.mjs";
|
|
10
|
+
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
let cfgPath = "config.json";
|
|
13
|
+
const cfgIdx = args.indexOf("--config");
|
|
14
|
+
if (cfgIdx >= 0) cfgPath = args[cfgIdx + 1];
|
|
15
|
+
|
|
16
|
+
const fileCfg = fs.existsSync(cfgPath) ? JSON.parse(fs.readFileSync(cfgPath, "utf8")) : {};
|
|
17
|
+
const cfg = {
|
|
18
|
+
name: process.env.TDXD_NAME ?? fileCfg.name ?? "TDX-Daemon",
|
|
19
|
+
port: Number(process.env.TDXD_PORT ?? fileCfg.port ?? 18200),
|
|
20
|
+
bind: process.env.TDXD_BIND ?? fileCfg.bind ?? "127.0.0.1",
|
|
21
|
+
token: process.env.TDXD_TOKEN ?? fileCfg.token ?? "",
|
|
22
|
+
workspace: process.env.TDXD_WORKSPACE ?? fileCfg.workspace ?? "",
|
|
23
|
+
command: process.env.TDXD_COMMAND ?? fileCfg.command ?? "opencode",
|
|
24
|
+
commandArgs: fileCfg.commandArgs ?? [],
|
|
25
|
+
idleTtlMin: Number(fileCfg.idleTtlMin ?? 10),
|
|
26
|
+
maxSessions: Number(fileCfg.maxSessions ?? 5),
|
|
27
|
+
promptTimeoutMs: Number(fileCfg.promptTimeoutMs ?? 360_000),
|
|
28
|
+
paperclip: {
|
|
29
|
+
url: process.env.TDXD_PAPERCLIP_URL ?? fileCfg.paperclip?.url ?? "",
|
|
30
|
+
agentKey: process.env.TDXD_PAPERCLIP_KEY ?? fileCfg.paperclip?.agentKey ?? "",
|
|
31
|
+
},
|
|
32
|
+
permissions: {
|
|
33
|
+
default: fileCfg.permissions?.default === "allow" ? "allow" : "deny",
|
|
34
|
+
allow: Array.isArray(fileCfg.permissions?.allow) ? fileCfg.permissions.allow : [],
|
|
35
|
+
askComment: fileCfg.permissions?.askComment !== false, // v2:deny 时向 issue 发审批请求评论
|
|
36
|
+
},
|
|
37
|
+
pricing: {
|
|
38
|
+
// 模型价格表(USD / 每百万 token):operator 可随时改配置,无需动代码
|
|
39
|
+
models: fileCfg.pricing?.models ?? {},
|
|
40
|
+
default: fileCfg.pricing?.default ?? null,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
if (!cfg.token) { console.error("[tdxd] 缺少 token(配置或 TDXD_TOKEN)"); process.exit(1); }
|
|
45
|
+
if (!cfg.workspace) { console.error("[tdxd] 缺少 workspace 路径"); process.exit(1); }
|
|
46
|
+
|
|
47
|
+
const router = new SessionRouter({
|
|
48
|
+
command: cfg.command, args: ["acp", ...(cfg.commandArgs ?? [])], workspace: cfg.workspace,
|
|
49
|
+
idleTtlMs: cfg.idleTtlMin * 60_000, maxSessions: cfg.maxSessions,
|
|
50
|
+
busyWaitTimeoutMs: Number(fileCfg.busyWaitTimeoutMs ?? 8 * 60_000),
|
|
51
|
+
permissionPolicy: basePermissionPolicy,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ---- 权限策略(v0/v1):默认 deny + 白名单(解析器:引号/heredoc 感知,$() 递归,核心命令授权) ----
|
|
55
|
+
const allowRes = cfg.permissions.allow
|
|
56
|
+
.map((e) => { try { return new RegExp(e?.command ?? String(e)); } catch { return null; } })
|
|
57
|
+
.filter(Boolean);
|
|
58
|
+
const evalCommand = makeEvaluator(allowRes, cfg.permissions.default);
|
|
59
|
+
function basePermissionPolicy(command) { return evalCommand(command); }
|
|
60
|
+
const permKey = (cmd) => permKeyOf(coreOfSegment(parseShell(String(cmd)).segments[0] ?? String(cmd)));
|
|
61
|
+
|
|
62
|
+
/** 按 run usage 与价格表计算成本(USD);模型无价且无默认价 → undefined(平台记 unpriced) */
|
|
63
|
+
function computeCostUsd(model, usage) {
|
|
64
|
+
const price = cfg.pricing.models?.[model] ?? cfg.pricing.default;
|
|
65
|
+
if (!price) return undefined;
|
|
66
|
+
const u = usage ?? {};
|
|
67
|
+
const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; }; // 防 NaN:Number(undefined)=NaN 会毒化总价(JSON 成 null)
|
|
68
|
+
const cost =
|
|
69
|
+
num(u.inputTokens) / 1e6 * num(price.inputPerMillion) +
|
|
70
|
+
num(u.cachedReadTokens ?? u.cachedInputTokens) / 1e6 * num(price.cachedReadPerMillion) +
|
|
71
|
+
num(u.outputTokens) / 1e6 * num(price.outputPerMillion) +
|
|
72
|
+
num(u.thoughtTokens) / 1e6 * num(price.thoughtPerMillion ?? price.outputPerMillion);
|
|
73
|
+
return Number(cost.toFixed(6));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 有 pending 审批时,每轮 wake 主动回查 issue 交互卡:accepted 且未武装过 → 武装授权。
|
|
77
|
+
* 对抗唤醒竞争合并导致 prompt 丢失 checkbox 选中项文本的场景(TDM-29 实证)。 */
|
|
78
|
+
async function armGrantsFromInteractions(taskId, authToken) {
|
|
79
|
+
if (!pendingApprovals.size || !taskId || !cfg.paperclip.url || !authToken) return;
|
|
80
|
+
try {
|
|
81
|
+
const r = await fetch(`${cfg.paperclip.url}/api/issues/${taskId}/interactions`, {
|
|
82
|
+
headers: { authorization: `Bearer ${authToken}` },
|
|
83
|
+
signal: AbortSignal.timeout(5000),
|
|
84
|
+
});
|
|
85
|
+
if (!r.ok) return;
|
|
86
|
+
const list = await r.json();
|
|
87
|
+
const arr = Array.isArray(list) ? list : (list.interactions ?? list.items ?? []);
|
|
88
|
+
for (const it of arr) {
|
|
89
|
+
const status = String(it.status ?? it.outcome ?? "");
|
|
90
|
+
if (!/accepted/.test(status)) continue;
|
|
91
|
+
const optId = it.payload?.options?.[0]?.id ?? "";
|
|
92
|
+
const m = String(it.idempotencyKey ?? "").match(/^perm-approval:[^:]+:([0-9a-f]{8}):/);
|
|
93
|
+
const k = (m ? m[1] : (optId.match(/^approve_([0-9a-f]{8})$/)?.[1] ?? "")).toLowerCase();
|
|
94
|
+
const pend = pendingApprovals.get(k);
|
|
95
|
+
if (!k || !pend?.cmd || consumedGrants.has(k)) continue;
|
|
96
|
+
grantedApprovals.set(k, { cmd: pend.cmd, until: Date.now() + 30 * 60_000, uses: 0 });
|
|
97
|
+
consumedGrants.add(k);
|
|
98
|
+
savePermState();
|
|
99
|
+
console.log(`[tdxd] 授权武装(交互卡回查)key=${k}`);
|
|
100
|
+
}
|
|
101
|
+
} catch { /* 回查失败不影响主流程 */ }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- v2:deny 审批(原生交互卡优先,评论降级;同命令 30 分钟去重,单 run 最多 5 条) ----
|
|
105
|
+
const permStateFile = `${cfgPath}.permstate`; // 无 .json 后缀,避免被 tdxd-ctl 的 configs/*.json 实例扫描误认
|
|
106
|
+
function loadPermState() {
|
|
107
|
+
try {
|
|
108
|
+
const j = JSON.parse(fs.readFileSync(permStateFile, "utf8"));
|
|
109
|
+
return {
|
|
110
|
+
pending: new Map(Object.entries(j.pending ?? {})),
|
|
111
|
+
granted: new Map(Object.entries(j.granted ?? {})),
|
|
112
|
+
consumed: new Set(j.consumed ?? []),
|
|
113
|
+
};
|
|
114
|
+
} catch { return { pending: new Map(), granted: new Map(), consumed: new Set() }; }
|
|
115
|
+
}
|
|
116
|
+
function savePermState() {
|
|
117
|
+
try {
|
|
118
|
+
fs.writeFileSync(permStateFile, JSON.stringify({
|
|
119
|
+
pending: Object.fromEntries(pendingApprovals),
|
|
120
|
+
granted: Object.fromEntries([...grantedApprovals].filter(([, g]) => Date.now() < g.until && g.uses < 1)),
|
|
121
|
+
consumed: [...consumedGrants],
|
|
122
|
+
}));
|
|
123
|
+
} catch { /* 状态持久化失败不影响主流程 */ }
|
|
124
|
+
}
|
|
125
|
+
const _perm = loadPermState();
|
|
126
|
+
const pendingApprovals = _perm.pending; // key → { at, cmd }(去重 + 供授权回填命令)
|
|
127
|
+
const grantedApprovals = _perm.granted; // key → { cmd, until, uses }(跨 run 持久授权:30 分钟窗口单次使用)
|
|
128
|
+
const consumedGrants = _perm.consumed; // 已武装过的交互卡 key(防重复武装)
|
|
129
|
+
async function queueApproval(taskId, runId, info, authToken) {
|
|
130
|
+
const cmd = String(info.command ?? "").trim();
|
|
131
|
+
const bearer = authToken || cfg.paperclip.agentKey;
|
|
132
|
+
if (!cmd || !cfg.permissions.askComment || !taskId || !cfg.paperclip.url || !bearer) return;
|
|
133
|
+
const key = permKey(cmd);
|
|
134
|
+
const prev = pendingApprovals.get(key);
|
|
135
|
+
if (prev && Date.now() - prev.at < 30 * 60_000) return;
|
|
136
|
+
pendingApprovals.set(key, { at: Date.now(), cmd });
|
|
137
|
+
savePermState();
|
|
138
|
+
const safeCmd = cmd.replace(/`/g, "'").slice(0, 300);
|
|
139
|
+
const headers = {
|
|
140
|
+
authorization: `Bearer ${bearer}`,
|
|
141
|
+
"content-type": "application/json",
|
|
142
|
+
"x-paperclip-run-id": String(runId ?? ""),
|
|
143
|
+
};
|
|
144
|
+
// 首选:原生 checkbox 交互卡(选中项文本会渲染进唤醒 prompt → approve 标记可透传;accept/reject 均唤醒 assignee)
|
|
145
|
+
try {
|
|
146
|
+
const r = await fetch(`${cfg.paperclip.url}/api/issues/${taskId}/interactions`, {
|
|
147
|
+
method: "POST", headers,
|
|
148
|
+
body: JSON.stringify({
|
|
149
|
+
kind: "request_checkbox_confirmation",
|
|
150
|
+
title: `权限审批:${safeCmd.slice(0, 80)}`,
|
|
151
|
+
summary: "命令被默认权限策略拒绝,勾选并确认即可放行(allow_once,单次生效)",
|
|
152
|
+
continuationPolicy: "wake_assignee",
|
|
153
|
+
idempotencyKey: `perm-approval:${taskId}:${key}:${Math.floor(Date.now() / 1800_000)}`,
|
|
154
|
+
payload: {
|
|
155
|
+
version: 1,
|
|
156
|
+
prompt: "勾选下方选项并点击确认,即可在下次心跳放行该命令(仅本命令单次生效)。",
|
|
157
|
+
detailsMarkdown: `- 命令:\`${safeCmd}\`\n- 授权范围:\`allow_once\`,仅对完全相同的命令单次生效\n- 拒绝则无需勾选直接点「拒绝」\n- 30 分钟内同命令不重复发起`,
|
|
158
|
+
options: [{ id: `approve_${key}`, label: `approve ${key} —— 放行上述命令`, description: safeCmd.slice(0, 120) }],
|
|
159
|
+
defaultSelectedOptionIds: [`approve_${key}`],
|
|
160
|
+
minSelected: 1,
|
|
161
|
+
maxSelected: 1,
|
|
162
|
+
acceptLabel: "批准",
|
|
163
|
+
rejectLabel: "拒绝",
|
|
164
|
+
rejectRequiresReason: false,
|
|
165
|
+
supersedeOnUserComment: false,
|
|
166
|
+
},
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
if (r.ok) { console.log(`[tdxd] 权限审批卡 key=${key} → HTTP ${r.status}`); return; }
|
|
170
|
+
console.log(`[tdxd] 权限审批卡失败 key=${key} → HTTP ${r.status},降级评论`);
|
|
171
|
+
} catch (e) {
|
|
172
|
+
console.log(`[tdxd] 权限审批卡异常 key=${key}: ${e.message},降级评论`);
|
|
173
|
+
}
|
|
174
|
+
// 降级:普通评论(需用户手动回复 approve <key>)
|
|
175
|
+
const body = `## 权限审批请求(${cfg.name},tdxd 自动发起)\n\nAgent 请求执行的命令被默认权限策略拒绝,本回合模型将绕行。如需放行,请直接回复本评论:\n\n\`approve ${key}\`\n\n- 命令:\`${safeCmd}\`\n- 授权范围:\`allow_once\`,仅对完全相同的命令单次生效\n- 回复后下一次心跳自动生效;30 分钟内同命令不重复发起`;
|
|
176
|
+
try {
|
|
177
|
+
const r = await fetch(`${cfg.paperclip.url}/api/issues/${taskId}/comments`, {
|
|
178
|
+
method: "POST", headers,
|
|
179
|
+
body: JSON.stringify({ body }),
|
|
180
|
+
});
|
|
181
|
+
console.log(`[tdxd] 权限审批评论 key=${key} → HTTP ${r.status}`);
|
|
182
|
+
} catch (e) {
|
|
183
|
+
console.log(`[tdxd] 权限审批评论失败: ${e.message}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---- ACP update → 协议 v1 信封(恒等翻译) ----
|
|
188
|
+
function translate(update) {
|
|
189
|
+
if (!update || typeof update.sessionUpdate !== "string") return null;
|
|
190
|
+
const u = update.sessionUpdate;
|
|
191
|
+
if (u === "agent_message_chunk") return { kind: "message_chunk", text: contentText(update.content) };
|
|
192
|
+
if (u === "agent_thought_chunk") return { kind: "thought_chunk", text: contentText(update.content) };
|
|
193
|
+
if (u === "user_message_chunk") return { kind: "user_message_chunk", text: contentText(update.content) };
|
|
194
|
+
if (u === "tool_call") return { kind: "tool_call", toolCallId: update.toolCallId, title: update.title, content: update.content ?? null, rawKind: update.kind ?? null };
|
|
195
|
+
if (u === "tool_call_update") return { kind: "tool_call_update", toolCallId: update.toolCallId, title: update.title, content: update.content ?? null, rawKind: update.kind ?? null };
|
|
196
|
+
if (u === "usage_update") return { kind: "usage", inputTokens: update.used ?? 0, outputTokens: 0, totalTokens: update.size ?? 0, note: "context-usage" };
|
|
197
|
+
return { kind: "agent_event", event: u, data: trim(update) };
|
|
198
|
+
}
|
|
199
|
+
const contentText = (c) => (c && typeof c === "object" ? String(c.text ?? "") : String(c ?? ""));
|
|
200
|
+
const trim = (o) => { try { return JSON.parse(JSON.stringify(o)); } catch { return null; } };
|
|
201
|
+
|
|
202
|
+
// ---- 平台回调 ----
|
|
203
|
+
async function reportDone(taskId, runId, summary) {
|
|
204
|
+
if (!taskId || !cfg.paperclip.url || !cfg.paperclip.agentKey) return;
|
|
205
|
+
try {
|
|
206
|
+
const r = await fetch(`${cfg.paperclip.url}/api/issues/${taskId}`, {
|
|
207
|
+
method: "PATCH",
|
|
208
|
+
headers: {
|
|
209
|
+
authorization: `Bearer ${cfg.paperclip.agentKey}`,
|
|
210
|
+
"content-type": "application/json",
|
|
211
|
+
"x-paperclip-run-id": String(runId ?? ""),
|
|
212
|
+
},
|
|
213
|
+
body: JSON.stringify({
|
|
214
|
+
status: "done",
|
|
215
|
+
comment: summary,
|
|
216
|
+
}),
|
|
217
|
+
});
|
|
218
|
+
console.log(`[tdxd] 回调 issue=${taskId.slice(0, 8)} → HTTP ${r.status}`);
|
|
219
|
+
} catch (e) {
|
|
220
|
+
console.log(`[tdxd] 回调失败: ${e.message}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const okAuth = (req) => (req.headers.authorization ?? "") === `Bearer ${cfg.token}`;
|
|
225
|
+
|
|
226
|
+
const server = http.createServer((req, res) => {
|
|
227
|
+
if (req.method === "GET" && req.url === "/health") {
|
|
228
|
+
if (!okAuth(req)) { res.writeHead(401); res.end(); return; }
|
|
229
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
230
|
+
res.end(JSON.stringify({ ok: true, name: cfg.name, workspace: cfg.workspace, version: "1.1.0",
|
|
231
|
+
activeSessions: router.sessions.size }));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (req.method === "POST" && req.url === "/run") {
|
|
236
|
+
if (!okAuth(req)) { res.writeHead(401); res.end("unauthorized"); return; }
|
|
237
|
+
(async () => {
|
|
238
|
+
let body = "";
|
|
239
|
+
for await (const c of req) body += c;
|
|
240
|
+
let wake;
|
|
241
|
+
try { wake = JSON.parse(body || "{}"); } catch { res.writeHead(400); res.end("bad json"); return; }
|
|
242
|
+
await handleRun(wake, req, res);
|
|
243
|
+
})().catch((e) => {
|
|
244
|
+
console.log(`[tdxd] 运行异常: ${e.message}`);
|
|
245
|
+
try { res.writeHead(500); res.end(JSON.stringify({ kind: "error", message: e.message })); } catch { /* noop */ }
|
|
246
|
+
});
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
res.writeHead(404); res.end();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
async function handleRun(wake, req, res) {
|
|
253
|
+
const key = wake.conversationKey ?? `adhoc:${wake.runId}`;
|
|
254
|
+
console.log(`[tdxd] run 收到 conversation=${key} resume=${!!wake.nativeSessionId} jwt=${!!wake.env?.authToken}`);
|
|
255
|
+
|
|
256
|
+
// 流式 NDJSON 响应
|
|
257
|
+
res.writeHead(200, { "content-type": "application/x-ndjson", "transfer-encoding": "chunked" });
|
|
258
|
+
const write = (obj) => res.write(JSON.stringify(obj) + "\n");
|
|
259
|
+
|
|
260
|
+
let terminal = { stopReason: "end_turn" };
|
|
261
|
+
const usageAcc = { inputTokens: 0, outputTokens: 0, totalTokens: 0, cachedReadTokens: 0 };
|
|
262
|
+
const msgTail = [];
|
|
263
|
+
|
|
264
|
+
const onUpdate = (u) => {
|
|
265
|
+
const ev = translate(u);
|
|
266
|
+
if (!ev) return;
|
|
267
|
+
if (ev.kind === "usage") return; // 终态 usage 更准,忽略流内 context usage
|
|
268
|
+
write(ev);
|
|
269
|
+
if (ev.kind === "message_chunk" && ev.text) msgTail.push(ev.text);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// run 凭据与工具(协议 v1.1):注入 agent 子进程 / 挂载 MCP,使 paperclip skill 原生心跳可用
|
|
273
|
+
const runEnv = wake.env?.authToken ? {
|
|
274
|
+
PAPERCLIP_API_KEY: wake.env.authToken,
|
|
275
|
+
PAPERCLIP_API_URL: cfg.paperclip.url,
|
|
276
|
+
PAPERCLIP_AGENT_ID: wake.env.agentId ?? "",
|
|
277
|
+
PAPERCLIP_COMPANY_ID: wake.env.companyId ?? "",
|
|
278
|
+
PAPERCLIP_RUN_ID: String(wake.runId ?? ""),
|
|
279
|
+
PAPERCLIP_TASK_ID: String(wake.taskId ?? ""),
|
|
280
|
+
} : null;
|
|
281
|
+
const runMcp = Array.isArray(wake.mcpServers) ? wake.mcpServers : [];
|
|
282
|
+
|
|
283
|
+
let entry, sessionId, note, resumed;
|
|
284
|
+
try {
|
|
285
|
+
({ entry, sessionId, resumed, note } = await router.acquire(key, wake.nativeSessionId, onUpdate, runEnv, runMcp));
|
|
286
|
+
} catch (e) {
|
|
287
|
+
write({ kind: "error", message: `会话建立失败: ${e.message}`, code: "session_setup_failed" });
|
|
288
|
+
res.end(); return;
|
|
289
|
+
}
|
|
290
|
+
write({ kind: "session", nativeSessionId: sessionId, resumed: !!resumed });
|
|
291
|
+
if (note) write({ kind: "agent_event", event: "note", data: { message: note } });
|
|
292
|
+
|
|
293
|
+
// v2:从 wake 上下文(交互卡选中项 / 审批评论)提取授权标记 → 武装跨 run 持久授权(30 分钟窗口单次使用)
|
|
294
|
+
for (const m of String(wake.prompt ?? "").matchAll(/^[ \t]*(?:[-*+][ \t]+)?approve[ \t_-]*([0-9a-f]{8})/gim)) {
|
|
295
|
+
const k = m[1].toLowerCase();
|
|
296
|
+
const pend = pendingApprovals.get(k);
|
|
297
|
+
if (pend?.cmd) { grantedApprovals.set(k, { cmd: pend.cmd, until: Date.now() + 30 * 60_000, uses: 0 }); savePermState(); }
|
|
298
|
+
}
|
|
299
|
+
if (runEnv) await armGrantsFromInteractions(wake.taskId, wake.env?.authToken).catch(() => {});
|
|
300
|
+
entry.agent.permissionPolicy = (cmd) => evalCommand(cmd, (coreKey) => {
|
|
301
|
+
const g = grantedApprovals.get(coreKey);
|
|
302
|
+
if (g && Date.now() < g.until && g.uses < 1) { g.uses++; savePermState(); return true; }
|
|
303
|
+
return false;
|
|
304
|
+
});
|
|
305
|
+
let askCount = 0;
|
|
306
|
+
entry.agent.onPermissionEvent = (info) => {
|
|
307
|
+
write({ kind: "permission", decision: info.decision, reason: info.reason, command: String(info.command ?? "").slice(0, 200) });
|
|
308
|
+
if (info.decision === "deny" && askCount < 5) {
|
|
309
|
+
askCount++;
|
|
310
|
+
queueApproval(wake.taskId, wake.runId, info, wake.env?.authToken).catch(() => {});
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// 客户端断开 → 取消本次 prompt(进程与会话保留,供续接)
|
|
315
|
+
req.on("close", () => { entry.agent.cancel(sessionId).catch(() => {}); });
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const result = await entry.agent.prompt(sessionId, wake.prompt ?? "(empty)");
|
|
319
|
+
terminal = { stopReason: result?.stopReason ?? "end_turn" };
|
|
320
|
+
const u = result?.usage ?? {};
|
|
321
|
+
usageAcc.inputTokens = u.inputTokens ?? 0;
|
|
322
|
+
usageAcc.outputTokens = u.outputTokens ?? 0;
|
|
323
|
+
usageAcc.totalTokens = u.totalTokens ?? (usageAcc.inputTokens + usageAcc.outputTokens);
|
|
324
|
+
usageAcc.cachedReadTokens = u.cachedReadTokens ?? u.cachedInputTokens ?? 0;
|
|
325
|
+
usageAcc.costUsd = computeCostUsd(entry.agent.model, u);
|
|
326
|
+
} catch (e) {
|
|
327
|
+
// BUG-11:prompt 失败/超时 → cancel + 丢弃会话条目(stop 进程 + 删条目 + 放锁),
|
|
328
|
+
// 不留僵尸进程供下轮复用(TDM-20 事故链根治);nativeSessionId 已上报平台留档,
|
|
329
|
+
// 下轮重派 respawn+resume 续接,会话状态由 opencode 落盘保留。
|
|
330
|
+
entry.agent.cancel(sessionId).catch(() => {});
|
|
331
|
+
router.discard(key);
|
|
332
|
+
write({ kind: "error", message: e.message, code: "prompt_failed" });
|
|
333
|
+
write({ kind: "done", stopReason: "error", nativeSessionId: sessionId,
|
|
334
|
+
usage: usageAcc, resumed: !!resumed });
|
|
335
|
+
res.end();
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
write({ kind: "usage", ...usageAcc, cachedInputTokens: usageAcc.cachedReadTokens }); // BUG-01:双口径下发,适配 adapter 字段名
|
|
340
|
+
write({ kind: "done", ...terminal, nativeSessionId: sessionId, resumed: !!resumed, model: entry.agent.model ?? undefined,
|
|
341
|
+
costUsd: usageAcc.costUsd !== undefined && Number.isFinite(usageAcc.costUsd) ? usageAcc.costUsd : undefined });
|
|
342
|
+
|
|
343
|
+
// 终态回调:仅旧链路(无 run 凭据)执行;新链路 agent 按 skill 心跳自行 PATCH 收尾
|
|
344
|
+
if (!runEnv) {
|
|
345
|
+
const summaryText = msgTail.join("").slice(-600).trim();
|
|
346
|
+
const comment = terminal.stopReason === "end_turn"
|
|
347
|
+
? (summaryText || `【${cfg.name}】(无文本输出)`)
|
|
348
|
+
: `【${cfg.name}】stopReason=${terminal.stopReason}\n\n${summaryText || "(无文本输出)"}`;
|
|
349
|
+
await reportDone(wake.taskId, wake.runId, comment);
|
|
350
|
+
} else if (terminal.stopReason !== "end_turn") {
|
|
351
|
+
// 新链路异常兜底:agent 未按预期运行时至少留下痕迹
|
|
352
|
+
await reportDone(wake.taskId, wake.runId,
|
|
353
|
+
`【${cfg.name}】stopReason=${terminal.stopReason}(agent 可能未能自行更新状态,请检查)`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
router.release(key);
|
|
357
|
+
res.end();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
server.listen(cfg.port, cfg.bind, () => {
|
|
361
|
+
console.log(`[tdxd] ${cfg.name} v1.1.0 就绪 ${cfg.bind}:${cfg.port}`);
|
|
362
|
+
console.log(`[tdxd] workspace=${cfg.workspace} command=${cfg.command} ${cfg.commandArgs.join(" ")}`);
|
|
363
|
+
console.log(`[tdxd] idleTTL=${cfg.idleTtlMin}min maxSessions=${cfg.maxSessions} 回调=${cfg.paperclip.url ? "启用" : "未配置"}`);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const shutdown = () => { console.log("[tdxd] 退出,回收全部会话进程"); router.stopAll(); server.close(); process.exit(0); };
|
|
367
|
+
process.on("SIGINT", shutdown);
|
|
368
|
+
process.on("SIGTERM", shutdown);
|