@yxzpro/agent-acta 2.14.4

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.
Files changed (127) hide show
  1. package/LICENSE +21 -0
  2. package/LOGFORMATS.md +960 -0
  3. package/README.md +238 -0
  4. package/REFERENCE.md +1428 -0
  5. package/agent-acta-page.html +1998 -0
  6. package/agent-acta-server.mjs +1066 -0
  7. package/agent-acta-widget.html +724 -0
  8. package/archive.mjs +335 -0
  9. package/core/log.mjs +20 -0
  10. package/core/service.mjs +4285 -0
  11. package/hooks/cursor-usage-hook.mjs +89 -0
  12. package/mcp-server.mjs +372 -0
  13. package/package.json +68 -0
  14. package/page/dialog-add-agent.js +65 -0
  15. package/page/dialog-alert.js +159 -0
  16. package/page/dialog-archive.js +342 -0
  17. package/page/dialog-compare.js +238 -0
  18. package/page/dialog-ctxw.js +101 -0
  19. package/page/dialog-diag.js +72 -0
  20. package/page/dialog-heatmap.js +215 -0
  21. package/page/dialog-selftest.js +202 -0
  22. package/page/dialog-sess-timeline.js +108 -0
  23. package/page/dialog-stats.js +694 -0
  24. package/page/dialog-usage.js +158 -0
  25. package/page/log-card.js +277 -0
  26. package/page/log-view.js +273 -0
  27. package/page/sess-view.js +382 -0
  28. package/page/shared.js +408 -0
  29. package/page/sidebar.js +129 -0
  30. package/page/style.css +973 -0
  31. package/page/topbar.js +61 -0
  32. package/parsers/atomcode.mjs +321 -0
  33. package/parsers/buddy.mjs +272 -0
  34. package/parsers/buddyext.mjs +582 -0
  35. package/parsers/claude.mjs +496 -0
  36. package/parsers/cline.mjs +468 -0
  37. package/parsers/codearts.mjs +96 -0
  38. package/parsers/codex.mjs +188 -0
  39. package/parsers/comate.mjs +195 -0
  40. package/parsers/copilot.mjs +308 -0
  41. package/parsers/cursor.mjs +493 -0
  42. package/parsers/devindb.mjs +358 -0
  43. package/parsers/discovery.mjs +1230 -0
  44. package/parsers/doubao.mjs +406 -0
  45. package/parsers/dsh.mjs +377 -0
  46. package/parsers/gemini.mjs +267 -0
  47. package/parsers/generic.mjs +494 -0
  48. package/parsers/hermes.mjs +398 -0
  49. package/parsers/kilo.mjs +42 -0
  50. package/parsers/kimi.mjs +249 -0
  51. package/parsers/mimocode.mjs +531 -0
  52. package/parsers/minimax.mjs +503 -0
  53. package/parsers/openclaw.mjs +490 -0
  54. package/parsers/opencode.mjs +465 -0
  55. package/parsers/shared.mjs +418 -0
  56. package/parsers/trace.mjs +66 -0
  57. package/parsers/tracecode.mjs +348 -0
  58. package/parsers/traedb.mjs +528 -0
  59. package/parsers/zcode.mjs +356 -0
  60. package/plugin/client.js +51 -0
  61. package/plugin/cordis.patch.yml +5 -0
  62. package/plugin/plugin.mjs +220 -0
  63. package/search.mjs +554 -0
  64. package/tools/grab-trae-key.ps1 +265 -0
  65. package/vendor/element-plus-icons.js +3 -0
  66. package/vendor/element-plus.css +1 -0
  67. package/vendor/element-plus.dark.css +1 -0
  68. package/vendor/element-plus.full.min.js +65 -0
  69. package/vendor/element-plus.zh-cn.js +2 -0
  70. package/vendor/svg/antgroup-color.svg +1 -0
  71. package/vendor/svg/bytedance-color.svg +1 -0
  72. package/vendor/svg/chatglm-color.svg +1 -0
  73. package/vendor/svg/claude-color.svg +1 -0
  74. package/vendor/svg/claudecode-color.svg +1 -0
  75. package/vendor/svg/cline.svg +1 -0
  76. package/vendor/svg/codebuddy-color.svg +1 -0
  77. package/vendor/svg/codebuddy.svg +1 -0
  78. package/vendor/svg/codex-color.svg +1 -0
  79. package/vendor/svg/copilot.svg +1 -0
  80. package/vendor/svg/cursor.svg +1 -0
  81. package/vendor/svg/deepseek-color.svg +1 -0
  82. package/vendor/svg/deepseek-harness-whale.svg +8 -0
  83. package/vendor/svg/doubao-color.svg +1 -0
  84. package/vendor/svg/gemini-color.svg +1 -0
  85. package/vendor/svg/glmv-color.svg +1 -0
  86. package/vendor/svg/grok.svg +1 -0
  87. package/vendor/svg/hermesagent.svg +1 -0
  88. package/vendor/svg/huawei-color.svg +1 -0
  89. package/vendor/svg/hunyuan-color.svg +1 -0
  90. package/vendor/svg/kilocode-avatar.svg +1 -0
  91. package/vendor/svg/kilocode.svg +1 -0
  92. package/vendor/svg/kimi-color.svg +1 -0
  93. package/vendor/svg/kimi.svg +1 -0
  94. package/vendor/svg/logo-AtomGit-G-red.svg +3 -0
  95. package/vendor/svg/logo-AtomGit-dark.svg +4 -0
  96. package/vendor/svg/logo-AtomGit-light.svg +4 -0
  97. package/vendor/svg/meta-color.svg +1 -0
  98. package/vendor/svg/mimo.svg +66 -0
  99. package/vendor/svg/minimax-code-logo.svg +8 -0
  100. package/vendor/svg/minimax-color.svg +1 -0
  101. package/vendor/svg/moonshot.svg +1 -0
  102. package/vendor/svg/nex-color.svg +1 -0
  103. package/vendor/svg/nex.svg +1 -0
  104. package/vendor/svg/nvidia-color.svg +1 -0
  105. package/vendor/svg/openai.svg +1 -0
  106. package/vendor/svg/openclaw-color.svg +1 -0
  107. package/vendor/svg/openclaw.svg +1 -0
  108. package/vendor/svg/opencode.svg +1 -0
  109. package/vendor/svg/openrouter-color.svg +1 -0
  110. package/vendor/svg/qoder-color.svg +1 -0
  111. package/vendor/svg/qwen-color.svg +1 -0
  112. package/vendor/svg/sensenova-color.svg +1 -0
  113. package/vendor/svg/stepfun.svg +1 -0
  114. package/vendor/svg/trae-color.svg +1 -0
  115. package/vendor/svg/traework-v2.svg +1 -0
  116. package/vendor/svg/wenxin-color.svg +1 -0
  117. package/vendor/svg/windsurf.svg +1 -0
  118. package/vendor/svg/workbuddy-color.svg +29 -0
  119. package/vendor/svg/xiaomimimo.svg +1 -0
  120. package/vendor/svg/yuanbao-color.svg +1 -0
  121. package/vendor/svg/yuanbao.svg +1 -0
  122. package/vendor/svg/zai.svg +1 -0
  123. package/vendor/svg/zhipu-color.svg +1 -0
  124. package/vendor/vue.global.prod.js +9 -0
  125. package/widget/main.js +202 -0
  126. package/widget/package.json +7 -0
  127. package/widget/preload.js +11 -0
