@migor/cli 0.8.3 → 0.9.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/dist/dev.js CHANGED
@@ -1,87 +1,971 @@
1
1
  /**
2
- * dev 命令:热重载开发模式(tsx watch src/main.ts)+ 本地 inspector 面板。
2
+ * dev 命令:本地 inspector 面板 + **常驻 runner 子进程**。
3
3
  *
4
- * inspector 的 trace 从哪来 —— dev 起的是【子进程】,而框架的 trace sink 必须是
5
- * 进程内注册(registerDefaultTraceSink 是进程级注册表)。所以:
6
- * dev 侧起 inspector 服务 → 用 NODE_OPTIONS=--import 把 inspector-preload 注入子进程 →
7
- * preload 从【用户项目】解析 @migor/agentia 并注册 dev sink(POST /ingest)。
8
- * 框架侧完全不感知 dev:不读 env、不含 dev 逻辑(决策见 spec §10)。
4
+ * ## 形状(D9(d):harness 在 CLI 里,用户工程没有 dev 文件)
9
5
  *
10
- * 面板是增强项,任何一步失败都只告警、不阻断 dev。
6
+ * ```
7
+ * dev.ts(CLI,本文件) ← 唯一的进程所有者
8
+ * ├─ inspector 服务(面板 + POST /run) ← 面板的读写入口
9
+ * ├─ fs.watch(src/**) + fs.watch(项目根, 只认 .env) ← 「代码变了要重启」的**唯一**触发源
10
+ * └─ 子进程:node <tsx> dist/dev-runner.js ← 常驻;import 用户的 src/app.ts 并驱动它
11
+ * └─ 用户代码 → trace 经 sink POST /ingest 回面板
12
+ * ```
13
+ *
14
+ * ⚠️ 子进程是 `node <tsx/cli>`,**不是** `npx tsx` —— 后者会吞掉 IPC 通道,理由见
15
+ * `resolveTsxCli()`(那不是一条风格偏好,是一次实测出来的静默故障)。
16
+ *
17
+ * 与旧实现的三个区别,都记在 docs/plans/2026-09-22-dev-debug-loop.md:
18
+ * 1. **watch 收编进本文件**。旧实现把「文件变 → 重跑」交给 `tsx watch`,而「面板 → 重启」
19
+ * 要归 dev.ts ⇒ 两个触发源就有竞态(保存的瞬间恰好点了运行 = 双双 spawn、端口/SSE 串台)。
20
+ * 现在只有一条路径、一个所有者;顺带解掉「`.md` 不在 tsx 的 import 图里所以不被 watch」。
21
+ * 2. **子进程跑 runner 而不是用户的 main.ts**。四个控件(能力选择 / 多轮 / 工作目录 /
22
+ * prompt)全是 `createApp` / `app.run` 的选项,而**调用者**能设它们 —— CLI 成了调用者,
23
+ * 于是用户工程里一个 dev 文件都不需要。
24
+ * 3. **trace sink 在 runner 里注册**(不再用 `NODE_OPTIONS=--import` 的 preload):
25
+ * CLI 代码本来就在子进程里了,那套注入的存在理由消失了。
26
+ *
27
+ * ## 什么会重启 runner
28
+ *
29
+ * | 变化 | 动作 | 为什么 |
30
+ * |---|---|---|
31
+ * | 能力选择(`toolSources`) | **重启进程** | 它是 `createApp` 的输入;而框架没有 `AgentApp.close()`,进程内反复重建会攒孤儿 MCP 子进程 ⇒ 进程边界是唯一的回收口(D8 第三轮补充) |
32
+ * | 代码 / 文本资产变更 | **重启进程** | Node 的 import 按 URL 缓存,常驻进程收不到源码改动 |
33
+ * | 工作目录 / prompt / 多轮 / 会话 id | **不重启** | 目录是 per-run 输入,runner 内部按目录重建 app(毫秒级);prompt / 多轮 / 会话 id 本来就是 per-run |
34
+ * | 面板点「中止」 | **先不重启**(优雅 abort) | 框架的 `signal` 是协作式的 ⇒ 中止后 run 以 `stopReason='aborted'` 正常返回、**trace 保得住**;但工具若不读 `signal` 就没人理 ⇒ 5 s 后升级为重启进程兜底(`ABORT_GRACE_MS`),否则 `running` 永远为 true、面板锁死 |
35
+ * | 面板点「清空对话」 | **不重启** | 只是换 sessionId(+ 落盘),下一次 run 换个 id 而已 |
36
+ *
37
+ * 面板是增强项,任何一步失败都只告警、不阻断 dev:inspector 起不来时**降级**成
38
+ * 「首次 run + 改代码自动重启」(不 spawn 面板,但 runner 照起 —— 见 `startPanel`),
39
+ * sink 挂不上 / runner 重启失败之类一律走**响亮**通道(`warning` / `lastError` / 告警条)。
40
+ * ⚠️ 承诺的是「降级 + 说清」,不是「假装没事」:告警文案必须写出**这次少了什么**。
11
41
  */
12
42
  import { spawn } from 'node:child_process';
