@migor/cli 0.8.2 → 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.
@@ -1,14 +1,13 @@
1
1
  export function createInspectSink(opts) {
2
2
  const url = `http://${opts.host ?? '127.0.0.1'}:${opts.port}/ingest`;
3
+ const headers = { 'content-type': 'application/json' };
4
+ if (opts.token)
5
+ headers['x-agentia-token'] = opts.token;
3
6
  return {
4
7
  async export(trace) {
5
8
  try {
6
9
  // Node 18+ 全局 fetch;这里不做降级 —— dev 场景 Node 版本由 dev.ts 统一把关
7
- await fetch(url, {
8
- method: 'POST',
9
- headers: { 'content-type': 'application/json' },
10
- body: JSON.stringify(trace),
11
- });
10
+ await fetch(url, { method: 'POST', headers, body: JSON.stringify(trace) });
12
11
  }
13
12
  catch {
14
13
  /* 面板未开 / 已关:静默 */
package/dist/inspector.js CHANGED
@@ -4,14 +4,37 @@
4
4
  * 面板展示每次 run 的调用树:左边 run 列表、右边该 run 的能力执行
5
5
  * (tool / skill / prompt / subagent 的入参、出参、耗时、token、cache、错误)。
6
6
  *
7
- * trace 由框架侧的 TraceSink 经 `POST /ingest` 投递 —— 注入方式见 dev.ts
8
- * (NODE_OPTIONS=--import 拉起 inspector-preload,preload 从用户项目解析框架并注册 sink)。
7
+ * trace 由框架侧的 TraceSink 经 `POST /ingest` 投递 —— 注册那一头在 **dev runner** 里
8
+ * (`dev-runner.ts` 的 `registerTraceSink()`,从**用户项目**解析 `@migor/agentia` 并注册)。
9
+ * ⚠️ 早先那套 `NODE_OPTIONS=--import` + `inspector-preload.ts` 已随「CLI 自己拥有子进程」
10
+ * 一起删掉(理由见 dev.ts 文件头)—— 那个文件**不在仓库里**了,别再按它理解投递路径。
9
11
  * 本服务不认识框架类型:入参按结构面(TraceLike)校验,CLI 因此保持零运行时依赖。
12
+ *
13
+ * 服务同时是**面板 → dev 环**的入口(`POST /run`):具体怎么驱动用户代码由 `dev.ts`
14
+ * 经 `DevHooks` 注入 —— 本文件不认识子进程、不认识 tsx,只做 HTTP 与校验。
15
+ *
16
+ * ## 鉴权(D0)
17
+ *
18
+ * 两道,都零依赖:
19
+ * 1. **`Origin` 校验**:缺省(同源导航 / 非浏览器客户端)或本机 origin 才放行。
20
+ * 这半条挡的是「开发者访问的任意网页往本地端口发跨源请求」—— 跨源 `fetch`
21
+ * 浏览器**必带** `Origin`。
22
+ * 2. **per-session token**:`dev.ts` 启动时生成随机 token,嵌进面板 URL。校验覆盖
23
+ * **所有**接口(不只是写接口)—— 读接口(`/api/runs`、trace 内容)同样不该对
24
+ * 本机任意网页开放,而校验成本为零。
25
+ *
26
+ * ⚠️ token 的真正作用**不是补 `Origin` 的漏**,而是挡「本机**其它已沦陷的进程**」——
27
+ * 它们能直接 `curl`,没有 `Origin` 这个约束。写清这一点是为了避免后来者以为
28
+ * 「`Origin` 没挡住,是 token 救的」而做出错误的加固取舍。
10
29
  */
30
+ import { timingSafeEqual } from 'node:crypto';
11
31
  import { createServer } from 'node:http';
12
32
  import { readFile } from 'node:fs/promises';
13
- import { dirname, join } from 'node:path';
33
+ import { readdirSync, statSync } from 'node:fs';
34
+ import { dirname, join, resolve } from 'node:path';
14
35
  import { fileURLToPath } from 'node:url';
36
+ import { DEFAULT_BUDGET, } from './dev-protocol.js';
37
+ import { rememberNote } from './panel-logic.js';
15
38
  /** 入站校验:结构不符的 trace 直接拒(400),不让 NaN 之类的坏数据流进面板与 SSE 广播 */
16
39
  function validateTrace(t) {
17
40
  if (!t || typeof t !== 'object' || Array.isArray(t))
@@ -47,19 +70,125 @@ function isLocalHostHeader(host) {
47
70
  .toLowerCase();
48
71
  return name === 'localhost' || name === '127.0.0.1' || name === '::1';
49
72
  }
73
+ /**
74
+ * `Origin` 校验(D0 的第一道)。
75
+ *
76
+ * 缺省 = 放行:同源导航与**非浏览器客户端**(`curl`、preload 的 `fetch`)都不带
77
+ * `Origin`,把它们拦掉等于把 dev 环弄坏。带 `Origin` 的一定是浏览器跨源/同源请求
78
+ * —— 那时只放行本机 origin。
79
+ *
80
+ * 注意 `Origin: null`(sandboxed iframe / file:// 页面)**不是**「缺省」:它是浏览器
81
+ * 明确告诉我们「这是个不该被信任的上下文」,一律拒。
82
+ */
83
+ function isAllowedOrigin(origin) {
84
+ if (origin === undefined || origin === '')
85
+ return true;
86
+ let url;
87
+ try {
88
+ url = new URL(origin);
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
94
+ return false;
95
+ const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
96
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1';
97
+ }
98
+ /** token 的 cookie 名(面板首帧带 `?t=` 拿到它之后,后续请求自动带上) */
99
+ const TOKEN_COOKIE = 'agentia_dev_token';
100
+ /** token 的自定义头名(非浏览器客户端 / 脚本用;不进 URL、不留痕) */
101
+ const TOKEN_HEADER = 'x-agentia-token';
102
+ /** 定长比较:长度不同直接否(`timingSafeEqual` 对不等长会抛) */
103
+ function tokenEquals(a, b) {
104
+ const x = Buffer.from(a, 'utf8');
105
+ const y = Buffer.from(b, 'utf8');
106
+ if (x.length !== y.length)
107
+ return false;
108
+ return timingSafeEqual(x, y);
109
+ }
110
+ function cookieValue(header, name) {
111
+ if (!header)
112
+ return null;
113
+ for (const part of header.split(';')) {
114
+ const eq = part.indexOf('=');
115
+ if (eq < 0)
116
+ continue;
117
+ if (part.slice(0, eq).trim() === name)
118
+ return part.slice(eq + 1).trim() || null;
119
+ }
120
+ return null;
121
+ }
122
+ /**
123
+ * 目录判定要**解引用软链**:pnpm store / monorepo 里 `src/tools/foo` 常是软链,
124
+ * 它的 `isDirectory()` 为 false —— 直接用会把真目录静默漏掉(discover.ts 同一条教训)。
125
+ */
126
+ function isDirLikeEntry(full, e) {
127
+ if (e.isDirectory())
128
+ return true;
129
+ if (!e.isSymbolicLink())
130
+ return false;
131
+ try {
132
+ return statSync(full).isDirectory();
133
+ }
134
+ catch {
135
+ return false;
136
+ }
137
+ }
50
138
  /** 环形缓冲上限:面板只保证「回看最近 N 条」,不做历史归档 */
51
139
  const MAX_RUNS = 50;
140
+ /**
141
+ * CLI 侧记账的上限。与 `MAX_RUNS` 同量级但**独立**:note 可能永远等不到它的 trace
142
+ * (见 `rememberNote`),所以它有自己的一条淘汰线。
143
+ */
144
+ const MAX_NOTES = MAX_RUNS;
52
145
  /** 静态资源目录(构建期由 scripts/copy-assets.mjs 就位) */
53
- const ASSETS = join(dirname(fileURLToPath(import.meta.url)), 'inspector');
54
- const PAGE = join(dirname(fileURLToPath(import.meta.url)), 'inspector-page.html');
55
- const STATIC = new Set(['index.js', 'view.js', 'fromTrace.js', 'summary.js', 'trace-view.css']);
56
- function summarize(t) {
146
+ const HERE = dirname(fileURLToPath(import.meta.url));
147
+ const ASSETS = join(HERE, 'inspector');
148
+ const PAGE = join(HERE, 'inspector-page.html');
149
+ /**
150
+ * 静态资源白名单:**只有这里列出的名字**能被读出来。
151
+ *
152
+ * `panel-logic.js` 是 CLI 自己编译出来的模块(面板纯逻辑,单测直接 import 它),
153
+ * 落在 dist 根而不是 dist/inspector/ —— 与 `inspector-page.html` 同处一层。
154
+ */
155
+ const STATIC = new Map([
156
+ ['index.js', join(ASSETS, 'index.js')],
157
+ ['view.js', join(ASSETS, 'view.js')],
158
+ ['fromTrace.js', join(ASSETS, 'fromTrace.js')],
159
+ ['summary.js', join(ASSETS, 'summary.js')],
160
+ ['trace-view.css', join(ASSETS, 'trace-view.css')],
161
+ ['panel-logic.js', join(HERE, 'panel-logic.js')],
162
+ ]);
163
+ /** 没有 dev 钩子时的状态(面板据此隐藏输入条) */
164
+ function unavailableDevState() {
165
+ return {
166
+ available: false,
167
+ projectRoot: '',
168
+ home: '',
169
+ capabilities: [],
170
+ multiTurn: [],
171
+ warning: null,
172
+ defaultWorkdir: '',
173
+ budget: { ...DEFAULT_BUDGET },
174
+ running: false,
175
+ lastError: null,
176
+ // 没有 dev 环 ⇒ 没有会话可谈(面板只在 available 时才读它)
177
+ sessionId: '',
178
+ };
179
+ }
180
+ function summarize(t, note) {
57
181
  const spans = t.spans || [];
58
182
  const root = spans.find((s) => s.kind === 'run') || spans[0];
59
183
  const startedAt = root ? root.startedAt : 0;
60
184
  const endedAt = root && root.endedAt != null ? root.endedAt : startedAt;
61
185
  const u = t.totalUsage || {};
62
186
  const status = t.status || root?.status || 'ok';
187
+ const sid = root?.attributes?.['session.id'];
188
+ // 收尾原因由引擎写在 run 根 span 的 `stop_reason` 上(`engine/loop.ts` 的
189
+ // `recorder.setAttribute(rootId, 'stop_reason', …)`)—— 面板判中止**必须**看它,
190
+ // 因为 `aborted` 的 `ok` 也是 false(见 `RunSummary.stopReason`)
191
+ const sr = root?.attributes?.stop_reason;
63
192
  return {
64
193
  traceId: t.traceId,
65
194
  status,
@@ -68,22 +197,45 @@ function summarize(t) {
68
197
  ms: endedAt - startedAt,
69
198
  tokens: (u.inputTokens || 0) + (u.outputTokens || 0),
70
199
  ok: status !== 'error',
200
+ stopReason: typeof sr === 'string' && sr.length > 0 ? sr : null,
201
+ sessionId: typeof sid === 'string' && sid.length > 0 ? sid : null,
202
+ note,
71
203
  };
72
204
  }
205
+ /** 带 statusCode 的错误:让 handler 的 catch 回对应的码而不是一律 500 */
206
+ export class HttpError extends Error {
207
+ statusCode;
208
+ constructor(statusCode, message) {
209
+ super(message);
210
+ this.statusCode = statusCode;
211
+ }
212
+ }
73
213
  /**
74
214
  * 起 inspector 服务。缺省监听 127.0.0.1 的随机空闲端口(port 0),
75
215
  * 便于 dev 命令并发多开互不打架;实际端口经 resolve 的 port 返回。
216
+ *
217
+ * ⚠️ `token` 缺省 = **不校验 token**(`Origin` 那道仍然生效)。这条留给
218
+ * 「程序内自用 / 测试」;`agentia dev` **一律传** —— 它是唯一会把这个端口
219
+ * 印给用户、并让面板能驱动真 agent 的路径。别在 dev 那条路上省这个参数。
76
220
  */
77
221
  export function startInspector(opts = {}) {
78
222
  const host = opts.host ?? '127.0.0.1';
79
223
  const runs = new Map();
80
224
  const order = []; // 到达顺序,用于淘汰与列表排序
225
+ const notes = new Map(); // CLI 侧记账(**有自己的上限**,见 rememberNote)
81
226
  const clients = new Set(); // SSE 订阅者
227
+ const token = opts.token;
82
228
  const broadcast = (s) => {
83
229
  const chunk = `data: ${JSON.stringify(s)}\n\n`;
84
230
  for (const c of clients)
85
231
  c.write(chunk);
86
232
  };
233
+ /** 命名事件 `dev`:run 生命周期与 runner 状态 —— 面板靠它显示「在跑 / 失败 / 重启」 */
234
+ const emitDev = (ev) => {
235
+ const chunk = `event: dev\ndata: ${JSON.stringify(ev)}\n\n`;
236
+ for (const c of clients)
237
+ c.write(chunk);
238
+ };
87
239
  const json = (res, code, body) => {
88
240
  const text = JSON.stringify(body);
89
241
  res.writeHead(code, {
@@ -92,8 +244,12 @@ export function startInspector(opts = {}) {
92
244
  });
93
245
  res.end(text);
94
246
  };
95
- const text = (res, code, type, body) => {
96
- res.writeHead(code, { 'content-type': type, 'content-length': Buffer.byteLength(body) });
247
+ const text = (res, code, type, body, headers = {}) => {
248
+ res.writeHead(code, {
249
+ 'content-type': type,
250
+ 'content-length': Buffer.byteLength(body),
251
+ ...headers,
252
+ });
97
253
  res.end(body);
98
254
  };
99
255
  const readBody = (req, maxBytes = 8 * 1024 * 1024) => new Promise((resolve, reject) => {
@@ -102,8 +258,7 @@ export function startInspector(opts = {}) {
102
258
  req.on('data', (c) => {
103
259
  size += c.length;
104
260
  if (size > maxBytes) {
105
- const err = new Error(`请求 body 过大(上限 ${maxBytes} 字节)`);
106
- err.statusCode = 413; // 让 handler 的 catch 回 413 而非 500
261
+ const err = new HttpError(413, `请求 body 过大(上限 ${maxBytes} 字节)`);
107
262
  reject(err);
108
263
  req.destroy();
109
264
  return;
@@ -113,13 +268,61 @@ export function startInspector(opts = {}) {
113
268
  req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
114
269
  req.on('error', reject);
115
270
  });
271
+ /** 校验 `RunRequest`:形状不对就 400,且说清是哪一项 —— 面板据此把原因显示给用户 */
272
+ const parseRunRequest = (raw) => {
273
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
274
+ throw new HttpError(400, 'body 必须是对象');
275
+ }
276
+ const b = raw;
277
+ if (typeof b.prompt !== 'string' || b.prompt.trim().length === 0) {
278
+ throw new HttpError(400, 'prompt 必须是非空字符串');
279
+ }
280
+ if (b.workdir !== undefined && typeof b.workdir !== 'string') {
281
+ throw new HttpError(400, 'workdir 必须是字符串');
282
+ }
283
+ if (b.multiTurn !== undefined && typeof b.multiTurn !== 'boolean') {
284
+ throw new HttpError(400, 'multiTurn 必须是布尔值');
285
+ }
286
+ let toolSources;
287
+ if (b.toolSources !== undefined) {
288
+ if (!Array.isArray(b.toolSources) || b.toolSources.some((s) => typeof s !== 'string')) {
289
+ throw new HttpError(400, 'toolSources 必须是字符串数组');
290
+ }
291
+ toolSources = b.toolSources;
292
+ }
293
+ return {
294
+ prompt: b.prompt,
295
+ ...(b.workdir === undefined ? {} : { workdir: b.workdir }),
296
+ ...(toolSources === undefined ? {} : { toolSources }),
297
+ ...(b.multiTurn === undefined ? {} : { multiTurn: b.multiTurn }),
298
+ };
299
+ };
116
300
  const handler = async (req, res) => {
117
- const path = new URL(req.url || '/', `http://${host}`).pathname;
301
+ const url = new URL(req.url || '/', `http://${host}`);
302
+ const path = url.pathname;
118
303
  try {
119
304
  if (!isLocalHostHeader(req.headers.host)) {
120
305
  json(res, 403, { error: '仅允许本机访问(Host 须为 localhost / 127.0.0.1 / [::1])' });
121
306
  return;
122
307
  }
308
+ if (!isAllowedOrigin(req.headers.origin)) {
309
+ json(res, 403, {
310
+ error: `Origin「${req.headers.origin}」不被允许(只接受本机 origin 或缺省)`,
311
+ });
312
+ return;
313
+ }
314
+ // token:URL query / 自定义头 / cookie 三选一(覆盖「面板首帧」与「脚本直连」两种用法)
315
+ const queryToken = url.searchParams.get('t');
316
+ const presented = queryToken ??
317
+ (typeof req.headers[TOKEN_HEADER] === 'string' ? req.headers[TOKEN_HEADER] : null) ??
318
+ cookieValue(req.headers.cookie, TOKEN_COOKIE);
319
+ if (token !== undefined && (presented === null || !tokenEquals(presented, token))) {
320
+ json(res, 403, {
321
+ error: '缺少或错误的 dev token。请用 `agentia dev` 打印的带 ?t= 的完整 URL 打开面板' +
322
+ '(token 会种进 cookie,之后刷新不带也行)',
323
+ });
324
+ return;
325
+ }
123
326
  if (req.method === 'POST' && path === '/ingest') {
124
327
  const body = await readBody(req); // 超 413 走外层 catch
125
328
  let trace;
@@ -138,11 +341,14 @@ export function startInspector(opts = {}) {
138
341
  const valid = trace;
139
342
  if (!runs.has(valid.traceId)) {
140
343
  order.push(valid.traceId);
141
- if (order.length > MAX_RUNS)
142
- runs.delete(order.shift());
344
+ if (order.length > MAX_RUNS) {
345
+ const evicted = order.shift();
346
+ runs.delete(evicted);
347
+ notes.delete(evicted);
348
+ }
143
349
  }
144
350
  runs.set(valid.traceId, valid);
145
- broadcast(summarize(valid));
351
+ broadcast(summarize(valid, notes.get(valid.traceId) ?? null));
146
352
  json(res, 200, { ok: true });
147
353
  return;
148
354
  }
@@ -150,7 +356,7 @@ export function startInspector(opts = {}) {
150
356
  json(res, 200, order
151
357
  .slice()
152
358
  .reverse()
153
- .map((id) => summarize(runs.get(id))));
359
+ .map((id) => summarize(runs.get(id), notes.get(id) ?? null)));
154
360
  return;
155
361
  }
156
362
  if (req.method === 'GET' && path.startsWith('/api/runs/')) {
@@ -163,6 +369,98 @@ export function startInspector(opts = {}) {
163
369
  json(res, 200, t);
164
370
  return;
165
371
  }
372
+ if (req.method === 'GET' && path === '/api/dev') {
373
+ json(res, 200, opts.dev ? opts.dev.state() : unavailableDevState());
374
+ return;
375
+ }
376
+ /**
377
+ * 目录浏览器:`GET /api/fs?path=<绝对路径>` → 该目录下的**子目录名**(不含文件)。
378
+ *
379
+ * 为什么需要它:浏览器**拿不到**用户选的文件夹的绝对路径 —— `<input webkitdirectory>`
380
+ * 只给相对路径,File System Access API 只给 handle.name。而 dev 环要的是绝对路径
381
+ * (工具按它解析)。⇒ 要么让用户手打路径,要么服务端列目录。后者才是「工作目录是
382
+ * 主控件」(D3/D7)该有的手感。
383
+ *
384
+ * 只列**目录**、不读文件内容;且只在 dev 钩子在场时开放(只读面板不暴露文件树)。
385
+ * 越界不是威胁(本机 dev 工具,token 已经挡住了其它进程),但**不存在 / 不是目录**
386
+ * 要响亮报错 —— 静默回退到项目根会让 agent 对着错的目录乱写(D5)。
387
+ */
388
+ if (req.method === 'GET' && path === '/api/fs') {
389
+ if (!opts.dev) {
390
+ json(res, 403, { error: '这个 inspector 不是 agentia dev 起的,不提供目录浏览' });
391
+ return;
392
+ }
393
+ const target = url.searchParams.get('path');
394
+ if (!target) {
395
+ json(res, 400, { error: '缺少 path 参数' });
396
+ return;
397
+ }
398
+ const abs = resolve(target);
399
+ let entries;
400
+ try {
401
+ if (!statSync(abs).isDirectory()) {
402
+ json(res, 400, { error: `不是文件夹:${abs}` });
403
+ return;
404
+ }
405
+ entries = readdirSync(abs, { withFileTypes: true });
406
+ }
407
+ catch (e) {
408
+ json(res, 400, { error: `读不了这个目录:${e.message}` });
409
+ return;
410
+ }
411
+ const dirs = entries
412
+ .filter((e) => !e.name.startsWith('.') && isDirLikeEntry(join(abs, e.name), e))
413
+ .map((e) => e.name)
414
+ .sort();
415
+ json(res, 200, { path: abs, parent: dirname(abs), dirs });
416
+ return;
417
+ }
418
+ if (req.method === 'POST' && path === '/run') {
419
+ if (!opts.dev) {
420
+ json(res, 503, {
421
+ error: '这个 inspector 不是 agentia dev 起的(没有 runner),不能驱动 run',
422
+ });
423
+ return;
424
+ }
425
+ const body = await readBody(req);
426
+ let raw;
427
+ try {
428
+ raw = JSON.parse(body);
429
+ }
430
+ catch {
431
+ json(res, 400, { error: 'body 不是合法 JSON' });
432
+ return;
433
+ }
434
+ const ack = await opts.dev.run(parseRunRequest(raw));
435
+ json(res, 202, ack);
436
+ return;
437
+ }
438
+ if (req.method === 'POST' && path === '/run/abort') {
439
+ if (!opts.dev) {
440
+ json(res, 503, { error: '这个 inspector 不是 agentia dev 起的(没有 runner)' });
441
+ return;
442
+ }
443
+ // 202 而不是 200:中止是**请求已受理**,run 真正收尾要走 SSE 的 `run-done`
444
+ // (优雅路径要在回合边界才生效,不是同步的)。
445
+ json(res, 202, await opts.dev.abort());
446
+ return;
447
+ }
448
+ if (req.method === 'POST' && path === '/session/clear') {
449
+ if (!opts.dev) {
450
+ json(res, 503, { error: '这个 inspector 不是 agentia dev 起的(没有 runner)' });
451
+ return;
452
+ }
453
+ json(res, 200, await opts.dev.clearSession());
454
+ return;
455
+ }
456
+ if (req.method === 'GET' && path === '/api/session') {
457
+ if (!opts.dev) {
458
+ json(res, 200, { session: null });
459
+ return;
460
+ }
461
+ json(res, 200, { session: await opts.dev.session() });
462
+ return;
463
+ }
166
464
  if (req.method === 'GET' && path === '/stream') {
167
465
  res.writeHead(200, {
168
466
  'content-type': 'text/event-stream',
@@ -175,7 +473,12 @@ export function startInspector(opts = {}) {
175
473
  return;
176
474
  }
177
475
  if (req.method === 'GET' && (path === '/' || path === '/index.html')) {
178
- text(res, 200, 'text/html; charset=utf-8', await readFile(PAGE, 'utf8'));
476
+ // 首帧带了合法 token ⇒ 种进 cookie(SameSite=Strict + HttpOnly):
477
+ // 之后面板自己的 fetch / EventSource 自动带上,token 不进 URL 历史、不进截图。
478
+ const headers = token !== undefined && queryToken !== null
479
+ ? { 'set-cookie': `${TOKEN_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict` }
480
+ : {};
481
+ text(res, 200, 'text/html; charset=utf-8', await readFile(PAGE, 'utf8'), headers);
179
482
  return;
180
483
  }
181
484
  if (req.method === 'GET' && STATIC.has(path.slice(1))) {
@@ -183,13 +486,13 @@ export function startInspector(opts = {}) {
183
486
  const type = name.endsWith('.css')
184
487
  ? 'text/css; charset=utf-8'
185
488
  : 'text/javascript; charset=utf-8';
186
- text(res, 200, type, await readFile(join(ASSETS, name), 'utf8'));
489
+ text(res, 200, type, await readFile(STATIC.get(name), 'utf8'));
187
490
  return;
188
491
  }
189
492
  json(res, 404, { error: 'not found' });
190
493
  }
191
494
  catch (e) {
192
- const status = e.statusCode ?? 500;
495
+ const status = e instanceof HttpError ? e.statusCode : (e.statusCode ?? 500);
193
496
  json(res, status, { error: e.message });
194
497
  }
195
498
  };
@@ -203,6 +506,10 @@ export function startInspector(opts = {}) {
203
506
  const port = addr && typeof addr === 'object' ? addr.port : (opts.port ?? 0);
204
507
  resolve({
205
508
  port,
509
+ emitDev,
510
+ noteRun: (traceId, note) => {
511
+ rememberNote(notes, traceId, note, MAX_NOTES);
512
+ },
206
513
  close: () => new Promise((done) => {
207
514
  for (const c of clients)
208
515
  c.end();