@@ -0,0 +1,356 @@
1
+ // ---------------- zcode 解析器(~/.zcode/cli/db/db.sqlite,WAL 模式的 SQLite 库) ----------------
2
+ // 从 agent-acta-server.mjs 拆出(纯搬家,逻辑零改动)。导出 scanZcode / zcodeScanErr / zcodeIdBase。
3
+ //
4
+ // 表结构与关联(本机实测,见 LOGFORMATS.md 的 zcode 一节):
5
+ // session(id, parent_id, directory, title, task_type, time_created/updated/archived)
6
+ // message(id, session_id, sequence, time_created, data JSON) —— data.role = user/assistant,
7
+ // data.time.{created,completed}、data.tokens、data.error、data.modelID/providerID
8
+ // part(id, message_id, session_id, sequence, data JSON) —— data.type = text / reasoning /
9
+ // tool / step-start / step-finish / timeline / …;tool part 的出入参在 data.state.{input,output}
10
+ // session_input(payload JSON) —— 用户输入的兜底:payload.text
11
+ // 或 payload.conversationInputIntent.text,promoted_message_id 关联到 user message
12
+ // tool_usage / model_usage / turn_usage —— 逐次工具/模型/轮的真实指标
13
+ // (起止时间、duration_ms、token、status、error),靠 turn_id / tool_call_id / user_message_id
14
+ // 关联回 message 与 part
15
+ //
16
+ // 切轮:一个 role=user 的 message 开一轮(与 claude/cursor 同口径),turn_usage.user_message_id
17
+ // 正好也是这个键,两边天然对齐。turn_usage 缺行(老版本/中断)时回退到「按消息分组 + model_usage 求和」。
18
+ // 首个 user 之前的 assistant 消息(实测存在:会话头一条 assistant)挂进一个「序章」轮,不丢。
19
+ //
20
+ // 增量:SQLite 没有字节偏移语义,且 WAL 模式下新数据先落在 db.sqlite-wal(库文件本身的 mtime 不动),
21
+ // 所以签名 = db 文件与 wal 文件的 {mtime,size} 四元组,变了才整份重解析 —— 与 gemini 同一路数。
22
+ // 库很小(本机 <1MB),整份重读比维护水位线可靠;zcode 因此不进 OFF_KINDS,也就无需 PARSER_REV。
23
+ //
24
+ // 只读纪律:DatabaseSync 一律 {readOnly:true} 打开,查完即关;任何一步失败(库被锁、损坏、表缺失)
25
+ // 都保留上一轮的解析结果并记下原因(诊断页可见),绝不对库做任何写操作。
26
+ import fs from 'node:fs';
27
+ import { files, entries, srcs, removedIds, sorted, addEntry, toolNameCounts, modelName, toText, sqliteMod } from './shared.mjs';
28
+
29
+ // node:sqlite 加载器在 shared.mjs(cursor / zcode / discovery 的嗅探共用同一份实例)。
30
+ // zcode 这里只需要「拿不拿得到模块」:拿到就能扫,拿不到在 zcodeScanErr 里说明原因。
31
+
32
+ // 每个 agent 最近一次 zcode 扫描的失败原因(诊断页「已接入 · 0 条」时展示;成功则清除)
33
+ export const zcodeScanErr = new Map();
34
+
35
+ // part.data 的安全解析:损坏 JSON 不能拖垮整个会话(需求「兼容和容错」),坏了就以原文进 others
36
+ function zcodeJson(raw) { try { return JSON.parse(raw); } catch { return null; } }
37
+
38
+ function zcodeTurn(sid, key, ts) {
39
+ return {
40
+ sid, key, time: ts || 0, lastTs: ts || 0, endTs: 0,
41
+ tin: 0, tout: 0, tcache: 0, ctxUsed: 0,
42
+ models: [], callList: [], tools: [], byCall: new Map(),
43
+ user: '', texts: [], events: [], others: [],
44
+ err: false, errMsg: '', steps: 0,
45
+ };
46
+ }
47
+
48
+ // 非文本 part 的处理:工具进 tools[],有信息量的状态事件压缩后进 others[],纯占位的不留。
49
+ // 未知类型**不静默丢弃**(需求):整个 JSON 截到 2000 字进 others,详情面板「其他事件」里可看。
50
+ function zcodePart(turn, msg, part) {
51
+ const d = zcodeJson(part.data);
52
+ const t = part.time_created || msg?.time_created || 0;
53
+ if (!d) {
54
+ turn.others.push({ type: '(损坏的 JSON)', t, json: String(part.data || '').slice(0, 2000) });
55
+ return;
56
+ }
57
+ const type = typeof d.type === 'string' ? d.type : '(无 type)';
58
+ if (type === 'text') {
59
+ const s = toText(d.text);
60
+ if (!s) return;
61
+ if (msg?.role === 'user') { if (!turn.user) turn.user = s; }
62
+ else if (msg?.role === 'assistant') turn.texts.push(s);
63
+ else {
64
+ // system 或更晚出现的角色:不进「AI 输出」冒充助手正文,留结构化锚点 + sys 事件
65
+ turn.others.push({ type: (msg?.role || '未知角色') + ' 文本', t, json: JSON.stringify(s.slice(0, 2000)) });
66
+ turn.events.push({ k: 'sys', t: t || turn.time, len: s.length });
67
+ }
68
+ return;
69
+ }
70
+ if (type === 'tool') {
71
+ const st = d.state || {};
72
+ const tool = {
73
+ name: d.tool || '?', tid: d.callID || null, ct: t,
74
+ input: st.input != null ? st.input : '', output: st.output != null ? st.output : '',
75
+ error: st.status && st.status !== 'completed' ? String(st.error || st.status) : null,
76
+ dur: 0,
77
+ };
78
+ turn.tools.push(tool);
79
+ if (tool.tid) turn.byCall.set(tool.tid, tool);
80
+ return;
81
+ }
82
+ if (type === 'step-start') { turn.steps++; turn.events.push({ k: 'step', t: t || turn.time, n: turn.steps, ph: 'start' }); return; }
83
+ if (type === 'step-finish') {
84
+ // step-finish 带这一步的 token 账:model_usage 缺行时它就是调用级的兜底来源
85
+ const tk = d.tokens || {};
86
+ turn.others.push({ type, t, json: JSON.stringify({ reason: d.reason, tokens: tk }).slice(0, 2000) });
87
+ turn._stepTokens = tk; // 调用级 token 兜底(见 scanZcode 的 calls 回填)
88
+ return;
89
+ }
90
+ if (type === 'reasoning') {
91
+ // 推理正文可能是整段思考,且 metadata 里常带签名junk —— 只留长度锚点(同 dsh 对 sys 的处理)
92
+ turn.others.push({ type, t, json: JSON.stringify({ len: toText(d.text).length }) });
93
+ return;
94
+ }
95
+ // timeline / 未知类型:保留结构化 JSON(截断),在详情「其他事件」里可查
96
+ turn.others.push({ type, t, json: JSON.stringify(d).slice(0, 2000) });
97
+ }
98
+
99
+ export function scanZcode(agent, dbPath) {
100
+ const key = dbPath;
101
+ let dst, wst = null;
102
+ try { dst = fs.statSync(dbPath); } catch { zcodeScanErr.set(agent, '数据库不存在:' + dbPath); return; }
103
+ try { wst = fs.statSync(dbPath + '-wal'); } catch {} // WAL 可能已被 checkpoint 掉,没有是正常的
104
+ const sig = dst.mtimeMs + '|' + dst.size + '|' + (wst ? wst.mtimeMs + '|' + wst.size : '-');
105
+ const prev = files.get(key);
106
+ if (prev && prev.data.sig === sig) {
107
+ // 库没变就不重解析 —— 但条目可能被 LRU 淘汰过(同 dsh 的处理:按最后一轮补回)
108
+ const last = prev.data?.turns?.[prev.data.turns.length - 1];
109
+ if (last && !entries.has(zcodeIdBase(key) + last.sid.replace(/#/g, '~') + '#' + last.key)) emitZcodeTurns(agent, key, prev.data, null);
110
+ return;
111
+ }
112
+
113
+ const mod = sqliteMod();
114
+ if (!mod) { zcodeScanErr.set(agent, '当前 Node 没有 node:sqlite(需要 22.5+)'); return; }
115
+ let db = null;
116
+ try {
117
+ db = new mod.DatabaseSync(dbPath, { readOnly: true });
118
+ // 每张表单独容错:库在、但某张表缺失/损坏时,其余表照常解析,不让整库变白
119
+ const qAll = (sql) => { try { return db.prepare(sql).all(); } catch { return null; } };
120
+ const sessions = qAll('SELECT id, parent_id, directory, path, title, task_type, time_created, time_updated, time_archived FROM session ORDER BY time_created');
121
+ if (!sessions) throw new Error('读不到 session 表(schema 不兼容或被锁定)');
122
+ const messages = qAll('SELECT id, session_id, sequence, time_created, time_updated, data FROM message ORDER BY session_id, sequence') || [];
123
+ const parts = qAll('SELECT id, message_id, session_id, sequence, time_created, data FROM part ORDER BY session_id, sequence') || [];
124
+ const inputs = qAll('SELECT id, session_id, kind, status, payload, promoted_message_id, time_created FROM session_input') || [];
125
+ const toolUs = qAll('SELECT session_id, turn_id, tool_call_id, tool_name, status, started_at, completed_at, duration_ms, exit_code, error_message, stdout_bytes, stderr_bytes FROM tool_usage') || [];
126
+ const modelUs = qAll('SELECT session_id, turn_id, parent_user_message_id, assistant_message_id, provider_id, model_id, status, started_at, first_token_at, completed_at, duration_ms, time_to_first_token_ms, finish_reason, tool_call_count, input_tokens, output_tokens, reasoning_tokens, cache_creation_input_tokens, cache_read_input_tokens, error_type, error_message FROM model_usage') || [];
127
+ const turnUs = qAll('SELECT session_id, turn_id, user_message_id, status, started_at, completed_at, duration_ms, time_to_first_token_ms, input_tokens, output_tokens, reasoning_tokens, cache_creation_input_tokens, cache_read_input_tokens, tool_call_count, error_type, error_code FROM turn_usage') || [];
128
+
129
+ const sessById = new Map(sessions.map(s => [s.id, s]));
130
+ // sequence 排序的防御:真库的 sequence 是 INTEGER,但万一哪个版本写成别的(或错列),
131
+ // 退回 time_created 也得保证「同一会话内按真实先后」——切轮正确性全靠这个顺序
132
+ const seqOf = r => (typeof r.sequence === 'number' ? r.sequence : (r.time_created || 0));
133
+ messages.sort((a, b) => (a.session_id < b.session_id ? -1 : a.session_id > b.session_id ? 1 : seqOf(a) - seqOf(b)));
134
+ parts.sort((a, b) => (a.session_id < b.session_id ? -1 : a.session_id > b.session_id ? 1 : seqOf(a) - seqOf(b)));
135
+ // ---- 消息与其 part 归组 ----
136
+ const msgById = new Map();
137
+ const partsByMsg = new Map();
138
+ for (const p of parts) {
139
+ if (!partsByMsg.has(p.message_id)) partsByMsg.set(p.message_id, []);
140
+ partsByMsg.get(p.message_id).push(p);
141
+ }
142
+ const inputByMsg = new Map();
143
+ for (const si of inputs) {
144
+ const d = zcodeJson(si.payload);
145
+ const text = toText(d?.text ?? d?.conversationInputIntent?.text);
146
+ if (text && si.promoted_message_id && !inputByMsg.has(si.promoted_message_id)) inputByMsg.set(si.promoted_message_id, text);
147
+ }
148
+
149
+ // ---- 按会话切轮:role=user 开一轮,其后的 assistant 归这一轮 ----
150
+ const turns = [];
151
+ const turnByMsg = new Map(); // 任意 message id(user 或 assistant)→ 它所属的轮
152
+ const turnByUserMsg = new Map();
153
+ for (const s of sessions) {
154
+ const sMsgs = messages.filter(m => m.session_id === s.id);
155
+ // 会话级信息拍平进每一轮:emit / 详情不再回查 session 表
156
+ const proj = s.directory || s.path || '(未知项目)';
157
+ const title = (s.title || '').trim();
158
+ const name = (s.parent_id ? '⤷ ' : '') + title; // parent_id 非空 = 子 agent 会话
159
+ let cur = null;
160
+ for (const m of sMsgs) {
161
+ const d = zcodeJson(m.data) || {};
162
+ const role = typeof d.role === 'string' ? d.role : '';
163
+ const ts = typeof d.time?.created === 'number' ? d.time.created : (m.time_created || 0);
164
+ const msg = { id: m.id, role, ts, d };
165
+ msgById.set(m.id, msg);
166
+ if (role === 'user' || !cur) {
167
+ // 首个 user 之前的消息(会话头的 assistant 序章)也开一轮,key 用消息 id 兜底
168
+ cur = zcodeTurn(s.id, role === 'user' ? m.id : ('pre#' + m.id), ts);
169
+ cur.ord = turns.length;
170
+ cur.project = proj;
171
+ cur.name = name || null;
172
+ // I16 子 agent 拓扑:`session.parent_id` 非空 = 这一坨轮次属于**子 agent 会话**。
173
+ // 源头给的显式外键(子会话 id 指向父会话 id),不需要按时间区间猜父子。
174
+ // 之前只用它给标题加了个「⤷ 」前缀,父子关系本身没往条目上落,聚合层也就无从成树。
175
+ cur.parentId = s.parent_id || null;
176
+ turns.push(cur);
177
+ if (role === 'user') turnByUserMsg.set(m.id, cur);
178
+ // 时间线锚点:这一轮的用户输入时刻(dsh 的 user/message 事件同义)
179
+ if (role === 'user') cur.events.push({ k: 'user', t: ts || 0 });
180
+ }
181
+ turnByMsg.set(m.id, cur);
182
+ if (ts) { if (!cur.time || ts < cur.time) cur.time = ts; cur.lastTs = Math.max(cur.lastTs, ts); }
183
+ if (d.time?.completed) cur.endTs = Math.max(cur.endTs, d.time.completed);
184
+ // message 级错误(模型调用失败整条挂在 assistant message 上)
185
+ if (d.error && !cur.errMsg) cur.errMsg = toText(d.error?.data?.message || d.error?.message || d.error?.name || '');
186
+ // 该消息的 parts 归进当前轮
187
+ for (const p of partsByMsg.get(m.id) || []) zcodePart(cur, msg, p);
188
+ // 用户输入兜底:user message 没有 text part 时,从 session_input 补
189
+ if (role === 'user' && !cur.user && inputByMsg.has(m.id)) cur.user = inputByMsg.get(m.id);
190
+ }
191
+ }
192
+
193
+ // ---- 用量表回填(turn_id / message id 三条路都试,配不上的按时间落) ----
194
+ const turnByTurnId = new Map();
195
+ for (const tu of turnUs) {
196
+ const t = turnByUserMsg.get(tu.user_message_id);
197
+ if (t && tu.turn_id) turnByTurnId.set(tu.session_id + '|' + tu.turn_id, t);
198
+ }
199
+ const findTurn = (sid, turnId, msgIds, ts) => {
200
+ if (turnId && turnByTurnId.has(sid + '|' + turnId)) return turnByTurnId.get(sid + '|' + turnId);
201
+ for (const mid of msgIds || []) if (mid && turnByMsg.has(mid)) return turnByMsg.get(mid);
202
+ // 时间兜底:落到「包含这个时刻」的轮(轮与轮之间按 user 消息切,时间必然单调)
203
+ if (ts) {
204
+ let best = null;
205
+ for (const t of turns) {
206
+ if (t.sid !== sid || !t.time || ts < t.time) continue;
207
+ if (!best || t.time > best.time) best = t;
208
+ }
209
+ if (best) return best;
210
+ }
211
+ return null;
212
+ };
213
+
214
+ for (const tu of turnUs) {
215
+ const t = findTurn(tu.session_id, null, [tu.user_message_id], tu.started_at);
216
+ if (!t) continue;
217
+ if (tu.turn_id) turnByTurnId.set(tu.session_id + '|' + tu.turn_id, t);
218
+ // 轮级账以 turn_usage 为准(它是源头自己算的聚合);字段为 null 时不覆盖已有值
219
+ t._tu = tu;
220
+ if (tu.started_at) t.time = t.time ? Math.min(t.time, tu.started_at) : tu.started_at;
221
+ if (tu.completed_at) { t.endTs = Math.max(t.endTs, tu.completed_at); t.lastTs = Math.max(t.lastTs, tu.completed_at); }
222
+ if (tu.status && tu.status !== 'completed') { t.err = true; if (!t.errMsg && tu.error_type) t.errMsg = tu.error_type; }
223
+ }
224
+ for (const mu of modelUs) {
225
+ const t = findTurn(mu.session_id, mu.turn_id, [mu.parent_user_message_id, mu.assistant_message_id], mu.started_at);
226
+ if (!t) continue;
227
+ const mdl = modelName(mu.model_id);
228
+ const cache = (mu.cache_read_input_tokens || 0) + (mu.cache_creation_input_tokens || 0);
229
+ const failed = mu.status && mu.status !== 'completed';
230
+ const call = {
231
+ model: mdl || '(未知模型)',
232
+ tin: mu.input_tokens || 0, tout: (mu.output_tokens || 0) + (mu.reasoning_tokens || 0), tcache: cache,
233
+ dur: mu.duration_ms || (mu.started_at && mu.completed_at ? Math.max(0, mu.completed_at - mu.started_at) : 0),
234
+ };
235
+ if (failed) call.error = mu.error_message || mu.error_type || String(mu.status);
236
+ // 这次调用自己产出的正文:assistant_message_id 直接指到那条 message,它的 text parts 就是
237
+ const amsg = mu.assistant_message_id ? msgById.get(mu.assistant_message_id) : null;
238
+ if (amsg) {
239
+ const pts = (partsByMsg.get(mu.assistant_message_id) || [])
240
+ .map(p => zcodeJson(p.data)).filter(d => d && d.type === 'text').map(d => toText(d.text)).filter(Boolean);
241
+ if (pts.length) call.text = pts.join('\n');
242
+ }
243
+ t.callList.push(call);
244
+ if (mdl && !t.models.includes(mdl)) t.models.push(mdl);
245
+ const ctxNow = (mu.input_tokens || 0) + cache;
246
+ if (ctxNow > 0) t.ctxUsed = ctxNow; // 轮内最后一次胜出(与 claude/kimi 同口径,非累加)
247
+ if (mu.started_at) { if (!t.time || mu.started_at < t.time) t.time = mu.started_at; }
248
+ if (mu.completed_at) { t.lastTs = Math.max(t.lastTs, mu.completed_at); t.endTs = Math.max(t.endTs, mu.completed_at); }
249
+ // 逐事件时间线(与 dsh 同一 schema):ttft = 首 token − 发起,dur = 流式解码段
250
+ t.events.push({
251
+ k: 'llm', t: mu.started_at || t.time,
252
+ ttft: mu.first_token_at && mu.started_at ? Math.max(0, mu.first_token_at - mu.started_at) : (mu.time_to_first_token_ms || 0),
253
+ dur: mu.first_token_at && mu.completed_at ? Math.max(0, mu.completed_at - mu.first_token_at) : call.dur,
254
+ model: mdl || '', tin: call.tin, tout: call.tout, tcache: cache,
255
+ err: !!failed, blocks: call.text ? ['text'] : [],
256
+ });
257
+ if (failed && !t.errMsg && call.error) t.errMsg = toText(call.error);
258
+ }
259
+ for (const tu of toolUs) {
260
+ const t = findTurn(tu.session_id, tu.turn_id, null, tu.started_at);
261
+ if (!t) continue;
262
+ // 指标挂回 part 层面的工具(tool_call_id ↔ callID);配不上 part 的以「纯指标」工具补一条
263
+ let tool = tu.tool_call_id ? t.byCall.get(tu.tool_call_id) : null;
264
+ if (!tool) {
265
+ tool = { name: tu.tool_name || '?', tid: tu.tool_call_id || null, ct: tu.started_at || 0, input: '', output: '', error: null, dur: 0 };
266
+ t.tools.push(tool);
267
+ if (tool.tid) t.byCall.set(tool.tid, tool);
268
+ }
269
+ const failed = tu.status && tu.status !== 'completed';
270
+ tool.dur = tu.duration_ms || (tu.started_at && tu.completed_at ? Math.max(0, tu.completed_at - tu.started_at) : 0);
271
+ if (failed && !tool.error) tool.error = toText(tu.error_message || tu.error_type || (tu.exit_code ? 'exit ' + tu.exit_code : tu.status));
272
+ const i = t.tools.indexOf(tool);
273
+ t.events.push({ k: 'call', t: tu.started_at || t.time, name: tool.name, tid: tool.tid, i });
274
+ t.events.push({ k: 'result', t: tu.completed_at || (tu.started_at || t.time), tid: tool.tid, i, dur: tool.dur, error: !!failed });
275
+ if (failed) t.err = true;
276
+ }
277
+
278
+ // ---- 收尾:轮级字段定稿 ----
279
+ for (const t of turns) {
280
+ const tu = t._tu;
281
+ if (tu) {
282
+ t.tin = tu.input_tokens || 0;
283
+ t.tout = (tu.output_tokens || 0) + (tu.reasoning_tokens || 0);
284
+ t.tcache = (tu.cache_read_input_tokens || 0) + (tu.cache_creation_input_tokens || 0);
285
+ } else {
286
+ // turn_usage 缺行:回退到逐次调用求和
287
+ for (const c of t.callList) { t.tin += c.tin; t.tout += c.tout; t.tcache += c.tcache; }
288
+ }
289
+ if (!t.callList.length && t._stepTokens) {
290
+ // 连 model_usage 也没有:step-finish 的 token 账兜底一次调用(宁可少算不造假,标 1 次)
291
+ const tk = t._stepTokens;
292
+ t.callList.push({ model: t.models[0] || '(未知模型)', tin: tk.input || 0, tout: (tk.output || 0) + (tk.reasoning || 0), tcache: (tk.cache?.read || 0) + (tk.cache?.write || 0), dur: 0 });
293
+ }
294
+ // 状态:turn_usage 说错了才算错;没有 turn_usage 时「有调用且全败」才算错(与 dsh 同思路)
295
+ if (!t.err && !tu && t.callList.length && t.callList.every(c => c.error)) t.err = true;
296
+ if (t.err && !t.errMsg) t.errMsg = (t.callList.find(c => c.error) || {}).error || '';
297
+ t.user = t.user.slice(0, 3000);
298
+ const body = t.texts.join('\n\n---\n\n');
299
+ t.preview = (t.user || body || t.errMsg || '').replace(/\s+/g, ' ').slice(0, 300);
300
+ // 事件按真实时刻排序;end 事件放在最后(turn_usage 的 completed_at 或最后一条消息时刻)
301
+ t.events.push({ k: 'end', t: t.endTs || t.lastTs || t.time, reason: t.err ? 'error' : 'completed' });
302
+ t.events.sort((a, b) => (a.t - b.t) || 0);
303
+ delete t._tu; delete t._stepTokens;
304
+ }
305
+
306
+ // 全空的轮不灌条目:真库里存在「序章 assistant 消息」(首个 user 之前、无 part 无调用)与
307
+ // 打开了但没说话的轮 —— 它们会以 preview 空、calls 0、tools 0 的卡片出现在列表里,纯噪音。
308
+ // 「只有推理/步态/时间线标记」也算空(实测:每个会话的序章 assistant 消息只挂一个
309
+ // timeline=model_change part,留着就是一张全空卡片);但带未知类型 part 的轮必须留着
310
+ // (未知内容不能静默丢弃)。
311
+ const KEEP_NOISY = new Set(['reasoning', 'step-finish', 'timeline']);
312
+ const liveTurns = turns.filter(t =>
313
+ t.user || t.texts.length || t.callList.length || t.tools.length || t.errMsg ||
314
+ t.others.some(o => !KEEP_NOISY.has(o.type)));
315
+ const data = { sig, turns: liveTurns, rev: (prev?.data?.rev || 0) + 1 };
316
+ files.set(key, { agent, kind: 'zcode', m: dst.mtimeMs, s: dst.size, off: 0, data });
317
+ zcodeScanErr.delete(agent);
318
+ emitZcodeTurns(agent, key, data, prev?.data);
319
+ } catch (e) {
320
+ // 库被锁/损坏/schema 变了:保留旧数据,原因写到诊断页,绝不让一个库拖垮整轮扫描
321
+ console.error('[zcode]', e.message);
322
+ zcodeScanErr.set(agent, e.message);
323
+ } finally {
324
+ try { db && db.close(); } catch {}
325
+ }
326
+ }
327
+
328
+ export function zcodeIdBase(key) { return 'z#' + key.replace(/[\\/:]/g, '~') + '#'; }
329
+
330
+ function emitZcodeTurns(agent, key, data, prevData) {
331
+ const idBase = zcodeIdBase(key);
332
+ const ids = new Set();
333
+ data.turns.forEach((t, i) => {
334
+ const id = idBase + t.sid.replace(/#/g, '~') + '#' + t.key;
335
+ ids.add(id);
336
+ addEntry(id, {
337
+ agent, project: t.project, session: t.sid,
338
+ time: t.time, dur: Math.max(0, (t.endTs || t.lastTs) - t.time), status: t.err ? 'error' : 'ok',
339
+ tin: t.tin, tout: t.tout, tcache: t.tcache, total: t.tin + t.tout + t.tcache,
340
+ ctx: 0, ctxUsed: t.ctxUsed || null, // zcode 源头没有窗口容量字段:只显示占用,不画进度条
341
+ rounds: t.callList.length, tools: t.tools.length, calls: t.callList.length,
342
+ models: t.models, preview: t.preview, name: t.name || null,
343
+ // I16:子会话谱系(parent_id → 父会话 id;根会话不落 depth 这个键)。源头显式外键,直接透传。
344
+ parent: t.parentId || undefined, depth: t.parentId ? 1 : undefined,
345
+ finished: !!t.finished || i < data.turns.length - 1, aborted: !!t.aborted,
346
+ toolNames: toolNameCounts(t.tools),
347
+ }, { file: key, turn: i, kind: 'zcode' });
348
+ });
349
+ // 库重写/会话被删时旧条目要撤掉(同 dsh / gemini)
350
+ for (const old of prevData?.ids || []) {
351
+ if (ids.has(old)) continue;
352
+ entries.delete(old); srcs.delete(old); removedIds.push(old);
353
+ }
354
+ data.ids = [...ids];
355
+ sorted.cache = null;
356
+ }
@@ -0,0 +1,51 @@
1
+ // AgentActa 的 DSH 客户端垫片(I11 批 3)。手写纯 ESM-free 的 .js:宿主把插件 client 原样拼成普通 <script>,
2
+ // react 由浏览器的模块表提供,所以这里没有 JSX、也没有构建步骤(实测见 DSH-PLUGIN-PLAN.md §8.4/§8.6)。
3
+ // __ModuleLoader__.load 的 id 必须等于包名,且同一个 id 只能注册一次(重复注册宿主会抛)。
4
+ window.__ModuleLoader__.load({
5
+ id: '@yxzpro/agent-acta',
6
+ factory(require) {
7
+ const React = require('react');
8
+ const h = React.createElement;
9
+
10
+ const PANEL_ID = 'agent-acta';
11
+ // 面板入口:由服务端**同源递出**面板 HTML(宿主前端在 dsh-app://app,只代理 /api/*,
12
+ // 而 iframe 302 到 http 口是跨源、实测被拦成空白 —— 见 DSH-PLUGIN-PLAN.md §8.6 第七轮)。
13
+ // 页面里的 /page/、/vendor/ 引用由服务端递送时翻译成 /api/agent-acta/asset?p=…,浏览器侧一行都不用改。
14
+ const PAGE_URL = '/api/agent-acta/entry';
15
+
16
+ // 没有 icon 这个注册参数 —— 注册的组件本身就是侧栏那颗图标,宿主按 { size, active } 喂它。
17
+ function PanelIcon({ size, active }) {
18
+ const s = size || 18;
19
+ return h('svg', {
20
+ width: s, height: s, viewBox: '0 0 24 24', 'aria-hidden': true,
21
+ style: { display: 'block', opacity: active ? 1 : 0.6 },
22
+ },
23
+ h('circle', { cx: 12, cy: 12, r: 9, fill: 'none', stroke: 'currentColor', strokeWidth: 1.6 }),
24
+ h('path', { d: 'M7.5 13.5h2.2l1.4-4 1.9 6.4 1.4-2.4h2.1', fill: 'none', stroke: active ? 'currentColor' : 'currentColor', strokeWidth: 1.6, 'stroke-linejoin': 'round', 'stroke-linecap': 'round' }));
25
+ }
26
+
27
+ function Panel() {
28
+ return h('iframe', {
29
+ // 面板 HTML 必须从 /api 递出:宿主前端在 dsh-app://app 下只把 /api/* 代理到 webServer,
30
+ // 走 /page/ 之类路径会被当成宿主自己的静态资源(§8.6 实测)。同源之后 SSE 也照用。
31
+ title: 'AgentActa',
32
+ src: PAGE_URL,
33
+ style: { width: '100%', height: '100%', border: 0, display: 'block' },
34
+ });
35
+ }
36
+
37
+ return {
38
+ inject: ['slots'],
39
+ apply(ctx) {
40
+ // 任一步失败都只降级成 console.error:slot API 变了不该把宿主一起拖崩。
41
+ try {
42
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register(
43
+ { name: 'sidebar.panellist', id: PANEL_ID, order: 900, label: 'AgentActa' }, PanelIcon));
44
+ } catch (e) { console.error('[agent-acta] 侧栏入口注册失败:', e && e.message); }
45
+ try {
46
+ ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PANEL_ID }, Panel));
47
+ } catch (e) { console.error('[agent-acta] 面板注册失败:', e && e.message); }
48
+ },
49
+ };
50
+ },
51
+ });
@@ -0,0 +1,5 @@
1
+ # AgentActa 插件的 bundle patch(I11 批 4 的另一半):宿主按这份把自己插进 bundle 层。
2
+ # patch 行的 id 是短名、name 是包名,两者不是一回事 —— 浏览器模块 id 用的是包名(见 plugin/client.js 的 load({id}))。
3
+ - insert:
4
+ - id: agent-acta-panel
5
+ name: '@yxzpro/agent-acta'
@@ -0,0 +1,220 @@
1
+ // AgentActa 的 DSH 插件壳(I11 批 2)。
2
+ //
3
+ // 形状是「URL 转接 + 逐条注册」,不是第二套服务:所有 handler 一律转给 core 的 handle(req,res),
4
+ // 静态文件也走 core 原有的 routePageFile / routeVendor(那套 path.resolve + 前缀校验防逃逸只有一份)。
5
+ // 装配层的生命周期(扫描 / 落索引 / SSE 心跳 / 归档轮)由 start({mode:'hosted'}) 开,
6
+ // 但**不监听端口、不写 pid、不挂空闲自停、不退出宿主进程** —— 见 core/service.mjs 的 MODE 三态。
7
+ import net from 'node:net';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ import {
12
+ ROUTES, PAGE_DIR, ROOT, PORT, VERSION, BUILD,
13
+ handle, start, stop,
14
+ } from '../core/service.mjs';
15
+
16
+ // 插件声明名 = 包名(cordis 按这个键装配;09-30 因为 npm 上 `agentacta` 已被别人的同名项目占了,
17
+ // 包名换成作用域名 `@yxzpro/agent-acta` —— 产品名、CLI 命令 `agentacta`、面板 key 与 URL 都不跟着改)。
18
+ export const name = '@yxzpro/agent-acta';
19
+ export const inject = ['webServer'];
20
+
21
+ // 宿主里不该存在的四条:退进程 / 起 Electron 壳 / 弹 UAC 抓密钥 / 悬浮球壳。
22
+ // 它们在 core 的路由表里照旧存在(CLI 要用),只是不给宿主注册。
23
+ // 导出是给守卫对的:test/cli-behavior-guard.mjs 断言「插件实际注册的集合 ≡ ROUTES − DENY + EXTRA」。
24
+ // 不导出的话,「插件悄悄少挂一条 API」这条红线只守住了 core 那半边。
25
+ //
26
+ // '/' 也必须让出去:宿主 webServer 的兜底位(dsh-host-frontend-static)在上面挂的是
27
+ // authorizeIndex —— 壳启动时拿 GET /?token=… 换 303 + set-cookie 才算认证过。
28
+ // exact 路由优先于兜底位,插件一旦把 '/' 挂成 exact,这一问就由面板 HTML 200 应答(无 cookie),
29
+ // 壳直接判「Desktop Host authentication failed」⇒ DSH 每次开机都起不来(2026-09-30 实测)。
30
+ export const HOSTED_DENY = ['/', '/api/shutdown', '/api/client', '/api/trae/capture-key', '/widget'];
31
+ // 面板的唯一入口路径:两支(A 说明页 / hosted 递面板 HTML)都挂在这一条上,
32
+ // client.js 的 iframe src 就是它 —— 两支换路不换路径,才不会出现「图标点了但 404」。
33
+ export const ENTRY_PATH = '/api/agent-acta/entry';
34
+ // 静态资产的中转路径:`?p=page/x.js`。宿主前端在 `dsh-app://app` 下**只把 /api/* 代理到 webServer**,
35
+ // 所以面板里那些 `/page/…`、`/vendor/…` 引用在浏览器侧一律 404(curl 打宿主 http 口却是 200,别被骗)。
36
+ // 只能把它们收进 /api 命名空间,见下面的 rewriteRefs。
37
+ export const ASSET_PATH = '/api/agent-acta/asset';
38
+ export const HOSTED_EXTRA = [ENTRY_PATH, ASSET_PATH];
39
+
40
+ // 双载体探测在测试里要能换掉(守卫不能依赖「用户有没有开着 14570」这种环境状态)。
41
+ let cliProbe = probeCliService;
42
+ export function setCliProbe(fn) { cliProbe = typeof fn === 'function' ? fn : probeCliService; }
43
+
44
+ // 暂定 A(PLAN §8.8):老 CLI 服务在跑时**不另起一份扫描器**,面板如实说明并给出退出办法。
45
+ // 选 B(代理转发)时换掉这一支即可 —— 所以它是个开关,不是一处重构。
46
+ function probeCliService() {
47
+ return new Promise((resolve) => {
48
+ const s = net.connect({ host: '127.0.0.1', port: PORT, timeout: 600 });
49
+ s.on('connect', () => {
50
+ s.write('GET /api/version HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n');
51
+ let buf = '';
52
+ s.on('data', (d) => { buf += d; });
53
+ s.on('end', () => {
54
+ const m = buf.match(/\{[\s\S]*\}/);
55
+ try { const j = m ? JSON.parse(m[0]) : null; resolve(j && j.ok ? j : null); } catch { resolve(null); }
56
+ });
57
+ s.on('error', () => resolve(null));
58
+ });
59
+ s.on('timeout', () => { s.destroy(); resolve(null); });
60
+ s.on('error', () => resolve(null));
61
+ });
62
+ }
63
+
64
+ // 静态资源白名单:启动时递归列目录,而不是抄一份常量表 ——
65
+ // 抄来的清单会随页面加组件/图标而过期(vendor/svg 下 54 个图标正是先前漏掉的那批)。
66
+ function staticFiles() {
67
+ const out = [];
68
+ for (const dir of [PAGE_DIR, path.join(ROOT, 'vendor')]) {
69
+ if (!fs.existsSync(dir)) continue;
70
+ const walk = (d, pre) => {
71
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
72
+ if (e.name.startsWith('.')) continue;
73
+ const abs = path.join(d, e.name);
74
+ if (e.isDirectory()) walk(abs, pre + e.name + '/');
75
+ else out.push(pre + e.name);
76
+ }
77
+ };
78
+ const base = path.basename(dir);
79
+ walk(dir, '/' + base + '/');
80
+ }
81
+ return out;
82
+ }
83
+
84
+ // 插件到底该往宿主注册哪些路由 —— **纯函数**,不 start、不起扫描、不碰数据目录。
85
+ // 守卫要的就是这一份:拿 apply() 去数注册条式意味着在测试里真起一个 hosted 服务,
86
+ // 那会在用户的机器上多出第二个索引写者(2026-09-29 我这么试过一次,2 秒,事后无法归因)。
87
+ export function hostedRouteSpecs() {
88
+ const deny = new Set(HOSTED_DENY);
89
+ const specs = ROUTES.filter(r => !deny.has(r.path)).map(r => ({ kind: 'exact', path: r.path }));
90
+ for (const p of staticFiles()) specs.push({ kind: 'exact', path: p });
91
+ for (const p of HOSTED_EXTRA) specs.push({ kind: 'exact', path: p });
92
+ return specs;
93
+ }
94
+
95
+ // 面板引用改写:只认「紧跟引号的那个 /page/ 或 /vendor/」,换成资产路由的形式。
96
+ // 收窄到引号后面这一处有两个理由:① 注释和报错文案里的 `/page/` 不是 URL,跟着改会让人以为是新路径;
97
+ // ② 运行时拼出来的图标(`'/vendor/svg/' + name`,page/shared.js:234)也是引号开头,所以这一条规则同时覆盖静态标签和动态拼接。
98
+ // 已核过 vendor/ 内部没有任何绝对引用(Element Plus 的 url() 全是 data:/#fragment),改写不会漏掉二跳引用。
99
+ const ASSET_URL = ASSET_PATH + '?p=';
100
+ const REWRITEABLE = /^text\/(html|javascript|css)/;
101
+ export function rewriteRefs(text) {
102
+ return text.replace(/(["'])\/(page|vendor)\//g, '$1' + ASSET_URL + '$2/');
103
+ }
104
+
105
+ // 借 core 的手把文件发出去,插件只在中间加一层「URL 前缀翻译」:
106
+ // 防穿越、MIME、no-store、`__AGENT_LOG_BUILD__` / `__AGENT_LOG_SHARED__` 注入全都还是那一份实现
107
+ //(顶注那条纪律 —— 装配层不另起一套服务)。
108
+ function deliver(req, res, urlPath) {
109
+ const cap = { statusCode: 200, headers: {}, body: null };
110
+ const push = (chunk) => {
111
+ if (chunk == null) return;
112
+ cap.body = Buffer.concat([cap.body || Buffer.alloc(0), Buffer.from(chunk)]);
113
+ };
114
+ const shim = {
115
+ get statusCode() { return cap.statusCode; },
116
+ set statusCode(v) { cap.statusCode = v; },
117
+ setHeader(k, v) { cap.headers[String(k).toLowerCase()] = v; },
118
+ getHeader(k) { return cap.headers[String(k).toLowerCase()]; },
119
+ hasHeader(k) { return String(k).toLowerCase() in cap.headers; },
120
+ write: push,
121
+ end(chunk) {
122
+ push(chunk);
123
+ const type = String(cap.headers['content-type'] || '');
124
+ let out = cap.body || Buffer.alloc(0);
125
+ if (cap.statusCode === 200 && REWRITEABLE.test(type)) out = Buffer.from(rewriteRefs(out.toString('utf8')), 'utf8');
126
+ res.statusCode = cap.statusCode;
127
+ for (const [k, v] of Object.entries(cap.headers)) { try { res.setHeader(k, v); } catch {} }
128
+ res.end(out);
129
+ },
130
+ };
131
+ // 那三条目标路由('/'、/page/*、/vendor/*)都是同步 readFileSync + 同步 end,所以改 req.url、调用、再改回来是安全的;
132
+ // 若哪天它们改成异步流式输出,这里要换成复制一个 req。
133
+ const orig = req.url;
134
+ req.url = urlPath;
135
+ try { handle(req, shim); }
136
+ catch (e) { res.statusCode = 500; res.end('agent-acta: 递送失败 ' + (e && e.message)); }
137
+ finally { req.url = orig; }
138
+ }
139
+
140
+ // ?p= 只收裸相对路径(page/x.js、vendor/svg/y.svg)。带前导斜杠、带 .. 的一律不收 ——
141
+ // core 里那套 resolve + 前缀比对仍作第二层,这两层不是重复:那层防文件穿越,这层防「把资产路由当成任意路径的口子」。
142
+ const ASSET_REL = /^(?:page|vendor)\/[\w.-]+(?:\/[\w.-]+)*$/;
143
+ export function assetTarget(req) {
144
+ let p = '';
145
+ try { p = new URL(req.url, 'http://agent-acta.local').searchParams.get('p') || ''; } catch { return null; }
146
+ if (!ASSET_REL.test(p) || p.includes('..')) return null;
147
+ return '/' + p;
148
+ }
149
+
150
+ const NOTICE = (cli) => `<!doctype html><html lang="zh-CN"><meta charset="utf-8">
151
+ <title>AgentActa</title><style>body{font:14px/1.7 ui-monospace,Consolas,monospace;background:#12161d;color:#dfe6ef;padding:22px;max-width:46rem}
152
+ code{background:#ffffff14;padding:2px 6px;border-radius:4px}</style>
153
+ <body><h3>AgentActa 面板没起来 —— 命令行那版服务正在跑</h3>
154
+ <p>端口 <code>127.0.0.1:${PORT}</code> 上有一个 AgentActa 服务(版本 <code>${cli && cli.version}</code> / 指纹 <code>${cli && cli.build}</code>)。
155
+ 它和插件是同一套数据目录,两个扫描器同时跑会互相抢着写索引与归档,所以插件这次<b>没有起自己的服务</b>。</p>
156
+ <p>想看面板:直接开 <a href="http://127.0.0.1:${PORT}/">http://127.0.0.1:${PORT}/</a>(就是它);
157
+ 想让插件接手:先跑 <code>agentacta --stop</code>,再重开这个面板。</p></body></html>`;
158
+
159
+ export function apply(ctx) {
160
+ let web = null;
161
+ const registered = [];
162
+
163
+ const reg = (kind, p, h) => {
164
+ try {
165
+ web.effect(() => {
166
+ const off = web.webServer.register({ kind, path: p, handler: h });
167
+ registered.push(p);
168
+ return () => { try { if (typeof off === 'function') off(); } catch (e) { /* 卸载不该拖垮宿主 */ } };
169
+ });
170
+ } catch (e) {
171
+ console.error('[agent-acta] 路由注册失败 ' + p + ':' + (e && e.message));
172
+ }
173
+ };
174
+
175
+ // 把 inject 的 promise 交回去:宿主(和测试)要能 await 到「注册真的挂完」,
176
+ // 否则 apply() 只是「发起」了装配 —— 测试里就会看见注册表还是空的、断言空跑。
177
+ return ctx.inject(['webServer'], async (webCtx) => {
178
+ web = webCtx;
179
+ const cli = await cliProbe();
180
+
181
+ if (cli) {
182
+ // A 分支:不起 hosted、不挂业务路由,只留一条说明页。
183
+ reg('exact', ENTRY_PATH, (req, res) => {
184
+ res.statusCode = 200;
185
+ res.setHeader('content-type', 'text/html; charset=utf-8');
186
+ res.end(NOTICE(cli));
187
+ });
188
+ webCtx.logger?.info?.('[agent-acta] 检测到命令行服务在跑,插件只提供说明页');
189
+ return;
190
+ }
191
+
192
+ await start({ mode: 'hosted', listen: false });
193
+
194
+ const passthrough = (req, res) => handle(req, res);
195
+
196
+ // EXTRA 那几条有自己的 handler,不能让 passthrough 先占位:core 没这些路由,passthrough 会答 404;
197
+ // 而重复的 (kind,path) 在宿主里是 throw + **先注册的那条赢** ⇒ 真 handler 永远挂不上(302 那次就是这么坏的)。
198
+ const extraHandlers = {
199
+ [ENTRY_PATH]: (req, res) => deliver(req, res, '/'),
200
+ [ASSET_PATH]: (req, res) => {
201
+ const target = assetTarget(req);
202
+ if (!target) { res.statusCode = 400; res.setHeader('content-type', 'text/plain; charset=utf-8'); res.end('agent-acta: 资产请求的 p 参数不在白名单(要形如 page/x.js)'); return; }
203
+ deliver(req, res, target);
204
+ },
205
+ };
206
+
207
+ for (const s of hostedRouteSpecs()) {
208
+ if (extraHandlers[s.path]) continue;
209
+ reg(s.kind, s.path, passthrough);
210
+ }
211
+ for (const p of HOSTED_EXTRA) reg('exact', p, extraHandlers[p]);
212
+
213
+ // 面板入口不再 302 到宿主 http 口:iframe 从 dsh-app:// 跳 http://127.0.0.1 是跨源,实测被拦(空白),
214
+ // 而且那口上的 /page/、/vendor/ 在 iframe 里本来也到不了。改成由 ENTRY_PATH 自己把 HTML 递出来,
215
+ // 页面里的静态引用由 rewriteRefs 翻译成 ASSET_PATH —— 全程同源、单源。
216
+ webCtx.logger?.info?.('[agent-acta] hosted 就绪 v' + VERSION + ' (' + BUILD + '),路由 ' + registered.length + ' 条,入口 ' + ENTRY_PATH);
217
+
218
+ web.effect(() => () => stop('DSH 插件卸载'));
219
+ });
220
+ }