@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.
package/dist/inspector.js CHANGED
@@ -13,6 +13,13 @@
13
13
  * 服务同时是**面板 → dev 环**的入口(`POST /run`):具体怎么驱动用户代码由 `dev.ts`
14
14
  * 经 `DevHooks` 注入 —— 本文件不认识子进程、不认识 tsx,只做 HTTP 与校验。
15
15
  *
16
+ * ## 本文件管什么(2026-09-23 拆分后)
17
+ *
18
+ * **服务**:监听、三道鉴权闸、应答/请求原语(`json` / `text` / `readBody` /
19
+ * `parseRunRequest`)、对外类型(`DevHooks` / `InspectorServer` / `TraceLike` /
20
+ * `RunSummary`)、`HttpError`。**路由表不在这里** —— 已切到 `inspector-routes.ts`
21
+ * (`handleRoutes`),理由与边界见那个文件的头注。
22
+ *
16
23
  * ## 鉴权(D0)
17
24
  *
18
25
  * 两道,都零依赖:
@@ -29,99 +36,8 @@
29
36
  */
30
37
  import { timingSafeEqual } from 'node:crypto';
31
38
  import { createServer } from 'node:http';
32
- import { readFile } from 'node:fs/promises';
33
- import { readdirSync, statSync } from 'node:fs';
34
- import { dirname, join, resolve } from 'node:path';
35
- import { fileURLToPath } from 'node:url';
36
- import { DEFAULT_BUDGET, } from './dev-protocol.js';
39
+ import { handleRoutes, TOKEN_COOKIE, TOKEN_HEADER, } from './inspector-routes.js';
37
40
  import { rememberNote } from './panel-logic.js';
