@migor/cli 0.9.2 → 0.9.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.
@@ -0,0 +1,530 @@
1
+ /**
2
+ * inspector 的**路由表** —— 2026-09-23 自 `inspector.ts` 切出(方案
3
+ * `docs/plans/2026-09-23-cli-structure.md` §3 C 的收尾那一刀)。
4
+ *
5
+ * 切分口径(两边各管什么,越界就是新的乱源):
6
+ * - `inspector.ts` = **服务**:监听、三道鉴权闸(Host / Origin / token)、应答原语
7
+ * (`json` / `text` / `readBody` / `parseRunRequest`)、公开类型(`DevHooks` /
8
+ * `InspectorServer` / `TraceLike` / `RunSummary`)、`HttpError`;
9
+ * - 本文件 = **路由**:路径匹配 → 入参校验 → 应答,外加**只被路由用到**的那几件
10
+ * (`validateTrace` / `validateTraceEvent` / `summarize` / `unavailableDevState` /
11
+ * `isDirLikeEntry` / 静态资源白名单 `STATIC`)。
12
+ *
13
+ * 为什么不是「换个 HTTP 框架」:**零运行时依赖是全仓铁律**
14
+ * (`tests/architecture/no-runtime-deps.test.ts`),hono / express 永久出局。
15
+ * 所以这次是**纯搬移** —— 不改行为、不换框架、不改任何路由语义与状态码。
16
+ *
17
+ * 依赖方向(单向,运行期无环):本文件只从 `inspector.js` 取**类型**(`import type`,
18
+ * 编译期擦除),值(`json` / `readBody` / `parseRunRequest` / …)由 `inspector.ts`
19
+ * 经 `RouteCtx` 注入。反向的那两个常量(`TOKEN_COOKIE` / `TOKEN_HEADER`)定义在**这里**
20
+ * 并被 `inspector.ts` 的鉴权闸 import —— 它们描述的是 **HTTP 面**(面板首帧种哪个 cookie、
21
+ * 脚本带哪个头),路由与闸共用同一份;放这边是为了让依赖只有「服务 → 路由」一个方向。
22
+ */
23
+ import { readFile } from 'node:fs/promises';
24
+ import { readdirSync, statSync } from 'node:fs';
25
+ import { dirname, join, resolve } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { DEFAULT_BUDGET, } from './dev-protocol.js';
28
+ /** token 的 cookie 名(面板首帧带 `?t=` 拿到它之后,后续请求自动带上) */
29
+ export const TOKEN_COOKIE = 'agentia_dev_token';
30
+ /** token 的自定义头名(非浏览器客户端 / 脚本用;不进 URL、不留痕) */
31
+ export const TOKEN_HEADER = 'x-agentia-token';
32
+ /**
33
+ * 「我走了,把在飞的系统选择框收掉」的路径 —— 面板在 `pagehide` 时用 `sendBeacon` 打它。
34
+ *
35
+ * 为什么需要它(**不是锦上添花**):只靠连接断开(`res.on('close')`)**漏得掉一个真实场景**。
36
+ * 2026-09-23 实测(带插桩的产物副本 + 真浏览器):刷新页面 / 卸载体之后,那条连接**还开着**
37
+ * (`lsof` 上浏览器侧仍是 ESTABLISHED),服务端**收不到 close** ⇒ 在飞的选择框没人收、
38
+ * 之后每次点「系统选择…」都 409。而 beacon 是**一次新的请求**,与服务端看不看得见断开无关。
39
+ * 两条路互补:beacon 覆盖「页面自己知道要走了」(刷新 / 关标签页 / 跳走),
40
+ * `res.on('close')` 覆盖「进程直接没了」(浏览器崩溃 / 被 kill / 非浏览器客户端断开)。
41
+ *
42
+ * 面板侧写的是**同一个字面量**(页面不能 import 本模块)⇒ `inspector.test.mjs` 有一条
43
+ * 逐字对拍断言钉住两侧一致(与模板 `.env` 那条同款)。
44
+ */
45
+ export const PICK_CANCEL_PATH = '/api/fs/pick/cancel';
46
+ /**
47
+ * `GET /api/fs` 一次最多回多少个**文件**名(目录不设限:一个位置要列几百个目录,本身就
48
+ * 说明位置选错了)。超限回 `filesTruncated: true` —— 截断必须**明示**,不许静默少给。
49
+ */
50
+ const FILE_LIMIT = 200;
51
+ /** 静态资源目录(构建期由 scripts/copy-assets.mjs 就位) */
52
+ const HERE = dirname(fileURLToPath(import.meta.url));
53
+ const ASSETS = join(HERE, 'inspector');
54
+ const PAGE = join(HERE, 'inspector-page.html');
55
+ /**
56
+ * 静态资源白名单:**只有这里列出的名字**能被读出来。
57
+ *
58
+ * `panel-logic.js` 是 CLI 自己编译出来的模块(面板纯逻辑,单测直接 import 它),
59
+ * 落在 dist 根而不是 dist/inspector/ —— 与 `inspector-page.html` 同处一层。
60
+ */
61
+ const STATIC = new Map([
62
+ ['index.js', join(ASSETS, 'index.js')],
63
+ ['view.js', join(ASSETS, 'view.js')],
64
+ ['fromTrace.js', join(ASSETS, 'fromTrace.js')],
65
+ ['summary.js', join(ASSETS, 'summary.js')],
66
+ ['trace-view.css', join(ASSETS, 'trace-view.css')],
67
+ ['panel-logic.js', join(HERE, 'panel-logic.js')],
68
+ // 面板的 Markdown 解析器(零依赖、按构造不产生 HTML —— 见 src/markdown.ts 的头注)。
69
+ // 与 panel-logic.js 同处 dist 根:两个都是 CLI 自己编译出来的模块,单测直接 import 它们。
70
+ ['markdown.js', join(HERE, 'markdown.js')],
71
+ ]);
72
+ /** 入站校验:结构不符的 trace 直接拒(400),不让 NaN 之类的坏数据流进面板与 SSE 广播 */
73
+ function validateTrace(t) {
74
+ if (!t || typeof t !== 'object' || Array.isArray(t))
75
+ return 'trace 必须是对象';
76
+ const trace = t;
77
+ if (typeof trace.traceId !== 'string' || trace.traceId.length === 0)
78
+ return 'traceId 缺失';
79
+ if (trace.spans !== undefined && !Array.isArray(trace.spans))
80
+ return 'spans 必须是数组';
81
+ for (const s of trace.spans ?? []) {
82
+ if (!s || typeof s !== 'object')
83
+ return 'span 必须是对象';
84
+ if (typeof s.spanId !== 'string' || s.spanId.length === 0)
85
+ return 'span.spanId 缺失';
86
+ if (typeof s.name !== 'string')
87
+ return `span ${s.spanId} 缺 name`;
88
+ if (typeof s.startedAt !== 'number' || !Number.isFinite(s.startedAt)) {
89
+ return `span ${s.spanId} 缺 startedAt(必须是有穷 number)`;
90
+ }
91
+ if (s.endedAt !== undefined && (typeof s.endedAt !== 'number' || !Number.isFinite(s.endedAt))) {
92
+ return `span ${s.spanId} 的 endedAt 非法`;
93
+ }
94
+ }
95
+ return null;
96
+ }
97
+ /**
98
+ * 入站校验:**增量记账事件**的形状(`POST /ingest-event`,① 实时右栏)。
99
+ *
100
+ * 为什么卡得比 `validateTrace` 还细:面板拿这些字段**直接建树**,而坏帧的报错现场在
101
+ * 浏览器里(最难查的地方)。缺一个 `spanId` 的表现是「树上少一个节点」而不是任何报错 ——
102
+ * 所以宁可在这里响亮拒(400 会被 runner 的 event sink 静默吞掉,但至少不会污染面板)。
103
+ */
104
+ function validateTraceEvent(e) {
105
+ if (!e || typeof e !== 'object' || Array.isArray(e))
106
+ return '事件必须是对象';
107
+ const ev = e;
108
+ if (typeof ev.seq !== 'number' || !Number.isFinite(ev.seq))
109
+ return 'seq 必须是有穷 number';
110
+ switch (ev.type) {
111
+ case 'span.begin': {
112
+ const s = ev.span;
113
+ if (!s || typeof s !== 'object')
114
+ return 'span.begin 缺 span';
115
+ if (typeof s.spanId !== 'string' || s.spanId.length === 0)
116
+ return 'span.begin 的 span.spanId 缺失';
117
+ if (typeof s.traceId !== 'string' || s.traceId.length === 0) {
118
+ return 'span.begin 的 span.traceId 缺失';
119
+ }
120
+ if (typeof s.name !== 'string')
121
+ return 'span.begin 的 span.name 缺失';
122
+ if (typeof s.startedAt !== 'number' || !Number.isFinite(s.startedAt)) {
123
+ return 'span.begin 的 span.startedAt 非法';
124
+ }
125
+ return null;
126
+ }
127
+ case 'span.end': {
128
+ if (typeof ev.spanId !== 'string' || ev.spanId.length === 0)
129
+ return 'span.end 缺 spanId';
130
+ if (typeof ev.endedAt !== 'number' || !Number.isFinite(ev.endedAt))
131
+ return 'span.end 的 endedAt 非法';
132
+ if (typeof ev.status !== 'string')
133
+ return 'span.end 的 status 缺失';
134
+ return null;
135
+ }
136
+ case 'span.event': {
137
+ if (typeof ev.spanId !== 'string' || ev.spanId.length === 0)
138
+ return 'span.event 缺 spanId';
139
+ if (!ev.event || typeof ev.event.name !== 'string')
140
+ return 'span.event 缺 event.name';
141
+ return null;
142
+ }
143
+ case 'span.attribute': {
144
+ if (typeof ev.spanId !== 'string' || ev.spanId.length === 0)
145
+ return 'span.attribute 缺 spanId';
146
+ if (typeof ev.key !== 'string' || ev.key.length === 0)
147
+ return 'span.attribute 缺 key';
148
+ return null;
149
+ }
150
+ case 'span.link': {
151
+ if (typeof ev.spanId !== 'string' || ev.spanId.length === 0)
152
+ return 'span.link 缺 spanId';
153
+ return null;
154
+ }
155
+ default:
156
+ return `不认识的事件类型:${String(ev.type)}`;
157
+ }
158
+ }
159
+ /**
160
+ * 目录判定要**解引用软链**:pnpm store / monorepo 里 `src/tools/foo` 常是软链,
161
+ * 它的 `isDirectory()` 为 false —— 直接用会把真目录静默漏掉(discover.ts 同一条教训)。
162
+ */
163
+ function isDirLikeEntry(full, e) {
164
+ if (e.isDirectory())
165
+ return true;
166
+ if (!e.isSymbolicLink())
167
+ return false;
168
+ try {
169
+ return statSync(full).isDirectory();
170
+ }
171
+ catch {
172
+ return false;
173
+ }
174
+ }
175
+ /** 没有 dev 钩子时的状态(面板据此隐藏输入条) */
176
+ function unavailableDevState() {
177
+ return {
178
+ available: false,
179
+ projectRoot: '',
180
+ home: '',
181
+ capabilities: [],
182
+ multiTurn: [],
183
+ warning: null,
184
+ defaultWorkdir: '',
185
+ budget: { ...DEFAULT_BUDGET },
186
+ running: false,
187
+ lastError: null,
188
+ // 没有 dev 环 ⇒ 没有会话可谈(面板只在 available 时才读它)
189
+ sessionId: '',
190
+ };
191
+ }
192
+ function summarize(t, note) {
193
+ const spans = t.spans || [];
194
+ const root = spans.find((s) => s.kind === 'run') || spans[0];
195
+ const startedAt = root ? root.startedAt : 0;
196
+ const endedAt = root && root.endedAt != null ? root.endedAt : startedAt;
197
+ const u = t.totalUsage || {};
198
+ const status = t.status || root?.status || 'ok';
199
+ const sid = root?.attributes?.['session.id'];
200
+ // 收尾原因由引擎写在 run 根 span 的 `stop_reason` 上(`engine/loop.ts` 的
201
+ // `recorder.setAttribute(rootId, 'stop_reason', …)`)—— 面板判中止**必须**看它,
202
+ // 因为 `aborted` 的 `ok` 也是 false(见 `RunSummary.stopReason`)
203
+ const sr = root?.attributes?.stop_reason;
204
+ return {
205
+ traceId: t.traceId,
206
+ status,
207
+ name: root ? root.name : 'run',
208
+ startedAt,
209
+ ms: endedAt - startedAt,
210
+ tokens: (u.inputTokens || 0) + (u.outputTokens || 0),
211
+ ok: status !== 'error',
212
+ stopReason: typeof sr === 'string' && sr.length > 0 ? sr : null,
213
+ sessionId: typeof sid === 'string' && sid.length > 0 ? sid : null,
214
+ note,
215
+ };
216
+ }
217
+ async function handleIngest(ctx) {
218
+ const body = await ctx.readBody(ctx.req); // 超 413 走外层 catch
219
+ let trace;
220
+ try {
221
+ trace = JSON.parse(body);
222
+ }
223
+ catch {
224
+ ctx.json(ctx.res, 400, { error: 'body 不是合法 JSON' });
225
+ return;
226
+ }
227
+ const problem = validateTrace(trace);
228
+ if (problem) {
229
+ ctx.json(ctx.res, 400, { error: problem });
230
+ return;
231
+ }
232
+ const valid = trace;
233
+ const { runs, order, notes } = ctx.state;
234
+ if (!runs.has(valid.traceId)) {
235
+ order.push(valid.traceId);
236
+ if (order.length > ctx.maxRuns) {
237
+ const evicted = order.shift();
238
+ runs.delete(evicted);
239
+ notes.delete(evicted);
240
+ }
241
+ }
242
+ runs.set(valid.traceId, valid);
243
+ ctx.broadcast(summarize(valid, notes.get(valid.traceId) ?? null));
244
+ ctx.json(ctx.res, 200, { ok: true });
245
+ }
246
+ /**
247
+ * **在飞 run 的增量记账事件**(① 实时右栏):`POST /ingest-event`。
248
+ *
249
+ * 与 `POST /ingest` 的分工是刻意的两条缝(框架侧同名区分):
250
+ * - 这条 = **此刻看到**:高频、逐笔、不落库、**不保证送达**;
251
+ * - 那条 = **最终账**:收尾一次、进 run 列表、被 `/api/runs` 读。
252
+ *
253
+ * 所以这里**只广播**、不建 run 记录:面板拿它把树「长」出来,收尾那份整棵 trace
254
+ * 回来时覆盖(`open()` 的渲染永远以收尾那份为准)。
255
+ *
256
+ * 202 而不是 200:已经广播出去了,但面板收没收到不归这里管(SSE 是单向流)。
257
+ * 没有 dev 钩子时回 503 —— 与 `/run` / `/run/abort` 同一口径(没有面板消费它)。
258
+ */
259
+ async function handleIngestEvent(ctx) {
260
+ if (!ctx.dev) {
261
+ ctx.json(ctx.res, 503, { error: '这个 inspector 不是 agentia dev 起的(没有 runner)' });
262
+ return;
263
+ }
264
+ const body = await ctx.readBody(ctx.req);
265
+ let ev;
266
+ try {
267
+ ev = JSON.parse(body);
268
+ }
269
+ catch {
270
+ ctx.json(ctx.res, 400, { error: 'body 不是合法 JSON' });
271
+ return;
272
+ }
273
+ const problem = validateTraceEvent(ev);
274
+ if (problem) {
275
+ ctx.json(ctx.res, 400, { error: problem });
276
+ return;
277
+ }
278
+ ctx.dev.traceEvent(ev);
279
+ ctx.json(ctx.res, 202, { ok: true });
280
+ }
281
+ function handleListRuns(ctx) {
282
+ const { runs, order, notes } = ctx.state;
283
+ ctx.json(ctx.res, 200, order
284
+ .slice()
285
+ .reverse()
286
+ .map((id) => summarize(runs.get(id), notes.get(id) ?? null)));
287
+ }
288
+ function handleRunDetail(ctx) {
289
+ const id = decodeURIComponent(ctx.path.slice('/api/runs/'.length));
290
+ const t = ctx.state.runs.get(id);
291
+ if (!t) {
292
+ ctx.json(ctx.res, 404, { error: 'run 不存在(可能已被淘汰)' });
293
+ return;
294
+ }
295
+ ctx.json(ctx.res, 200, t);
296
+ }
297
+ function handleDevState(ctx) {
298
+ ctx.json(ctx.res, 200, ctx.dev ? ctx.dev.state() : unavailableDevState());
299
+ }
300
+ /**
301
+ * 目录浏览器:`GET /api/fs?path=<绝对路径>` →
302
+ * `{ path, parent, dirs, dotDirs, files, filesTruncated }`。
303
+ *
304
+ * 为什么需要它:浏览器**拿不到**用户选的文件夹的绝对路径 —— `<input webkitdirectory>`
305
+ * 只给相对路径,File System Access API 只给 handle.name。而 dev 环要的是绝对路径
306
+ * (工具按它解析)。⇒ 要么让用户手打路径,要么服务端列目录。后者才是「工作目录是
307
+ * 主控件」(D3/D7)该有的手感。
308
+ *
309
+ * 三条口径(2026-09-22 复核后改,前两条是实测出来的缺口):
310
+ * - **文件也列**(`files`,只给名字、不读内容):面板据此提供「选这个文件」——
311
+ * 工作目录取它所在目录、prompt 空时填文件名。只列目录时,「让 agent 看某个文件」
312
+ * 只能靠用户自己把文件名打进 prompt。
313
+ * - **隐藏目录单列**(`dotDirs`,不在 `dirs` 里):以前一批过滤掉,于是
314
+ * `~/xxx/.yyy` 这类目录**根本点不进去**(`..` 只能退到它上面,进不去)。
315
+ * 单列是为了不把 `.git` / `.cache` 混进正常浏览,同时保证**可达**。
316
+ * - `files` 有上限(`FILE_LIMIT`)并回 `filesTruncated`:一次列几千个文件的名
317
+ * 没有意义,但**必须明示**截断(本仓纪律:不许静默少给)。
318
+ *
319
+ * 只列名字、**不读文件内容**;且只在 dev 钩子在场时开放(只读面板不暴露文件树)。
320
+ * 越界不是威胁(本机 dev 工具,token 已经挡住了其它进程),但**不存在 / 不是目录**
321
+ * 要响亮报错 —— 静默回退到项目根会让 agent 对着错的目录乱写(D5)。
322
+ */
323
+ function handleFsList(ctx) {
324
+ if (!ctx.dev) {
325
+ ctx.json(ctx.res, 403, { error: '这个 inspector 不是 agentia dev 起的,不提供目录浏览' });
326
+ return;
327
+ }
328
+ const target = ctx.url.searchParams.get('path');
329
+ if (!target) {
330
+ ctx.json(ctx.res, 400, { error: '缺少 path 参数' });
331
+ return;
332
+ }
333
+ const abs = resolve(target);
334
+ let entries;
335
+ try {
336
+ if (!statSync(abs).isDirectory()) {
337
+ ctx.json(ctx.res, 400, { error: `不是文件夹:${abs}` });
338
+ return;
339
+ }
340
+ entries = readdirSync(abs, { withFileTypes: true });
341
+ }
342
+ catch (e) {
343
+ ctx.json(ctx.res, 400, { error: `读不了这个目录:${e.message}` });
344
+ return;
345
+ }
346
+ const dirs = [];
347
+ const dotDirs = [];
348
+ const files = [];
349
+ for (const e of entries) {
350
+ if (isDirLikeEntry(join(abs, e.name), e)) {
351
+ (e.name.startsWith('.') ? dotDirs : dirs).push(e.name);
352
+ continue;
353
+ }
354
+ // 读不了内容的条目(管道 / socket / 权限)也当文件列出来 —— 列名字不需要读它
355
+ if (!e.name.startsWith('.'))
356
+ files.push(e.name);
357
+ }
358
+ ctx.json(ctx.res, 200, {
359
+ path: abs,
360
+ parent: dirname(abs),
361
+ dirs: dirs.sort(),
362
+ dotDirs: dotDirs.sort(),
363
+ files: files.sort().slice(0, FILE_LIMIT),
364
+ filesTruncated: files.length > FILE_LIMIT,
365
+ });
366
+ }
367
+ /**
368
+ * 原生文件夹选择框:`POST /api/fs/pick` → `{ path }`(取消时 `path: null`)。
369
+ *
370
+ * 浏览器拿不到所选文件夹的**绝对路径**,而 CLI 是本机进程 —— 替用户拉起 OS 原生
371
+ * 选择框就是这条路由存在的理由(`GET /api/fs` 的「浏览…」是它的降级路径)。
372
+ * 状态码:200 选中/取消;**409** 已有一个选择框在等(串行化在钩子里);
373
+ * **501** 平台不支持 / 命令缺失(报错文案指向降级路径)—— 两者都由钩子抛
374
+ * HttpError,走外层 catch 的统一映射。与 /api/fs 同一条闸:只在 dev 钩子在场时开放。
375
+ */
376
+ async function handleFsPick(ctx) {
377
+ const { res } = ctx;
378
+ if (!ctx.dev) {
379
+ ctx.json(res, 403, { error: '这个 inspector 不是 agentia dev 起的,不提供目录选择' });
380
+ return;
381
+ }
382
+ // 客户端断开出口:等待选择期间用户刷新 / 关了标签页 ⇒ 桌面上的选择框没人看了,
383
+ // 通知钩子收掉它(否则钩子里的「在飞」标志永远不落,之后每次点都 409)。
384
+ // ⚠️ 判据用「是否还在 await」而不是只看 close:409/501 那条路会**立即**抛错走
385
+ // 外层 catch,那时若再 cancel 会把**别人**的在飞选择框杀掉。
386
+ let awaiting = false;
387
+ res.on('close', () => {
388
+ if (awaiting)
389
+ ctx.dev?.cancelPick?.();
390
+ });
391
+ try {
392
+ awaiting = true;
393
+ const picked = await ctx.dev.pickFolder();
394
+ awaiting = false;
395
+ // 断开后的响应写入没有必要(也写不进去)—— 客户端已经走了
396
+ if (!res.destroyed)
397
+ ctx.json(res, 200, { path: picked });
398
+ }
399
+ finally {
400
+ awaiting = false;
401
+ }
402
+ }
403
+ /**
404
+ * 面板自己说「我要走了」:`pagehide` 时的 beacon(见 `PICK_CANCEL_PATH` 的说明)。
405
+ *
406
+ * **幂等且无副作用**:机器侧 `picking=false` 时它就是一个空操作(没有在飞的选择框可收),
407
+ * 所以在飞/不在飞都安全 —— 面板因此可以**无条件**发它,不必先判断自己有没有在等。
408
+ * 与其余 fs 路由同一条闸:没有 dev 钩子(statically 起的 inspector)一律 403。
409
+ */
410
+ async function handleFsPickCancel(ctx) {
411
+ if (!ctx.dev) {
412
+ ctx.json(ctx.res, 403, { error: '这个 inspector 不是 agentia dev 起的,不提供目录选择' });
413
+ return;
414
+ }
415
+ // beacon 是即发即弃的:响应体没人读,但仍然照常回(脚本直连时能看到语义)
416
+ ctx.dev.cancelPick?.();
417
+ ctx.json(ctx.res, 200, { cancelled: true });
418
+ }
419
+ async function handleRun(ctx) {
420
+ if (!ctx.dev) {
421
+ ctx.json(ctx.res, 503, {
422
+ error: '这个 inspector 不是 agentia dev 起的(没有 runner),不能驱动 run',
423
+ });
424
+ return;
425
+ }
426
+ const body = await ctx.readBody(ctx.req);
427
+ let raw;
428
+ try {
429
+ raw = JSON.parse(body);
430
+ }
431
+ catch {
432
+ ctx.json(ctx.res, 400, { error: 'body 不是合法 JSON' });
433
+ return;
434
+ }
435
+ const ack = await ctx.dev.run(ctx.parseRunRequest(raw));
436
+ ctx.json(ctx.res, 202, ack);
437
+ }
438
+ async function handleAbort(ctx) {
439
+ if (!ctx.dev) {
440
+ ctx.json(ctx.res, 503, { error: '这个 inspector 不是 agentia dev 起的(没有 runner)' });
441
+ return;
442
+ }
443
+ // 202 而不是 200:中止是**请求已受理**,run 真正收尾要走 SSE 的 `run-done`
444
+ // (优雅路径要在回合边界才生效,不是同步的)。
445
+ ctx.json(ctx.res, 202, await ctx.dev.abort());
446
+ }
447
+ async function handleSessionClear(ctx) {
448
+ if (!ctx.dev) {
449
+ ctx.json(ctx.res, 503, { error: '这个 inspector 不是 agentia dev 起的(没有 runner)' });
450
+ return;
451
+ }
452
+ ctx.json(ctx.res, 200, await ctx.dev.clearSession());
453
+ }
454
+ async function handleSession(ctx) {
455
+ if (!ctx.dev) {
456
+ ctx.json(ctx.res, 200, { session: null });
457
+ return;
458
+ }
459
+ ctx.json(ctx.res, 200, { session: await ctx.dev.session() });
460
+ }
461
+ function handleStream(ctx) {
462
+ const { req, res } = ctx;
463
+ res.writeHead(200, {
464
+ 'content-type': 'text/event-stream',
465
+ 'cache-control': 'no-cache',
466
+ connection: 'keep-alive',
467
+ });
468
+ res.write(': connected\n\n');
469
+ ctx.state.clients.add(res);
470
+ req.on('close', () => ctx.state.clients.delete(res));
471
+ }
472
+ async function handlePage(ctx) {
473
+ // 首帧带了合法 token ⇒ 种进 cookie(SameSite=Strict + HttpOnly):
474
+ // 之后面板自己的 fetch / EventSource 自动带上,token 不进 URL 历史、不进截图。
475
+ const headers = ctx.token !== undefined && ctx.queryToken !== null
476
+ ? { 'set-cookie': `${TOKEN_COOKIE}=${ctx.token}; Path=/; HttpOnly; SameSite=Strict` }
477
+ : {};
478
+ ctx.text(ctx.res, 200, 'text/html; charset=utf-8', await readFile(PAGE, 'utf8'), headers);
479
+ }
480
+ async function handleStatic(ctx) {
481
+ const name = ctx.path.slice(1);
482
+ const type = name.endsWith('.css') ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8';
483
+ ctx.text(ctx.res, 200, type, await readFile(STATIC.get(name), 'utf8'));
484
+ }
485
+ /**
486
+ * 路由分发:按(方法, 路径)分派给上面的命名 handler,最后落到兜底 404。
487
+ *
488
+ * 调用方(`inspector.ts` 的 handler)已经做完 Host / Origin / token 三道闸 ——
489
+ * 本函数只管**路由**,不再鉴权。handler 里 `await` 到的 `HttpError`(由 `DevHooks`
490
+ * 的实现抛出,如 `pickFolder` 的 409/501)会**穿过本函数**冒泡给调用方的 catch,
491
+ * 由那里统一映射成状态码。
492
+ *
493
+ * ⚠️ 顺序即优先级:`/api/runs` 必须排在 `/api/runs/` 之前(后者是前缀匹配)。
494
+ */
495
+ export async function handleRoutes(ctx) {
496
+ const { req, path } = ctx;
497
+ if (req.method === 'POST' && path === '/ingest')
498
+ return handleIngest(ctx);
499
+ if (req.method === 'POST' && path === '/ingest-event')
500
+ return handleIngestEvent(ctx);
501
+ if (req.method === 'GET' && path === '/api/runs')
502
+ return handleListRuns(ctx);
503
+ if (req.method === 'GET' && path.startsWith('/api/runs/'))
504
+ return handleRunDetail(ctx);
505
+ if (req.method === 'GET' && path === '/api/dev')
506
+ return handleDevState(ctx);
507
+ if (req.method === 'GET' && path === '/api/fs')
508
+ return handleFsList(ctx);
509
+ if (req.method === 'POST' && path === '/api/fs/pick')
510
+ return handleFsPick(ctx);
511
+ // 面板卸载时的显式收口(`pagehide` beacon)—— 必须与上一条**并列**存在,不是替代关系:
512
+ // 断开那条路在浏览器刷新时看不见(见 PICK_CANCEL_PATH 的说明)。
513
+ if (req.method === 'POST' && path === PICK_CANCEL_PATH)
514
+ return handleFsPickCancel(ctx);
515
+ if (req.method === 'POST' && path === '/run')
516
+ return handleRun(ctx);
517
+ if (req.method === 'POST' && path === '/run/abort')
518
+ return handleAbort(ctx);
519
+ if (req.method === 'POST' && path === '/session/clear')
520
+ return handleSessionClear(ctx);
521
+ if (req.method === 'GET' && path === '/api/session')
522
+ return handleSession(ctx);
523
+ if (req.method === 'GET' && path === '/stream')
524
+ return handleStream(ctx);
525
+ if (req.method === 'GET' && (path === '/' || path === '/index.html'))
526
+ return handlePage(ctx);
527
+ if (req.method === 'GET' && STATIC.has(path.slice(1)))
528
+ return handleStatic(ctx);
529
+ ctx.json(ctx.res, 404, { error: 'not found' });
530
+ }