@migor/cli 0.8.3 → 0.9.1

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
  /* 面板未开 / 已关:静默 */
@@ -16,3 +15,22 @@ export function createInspectSink(opts) {
16
15
  },
17
16
  };
18
17
  }
18
+ export function createInspectEventSink(opts) {
19
+ const url = `http://${opts.host ?? '127.0.0.1'}:${opts.port}/ingest-event`;
20
+ const headers = { 'content-type': 'application/json' };
21
+ if (opts.token)
22
+ headers['x-agentia-token'] = opts.token;
23
+ let chain = Promise.resolve();
24
+ return {
25
+ send(event) {
26
+ chain = chain.then(async () => {
27
+ try {
28
+ await fetch(url, { method: 'POST', headers, body: JSON.stringify(event) });
29
+ }
30
+ catch {
31
+ /* 面板未开 / 已关 / 连接被拒:静默(观测失败不击穿 dev) */
32
+ }
33
+ });
34
+ },
35
+ };
36
+ }
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))
@@ -37,6 +60,68 @@ function validateTrace(t) {
37
60
  }
38
61
  return null;
39
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
+ }
40
125
  /** 防 DNS rebinding:面板只服务本机,Host 不是 localhost/127.0.0.1/[::1] 的一律拒 */
41
126
  function isLocalHostHeader(host) {
42
127
  if (!host)
@@ -47,19 +132,130 @@ function isLocalHostHeader(host) {
47
132
  .toLowerCase();
48
133
  return name === 'localhost' || name === '127.0.0.1' || name === '::1';
49
134
  }
135
+ /**
136
+ * `Origin` 校验(D0 的第一道)。
137
+ *
138
+ * 缺省 = 放行:同源导航与**非浏览器客户端**(`curl`、preload 的 `fetch`)都不带
139
+ * `Origin`,把它们拦掉等于把 dev 环弄坏。带 `Origin` 的一定是浏览器跨源/同源请求
140
+ * —— 那时只放行本机 origin。
141
+ *
142
+ * 注意 `Origin: null`(sandboxed iframe / file:// 页面)**不是**「缺省」:它是浏览器
143
+ * 明确告诉我们「这是个不该被信任的上下文」,一律拒。
144
+ */
145
+ function isAllowedOrigin(origin) {
146
+ if (origin === undefined || origin === '')
147
+ return true;
148
+ let url;
149
+ try {
150
+ url = new URL(origin);
151
+ }
152
+ catch {
153
+ return false;
154
+ }
155
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
156
+ return false;
157
+ const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
158
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1';
159
+ }
160
+ /** token 的 cookie 名(面板首帧带 `?t=` 拿到它之后,后续请求自动带上) */
161
+ const TOKEN_COOKIE = 'agentia_dev_token';
162
+ /** token 的自定义头名(非浏览器客户端 / 脚本用;不进 URL、不留痕) */
163
+ const TOKEN_HEADER = 'x-agentia-token';
164
+ /** 定长比较:长度不同直接否(`timingSafeEqual` 对不等长会抛) */
165
+ function tokenEquals(a, b) {
166
+ const x = Buffer.from(a, 'utf8');
167
+ const y = Buffer.from(b, 'utf8');
168
+ if (x.length !== y.length)
169
+ return false;
170
+ return timingSafeEqual(x, y);
171
+ }
172
+ function cookieValue(header, name) {
173
+ if (!header)
174
+ return null;
175
+ for (const part of header.split(';')) {
176
+ const eq = part.indexOf('=');
177
+ if (eq < 0)
178
+ continue;
179
+ if (part.slice(0, eq).trim() === name)
180
+ return part.slice(eq + 1).trim() || null;
181
+ }
182
+ return null;
183
+ }
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
+ }
50
200
  /** 环形缓冲上限:面板只保证「回看最近 N 条」,不做历史归档 */
51
201
  const MAX_RUNS = 50;
