@yxzpro/agent-acta 2.14.4 → 2.14.6

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 CHANGED
@@ -14,6 +14,15 @@
14
14
 
15
15
  ## 安装与快速上手
16
16
 
17
+ ### 从 npm 装
18
+
19
+ ```bash
20
+ npm i -g @yxzpro/agent-acta # 装完多出命令 agentacta 与 agentacta-mcp
21
+ agentacta --open # 起服务并打开 http://127.0.0.1:14570
22
+ ```
23
+
24
+ ⚠ 包名带作用域 `@yxzpro/`:裸名 `agent-acta` 与 npm 上已存在的 `agentacta`(别人的同类项目)被判定太像而发不出去。**命令名不受影响**,敲的还是 `agentacta`。国内镜像同步有先后(实测 npmmirror 还没同步到,腾讯云镜像已可回源),装不上就加 `--registry=https://registry.npmjs.org/`。
25
+
17
26
  ### 拿到的是压缩包(tgz)
18
27
 
19
28
  ```bash
@@ -96,16 +105,17 @@ dsh plugin --profile desktop add "git+https://atomgit.com/elegant001/agent-acta.
96
105
  - **装完要彻底退出 DSH 再启动**(进程全没才算,只关窗口无效 —— 宿主有模块缓存)。
97
106
  - 图标没出现,先按这两条查:① 宿主按 semver 判插件兼容(不匹配只进 `skippedBundles`、**不报错**,表现就是不出现);② `--profile` 有没有指对你实际在用的那个 profile。已在 **DSH 0.1.7-rc.2 / runtime 0.2.0-rc.1** 上验通。
98
107
 
99
- **和命令行服务怎么共处**(这条最常问):插件装载那一刻会探一次 `127.0.0.1:14570`。
108
+ **和命令行服务怎么共处**(这条最常问):插件**每次请求**现判 `127.0.0.1:14570` 在不在(环回探测约 1ms,结果缓存 2 秒),所以不用你选:
100
109
 
101
110
  | 14570 | 面板给你什么 |
102
111
  |---|---|
103
- | 在跑 | 一张说明页,写明「命令行那版服务正在跑,插件没有另起自己的扫描器」—— 因为两个载体共用同一个数据目录,两套扫描/归档同时写会互相抢 |
104
- | 没在跑 | 插件自己在宿主进程里起一套(不监听端口、不写 pid、不空闲自停),面板直接可用 |
112
+ | 在跑 | **就是它的面板**,原样递进 iframe(数据、SSE、写操作全通)—— 本机仍然只有它那一个扫描器,插件不另起一套 |
113
+ | 没在跑 | 插件自己在宿主进程里起一套(不监听端口、不写 pid、不空闲自停),面板照常用 |
105
114
 
106
- 想让插件接手:`agentacta --stop` → **重启 DSH**(分支是装载时定死的,只 `--stop` 不重开,面板会停在说明页不动,那不是坏)。反过来想回浏览器/命令行用:`agentacta --ensure` 就回来。
115
+ CLI 中途退出时面板会换成一小页「命令行服务已退出」,带一个**让插件接手**按钮 —— 点了才起,不自动起:自动起可能和你又把 CLI 拉回来撞成两个写者,这种决定留给人做。
116
+ **不需要**为了看面板去 `agentacta --stop` 或重启整个 DSH(早先那版要你这么做,体验很差,已改掉)。
107
117
 
108
- **边界**(有意为之,不是漏做):插件不唤起悬浮卡片,`/api/shutdown`、`/api/client`、`/api/trae/capture-key`、`/widget` 这四条路由在宿主里根本不注册(防一次误触把宿主进程带走);面板保持自家深色皮、不跟宿主明暗切换;数据面照旧只读本机、零网络出口。细节在 REFERENCE 的「作为 DSH 插件接入」。
118
+ **边界**(有意为之,不是漏做):插件不唤起悬浮卡片,`/`(宿主的认证兜底位)、`/api/shutdown`、`/api/client`、`/api/trae/capture-key`、`/widget` 这五条路由在宿主里根本不注册(防一次误触把宿主进程带走);面板保持自家深色皮、不跟宿主明暗切换;数据面照旧只读本机、零网络出口。细节在 REFERENCE 的「作为 DSH 插件接入」。
109
119
 
110
120
  ## 页面用法速查
111
121
 
package/REFERENCE.md CHANGED
@@ -1096,7 +1096,7 @@ stdout 一行一条 JSON-RPC 消息,stderr 是诊断。回归见 `test/mcp-tes
1096
1096
  ### 装与生效