13
- import { existsSync } from 'node:fs';
14
- import { dirname, join } from 'node:path';
15
- import { fileURLToPath, pathToFileURL } from 'node:url';
16
- import { startInspector } from './inspector.js';
17
- import { npmSpawn } from './npm-bin.js';
18
- /** --import 可用性:Node ≥20.6(≥18.19 已回移植) */
19
- function supportsImport() {
20
- const [major, minor] = process.versions.node.split('.').map(Number);
21
- if (major > 20)
43
+ import { randomBytes } from 'node:crypto';
44
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, watch, writeFileSync, } from 'node:fs';
45
+ import { createRequire } from 'node:module';
46
+ import { dirname, join, relative, sep } from 'node:path';
47
+ import { fileURLToPath } from 'node:url';
48
+ import { HttpError, startInspector } from './inspector.js';
49
+ import { multiTurnDefault, normalizeToolSources } from './panel-logic.js';
50
+ import { APP_ENTRY_REL, DEFAULT_BUDGET, DEV_SESSION_ID, SESSION_ID_REL, SESSION_REL, } from './dev-protocol.js';
51
+ const HERE = dirname(fileURLToPath(import.meta.url));
52
+ const RUNNER = join(HERE, 'dev-runner.js');
53
+ /**
54
+ * 解析 tsx 的 CLI 入口(绝对路径)。
55
+ *
56
+ * ## 为什么不走 `npx tsx`(实测,不是风格偏好)
57
+ *
58
+ * `npx` 是个**包装器**:它自己再 spawn 一层,而**不把 fd 3 的 IPC 通道转发给孙进程**。
59
+ * 后果不是「启动失败」而是**静默**:runner 里 `process.send` 变成 `undefined`,而
60
+ * `send()` 写的是 `process.send?.()`(可选链)—— 于是所有协议消息被无声丢弃:
61
+ * 面板永远等不到 `ready`,`POST /run` 也永远回不来。更坏的是没有 IPC 通道之后事件循环
62
+ * 无事可做,进程会以 **code=0 干净退出**,看起来像「用户代码自己跑完了」。
63
+ *
64
+ * 同一个 hello 脚本的三方对照:
65
+ *
66
+ * | 启动方式 | `typeof process.send` | IPC |
67
+ * |---|---|---|
68
+ * | `node whoami.mjs` | `function` | ✅ 收到 |
69
+ * | `npx tsx whoami.mjs` | `undefined` | ❌ 无(还多出 npx 的 spinner 噪音) |
70
+ * | `node <tsx>/dist/cli.mjs whoami.mjs` | `function` | ✅ 收到 |
71
+ *
72
+ * ## 为什么不 spawn `node_modules/.bin/tsx`
73
+ *
74
+ * Windows 上它是 `.cmd` shim,会绕回 `npm-bin.ts` 专门要避开的那个 CVE-2024-27980 坑。
75
+ * 直接 `node <tsx/dist/cli.mjs>` 跨平台一致,且是**直接子进程** ⇒ IPC 天然可用。
76
+ *
77
+ * 解析顺序:**用户工程优先**(tsx 是脚手架声明的 devDependency,尊重他们的版本 pin),
78
+ * 再退到 CLI 自己的解析位置(monorepo / 全局安装)。两处都没有 ⇒ `null`,调用方响亮报错。
79
+ */
80
+ export function resolveTsxCli(root, fromCli) {
81
+ for (const base of [join(root, 'package.json'), fromCli]) {
82
+ try {
83
+ return createRequire(base).resolve('tsx/cli');
84
+ }
85
+ catch {
86
+ /* 试下一个解析位置 */
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+ /** 子进程就绪的等待上限:装配正常是毫秒级,MCP 重的工程也要不了这么久 */
92
+ const READY_TIMEOUT_MS = 60_000;
93
+ /** 子进程优雅退出的等待上限,超时补 SIGKILL(kill 必须连整棵树 —— 见 killTree) */
94
+ const KILL_GRACE_MS = 3_000;
95
+ /** 文件事件去抖:编辑器保存常常连发好几个事件(写临时文件 + rename) */
96
+ const WATCH_DEBOUNCE_MS = 150;
97
+ /**
98
+ * 「中止」发出后等它优雅收尾的宽限:超时就重启进程兜底(见 `abortRun`)。
99
+ * 取 5s —— 优雅路径要在**回合边界**才生效(在飞的模型请求先被取消),
100
+ * 给足一个回合;再长就等于按钮按了没反应。
101
+ */
102
+ const ABORT_GRACE_MS = 5_000;
103
+ /**
104
+ * 收尾的**硬上限**:超过它就硬退。
105
+ *
106
+ * 必须 > `KILL_GRACE_MS`(3 s)—— 上限的本意是兜「面板还有连接没关掉」这类卡死,
107
+ * 而不是替 SIGKILL 兜底计时器的班(那个兜底只在宽限期到点时才会发出来)。
108
+ */
109
+ const SHUTDOWN_DEADLINE_MS = KILL_GRACE_MS + 2_000;
110
+ /**
111
+ * 会触发重启的文件类型(**允许清单**,不是排除清单)。
112
+ *
113
+ * 用允许清单而不是「除了 node_modules 都算」:`.DS_Store` / 编辑器 swap / 日志会
114
+ * 让常驻进程反复重启,而那种重启看起来像「agent 自己在抖」。
115
+ * `.md` **必须在**里面 —— 文本资产(`@Prompt` 拉的 `.md`、子 agent 的 `system.md`)
116
+ * 不在 tsx 的 import 图里,旧实现因此对它们**静默无感**(D4 的 G3b)。
117
+ */
118
+ const WATCH_EXT = new Set(['.ts', '.mts', '.cts', '.tsx', '.js', '.mjs', '.cjs', '.json', '.md']);
119
+ /**
120
+ * 按文件名(而非扩展名)命中的环境变量文件。
121
+ *
122
+ * ⚠️ 它们**不在** `watchTree` 的根(`src/`)下 ⇒ 由 `watchRootEnvFiles` 单独盯。
123
+ * 早先只有 `shouldWatch` 认识这两个名字、却没有任何一个 watch 够得着它们,
124
+ * 于是「改 `.env` 会重启」是一句**没人执行**的承诺(真跑抓出来的,见 `watchRootEnvFiles`)。
125
+ */
126
+ const WATCH_NAMES = new Set(['.env', '.env.local']);
127
+ /**
128
+ * 不看的目录:产物、依赖、VCS、dev 自己的状态。
129
+ *
130
+ * ⚠️ 这道闸是**第二层**。第一层是**监视根**:`devServer` 只 `watchTree(<projectRoot>/src)`
131
+ * —— `.agentia/`(会话历史 + 当前会话 id)与 `dist/` 都在项目根,**根本不在范围内**。
132
+ * 两层都要有:只靠根的话,哪天把根改成项目根(一个很自然的想法),`.agentia/session.json`
133
+ * 就会立刻变成「每次多轮 run 重启一次子进程」的自噬循环。
134
+ */
135
+ const WATCH_SKIP = new Set(['node_modules', 'dist', '.git', '.agentia', 'coverage']);
136
+ /**
137
+ * 这个文件该不该触发重启?**导出是为了单测** —— 它是「改 `.md` 会不会重启」这条
138
+ * 承诺的唯一判据,而那条承诺正是旧实现静默失效的地方(G3b)。
139
+ */
140
+ export function shouldWatch(abs) {
141
+ const base = abs.slice(abs.lastIndexOf(sep) + 1);
142
+ if (WATCH_NAMES.has(base))
22
143
  return true;
23
- if (major === 20)
24
- return minor >= 6;
25
- if (major === 18)
26
- return minor >= 19;
27
- return false;
144
+ const dot = base.lastIndexOf('.');
145
+ return dot > 0 && WATCH_EXT.has(base.slice(dot));
146
+ }
147
+ /**
148
+ * 杀掉**整棵树**。
149
+ *
150
+ * `npx tsx` 自己还会 spawn 子进程,只 kill 直接子进程会留下孤儿(照搬 v0.7.0
151
+ * 「`close()` 保证子进程已终止」的纪律)。POSIX 下靠 `detached: true` 把子进程
152
+ * 变成进程组组长,再对**负 pid** 发信号;win32 没有进程组,用 `taskkill /T`。
153
+ */
154
+ function killTree(child, signal) {
155
+ const pid = child.pid;
156
+ if (pid === undefined)
157
+ return;
158
+ if (process.platform === 'win32') {
159
+ try {
160
+ spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
161
+ }
162
+ catch {
163
+ /* 已经退出了 */
164
+ }
165
+ return;
166
+ }
167
+ try {
168
+ process.kill(-pid, signal); // 负 pid = 整个进程组
169
+ }
170
+ catch {
171
+ try {
172
+ child.kill(signal);
173
+ }
174
+ catch {
175
+ /* 已经退出了 */
176
+ }
177
+ }
178
+ }
179
+ /**
180
+ * 这个**目录**要不要进 watch 范围?
181
+ *
182
+ * ⚠️ 判据必须由 `addDir` 自己执行,不能只留在「初始递归」那一个调用点 ——
183
+ * 动态新增目录(watcher 回调里发现新目录时调 `addDir`)走的是**另一条路**,
184
+ * 只在一处判就等于「启动时就存在的 `dist/` 不看、启动后才出现的 `dist/` 看」。
185
+ *
186
+ * 真实影响面由**监视根**兜着(`devServer` 只看 `<projectRoot>/src`,见 `WATCH_SKIP`),
187
+ * 所以它今天多半打不到 `.agentia` —— 但那是「碰巧打不到」,不是「判据成立」。
188
+ *
189
+ * 判据只看**目录名**(不看路径段):项目根本身叫 `dist` / `node_modules` 是合法的
190
+ * (见 `shouldWatch` 的同一条教训),所以 root 由调用方显式豁免。
191
+ */
192
+ function shouldDescend(dir) {
193
+ const name = dir.slice(dir.lastIndexOf(sep) + 1);
194
+ return !WATCH_SKIP.has(name) && !name.startsWith('.');
195
+ }
196
+ /**
197
+ * 文件事件去抖通知器:同一批事件只回调**第一条**。
198
+ *
199
+ * 为什么抽出来共用:**两个** watch(递归目录树 `watchTree` / 项目根的 env 文件
200
+ * `watchRootEnvFiles`)都要同一套去抖与关闭语义。各写一份就会漂 —— 比如只有一处清理
201
+ * `timer`,关闭后仍会回调一次;而那种漂的后果是「停机时又重启了一次子进程」。
202
+ */
203
+ function makeNotifier(onFile) {
204
+ let closed = false;
205
+ let timer = null;
206
+ const pending = new Set();
207
+ const flush = () => {
208
+ timer = null;
209
+ if (closed || pending.size === 0)
210
+ return;
211
+ const first = [...pending][0];
212
+ pending.clear();
213
+ onFile(first);
214
+ };
215
+ return {
216
+ note: (abs) => {
217
+ if (closed)
218
+ return;
219
+ pending.add(abs);
220
+ if (timer === null)
221
+ timer = setTimeout(flush, WATCH_DEBOUNCE_MS);
222
+ },
223
+ dispose: () => {
224
+ closed = true;
225
+ if (timer !== null)
226
+ clearTimeout(timer);
227
+ },
228
+ };
28
229
  }
29
230
  /**
30
- * @param argv 透传给用户脚本的参数(`agentia dev -- "问题"`)。脚手架把 `npm run dev` 指到本
31
- * 命令,而工程 README 文档化的用法正是 `npm run dev -- "你的问题"` —— 不能把参数吃掉。
231
+ * 递归 watch 一个目录树(`fs.watch` 的 recursive 在 Linux 上到 Node 20 才稳,
232
+ * 这里自己递归 + 新目录动态加入,跨平台同一条代码路径)。
233
+ *
234
+ * 跳过目录(`WATCH_SKIP` / 点开头)的判据由 `addDir` **内部**执行 —— 初始递归与
235
+ * 动态新增两条路共用它,见 `shouldDescend`。
236
+ *
237
+ * ⚠️ 它只看**一棵**树:`.env` / `.env.local` 在项目根、不在 `src/` 下,所以那两份由
238
+ * `watchRootEnvFiles` 单独盯(见它的说明)。
239
+ *
240
+ * **导出是为了单测**:它是「改文本资产不用重启」那条承诺的落点。
241
+ */
242
+ export function watchTree(root, onFile) {
243
+ const watchers = new Map();
244
+ let closed = false;
245
+ const notifier = makeNotifier(onFile);
246
+ const note = (abs) => {
247
+ if (closed || !shouldWatch(abs))
248
+ return;
249
+ notifier.note(abs);
250
+ };
251
+ /** @param isRoot 项目根自己 —— 它可能就叫 `dist` / `.foo`,但必须看(唯一豁免处) */
252
+ const addDir = (dir, isRoot = false) => {
253
+ if (closed || watchers.has(dir))
254
+ return;
255
+ // 跳过目录的判据在**这里**(唯一一处),不在调用点 —— 见 shouldDescend 的说明
256
+ if (!isRoot && !shouldDescend(dir))
257
+ return;
258
+ let entries;
259
+ try {
260
+ entries = readdirSync(dir, { withFileTypes: true });
261
+ }
262
+ catch {
263
+ return; // 目录刚被删掉
264
+ }
265
+ let w;
266
+ try {
267
+ w = watch(dir, { persistent: true }, (_event, name) => {
268
+ if (name === null)
269
+ return;
270
+ const abs = join(dir, String(name));
271
+ note(abs);
272
+ // 新目录要加进 watch 范围(否则新加的能力目录永远不触发重启)。
273
+ // 跳过判据由 addDir 内部执行 ⇒ 这里**不要**再判一次(两处判就会漂)。
274
+ try {
275
+ if (statSync(abs).isDirectory())
276
+ addDir(abs);
277
+ }
278
+ catch {
279
+ /* 被删了 */
280
+ }
281
+ });
282
+ }
283
+ catch {
284
+ return; // 权限 / 已被删
285
+ }
286
+ w.on('error', () => { });
287
+ watchers.set(dir, w);
288
+ for (const e of entries) {
289
+ if (e.isDirectory())
290
+ addDir(join(dir, e.name));
291
+ }
292
+ };
293
+ addDir(root, true);
294
+ return () => {
295
+ closed = true;
296
+ notifier.dispose();
297
+ for (const w of watchers.values())
298
+ w.close();
299
+ watchers.clear();
300
+ };
301
+ }
302
+ /**
303
+ * 只盯**项目根**下按文件名命中的那几份文件(`.env` / `.env.local`)。
304
+ *
305
+ * **为什么单开一个、而不是把 `watchTree` 的根抬到项目根**:`.env` 在项目根,而**其他**
306
+ * 该看的东西都在 `src/` —— 抬根会把 `README.md` / `docs/` / `examples/` 全收进来,
307
+ * 「改代码要重启」就变成「改任何文档也重启」。所以这里刻意**不递归**,且**只认名字**
308
+ * (不认扩展名:项目根的 `package.json` 同样不该由这里管)。
309
+ *
310
+ * ⚠️ 它存在的理由:`WATCH_NAMES` 曾经是一条**够不着**的判据 —— 允许清单里有 `.env`、
311
+ * `usage-guide` 也承诺「看 `.env`」,但 `watchTree` 的根是 `<projectRoot>/src`,
312
+ * 而 `.env` 在项目根 ⇒ 改 `.env` **静默无感**(与 G3b 同类:文档承诺了、代码够不着)。
313
+ * **这条只有真跑能守**:根是**调用点**决定的,`watchTree` 自己无从知道该看哪儿
314
+ * (`scripts/e2e-dev.ts` 第 9-bis 步钉着它)。
315
+ */
316
+ export function watchRootEnvFiles(root, onFile) {
317
+ const notifier = makeNotifier(onFile);
318
+ let w;
319
+ try {
320
+ w = watch(root, { persistent: true }, (_event, name) => {
321
+ if (name === null)
322
+ return;
323
+ const base = String(name);
324
+ if (!WATCH_NAMES.has(base))
325
+ return;
326
+ notifier.note(join(root, base));
327
+ });
328
+ }
329
+ catch {
330
+ return () => { }; // 项目根不可 watch(权限)—— 静默降级成「只看 src/」,与旧行为一致
331
+ }
332
+ w.on('error', () => { });
333
+ return () => {
334
+ w.close();
335
+ notifier.dispose();
336
+ };
337
+ }
338
+ /**
339
+ * 「清空对话」换到的下一个 id:`dev` → `dev-2` → `dev-3` …(导出是为了单测,同 `shouldWatch`)。
340
+ *
341
+ * 为什么是**换 id** 而不是删会话文件:`session.json` 是 `SessionStore` 的账,面板对它
342
+ * **只读**(写它会造出「面板显示的对话」与「模型真正看到的对话」不一致,见 `readSession`)。
343
+ * 而「当前用哪个 id」是 dev 环的界面状态 ⇒ 换 id 就够,且**不动别人的账**
344
+ * (旧对话还在盘上,run 列表里那些 run 也都还指得到)。
345
+ *
346
+ * 认不出的形状(用户手工改了 id 文件)⇒ 回到 `-2`,不抛:一个怪 id 不该让面板坏掉。
347
+ */
348
+ export function nextSessionId(current, base = DEV_SESSION_ID) {
349
+ // base 是**参数**(导出为单测用)⇒ 进正则前先转义。现在只有默认值走得到,
350
+ // 但「一个参数迟早被传脏值」这类坑不值得留着(`.x` 会变成「任意字符 + x」)
351
+ const esc = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
352
+ const m = new RegExp(`^${esc}(?:-(\\d+))?$`).exec(current);
353
+ if (m === null)
354
+ return `${base}-2`;
355
+ const n = m[1] === undefined ? 1 : Number(m[1]);
356
+ return `${base}-${n + 1}`;
357
+ }
358
+ /**
359
+ * @param argv 透传给 dev 环的参数:`agentia dev -- "问题"` 时 argv[0] 是**首次 run 的 prompt**。
360
+ * 脚手架把 `npm run dev` 指到本命令,而工程 README 文档化的用法正是
361
+ * `npm run dev -- "你的问题"` —— 不能把参数吃掉。
362
+ * (与旧实现一致;区别是不给参数时**不再**自动跑一次默认 prompt —— 面板就是输入口。)
32
363
  */
33
364
  export function devServer(argv = []) {
34
- const cwd = process.cwd();
35
- const entry = join(cwd, 'src', 'main.ts');
365
+ const projectRoot = process.cwd();
366
+ const entry = join(projectRoot, APP_ENTRY_REL);
36
367
  if (!existsSync(entry)) {
37
- console.error('错误:当前目录下未找到 src/main.ts,请先用 agentia create <name> 创建项目(或 cd 到项目根目录)');
368
+ // 破坏性模板变更的**响亮**失败点:老工程的 main.ts 里有 createApp,但没有 app.ts。
369
+ // 静默降级成「面板能用但什么都驱动不了」是最糟的形状,所以这里直接不启动。
370
+ const legacy = join(projectRoot, 'src', 'main.ts');
371
+ if (existsSync(legacy)) {
372
+ console.error(`错误:当前工程的 src/main.ts 存在,但没有 ${APP_ENTRY_REL}。\n` +
373
+ 'dev 环需要 app.ts 导出一个接受选项的工厂(装配与启动分离),这样 CLI 才能替你设\n' +
374
+ '「能力选择 / 工作目录 / 多轮 / prompt」这四个控件。\n' +
375
+ '迁移是机械的:把 main.ts 里 createApp(...) 那一段整体搬进 src/app.ts 并包成\n' +
376
+ 'export function createAgentApp(opts) { … },main.ts 只留「读 .env → 调工厂 → 处理 result.error」。\n' +
377
+ '见 CHANGELOG 的迁移小节,或直接照新模板(agentia create 生成的那份)改。');
378
+ process.exitCode = 1;
379
+ return 1;
380
+ }
381
+ console.error('错误:当前目录下未找到 src/app.ts,请先用 agentia create <name> 创建项目(或 cd 到项目根目录)');
38
382
  process.exitCode = 1;
39
383
  return 1;
40
384
  }
41
385
  let child = null;
42
386
  let inspector = null;
387
+ let stopWatch = null;
388
+ /** 项目根那份 `.env` / `.env.local` 的 watch —— 与 `stopWatch` 是**两个**,两者都要收 */
389
+ let stopEnvWatch = null;
43
390
  let closing = false;
44
- const forward = (sig) => () => {
45
- if (child && !child.killed)
46
- child.kill(sig);
47
- };
48
- process.on('SIGINT', forward('SIGINT'));
49
- process.on('SIGTERM', forward('SIGTERM'));
50
- const startChild = (env) => {
51
- // argv 是用户输入(`npm run dev -- "问题"`)⇒ 正是 npmSpawn 逐参数脱敏的存在理由:
52
- // win32 下走 cmd.exe 包装并转义(shell:true 不转义参数,见 npm-bin.ts)
53
- const spec = npmSpawn('npx', ['tsx', 'watch', 'src/main.ts', ...argv]);
54
- child = spawn(spec.command, spec.args, { cwd, stdio: 'inherit', env, ...spec.options });
55
- child.on('error', (err) => {
56
- console.error(`错误:启动 dev 失败:${err.message}`);
57
- process.exitCode = 1;
391
+ let running = false;
392
+ /**
393
+ * 「已经受理、但 run 还没发出去」的窄窗口标志(`submitRun` 里换能力选择要 `await restart`)。
394
+ *
395
+ * 为什么必须有它:`running` 只能在 run **真的发进通道之后**置位(它是「runner 里有个
396
+ * run 在飞」的口径,面板据此禁用按钮、`abortRun` 据此判「有没有可中止的东西」)。
397
+ * 而受理到发出之间有一次 await ⇒ 只看 `running` 的闸在「改了能力选择 ⇒ 双击运行」
398
+ * 这条路径上会**放两个请求进来**(两个 `runOnce` 并发、`currentAbort` 被覆盖、
399
+ * `pendingNote` 挂到别人的 traceId 上)。§6 待定 5 承诺的是**默认拒绝**。
400
+ *
401
+ * 刻意**不**并进 `running`(那会让面板在还没 run 时就把「中止」按钮点亮,而
402
+ * `abortRun` 此时只能回 409 —— 一个自相矛盾的面板)。
403
+ */
404
+ let launching = false;
405
+ let lastError = null;
406
+ /** 在飞 run 期间来的文件变更:等 run 收尾再重启(不在飞的 run 不该被掐掉) */
407
+ let pendingRestart = null;
408
+ let pendingNote = null;
409
+ /** 重启串行化:两次重启不能交叠(否则会留下没人管的子进程) */
410
+ let chain = Promise.resolve();
411
+ /** dev token(D0):每个 dev 会话一个,进程退出即失效 */
412
+ const token = randomBytes(16).toString('hex');
413
+ // ---------- 会话 id(面板的「清空对话」) ----------
414
+ /**
415
+ * 当前会话 id。从 `.agentia/dev-session-id` 读,读不到就用 `DEV_SESSION_ID`。
416
+ *
417
+ * 为什么要落盘:只放内存的话,用户「清空对话」之后重启 `npm run dev`,
418
+ * 刚清掉的那段对话会**自己回来** —— 静默不一致。
419
+ * 为什么另存一个文件而不是写进 `session.json`:后者是 `SessionStore` 的账,
420
+ * 面板对它是只读的(见 `readSession` 的注释)。
421
+ */
422
+ const readSessionId = () => {
423
+ try {
424
+ const raw = readFileSync(join(projectRoot, SESSION_ID_REL), 'utf8').trim();
425
+ return raw.length > 0 ? raw : DEV_SESSION_ID;
426
+ }
427
+ catch {
428
+ // 文件不存在 = 从没用过「清空」⇒ 初始 id。这里**不该**告警(首次运行是常态)
429
+ return DEV_SESSION_ID;
430
+ }
431
+ };
432
+ let sessionId = readSessionId();
433
+ const writeSessionId = (id) => {
434
+ try {
435
+ mkdirSync(join(projectRoot, '.agentia'), { recursive: true });
436
+ writeFileSync(join(projectRoot, SESSION_ID_REL), `${id}\n`);
437
+ }
438
+ catch (e) {
439
+ // 写不下去 ⇒ 换 id 只在本次进程有效(重启会退回旧 id)。响亮说清,不静默降级。
440
+ console.warn(`[agentia] 会话 id 落盘失败(${e.message}):本次「清空对话」在重启 dev 后会失效`);
441
+ }
442
+ };
443
+ /** 中止在飞 run 的兜底计时器:signal 是**协作式**的,卡死的工具不会理它(见 abortRun) */
444
+ let abortTimer = null;
445
+ const clearAbortTimer = () => {
446
+ if (abortTimer !== null) {
447
+ clearTimeout(abortTimer);
448
+ abortTimer = null;
449
+ }
450
+ };
451
+ // ---------- 子进程 ----------
452
+ const spawnChild = (toolSources) => {
453
+ // 每次 spawn 都重解析一次:tsx 是启动期才知道存不存在的东西(用户可能刚 `npm i -D tsx`),
454
+ // 缓存住会让「装好了但还报找不到」这种话变成一句谎。
455
+ const tsxCli = resolveTsxCli(projectRoot, import.meta.url);
456
+ if (tsxCli === null) {
457
+ return Promise.reject(new Error('找不到 tsx —— dev 环要用它跑 TypeScript 源码(runner 会 import 你的 src/app.ts)。\n' +
458
+ '装一个即可:npm i -D tsx(脚手架生成的工程已经声明了它;手写工程把它加进 devDependencies)'));
459
+ }
460
+ const env = {
461
+ ...process.env,
462
+ // ⚠️ cwd 必须仍是**项目根**:loadEnvFile() 按 cwd 找 .env、discover 的相对路径也按 cwd。
463
+ // 把 cwd 设成「agent 工作目录」会让 .env 静默读错 —— 工作目录走 DI 注入,不走 chdir。
464
+ AGENTIA_DEV_ROOT: projectRoot,
465
+ AGENTIA_INSPECT_PORT: String(inspector ? inspector.port : 0),
466
+ AGENTIA_INSPECT_TOKEN: token,
467
+ ...(toolSources === null ? {} : { AGENTIA_DEV_TOOL_SOURCES: JSON.stringify(toolSources) }),
468
+ };
469
+ // `process.execPath` + tsx 的 CLI 入口,而不是 `npx tsx`(理由见 resolveTsxCli):
470
+ // 直接子进程 ⇒ fd 3 的 IPC 通道可用;也顺带丢掉 npx 那一层启动开销与 spinner 噪音。
471
+ const proc = spawn(process.execPath, [tsxCli, RUNNER], {
472
+ cwd: projectRoot,
473
+ stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
474
+ // 进程组:killTree 要能一次收编整棵树(tsx 自己还会 spawn)
475
+ detached: process.platform !== 'win32',
476
+ env,
58
477
  });
59
- child.on('exit', (code, sig) => {
60
- // 子进程退出后同步退出码;被信号杀死视为正常(用户 Ctrl+C)
61
- process.exitCode = sig ? 0 : (code ?? 0);
62
- if (inspector && !closing) {
63
- closing = true;
64
- void inspector.close();
478
+ const handle = {
479
+ child: proc,
480
+ capabilities: [],
481
+ multiTurn: [],
482
+ defaultWorkdir: projectRoot,
483
+ // 就绪前只能给缺省值(真正生效的那份由 `ready` 消息报上来)
484
+ budget: { ...DEFAULT_BUDGET },
485
+ warning: null,
486
+ toolSources,
487
+ };
488
+ // 立刻挂上(不是等到 ready):ready 之前崩掉的进程也得能被 stopChild 收掉,
489
+ // 否则每失败一次就留一个孤儿 npx/tsx 进程树。
490
+ child = handle;
491
+ return new Promise((resolve, reject) => {
492
+ let settled = false;
493
+ const timer = setTimeout(() => {
494
+ killTree(proc, 'SIGKILL');
495
+ fail(`dev runner 启动超时(${READY_TIMEOUT_MS} ms 内没有就绪信号)`);
496
+ }, READY_TIMEOUT_MS);
497
+ /**
498
+ * 启动失败 ⇒ **把 child 摘掉**,不能把一个死 handle 留在那儿。
499
+ *
500
+ * 为什么这是硬要求:`restart` 的失败路径靠 `if (!child)` 判「起不来」(⇒ 回 500、
501
+ * `running` 不置位),而 spawn 出来的进程如果只发了 `'error'`(ENOENT / EACCES 那类),
502
+ * 它**不会**再发 `'exit'`、`exitCode` 恒为 `null` ⇒ 留着的话下一次 `POST /run` 会
503
+ * 往一条已断的通道 `send()`(抛 `ERR_IPC_CHANNEL_CLOSED`),且 `running` 卡在 `true`
504
+ * ⇒ 面板之后每次运行都 409。摘掉它,`if (!child)` 就能正确报「runner 起不来」。
505
+ */
506
+ const fail = (message) => {
507
+ if (settled)
508
+ return;
509
+ settled = true;
510
+ clearTimeout(timer);
511
+ if (child?.child === proc)
512
+ child = null;
513
+ reject(new Error(message));
514
+ };
515
+ proc.on('error', (err) => {
516
+ lastError = `启动 dev runner 失败:${err.message}`;
517
+ fail(lastError);
518
+ });
519
+ proc.on('message', (raw) => {
520
+ // 归属判定用**进程身份**而不是代号:迟到的消息只可能来自已经不在台上的那个进程
521
+ if (child?.child !== proc)
522
+ return;
523
+ const msg = raw;
524
+ if (msg.type === 'ready') {
525
+ handle.capabilities = msg.capabilities;
526
+ handle.multiTurn = msg.multiTurn;
527
+ handle.defaultWorkdir = msg.defaultWorkdir;
528
+ handle.budget = msg.budget;
529
+ handle.warning = msg.warning;
530
+ if (msg.warning)
531
+ console.warn(`[agentia] ${msg.warning}`);
532
+ // 广播「就绪」:面板加载时那次 /api/dev 拿到的还是初值(capabilities 为空),
533
+ // 没有这一条它就一直空着 —— 详见 dev-protocol 的 DevEvent 注释。
534
+ emit({ kind: 'runner-ready' });
535
+ if (!settled) {
536
+ settled = true;
537
+ clearTimeout(timer);
538
+ resolve(handle);
539
+ }
540
+ return;
541
+ }
542
+ if (msg.type === 'run-start') {
543
+ running = true;
544
+ return;
545
+ }
546
+ if (msg.type === 'run-done') {
547
+ running = false;
548
+ clearAbortTimer();
549
+ if (msg.traceId !== null && pendingNote !== null) {
550
+ inspector?.noteRun(msg.traceId, pendingNote);
551
+ }
552
+ pendingNote = null;
553
+ // 中止的 run 也带 error(`abortedResult()` 刻意如此)⇒ `ok` 是 false。
554
+ // 但它**不是**「环坏了」,不该在告警条上留一条永远不消的红字(判别只看 stopReason)。
555
+ if (!msg.ok && msg.stopReason !== 'aborted')
556
+ lastError = msg.error;
557
+ emit({
558
+ kind: 'run-done',
559
+ traceId: msg.traceId,
560
+ ok: msg.ok,
561
+ stopReason: msg.stopReason,
562
+ error: msg.error,
563
+ finalText: msg.finalText,
564
+ });
565
+ void afterRun();
566
+ return;
567
+ }
568
+ if (msg.type === 'run-error') {
569
+ running = false;
570
+ clearAbortTimer();
571
+ pendingNote = null;
572
+ lastError = msg.message;
573
+ emit({ kind: 'runner-error', message: msg.message });
574
+ void afterRun();
575
+ }
576
+ });
577
+ proc.on('exit', (code, sig) => {
578
+ const isCurrent = child?.child === proc;
579
+ if (isCurrent)
580
+ child = null;
581
+ const how = `code=${code ?? 'null'}${sig ? `, signal=${sig}` : ''}`;
582
+ if (!settled) {
583
+ // 没等到 ready 就退了 ⇒ 这是**启动失败**,不是「跑着跑着意外退出」。
584
+ // ⚠️ 必须先把原因写进 lastError 再往下走:下面的 emit 是同步执行的,而
585
+ // fail() 的 reject 要等到下一个微任务才被调用方的 catch 接住 —— 先 reject
586
+ // 会让面板收到一句更笼统的「意外退出」,把真正的原因挤成只出现在终端里。
587
+ lastError = `dev runner 退出(${how})—— 多半是用户代码 import 期就抛了,看上面的输出`;
588
+ fail(lastError);
589
+ }
590
+ if (!isCurrent || closing)
591
+ return;
592
+ running = false;
593
+ clearAbortTimer();
594
+ lastError = lastError ?? `dev runner 意外退出(${how})`;
595
+ emit({ kind: 'runner-error', message: lastError });
596
+ });
597
+ });
598
+ };
599
+ const stopChild = async () => {
600
+ const handle = child;
601
+ child = null;
602
+ if (!handle)
603
+ return;
604
+ // ⚠️ 子进程要没了 ⇒ **不可能再有在飞 run**,必须把 running 落下来。
605
+ // 为什么这是硬要求:`proc.on('exit')` 的归属判定(`child?.child === proc`)此刻已经
606
+ // 为假(上一行刚把 child 置 null),所以 exit 处理器会**早退**、不会帮忙清 running。
607
+ // 少了这一行,「中止超时 ⇒ 重启兜底」之后 running 永远是 true ⇒ 之后每次 POST /run
608
+ // 都 409 —— 面板**照样锁死**,只是从「卡在 run 上」变成「卡在 running 标志上」。
609
+ // (唯一会在 run 在飞时杀子进程的路径就是这条兜底:文件变更那条走 pendingRestart 延后。)
610
+ running = false;
611
+ clearAbortTimer();
612
+ const proc = handle.child;
613
+ if (proc.exitCode !== null || proc.signalCode !== null)
614
+ return;
615
+ await new Promise((done) => {
616
+ const timer = setTimeout(() => {
617
+ killTree(proc, 'SIGKILL');
618
+ done();
619
+ }, KILL_GRACE_MS);
620
+ proc.once('exit', () => {
621
+ clearTimeout(timer);
622
+ done();
623
+ });
624
+ killTree(proc, 'SIGTERM');
625
+ });
626
+ };
627
+ /** 重启 = 停旧的 + 起新的;串行化,且把「重启」这件事广播给面板(不静默) */
628
+ const restart = (reason, toolSources) => {
629
+ chain = chain.then(async () => {
630
+ if (closing)
631
+ return;
632
+ emit({ kind: 'runner-restart', reason });
633
+ await stopChild();
634
+ try {
635
+ await spawnChild(toolSources);
636
+ }
637
+ catch (e) {
638
+ lastError = e.message;
639
+ emit({ kind: 'runner-error', message: lastError });
65
640
  }
66
641
  });
642
+ return chain;
67
643
  };
68
- if (!supportsImport()) {
69
- console.warn(`[agentia] Node ${process.versions.node} 不支持 --import(需 ≥20.6 或 ≥18.19),inspector 面板不可用,dev 照常`);
70
- startChild(process.env);
71
- return 0;
72
- }
73
- startInspector()
74
- .then((srv) => {
75
- inspector = srv;
76
- const here = dirname(fileURLToPath(import.meta.url));
77
- const preload = pathToFileURL(join(here, 'inspector-preload.js')).href;
78
- const merged = `${process.env.NODE_OPTIONS ? process.env.NODE_OPTIONS + ' ' : ''}--import "${preload}"`;
79
- console.log(`Inspector: http://127.0.0.1:${srv.port} (看每次 run 的调用树与能力执行)`);
80
- startChild({ ...process.env, NODE_OPTIONS: merged, AGENTIA_INSPECT_PORT: String(srv.port) });
81
- })
82
- .catch((err) => {
83
- console.warn(`[agentia] inspector 起不来(${err.message}),dev 照常运行`);
84
- startChild(process.env);
644
+ /** run 收尾后处理「在飞期间攒下的重启请求」 */
645
+ const afterRun = () => {
646
+ if (pendingRestart === null || closing)
647
+ return Promise.resolve();
648
+ const reason = pendingRestart;
649
+ pendingRestart = null;
650
+ return restart(reason, child?.toolSources ?? null);
651
+ };
652
+ // ---------- 面板侧 ----------
653
+ const emit = (ev) => inspector?.emitDev(ev);
654
+ const devState = () => ({
655
+ available: true,
656
+ projectRoot,
657
+ home: process.env.HOME ?? '',
658
+ capabilities: child?.capabilities ?? [],
659
+ multiTurn: child?.multiTurn ?? [],
660
+ warning: child?.warning ?? null,
661
+ defaultWorkdir: child?.defaultWorkdir ?? projectRoot,
662
+ // 生效值由 runner 报(就绪前是缺省值)—— 面板显示与实际生效必须是同一份
663
+ budget: child?.budget ?? { ...DEFAULT_BUDGET },
664
+ sessionId,
665
+ running,
666
+ lastError,
85
667
  });
668
+ const submitRun = async (req) => {
669
+ // ⚠️ 闸必须**同时**看 `launching`:受理到 run 真正发出去之间有 `await restart(...)`,
670
+ // 只判 `running` 的话「改了能力选择 ⇒ 双击运行」的两个请求会双双通过 ⇒
671
+ // runner 里两个 `runOnce` 并发、`currentAbort` 被覆盖、`pendingNote` 挂错 traceId。
672
+ // §6 待定 5 承诺的是**默认拒绝**,而这条正是当初漏掉的路径。
673
+ if (running || launching) {
674
+ throw new HttpError(409, '上一次 run 还在跑。等它结束,或先「中止」它再发新的(并发语义见计划 §6 待定 5)');
675
+ }
676
+ // 上一轮中止留下的兜底计时器不该跨到这一轮来(否则会掐掉一次正常的 run)
677
+ clearAbortTimer();
678
+ const caps = child?.capabilities ?? [];
679
+ const selected = req.toolSources ?? caps;
680
+ const toolSources = normalizeToolSources(selected, caps);
681
+ const workdir = req.workdir && req.workdir.length > 0 ? req.workdir : devState().defaultWorkdir;
682
+ if (!existsSync(workdir) || !statSync(workdir).isDirectory()) {
683
+ // 响亮失败:目录写错时**不能**退回项目根 —— 那会让 agent 对着错的目录乱写(D5)
684
+ throw new HttpError(400, `工作目录不存在或不是文件夹:${workdir}`);
685
+ }
686
+ const defaults = multiTurnDefault(toolSources ?? caps, child?.multiTurn ?? []);
687
+ const multiTurn = req.multiTurn ?? defaults.value;
688
+ let restarted = false;
689
+ const prev = child?.toolSources ?? null;
690
+ const same = (prev === null && toolSources === undefined) ||
691
+ (prev !== null &&
692
+ toolSources !== undefined &&
693
+ prev.join('\u0000') === toolSources.join('\u0000'));
694
+ // 占位:从这里到「run 发出」之间不能让第二个请求通过上面的闸(见 launching 的说明)。
695
+ // 校验已经做完(工作目录在上一行之前判过)⇒ 占位期间不会有 400 把标志留在 true 上。
696
+ launching = true;
697
+ try {
698
+ if (!child || !same) {
699
+ // 换能力选择 ⇒ 重启进程(D8:理由是回收口,不是成本)。不重启的话旧 app 的
700
+ // MCP 子进程没人收编 —— 框架没有 AgentApp.close()。
701
+ restarted = true;
702
+ await restart('能力选择变化(toolSources)', toolSources ?? null);
703
+ if (!child)
704
+ throw new HttpError(500, lastError ?? 'runner 起不来');
705
+ }
706
+ pendingNote = {
707
+ prompt: req.prompt,
708
+ workdir,
709
+ toolSources: toolSources ?? null,
710
+ multiTurn,
711
+ };
712
+ running = true; // 乐观置位:连点两次在 runner ack 之前就该被拒
713
+ // 会话 id 由**父进程**给:它是面板级状态(「清空对话」换的就是它),
714
+ // 且必须跨 runner 重启稳定 —— 改代码会重启 runner,但不该丢掉会话。
715
+ // (面板**不能**指定它,见 `RunRequest` 里那条说明。)
716
+ let sent = false;
717
+ try {
718
+ sent = child.child.send({
719
+ type: 'run',
720
+ request: {
721
+ prompt: req.prompt,
722
+ workdir,
723
+ multiTurn,
724
+ sessionId,
725
+ toolSources: toolSources ?? null,
726
+ },
727
+ });
728
+ }
729
+ catch (e) {
730
+ // 通道刚断开时 `send()` **会抛**(`ERR_IPC_CHANNEL_CLOSED`),不只是返回 false。
731
+ // 抛的那条会跳过下面所有复位 ⇒ `running` 永久留成 true ⇒ 面板之后每次
732
+ // `POST /run` 都 409(「面板锁死」换了个地方复现)。统一翻译成 500。
733
+ throw new HttpError(500, `runner 通道已断(${e.message}),稍后再试`);
734
+ }
735
+ if (!sent)
736
+ throw new HttpError(500, 'runner 通道已断(进程可能刚退出),稍后再试');
737
+ emit({
738
+ kind: 'run-start',
739
+ prompt: req.prompt,
740
+ workdir,
741
+ toolSources: toolSources ?? null,
742
+ multiTurn,
743
+ });
744
+ return { accepted: true, restarted };
745
+ }
746
+ catch (e) {
747
+ // 任何一步失败都必须把两处状态复位:留下的 `running = true` 会让面板**永久** 409,
748
+ // 而留下的 `pendingNote` 会挂到下一轮 run 的 traceId 上(张冠李戴)。
749
+ running = false;
750
+ pendingNote = null;
751
+ throw e;
752
+ }
753
+ finally {
754
+ launching = false;
755
+ }
756
+ };
757
+ /**
758
+ * 中止在飞 run(§6 待定 5 的「显式 kill 按钮」)。
759
+ *
760
+ * 两条路,按顺序:
761
+ * 1. **优雅**:给 runner 发 `run-abort`,它 abort 掉 `app.run` 的 `signal` ⇒ 引擎在回合边界
762
+ * 以 `stopReason='aborted'` **正常返回** ⇒ **trace 照常落盘**(框架契约:不抛异常)。
763
+ * 2. **兜底**:`signal` 是**协作式**的 —— 工具若不读 `ToolRunContext.signal`(本仓已知
764
+ * 「MCP 在途中止 ⇒ Promise 永不 settle」那类),abort 就没人理。那就重启进程收编它
765
+ * (代价是这次 run 的 trace 丢掉,但**面板不至于永久锁死**:`running` 一直是 true 的话,
766
+ * 之后每次 `POST /run` 都 409 —— 那才是真正没法用的状态)。
767
+ */
768
+ const abortRun = async () => {
769
+ if (!running || !child)
770
+ throw new HttpError(409, '当前没有在飞的 run');
771
+ // 幂等:中止已发出、升级计时器还挂着时再点一次,**不该再挂一个计时器** ——
772
+ // 两个计时器到点会触发两次 restart,第二次打在一个刚重启好的 runner 上
773
+ // (那会把用户刚跑起来的 run 又掐掉一次)。
774
+ if (abortTimer !== null)
775
+ return { accepted: true, escalated: false };
776
+ const proc = child.child;
777
+ const sent = proc.send({ type: 'run-abort' });
778
+ if (!sent)
779
+ throw new HttpError(500, 'runner 通道已断(进程可能刚退出),稍后再试');
780
+ const escalated = await new Promise((resolve) => {
781
+ let tick = null;
782
+ abortTimer = setTimeout(() => {
783
+ abortTimer = null;
784
+ if (tick !== null)
785
+ clearInterval(tick);
786
+ // 还在跑 ⇒ signal 没人理。这时**必须**升级,否则面板锁死。
787
+ // 也记进 lastError:被掐掉的 run 是个「发生过的事」,不该只闪一下就没了
788
+ // (面板的通知会被下一次操作覆盖,刷新页面就再也看不到)。
789
+ lastError = '中止超时(工具不响应 signal)⇒ 已重启 runner 兜底:这次 run 的 trace 丢了';
790
+ void restart('中止超时:run 不响应 signal(工具可能不可取消)', child?.toolSources ?? null);
791
+ resolve(true);
792
+ }, ABORT_GRACE_MS);
793
+ // 正常收尾(run-done / run-error)会把计时器清掉并让这里 resolve(false)
794
+ tick = setInterval(() => {
795
+ if (!running) {
796
+ if (tick !== null)
797
+ clearInterval(tick);
798
+ clearAbortTimer();
799
+ resolve(false);
800
+ }
801
+ }, 50);
802
+ tick.unref();
803
+ });
804
+ return { accepted: true, escalated };
805
+ };
806
+ /**
807
+ * 清空对话 = **换一个 sessionId**(§6 待定 3)。
808
+ *
809
+ * 不删 `session.json` 里的东西:那是 `SessionStore` 的账,面板对它是只读的
810
+ * (见 `readSession`)。旧对话留在盘上,run 列表里那些 run 也还指得到它 ——
811
+ * 而新的 run 用新 id ⇒ 模型看到的上下文是空的。
812
+ */
813
+ const clearSession = async () => {
814
+ if (running) {
815
+ throw new HttpError(409, '有 run 在飞时不能清空对话(它会写回当前会话)。先中止或等它结束');
816
+ }
817
+ sessionId = nextSessionId(sessionId);
818
+ writeSessionId(sessionId);
819
+ return { sessionId };
820
+ };
821
+ const readSession = async () => {
822
+ // 面板**只读**会话文件:它记的是「我发了什么」,而模型看到的是「store 里有什么」,
823
+ // 两者一旦漂开(历史被裁剪、run 失败没回写、手工改了文件),面板就会显示一份
824
+ // **不存在的对话** —— 正是本仓一直在猎的那类静默不一致。
825
+ // 路径取 dev-protocol 的常量,不在这里就地写字符串 —— 就地写一份就是
826
+ // 「同一个事实两个写法」,而这两处漂开的后果是**面板显示一份不存在的对话**。
827
+ // ⚠️ 这条耦合是**约定**而非机制:`SESSION_REL` 必须与用户工程 `src/app.ts` 的
828
+ // `SESSION_FILE` 一致(模板里两者是一致的)。要根治得让 runner 把真实路径报回来。
829
+ const file = join(projectRoot, SESSION_REL);
830
+ if (!existsSync(file))
831
+ return null;
832
+ try {
833
+ const { readFile } = await import('node:fs/promises');
834
+ const parsed = JSON.parse(await readFile(file, 'utf8'));
835
+ // 读**当前** id(不是常量):清空对话之后面板必须立刻显示空,而不是旧对话
836
+ const messages = parsed.sessions?.[sessionId];
837
+ return { messages: Array.isArray(messages) ? messages : [] };
838
+ }
839
+ catch (e) {
840
+ throw new HttpError(500, `读会话文件失败:${e.message}`);
841
+ }
842
+ };
843
+ // ---------- 收尾 ----------
844
+ /**
845
+ * 收尾:停 watch、收**整棵**子进程树、关面板。
846
+ *
847
+ * 返回 Promise 而不是 fire-and-forget:信号处理器要等它真的做完才 `exit` —— 理由见
848
+ * `shutdownAndExit`(这是个实测出来的坑,不是风格)。
849
+ */
850
+ const shutdown = async () => {
851
+ if (closing)
852
+ return;
853
+ closing = true;
854
+ stopWatch?.();
855
+ stopEnvWatch?.();
856
+ try {
857
+ await stopChild();
858
+ }
859
+ finally {
860
+ if (inspector)
861
+ await inspector.close();
862
+ }
863
+ };
864
+ let shuttingDown = false;
865
+ /**
866
+ * 收尾后退出(SIGINT / SIGTERM 共用同一条出口)。
867
+ *
868
+ * ## 为什么不能沿用「固定 50 ms 后 `process.exit(0)`」(实测,不是风格偏好)
869
+ *
870
+ * `stopChild()` 给子进程的宽限期是 `KILL_GRACE_MS`(3 s)—— 它 SIGTERM 之后等 3 s
871
+ * 才补 SIGKILL。而固定 50 ms 就 exit 会让父进程在这 3 s 之前消失:
872
+ * - 那段宽限连同 **SIGKILL 兜底**一起没了(计时器随父进程消失);
873
+ * - `process.on('exit')` 那条兜底此刻也是空操作 —— `stopChild` 第一行就把 `child`
874
+ * 置成了 null,而它的归属判定是 `child?.child === proc`。
875
+ * ⇒ 一个**不响应 SIGTERM** 的 runner(连同它自己 spawn 的 MCP 子进程)活成孤儿。
876
+ * SIGTERM 路径没有这个问题(它不主动退出、等事件循环自然空),两条路径口径必须一致。
877
+ *
878
+ * 上限仍然要有:面板若还有挂着的连接,收尾可能卡住 ⇒ `SHUTDOWN_DEADLINE_MS` 到点硬退;
879
+ * 再按一次 Ctrl+C 立即硬退(不让用户面对一个按不动的 Ctrl+C)。
880
+ */
881
+ const shutdownAndExit = (code) => {
882
+ if (shuttingDown) {
883
+ process.exit(code);
884
+ }
885
+ shuttingDown = true;
886
+ const deadline = setTimeout(() => process.exit(code), SHUTDOWN_DEADLINE_MS);
887
+ deadline.unref();
888
+ void shutdown().finally(() => {
889
+ clearTimeout(deadline);
890
+ process.exit(code);
891
+ });
892
+ };
893
+ process.on('SIGINT', () => shutdownAndExit(0));
894
+ process.on('SIGTERM', () => shutdownAndExit(0));
895
+ // 最后一道兜底:任何路径走到进程退出都不该留下子进程树(含上面那条硬退路径)
896
+ process.on('exit', () => {
897
+ if (child)
898
+ killTree(child.child, 'SIGKILL');
899
+ });
900
+ // ---------- 启动 ----------
901
+ /**
902
+ * 起面板。失败**不阻断** dev(文件头那条「面板是增强项」的承诺)—— 但要响亮说清
903
+ * 「这一次没有面板」,而不是含糊的「dev 照常运行」。
904
+ *
905
+ * 为什么要真的降级而不是退出:`agentia dev -- "问题"` 这条**文档化的用法**不需要面板
906
+ * (首次 run 由 argv 给,改代码照样重启),而且「面板起不来 ⇒ 连 runner 都不 spawn、
907
+ * 进程随即以 code=1 退出」正是那句旧文案在说谎的地方(它说照常运行,事实是 dev 根本没起来)。
908
+ */
909
+ const startPanel = async () => {
910
+ try {
911
+ inspector = await startInspector({
912
+ token,
913
+ dev: {
914
+ state: devState,
915
+ run: submitRun,
916
+ session: readSession,
917
+ abort: abortRun,
918
+ clearSession,
919
+ },
920
+ });
921
+ console.log(`Inspector: http://127.0.0.1:${inspector.port}/?t=${token}\n` +
922
+ ' (带 token 的完整 URL —— 打开一次就会种进 cookie,之后刷新不用再带)');
923
+ }
924
+ catch (err) {
925
+ inspector = null;
926
+ console.warn(`[agentia] inspector 起不来(${err.message})⇒ 这一次**没有面板**:\n` +
927
+ ' trace / 对话视图 / 「中止」都不可用;dev 只保留「首次 run(`npm run dev -- "问题"`)」\n' +
928
+ ' 与「改代码自动重启」两条路,run 的正文与失败原因打在终端上。');
929
+ }
930
+ };
931
+ void (async () => {
932
+ await startPanel();
933
+ try {
934
+ await spawnChild(null);
935
+ }
936
+ catch (e) {
937
+ lastError = e.message;
938
+ console.error(`[agentia] ${lastError}`);
939
+ }
940
+ // 「文件变了 ⇒ 重启」的**唯一**出口:两处 watch 都走它,理由文案因此只有一种写法。
941
+ const onFileChange = (abs) => {
942
+ const rel = relative(projectRoot, abs);
943
+ if (running) {
944
+ pendingRestart = `文件变更:${rel}`;
945
+ return;
946
+ }
947
+ void restart(`文件变更:${rel}`, child?.toolSources ?? null);
948
+ };
949
+ // 代码与文本资产都在 `src/` 下(`.md` 必须在允许清单里 —— 见 WATCH_EXT)
950
+ stopWatch = watchTree(join(projectRoot, 'src'), onFileChange);
951
+ // `.env` / `.env.local` 在**项目根**、不在 `src/` 下 ⇒ 单独盯。
952
+ // 少了这一句,「改 .env 会重启」就是一句没人执行的承诺(真跑抓出来的)。
953
+ stopEnvWatch = watchRootEnvFiles(projectRoot, onFileChange);
954
+ // `agentia dev -- "问题"` / `npm run dev -- "问题"`:文档化的用法,首次 run 用这个 prompt。
955
+ // ⚠️ 必须把分隔符 `--` 自己挑掉:`npm run dev -- "问题"` 里 npm 会吃掉那个 `--`
956
+ // (CLI 收到的是 `dev "问题"`),但**裸 CLI** 那条(cli.ts 的 usage 就写着
957
+ // `agentia dev [-- "问题"]`)会把 `--` 原样放进 rest ⇒ 取第一个非空参数时
958
+ // 拿到的是 `"--"`:首次 run 的 prompt 变成两个连字符,而模型照样会回话
959
+ // (所以它**不会**报错,只是问了另一件事 —— 静默错到底)。
960
+ const initial = argv.filter((a) => a !== '--').find((a) => a.trim().length > 0);
961
+ if (initial !== undefined) {
962
+ try {
963
+ await submitRun({ prompt: initial });
964
+ }
965
+ catch (e) {
966
+ console.error(`[agentia] 首次 run 没起来:${e.message}`);
967
+ }
968
+ }
969
+ })();
86
970
  return 0;
87
971
  }