@migor/cli 0.9.1 → 0.9.3
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/CHANGELOG.md +187 -5
- package/dist/AGENTS.md +15 -2
- package/dist/dev-child.js +84 -0
- package/dist/dev-logic.js +46 -0
- package/dist/dev-machine.js +489 -0
- package/dist/dev-runner.js +8 -1
- package/dist/dev-watch.js +223 -0
- package/dist/dev.js +377 -626
- package/dist/inspector-page.html +295 -17
- package/dist/inspector-routes.js +530 -0
- package/dist/inspector.js +44 -418
- package/dist/markdown.js +262 -0
- package/dist/native-pick.js +168 -0
- package/dist/panel-logic.js +64 -0
- package/dist/templates/package.json +2 -2
- package/dist/templates/src/app.ts +3 -0
- package/dist/templates/src/dev.config.ts +5 -0
- package/dist/templates/src/registry.ts +5 -0
- package/dist/templates/src/session-store.ts +11 -2
- package/dist/templates/src/tools/read-file/index.ts +50 -3
- package/package.json +1 -1
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 {
|
|
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`、
|
|
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,71 +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
|
-
]);
|
|
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) {
|
|
248
|
-
const spans = t.spans || [];
|
|
249
|
-
const root = spans.find((s) => s.kind === 'run') || spans[0];
|
|
250
|
-
const startedAt = root ? root.startedAt : 0;
|
|
251
|
-
const endedAt = root && root.endedAt != null ? root.endedAt : startedAt;
|
|
252
|
-
const u = t.totalUsage || {};
|
|
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;
|
|
259
|
-
return {
|
|
260
|
-
traceId: t.traceId,
|
|
261
|
-
status,
|
|
262
|
-
name: root ? root.name : 'run',
|
|
263
|
-
startedAt,
|
|
264
|
-
ms: endedAt - startedAt,
|
|
265
|
-
tokens: (u.inputTokens || 0) + (u.outputTokens || 0),
|
|
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,
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
103
|
/** 带 statusCode 的错误:让 handler 的 catch 回对应的码而不是一律 500 */
|
|
273
104
|
export class HttpError extends Error {
|
|
274
105
|
statusCode;
|
|
@@ -287,20 +118,24 @@ export class HttpError extends Error {
|
|
|
287
118
|
*/
|
|
288
119
|
export function startInspector(opts = {}) {
|
|
289
120
|
const host = opts.host ?? '127.0.0.1';
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const
|
|
293
|
-
|
|
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
|
+
};
|
|
294
129
|
const token = opts.token;
|
|
295
130
|
const broadcast = (s) => {
|
|
296
131
|
const chunk = `data: ${JSON.stringify(s)}\n\n`;
|
|
297
|
-
for (const c of clients)
|
|
132
|
+
for (const c of state.clients)
|
|
298
133
|
c.write(chunk);
|
|
299
134
|
};
|
|
300
135
|
/** 命名事件 `dev`:run 生命周期与 runner 状态 —— 面板靠它显示「在跑 / 失败 / 重启」 */
|
|
301
136
|
const emitDev = (ev) => {
|
|
302
137
|
const chunk = `event: dev\ndata: ${JSON.stringify(ev)}\n\n`;
|
|
303
|
-
for (const c of clients)
|
|
138
|
+
for (const c of state.clients)
|
|
304
139
|
c.write(chunk);
|
|
305
140
|
};
|
|
306
141
|
const json = (res, code, body) => {
|
|
@@ -335,7 +170,13 @@ export function startInspector(opts = {}) {
|
|
|
335
170
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
336
171
|
req.on('error', reject);
|
|
337
172
|
});
|
|
338
|
-
/**
|
|
173
|
+
/**
|
|
174
|
+
* 校验 `RunRequest`:形状不对就 400,且说清是哪一项 —— 面板据此把原因显示给用户。
|
|
175
|
+
*
|
|
176
|
+
* 为什么留在本文件而不是随路由搬走:它要构造 `HttpError`,而 `HttpError` 定义在这里
|
|
177
|
+
* (`dev.ts` 也从 `dist/inspector.js` 取它)。把它搬去 `inspector-routes.ts` 会让两个
|
|
178
|
+
* 模块**互相 import 值** ⇒ 运行期成环。这条边界比「按职责归类」更重要。
|
|
179
|
+
*/
|
|
339
180
|
const parseRunRequest = (raw) => {
|
|
340
181
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
341
182
|
throw new HttpError(400, 'body 必须是对象');
|
|
@@ -364,6 +205,18 @@ export function startInspector(opts = {}) {
|
|
|
364
205
|
...(b.multiTurn === undefined ? {} : { multiTurn: b.multiTurn }),
|
|
365
206
|
};
|
|
366
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
|
+
};
|
|
367
220
|
const handler = async (req, res) => {
|
|
368
221
|
const url = new URL(req.url || '/', `http://${host}`);
|
|
369
222
|
const path = url.pathname;
|
|
@@ -390,235 +243,8 @@ export function startInspector(opts = {}) {
|
|
|
390
243
|
});
|
|
391
244
|
return;
|
|
392
245
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
let trace;
|
|
396
|
-
try {
|
|
397
|
-
trace = JSON.parse(body);
|
|
398
|
-
}
|
|
399
|
-
catch {
|
|
400
|
-
json(res, 400, { error: 'body 不是合法 JSON' });
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
const problem = validateTrace(trace);
|
|
404
|
-
if (problem) {
|
|
405
|
-
json(res, 400, { error: problem });
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
const valid = trace;
|
|
409
|
-
if (!runs.has(valid.traceId)) {
|
|
410
|
-
order.push(valid.traceId);
|
|
411
|
-
if (order.length > MAX_RUNS) {
|
|
412
|
-
const evicted = order.shift();
|
|
413
|
-
runs.delete(evicted);
|
|
414
|
-
notes.delete(evicted);
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
runs.set(valid.traceId, valid);
|
|
418
|
-
broadcast(summarize(valid, notes.get(valid.traceId) ?? null));
|
|
419
|
-
json(res, 200, { ok: true });
|
|
420
|
-
return;
|
|
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
|
-
}
|
|
458
|
-
if (req.method === 'GET' && path === '/api/runs') {
|
|
459
|
-
json(res, 200, order
|
|
460
|
-
.slice()
|
|
461
|
-
.reverse()
|
|
462
|
-
.map((id) => summarize(runs.get(id), notes.get(id) ?? null)));
|
|
463
|
-
return;
|
|
464
|
-
}
|
|
465
|
-
if (req.method === 'GET' && path.startsWith('/api/runs/')) {
|
|
466
|
-
const id = decodeURIComponent(path.slice('/api/runs/'.length));
|
|
467
|
-
const t = runs.get(id);
|
|
468
|
-
if (!t) {
|
|
469
|
-
json(res, 404, { error: 'run 不存在(可能已被淘汰)' });
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
472
|
-
json(res, 200, t);
|
|
473
|
-
return;
|
|
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
|
-
}
|
|
593
|
-
if (req.method === 'GET' && path === '/stream') {
|
|
594
|
-
res.writeHead(200, {
|
|
595
|
-
'content-type': 'text/event-stream',
|
|
596
|
-
'cache-control': 'no-cache',
|
|
597
|
-
connection: 'keep-alive',
|
|
598
|
-
});
|
|
599
|
-
res.write(': connected\n\n');
|
|
600
|
-
clients.add(res);
|
|
601
|
-
req.on('close', () => clients.delete(res));
|
|
602
|
-
return;
|
|
603
|
-
}
|
|
604
|
-
if (req.method === 'GET' && (path === '/' || path === '/index.html')) {
|
|
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);
|
|
611
|
-
return;
|
|
612
|
-
}
|
|
613
|
-
if (req.method === 'GET' && STATIC.has(path.slice(1))) {
|
|
614
|
-
const name = path.slice(1);
|
|
615
|
-
const type = name.endsWith('.css')
|
|
616
|
-
? 'text/css; charset=utf-8'
|
|
617
|
-
: 'text/javascript; charset=utf-8';
|
|
618
|
-
text(res, 200, type, await readFile(STATIC.get(name), 'utf8'));
|
|
619
|
-
return;
|
|
620
|
-
}
|
|
621
|
-
json(res, 404, { error: 'not found' });
|
|
246
|
+
// 闸已过 ⇒ 交给路由表(它假定自己只在已放行的请求上被调用)
|
|
247
|
+
await handleRoutes({ ...baseCtx, req, res, url, path, queryToken });
|
|
622
248
|
}
|
|
623
249
|
catch (e) {
|
|
624
250
|
const status = e instanceof HttpError ? e.statusCode : (e.statusCode ?? 500);
|
|
@@ -637,12 +263,12 @@ export function startInspector(opts = {}) {
|
|
|
637
263
|
port,
|
|
638
264
|
emitDev,
|
|
639
265
|
noteRun: (traceId, note) => {
|
|
640
|
-
rememberNote(notes, traceId, note, MAX_NOTES);
|
|
266
|
+
rememberNote(state.notes, traceId, note, MAX_NOTES);
|
|
641
267
|
},
|
|
642
268
|
close: () => new Promise((done) => {
|
|
643
|
-
for (const c of clients)
|
|
269
|
+
for (const c of state.clients)
|
|
644
270
|
c.end();
|
|
645
|
-
clients.clear();
|
|
271
|
+
state.clients.clear();
|
|
646
272
|
server.close(() => done());
|
|
647
273
|
}),
|
|
648
274
|
});
|