202
+ /**
203
+ * CLI 侧记账的上限。与 `MAX_RUNS` 同量级但**独立**:note 可能永远等不到它的 trace
204
+ * (见 `rememberNote`),所以它有自己的一条淘汰线。
205
+ */
206
+ const MAX_NOTES = MAX_RUNS;
207
+ /**
208
+ * `GET /api/fs` 一次最多回多少个**文件**名(目录不设限:一个位置要列几百个目录,本身就
209
+ * 说明位置选错了)。超限回 `filesTruncated: true` —— 截断必须**明示**,不许静默少给。
210
+ */
211
+ const FILE_LIMIT = 200;
52
212
  /** 静态资源目录(构建期由 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) {
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
+ ]);
230
+ /** 没有 dev 钩子时的状态(面板据此隐藏输入条) */
231
+ function unavailableDevState() {
232
+ return {
233
+ available: false,
234
+ projectRoot: '',
235
+ home: '',
236
+ capabilities: [],
237
+ multiTurn: [],
238
+ warning: null,
239
+ defaultWorkdir: '',
240
+ budget: { ...DEFAULT_BUDGET },
241
+ running: false,
242
+ lastError: null,
243
+ // 没有 dev 环 ⇒ 没有会话可谈(面板只在 available 时才读它)
244
+ sessionId: '',
245
+ };
246
+ }
247
+ function summarize(t, note) {
57
248
  const spans = t.spans || [];
58
249
  const root = spans.find((s) => s.kind === 'run') || spans[0];
59
250
  const startedAt = root ? root.startedAt : 0;
60
251
  const endedAt = root && root.endedAt != null ? root.endedAt : startedAt;
61
252
  const u = t.totalUsage || {};
62
253
  const status = t.status || root?.status || 'ok';
254
+ const sid = root?.attributes?.['session.id'];
255
+ // 收尾原因由引擎写在 run 根 span 的 `stop_reason` 上(`engine/loop.ts` 的
256
+ // `recorder.setAttribute(rootId, 'stop_reason', …)`)—— 面板判中止**必须**看它,
257
+ // 因为 `aborted` 的 `ok` 也是 false(见 `RunSummary.stopReason`)
258
+ const sr = root?.attributes?.stop_reason;
63
259
  return {
64
260
  traceId: t.traceId,
65
261
  status,
@@ -68,22 +264,45 @@ function summarize(t) {
68
264
  ms: endedAt - startedAt,
69
265
  tokens: (u.inputTokens || 0) + (u.outputTokens || 0),
70
266
  ok: status !== 'error',
267
+ stopReason: typeof sr === 'string' && sr.length > 0 ? sr : null,
268
+ sessionId: typeof sid === 'string' && sid.length > 0 ? sid : null,
269
+ note,
71
270
  };
72
271
  }
272
+ /** 带 statusCode 的错误:让 handler 的 catch 回对应的码而不是一律 500 */
273
+ export class HttpError extends Error {
274
+ statusCode;
275
+ constructor(statusCode, message) {
276
+ super(message);
277
+ this.statusCode = statusCode;
278
+ }
279
+ }
73
280
  /**
74
281
  * 起 inspector 服务。缺省监听 127.0.0.1 的随机空闲端口(port 0),
75
282
  * 便于 dev 命令并发多开互不打架;实际端口经 resolve 的 port 返回。
283
+ *
284
+ * ⚠️ `token` 缺省 = **不校验 token**(`Origin` 那道仍然生效)。这条留给
285
+ * 「程序内自用 / 测试」;`agentia dev` **一律传** —— 它是唯一会把这个端口
286
+ * 印给用户、并让面板能驱动真 agent 的路径。别在 dev 那条路上省这个参数。
76
287
  */
77
288
  export function startInspector(opts = {}) {
78
289
  const host = opts.host ?? '127.0.0.1';
79
290
  const runs = new Map();
80
291
  const order = []; // 到达顺序,用于淘汰与列表排序
292
+ const notes = new Map(); // CLI 侧记账(**有自己的上限**,见 rememberNote)
81
293
  const clients = new Set(); // SSE 订阅者
294
+ const token = opts.token;
82
295
  const broadcast = (s) => {
83
296
  const chunk = `data: ${JSON.stringify(s)}\n\n`;
84
297
  for (const c of clients)
85
298
  c.write(chunk);
86
299
  };
300
+ /** 命名事件 `dev`:run 生命周期与 runner 状态 —— 面板靠它显示「在跑 / 失败 / 重启」 */
301
+ const emitDev = (ev) => {
302
+ const chunk = `event: dev\ndata: ${JSON.stringify(ev)}\n\n`;
303
+ for (const c of clients)
304
+ c.write(chunk);
305
+ };
87
306
  const json = (res, code, body) => {
88
307
  const text = JSON.stringify(body);
89
308
  res.writeHead(code, {
@@ -92,8 +311,12 @@ export function startInspector(opts = {}) {
92
311
  });
93
312
  res.end(text);
94
313
  };
95
- const text = (res, code, type, body) => {
96
- res.writeHead(code, { 'content-type': type, 'content-length': Buffer.byteLength(body) });
314
+ const text = (res, code, type, body, headers = {}) => {
315
+ res.writeHead(code, {
316
+ 'content-type': type,
317
+ 'content-length': Buffer.byteLength(body),
318
+ ...headers,
319
+ });
97
320
  res.end(body);
98
321
  };
99
322
  const readBody = (req, maxBytes = 8 * 1024 * 1024) => new Promise((resolve, reject) => {
@@ -102,8 +325,7 @@ export function startInspector(opts = {}) {
102
325
  req.on('data', (c) => {
103
326
  size += c.length;
104
327
  if (size > maxBytes) {
105
- const err = new Error(`请求 body 过大(上限 ${maxBytes} 字节)`);
106
- err.statusCode = 413; // 让 handler 的 catch 回 413 而非 500
328
+ const err = new HttpError(413, `请求 body 过大(上限 ${maxBytes} 字节)`);
107
329
  reject(err);
108
330
  req.destroy();
109
331
  return;
@@ -113,13 +335,61 @@ export function startInspector(opts = {}) {
113
335
  req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
114
336
  req.on('error', reject);
115
337
  });
338
+ /** 校验 `RunRequest`:形状不对就 400,且说清是哪一项 —— 面板据此把原因显示给用户 */
339
+ const parseRunRequest = (raw) => {
340
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
341
+ throw new HttpError(400, 'body 必须是对象');
342
+ }
343
+ const b = raw;
344
+ if (typeof b.prompt !== 'string' || b.prompt.trim().length === 0) {
345
+ throw new HttpError(400, 'prompt 必须是非空字符串');
346
+ }
347
+ if (b.workdir !== undefined && typeof b.workdir !== 'string') {
348
+ throw new HttpError(400, 'workdir 必须是字符串');
349
+ }
350
+ if (b.multiTurn !== undefined && typeof b.multiTurn !== 'boolean') {
351
+ throw new HttpError(400, 'multiTurn 必须是布尔值');
352
+ }
353
+ let toolSources;
354
+ if (b.toolSources !== undefined) {
355
+ if (!Array.isArray(b.toolSources) || b.toolSources.some((s) => typeof s !== 'string')) {
356
+ throw new HttpError(400, 'toolSources 必须是字符串数组');
357
+ }
358
+ toolSources = b.toolSources;
359
+ }
360
+ return {
361
+ prompt: b.prompt,
362
+ ...(b.workdir === undefined ? {} : { workdir: b.workdir }),
363
+ ...(toolSources === undefined ? {} : { toolSources }),
364
+ ...(b.multiTurn === undefined ? {} : { multiTurn: b.multiTurn }),
365
+ };
366
+ };
116
367
  const handler = async (req, res) => {
117
- const path = new URL(req.url || '/', `http://${host}`).pathname;
368
+ const url = new URL(req.url || '/', `http://${host}`);
369
+ const path = url.pathname;
118
370
  try {
119
371
  if (!isLocalHostHeader(req.headers.host)) {
120
372
  json(res, 403, { error: '仅允许本机访问(Host 须为 localhost / 127.0.0.1 / [::1])' });
121
373
  return;
122
374
  }
375
+ if (!isAllowedOrigin(req.headers.origin)) {
376
+ json(res, 403, {
377
+ error: `Origin「${req.headers.origin}」不被允许(只接受本机 origin 或缺省)`,
378
+ });
379
+ return;
380
+ }
381
+ // token:URL query / 自定义头 / cookie 三选一(覆盖「面板首帧」与「脚本直连」两种用法)
382
+ const queryToken = url.searchParams.get('t');
383
+ const presented = queryToken ??
384
+ (typeof req.headers[TOKEN_HEADER] === 'string' ? req.headers[TOKEN_HEADER] : null) ??
385
+ cookieValue(req.headers.cookie, TOKEN_COOKIE);
386
+ if (token !== undefined && (presented === null || !tokenEquals(presented, token))) {
387
+ json(res, 403, {
388
+ error: '缺少或错误的 dev token。请用 `agentia dev` 打印的带 ?t= 的完整 URL 打开面板' +
389
+ '(token 会种进 cookie,之后刷新不带也行)',
390
+ });
391
+ return;
392
+ }
123
393
  if (req.method === 'POST' && path === '/ingest') {
124
394
  const body = await readBody(req); // 超 413 走外层 catch
125
395
  let trace;
@@ -138,19 +408,58 @@ export function startInspector(opts = {}) {
138
408
  const valid = trace;
139
409
  if (!runs.has(valid.traceId)) {
140
410
  order.push(valid.traceId);
141
- if (order.length > MAX_RUNS)
142
- runs.delete(order.shift());
411
+ if (order.length > MAX_RUNS) {
412
+ const evicted = order.shift();
413
+ runs.delete(evicted);
414
+ notes.delete(evicted);
415
+ }
143
416
  }
144
417
  runs.set(valid.traceId, valid);
145
- broadcast(summarize(valid));
418
+ broadcast(summarize(valid, notes.get(valid.traceId) ?? null));
146
419
  json(res, 200, { ok: true });
147
420
  return;
148
421
  }
422
+ /**
423
+ * **在飞 run 的增量记账事件**(① 实时右栏):`POST /ingest-event`。
424
+ *
425
+ * 与 `POST /ingest` 的分工是刻意的两条缝(框架侧同名区分):
426
+ * - 这条 = **此刻看到**:高频、逐笔、不落库、**不保证送达**;
427
+ * - 那条 = **最终账**:收尾一次、进 run 列表、被 `/api/runs` 读。
428
+ *
429
+ * 所以这里**只广播**、不建 run 记录:面板拿它把树「长」出来,收尾那份整棵 trace
430
+ * 回来时覆盖(`open()` 的渲染永远以收尾那份为准)。
431
+ *
432
+ * 202 而不是 200:已经广播出去了,但面板收没收到不归这里管(SSE 是单向流)。
433
+ * 没有 dev 钩子时回 503 —— 与 `/run` / `/run/abort` 同一口径(没有面板消费它)。
434
+ */
435
+ if (req.method === 'POST' && path === '/ingest-event') {
436
+ if (!opts.dev) {
437
+ json(res, 503, { error: '这个 inspector 不是 agentia dev 起的(没有 runner)' });
438
+ return;
439
+ }
440
+ const body = await readBody(req);
441
+ let ev;
442
+ try {
443
+ ev = JSON.parse(body);
444
+ }
445
+ catch {
446
+ json(res, 400, { error: 'body 不是合法 JSON' });
447
+ return;
448
+ }
449
+ const problem = validateTraceEvent(ev);
450
+ if (problem) {
451
+ json(res, 400, { error: problem });
452
+ return;
453
+ }
454
+ opts.dev.traceEvent(ev);
455
+ json(res, 202, { ok: true });
456
+ return;
457
+ }
149
458
  if (req.method === 'GET' && path === '/api/runs') {
150
459
  json(res, 200, order
151
460
  .slice()
152
461
  .reverse()
153
- .map((id) => summarize(runs.get(id))));
462
+ .map((id) => summarize(runs.get(id), notes.get(id) ?? null)));
154
463
  return;
155
464
  }
156
465
  if (req.method === 'GET' && path.startsWith('/api/runs/')) {
@@ -163,6 +472,124 @@ export function startInspector(opts = {}) {
163
472
  json(res, 200, t);
164
473
  return;
165
474
  }
475
+ if (req.method === 'GET' && path === '/api/dev') {
476
+ json(res, 200, opts.dev ? opts.dev.state() : unavailableDevState());
477
+ return;
478
+ }
479
+ /**
480
+ * 目录浏览器:`GET /api/fs?path=<绝对路径>` →
481
+ * `{ path, parent, dirs, dotDirs, files, filesTruncated }`。
482
+ *
483
+ * 为什么需要它:浏览器**拿不到**用户选的文件夹的绝对路径 —— `<input webkitdirectory>`
484
+ * 只给相对路径,File System Access API 只给 handle.name。而 dev 环要的是绝对路径
485
+ * (工具按它解析)。⇒ 要么让用户手打路径,要么服务端列目录。后者才是「工作目录是
486
+ * 主控件」(D3/D7)该有的手感。
487
+ *
488
+ * 三条口径(2026-09-22 复核后改,前两条是实测出来的缺口):
489
+ * - **文件也列**(`files`,只给名字、不读内容):面板据此提供「选这个文件」——
490
+ * 工作目录取它所在目录、prompt 空时填文件名。只列目录时,「让 agent 看某个文件」
491
+ * 只能靠用户自己把文件名打进 prompt。
492
+ * - **隐藏目录单列**(`dotDirs`,不在 `dirs` 里):以前一批过滤掉,于是
493
+ * `~/xxx/.yyy` 这类目录**根本点不进去**(`..` 只能退到它上面,进不去)。
494
+ * 单列是为了不把 `.git` / `.cache` 混进正常浏览,同时保证**可达**。
495
+ * - `files` 有上限(`FILE_LIMIT`)并回 `filesTruncated`:一次列几千个文件的名
496
+ * 没有意义,但**必须明示**截断(本仓纪律:不许静默少给)。
497
+ *
498
+ * 只列名字、**不读文件内容**;且只在 dev 钩子在场时开放(只读面板不暴露文件树)。
499
+ * 越界不是威胁(本机 dev 工具,token 已经挡住了其它进程),但**不存在 / 不是目录**
500
+ * 要响亮报错 —— 静默回退到项目根会让 agent 对着错的目录乱写(D5)。
501
+ */
502
+ if (req.method === 'GET' && path === '/api/fs') {
503
+ if (!opts.dev) {
504
+ json(res, 403, { error: '这个 inspector 不是 agentia dev 起的,不提供目录浏览' });
505
+ return;
506
+ }
507
+ const target = url.searchParams.get('path');
508
+ if (!target) {
509
+ json(res, 400, { error: '缺少 path 参数' });
510
+ return;
511
+ }
512
+ const abs = resolve(target);
513
+ let entries;
514
+ try {
515
+ if (!statSync(abs).isDirectory()) {
516
+ json(res, 400, { error: `不是文件夹:${abs}` });
517
+ return;
518
+ }
519
+ entries = readdirSync(abs, { withFileTypes: true });
520
+ }
521
+ catch (e) {
522
+ json(res, 400, { error: `读不了这个目录:${e.message}` });
523
+ return;
524
+ }
525
+ const dirs = [];
526
+ const dotDirs = [];
527
+ const files = [];
528
+ for (const e of entries) {
529
+ if (isDirLikeEntry(join(abs, e.name), e)) {
530
+ (e.name.startsWith('.') ? dotDirs : dirs).push(e.name);
531
+ continue;
532
+ }
533
+ // 读不了内容的条目(管道 / socket / 权限)也当文件列出来 —— 列名字不需要读它
534
+ if (!e.name.startsWith('.'))
535
+ files.push(e.name);
536
+ }
537
+ json(res, 200, {
538
+ path: abs,
539
+ parent: dirname(abs),
540
+ dirs: dirs.sort(),
541
+ dotDirs: dotDirs.sort(),
542
+ files: files.sort().slice(0, FILE_LIMIT),
543
+ filesTruncated: files.length > FILE_LIMIT,
544
+ });
545
+ return;
546
+ }
547
+ if (req.method === 'POST' && path === '/run') {
548
+ if (!opts.dev) {
549
+ json(res, 503, {
550
+ error: '这个 inspector 不是 agentia dev 起的(没有 runner),不能驱动 run',
551
+ });
552
+ return;
553
+ }
554
+ const body = await readBody(req);
555
+ let raw;
556
+ try {
557
+ raw = JSON.parse(body);
558
+ }
559
+ catch {
560
+ json(res, 400, { error: 'body 不是合法 JSON' });
561
+ return;
562
+ }
563
+ const ack = await opts.dev.run(parseRunRequest(raw));
564
+ json(res, 202, ack);
565
+ return;
566
+ }
567
+ if (req.method === 'POST' && path === '/run/abort') {
568
+ if (!opts.dev) {
569
+ json(res, 503, { error: '这个 inspector 不是 agentia dev 起的(没有 runner)' });
570
+ return;
571
+ }
572
+ // 202 而不是 200:中止是**请求已受理**,run 真正收尾要走 SSE 的 `run-done`
573
+ // (优雅路径要在回合边界才生效,不是同步的)。
574
+ json(res, 202, await opts.dev.abort());
575
+ return;
576
+ }
577
+ if (req.method === 'POST' && path === '/session/clear') {
578
+ if (!opts.dev) {
579
+ json(res, 503, { error: '这个 inspector 不是 agentia dev 起的(没有 runner)' });
580
+ return;
581
+ }
582
+ json(res, 200, await opts.dev.clearSession());
583
+ return;
584
+ }
585
+ if (req.method === 'GET' && path === '/api/session') {
586
+ if (!opts.dev) {
587
+ json(res, 200, { session: null });
588
+ return;
589
+ }
590
+ json(res, 200, { session: await opts.dev.session() });
591
+ return;
592
+ }
166
593
  if (req.method === 'GET' && path === '/stream') {
167
594
  res.writeHead(200, {
168
595
  'content-type': 'text/event-stream',
@@ -175,7 +602,12 @@ export function startInspector(opts = {}) {
175
602
  return;
176
603
  }
177
604
  if (req.method === 'GET' && (path === '/' || path === '/index.html')) {
178
- text(res, 200, 'text/html; charset=utf-8', await readFile(PAGE, 'utf8'));
605
+ // 首帧带了合法 token ⇒ 种进 cookie(SameSite=Strict + HttpOnly):
606
+ // 之后面板自己的 fetch / EventSource 自动带上,token 不进 URL 历史、不进截图。
607
+ const headers = token !== undefined && queryToken !== null
608
+ ? { 'set-cookie': `${TOKEN_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict` }
609
+ : {};
610
+ text(res, 200, 'text/html; charset=utf-8', await readFile(PAGE, 'utf8'), headers);
179
611
  return;
180
612
  }
181
613
  if (req.method === 'GET' && STATIC.has(path.slice(1))) {
@@ -183,13 +615,13 @@ export function startInspector(opts = {}) {
183
615
  const type = name.endsWith('.css')
184
616
  ? 'text/css; charset=utf-8'
185
617
  : 'text/javascript; charset=utf-8';
186
- text(res, 200, type, await readFile(join(ASSETS, name), 'utf8'));
618
+ text(res, 200, type, await readFile(STATIC.get(name), 'utf8'));
187
619
  return;
188
620
  }
189
621
  json(res, 404, { error: 'not found' });
190
622
  }
191
623
  catch (e) {
192
- const status = e.statusCode ?? 500;
624
+ const status = e instanceof HttpError ? e.statusCode : (e.statusCode ?? 500);
193
625
  json(res, status, { error: e.message });
194
626
  }
195
627
  };
@@ -203,6 +635,10 @@ export function startInspector(opts = {}) {
203
635
  const port = addr && typeof addr === 'object' ? addr.port : (opts.port ?? 0);
204
636
  resolve({
205
637
  port,
638
+ emitDev,
639
+ noteRun: (traceId, note) => {
640
+ rememberNote(notes, traceId, note, MAX_NOTES);
641
+ },
206
642
  close: () => new Promise((done) => {
207
643
  for (const c of clients)
208
644
  c.end();