38
- /** 入站校验:结构不符的 trace 直接拒(400),不让 NaN 之类的坏数据流进面板与 SSE 广播 */
39
- function validateTrace(t) {
40
- if (!t || typeof t !== 'object' || Array.isArray(t))
41
- return 'trace 必须是对象';
42
- const trace = t;
43
- if (typeof trace.traceId !== 'string' || trace.traceId.length === 0)
44
- return 'traceId 缺失';
45
- if (trace.spans !== undefined && !Array.isArray(trace.spans))
46
- return 'spans 必须是数组';
47
- for (const s of trace.spans ?? []) {
48
- if (!s || typeof s !== 'object')
49
- return 'span 必须是对象';
50
- if (typeof s.spanId !== 'string' || s.spanId.length === 0)
51
- return 'span.spanId 缺失';
52
- if (typeof s.name !== 'string')
53
- return `span ${s.spanId} 缺 name`;
54
- if (typeof s.startedAt !== 'number' || !Number.isFinite(s.startedAt)) {
55
- return `span ${s.spanId} 缺 startedAt(必须是有穷 number)`;
56
- }
57
- if (s.endedAt !== undefined && (typeof s.endedAt !== 'number' || !Number.isFinite(s.endedAt))) {
58
- return `span ${s.spanId} 的 endedAt 非法`;
59
- }
60
- }
61
- return null;
62
- }
63
- /**
64
- * 入站校验:**增量记账事件**的形状(`POST /ingest-event`,① 实时右栏)。
65
- *
66
- * 为什么卡得比 `validateTrace` 还细:面板拿这些字段**直接建树**,而坏帧的报错现场在
67
- * 浏览器里(最难查的地方)。缺一个 `spanId` 的表现是「树上少一个节点」而不是任何报错 ——
68
- * 所以宁可在这里响亮拒(400 会被 runner 的 event sink 静默吞掉,但至少不会污染面板)。
69
- */
70
- function validateTraceEvent(e) {
71
- if (!e || typeof e !== 'object' || Array.isArray(e))
72
- return '事件必须是对象';
73
- const ev = e;
74
- if (typeof ev.seq !== 'number' || !Number.isFinite(ev.seq))
75
- return 'seq 必须是有穷 number';
76
- switch (ev.type) {
77
- case 'span.begin': {
78
- const s = ev.span;
79
- if (!s || typeof s !== 'object')
80
- return 'span.begin 缺 span';
81
- if (typeof s.spanId !== 'string' || s.spanId.length === 0)
82
- return 'span.begin 的 span.spanId 缺失';
83
- if (typeof s.traceId !== 'string' || s.traceId.length === 0) {
84
- return 'span.begin 的 span.traceId 缺失';
85
- }
86
- if (typeof s.name !== 'string')
87
- return 'span.begin 的 span.name 缺失';
88
- if (typeof s.startedAt !== 'number' || !Number.isFinite(s.startedAt)) {
89
- return 'span.begin 的 span.startedAt 非法';
90
- }
91
- return null;
92
- }
93
- case 'span.end': {
94
- if (typeof ev.spanId !== 'string' || ev.spanId.length === 0)
95
- return 'span.end 缺 spanId';
96
- if (typeof ev.endedAt !== 'number' || !Number.isFinite(ev.endedAt))
97
- return 'span.end 的 endedAt 非法';
98
- if (typeof ev.status !== 'string')
99
- return 'span.end 的 status 缺失';
100
- return null;
101
- }
102
- case 'span.event': {
103
- if (typeof ev.spanId !== 'string' || ev.spanId.length === 0)
104
- return 'span.event 缺 spanId';
105
- if (!ev.event || typeof ev.event.name !== 'string')
106
- return 'span.event 缺 event.name';
107
- return null;
108
- }
109
- case 'span.attribute': {
110
- if (typeof ev.spanId !== 'string' || ev.spanId.length === 0)
111
- return 'span.attribute 缺 spanId';
112
- if (typeof ev.key !== 'string' || ev.key.length === 0)
113
- return 'span.attribute 缺 key';
114
- return null;
115
- }
116
- case 'span.link': {
117
- if (typeof ev.spanId !== 'string' || ev.spanId.length === 0)
118
- return 'span.link 缺 spanId';
119
- return null;
120
- }
121
- default:
122
- return `不认识的事件类型:${String(ev.type)}`;
123
- }
124
- }
125
41
  /** 防 DNS rebinding:面板只服务本机,Host 不是 localhost/127.0.0.1/[::1] 的一律拒 */