1097
1097
 
1098
1098
  ```
1099
- dsh plugin --profile desktop add "@yxzpro/agent-acta" # npm 包名(发布后可用)
1099
+ dsh plugin --profile desktop add "@yxzpro/agent-acta" # npm 包名(已发布)
1100
1100
  dsh plugin --profile desktop add "git+https://atomgit.com/elegant001/agent-acta.git#v2.14.4"
1101
1101
  dsh plugin --profile desktop add "file:C:/绝对路径/yxzpro-agent-acta-2.14.4.tgz" # 或直接给包
1102
1102
  dsh plugin --profile desktop list # 看装没装上
@@ -1109,17 +1109,20 @@ dsh plugin --profile desktop list #
1109
1109
  - 图标不出现有两类原因,别再怀疑「路由没挂上」:① 宿主按 semver 判兼容性,不匹配**只进 `skippedBundles`、不报错**;② `dsh.client.inject` 必须列出声明目标 slot 的宿主包(`@deepseek-ai/dsh-client-ui-sidebar` / `-layout`,已在本包写好),少了它服务端路由、client 模块、两条 `slots.register` 全都「成功」而图标就是不出现。
1110
1110
 
1111
1111
  本项目以 **MIT** 许可发布(`LICENSE`)。npm 上的名字是 **`@yxzpro/agent-acta`** —— 裸名 `agent-acta` 发不出去:npm 的相似性规则判它与已存在的 `agentacta`(Miraj Chokshi 的同类项目,2026-02 首发,MIT)太像,`PUT` 时直接 403 并建议改用作用域名。⚠ **这只改 npm 标识与 DSH 的 bundle 名**:CLI 命令仍是 `agentacta`,仓库名、面板标题、`/api/agent-acta/*` 那些 URL 都没动。发到 npm 之后装法可以直接写包名 —— `dsh plugin --profile desktop add "@yxzpro/agent-acta"`,这也是社区目录 `awesome-dsh-plugin.com/plugins.json` 里主流形态(4392 条中 2264 条走 npm 包名、1903 条走 `github:` 简写、`git+https` 全形式 0 条先例,详见 `DSH-PLUGIN-PLAN.md` §8.9)。代码里没有任何网络出口,也不上传日志内容:它只读你自己机器上那些 agent 已经落盘的文件。
1112
+ **已发布(2026-09-30)**:`@yxzpro/agent-acta@2.14.4` 在 npmjs 上公开可读(匿名 `GET` 包文档 200、空目录 `npm i @yxzpro/agent-acta` 实测装到 `plugin/` 三件齐、两个 bin 都在)。⚠ 镜像同步有先后:实测腾讯云镜像已能回源,**npmmirror 当时还是 404** —— 装不上就显式带 `--registry=https://registry.npmjs.org/`。
1112
1113
 
1113
- ### 面板给什么内容:装载那一刻定的二选一
1114
+ ### 面板给什么内容:每次请求现判(09-30 由「装载时二选一」改成代理)
1114
1115
 
1115
- 插件 `apply()` 时探一次 `127.0.0.1:14570`,**这个选择不会再变**(不重探):
1116
+ 插件对 `127.0.0.1:14570` 的探测是**每次请求**做的(环回探测约 1ms,结果缓存 2 秒),不是装载时定死:
1116
1117
 
1117
- | 探到 | 行为 |
1118
+ | 14570 | 行为 |
1118
1119
  |---|---|
1119
- | 有 CLI 服务在跑 | 只注册入口一条,面板显示说明页(含对方的 `version` / `build`),**不起第二套扫描器** —— 两个写者同抢 `~/.agent-acta` 的索引与归档是要出事的那种抢 |
1120
- | 没有 | `start({ mode:'hosted', listen:false })`:扫描 / 落索引 / SSE 心跳照跑,但**不监听端口、不写 pid、不挂空闲自停**,业务路由逐条注册进宿主的 `webServer` |
1120
+ | 在跑 | **代理**:入口/资产/API 全部转给那个服务,上游递出来的 HTML、JS、CSS 再过一遍 `rewriteRefs`(代理过来的面板若不改写,在 iframe 里照样白屏);`/api/events` 走**流式**透传(缓冲整条流等于把实时推送做成轮询);POST 的 body 要转出去,写操作才有效;转的时候 `host` 换成上游、剔掉 hop-by-hop 与 `content-length`。本机仍然只有它那一个扫描器 |
1121
+ | 没在跑 | 插件自己 `start({ mode:'hosted', listen:false })`:扫描 / 落索引 / SSE 心跳照跑,但不监听端口、不写 pid、不挂空闲自停 |
1121
1122
 
1122
- 想从前者换到后者:`agentacta --stop` → 重启 DSH。反过来 `agentacta --ensure` 即可回浏览器用法。
1123
+ CLI 中途退出时不自动接手,入口换成一小页带按钮的「命令行服务已退出」,点 `POST /api/agent-acta/takeover` 才起 hosted ——
1124
+ 自动起可能和「用户又把 CLI 起回来」撞成两个写者,这个决定留给人做。上游不在时代理回 **502**(不是 500、也不把插件弄崩)。
1125
+ 注册数因此是 **109** 条(`ROUTES − DENY + EXTRA + 静态`,EXTRA = 入口 / 资产 / 接手三条)。
1123
1126
 
1124
1127
  ### 为什么面板不是「打开一个 URL」,以及静态资源为什么要中转
1125
1128
 
package/core/service.mjs CHANGED
@@ -113,7 +113,15 @@ const sameModule = (a, b) => process.platform === 'win32' ? a.toLowerCase() ===
113
113
  // 入口是**包根下**的 agent-acta-server.mjs。判据不能再拿 import.meta.url 去比:那比的是 core/service.mjs,
114
114
  // 于是正常跑 CLI 时 argv[1] ≠ 它 ⇒ 判成 'lib' ⇒ 服务静默变成只读、一个字都不落盘。
115
115
  const CLI_ENTRY = path.resolve(path.join(ROOT, 'agent-acta-server.mjs'));
116
- let MODE = sameModule(path.resolve(process.argv[1] || ''), CLI_ENTRY) ? 'cli' : 'lib';
116
+ // 判据**必须过 realpath**。npm 在 mac / linux 上把 bin 装成**符号链接**(`/opt/homebrew/bin/agentacta` → 包内那个 .mjs),
117
+ // 而 node 交给 `process.argv[1]` 的是「被敲进去的那个路径」、不解析软链 ⇒ 直接字符串相等会把 CLI 判成 'lib',
118
+ // 症状极难查:**任何参数都零输出、退出码 0**(`runCli()` 压根没被调用),Windows 却一切正常 ——
119
+ // 因为 npm 在 Windows 生成的是 `.cmd` / `.ps1` shim,传给 node 的是真路径。09-30 同事在 mac 上装完就是这样。
120
+ // `real` 做成可注入:这台机(Windows,未开开发者模式)建不了软链,单测靠假 realpath 才能钉住这条语义。
121
+ export function detectMode(argv1, entryPath, real = p => { try { return fs.realpathSync(p); } catch { return p; } }) {
122
+ return sameModule(real(path.resolve(argv1 || '')), real(entryPath)) ? 'cli' : 'lib';
123
+ }
124
+ let MODE = detectMode(process.argv[1], CLI_ENTRY);
117
125
  // 诊断行的去向在这里定一次,之后全库一律走 log()(core/log.mjs):
118
126
  // cli → stdout —— 终端体验与搬家前一致(--status / --doctor 的球与文字走原来的流)
119
127
  // 其余 → stderr —— lib 的 stdout 是 importer 的**协议通道**(MCP over stdio),混进一行
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yxzpro/agent-acta",
3
- "version": "2.14.4",
3
+ "version": "2.14.6",
4
4
  "author": "elegant001",
5
5
  "repository": {
6
6
  "type": "git",
package/plugin/plugin.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  // AgentActa 的 DSH 插件壳(I11 批 2)。
2
2
  //
3
- // 形状是「URL 转接 + 逐条注册」,不是第二套服务:所有 handler 一律转给 core 的 handle(req,res),
3
+ // 形状是「URL 转接 + 逐条注册」,不是第二套服务:handler 要么转给 core 的 handle(req,res)(本机 hosted),
4
+ // 要么转给 14570 上那一个已在跑的服务(代理),两条都不自己再开扫描器。
4
5
  // 静态文件也走 core 原有的 routePageFile / routeVendor(那套 path.resolve + 前缀校验防逃逸只有一份)。
5
6
  // 装配层的生命周期(扫描 / 落索引 / SSE 心跳 / 归档轮)由 start({mode:'hosted'}) 开,
6
7
  // 但**不监听端口、不写 pid、不挂空闲自停、不退出宿主进程** —— 见 core/service.mjs 的 MODE 三态。
7
8
  import net from 'node:net';
9
+ import http from 'node:http';
8
10
  import fs from 'node:fs';
9
11
  import path from 'node:path';
10
12
 
@@ -18,7 +20,7 @@ import {
18
20
  export const name = '@yxzpro/agent-acta';
19
21
  export const inject = ['webServer'];
20
22
 
21
- // 宿主里不该存在的四条:退进程 / 起 Electron 壳 / 弹 UAC 抓密钥 / 悬浮球壳。
23
+ // 宿主里不该存在的五条:抢认证兜底的 `/`、退进程、起 Electron 壳、弹 UAC 抓密钥、悬浮球壳。
22
24
  // 它们在 core 的路由表里照旧存在(CLI 要用),只是不给宿主注册。
23
25
  // 导出是给守卫对的:test/cli-behavior-guard.mjs 断言「插件实际注册的集合 ≡ ROUTES − DENY + EXTRA」。
24
26
  // 不导出的话,「插件悄悄少挂一条 API」这条红线只守住了 core 那半边。
@@ -28,24 +30,32 @@ export const inject = ['webServer'];
28
30
  // exact 路由优先于兜底位,插件一旦把 '/' 挂成 exact,这一问就由面板 HTML 200 应答(无 cookie),
29
31
  // 壳直接判「Desktop Host authentication failed」⇒ DSH 每次开机都起不来(2026-09-30 实测)。
30
32
  export const HOSTED_DENY = ['/', '/api/shutdown', '/api/client', '/api/trae/capture-key', '/widget'];
31
- // 面板的唯一入口路径:两支(A 说明页 / hosted 递面板 HTML)都挂在这一条上,
33
+ // 面板的唯一入口路径:两支(本机 hosted 递 HTML / 代理上游递 HTML)都挂在这一条上,
32
34
  // client.js 的 iframe src 就是它 —— 两支换路不换路径,才不会出现「图标点了但 404」。
33
35
  export const ENTRY_PATH = '/api/agent-acta/entry';
34
36
  // 静态资产的中转路径:`?p=page/x.js`。宿主前端在 `dsh-app://app` 下**只把 /api/* 代理到 webServer**,
35
37
  // 所以面板里那些 `/page/…`、`/vendor/…` 引用在浏览器侧一律 404(curl 打宿主 http 口却是 200,别被骗)。
36
38
  // 只能把它们收进 /api 命名空间,见下面的 rewriteRefs。
37
39
  export const ASSET_PATH = '/api/agent-acta/asset';
38
- export const HOSTED_EXTRA = [ENTRY_PATH, ASSET_PATH];
40
+ // 「让插件接手」那条:代理模式下 CLI 服务中途停了,面板顶部会给出一个按钮,点它才起 hosted。
41
+ // 为什么要人点一下才起:自动起就可能和「用户又把 CLI 起回来」撞成两个写者,那个决定该由人负责。
42
+ export const TAKEOVER_PATH = '/api/agent-acta/takeover';
43
+ export const HOSTED_EXTRA = [ENTRY_PATH, ASSET_PATH, TAKEOVER_PATH];
39
44
 
40
45
  // 双载体探测在测试里要能换掉(守卫不能依赖「用户有没有开着 14570」这种环境状态)。
41
46
  let cliProbe = probeCliService;
42
47
  export function setCliProbe(fn) { cliProbe = typeof fn === 'function' ? fn : probeCliService; }
48
+ // 上游端口也要能换:代理那一支的测试靠它指向一个假上游,不能去碰用户真在跑的 14570。
49
+ let cliPort = PORT;
50
+ export function setCliPort(p) { const n = Number(p); cliPort = Number.isFinite(n) && n > 0 ? n : PORT; }
43
51
 
44
- // 暂定 A(PLAN §8.8):老 CLI 服务在跑时**不另起一份扫描器**,面板如实说明并给出退出办法。
45
- // 选 B(代理转发)时换掉这一支即可 —— 所以它是个开关,不是一处重构。
52
+ // PLAN §8.8 的 A/B 之择,09-30 由 A 改判成 **B(代理转发)**:A 的体感是「装了个不能用」——
53
+ // 常驻服务才是常态,于是点开面板永远只看到一页说教,而给出的处置(停服务 + 重启整个 DSH)比看日志本身贵得多。
54
+ // B 之后:CLI 在跑就把它的面板原样递进来(本机仍然只有一个扫描器),没在跑才由插件自己起 hosted。
55
+ // 「在不在跑」改成**每次请求现判**(环回探测约 1ms,结果缓存 2 秒),所以说明页上那个假版本号也一并消了。
46
56
  function probeCliService() {
47
57
  return new Promise((resolve) => {
48
- const s = net.connect({ host: '127.0.0.1', port: PORT, timeout: 600 });
58
+ const s = net.connect({ host: '127.0.0.1', port: cliPort, timeout: 600 });
49
59
  s.on('connect', () => {
50
60
  s.write('GET /api/version HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n');
51
61
  let buf = '';
@@ -61,6 +71,16 @@ function probeCliService() {
61
71
  });
62
72
  }
63
73
 
74
+ let cliCache = { at: -1e9, up: null };
75
+ const CLI_TTL = 2000;
76
+ async function cliServiceUp(force) {
77
+ const now = Date.now();
78
+ if (!force && now - cliCache.at < CLI_TTL) return cliCache.up;
79
+ const up = await cliProbe();
80
+ cliCache = { at: now, up: up || null };
81
+ return cliCache.up;
82
+ }
83
+
64
84
  // 静态资源白名单:启动时递归列目录,而不是抄一份常量表 ——
65
85
  // 抄来的清单会随页面加组件/图标而过期(vendor/svg 下 54 个图标正是先前漏掉的那批)。
66
86
  function staticFiles() {
@@ -147,14 +167,67 @@ export function assetTarget(req) {
147
167
  return '/' + p;
148
168
  }
149
169
 
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>`;
170
+ // 上游递出来的 HTML / JS / CSS 里那些 `/page/`、`/vendor/` 一样到不了浏览器(iframe 在 dsh-app:// 下),
171
+ // 所以代理这一支也必须过同一遍改写 —— 复用 rewriteRefs,不另写第二套规则。
172
+ const HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'content-length']);
173
+
174
+ export function proxy(req, res, upstreamPath) {
175
+ return new Promise((resolve) => {
176
+ const finish = () => resolve();
177
+ const up = http.request(
178
+ {
179
+ host: '127.0.0.1', port: cliPort, path: upstreamPath || req.url, method: req.method,
180
+ headers: { ...req.headers, host: '127.0.0.1:' + cliPort },
181
+ },
182
+ (ur) => {
183
+ const type = String(ur.headers['content-type'] || '');
184
+ const rewrite = ur.statusCode === 200 && REWRITEABLE.test(type);
185
+ const copyHeaders = () => {
186
+ res.statusCode = ur.statusCode;
187
+ for (const [k, v] of Object.entries(ur.headers)) if (!HOP.has(k.toLowerCase())) { try { res.setHeader(k, v); } catch {} }
188
+ };
189
+ if (!rewrite) {
190
+ // 原样透传:SSE(/api/events)靠这一支才能一段段 flush 出去,缓冲整条流等于把实时推送做成轮询
191
+ copyHeaders();
192
+ ur.on('data', (c) => { try { res.write(c); } catch {} });
193
+ ur.on('end', () => { try { res.end(); } catch {}; finish(); });
194
+ ur.on('error', () => { try { res.end(); } catch {}; finish(); });
195
+ return;
196
+ }
197
+ const chunks = [];
198
+ ur.on('data', (c) => chunks.push(c));
199
+ ur.on('end', () => {
200
+ copyHeaders();
201
+ res.end(Buffer.from(rewriteRefs(Buffer.concat(chunks).toString('utf8')), 'utf8'));
202
+ finish();
203
+ });
204
+ ur.on('error', () => { try { res.statusCode = 502; res.end('agent-acta: 上游断流'); } catch {}; finish(); });
205
+ });
206
+ up.on('error', () => {
207
+ res.statusCode = 502;
208
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
209
+ res.end('agent-acta: 连不上 127.0.0.1:' + cliPort + '(命令行服务刚好退了?重新打开面板可让插件接手)');
210
+ finish();
211
+ });
212
+ if (req.method === 'GET' || req.method === 'HEAD') up.end(); else req.pipe(up); // POST 的 body 要转出去,写操作才有效
213
+ });
214
+ }
215
+
216
+ const PAGE_STYLE = 'body{font:14px/1.7 ui-monospace,Consolas,monospace;background:#12161d;color:#dfe6ef;padding:22px;max-width:46rem}code{background:#ffffff14;padding:2px 6px;border-radius:4px}button{font:inherit;background:#2d6cdf;color:#fff;border:0;border-radius:6px;padding:8px 16px;cursor:pointer}';
217
+
218
+ // 代理模式下 CLI 中途退了才见得到这一页:给一个明确的按钮,点了才起 hosted(不自动起 —— 自动起可能和
219
+ // 「用户又把 CLI 起回来」撞成两个写者,那个决定该由人负责)。
220
+ const TAKEOVER_PAGE = () => `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>AgentActa</title>
221
+ <style>${PAGE_STYLE}</style>
222
+ <body><h3>命令行那版服务已退出</h3>
223
+ <p>插件刚才是把 <code>127.0.0.1:${cliPort}</code> 的面板原样递进来的;那个服务不在了,所以这里没有数据可读。</p>
224
+ <p>要插件自己接手(在本进程里起一套扫描与索引,仍不占端口、不写 pid):
225
+ <button id="go">让插件接手</button>
226
+ <script>document.getElementById('go').onclick=function(){var b=this;b.disabled=1;b.textContent='启动中…';
227
+ fetch('${TAKEOVER_PATH}',{method:'POST'}).then(function(r){return r.json()}).then(function(){location.reload()},function(){location.reload()})};</script>
228
+ </body></html>`;
229
+
230
+ const plain = (res, code, msg) => { res.statusCode = code; res.setHeader('content-type', 'text/plain; charset=utf-8'); res.end('agent-acta: ' + msg); };
158
231
 
159
232
  export function apply(ctx) {
160
233
  let web = null;
@@ -176,45 +249,53 @@ export function apply(ctx) {
176
249
  // 否则 apply() 只是「发起」了装配 —— 测试里就会看见注册表还是空的、断言空跑。
177
250
  return ctx.inject(['webServer'], async (webCtx) => {
178
251
  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
- }
252
+ // local = hosted 是否已在本进程起来。装载时探一次:CLI 在跑就先走代理,一个扫描器都不多起。
253
+ let local = false;
254
+ if (!(await cliServiceUp(true))) { await start({ mode: 'hosted', listen: false }); local = true; }
191
255
 
192
- await start({ mode: 'hosted', listen: false });
193
-
194
- const passthrough = (req, res) => handle(req, res);
256
+ const api = async (req, res) => {
257
+ if (local) return handle(req, res);
258
+ if (await cliServiceUp()) return proxy(req, res);
259
+ return plain(res, 503, '命令行服务已退出、插件也还没接手 —— 重新打开面板,点「让插件接手」');
260
+ };
261
+ const entry = async (req, res) => {
262
+ if (local) return deliver(req, res, '/');
263
+ if (await cliServiceUp()) return proxy(req, res, '/');
264
+ res.statusCode = 200; res.setHeader('content-type', 'text/html; charset=utf-8'); res.end(TAKEOVER_PAGE());
265
+ };
266
+ const asset = async (req, res) => {
267
+ const target = assetTarget(req);
268
+ if (!target) return plain(res, 400, '资产请求的 p 参数不在白名单(要形如 page/x.js)');
269
+ if (local) return deliver(req, res, target);
270
+ if (await cliServiceUp()) return proxy(req, res, target);
271
+ return plain(res, 503, '命令行服务已退出,插件也还没接手');
272
+ };
273
+ const takeover = async (req, res) => {
274
+ res.setHeader('content-type', 'application/json; charset=utf-8');
275
+ if (local) { res.end('{"ok":true,"already":true}'); return; }
276
+ if (await cliServiceUp(true)) { res.statusCode = 409; res.end('{"ok":false,"error":"命令行服务又在跑了,插件继续代理即可"}'); return; }
277
+ await start({ mode: 'hosted', listen: false });
278
+ local = true;
279
+ webCtx.logger?.info?.('[agent-acta] 接手:hosted 就绪 v' + VERSION + ' (' + BUILD + ')');
280
+ res.end('{"ok":true,"started":true}');
281
+ };
195
282
 
196
283
  // EXTRA 那几条有自己的 handler,不能让 passthrough 先占位:core 没这些路由,passthrough 会答 404;
197
284
  // 而重复的 (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
- };
285
+ const extraHandlers = { [ENTRY_PATH]: entry, [ASSET_PATH]: asset, [TAKEOVER_PATH]: takeover };
206
286
 
207
287
  for (const s of hostedRouteSpecs()) {
208
288
  if (extraHandlers[s.path]) continue;
209
- reg(s.kind, s.path, passthrough);
289
+ reg(s.kind, s.path, api);
210
290
  }
211
291
  for (const p of HOSTED_EXTRA) reg('exact', p, extraHandlers[p]);
212
292
 
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);
293
+ // 面板入口不 302 到宿主 http 口:iframe 从 dsh-app:// 跳 http://127.0.0.1 是跨源,实测被拦(空白)。
294
+ // 由 ENTRY_PATH 同源递 HTML(本机 hosted 那份或代理上游那份),页面里的静态引用统一翻译成 ASSET_PATH。
295
+ webCtx.logger?.info?.('[agent-acta] 就绪 v' + VERSION + ' (' + BUILD + '),路由 ' + registered.length + ' 条,'
296
+ + (local ? '本机 hosted' : '代理 127.0.0.1:' + cliPort) + ',入口 ' + ENTRY_PATH);
217
297
 
218
- web.effect(() => () => stop('DSH 插件卸载'));
298
+ // 只有真起了 hosted 才需要收尾停掉它(代理那一支没起任何东西)
299
+ web.effect(() => () => { if (local) stop('DSH 插件卸载'); });
219
300
  });
220
301
  }