126
42
  function isLocalHostHeader(host) {
127
43
  if (!host)
@@ -135,8 +51,8 @@ function isLocalHostHeader(host) {
135
51
  /**
136
52
  * `Origin` 校验(D0 的第一道)。
137
53
  *
138
- * 缺省 = 放行:同源导航与**非浏览器客户端**(`curl`、preload 的 `fetch`)都不带
139
- * `Origin`,把它们拦掉等于把 dev 环弄坏。带 `Origin` 的一定是浏览器跨源/同源请求
54
+ * 缺省 = 放行:同源导航与**非浏览器客户端**(`curl`、runner 里 trace sink 的 `fetch`)
55
+ * 都不带 `Origin`,把它们拦掉等于把 dev 环弄坏。带 `Origin` 的一定是浏览器跨源/同源请求
140
56
  * —— 那时只放行本机 origin。
141
57
  *
142
58
  * 注意 `Origin: null`(sandboxed iframe / file:// 页面)**不是**「缺省」:它是浏览器
@@ -157,10 +73,6 @@ function isAllowedOrigin(origin) {
157
73
  const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
158
74
  return host === 'localhost' || host === '127.0.0.1' || host === '::1';
159
75
  }
160
- /** token 的 cookie 名(面板首帧带 `?t=` 拿到它之后,后续请求自动带上) */
161
- const TOKEN_COOKIE = 'agentia_dev_token';
162
- /** token 的自定义头名(非浏览器客户端 / 脚本用;不进 URL、不留痕) */
163
- const TOKEN_HEADER = 'x-agentia-token';
164
76
  /** 定长比较:长度不同直接否(`timingSafeEqual` 对不等长会抛) */
165
77
  function tokenEquals(a, b) {
166
78
  const x = Buffer.from(a, 'utf8');
@@ -181,22 +93,6 @@ function cookieValue(header, name) {
181
93
  }
182
94
  return null;
183
95
  }
184
- /**
185
- * 目录判定要**解引用软链**:pnpm store / monorepo 里 `src/tools/foo` 常是软链,
186
- * 它的 `isDirectory()` 为 false —— 直接用会把真目录静默漏掉(discover.ts 同一条教训)。
187
- */
188
- function isDirLikeEntry(full, e) {
189
- if (e.isDirectory())
190
- return true;
191
- if (!e.isSymbolicLink())
192
- return false;
193
- try {
194
- return statSync(full).isDirectory();
195
- }
196
- catch {
197
- return false;
198
- }
199
- }
200
96
  /** 环形缓冲上限:面板只保证「回看最近 N 条」,不做历史归档 */
201
97
  const MAX_RUNS = 50;
202
98
  /**
@@ -204,74 +100,6 @@ const MAX_RUNS = 50;
204
100
  * (见 `rememberNote`),所以它有自己的一条淘汰线。
205
101
  */
206
102
  const MAX_NOTES = MAX_RUNS;
207
- /**
208
- * `GET /api/fs` 一次最多回多少个**文件**名(目录不设限:一个位置要列几百个目录,本身就
209
- * 说明位置选错了)。超限回 `filesTruncated: true` —— 截断必须**明示**,不许静默少给。
210
- */
211
- const FILE_LIMIT = 200;
212
- /** 静态资源目录(构建期由 scripts/copy-assets.mjs 就位) */
213
- const HERE = dirname(fileURLToPath(import.meta.url));
214
- const ASSETS = join(HERE, 'inspector');
215
- const PAGE = join(HERE, 'inspector-page.html');
216
- /**
217
- * 静态资源白名单:**只有这里列出的名字**能被读出来。
218
- *
219
- * `panel-logic.js` 是 CLI 自己编译出来的模块(面板纯逻辑,单测直接 import 它),
220
- * 落在 dist 根而不是 dist/inspector/ —— 与 `inspector-page.html` 同处一层。
221
- */
222
- const STATIC = new Map([
223
- ['index.js', join(ASSETS, 'index.js')],
224
- ['view.js', join(ASSETS, 'view.js')],
225
- ['fromTrace.js', join(ASSETS, 'fromTrace.js')],
226
- ['summary.js', join(ASSETS, 'summary.js')],
227
- ['trace-view.css', join(ASSETS, 'trace-view.css')],
228
- ['panel-logic.js', join(HERE, 'panel-logic.js')],
229
- // 面板的 Markdown 解析器(零依赖、按构造不产生 HTML —— 见 src/markdown.ts 的头注)。
230
- // 与 panel-logic.js 同处 dist 根:两个都是 CLI 自己编译出来的模块,单测直接 import 它们。
231
- ['markdown.js', join(HERE, 'markdown.js')],
232
- ]);
233
- /** 没有 dev 钩子时的状态(面板据此隐藏输入条) */
234
- function unavailableDevState() {
235
- return {
236
- available: false,
237
- projectRoot: '',
238
- home: '',
239
- capabilities: [],
240
- multiTurn: [],
241
- warning: null,
242
- defaultWorkdir: '',
243
- budget: { ...DEFAULT_BUDGET },
244
- running: false,
245
- lastError: null,
246
- // 没有 dev 环 ⇒ 没有会话可谈(面板只在 available 时才读它)
247
- sessionId: '',
248
- };
249
- }
250
- function summarize(t, note) {
251
- const spans = t.spans || [];
252
- const root = spans.find((s) => s.kind === 'run') || spans[0];
253
- const startedAt = root ? root.startedAt : 0;
254
- const endedAt = root && root.endedAt != null ? root.endedAt : startedAt;
255
- const u = t.totalUsage || {};
256
- const status = t.status || root?.status || 'ok';
257
- const sid = root?.attributes?.['session.id'];
258
- // 收尾原因由引擎写在 run 根 span 的 `stop_reason` 上(`engine/loop.ts` 的
259
- // `recorder.setAttribute(rootId, 'stop_reason', …)`)—— 面板判中止**必须**看它,
260
- // 因为 `aborted` 的 `ok` 也是 false(见 `RunSummary.stopReason`)
261
- const sr = root?.attributes?.stop_reason;
262
- return {
263
- traceId: t.traceId,
264
- status,
265
- name: root ? root.name : 'run',
266
- startedAt,
267
- ms: endedAt - startedAt,
268
- tokens: (u.inputTokens || 0) + (u.outputTokens || 0),
269
- ok: status !== 'error',
270
- stopReason: typeof sr === 'string' && sr.length > 0 ? sr : null,
271
- sessionId: typeof sid === 'string' && sid.length > 0 ? sid : null,
272
- note,
273
- };
274
- }
275
103
  /** 带 statusCode 的错误:让 handler 的 catch 回对应的码而不是一律 500 */
276
104
  export class HttpError extends Error {
277
105
  statusCode;
@@ -290,20 +118,24 @@ export class HttpError extends Error {
290
118
  */
291
119
  export function startInspector(opts = {}) {
292
120
  const host = opts.host ?? '127.0.0.1';
293
- const runs = new Map();
294
- const order = []; // 到达顺序,用于淘汰与列表排序
295
- const notes = new Map(); // CLI 侧记账(**有自己的上限**,见 rememberNote)
296
- const clients = new Set(); // SSE 订阅者
121
+ // 环形缓冲 + SSE 订阅者。整体一个对象是为了**原样**交给路由层(`RouteCtx.state`),
122
+ // 路由拿到的就是这里的引用 —— 两边看到的是同一份,不存在「路由改了服务不知道」。
123
+ const state = {
124
+ runs: new Map(),
125
+ order: [], // 到达顺序,用于淘汰与列表排序
126
+ notes: new Map(), // CLI 侧记账(**有自己的上限**,见 rememberNote)
127
+ clients: new Set(), // SSE 订阅者
128
+ };
297
129
  const token = opts.token;
298
130
  const broadcast = (s) => {
299
131
  const chunk = `data: ${JSON.stringify(s)}\n\n`;
300
- for (const c of clients)
132
+ for (const c of state.clients)
301
133
  c.write(chunk);
302
134
  };
303
135
  /** 命名事件 `dev`:run 生命周期与 runner 状态 —— 面板靠它显示「在跑 / 失败 / 重启」 */
304
136
  const emitDev = (ev) => {
305
137
  const chunk = `event: dev\ndata: ${JSON.stringify(ev)}\n\n`;
306
- for (const c of clients)
138
+ for (const c of state.clients)
307
139
  c.write(chunk);
308
140
  };
309
141
  const json = (res, code, body) => {
@@ -338,7 +170,13 @@ export function startInspector(opts = {}) {
338
170
  req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
339
171
  req.on('error', reject);
340
172
  });
341
- /** 校验 `RunRequest`:形状不对就 400,且说清是哪一项 —— 面板据此把原因显示给用户 */
173
+ /**
174
+ * 校验 `RunRequest`:形状不对就 400,且说清是哪一项 —— 面板据此把原因显示给用户。
175
+ *
176
+ * 为什么留在本文件而不是随路由搬走:它要构造 `HttpError`,而 `HttpError` 定义在这里
177
+ * (`dev.ts` 也从 `dist/inspector.js` 取它)。把它搬去 `inspector-routes.ts` 会让两个
178
+ * 模块**互相 import 值** ⇒ 运行期成环。这条边界比「按职责归类」更重要。
179
+ */
342
180
  const parseRunRequest = (raw) => {
343
181
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
344
182
  throw new HttpError(400, 'body 必须是对象');
@@ -367,6 +205,18 @@ export function startInspector(opts = {}) {
367
205
  ...(b.multiTurn === undefined ? {} : { multiTurn: b.multiTurn }),
368
206
  };
369
207
  };
208
+ // **每服务**一份的路由依赖(路由拿到的是这些引用本身);每请求只叠 req/res/url/path/queryToken。
209
+ const baseCtx = {
210
+ dev: opts.dev,
211
+ state,
212
+ maxRuns: MAX_RUNS,
213
+ token,
214
+ json,
215
+ text,
216
+ readBody,
217
+ parseRunRequest,
218
+ broadcast,
219
+ };
370
220
  const handler = async (req, res) => {
371
221
  const url = new URL(req.url || '/', `http://${host}`);
372
222
  const path = url.pathname;
@@ -393,235 +243,8 @@ export function startInspector(opts = {}) {
393
243
  });
394
244
  return;
395
245
  }
396
- if (req.method === 'POST' && path === '/ingest') {
397
- const body = await readBody(req); // 超 413 走外层 catch
398
- let trace;
399
- try {
400
- trace = JSON.parse(body);
401
- }
402
- catch {
403
- json(res, 400, { error: 'body 不是合法 JSON' });
404
- return;
405
- }
406
- const problem = validateTrace(trace);
407
- if (problem) {
408
- json(res, 400, { error: problem });
409
- return;
410
- }
411
- const valid = trace;
412
- if (!runs.has(valid.traceId)) {
413
- order.push(valid.traceId);
414
- if (order.length > MAX_RUNS) {
415
- const evicted = order.shift();
416
- runs.delete(evicted);
417
- notes.delete(evicted);
418
- }
419
- }
420
- runs.set(valid.traceId, valid);
421
- broadcast(summarize(valid, notes.get(valid.traceId) ?? null));
422
- json(res, 200, { ok: true });
423
- return;
424
- }
425
- /**
426
- * **在飞 run 的增量记账事件**(① 实时右栏):`POST /ingest-event`。
427
- *
428
- * 与 `POST /ingest` 的分工是刻意的两条缝(框架侧同名区分):
429
- * - 这条 = **此刻看到**:高频、逐笔、不落库、**不保证送达**;
430
- * - 那条 = **最终账**:收尾一次、进 run 列表、被 `/api/runs` 读。
431
- *
432
- * 所以这里**只广播**、不建 run 记录:面板拿它把树「长」出来,收尾那份整棵 trace
433
- * 回来时覆盖(`open()` 的渲染永远以收尾那份为准)。
434
- *
435
- * 202 而不是 200:已经广播出去了,但面板收没收到不归这里管(SSE 是单向流)。
436
- * 没有 dev 钩子时回 503 —— 与 `/run` / `/run/abort` 同一口径(没有面板消费它)。
437
- */
438
- if (req.method === 'POST' && path === '/ingest-event') {
439
- if (!opts.dev) {
440
- json(res, 503, { error: '这个 inspector 不是 agentia dev 起的(没有 runner)' });
441
- return;
442
- }
443
- const body = await readBody(req);
444
- let ev;
445
- try {
446
- ev = JSON.parse(body);
447
- }
448
- catch {
449
- json(res, 400, { error: 'body 不是合法 JSON' });
450
- return;
451
- }
452
- const problem = validateTraceEvent(ev);
453
- if (problem) {
454
- json(res, 400, { error: problem });
455
- return;
456
- }
457
- opts.dev.traceEvent(ev);
458
- json(res, 202, { ok: true });
459
- return;
460
- }
461
- if (req.method === 'GET' && path === '/api/runs') {
462
- json(res, 200, order
463
- .slice()
464
- .reverse()
465
- .map((id) => summarize(runs.get(id), notes.get(id) ?? null)));
466
- return;
467
- }
468
- if (req.method === 'GET' && path.startsWith('/api/runs/')) {
469
- const id = decodeURIComponent(path.slice('/api/runs/'.length));
470
- const t = runs.get(id);
471
- if (!t) {
472
- json(res, 404, { error: 'run 不存在(可能已被淘汰)' });
473
- return;
474
- }
475
- json(res, 200, t);
476
- return;
477
- }
478
- if (req.method === 'GET' && path === '/api/dev') {
479
- json(res, 200, opts.dev ? opts.dev.state() : unavailableDevState());
480
- return;
481
- }
482
- /**
483
- * 目录浏览器:`GET /api/fs?path=<绝对路径>` →
484
- * `{ path, parent, dirs, dotDirs, files, filesTruncated }`。
485
- *
486
- * 为什么需要它:浏览器**拿不到**用户选的文件夹的绝对路径 —— `<input webkitdirectory>`
487
- * 只给相对路径,File System Access API 只给 handle.name。而 dev 环要的是绝对路径
488
- * (工具按它解析)。⇒ 要么让用户手打路径,要么服务端列目录。后者才是「工作目录是
489
- * 主控件」(D3/D7)该有的手感。
490
- *
491
- * 三条口径(2026-09-22 复核后改,前两条是实测出来的缺口):
492
- * - **文件也列**(`files`,只给名字、不读内容):面板据此提供「选这个文件」——
493
- * 工作目录取它所在目录、prompt 空时填文件名。只列目录时,「让 agent 看某个文件」
494
- * 只能靠用户自己把文件名打进 prompt。
495
- * - **隐藏目录单列**(`dotDirs`,不在 `dirs` 里):以前一批过滤掉,于是
496
- * `~/xxx/.yyy` 这类目录**根本点不进去**(`..` 只能退到它上面,进不去)。
497
- * 单列是为了不把 `.git` / `.cache` 混进正常浏览,同时保证**可达**。
498
- * - `files` 有上限(`FILE_LIMIT`)并回 `filesTruncated`:一次列几千个文件的名
499
- * 没有意义,但**必须明示**截断(本仓纪律:不许静默少给)。
500
- *
501
- * 只列名字、**不读文件内容**;且只在 dev 钩子在场时开放(只读面板不暴露文件树)。
502
- * 越界不是威胁(本机 dev 工具,token 已经挡住了其它进程),但**不存在 / 不是目录**
503
- * 要响亮报错 —— 静默回退到项目根会让 agent 对着错的目录乱写(D5)。
504
- */
505
- if (req.method === 'GET' && path === '/api/fs') {
506
- if (!opts.dev) {
507
- json(res, 403, { error: '这个 inspector 不是 agentia dev 起的,不提供目录浏览' });
508
- return;
509
- }
510
- const target = url.searchParams.get('path');
511
- if (!target) {
512
- json(res, 400, { error: '缺少 path 参数' });
513
- return;
514
- }
515
- const abs = resolve(target);
516
- let entries;
517
- try {
518
- if (!statSync(abs).isDirectory()) {
519
- json(res, 400, { error: `不是文件夹:${abs}` });
520
- return;
521
- }
522
- entries = readdirSync(abs, { withFileTypes: true });
523
- }
524
- catch (e) {
525
- json(res, 400, { error: `读不了这个目录:${e.message}` });
526
- return;
527
- }
528
- const dirs = [];
529
- const dotDirs = [];
530
- const files = [];
531
- for (const e of entries) {
532
- if (isDirLikeEntry(join(abs, e.name), e)) {
533
- (e.name.startsWith('.') ? dotDirs : dirs).push(e.name);
534
- continue;
535
- }
536
- // 读不了内容的条目(管道 / socket / 权限)也当文件列出来 —— 列名字不需要读它
537
- if (!e.name.startsWith('.'))
538
- files.push(e.name);
539
- }
540
- json(res, 200, {
541
- path: abs,
542
- parent: dirname(abs),
543
- dirs: dirs.sort(),
544
- dotDirs: dotDirs.sort(),
545
- files: files.sort().slice(0, FILE_LIMIT),
546
- filesTruncated: files.length > FILE_LIMIT,
547
- });
548
- return;
549
- }
550
- if (req.method === 'POST' && path === '/run') {
551
- if (!opts.dev) {
552
- json(res, 503, {
553
- error: '这个 inspector 不是 agentia dev 起的(没有 runner),不能驱动 run',
554
- });
555
- return;
556
- }
557
- const body = await readBody(req);
558
- let raw;
559
- try {
560
- raw = JSON.parse(body);
561
- }
562
- catch {
563
- json(res, 400, { error: 'body 不是合法 JSON' });
564
- return;
565
- }
566
- const ack = await opts.dev.run(parseRunRequest(raw));
567
- json(res, 202, ack);
568
- return;
569
- }
570
- if (req.method === 'POST' && path === '/run/abort') {
571
- if (!opts.dev) {
572
- json(res, 503, { error: '这个 inspector 不是 agentia dev 起的(没有 runner)' });
573
- return;
574
- }
575
- // 202 而不是 200:中止是**请求已受理**,run 真正收尾要走 SSE 的 `run-done`
576
- // (优雅路径要在回合边界才生效,不是同步的)。
577
- json(res, 202, await opts.dev.abort());
578
- return;
579
- }
580
- if (req.method === 'POST' && path === '/session/clear') {
581
- if (!opts.dev) {
582
- json(res, 503, { error: '这个 inspector 不是 agentia dev 起的(没有 runner)' });
583
- return;
584
- }
585
- json(res, 200, await opts.dev.clearSession());
586
- return;
587
- }
588
- if (req.method === 'GET' && path === '/api/session') {
589
- if (!opts.dev) {
590
- json(res, 200, { session: null });
591
- return;
592
- }
593
- json(res, 200, { session: await opts.dev.session() });
594
- return;
595
- }
596
- if (req.method === 'GET' && path === '/stream') {
597
- res.writeHead(200, {
598
- 'content-type': 'text/event-stream',
599
- 'cache-control': 'no-cache',
600
- connection: 'keep-alive',
601
- });
602
- res.write(': connected\n\n');
603
- clients.add(res);
604
- req.on('close', () => clients.delete(res));
605
- return;
606
- }
607
- if (req.method === 'GET' && (path === '/' || path === '/index.html')) {
608
- // 首帧带了合法 token ⇒ 种进 cookie(SameSite=Strict + HttpOnly):
609
- // 之后面板自己的 fetch / EventSource 自动带上,token 不进 URL 历史、不进截图。
610
- const headers = token !== undefined && queryToken !== null
611
- ? { 'set-cookie': `${TOKEN_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict` }
612
- : {};
613
- text(res, 200, 'text/html; charset=utf-8', await readFile(PAGE, 'utf8'), headers);
614
- return;
615
- }
616
- if (req.method === 'GET' && STATIC.has(path.slice(1))) {
617
- const name = path.slice(1);
618
- const type = name.endsWith('.css')
619
- ? 'text/css; charset=utf-8'
620
- : 'text/javascript; charset=utf-8';
621
- text(res, 200, type, await readFile(STATIC.get(name), 'utf8'));
622
- return;
623
- }
624
- json(res, 404, { error: 'not found' });
246
+ // 闸已过 ⇒ 交给路由表(它假定自己只在已放行的请求上被调用)
247
+ await handleRoutes({ ...baseCtx, req, res, url, path, queryToken });
625
248
  }
626
249
  catch (e) {
627
250
  const status = e instanceof HttpError ? e.statusCode : (e.statusCode ?? 500);
@@ -640,12 +263,12 @@ export function startInspector(opts = {}) {
640
263
  port,
641
264
  emitDev,
642
265
  noteRun: (traceId, note) => {
643
- rememberNote(notes, traceId, note, MAX_NOTES);
266
+ rememberNote(state.notes, traceId, note, MAX_NOTES);
644
267
  },
645
268
  close: () => new Promise((done) => {
646
- for (const c of clients)
269
+ for (const c of state.clients)
647
270
  c.end();
648
- clients.clear();
271
+ state.clients.clear();
649
272
  server.close(() => done());
650
273
  }),
651
274
  });
package/dist/markdown.js CHANGED
@@ -23,6 +23,7 @@
23
23
  * - 不做表格 / 任务列表 / 嵌套列表 / 脚注 / 数学 —— 面板是调试视图,不是文档站
24
24
  * - 不做 setext 标题(`===` 下划线式)—— 与分隔线歧义大、收益小
25
25
  * - 不做图片(`![]()`)—— 面板**不去外网取图**:一次 run 的正文不该触发网络请求
26
+ * - 不做**词内** `_` 强调(`read_file_tool` 原样显示)—— 与 CommonMark 同口径,见 `parseInline`
26
27
  */
27
28
  /**
28
29
  * 链接协议白名单。**这是安全边界,不是风格选择**:
@@ -42,6 +43,12 @@ const QUOTE = /^\s{0,3}>\s?(.*)$/;
42
43
  const UL = /^(\s*)([-*+])\s+(.*)$/;
43
44
  const OL = /^(\s*)(\d{1,9})[.)]\s+(.*)$/;
44
45
  const BLANK = /^\s*$/;
46
+ /** 「单词字符」:`_` 的词内排除用它判边界(与 CommonMark 的 intraword 规则同口径) */
47
+ const WORD_CHAR = /[A-Za-z0-9_]/;
48
+ /** 下标可能越界(`text[-1]` / `text[len]` 都是 `undefined`)⇒ 越界一律当「不是单词字符」 */
49
+ function isWordChar(ch) {
50
+ return ch !== undefined && WORD_CHAR.test(ch);
51
+ }
45
52
  /**
46
53
  * 把一段 Markdown 文本解析成块序列。
47
54
  *
@@ -226,7 +233,19 @@ export function parseInline(text, depth) {
226
233
  const end = text.indexOf(mark, i + mark.length);
227
234
  if (end > i + mark.length) {
228
235
  const inner = text.slice(i + mark.length, end);
229
- if (inner.trim() && !inner.startsWith(' ') && !inner.endsWith(' ')) {
236
+ /**
237
+ * `_` 的**词内排除**(CommonMark 的 intraword 规则):两侧只要有一边紧贴单词字符,
238
+ * 就不构成强调。
239
+ *
240
+ * 没有这条,`read_file_tool` 会被切成 `read` + <em>file</em> + `tool`、
241
+ * `src/my_dir/my_file.ts` 同理 —— 而面板渲染的正是**模型正文**,标识符是那里最常见的东西。
242
+ * 单下划线(`read_file`)本来就不配对、不受影响;真正被切开的是有两个及以上 `_` 的标识符。
243
+ *
244
+ * ⚠️ `*` **不做**这条限制:CommonMark 里 `a*b*c` **就是** `a<em>b</em>c`。
245
+ * 只修 `_`、不修 `*`,是照口径修,不是照直觉修。
246
+ */
247
+ const intraword = c === '_' && (isWordChar(text[i - 1]) || isWordChar(text[end + mark.length]));
248
+ if (!intraword && inner.trim() && !inner.startsWith(' ') && !inner.endsWith(' ')) {
230
249
  pushText();
231
250
  const children = depth < 3 ? parseInline(inner, depth + 1) : [{ type: 'text', text: inner }];
232
251
  out.push(strong ? { type: 'strong', children } : { type: 'em', children });