@epoch-agent/server 0.1.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.
package/dist/index.js ADDED
@@ -0,0 +1,3785 @@
1
+ import { createServer } from 'http';
2
+ import { timingSafeEqual, randomBytes } from 'crypto';
3
+ import { PLAN_OUTCOMES, WIRE_AUTH_COOKIE, WIRE_AUTH_COOKIE_ATTRS, WIRE_AUTH_TOKEN_PARAM, isWireRewindScope, WIRE_REWIND_SCOPES, WIRE_LANG_PARAM, isLang, isQuestionAnswerMap, isProviderType, WIRE_SETTING_WRITE_LAYERS, isWirePlanAction, WIRE_PLAN_ACTIONS, isPermissionLevel, PERMISSION_LEVELS, isWireSettingWriteLayer, parseModelRef, OPERATION_TYPES, isOperationType } from '@epoch-agent/protocol';
4
+ import { ApprovalRelay, QuestionRelay, serializeStream, withRoleScope, withModelScope, withToolScope, t, nextRunAt, skillIndexDescription, SANDBOX_EXCLUDES, SANDBOX_COVERS, backgroundTaskOutput, listBackgroundTasks, SCHEDULE_DEFAULT_ALLOWLIST, SCHEDULE_DEFAULTS, readRecording } from '@epoch-agent/runtime';
5
+ import { resolve, join, relative, isAbsolute, sep, dirname, basename, extname } from 'path';
6
+ import { existsSync, realpathSync, statSync, mkdirSync, createReadStream, readdirSync, accessSync, constants } from 'fs';
7
+ import { homedir } from 'os';
8
+ import { stat, readFile } from 'fs/promises';
9
+ import { execFile } from 'child_process';
10
+ import { fileURLToPath } from 'url';
11
+
12
+ // src/index.ts
13
+ var LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]"]);
14
+ function createAuthGuard(opts) {
15
+ const { token, port, lanExposed } = opts;
16
+ const setCookie = `${WIRE_AUTH_COOKIE}=${token}; ${WIRE_AUTH_COOKIE_ATTRS}`;
17
+ return (request) => {
18
+ const host = request.host ?? "";
19
+ if (!isAllowedHost(host, port, lanExposed)) {
20
+ return { ok: false, status: 403, reason: "bad-host" };
21
+ }
22
+ if (!isSafeMethod(request.method) && !isAllowedOrigin(request.origin, host, port, lanExposed)) {
23
+ return { ok: false, status: 403, reason: "bad-origin" };
24
+ }
25
+ const fromUrl = readQueryToken(request.url);
26
+ if (fromUrl !== void 0) {
27
+ return sameToken(fromUrl, token) ? { ok: true, setCookie } : { ok: false, status: 401, reason: "bad-token" };
28
+ }
29
+ const fromCookie = readCookie(request.cookie, WIRE_AUTH_COOKIE);
30
+ if (fromCookie === void 0) return { ok: false, status: 401, reason: "missing-credential" };
31
+ if (!sameToken(fromCookie, token)) return { ok: false, status: 401, reason: "bad-token" };
32
+ return { ok: true };
33
+ };
34
+ }
35
+ function isSafeMethod(method) {
36
+ return method === "GET" || method === "HEAD";
37
+ }
38
+ function splitAuthority(authority) {
39
+ if (authority.length === 0) return null;
40
+ try {
41
+ const url = new URL(`http://${authority}`);
42
+ return { hostname: url.hostname, port: url.port === "" ? 80 : Number(url.port) };
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+ function isAllowedHost(host, port, lanExposed) {
48
+ const parsed = splitAuthority(host);
49
+ if (!parsed || parsed.port !== port) return false;
50
+ return LOOPBACK_HOSTNAMES.has(parsed.hostname) || lanExposed;
51
+ }
52
+ function isAllowedOrigin(origin, host, port, lanExposed) {
53
+ if (origin === void 0 || origin.length === 0) return false;
54
+ let parsed;
55
+ try {
56
+ parsed = new URL(origin);
57
+ } catch {
58
+ return false;
59
+ }
60
+ if (parsed.protocol !== "http:") return false;
61
+ if (parsed.host !== host) return false;
62
+ return isAllowedHost(host, port, lanExposed);
63
+ }
64
+ function readQueryToken(url) {
65
+ try {
66
+ const value = new URL(url, "http://127.0.0.1").searchParams.get(WIRE_AUTH_TOKEN_PARAM);
67
+ return value === null ? void 0 : value;
68
+ } catch {
69
+ return void 0;
70
+ }
71
+ }
72
+ function readCookie(header, name) {
73
+ if (!header) return void 0;
74
+ for (const pair of header.split(";")) {
75
+ const eq = pair.indexOf("=");
76
+ if (eq < 0) continue;
77
+ if (pair.slice(0, eq).trim() !== name) continue;
78
+ return pair.slice(eq + 1).trim();
79
+ }
80
+ return void 0;
81
+ }
82
+ function sameToken(given, expected) {
83
+ const a = Buffer.from(given, "utf8");
84
+ const b = Buffer.from(expected, "utf8");
85
+ if (a.length !== b.length) return false;
86
+ return timingSafeEqual(a, b);
87
+ }
88
+ var DEFAULT_WEB_PORT = 4400;
89
+ var DEFAULT_WEB_HOST = "127.0.0.1";
90
+ function isLoopbackHost(host) {
91
+ const h = host.toLowerCase();
92
+ if (h === "localhost" || h === "::1" || h === "[::1]") return true;
93
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h);
94
+ }
95
+ function displayHost(host) {
96
+ if (host === "0.0.0.0" || host === "::" || host === "[::]") return DEFAULT_WEB_HOST;
97
+ return host;
98
+ }
99
+ function decideBinding(req = {}) {
100
+ const host = req.host?.trim() || DEFAULT_WEB_HOST;
101
+ const port = req.port ?? DEFAULT_WEB_PORT;
102
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
103
+ return { ok: false, error: `\u7AEF\u53E3\u5FC5\u987B\u662F 0-65535 \u7684\u6574\u6570\uFF0C\u6536\u5230 ${String(req.port)}` };
104
+ }
105
+ const lanExposed = !isLoopbackHost(host);
106
+ const token = req.token?.trim();
107
+ if (lanExposed && !token) {
108
+ return {
109
+ ok: false,
110
+ error: `--host ${host} \u4F1A\u628A\u670D\u52A1\u66B4\u9732\u5230\u56DE\u73AF\u4E4B\u5916\uFF0C\u5FC5\u987B\u540C\u65F6\u7ED9 --token <\u968F\u673A\u4E32>\uFF0C\u62D2\u7EDD\u542F\u52A8\u3002
111
+ \u672C\u5730\u4F7F\u7528\u8BF7\u53BB\u6389 --host\uFF08\u9ED8\u8BA4\u53EA\u7ED1 127.0.0.1\uFF09\u3002
112
+ \u771F\u8981\u7ED9\u5C40\u57DF\u7F51\u7528\uFF1Aepoch web --host 0.0.0.0 --token "$(openssl rand -base64 32)"`
113
+ };
114
+ }
115
+ return { ok: true, host, port, lanExposed, token: token || generateToken() };
116
+ }
117
+ function generateToken() {
118
+ return randomBytes(32).toString("base64url");
119
+ }
120
+ var UI_THEME_PARAM = "theme";
121
+ var UI_LANG_PARAM = "lang";
122
+ function firstScreenUrl(host, port, token, ui = {}) {
123
+ const shown = displayHost(host);
124
+ const authority = shown.includes(":") && !shown.startsWith("[") ? `[${shown}]` : shown;
125
+ const params = [`${WIRE_AUTH_TOKEN_PARAM}=${encodeURIComponent(token)}`];
126
+ if (ui.theme) params.push(`${UI_THEME_PARAM}=${encodeURIComponent(ui.theme)}`);
127
+ if (ui.lang) params.push(`${UI_LANG_PARAM}=${encodeURIComponent(ui.lang)}`);
128
+ return `http://${authority}:${port}/?${params.join("&")}`;
129
+ }
130
+
131
+ // src/ring.ts
132
+ var DEFAULT_RING_CAPACITY = 512;
133
+ var EnvelopeRing = class {
134
+ constructor(capacity = DEFAULT_RING_CAPACITY) {
135
+ this.capacity = capacity;
136
+ if (!Number.isInteger(capacity) || capacity < 1) {
137
+ throw new Error(`\u73AF\u5F62\u7F13\u51B2\u5BB9\u91CF\u5FC5\u987B\u662F >= 1 \u7684\u6574\u6570\uFF0C\u6536\u5230 ${capacity}`);
138
+ }
139
+ }
140
+ capacity;
141
+ /**
142
+ * 按 seq 升序、**连续无洞**的一段。
143
+ *
144
+ * 连续性是 `since()` 能只靠首尾两个 seq 判定的前提:每个广播帧都让全局计数器
145
+ * 恰好 +1,所以缓冲里存的一定是 `[latest-size+1, latest]` 这个闭区间。
146
+ * 哪天出现「某些帧不进缓冲」的需求,这条前提就断了,`since()` 必须跟着改。
147
+ */
148
+ frames = [];
149
+ /**
150
+ * 收一帧。**只收广播帧**(`seq >= 1`)。
151
+ *
152
+ * 连接级帧(`connected` / `stream-reset`,`seq: 0`)不进来:它们只发给刚接上来
153
+ * 的那一个连接,进了缓冲就会在下一次续传时被当成历史补给别人。
154
+ */
155
+ push(frame) {
156
+ if (frame.seq < 1) throw new Error(`\u8FDE\u63A5\u7EA7\u5E27\uFF08seq=${frame.seq}\uFF09\u4E0D\u8BE5\u8FDB\u73AF\u5F62\u7F13\u51B2`);
157
+ this.frames.push(frame);
158
+ if (this.frames.length > this.capacity) this.frames.shift();
159
+ }
160
+ /** 缓冲里最新那一帧的 seq;空缓冲是 0(= 还没发过任何广播帧) */
161
+ get latestSeq() {
162
+ return this.frames[this.frames.length - 1]?.seq ?? 0;
163
+ }
164
+ /** 缓冲里最老那一帧的 seq;空缓冲是 undefined */
165
+ get oldestSeq() {
166
+ return this.frames[0]?.seq;
167
+ }
168
+ /** 现存帧数 */
169
+ get size() {
170
+ return this.frames.length;
171
+ }
172
+ /**
173
+ * 断点之后的帧。
174
+ *
175
+ * @param lastEventId 客户端带上来的 `Last-Event-ID`(它已经收到的最后一个 seq)
176
+ * @returns 要补发的帧(可能是空数组 = 一条没落下);
177
+ * **`null` 表示续不上**,调用方必须回 `stream-reset`
178
+ *
179
+ * 四种边界,全都在这里一次判掉:
180
+ *
181
+ * | 情况 | 返回 | 为什么 |
182
+ * | --------------------------- | ----------- | -------------------------------------------------- |
183
+ * | `last === latest` | `[]` | 断得很干净,一条没落下 |
184
+ * | `last > latest` | `null` | 游标比我们发过的还新 —— 换了个进程,seq 从头数过了 |
185
+ * | `last + 1 < oldest` | `null` | 中间那段被挤掉了,这就是验收第 4 条 |
186
+ * | 其余 | `(last, ∞)` | 正常续传,一条不丢一条不重(验收第 3 条) |
187
+ */
188
+ since(lastEventId) {
189
+ if (!Number.isInteger(lastEventId) || lastEventId < 1) {
190
+ return [];
191
+ }
192
+ const { latestSeq, oldestSeq } = this;
193
+ if (lastEventId === latestSeq) return [];
194
+ if (lastEventId > latestSeq) return null;
195
+ if (oldestSeq === void 0 || lastEventId + 1 < oldestSeq) return null;
196
+ return this.frames.filter((f) => f.seq > lastEventId);
197
+ }
198
+ };
199
+ var DEFAULT_MAX_ACTIVE_SESSIONS = 8;
200
+ var DEFAULT_MAX_QUEUED_MESSAGES = 8;
201
+ var SessionRegistry = class {
202
+ entries = /* @__PURE__ */ new Map();
203
+ /**
204
+ * 被冷却过的 id。**只留 id,不留会话对象** —— 留着对象就等于什么都没释放,
205
+ * 而释放内存里那份对话历史正是冷却的全部意义。
206
+ *
207
+ * 它存在的唯一理由是让「往一个被冷却的会话发消息」能回一句准话
208
+ * (409 `session-cooled`)而不是 404「没有这个会话」—— 后者会让调用方
209
+ * 以为自己拼错了 id。插入序即冷却序,超出容量时从最早的开始丢。
210
+ */
211
+ cooled = /* @__PURE__ */ new Set();
212
+ maxActive;
213
+ maxQueued;
214
+ /** 单调递增的逻辑刻度,见 `SessionEntry.lastActiveTick` */
215
+ tick = 0;
216
+ constructor(opts = {}) {
217
+ this.maxActive = Math.max(1, opts.maxActive ?? DEFAULT_MAX_ACTIVE_SESSIONS);
218
+ this.maxQueued = Math.max(0, opts.maxQueued ?? DEFAULT_MAX_QUEUED_MESSAGES);
219
+ }
220
+ /**
221
+ * 把一个会话放进表里。重复注册同一个 id 是空操作。
222
+ *
223
+ * 注册一个**曾被冷却**的 id 就是「重新打开它」:从冷却名单里摘掉,
224
+ * 之后它和别的活跃会话没有区别(历史由 `GET /messages` 回放,不在这里恢复)。
225
+ */
226
+ register(session) {
227
+ const id = session.sessionId;
228
+ if (this.entries.has(id)) return { added: false, cooled: null };
229
+ this.cooled.delete(id);
230
+ this.entries.set(id, {
231
+ session,
232
+ state: "idle",
233
+ relay: new ApprovalRelay(),
234
+ questions: new QuestionRelay(),
235
+ controller: null,
236
+ turnStartedAt: null,
237
+ lastFinish: null,
238
+ queue: [],
239
+ lastActiveTick: ++this.tick
240
+ });
241
+ return { added: true, cooled: this.coolIfCrowded(id) };
242
+ }
243
+ get(sessionId) {
244
+ return this.entries.get(sessionId);
245
+ }
246
+ has(sessionId) {
247
+ return this.entries.has(sessionId);
248
+ }
249
+ /** 这个 id 是被冷却掉的(而不是从来没存在过)吗 */
250
+ isCooled(sessionId) {
251
+ return this.cooled.has(sessionId);
252
+ }
253
+ get size() {
254
+ return this.entries.size;
255
+ }
256
+ /** 遍历。插入序 = 注册序 */
257
+ all() {
258
+ return this.entries.entries();
259
+ }
260
+ /** 从表里摘掉(会话被删除时)。**不进冷却名单** —— 它是真没了,不是被挤走 */
261
+ remove(sessionId) {
262
+ const entry = this.entries.get(sessionId);
263
+ if (entry) this.entries.delete(sessionId);
264
+ this.cooled.delete(sessionId);
265
+ return entry;
266
+ }
267
+ /** 记一次活动。开轮和收轮都要调 —— LRU 挑的就是这个刻度最小的那个 */
268
+ touch(sessionId) {
269
+ const entry = this.entries.get(sessionId);
270
+ if (entry) entry.lastActiveTick = ++this.tick;
271
+ }
272
+ /**
273
+ * 排一条消息。
274
+ *
275
+ * @returns `false` = 队列满了,调用方回 409。**不悄悄丢**:丢掉的表现是
276
+ * 用户发了一条消息,服务端回 202,然后什么都没发生
277
+ */
278
+ enqueue(entry, queued) {
279
+ if (entry.queue.length >= this.maxQueued) return false;
280
+ entry.queue.push(queued);
281
+ return true;
282
+ }
283
+ /** 取下一条排队的消息。空队列时 `undefined` */
284
+ dequeue(entry) {
285
+ return entry.queue.shift();
286
+ }
287
+ /**
288
+ * 挤掉一个最久没动过的**空闲**会话。
289
+ *
290
+ * 三条判据都不能少:
291
+ * - **只挑 idle 的**:冷却一个正在跑的会话等于中途把它的历史扔了
292
+ * - **不挑刚注册的那个**(`keep`):否则「注册第 9 个」的结果可能是
293
+ * 「第 9 个自己被冷却」,调用方会看到一个刚建好就不见了的会话
294
+ * - **一个都挑不出来就不挤**(全在跑):上限是软的。硬挤的代价是丢一轮
295
+ * 正在跑的对话,那比多占一份内存严重得多
296
+ */
297
+ coolIfCrowded(keep) {
298
+ if (this.entries.size <= this.maxActive) return null;
299
+ let victim = null;
300
+ let oldest = Number.POSITIVE_INFINITY;
301
+ for (const [id, entry] of this.entries) {
302
+ if (id === keep || entry.state !== "idle") continue;
303
+ if (entry.lastActiveTick < oldest) {
304
+ oldest = entry.lastActiveTick;
305
+ victim = id;
306
+ }
307
+ }
308
+ if (victim === null) return null;
309
+ this.entries.delete(victim);
310
+ this.rememberCooled(victim);
311
+ return victim;
312
+ }
313
+ /** 冷却名单也要有界 —— 它只是给错误信息用的,不该跟着进程一直长 */
314
+ rememberCooled(sessionId) {
315
+ this.cooled.add(sessionId);
316
+ const limit = this.maxActive * 4;
317
+ while (this.cooled.size > limit) {
318
+ const oldest = this.cooled.values().next();
319
+ if (oldest.done) break;
320
+ this.cooled.delete(oldest.value);
321
+ }
322
+ }
323
+ };
324
+ function toWireSessionWorkspace(bound) {
325
+ const root = bound.workspace.root;
326
+ return {
327
+ root,
328
+ name: displayName(root),
329
+ extraRoots: bound.workspace.extra,
330
+ trusted: bound.trust.trusted,
331
+ instructionFiles: bound.projectContext.instructions.map((i) => i.path),
332
+ skippedInstructionFiles: bound.projectContext.skippedInstructions
333
+ };
334
+ }
335
+ function toWireBinding(state, bound) {
336
+ if (state === "bound" && bound)
337
+ return { state: "bound", workspace: toWireSessionWorkspace(bound) };
338
+ return { state: state === "bound" ? "unbound" : state };
339
+ }
340
+ function toWireWorkspaceRef(bound) {
341
+ if (!bound) return null;
342
+ return workspaceRefOf(bound.workspace.root);
343
+ }
344
+ function workspaceRefOf(root) {
345
+ return { root, name: displayName(root) };
346
+ }
347
+ function toWireKnownWorkspaces(known) {
348
+ return known.map((w) => ({ root: w.root, name: w.name }));
349
+ }
350
+ function displayName(root) {
351
+ return basename(root) || root;
352
+ }
353
+ function readWorkspaceField(body, lang) {
354
+ const raw = body["workspace"];
355
+ if (raw === void 0 || raw === null) return { ok: true, dir: null };
356
+ if (typeof raw !== "string" || raw.trim().length === 0) {
357
+ return { ok: false, message: t("web.workspace_must_be_string", void 0, lang) };
358
+ }
359
+ return { ok: true, dir: raw.trim() };
360
+ }
361
+ function readBindWorkspaceBody(body, lang) {
362
+ const rawRoot = body["root"];
363
+ const wantsNone = body["none"] === true;
364
+ const hasRoot = rawRoot !== void 0 && rawRoot !== null;
365
+ if (hasRoot && wantsNone) {
366
+ return { ok: false, message: t("web.workspace_bind_both", void 0, lang) };
367
+ }
368
+ if (wantsNone) return { ok: true, kind: "none" };
369
+ if (!hasRoot) return { ok: false, message: t("web.workspace_bind_neither", void 0, lang) };
370
+ if (typeof rawRoot !== "string" || rawRoot.trim().length === 0) {
371
+ return { ok: false, message: t("web.workspace_must_be_string", void 0, lang) };
372
+ }
373
+ return { ok: true, kind: "root", root: rawRoot.trim() };
374
+ }
375
+ function bindFailureToHttp(failure, lang) {
376
+ if (failure.reason === "locked") {
377
+ return {
378
+ status: 409,
379
+ code: "workspace-locked",
380
+ message: t("web.workspace_locked", { detail: failure.detail }, lang)
381
+ };
382
+ }
383
+ return { status: 400, code: "bad-workspace", message: failure.detail };
384
+ }
385
+
386
+ // src/sessions/summary.ts
387
+ function toWireFinish(event, ts, turnStartedAt) {
388
+ return {
389
+ reason: event.reason,
390
+ // 起点不知道就给 null,**不给 0** —— 0 在界面上是「0s」,那是个假事实
391
+ durationMs: turnStartedAt === null ? null : Math.max(0, ts - turnStartedAt),
392
+ runUsage: event.runUsage ?? null
393
+ };
394
+ }
395
+ var DEFAULT_SESSION_LIST_LIMIT = 50;
396
+ var OFFLINE = {
397
+ state: "idle",
398
+ pendingApprovals: 0,
399
+ pendingQuestions: 0,
400
+ // 「从什么时候起被拦住」在这一档也是 null,理由和上面那条一样:挂起的账只活在
401
+ // Hub 的内存里。⚠️ 它**不是**「没在等」——是「这个进程不知道」,而两者在读的
402
+ // 那一侧下一步相同(那一格整个不画),所以归同一档。判据全文在
403
+ // `WireSessionSummary.pendingSince` 的最后一节
404
+ pendingSince: null,
405
+ turnStartedAt: null,
406
+ lastFinish: null
407
+ };
408
+ function toWireSummary(id, hub, row, extra = {}) {
409
+ const live = hub !== void 0;
410
+ const state = hub ?? OFFLINE;
411
+ return {
412
+ id,
413
+ state: state.state,
414
+ live,
415
+ title: row?.title ?? null,
416
+ model: row?.model ?? null,
417
+ messageCount: row?.messageCount ?? 0,
418
+ costUsd: row?.costUsd ?? null,
419
+ startedAt: row?.startedAt ?? null,
420
+ // 和 `startedAt` 同生共死:两者的 null 都只有一个含义 ——「DB 里查不到这段
421
+ // 会话」。**这一层不拿 `startedAt` 兜它**:兜了就等于把「不知道」写成
422
+ // 「刚建出来那一刻动过」,而那正是加这一列之前界面上那句假话
423
+ updatedAt: row?.updatedAt ?? null,
424
+ endedAt: row?.endedAt ?? null,
425
+ pendingApprovals: state.pendingApprovals,
426
+ pendingQuestions: state.pendingQuestions,
427
+ // 原样透传,这一层**一个字都不加工**:两张登记表合成一格那一步在 Hub 里
428
+ // 就着两个 relay 做完了(`earlierOf`),在这儿再合一次就是第二个口径 ——
429
+ // 同下面回合那两格的判据
430
+ pendingSince: state.pendingSince,
431
+ // 这一格是**两个真源真的合在一起**的唯一一处(其余每一格各归各家)。
432
+ // 合法和它推翻了哪一条上一轮的规矩,全在 `toWireSummaryWorkspace` 上
433
+ workspace: toWireSummaryWorkspace(extra.workspace ?? null, live, row),
434
+ // 回合那两格原样透传,这一层不做任何加工:`durationMs` 的减法在 Hub 里
435
+ // 就着那一帧算完了(见 `hub.ts` 的 `finishOf`),在这儿再算一次就是第二个口径
436
+ turnStartedAt: state.turnStartedAt,
437
+ lastFinish: state.lastFinish,
438
+ // 同 `workspace` 那一格:不给就是 null,**不去 DB 兜一手**。身份决定 system
439
+ // prompt 和工具表,只有活着的那份内存说得出它现在是谁
440
+ role: extra.role ?? null,
441
+ ...extra.snippet === void 0 ? {} : { snippet: extra.snippet }
442
+ };
443
+ }
444
+ function toWireSummaryWorkspace(bound, live, row) {
445
+ const ref = toWireWorkspaceRef(bound);
446
+ if (ref) return { state: "bound", ref };
447
+ if (live) return { state: "none" };
448
+ if (row?.workspaceState === "bound" && row.workspaceRoot) {
449
+ return { state: "bound", ref: workspaceRefOf(row.workspaceRoot) };
450
+ }
451
+ return row?.workspaceState === "none" ? { state: "none" } : { state: "unknown" };
452
+ }
453
+ function listSessionSummaries(hubStates, catalog, query, workspaceOf = () => null, roleOf = () => null, limit = DEFAULT_SESSION_LIST_LIMIT) {
454
+ const byId = new Map(hubStates.map((s) => [s.id, s]));
455
+ if (query) {
456
+ if (!catalog) return [];
457
+ const seen = /* @__PURE__ */ new Set();
458
+ const hits = [];
459
+ for (const hit of catalog.search(query, limit)) {
460
+ if (seen.has(hit.sessionId)) continue;
461
+ seen.add(hit.sessionId);
462
+ hits.push(
463
+ toWireSummary(hit.sessionId, byId.get(hit.sessionId), catalog.get(hit.sessionId), {
464
+ snippet: hit.snippet,
465
+ workspace: workspaceOf(hit.sessionId),
466
+ role: roleOf(hit.sessionId)
467
+ })
468
+ );
469
+ }
470
+ return hits;
471
+ }
472
+ const rows = catalog?.list(limit) ?? [];
473
+ const summaries = rows.map(
474
+ (row) => toWireSummary(row.id, byId.get(row.id), row, {
475
+ workspace: workspaceOf(row.id),
476
+ role: roleOf(row.id)
477
+ })
478
+ );
479
+ const known = new Set(rows.map((r) => r.id));
480
+ const fresh = hubStates.filter((s) => !known.has(s.id)).map((s) => toWireSummary(s.id, s, null, { workspace: workspaceOf(s.id), role: roleOf(s.id) }));
481
+ return [...fresh, ...summaries].sort(compareSummaries).slice(0, limit);
482
+ }
483
+ function compareSummaries(a, b) {
484
+ if (a.live !== b.live) return a.live ? -1 : 1;
485
+ const at = a.updatedAt ?? Number.POSITIVE_INFINITY;
486
+ const bt = b.updatedAt ?? Number.POSITIVE_INFINITY;
487
+ if (at !== bt) return bt - at;
488
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
489
+ }
490
+
491
+ // src/hub.ts
492
+ var SessionHub = class {
493
+ ring;
494
+ idleGraceMs;
495
+ clock;
496
+ /** 见 {@link SessionHubOptions.onCooled} */
497
+ onCooled;
498
+ registry;
499
+ sinks = /* @__PURE__ */ new Set();
500
+ /**
501
+ * 同进程旁听者(方案 54 §三)。**故意是第二个集合,而不是 `sinks` 上的一个标记位。**
502
+ *
503
+ * 标记位那种写法要求每一处 `sinks.size` 都改成「数一下里面有几个不是标记的」,
504
+ * 而那四处判定里有两处(`reap()` / `drainQueue()`)的错法是**静默的**:
505
+ * 挂起的审批永远不被放弃、没人看着的时候凭空开始跑一条排队的消息。
506
+ * 两个集合的写法让那四处一个字都不用改。
507
+ */
508
+ observers = /* @__PURE__ */ new Set();
509
+ /** requestId → sessionId。`POST /api/approvals/:requestId` 上没有会话 id */
510
+ requestOwner = /* @__PURE__ */ new Map();
511
+ /** 全局广播游标。0 是保留值(连接级帧),所以广播帧从 1 开始 */
512
+ seq = 0;
513
+ graceTimer = null;
514
+ closed = false;
515
+ constructor(opts = {}) {
516
+ this.ring = new EnvelopeRing(opts.ringCapacity ?? DEFAULT_RING_CAPACITY);
517
+ this.idleGraceMs = opts.idleGraceMs ?? 3e4;
518
+ this.clock = opts.clock ?? Date.now;
519
+ this.onCooled = opts.onCooled;
520
+ this.registry = new SessionRegistry({
521
+ ...opts.maxActiveSessions === void 0 ? {} : { maxActive: opts.maxActiveSessions },
522
+ ...opts.maxQueuedMessages === void 0 ? {} : { maxQueued: opts.maxQueuedMessages }
523
+ });
524
+ }
525
+ /**
526
+ * 把一个会话交给 Hub 管。重复注册同一个 id 是空操作。
527
+ *
528
+ * 活跃会话超过上限时,最久没动过的**空闲**会话会被顺带冷却掉 ——
529
+ * 它从此不在 Hub 里,但**仍然在会话列表里**(列表的另一半来自 DB),
530
+ * 重新打开时历史走 `GET /api/sessions/:id/messages` 回放。
531
+ */
532
+ register(session) {
533
+ const { added, cooled } = this.registry.register(session);
534
+ if (!added) return;
535
+ if (cooled === null) return;
536
+ this.publish(cooled, { type: "session-cooled" });
537
+ this.onCooled?.(cooled);
538
+ }
539
+ has(sessionId) {
540
+ return this.registry.has(sessionId);
541
+ }
542
+ /** 这个 id 是被冷却掉的(而不是从来没存在过)吗 */
543
+ isCooled(sessionId) {
544
+ return this.registry.isCooled(sessionId);
545
+ }
546
+ /** 不认识的会话按 idle 报 —— 调用方要区分的话先问 `has()` */
547
+ stateOf(sessionId) {
548
+ return this.registry.get(sessionId)?.state ?? "idle";
549
+ }
550
+ /** Hub 手里这些会话此刻的样子。Map 的插入序 = 注册序 */
551
+ listSessions() {
552
+ return [...this.registry.all()].map(([id, entry]) => ({
553
+ id,
554
+ state: entry.state,
555
+ pendingApprovals: entry.relay.pendingCount,
556
+ pendingQuestions: entry.questions.pendingCount,
557
+ pendingSince: earlierOf(entry.relay.pendingSince, entry.questions.pendingSince),
558
+ queued: entry.queue.length,
559
+ turnStartedAt: entry.turnStartedAt,
560
+ lastFinish: entry.lastFinish
561
+ }));
562
+ }
563
+ /**
564
+ * 把一个会话从 Hub 里摘掉(`DELETE /api/sessions/:id`)。
565
+ *
566
+ * 收尾按 §4.3 那套:**放弃**挂起的请求(不是逐个 deny)+ 中止在跑的回合 +
567
+ * 丢掉排队的消息。删掉的会话不进冷却名单 —— 它是真没了,不是被挤走。
568
+ *
569
+ * @returns Hub 里本来有没有它
570
+ */
571
+ unregister(sessionId) {
572
+ const entry = this.registry.remove(sessionId);
573
+ if (!entry) return false;
574
+ entry.queue.length = 0;
575
+ entry.relay.abandon();
576
+ entry.questions.abandon();
577
+ entry.controller?.abort();
578
+ for (const [requestId, owner] of this.requestOwner) {
579
+ if (owner === sessionId) this.requestOwner.delete(requestId);
580
+ }
581
+ return true;
582
+ }
583
+ /** 当前活着的 SSE 连接数。给 `/api/config` 和用例看 */
584
+ get connectionCount() {
585
+ return this.sinks.size;
586
+ }
587
+ /**
588
+ * 接一条 SSE 连接。
589
+ *
590
+ * 同步完成三件事,顺序不能换:先 `connected`(把响应头刷出去、证明流通了),
591
+ * 再按 `Last-Event-ID` 补发或回 `stream-reset`,最后才登记进扇出集合 ——
592
+ * 反过来的话补发和实时帧会交织,页面上就是一段乱序的对话。
593
+ *
594
+ * @param lastEventId 客户端带上来的断点。省略 = 全新的流(历史归 GET /messages)
595
+ * @returns 取消订阅。**幂等**,连接关闭和服务 close 会各调一次
596
+ */
597
+ subscribe(sink, lastEventId) {
598
+ sink(this.connectionFrame({ type: "connected" }));
599
+ if (lastEventId !== void 0) {
600
+ const replay = this.ring.since(lastEventId);
601
+ if (replay === null) {
602
+ sink(this.connectionFrame({ type: "stream-reset", reason: "buffer-overflow" }));
603
+ } else {
604
+ for (const frame of replay) sink(frame);
605
+ }
606
+ }
607
+ this.sinks.add(sink);
608
+ this.clearGrace();
609
+ let released = false;
610
+ return () => {
611
+ if (released) return;
612
+ released = true;
613
+ this.sinks.delete(sink);
614
+ if (this.sinks.size === 0) this.armGrace();
615
+ };
616
+ }
617
+ /**
618
+ * 同进程旁听广播(方案 54 §三)。**不是一条连接。**
619
+ *
620
+ * 和 `subscribe()` 的差别不是「少发一帧」,是它整条不进那套连接语义:
621
+ *
622
+ * - **不进 `sinks`**:不计入 `connectionCount`,不影响 idle-grace 的收摊
623
+ * (`armGrace()` → `reap()`),也不影响 `drainQueue()` 的「全断了就丢队列」。
624
+ * 那三处判的是「**人**还在不在」,而旁听者是宿主的代码,不是一双眼睛
625
+ * - **不发 `connected`、不认 `Last-Event-ID`、不从环形缓冲补帧**:补帧机制存在的
626
+ * 理由是网线断过一段,而同进程的函数调用不会丢帧。真要历史就走
627
+ * `GET /api/sessions/:id/messages`,和浏览器同一条路
628
+ *
629
+ * 收到的是**和 SSE 上同一个信封对象**(同一个 `seq`),所以「宿主看到的和浏览器
630
+ * 看到的是同一条流」不需要额外的对齐机制。四种状态怎么映射到宿主界面上的一枚灯,
631
+ * 那是宿主的产品决定,不在这里做。
632
+ *
633
+ * @returns 取消旁听。**幂等** —— 多调几次不会误删后来注册的同一个函数
634
+ */
635
+ observe(sink) {
636
+ if (this.closed) return () => {
637
+ };
638
+ this.observers.add(sink);
639
+ let released = false;
640
+ return () => {
641
+ if (released) return;
642
+ released = true;
643
+ this.observers.delete(sink);
644
+ };
645
+ }
646
+ /**
647
+ * 发一条消息。会话空闲就立刻开一轮,否则**排队**(方案 30 §2.3 第 4 条)。
648
+ *
649
+ * 排队而不是并发:`AgentSession` 的历史累积发生在 `run()` 的末尾,两轮并发跑
650
+ * 会让历史顺序变成不确定的 —— 那不是「偶尔乱序」,是每次重开会话看到的对话
651
+ * 都可能不一样。**不同会话之间不受这条限制**,它们各跑各的。
652
+ *
653
+ * 这是对方案 20 验收第 7 条(「running 时第二个 POST 直接 409」)的**有意
654
+ * 修改**:那时只有一个会话,409 是唯一能给的答复;现在排队和「不交织」
655
+ * 这个不变量并不冲突,而 409 会逼每个客户端自己实现一遍重试。
656
+ *
657
+ * @param decorate 给这一轮的事件流套一层(见 {@link TurnDecorator})。
658
+ * 自定义斜杠命令的工具收窄和临时模型走的就是它 —— Hub 自己不认识命令这个概念,
659
+ * 只负责把这个函数**跟着消息一路带到轮次真正开始的那一刻**(排队也带着)。
660
+ */
661
+ start(sessionId, message, decorate) {
662
+ const entry = this.registry.get(sessionId);
663
+ if (!entry) {
664
+ return this.registry.isCooled(sessionId) ? { ok: false, reason: "cooled" } : { ok: false, reason: "unknown-session" };
665
+ }
666
+ const queued = { message, ...decorate ? { decorate } : {} };
667
+ if (entry.state !== "idle") {
668
+ if (!this.registry.enqueue(entry, queued)) {
669
+ return { ok: false, reason: "queue-full", state: entry.state };
670
+ }
671
+ return { ok: true, queued: true };
672
+ }
673
+ this.beginTurn(entry, queued);
674
+ return { ok: true, queued: false };
675
+ }
676
+ /**
677
+ * 中止本轮,并**丢掉排在后面的消息**。
678
+ *
679
+ * 排队的一起丢是刻意的:用户按下「停」要的是「现在别干了」,而不是
680
+ * 「停掉这一轮然后立刻开始下一轮」—— 后者的表现是按了停之后 agent 又动起来了。
681
+ *
682
+ * @returns 是否真有一轮在跑(**只看这个**,丢掉的队列不算)。
683
+ * `false` 表示这一轮本来就没在跑,不是失败
684
+ */
685
+ abort(sessionId) {
686
+ const entry = this.registry.get(sessionId);
687
+ if (!entry) return false;
688
+ entry.queue.length = 0;
689
+ if (!entry.controller) return false;
690
+ entry.controller.abort();
691
+ return true;
692
+ }
693
+ /** 补拉挂起的审批 —— 新连上来的页面靠它把弹层恢复出来(§4.3) */
694
+ listPending(sessionId) {
695
+ return this.registry.get(sessionId)?.relay.listPending() ?? [];
696
+ }
697
+ /** 补拉挂起的提问(方案 34 验收 9)。与 `listPending` 同构 */
698
+ listPendingQuestions(sessionId) {
699
+ return this.registry.get(sessionId)?.questions.listPending() ?? [];
700
+ }
701
+ /**
702
+ * 答复一个审批。
703
+ *
704
+ * @returns `false` = 这个 requestId 不认识(重复答复 / 上一轮的 ID / 已被放弃)。
705
+ * 调用方该回 404 而不是 500:用户在两个标签页上各点一下就会走到这里。
706
+ */
707
+ respondApproval(requestId, outcome, note) {
708
+ const sessionId = this.requestOwner.get(requestId);
709
+ const entry = sessionId === void 0 ? void 0 : this.registry.get(sessionId);
710
+ if (!entry || !sessionId) return false;
711
+ if (!entry.relay.respond(requestId, outcome, note)) return false;
712
+ this.requestOwner.delete(requestId);
713
+ this.publish(sessionId, { type: "approval-resolved", requestId });
714
+ this.syncApprovalState(entry);
715
+ return true;
716
+ }
717
+ /**
718
+ * 答复一个提问(方案 34)。
719
+ *
720
+ * **另一个方法而不是给 `respondApproval` 加个联合入参**:两者的载荷没有交集,
721
+ * 合成一个之后路由层要先猜「这个 requestId 是审批还是提问」,
722
+ * 而猜错的表现是一个拼错的答案被判成合法的 `deny`。
723
+ *
724
+ * @returns `false` = 这个 requestId 不认识(重复答复 / 上一轮的 / 已被放弃)。
725
+ * 调用方回 404,同审批。
726
+ */
727
+ respondQuestion(requestId, answer) {
728
+ const sessionId = this.requestOwner.get(requestId);
729
+ const entry = sessionId === void 0 ? void 0 : this.registry.get(sessionId);
730
+ if (!entry || !sessionId) return false;
731
+ if (!entry.questions.respond(requestId, answer)) return false;
732
+ this.requestOwner.delete(requestId);
733
+ this.publish(sessionId, { type: "question-resolved", requestId });
734
+ this.syncApprovalState(entry);
735
+ return true;
736
+ }
737
+ /**
738
+ * 收摊:撤掉倒计时、放弃所有挂起的审批、中止所有在跑的回合。
739
+ *
740
+ * 进程收到 SIGINT 时立即调它(§4.3 最后一行),之后宿主再 `dispose()`。
741
+ * 幂等。
742
+ */
743
+ shutdown() {
744
+ if (this.closed) return;
745
+ this.closed = true;
746
+ this.clearGrace();
747
+ this.reap();
748
+ this.sinks.clear();
749
+ this.observers.clear();
750
+ }
751
+ // -------------------------------------------------------------------------
752
+ // 私有:发帧
753
+ // -------------------------------------------------------------------------
754
+ /**
755
+ * 广播一帧:占一个全局序号、进环形缓冲、扇出给所有连接和旁听者。
756
+ *
757
+ * **回传发出去的那个信封**(调用方绝大多数不看)。这是「一轮跑了多久」
758
+ * 唯一合法的取数口:`clock` 上钉着「每产出一个信封恰好读一次」的不变量
759
+ * (见 `sessions/registry.ts` 的 `lastActiveTick`),想知道某一帧的时刻
760
+ * 只能从那一帧上拿,**不许再读一次挂钟**。
761
+ */
762
+ publish(sessionId, event) {
763
+ const frame = { seq: ++this.seq, sessionId, ts: this.clock(), event };
764
+ this.ring.push(frame);
765
+ for (const sink of this.sinks) sink(frame);
766
+ for (const sink of this.observers) sink(frame);
767
+ return frame;
768
+ }
769
+ /** 连接级帧:`seq: 0`、没有 `sessionId`、不进缓冲、不写 SSE 的 `id:` */
770
+ connectionFrame(event) {
771
+ return { seq: 0, ts: this.clock(), event };
772
+ }
773
+ /**
774
+ * 状态迁移。**只在真的变了的时候发帧** —— 否则并行审批会刷出一串重复状态。
775
+ *
776
+ * 回传那一帧的时刻(没发帧时 undefined),给 {@link beginTurn} 记回合起点用。
777
+ */
778
+ setState(entry, state) {
779
+ if (entry.state === state) return void 0;
780
+ entry.state = state;
781
+ return this.publish(entry.session.sessionId, { type: "session-state", state }).ts;
782
+ }
783
+ /**
784
+ * 按「还挂着几条请求」把状态摆正。
785
+ *
786
+ * 只在回合真的在跑的时候动(`controller !== null`):回合已经收尾之后
787
+ * 状态该是 idle,这里不能把它拽回 running。
788
+ *
789
+ * **审批优先于提问**:两者同时挂着时报 `awaiting-approval`。理由是界面上
790
+ * 审批层压在提问层上面(它是安全边界,不该被一个选择题挡住),
791
+ * 而状态栏那句话得和用户眼前看到的那个框对上。
792
+ */
793
+ syncApprovalState(entry) {
794
+ if (!entry.controller) return;
795
+ if (entry.relay.pendingCount > 0) {
796
+ this.setState(entry, "awaiting-approval");
797
+ return;
798
+ }
799
+ this.setState(entry, entry.questions.pendingCount > 0 ? "awaiting-question" : "running");
800
+ }
801
+ // -------------------------------------------------------------------------
802
+ // 私有:跑一轮
803
+ // -------------------------------------------------------------------------
804
+ /**
805
+ * 开一轮:换一副新的登记表、拉起中止闸、广播状态、然后驱动。
806
+ *
807
+ * 状态帧必须在 `start()` 返回前就广播出去:POST 拿到 202 之后前端立刻可能
808
+ * 再问一次状态,而第二个标签页只靠这一帧才知道「现在正在跑」。
809
+ */
810
+ beginTurn(entry, queued) {
811
+ entry.relay = new ApprovalRelay();
812
+ entry.questions = new QuestionRelay();
813
+ entry.controller = new AbortController();
814
+ entry.lastFinish = null;
815
+ this.registry.touch(entry.session.sessionId);
816
+ entry.turnStartedAt = this.setState(entry, "running") ?? null;
817
+ void this.drive(entry, queued);
818
+ }
819
+ /**
820
+ * 独占那个 for-await,把事件扇出去。
821
+ *
822
+ * `serializeStream` 负责把 `approval-request` 里的闭包摘成 `requestId` 并登记
823
+ * 进 relay —— 这一段是 runtime 现成的,不在这里重写一遍。
824
+ *
825
+ * ## `decorate` 套在**最里面**,紧贴 `run()`
826
+ *
827
+ * 顺序不能换:那一层管的是「这一轮引擎能看见哪些工具、用哪个模型」,
828
+ * 而 `serializeStream` 只是把事件里的闭包摘掉 —— 套反了的话,收窄会在
829
+ * 序列化那一层进出,而真正跑工具的 `run()` 在它外面,等于整层白做。
830
+ */
831
+ async drive(entry, queued) {
832
+ const sessionId = entry.session.sessionId;
833
+ const signal = entry.controller?.signal;
834
+ try {
835
+ const run = entry.session.run(queued.message, signal ? { signal } : {});
836
+ const raw = queued.decorate ? queued.decorate(run) : run;
837
+ for await (const event of serializeStream(raw, entry.relay, entry.questions)) {
838
+ const blocking = event.type === "approval-request" || event.type === "question";
839
+ if (blocking) this.requestOwner.set(event.requestId, sessionId);
840
+ const frame = this.publish(sessionId, stripArtifactData(event));
841
+ if (event.type === "finish") {
842
+ entry.lastFinish = toWireFinish(event, frame.ts, entry.turnStartedAt);
843
+ }
844
+ if (blocking) this.syncApprovalState(entry);
845
+ }
846
+ } catch (err) {
847
+ this.publish(sessionId, { type: "error", message: describeError(err) });
848
+ } finally {
849
+ entry.controller = null;
850
+ for (const [requestId, owner] of this.requestOwner) {
851
+ if (owner === sessionId) this.requestOwner.delete(requestId);
852
+ }
853
+ this.registry.touch(sessionId);
854
+ entry.turnStartedAt = null;
855
+ this.setState(entry, "idle");
856
+ this.drainQueue(entry);
857
+ }
858
+ }
859
+ /**
860
+ * 一轮跑完,把排在后面的那条接上(方案 30 §2.3 第 4 条)。
861
+ *
862
+ * 三个前提缺一不可,缺了就是「浏览器早就关了,服务端自己接着聊」:
863
+ * 会话还在表里(没被删)、Hub 没收摊、且**还有连接活着**。
864
+ */
865
+ drainQueue(entry) {
866
+ if (this.closed || !this.registry.has(entry.session.sessionId)) return;
867
+ if (this.sinks.size === 0) {
868
+ entry.queue.length = 0;
869
+ return;
870
+ }
871
+ const next = this.registry.dequeue(entry);
872
+ if (next !== void 0) this.beginTurn(entry, next);
873
+ }
874
+ // -------------------------------------------------------------------------
875
+ // 私有:全断之后的收尾(§4.3)
876
+ // -------------------------------------------------------------------------
877
+ armGrace() {
878
+ if (this.closed || this.graceTimer) return;
879
+ this.graceTimer = setTimeout(() => {
880
+ this.graceTimer = null;
881
+ this.reap();
882
+ }, this.idleGraceMs);
883
+ this.graceTimer.unref();
884
+ }
885
+ clearGrace() {
886
+ if (!this.graceTimer) return;
887
+ clearTimeout(this.graceTimer);
888
+ this.graceTimer = null;
889
+ }
890
+ /**
891
+ * 放弃挂起的审批并中止在跑的回合。
892
+ *
893
+ * **`abandon()` 而不是逐个 `deny`**:前者一个 promise 都不 resolve,随后的
894
+ * `abort()` 让流收尾,引擎侧的 finally 才去兜底 —— 模型看到的是「这一轮被中止
895
+ * 了」而不是「用户否了我五次」。后者会让模型换个方式再试一遍。
896
+ */
897
+ reap() {
898
+ if (this.sinks.size > 0 && !this.closed) return;
899
+ for (const [, entry] of this.registry.all()) {
900
+ entry.queue.length = 0;
901
+ if (entry.state === "idle") continue;
902
+ entry.relay.abandon();
903
+ entry.questions.abandon();
904
+ entry.controller?.abort();
905
+ }
906
+ }
907
+ };
908
+ function earlierOf(a, b) {
909
+ if (a === null) return b;
910
+ if (b === null) return a;
911
+ return Math.min(a, b);
912
+ }
913
+ function describeError(err) {
914
+ return err instanceof Error ? err.message : String(err);
915
+ }
916
+ function stripArtifactData(event) {
917
+ if (event.type !== "tool-result" || !event.artifacts) return event;
918
+ return { ...event, artifacts: event.artifacts.map(withoutData) };
919
+ }
920
+ function withoutData(artifact) {
921
+ if (artifact.data === void 0) return artifact;
922
+ const copy = { ...artifact };
923
+ delete copy.data;
924
+ return copy;
925
+ }
926
+ var JSON_BODY_LIMIT = 16 * 1024 * 1024;
927
+ var BASE_HEADERS = {
928
+ "Cache-Control": "no-store",
929
+ "Referrer-Policy": "no-referrer",
930
+ "X-Content-Type-Options": "nosniff"
931
+ };
932
+ function toAuthView(req) {
933
+ return {
934
+ method: (req.method ?? "GET").toUpperCase(),
935
+ url: req.url ?? "/",
936
+ ...typeof req.headers.cookie === "string" ? { cookie: req.headers.cookie } : {},
937
+ ...typeof req.headers.origin === "string" ? { origin: req.headers.origin } : {},
938
+ ...typeof req.headers.host === "string" ? { host: req.headers.host } : {}
939
+ };
940
+ }
941
+ function pathnameOf(url) {
942
+ try {
943
+ return new URL(url ?? "/", "http://127.0.0.1").pathname;
944
+ } catch {
945
+ return "/";
946
+ }
947
+ }
948
+ function langOf(url) {
949
+ let raw;
950
+ try {
951
+ raw = new URL(url ?? "/", "http://127.0.0.1").searchParams.get(WIRE_LANG_PARAM);
952
+ } catch {
953
+ return void 0;
954
+ }
955
+ return raw && isLang(raw) ? raw : void 0;
956
+ }
957
+ function sendJson(res, status, body, extra = {}) {
958
+ const payload = JSON.stringify(body);
959
+ res.writeHead(status, {
960
+ ...BASE_HEADERS,
961
+ ...extra,
962
+ "Content-Type": "application/json; charset=utf-8",
963
+ "Content-Length": Buffer.byteLength(payload)
964
+ });
965
+ res.end(payload);
966
+ }
967
+ function sendError(res, status, code, message, extra = {}) {
968
+ sendJson(res, status, { error: { code, message } }, extra);
969
+ }
970
+ function sendUnknownSession(res, sessionId) {
971
+ sendError(res, 404, "unknown-session", `\u6CA1\u6709\u4F1A\u8BDD ${sessionId}`);
972
+ }
973
+ function baseHeaders() {
974
+ return { ...BASE_HEADERS };
975
+ }
976
+ async function readJsonBody(req, limit = JSON_BODY_LIMIT) {
977
+ const chunks = [];
978
+ let size = 0;
979
+ try {
980
+ for await (const chunk of req) {
981
+ const buf = chunk;
982
+ size += buf.length;
983
+ if (size > limit) {
984
+ req.destroy();
985
+ return { ok: false, status: 413, message: `\u8BF7\u6C42\u4F53\u8D85\u8FC7 ${limit} \u5B57\u8282` };
986
+ }
987
+ chunks.push(buf);
988
+ }
989
+ } catch (err) {
990
+ return { ok: false, status: 400, message: `\u8BFB\u53D6\u8BF7\u6C42\u4F53\u5931\u8D25\uFF1A${describe(err)}` };
991
+ }
992
+ const text = Buffer.concat(chunks).toString("utf8");
993
+ if (text.trim().length === 0) return { ok: true, value: {} };
994
+ try {
995
+ return { ok: true, value: JSON.parse(text) };
996
+ } catch (err) {
997
+ return { ok: false, status: 400, message: `\u8BF7\u6C42\u4F53\u4E0D\u662F\u5408\u6CD5 JSON\uFF1A${describe(err)}` };
998
+ }
999
+ }
1000
+ function describe(err) {
1001
+ return err instanceof Error ? err.message : String(err);
1002
+ }
1003
+ function asRecord(value) {
1004
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
1005
+ }
1006
+
1007
+ // src/assets.ts
1008
+ var INLINE_ARTIFACT_TYPES = /* @__PURE__ */ new Map([
1009
+ [".png", "image/png"],
1010
+ [".jpg", "image/jpeg"],
1011
+ [".jpeg", "image/jpeg"],
1012
+ [".gif", "image/gif"],
1013
+ [".webp", "image/webp"],
1014
+ [".bmp", "image/bmp"],
1015
+ [".wav", "audio/wav"],
1016
+ [".mp3", "audio/mpeg"],
1017
+ [".ogg", "audio/ogg"],
1018
+ [".m4a", "audio/mp4"]
1019
+ ]);
1020
+ var STATIC_TYPES = /* @__PURE__ */ new Map([
1021
+ [".html", "text/html; charset=utf-8"],
1022
+ [".js", "text/javascript; charset=utf-8"],
1023
+ [".mjs", "text/javascript; charset=utf-8"],
1024
+ [".css", "text/css; charset=utf-8"],
1025
+ [".json", "application/json; charset=utf-8"],
1026
+ [".svg", "image/svg+xml"],
1027
+ [".png", "image/png"],
1028
+ [".jpg", "image/jpeg"],
1029
+ [".webp", "image/webp"],
1030
+ [".ico", "image/x-icon"],
1031
+ [".woff2", "font/woff2"],
1032
+ [".map", "application/json; charset=utf-8"]
1033
+ ]);
1034
+ function isInside(root, child) {
1035
+ return child === root || child.startsWith(root + sep);
1036
+ }
1037
+ function resolveArtifactPath(root, sessionId, name) {
1038
+ if (sessionId.includes("\0") || name.includes("\0")) return null;
1039
+ if (/[/\\]/.test(name) || name.length === 0) return null;
1040
+ try {
1041
+ const realRoot = realpathSync(resolve(root));
1042
+ const target = resolve(realRoot, sessionId, name);
1043
+ if (!isInside(realRoot, target)) return null;
1044
+ const real = realpathSync(target);
1045
+ if (!isInside(realRoot, real)) return null;
1046
+ if (!statSync(real).isFile()) return null;
1047
+ return real;
1048
+ } catch {
1049
+ return null;
1050
+ }
1051
+ }
1052
+ function artifactDisposition(path) {
1053
+ const known = INLINE_ARTIFACT_TYPES.get(extname(path).toLowerCase());
1054
+ return known ? { type: known, inline: true } : { type: "application/octet-stream", inline: false };
1055
+ }
1056
+ function staticContentType(path) {
1057
+ return STATIC_TYPES.get(extname(path).toLowerCase()) ?? "application/octet-stream";
1058
+ }
1059
+ function sendFile(res, path, headers) {
1060
+ let size;
1061
+ try {
1062
+ size = statSync(path).size;
1063
+ } catch {
1064
+ res.writeHead(404, baseHeaders());
1065
+ res.end();
1066
+ return;
1067
+ }
1068
+ res.writeHead(200, { ...baseHeaders(), ...headers, "Content-Length": String(size) });
1069
+ const stream = createReadStream(path);
1070
+ stream.on("error", () => res.destroy());
1071
+ stream.pipe(res);
1072
+ }
1073
+ function resolveStaticPath(webRoot, pathname) {
1074
+ try {
1075
+ const realRoot = realpathSync(resolve(webRoot));
1076
+ const requested = pathname === "/" ? "/index.html" : pathname;
1077
+ const target = resolve(realRoot, `.${requested}`);
1078
+ if (isInside(realRoot, target)) {
1079
+ try {
1080
+ if (statSync(target).isFile()) return realpathSync(target);
1081
+ } catch {
1082
+ }
1083
+ }
1084
+ const index = resolve(realRoot, "index.html");
1085
+ return statSync(index).isFile() ? index : null;
1086
+ } catch {
1087
+ return null;
1088
+ }
1089
+ }
1090
+ function placeholderPage(version) {
1091
+ return `<!doctype html>
1092
+ <html lang="zh-CN">
1093
+ <head>
1094
+ <meta charset="utf-8">
1095
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1096
+ <title>Epoch Agent Web</title>
1097
+ <style>
1098
+ body{margin:0;display:grid;place-items:center;min-height:100vh;
1099
+ font:14px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace;background:#111418;color:#c8d0da}
1100
+ main{max-width:34rem;padding:2rem}
1101
+ h1{font-size:1rem;font-weight:600;color:#e8eef5;margin:0 0 .75rem}
1102
+ code{background:#1b2027;padding:.1rem .35rem;border-radius:3px}
1103
+ </style>
1104
+ </head>
1105
+ <body><main>
1106
+ <h1>Epoch Agent Web \xB7 ${escapeHtml(version)}</h1>
1107
+ <p>\u670D\u52A1\u7AEF\u5DF2\u5C31\u7EEA\uFF0C\u9274\u6743 cookie \u5DF2\u4E0B\u53D1\u3002<strong>\u524D\u7AEF\u4EA7\u7269\u5C1A\u672A\u6784\u5EFA</strong>\uFF0C
1108
+ \u5148\u8DD1\u4E00\u6B21 <code>pnpm build</code>\u3002</p>
1109
+ <p>\u73B0\u5728\u53EF\u4EE5\u76F4\u63A5\u7528 REST / SSE\uFF1A<code>GET /api/health</code>\u3001<code>GET /api/config</code>\u3001
1110
+ <code>GET /api/events</code>\u3002</p>
1111
+ </main></body></html>`;
1112
+ }
1113
+ function escapeHtml(text) {
1114
+ return text.replace(
1115
+ /[&<>"']/g,
1116
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c
1117
+ );
1118
+ }
1119
+ function builtinConnectors(tools) {
1120
+ const counts = /* @__PURE__ */ new Map();
1121
+ for (const tool of tools) {
1122
+ if (tool.source === "builtin" || tool.source.startsWith("mcp:")) continue;
1123
+ counts.set(tool.source, (counts.get(tool.source) ?? 0) + 1);
1124
+ }
1125
+ return [...counts.entries()].map(([name, toolCount]) => ({ name, toolCount })).sort((a, b) => a.name.localeCompare(b.name));
1126
+ }
1127
+ function toWireRole(role, notices) {
1128
+ return {
1129
+ name: role.name,
1130
+ description: role.description,
1131
+ source: role.source,
1132
+ // `?? null` 而不是 `?? []`:不给 tools 的角色是「不限」,空数组是
1133
+ // 「一个都不给」。合并成同一个值之后,`general` 会显示成一个废掉的角色
1134
+ tools: role.tools ?? null,
1135
+ maxTurns: role.maxTurns ?? null,
1136
+ toolsCut: notices.find((n) => n.role === role.name)?.cut ?? []
1137
+ };
1138
+ }
1139
+ function toWireSkill(skill) {
1140
+ const description = skillIndexDescription(skill.description);
1141
+ return {
1142
+ name: skill.name,
1143
+ category: skill.category || "general",
1144
+ description,
1145
+ // 索引里的那一行是 `- <名字>: <描述>`,按它整行估才是这条技能的真实占用
1146
+ tokens: estimateIndexTokens(`- ${skill.name}: ${description}`),
1147
+ scope: skill.scope,
1148
+ type: skill.type
1149
+ };
1150
+ }
1151
+ function estimateIndexTokens(text) {
1152
+ return Math.ceil(text.length / 4);
1153
+ }
1154
+ function toWireMcp(server) {
1155
+ const state = server.needsLogin ? "needs-login" : server.connected ? "connected" : "failed";
1156
+ return {
1157
+ name: server.name,
1158
+ state,
1159
+ toolCount: server.toolCount,
1160
+ reconnectAttempts: server.reconnectAttempts,
1161
+ ...server.lastError ? { lastError: server.lastError } : {},
1162
+ // **原样转发,这一层不判也不补**:来源是装配层的结论(`connectAll(…, source)`),
1163
+ // 在这儿再猜一次(比如「名字里有 `__` 就算宿主的」)等于给同一件事第二个答案
1164
+ source: server.source
1165
+ };
1166
+ }
1167
+ function collectCapabilities(runtime, tools, bound) {
1168
+ return {
1169
+ roles: runtime.agentRoles.map((r) => toWireRole(r, runtime.agentRoleNotices)),
1170
+ skills: runtime.skills.map(toWireSkill),
1171
+ builtinConnectors: builtinConnectors(tools),
1172
+ mcpServers: runtime.mcpServers.map(toWireMcp),
1173
+ project: {
1174
+ root: bound?.workspace.root ?? null,
1175
+ // 没绑工作区时报 `false` 而不是 `true`:界面拿它决定要不要画那句
1176
+ // 「不受信任的目录连读都不读」,而没有工作区时那句话是对的
1177
+ // —— 项目层确实一个字都没读
1178
+ trusted: bound?.trust.trusted ?? false
1179
+ }
1180
+ };
1181
+ }
1182
+ var MAX_SKILL_BODY_BYTES = 256 * 1024;
1183
+ function toWireSkillBody(name, body) {
1184
+ const bytes = Buffer.byteLength(body, "utf-8");
1185
+ if (bytes <= MAX_SKILL_BODY_BYTES) return { name, body, truncated: false, bytes };
1186
+ const cut = Buffer.from(body, "utf-8").subarray(0, MAX_SKILL_BODY_BYTES).toString("utf-8");
1187
+ const safe = Buffer.byteLength(cut, "utf-8") > MAX_SKILL_BODY_BYTES ? cut.slice(0, -1) : cut;
1188
+ return { name, body: safe, truncated: true, bytes };
1189
+ }
1190
+ function getSkillBody(ctx, res, sessionId, name, lang) {
1191
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1192
+ return sendUnknownSession(res, sessionId);
1193
+ }
1194
+ const body = ctx.runtime.skillBody(name);
1195
+ if (body === null) {
1196
+ return sendError(res, 404, "unknown-skill", t("web.skill_unknown", { name }, lang));
1197
+ }
1198
+ sendJson(res, 200, toWireSkillBody(name, body));
1199
+ }
1200
+ function toWireCommand(def) {
1201
+ return {
1202
+ name: def.name,
1203
+ description: def.description,
1204
+ // 没写就不带这个键(不是空串):空串会被画成一个空的参数提示位,
1205
+ // 于是「这条命令不吃参数」和「作者忘了写提示」看起来一样
1206
+ ...def.argumentHint ? { argumentHint: def.argumentHint } : {}
1207
+ };
1208
+ }
1209
+ function parseSlashInput(text) {
1210
+ if (!text.startsWith("/")) return null;
1211
+ if (text.includes("\n")) return null;
1212
+ const body = text.slice(1);
1213
+ const spaceIdx = body.search(/\s/);
1214
+ const name = (spaceIdx === -1 ? body : body.slice(0, spaceIdx)).toLowerCase();
1215
+ if (!name) return null;
1216
+ return { name, args: spaceIdx === -1 ? "" : body.slice(spaceIdx).trim() };
1217
+ }
1218
+ function expandUserContent(runtime, message, model, role) {
1219
+ const text = leadingText(message);
1220
+ const parsed = text === null ? null : parseSlashInput(text);
1221
+ const expanded = parsed ? runtime.commands.expand(parsed.name, parsed.args) : void 0;
1222
+ const wraps = role !== null || expanded?.allowedTools !== void 0 || expanded?.model !== void 0;
1223
+ return {
1224
+ message: expanded ? replaceLeadingText(message, expanded.prompt) : message,
1225
+ ...wraps ? { decorate: (stream) => scopeTurn(runtime, model, role, expanded, stream) } : {},
1226
+ ...parsed && expanded ? { notice: { name: parsed.name, warnings: expanded.warnings ?? [] } } : {}
1227
+ };
1228
+ }
1229
+ function scopeTurn(runtime, model, role, expanded, stream) {
1230
+ return withRoleScope(
1231
+ role?.control ?? null,
1232
+ role?.name,
1233
+ withModelScope(
1234
+ model,
1235
+ expanded?.model,
1236
+ withToolScope(runtime.commands, expanded?.allowedTools, stream)
1237
+ )
1238
+ );
1239
+ }
1240
+ function leadingText(message) {
1241
+ if (typeof message === "string") return message;
1242
+ const first = message[0];
1243
+ return first && first.type === "text" ? first.text : null;
1244
+ }
1245
+ function replaceLeadingText(message, prompt) {
1246
+ if (typeof message === "string") return prompt;
1247
+ const head = { type: "text", text: prompt };
1248
+ return [head, ...message.slice(1)];
1249
+ }
1250
+ function toWireSandbox(iso) {
1251
+ return {
1252
+ level: iso.level,
1253
+ backend: iso.backend,
1254
+ reason: iso.reason,
1255
+ platform: iso.platform,
1256
+ covers: SANDBOX_COVERS,
1257
+ excludes: SANDBOX_EXCLUDES
1258
+ };
1259
+ }
1260
+ function toWireRule(rule) {
1261
+ return { verdict: rule.bucket, rule: rule.rule, layer: rule.layer };
1262
+ }
1263
+ function toWireShadow(finding) {
1264
+ return { kind: finding.kind, rule: finding.rule, by: finding.by };
1265
+ }
1266
+ function toWireAudit(audit) {
1267
+ const rows = audit.entries.map((e) => ({
1268
+ at: e.at,
1269
+ toolName: e.toolName,
1270
+ type: e.type,
1271
+ target: e.target,
1272
+ outcome: e.outcome,
1273
+ code: e.code
1274
+ }));
1275
+ return { rows, dropped: audit.dropped };
1276
+ }
1277
+ function toWireManaged(managed) {
1278
+ return {
1279
+ present: managed.present,
1280
+ path: managed.path,
1281
+ rulesOnly: managed.rulesOnly,
1282
+ bypassDisabled: managed.bypassDisabled
1283
+ };
1284
+ }
1285
+ var PERMISSIONS_UNAVAILABLE = {
1286
+ level: "unknown",
1287
+ // 配置里那一档同样说不出来(方案 56 §1.3):`PermissionsControl` 整个没起来,
1288
+ // 而这两格都是从它上面读的。⚠️ **不在这儿回落成 `default`** —— 那是一个
1289
+ // 看起来确切、其实没人核过的值;`unknown` 会被界面原样印出来,正是要的效果。
1290
+ // `configuredLayer` 跟着报 `unknown`:那一档的含义就是「分不出」
1291
+ configured: "unknown",
1292
+ configuredLayer: "unknown",
1293
+ rules: [],
1294
+ shadows: [],
1295
+ managed: { present: false, path: "", rulesOnly: false, bypassDisabled: false }
1296
+ };
1297
+ function toWirePolicy(policy) {
1298
+ const dirs = policy.dirs.map((d) => ({
1299
+ dir: d.dir,
1300
+ source: d.source,
1301
+ ruleCount: d.ruleCount
1302
+ }));
1303
+ return { dirs, projectSkipped: policy.projectSkipped, skippedDir: policy.skippedDir };
1304
+ }
1305
+ function toWireSecurityWorkspace(bound) {
1306
+ return {
1307
+ ...toWireSessionWorkspace(bound),
1308
+ trustLevel: bound.trust.level,
1309
+ unloadedExtraInstructionFiles: bound.unloadedInstructions
1310
+ };
1311
+ }
1312
+ function toWireTrustList(known, workspaces, currentRoot) {
1313
+ return known.map((w) => {
1314
+ const decision = workspaces.trustOf(w.root);
1315
+ return {
1316
+ root: w.root,
1317
+ name: w.name,
1318
+ level: decision.level,
1319
+ trusted: decision.trusted,
1320
+ current: currentRoot !== null && w.root === currentRoot
1321
+ };
1322
+ });
1323
+ }
1324
+ function collectSecurity(runtime, workspaces, bound, session) {
1325
+ const perms = runtime.permissions;
1326
+ return {
1327
+ // `null` 是第三种状态(沙箱没建起来),不是「仅进程隔离」
1328
+ sandbox: runtime.isolation ? toWireSandbox(runtime.isolation) : null,
1329
+ permission: perms ? {
1330
+ /*
1331
+ * **这个会话**那一档,取不到才退回进程那一份。
1332
+ *
1333
+ * 退回去的那一支不是兜底措辞,是一句真话:这个进程没在跑那段会话
1334
+ * (只剩历史 / 被冷却)时,「它此刻是哪一档」没有答案,而进程那一档
1335
+ * 是这台机器上此刻真在生效的那个。判据逐字同 `permission.ts` 的
1336
+ * `permissionState()`。⚠️ 下面四样**刻意不跟着分家**,见文件头。
1337
+ */
1338
+ level: session?.level() ?? perms.level(),
1339
+ /*
1340
+ * 配置里那一档(方案 56 §1.3)。**刻意不跟着上面那一格走会话** ——
1341
+ * 它答的是「新会话开出来是哪一档」,一个进程一份。这两格经常真的
1342
+ * 不同(在底栏改过一次档就当场分家),而那正是界面画两行的理由:
1343
+ * 只画一行的话,「此刻在跑的是 bypass」和「配置里赢在②的是 default」
1344
+ * 会被混成一句话
1345
+ */
1346
+ configured: perms.configured().level,
1347
+ configuredLayer: perms.configured().layer,
1348
+ rules: perms.rules().map(toWireRule),
1349
+ shadows: perms.shadows().map(toWireShadow),
1350
+ managed: toWireManaged(perms.managed())
1351
+ } : PERMISSIONS_UNAVAILABLE,
1352
+ workspace: bound ? toWireSecurityWorkspace(bound) : null,
1353
+ knownWorkspaces: toWireTrustList(workspaces.known(), workspaces, bound?.workspace.root ?? null),
1354
+ policy: toWirePolicy(runtime.policy),
1355
+ // 权限层起不来时是**空账**而不是别的什么:没有权限层就等于什么都没判过,
1356
+ // 而那句话是真的(同 runtime 那边 `audit()` 的兜底,两处必须一致)
1357
+ audit: perms ? toWireAudit(perms.audit()) : { rows: [], dropped: 0 }
1358
+ };
1359
+ }
1360
+ function unavailableToHttp(lang) {
1361
+ return {
1362
+ status: 503,
1363
+ code: "no-provider",
1364
+ message: t("web.session_unavailable", void 0, lang)
1365
+ };
1366
+ }
1367
+ function unknownRoleToHttp(role, lang) {
1368
+ return {
1369
+ status: 400,
1370
+ code: "unknown-role",
1371
+ message: t("web.session_unknown_role", { role }, lang)
1372
+ };
1373
+ }
1374
+
1375
+ // src/sessions/revive.ts
1376
+ function persistedDecision(row) {
1377
+ if (!row) return null;
1378
+ if (row.workspaceState === "none") return { state: "none" };
1379
+ if (row.workspaceState === "bound" && row.workspaceRoot) {
1380
+ return { state: "bound", root: row.workspaceRoot };
1381
+ }
1382
+ return null;
1383
+ }
1384
+ function reviveSession(ctx, sessionId, lang) {
1385
+ const outcome = ctx.runtime.sessionFactory.revive({
1386
+ sessionId,
1387
+ decision: persistedDecision(ctx.runtime.sessions?.get(sessionId) ?? null),
1388
+ // 绑不上那句 `detail` 跟着请求语言走,同建会话那条路(方案 58 PR-2)
1389
+ ...lang === void 0 ? {} : { lang }
1390
+ });
1391
+ if (outcome.ok) {
1392
+ ctx.hub.register(outcome.live.session);
1393
+ return { ok: true };
1394
+ }
1395
+ if (outcome.reason === "no-decision") return { ok: false, kind: "skipped" };
1396
+ if (outcome.reason === "unavailable") {
1397
+ return { ok: false, kind: "failed", ...unavailableToHttp(lang) };
1398
+ }
1399
+ return { ok: false, kind: "failed", ...bindFailureToHttp(outcome, lang) };
1400
+ }
1401
+ function toWireSettingRow(row) {
1402
+ return {
1403
+ key: row.key,
1404
+ ...row.value === void 0 ? {} : { value: row.value },
1405
+ layer: row.layer,
1406
+ chain: row.chain.map(toWireSettingStep),
1407
+ overridable: row.overridable,
1408
+ writes: row.writes.map(toWireSettingWrite)
1409
+ };
1410
+ }
1411
+ function toWireSettingStep(step) {
1412
+ return { layer: step.layer, ...step.value === void 0 ? {} : { value: step.value } };
1413
+ }
1414
+ function toWireSettingWrite(write) {
1415
+ return {
1416
+ layer: write.layer,
1417
+ path: write.path,
1418
+ ...write.futile ? { futile: toWireSettingFutile(write.futile) } : {},
1419
+ apply: write.apply
1420
+ };
1421
+ }
1422
+ function toWireSettingFutile(futile) {
1423
+ return { reason: futile.reason, ...futile.by === void 0 ? {} : { by: futile.by } };
1424
+ }
1425
+ function collectSettings(runtime, sessionId) {
1426
+ const managed = runtime.permissions?.managed();
1427
+ return {
1428
+ rows: runtime.settings.rows(sessionId).map(toWireSettingRow),
1429
+ overridableKeys: [...runtime.settings.overridableKeys()],
1430
+ managedPath: managed?.present ? managed.path : null
1431
+ };
1432
+ }
1433
+ var WRITE_FAILURE = {
1434
+ "unknown-key": { status: 400, key: "web.settings_write_unknown_key" },
1435
+ "bad-value": { status: 400, key: "web.settings_write_bad_value" },
1436
+ "unwritable-file": { status: 409, key: "web.settings_write_unwritable" },
1437
+ io: { status: 500, key: "web.settings_write_io" }
1438
+ };
1439
+ function settingWriteFailure(report) {
1440
+ const { status, key } = WRITE_FAILURE[report.reason];
1441
+ return { status, code: report.reason, key, path: report.path ?? "" };
1442
+ }
1443
+ function toWireSettingsWrite(done) {
1444
+ return {
1445
+ key: done.key,
1446
+ layer: done.layer,
1447
+ path: done.path,
1448
+ value: done.value,
1449
+ ...done.futile ? { futile: toWireSettingFutile(done.futile) } : {},
1450
+ apply: done.apply
1451
+ };
1452
+ }
1453
+ function readSettingWriteBody(body) {
1454
+ const { key, layer, value } = body;
1455
+ if (typeof key !== "string" || key.length === 0) return { ok: false };
1456
+ if (!isWireSettingWriteLayer(layer)) return { ok: false };
1457
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
1458
+ return { ok: false };
1459
+ }
1460
+ return { ok: true, key, layer, value };
1461
+ }
1462
+ function callCounts(runtime, sessionId) {
1463
+ const counts = /* @__PURE__ */ new Map();
1464
+ for (const message of runtime.sessionStore?.loadMessages(sessionId) ?? []) {
1465
+ for (const call of message.toolCalls ?? []) {
1466
+ counts.set(call.toolName, (counts.get(call.toolName) ?? 0) + 1);
1467
+ }
1468
+ }
1469
+ return counts;
1470
+ }
1471
+ function toWireGate(row) {
1472
+ return {
1473
+ verdict: row.verdict,
1474
+ by: row.by,
1475
+ ...row.rule === void 0 ? {} : { rule: row.rule },
1476
+ ...row.layer === void 0 ? {} : { layer: row.layer },
1477
+ conditional: row.conditional
1478
+ };
1479
+ }
1480
+ function collectTools(runtime, sessionId, session) {
1481
+ const counts = callCounts(runtime, sessionId);
1482
+ const raw = session?.level();
1483
+ const level = raw !== void 0 && isPermissionLevel(raw) ? raw : null;
1484
+ const gates = /* @__PURE__ */ new Map();
1485
+ if (level !== null) {
1486
+ for (const row of runtime.permissions?.gates(level) ?? []) gates.set(row.name, row);
1487
+ }
1488
+ const tools = runtime.tools.map((tool) => {
1489
+ const gate = gates.get(tool.name);
1490
+ return {
1491
+ name: tool.name,
1492
+ description: tool.description,
1493
+ source: tool.source,
1494
+ // 类型只有 `gates` 那一路知道(core 的 `mapOperation()` 三级回退)。
1495
+ // 拿不到时回 `command` ——「最保守的那一类」而不是「最常见的那一类」:
1496
+ // 这一格此刻只被界面拿去分组,而分错组的代价远小于按工具名前缀猜一个
1497
+ // (那种猜法在第一个不按前缀命名的工具上就错,判据在 `WireToolSummary.source`)
1498
+ type: gate?.type ?? "command",
1499
+ calls: counts.get(tool.name) ?? 0,
1500
+ ...gate === void 0 ? {} : { gate: toWireGate(gate) }
1501
+ };
1502
+ });
1503
+ return { tools, level: level ?? "unknown" };
1504
+ }
1505
+
1506
+ // src/api.ts
1507
+ var APPROVAL_OUTCOMES = /* @__PURE__ */ new Set([
1508
+ "allow-once",
1509
+ "allow-session",
1510
+ "allow-always",
1511
+ "deny",
1512
+ ...PLAN_OUTCOMES
1513
+ ]);
1514
+ function health(ctx, res) {
1515
+ sendJson(res, 200, { ok: true, version: ctx.version });
1516
+ }
1517
+ function config(ctx, res, lang) {
1518
+ const { runtime } = ctx;
1519
+ const bound = runtime.workspaces.of(runtime.sessionId);
1520
+ sendJson(res, 200, {
1521
+ sessionId: runtime.sessionId,
1522
+ model: runtime.config.model,
1523
+ permission: runtime.config.permission,
1524
+ workspace: bound ? toWireSessionWorkspace(bound) : null,
1525
+ knownWorkspaces: toWireKnownWorkspaces(runtime.workspaces.known()),
1526
+ usageScope: runtime.usageScope,
1527
+ // 原样转发,**一个字段都不在这儿重算**:窗口是 `AgentLoop` 手里那一份本体、
1528
+ // 阈值走的是 core 那个「装配层唯一的入口」。在这一层拿 `runtime.config` 拼一份
1529
+ // 出来,就是给同一条线第二个说法(同 `artifactsRoot` 那条判据)
1530
+ compression: runtime.compression,
1531
+ // 没配就**不带这个键**(决定 8)。写成 `?? null` 之类的话,浏览器那边
1532
+ // 「配没配上限」就要靠判空值,而金额显不显示正是挂在这一个判断上
1533
+ ...runtime.config.budget?.maxCostUsd !== void 0 ? { maxCostUsd: runtime.config.budget.maxCostUsd } : {},
1534
+ // 品牌词标同上:**没配就不带这个键**(决定 19)。侧栏那一格「显不显示」
1535
+ // 挂在这一个判断上 —— 给一个 `null` 或者空串的话,「宿主没给」就成了
1536
+ // 浏览器要自己解读的东西,而它的硬约束是不给就什么都不画
1537
+ ...runtime.config.brand !== void 0 ? { brand: runtime.config.brand } : {},
1538
+ provider: runtime.providerInfo,
1539
+ tools: runtime.tools,
1540
+ // 请求带了语言、而且宿主给得出「换个语言说一遍」时才走那条;两个条件缺一个
1541
+ // 就是 `diagnosticList`(进程语言)——判据在 `WebRuntimeView.diagnosticsIn` 上
1542
+ diagnostics: (lang && runtime.diagnosticsIn?.(lang)) ?? runtime.diagnosticList,
1543
+ about: {
1544
+ // 版本从 `ctx.version` 取 —— 那是**宿主传进来的**那一个(`/api/health` 回的
1545
+ // 也是它)。在这儿读 `package.json` 会给同一个数第二个说法,而嵌入宿主的
1546
+ // 版本号本来就不是我们的版本号
1547
+ version: ctx.version,
1548
+ nodeVersion: process.version,
1549
+ platform: `${process.platform}-${process.arch}`,
1550
+ homeDir: runtime.config.homeDir,
1551
+ dbPath: runtime.config.dbPath,
1552
+ // 缺省档(字面量 `default`)时**不带这个键** —— 判据在 `WireAbout.profile`:
1553
+ // 恒发一行「配置档:default」对没换过档的人是噪声
1554
+ ...runtime.config.profile !== "default" ? { profile: runtime.config.profile } : {}
1555
+ },
1556
+ // 绑在回环之外了没有(2026-08-18)。**原样转发 `ctx.lanExposed`,这一层不判
1557
+ // 也不改名** —— 它是一个事实,不是一个结论(判据在 `WireConfigResponse.lanExposed`
1558
+ // 上)。今天的消费方只有能力页那张「新建身份」:它据此在**按下去之前**画出
1559
+ // 「这一档下建不了」,而真正拦住那一发的是 `role-add.ts` 里那道 403
1560
+ lanExposed: ctx.lanExposed
1561
+ });
1562
+ }
1563
+ function listSessions(ctx, res, query, _lang) {
1564
+ const sessions = listSessionSummaries(
1565
+ ctx.hub.listSessions(),
1566
+ ctx.runtime.sessions,
1567
+ query ?? null,
1568
+ // 每行带上**这个进程手里**那份绑定(决定 18)—— 侧栏按「任务 / 空间」分组要用它。
1569
+ // ⚠️ 手里没有不等于那一行就说不出地盘:会话库 v7 之后落盘的那次决定会接着答
1570
+ // (顺序在 `sessions/summary.ts` 的 `toWireSummaryWorkspace` 那张表上)
1571
+ (id) => ctx.runtime.workspaces.of(id),
1572
+ // 以及它以什么身份跑(2026-08-18)。只有活着的那几行答得出 —— 身份只活在
1573
+ // 内存里。⚠️ **2026-08-19 起这句话和 `workspace` 那格不再是同一条边界**:
1574
+ // 那一格落盘了,这一格没有 —— 那是一笔明写的欠账,记在
1575
+ // `WireSessionSummary.role` 上(复活的旧会话会以 `general` 接着跑)
1576
+ (id) => ctx.runtime.sessionFactory.get(id)?.role ?? null
1577
+ );
1578
+ sendJson(res, 200, { sessions });
1579
+ }
1580
+ function getSession(ctx, res, sessionId, _lang) {
1581
+ const hub = ctx.hub.listSessions().find((one) => one.id === sessionId);
1582
+ const row = ctx.runtime.sessions?.get(sessionId) ?? null;
1583
+ if (hub === void 0 && row === null) return sendUnknownSession(res, sessionId);
1584
+ sendJson(res, 200, {
1585
+ session: toWireSummary(sessionId, hub, row, {
1586
+ workspace: ctx.runtime.workspaces.of(sessionId),
1587
+ role: ctx.runtime.sessionFactory.get(sessionId)?.role ?? null
1588
+ })
1589
+ });
1590
+ }
1591
+ async function deleteSession(ctx, res, sessionId, lang) {
1592
+ const inHub = ctx.hub.unregister(sessionId);
1593
+ ctx.runtime.sessionFactory.release(sessionId);
1594
+ ctx.runtime.workspaces.release(sessionId);
1595
+ const catalog = ctx.runtime.sessions;
1596
+ if (!catalog) {
1597
+ if (inHub) {
1598
+ return sendJson(res, 200, {
1599
+ deleted: false,
1600
+ checkpointsRemoved: false
1601
+ });
1602
+ }
1603
+ return sendError(
1604
+ res,
1605
+ 503,
1606
+ "no-session-store",
1607
+ t("web.session_store_unavailable", void 0, lang)
1608
+ );
1609
+ }
1610
+ const outcome = await catalog.delete(sessionId);
1611
+ if (!outcome.deleted && !inHub) return sendUnknownSession(res, sessionId);
1612
+ sendJson(res, 200, outcome);
1613
+ }
1614
+ async function patchSession(ctx, req, res, sessionId, lang) {
1615
+ const body = await readJsonBody(req);
1616
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
1617
+ const raw = asRecord(body.value).title;
1618
+ const title = typeof raw === "string" ? raw.trim() : "";
1619
+ if (title.length === 0) {
1620
+ return sendError(res, 400, "bad-title", t("web.title_required", void 0, lang));
1621
+ }
1622
+ const catalog = ctx.runtime.sessions;
1623
+ if (!catalog) {
1624
+ return sendError(
1625
+ res,
1626
+ 503,
1627
+ "no-session-store",
1628
+ t("web.session_store_unavailable", void 0, lang)
1629
+ );
1630
+ }
1631
+ const row = catalog.rename(sessionId, title);
1632
+ if (!row) return sendUnknownSession(res, sessionId);
1633
+ const hub = ctx.hub.listSessions().find((s) => s.id === sessionId);
1634
+ sendJson(res, 200, {
1635
+ session: toWireSummary(sessionId, hub, row, {
1636
+ workspace: ctx.runtime.workspaces.of(sessionId),
1637
+ role: ctx.runtime.sessionFactory.get(sessionId)?.role ?? null
1638
+ })
1639
+ });
1640
+ }
1641
+ function listMessages(ctx, res, sessionId, _lang) {
1642
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1643
+ return sendUnknownSession(res, sessionId);
1644
+ }
1645
+ const messages = ctx.runtime.sessionStore?.loadMessages(sessionId) ?? [];
1646
+ sendJson(res, 200, { messages });
1647
+ }
1648
+ async function postMessage(ctx, req, res, sessionId, lang) {
1649
+ if (!ctx.hub.has(sessionId)) {
1650
+ const revived = reviveSession(ctx, sessionId, lang);
1651
+ if (!revived.ok && revived.kind === "failed") {
1652
+ return sendError(res, revived.status, revived.code, revived.message);
1653
+ }
1654
+ if (!revived.ok && !ctx.hub.isCooled(sessionId)) {
1655
+ return sendUnknownSession(res, sessionId);
1656
+ }
1657
+ }
1658
+ const body = await readJsonBody(req);
1659
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
1660
+ const message = parseUserContent(asRecord(body.value).message);
1661
+ if (message === null) {
1662
+ return sendError(res, 400, "bad-message", "message \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\u6216 EpochContentPart[]");
1663
+ }
1664
+ const live = ctx.runtime.sessionFactory.get(sessionId);
1665
+ const picked = pickTurnRole(live, asRecord(body.value)["role"]);
1666
+ if (!picked.ok) {
1667
+ const mapped = unknownRoleToHttp(picked.detail, lang);
1668
+ return sendError(res, mapped.status, mapped.code, mapped.message);
1669
+ }
1670
+ const expansion = expandUserContent(ctx.runtime, message, live?.model ?? null, picked.role);
1671
+ const started = ctx.hub.start(sessionId, expansion.message, expansion.decorate);
1672
+ if (started.ok) {
1673
+ return sendJson(res, 202, {
1674
+ accepted: true,
1675
+ queued: started.queued,
1676
+ // 不是命令时**整个键缺席**(不是空对象):界面据此区分「认成命令了、
1677
+ // 只是没话要说」和「原样发给模型了」,见 `WireSendMessageResponse.command`
1678
+ ...expansion.notice ? { command: expansion.notice } : {}
1679
+ });
1680
+ }
1681
+ if (started.reason === "unknown-session") return sendUnknownSession(res, sessionId);
1682
+ if (started.reason === "cooled") {
1683
+ return sendError(res, 409, "session-cooled", t("web.session_cooled", void 0, lang));
1684
+ }
1685
+ sendError(res, 409, "busy", `\u4F1A\u8BDD\u6B63\u5728 ${started.state}\uFF0C\u6392\u961F\u5DF2\u6EE1\uFF0C\u7B49\u8FD9\u4E00\u8F6E\u7ED3\u675F\u518D\u53D1`, {
1686
+ "X-Epoch-Turn-State": started.state
1687
+ });
1688
+ }
1689
+ function pickTurnRole(live, raw) {
1690
+ const name = typeof raw === "string" ? raw.trim() : "";
1691
+ if (name === "") return { ok: true, role: null };
1692
+ if (!live?.roles.has(name)) return { ok: false, detail: name };
1693
+ return { ok: true, role: { control: live.roles, name } };
1694
+ }
1695
+ function abortSession(ctx, res, sessionId, _lang) {
1696
+ if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId);
1697
+ sendJson(res, 200, { aborted: ctx.hub.abort(sessionId) });
1698
+ }
1699
+ function listApprovals(ctx, res, sessionId, _lang) {
1700
+ if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId);
1701
+ sendJson(res, 200, {
1702
+ state: ctx.hub.stateOf(sessionId),
1703
+ approvals: ctx.hub.listPending(sessionId)
1704
+ });
1705
+ }
1706
+ async function respondApproval(ctx, req, res, requestId, _lang) {
1707
+ const body = await readJsonBody(req);
1708
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
1709
+ const payload = asRecord(body.value);
1710
+ const outcome = payload.outcome;
1711
+ if (typeof outcome !== "string" || !APPROVAL_OUTCOMES.has(outcome)) {
1712
+ return sendError(res, 400, "bad-outcome", `outcome \u5FC5\u987B\u662F ${[...APPROVAL_OUTCOMES].join("/")}`);
1713
+ }
1714
+ const note = typeof payload.note === "string" ? payload.note : void 0;
1715
+ if (!ctx.hub.respondApproval(requestId, outcome, note)) {
1716
+ return sendError(res, 404, "unknown-request", `\u5BA1\u6279 ${requestId} \u5DF2\u88AB\u7B54\u590D\u6216\u5DF2\u5931\u6548`);
1717
+ }
1718
+ sendJson(res, 200, { resolved: true });
1719
+ }
1720
+ function listQuestions(ctx, res, sessionId, _lang) {
1721
+ if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId);
1722
+ sendJson(res, 200, {
1723
+ state: ctx.hub.stateOf(sessionId),
1724
+ questions: ctx.hub.listPendingQuestions(sessionId)
1725
+ });
1726
+ }
1727
+ async function respondQuestion(ctx, req, res, requestId, _lang) {
1728
+ const body = await readJsonBody(req);
1729
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
1730
+ const payload = asRecord(body.value);
1731
+ if (!isQuestionAnswerMap(payload.answers)) {
1732
+ return sendError(
1733
+ res,
1734
+ 400,
1735
+ "bad-answers",
1736
+ 'answers \u5FC5\u987B\u662F\u300C\u95EE\u53E5 \u2192 \u5B57\u7B26\u4E32\u6216\u5B57\u7B26\u4E32\u6570\u7EC4\u300D\u7684\u5BF9\u8C61\u3002\u7B54\u4E0D\u4E0A\u6765\u5C31\u53D1 {"answers":{},"skipped":true}'
1737
+ );
1738
+ }
1739
+ const answer = {
1740
+ answers: payload.answers,
1741
+ ...payload.skipped === true ? { skipped: true } : {}
1742
+ };
1743
+ if (!ctx.hub.respondQuestion(requestId, answer)) {
1744
+ return sendError(res, 404, "unknown-request", `\u63D0\u95EE ${requestId} \u5DF2\u88AB\u7B54\u590D\u6216\u5DF2\u5931\u6548`);
1745
+ }
1746
+ sendJson(res, 200, { resolved: true });
1747
+ }
1748
+ function listCapabilities(ctx, res, sessionId, _lang) {
1749
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1750
+ return sendUnknownSession(res, sessionId);
1751
+ }
1752
+ const payload = collectCapabilities(
1753
+ ctx.runtime,
1754
+ ctx.runtime.tools,
1755
+ ctx.runtime.workspaces.of(sessionId)
1756
+ );
1757
+ sendJson(res, 200, payload);
1758
+ }
1759
+ function listCommands(ctx, res, sessionId, _lang) {
1760
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1761
+ return sendUnknownSession(res, sessionId);
1762
+ }
1763
+ const commands = ctx.runtime.commands.list.map(toWireCommand);
1764
+ sendJson(res, 200, { commands });
1765
+ }
1766
+ function getSecurity(ctx, res, sessionId, _lang) {
1767
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1768
+ return sendUnknownSession(res, sessionId);
1769
+ }
1770
+ const payload = collectSecurity(
1771
+ ctx.runtime,
1772
+ ctx.runtime.workspaces,
1773
+ ctx.runtime.workspaces.of(sessionId),
1774
+ // **档位按会话取**(2026-08-16 那一轮之后 `PermissionManager` 一个会话一份)。
1775
+ // 少了这个实参,这一屏印的是引导会话那一档,而那张卡的色带跟着一起答错人 ——
1776
+ // 判据全文在 `security.ts` 文件头。工厂手里没有(只剩历史)时传 null,
1777
+ // 投影自己退回进程那一份
1778
+ ctx.runtime.sessionFactory.get(sessionId)?.permissions ?? null
1779
+ );
1780
+ sendJson(res, 200, payload);
1781
+ }
1782
+ function getTools(ctx, res, sessionId, _lang) {
1783
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1784
+ return sendUnknownSession(res, sessionId);
1785
+ }
1786
+ const payload = collectTools(
1787
+ ctx.runtime,
1788
+ sessionId,
1789
+ ctx.runtime.sessionFactory.get(sessionId)?.permissions ?? null
1790
+ );
1791
+ sendJson(res, 200, payload);
1792
+ }
1793
+ function getSettings(ctx, res, sessionId, _lang) {
1794
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1795
+ return sendUnknownSession(res, sessionId);
1796
+ }
1797
+ sendJson(res, 200, collectSettings(ctx.runtime, sessionId));
1798
+ }
1799
+ async function writeSetting(ctx, req, res, sessionId, lang) {
1800
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1801
+ return sendUnknownSession(res, sessionId);
1802
+ }
1803
+ const body = await readJsonBody(req);
1804
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
1805
+ const input = readSettingWriteBody(asRecord(body.value));
1806
+ if (!input.ok) {
1807
+ return sendError(
1808
+ res,
1809
+ 400,
1810
+ "bad-request",
1811
+ t("web.settings_write_bad_request", { layers: WIRE_SETTING_WRITE_LAYERS.join("/") }, lang)
1812
+ );
1813
+ }
1814
+ const report = ctx.runtime.settings.write({ ...input, sessionId });
1815
+ if (!report.ok) {
1816
+ const failure = settingWriteFailure(report);
1817
+ return sendError(
1818
+ res,
1819
+ failure.status,
1820
+ failure.code,
1821
+ t(failure.key, { path: failure.path }, lang)
1822
+ );
1823
+ }
1824
+ sendJson(res, 200, toWireSettingsWrite(report));
1825
+ }
1826
+ function getArtifacts(ctx, res, sessionId, _lang) {
1827
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1828
+ return sendUnknownSession(res, sessionId);
1829
+ }
1830
+ const root = ctx.runtime.workspaces.of(sessionId)?.workspace.root ?? null;
1831
+ const changes = root === null ? [] : ctx.runtime.sessionStore?.fileChanges(sessionId, root) ?? [];
1832
+ sendJson(res, 200, { changes, root });
1833
+ }
1834
+ function getArtifact(ctx, res, sessionId, name, _lang) {
1835
+ const path = resolveArtifactPath(ctx.artifactsRoot, sessionId, name);
1836
+ if (!path) return sendError(res, 404, "not-found", "artifact \u4E0D\u5B58\u5728");
1837
+ const { type, inline } = artifactDisposition(path);
1838
+ sendFile(res, path, {
1839
+ "Content-Type": type,
1840
+ // 不认识的类型一律 attachment:让浏览器把 artifact 当 HTML 渲染,
1841
+ // 等于在本服务的同源里执行任意脚本,而这个源上挂着全部 agent API
1842
+ "Content-Disposition": inline ? "inline" : `attachment; filename="${encodeURIComponent(name)}"`
1843
+ });
1844
+ }
1845
+ function parseUserContent(raw) {
1846
+ if (typeof raw === "string") return raw.trim().length > 0 ? raw : null;
1847
+ if (Array.isArray(raw) && raw.length > 0) return raw;
1848
+ return null;
1849
+ }
1850
+ function toWireCheckpoint(summary) {
1851
+ return {
1852
+ turnIndex: summary.turnIndex,
1853
+ createdAt: summary.createdAt,
1854
+ // 用户自己那句原话,**照原样过** —— 它不是我们写的字,不进任何 catalog
1855
+ preview: summary.preview,
1856
+ fileCount: summary.fileCount,
1857
+ incomplete: summary.incomplete
1858
+ };
1859
+ }
1860
+ function toWirePlan(plan) {
1861
+ return { path: plan.path, action: plan.action, drift: plan.drift };
1862
+ }
1863
+ function toWirePreview(preview) {
1864
+ return {
1865
+ turnIndex: preview.turnIndex,
1866
+ createdAt: preview.createdAt,
1867
+ preview: preview.preview,
1868
+ // 引擎算好的那个布尔,**不在这一层从 cursor 重算** —— 镜像上压根没有 cursor
1869
+ canRewindConversation: preview.canRewindConversation,
1870
+ incomplete: preview.incomplete,
1871
+ files: preview.files.map(toWirePlan),
1872
+ conflicts: preview.conflicts.map(toWirePlan)
1873
+ };
1874
+ }
1875
+ function toWireOutcome(outcome) {
1876
+ return {
1877
+ restored: [...outcome.restored],
1878
+ deleted: [...outcome.deleted],
1879
+ recreated: [...outcome.recreated],
1880
+ skippedConflicts: [...outcome.skippedConflicts],
1881
+ // 成功时**整个不给这个键**(同 `note` / `skipped` 的规矩):契约里它就是
1882
+ // 「可以不给」,而 `failed: undefined` 会被 JSON.stringify 抹掉之后长得一样,
1883
+ // 写成条件展开是把「没有失败」这件事说明白
1884
+ ...outcome.failed === void 0 ? {} : { failed: outcome.failed }
1885
+ };
1886
+ }
1887
+ function toWireRewindResult(result) {
1888
+ return {
1889
+ files: result.files === null ? null : toWireOutcome(result.files),
1890
+ messagesRemoved: result.messagesRemoved
1891
+ };
1892
+ }
1893
+ function isTurnIndex(value) {
1894
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
1895
+ }
1896
+ function readRewindInput(body, lang) {
1897
+ const record = asRecord(body);
1898
+ const turnIndex = record["turnIndex"];
1899
+ if (!isTurnIndex(turnIndex))
1900
+ return { ok: false, code: "bad-turn", message: t("web.rewind_bad_turn", void 0, lang) };
1901
+ const scope = record["scope"];
1902
+ if (typeof scope !== "string" || !isWireRewindScope(scope)) {
1903
+ return {
1904
+ ok: false,
1905
+ code: "bad-scope",
1906
+ message: t("web.rewind_bad_scope", { scopes: WIRE_REWIND_SCOPES.join("/") }, lang)
1907
+ };
1908
+ }
1909
+ const raw = record["overwrite"];
1910
+ if (raw !== void 0 && (!Array.isArray(raw) || raw.some((p) => typeof p !== "string"))) {
1911
+ return {
1912
+ ok: false,
1913
+ code: "bad-overwrite",
1914
+ message: t("web.rewind_bad_overwrite", void 0, lang)
1915
+ };
1916
+ }
1917
+ const overwrite = raw === void 0 ? void 0 : raw;
1918
+ return {
1919
+ ok: true,
1920
+ // 空数组和「没给」在引擎那边是一回事(`new Set(opts.overwrite ?? [])`),
1921
+ // 但这里照样原样转:客户端发了空数组是它在说「一个都不覆盖」,那是一句话
1922
+ value: { turnIndex, scope, ...overwrite === void 0 ? {} : { overwrite } }
1923
+ };
1924
+ }
1925
+ function checkpointsOf(ctx, sessionId) {
1926
+ return ctx.runtime.sessionFactory.get(sessionId)?.checkpoints ?? null;
1927
+ }
1928
+ async function listCheckpoints(ctx, res, sessionId, _lang) {
1929
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1930
+ return sendUnknownSession(res, sessionId);
1931
+ }
1932
+ const control = checkpointsOf(ctx, sessionId);
1933
+ if (!control) {
1934
+ return sendJson(res, 200, { checkpoints: [] });
1935
+ }
1936
+ let checkpoints = [];
1937
+ try {
1938
+ checkpoints = await control.list();
1939
+ } catch {
1940
+ }
1941
+ sendJson(res, 200, {
1942
+ checkpoints: checkpoints.map(toWireCheckpoint)
1943
+ });
1944
+ }
1945
+ async function previewCheckpoint(ctx, res, sessionId, rawTurn, lang) {
1946
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1947
+ return sendUnknownSession(res, sessionId);
1948
+ }
1949
+ const control = checkpointsOf(ctx, sessionId);
1950
+ if (!control) {
1951
+ return sendError(res, 409, "not-live-session", t("web.rewind_not_live", void 0, lang));
1952
+ }
1953
+ const turnIndex = Number(rawTurn);
1954
+ if (!isTurnIndex(turnIndex)) {
1955
+ return sendError(res, 400, "bad-turn", t("web.rewind_bad_turn", void 0, lang));
1956
+ }
1957
+ const preview = await control.preview(turnIndex);
1958
+ if (!preview) {
1959
+ return sendError(
1960
+ res,
1961
+ 404,
1962
+ "unknown-checkpoint",
1963
+ t("web.rewind_no_checkpoint", { turn: turnIndex }, lang)
1964
+ );
1965
+ }
1966
+ sendJson(res, 200, toWirePreview(preview));
1967
+ }
1968
+ async function rewind(ctx, req, res, sessionId, lang) {
1969
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1970
+ return sendUnknownSession(res, sessionId);
1971
+ }
1972
+ const control = checkpointsOf(ctx, sessionId);
1973
+ if (!control) {
1974
+ return sendError(res, 409, "not-live-session", t("web.rewind_not_live", void 0, lang));
1975
+ }
1976
+ const body = await readJsonBody(req);
1977
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
1978
+ const input = readRewindInput(body.value, lang);
1979
+ if (!input.ok) return sendError(res, 400, input.code, input.message);
1980
+ const { turnIndex, scope, overwrite } = input.value;
1981
+ const result = await control.rewind(turnIndex, {
1982
+ scope,
1983
+ // ⚠️ **只转用户点过头的那几个**。这里绝不能顺手把 `preview.conflicts` 并进去 ——
1984
+ // 那正是「全部覆盖」那个按钮的等价物,而它毁掉的是用户自己刚写的东西
1985
+ ...overwrite === void 0 ? {} : { overwrite }
1986
+ });
1987
+ sendJson(res, 200, toWireRewindResult(result));
1988
+ }
1989
+ function readConfig(ctx, res, lang) {
1990
+ if (ctx.lanExposed) return sendLanExposed(res, lang);
1991
+ const outcome = ctx.runtime.mcp.readConfig();
1992
+ if (!outcome.ok) {
1993
+ return sendError(
1994
+ res,
1995
+ 409,
1996
+ "mcp-config-unreadable",
1997
+ t("web.mcp_config_unreadable", { detail: outcome.detail }, lang)
1998
+ );
1999
+ }
2000
+ const { snapshot } = outcome;
2001
+ sendJson(res, 200, {
2002
+ configPath: snapshot.configPath,
2003
+ text: snapshot.text,
2004
+ exists: snapshot.exists,
2005
+ revision: snapshot.revision,
2006
+ issues: toWireIssues(snapshot.issues)
2007
+ });
2008
+ }
2009
+ async function saveConfig(ctx, req, res, lang) {
2010
+ if (ctx.lanExposed) return sendLanExposed(res, lang);
2011
+ const body = await readJsonBody(req);
2012
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2013
+ const payload = asRecord(body.value);
2014
+ const text = payload["text"];
2015
+ const revision = payload["revision"];
2016
+ if (typeof text !== "string" || typeof revision !== "string") {
2017
+ return sendError(
2018
+ res,
2019
+ 400,
2020
+ "bad-mcp-config-body",
2021
+ t("web.mcp_config_bad_body", void 0, lang)
2022
+ );
2023
+ }
2024
+ const outcome = ctx.runtime.mcp.writeConfig(text, revision, lang);
2025
+ if (!outcome.ok) return sendError(res, ...saveFailure(outcome.reason, outcome.detail, lang));
2026
+ sendJson(res, 200, {
2027
+ configPath: outcome.configPath,
2028
+ revision: outcome.revision,
2029
+ issues: toWireIssues(outcome.issues)
2030
+ });
2031
+ }
2032
+ async function applyConfig(ctx, req, res, lang) {
2033
+ if (ctx.lanExposed) return sendLanExposed(res, lang);
2034
+ const body = await readJsonBody(req);
2035
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2036
+ const revision = asRecord(body.value)["revision"];
2037
+ if (typeof revision !== "string") {
2038
+ return sendError(
2039
+ res,
2040
+ 400,
2041
+ "bad-mcp-config-body",
2042
+ t("web.mcp_config_bad_body", void 0, lang)
2043
+ );
2044
+ }
2045
+ const outcome = await ctx.runtime.mcp.applyConfig(revision, lang);
2046
+ if (!outcome.ok) return sendError(res, ...applyFailure(outcome.reason, outcome.detail, lang));
2047
+ sendJson(res, 200, {
2048
+ configPath: outcome.configPath,
2049
+ revision: outcome.revision,
2050
+ issues: toWireIssues(outcome.issues),
2051
+ changes: outcome.changes.map((change) => ({
2052
+ name: change.name,
2053
+ action: change.action,
2054
+ // 投影借的是能力页那一栏**同一个函数**(`toWireMcp`):这几行会被界面
2055
+ // 直接换进那张列表,形状对不上的话它们在那一栏里长得和别的行不一样
2056
+ ...change.status === void 0 ? {} : { server: toWireMcp(change.status) },
2057
+ ...change.reason === void 0 ? {} : { reason: change.reason }
2058
+ }))
2059
+ });
2060
+ }
2061
+ function sendLanExposed(res, lang) {
2062
+ sendError(res, 403, "mcp-config-lan-exposed", t("web.mcp_config_lan_exposed", void 0, lang));
2063
+ }
2064
+ function saveFailure(reason, detail, lang) {
2065
+ if (reason === "stale") {
2066
+ return [409, "mcp-config-stale", t("web.mcp_config_stale", void 0, lang)];
2067
+ }
2068
+ if (reason === "invalid-json") return [400, "mcp-config-invalid-json", detail];
2069
+ return [409, "mcp-config-unwritable", t("web.mcp_config_unwritable", { detail }, lang)];
2070
+ }
2071
+ function applyFailure(reason, detail, lang) {
2072
+ if (reason === "unreadable") {
2073
+ return [409, "mcp-config-unreadable", t("web.mcp_config_unreadable", { detail }, lang)];
2074
+ }
2075
+ if (reason === "stale") {
2076
+ return [409, "mcp-config-stale", t("web.mcp_config_stale", void 0, lang)];
2077
+ }
2078
+ if (reason === "invalid-json") return [400, "mcp-config-invalid-json", detail];
2079
+ return [409, "mcp-config-no-servers", t("web.mcp_config_no_servers", { detail }, lang)];
2080
+ }
2081
+ function toWireIssues(issues) {
2082
+ return issues.map((issue) => ({ path: issue.path, message: issue.message }));
2083
+ }
2084
+ async function reconnectMcp(ctx, res, name, lang) {
2085
+ const outcome = await ctx.runtime.mcp.reconnect(name);
2086
+ if (!outcome.ok) {
2087
+ if (outcome.reason === "unknown") {
2088
+ return sendError(res, 404, "unknown-mcp-server", t("web.mcp_unknown_server", { name }, lang));
2089
+ }
2090
+ const status = {
2091
+ ...outcome.status,
2092
+ ...outcome.detail === void 0 ? {} : { lastError: outcome.detail }
2093
+ };
2094
+ return sendJson(res, 200, {
2095
+ ok: false,
2096
+ server: toWireMcp(status)
2097
+ });
2098
+ }
2099
+ sendJson(res, 200, {
2100
+ ok: true,
2101
+ server: toWireMcp(outcome.status)
2102
+ });
2103
+ }
2104
+ async function addMcp(ctx, req, res, lang) {
2105
+ if (ctx.lanExposed) {
2106
+ return sendError(
2107
+ res,
2108
+ 403,
2109
+ "mcp-add-lan-exposed",
2110
+ t("web.mcp_add_lan_exposed", void 0, lang)
2111
+ );
2112
+ }
2113
+ const body = await readJsonBody(req);
2114
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2115
+ const payload = asRecord(body.value);
2116
+ const name = typeof payload["name"] === "string" ? payload["name"].trim() : "";
2117
+ const transport = payload["transport"];
2118
+ if (transport !== "stdio" && transport !== "sse" && transport !== "http") {
2119
+ return sendError(res, 400, "bad-mcp-config", t("web.mcp_add_bad_transport", void 0, lang));
2120
+ }
2121
+ const outcome = await ctx.runtime.mcp.add({
2122
+ name,
2123
+ transport,
2124
+ ...typeof payload["command"] === "string" ? { command: payload["command"].trim() } : {},
2125
+ ...Array.isArray(payload["args"]) ? { args: stringList(payload["args"]) } : {},
2126
+ ...typeof payload["url"] === "string" ? { url: payload["url"].trim() } : {},
2127
+ ...payload["env"] === void 0 ? {} : { env: stringMap(payload["env"]) }
2128
+ });
2129
+ if (!outcome.ok) {
2130
+ return sendError(res, ...addFailure(outcome.reason, outcome.detail, name, lang));
2131
+ }
2132
+ sendJson(res, 200, {
2133
+ server: toWireMcp(outcome.status),
2134
+ configPath: outcome.configPath
2135
+ });
2136
+ }
2137
+ function addFailure(reason, detail, name, lang) {
2138
+ if (reason === "invalid-name") {
2139
+ return [400, "bad-mcp-name", t("web.mcp_add_bad_name", { name }, lang)];
2140
+ }
2141
+ if (reason === "invalid-config") return [400, "bad-mcp-config", detail];
2142
+ if (reason === "duplicate")
2143
+ return [409, "mcp-name-taken", t("web.mcp_add_taken", { name }, lang)];
2144
+ return [409, "mcp-config-unwritable", t("web.mcp_add_unwritable", { detail }, lang)];
2145
+ }
2146
+ function stringList(value) {
2147
+ return value.filter((v) => typeof v === "string");
2148
+ }
2149
+ function stringMap(value) {
2150
+ const out = {};
2151
+ for (const [k, v] of Object.entries(asRecord(value))) {
2152
+ if (typeof v === "string") out[k] = v;
2153
+ }
2154
+ return out;
2155
+ }
2156
+ function toRef(raw) {
2157
+ return parseModelRef(raw);
2158
+ }
2159
+ function toWireSelection(selection) {
2160
+ return { provider: selection.provider, model: selection.model, origin: selection.origin };
2161
+ }
2162
+ function sessionHasImages(session) {
2163
+ const history = session?.getHistory() ?? [];
2164
+ return history.some((m) => m.parts?.some((p) => p.type === "image"));
2165
+ }
2166
+ function getModel(ctx, res, sessionId, _lang) {
2167
+ const { runtime } = ctx;
2168
+ const selection = runtime.sessionFactory.get(sessionId)?.model.get();
2169
+ sendJson(res, 200, {
2170
+ // 问不到就是**这个进程没在跑这段会话**(只剩历史 / 被冷却了 / provider 层
2171
+ // 起不来)。三种情形合成一个 null 照旧是刻意的:界面在三档下该做的事
2172
+ // 一模一样(不画菜单)
2173
+ selection: selection ? toWireSelection(selection) : null,
2174
+ // 空串当没配。`configured` 的语义是「`reset` 会把你送回哪儿」,
2175
+ // 而一个空字符串答不了这个问题 —— 那时菜单上那一项整个不画
2176
+ configured: runtime.config.model || null
2177
+ });
2178
+ }
2179
+ async function setModel(ctx, req, res, sessionId, lang) {
2180
+ const live = ctx.runtime.sessionFactory.get(sessionId);
2181
+ if (!live) return sendUnknownSession(res, sessionId);
2182
+ const control = live.model;
2183
+ const body = await readJsonBody(req);
2184
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2185
+ const input = readModelSetBody(asRecord(body.value));
2186
+ if (!input.ok)
2187
+ return sendError(res, 400, "bad-model", t("web.model_bad_request", void 0, lang));
2188
+ const ref = input.model === null ? null : toRef(input.model);
2189
+ if (ref === void 0) {
2190
+ return sendError(res, 400, "bad-model", t("web.model_bad_request", void 0, lang));
2191
+ }
2192
+ const selectionCtx = {
2193
+ // **问的是这段会话自己的历史**(2026-08-16)。上一版读的是
2194
+ // `ctx.runtime.session` —— 引导会话那一段。这条路只对引导会话开放的时候
2195
+ // 那是对的;现在它对任意一个活着的会话开放,拿别人的历史当闸门的表现是:
2196
+ // A 那段里有图片,于是 B 换一个不认图的模型会被一道莫名其妙的闸拦下来
2197
+ hasImages: sessionHasImages(live.session),
2198
+ ...input.usedTokens === void 0 ? {} : { usedTokens: input.usedTokens }
2199
+ };
2200
+ const result = ref === null ? control.reset(selectionCtx) : control.set(ref, selectionCtx);
2201
+ const payload = result.ok ? {
2202
+ ok: true,
2203
+ selection: toWireSelection(result.selection),
2204
+ contextLength: result.contextLength,
2205
+ // **空数组也照发**,不判长度:那是界面的事。在这儿吞掉一个空数组
2206
+ // 等于让「没有代价」和「这个字段不存在」在契约上长得一样
2207
+ caveats: result.caveats
2208
+ } : { ok: false, rejection: result.rejection };
2209
+ sendJson(res, 200, payload);
2210
+ }
2211
+ function readModelSetBody(body) {
2212
+ const { model, usedTokens } = body;
2213
+ if (model !== null && (typeof model !== "string" || model.trim() === "")) return { ok: false };
2214
+ const used = typeof usedTokens === "number" && Number.isFinite(usedTokens) && usedTokens >= 0 ? Math.floor(usedTokens) : void 0;
2215
+ return {
2216
+ ok: true,
2217
+ model: model === null ? null : model.trim(),
2218
+ ...used === void 0 ? {} : { usedTokens: used }
2219
+ };
2220
+ }
2221
+ function planModeState(ctx, sessionId) {
2222
+ if (!ctx.runtime.plan) return null;
2223
+ const plan = ctx.runtime.sessionFactory.get(sessionId)?.plan;
2224
+ if (!plan) return { active: false, from: null };
2225
+ return { active: plan.active(), from: plan.from() };
2226
+ }
2227
+ async function setPlanMode(ctx, req, res, sessionId, lang) {
2228
+ const live = ctx.runtime.sessionFactory.get(sessionId);
2229
+ if (!live) return sendUnknownSession(res, sessionId);
2230
+ const plan = live.plan;
2231
+ if (!plan) {
2232
+ return sendError(res, 503, "no-plan-mode", t("web.plan_mode_unavailable", void 0, lang));
2233
+ }
2234
+ const body = await readJsonBody(req);
2235
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2236
+ const action = asRecord(body.value)["action"];
2237
+ if (typeof action !== "string" || !isWirePlanAction(action)) {
2238
+ return sendError(
2239
+ res,
2240
+ 400,
2241
+ "bad-action",
2242
+ t("web.plan_mode_bad_action", { actions: WIRE_PLAN_ACTIONS.join("/") }, lang)
2243
+ );
2244
+ }
2245
+ const changed = action === "enter" ? plan.enter().ok : plan.leave() !== null;
2246
+ sendJson(res, 200, {
2247
+ mode: { active: plan.active(), from: plan.from() },
2248
+ changed
2249
+ });
2250
+ }
2251
+
2252
+ // src/permission.ts
2253
+ var REFUSAL = {
2254
+ "managed-bypass-disabled": "managed"
2255
+ };
2256
+ function blockedLevels(managed) {
2257
+ return managed.bypassDisabled ? [{ level: "bypass", reason: "managed" }] : [];
2258
+ }
2259
+ function permissionState(ctx, sessionId) {
2260
+ const perms = ctx.runtime.sessionFactory.get(sessionId)?.permissions ?? null;
2261
+ return {
2262
+ // 权限层没起来 / 这个进程没在跑这段会话时退回 `config.permission`,
2263
+ // 那仍然是一句真话:那时配置里那一档就是这台机器上的档位
2264
+ level: perms?.level() ?? ctx.runtime.config.permission,
2265
+ // 改得动 = 这个会话在这个进程里活着、**而且**它有权限层。两种成因合成一个
2266
+ // 布尔,判据在 `WirePermissionState.editable` 上:界面在两档下要做的事
2267
+ // 一模一样(画回只读事实)
2268
+ editable: perms !== null,
2269
+ blocked: perms ? blockedLevels(perms.managed()) : [],
2270
+ planMode: planModeState(ctx, sessionId)
2271
+ };
2272
+ }
2273
+ function getPermission(ctx, res, sessionId, _lang) {
2274
+ if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId);
2275
+ sendJson(res, 200, permissionState(ctx, sessionId));
2276
+ }
2277
+ async function setPermission(ctx, req, res, sessionId, lang) {
2278
+ const live = ctx.runtime.sessionFactory.get(sessionId);
2279
+ if (!live) return sendUnknownSession(res, sessionId);
2280
+ const perms = live.permissions;
2281
+ if (!perms) {
2282
+ return sendError(
2283
+ res,
2284
+ 503,
2285
+ "no-permission-layer",
2286
+ t("web.permission_unavailable", void 0, lang)
2287
+ );
2288
+ }
2289
+ const body = await readJsonBody(req);
2290
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2291
+ const level = asRecord(body.value)["level"];
2292
+ if (typeof level !== "string" || !isPermissionLevel(level)) {
2293
+ return sendError(
2294
+ res,
2295
+ 400,
2296
+ "bad-level",
2297
+ t("web.permission_bad_level", { levels: PERMISSION_LEVELS.join("/") }, lang)
2298
+ );
2299
+ }
2300
+ const before = perms.level();
2301
+ const result = perms.setLevel(level);
2302
+ const state = permissionState(ctx, sessionId);
2303
+ const payload = result.ok ? { ok: true, changed: state.level !== before, state } : { ok: false, refused: REFUSAL[result.reason], changed: false, state };
2304
+ sendJson(res, 200, payload);
2305
+ }
2306
+
2307
+ // src/plan.ts
2308
+ function getPlan(ctx, res, sessionId, _lang) {
2309
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
2310
+ return sendUnknownSession(res, sessionId);
2311
+ }
2312
+ const live = ctx.runtime.sessionFactory.get(sessionId);
2313
+ const markdown = live ? live.plan?.approvedPlan() ?? null : ctx.runtime.sessionStore?.loadPlan(sessionId) ?? null;
2314
+ sendJson(res, 200, {
2315
+ plan: normalize(markdown),
2316
+ mode: planModeState(ctx, sessionId)
2317
+ });
2318
+ }
2319
+ function normalize(markdown) {
2320
+ return markdown?.trim() ? markdown : null;
2321
+ }
2322
+ function toWireProvider(option) {
2323
+ return {
2324
+ type: option.type,
2325
+ label: option.label,
2326
+ envVar: option.envVar,
2327
+ hasKey: option.hasKey
2328
+ };
2329
+ }
2330
+ function toWireSuggestions(view) {
2331
+ return {
2332
+ provider: view.provider,
2333
+ models: view.models,
2334
+ source: view.source,
2335
+ status: view.status
2336
+ };
2337
+ }
2338
+ function listProviders(ctx, res, _lang) {
2339
+ sendJson(res, 200, {
2340
+ providers: ctx.runtime.modelCatalog.providers().map(toWireProvider)
2341
+ });
2342
+ }
2343
+ async function discoverProviderModels(ctx, req, res, type, lang) {
2344
+ if (!isProviderType(type)) {
2345
+ return sendError(res, 404, "unknown-provider", t("web.provider_unknown", { name: type }, lang));
2346
+ }
2347
+ const body = await readJsonBody(req);
2348
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2349
+ const refresh = asRecord(body.value).refresh === true;
2350
+ const suggestions = await ctx.runtime.modelCatalog.discover(type, { refresh });
2351
+ sendJson(res, 200, {
2352
+ suggestions: toWireSuggestions(suggestions)
2353
+ });
2354
+ }
2355
+ async function addRole(ctx, req, res, lang) {
2356
+ if (ctx.lanExposed) {
2357
+ return sendError(
2358
+ res,
2359
+ 403,
2360
+ "role-add-lan-exposed",
2361
+ t("web.role_add_lan_exposed", void 0, lang)
2362
+ );
2363
+ }
2364
+ const body = await readJsonBody(req);
2365
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2366
+ const payload = asRecord(body.value);
2367
+ const outcome = ctx.runtime.roleWrite.add(
2368
+ {
2369
+ name: typeof payload["name"] === "string" ? payload["name"].trim() : "",
2370
+ description: typeof payload["description"] === "string" ? payload["description"].trim() : "",
2371
+ ...typeof payload["prompt"] === "string" ? { prompt: payload["prompt"] } : {},
2372
+ // ⚠️ `tools` 用 `Array.isArray` 而不是「有没有给」:一个空数组是**一档真实的
2373
+ // 意思**(一个工具都不给),而 `undefined` 是「不限」。两者合成同一个值之后,
2374
+ // 一个用户明确清空了工具表的身份会拿到全部工具
2375
+ ...Array.isArray(payload["tools"]) ? { tools: stringList2(payload["tools"]) } : {},
2376
+ ...typeof payload["maxTurns"] === "number" ? { maxTurns: payload["maxTurns"] } : {}
2377
+ },
2378
+ lang
2379
+ );
2380
+ if (!outcome.ok) return sendError(res, ...addFailure2(outcome.reason, outcome.detail, lang));
2381
+ sendJson(res, 200, {
2382
+ // 投影走 `toWireRole`,**和能力页那一栏同一个函数** —— 各写一份的话,
2383
+ // 刚插进列表的那一行和刷新之后那一行会在 `tools: null` / `toolsCut` 这种
2384
+ // 事上长得不一样,而那是这一屏最容易被当成 bug 的差别
2385
+ role: toWireRole(outcome.role, ctx.runtime.agentRoleNotices),
2386
+ path: outcome.path
2387
+ });
2388
+ }
2389
+ function addFailure2(reason, detail, lang) {
2390
+ if (reason === "invalid-name") return [400, "bad-role-name", detail];
2391
+ if (reason === "invalid-field")
2392
+ return [400, "bad-role-field", t("web.role_add_bad_field", { detail }, lang)];
2393
+ if (reason === "unknown-tool") return [400, "bad-role-tools", detail];
2394
+ if (reason === "duplicate") return [409, "role-name-taken", detail];
2395
+ return [409, "role-unwritable", detail];
2396
+ }
2397
+ function stringList2(value) {
2398
+ return value.filter((v) => typeof v === "string");
2399
+ }
2400
+ function stringList3(value) {
2401
+ if (!Array.isArray(value)) return null;
2402
+ const out = [];
2403
+ for (const item of value) {
2404
+ if (typeof item !== "string" || item.trim().length === 0) return null;
2405
+ out.push(item);
2406
+ }
2407
+ return out;
2408
+ }
2409
+ function operationList(value) {
2410
+ if (!Array.isArray(value)) return null;
2411
+ const out = [];
2412
+ for (const item of value) {
2413
+ if (typeof item !== "string" || !isOperationType(item)) return null;
2414
+ out.push(item);
2415
+ }
2416
+ return out;
2417
+ }
2418
+ function finite(value) {
2419
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
2420
+ }
2421
+ function parseTrigger(value) {
2422
+ const raw = asRecord(value);
2423
+ const kind = raw["kind"];
2424
+ const at = raw["at"];
2425
+ if (kind === "interval") {
2426
+ const every = finite(raw["everyMinutes"]);
2427
+ return every === null ? null : { kind: "interval", everyMinutes: every };
2428
+ }
2429
+ if (typeof at !== "string") return null;
2430
+ if (kind === "once") {
2431
+ const date = raw["date"];
2432
+ return typeof date === "string" ? { kind: "once", date, at } : null;
2433
+ }
2434
+ if (kind !== "cron") return null;
2435
+ const cycle = raw["cycle"];
2436
+ if (cycle === "daily") return { kind: "cron", cycle: "daily", at };
2437
+ if (cycle === "weekly") {
2438
+ const days = weekdayList(raw["weekdays"]);
2439
+ return days === null ? null : { kind: "cron", cycle: "weekly", at, weekdays: days };
2440
+ }
2441
+ if (cycle === "monthly") {
2442
+ const days = numberList(raw["days"]);
2443
+ return days === null ? null : { kind: "cron", cycle: "monthly", at, days };
2444
+ }
2445
+ return null;
2446
+ }
2447
+ function weekdayList(value) {
2448
+ if (!Array.isArray(value)) return null;
2449
+ const out = [];
2450
+ for (const item of value) {
2451
+ if (typeof item !== "number" || !Number.isInteger(item) || item < 0 || item > 6) return null;
2452
+ out.push(item);
2453
+ }
2454
+ return out;
2455
+ }
2456
+ function numberList(value) {
2457
+ if (!Array.isArray(value)) return null;
2458
+ const out = [];
2459
+ for (const item of value) {
2460
+ if (typeof item !== "number" || !Number.isInteger(item)) return null;
2461
+ out.push(item);
2462
+ }
2463
+ return out;
2464
+ }
2465
+ function optionals(raw, lang) {
2466
+ const out = {};
2467
+ for (const key of ["name", "prompt", "model", "workDir", "startDate", "endDate"]) {
2468
+ const value = raw[key];
2469
+ if (value === void 0) continue;
2470
+ if (value === null) {
2471
+ if (key === "name" || key === "prompt") {
2472
+ return { ok: false, message: t("web.schedule_bad_field", { field: key }, lang) };
2473
+ }
2474
+ out[key] = "";
2475
+ continue;
2476
+ }
2477
+ if (typeof value !== "string") {
2478
+ return { ok: false, message: t("web.schedule_bad_field", { field: key }, lang) };
2479
+ }
2480
+ out[key] = value;
2481
+ }
2482
+ for (const key of ["maxTurns", "maxBudgetUsd", "timeoutMs"]) {
2483
+ const value = raw[key];
2484
+ if (value === void 0) continue;
2485
+ const num = finite(value);
2486
+ if (num === null)
2487
+ return { ok: false, message: t("web.schedule_bad_field", { field: key }, lang) };
2488
+ out[key] = num;
2489
+ }
2490
+ for (const key of ["allowTools", "allowRules"]) {
2491
+ const value = raw[key];
2492
+ if (value === void 0) continue;
2493
+ const list = stringList3(value);
2494
+ if (list === null)
2495
+ return { ok: false, message: t("web.schedule_bad_field", { field: key }, lang) };
2496
+ out[key] = list;
2497
+ }
2498
+ if (raw["allowOperations"] !== void 0) {
2499
+ const ops = operationList(raw["allowOperations"]);
2500
+ if (ops === null) {
2501
+ return {
2502
+ ok: false,
2503
+ message: t("web.schedule_bad_operation", { types: OPERATION_TYPES.join("/") }, lang)
2504
+ };
2505
+ }
2506
+ out.allowOperations = ops;
2507
+ }
2508
+ if (raw["enabled"] !== void 0) {
2509
+ if (typeof raw["enabled"] !== "boolean") {
2510
+ return { ok: false, message: t("web.schedule_bad_field", { field: "enabled" }, lang) };
2511
+ }
2512
+ out.enabled = raw["enabled"];
2513
+ }
2514
+ if (raw["permission"] !== void 0) {
2515
+ const level = raw["permission"];
2516
+ if (typeof level !== "string" || !isPermissionLevel(level)) {
2517
+ return { ok: false, message: t("web.schedule_bad_permission", void 0, lang) };
2518
+ }
2519
+ out.permission = level;
2520
+ }
2521
+ if (raw["trigger"] !== void 0) {
2522
+ const trigger = parseTrigger(raw["trigger"]);
2523
+ if (trigger === null)
2524
+ return { ok: false, message: t("web.schedule_bad_trigger", void 0, lang) };
2525
+ out.trigger = trigger;
2526
+ }
2527
+ return { ok: true, value: out };
2528
+ }
2529
+ function bypassAcknowledged(body) {
2530
+ return asRecord(body)["bypassAcknowledged"] === true;
2531
+ }
2532
+ function parseCreate(body, lang) {
2533
+ const raw = asRecord(body);
2534
+ const name = raw["name"];
2535
+ const prompt = raw["prompt"];
2536
+ const permission = raw["permission"];
2537
+ const budget = finite(raw["maxBudgetUsd"]);
2538
+ if (typeof name !== "string" || typeof prompt !== "string") {
2539
+ return { ok: false, message: t("web.schedule_create_incomplete", void 0, lang) };
2540
+ }
2541
+ if (typeof permission !== "string" || !isPermissionLevel(permission)) {
2542
+ return { ok: false, message: t("web.schedule_bad_permission", void 0, lang) };
2543
+ }
2544
+ const trigger = parseTrigger(raw["trigger"]);
2545
+ if (trigger === null)
2546
+ return { ok: false, message: t("web.schedule_bad_trigger", void 0, lang) };
2547
+ if (budget === null)
2548
+ return { ok: false, message: t("web.schedule_budget_required", void 0, lang) };
2549
+ const rest = optionals(raw, lang);
2550
+ if (!rest.ok) return rest;
2551
+ return {
2552
+ ok: true,
2553
+ // 顺序有意义:`rest` 里那几格已经收窄过,五个必填的**后写**,
2554
+ // 于是一个把 `name` 同时写在两处的请求体不会得到两个答案
2555
+ value: { ...rest.value, name, prompt, permission, trigger, maxBudgetUsd: budget }
2556
+ };
2557
+ }
2558
+ function parseUpdate(body, lang) {
2559
+ return optionals(asRecord(body), lang);
2560
+ }
2561
+ function parseFix(body, lang) {
2562
+ const runId = asRecord(body)["runId"];
2563
+ if (typeof runId !== "string" || runId.trim().length === 0) {
2564
+ return { ok: false, message: t("web.schedule_fix_needs_run", void 0, lang) };
2565
+ }
2566
+ return { ok: true, value: runId };
2567
+ }
2568
+
2569
+ // src/schedule/handlers.ts
2570
+ var RECENT_RUNS_LIMIT = 100;
2571
+ var RUNS_LIMIT = 20;
2572
+ var MAX_RECORDING_FRAMES = 4e3;
2573
+ function schedulesOf(ctx) {
2574
+ return ctx.runtime.schedules;
2575
+ }
2576
+ function toWireRow(control, def) {
2577
+ return {
2578
+ schedule: def,
2579
+ nextRunAt: def.enabled ? nextRunAt(def.trigger, Date.now(), def.startDate, def.endDate) : null,
2580
+ allowlistApplies: control.needsAllowlist(def.permission)
2581
+ };
2582
+ }
2583
+ function defaultsOf() {
2584
+ return {
2585
+ maxTurns: SCHEDULE_DEFAULTS.maxTurns,
2586
+ timeoutMs: SCHEDULE_DEFAULTS.timeoutMs,
2587
+ allowTools: SCHEDULE_DEFAULT_ALLOWLIST.allowTools,
2588
+ allowOperations: SCHEDULE_DEFAULT_ALLOWLIST.allowOperations,
2589
+ allowRules: SCHEDULE_DEFAULT_ALLOWLIST.allowRules
2590
+ };
2591
+ }
2592
+ async function listSchedules(ctx, res, _lang) {
2593
+ const control = schedulesOf(ctx);
2594
+ const payload = {
2595
+ schedules: control.list().map((def) => toWireRow(control, def)),
2596
+ capability: await control.capability(),
2597
+ defaults: defaultsOf(),
2598
+ recentRuns: control.recentRuns(RECENT_RUNS_LIMIT)
2599
+ };
2600
+ sendJson(res, 200, payload);
2601
+ }
2602
+ function getSchedule(ctx, res, id, lang) {
2603
+ const control = schedulesOf(ctx);
2604
+ const def = control.get(id);
2605
+ if (!def) return sendNotFound(res, id, lang);
2606
+ sendJson(res, 200, { row: toWireRow(control, def) });
2607
+ }
2608
+ function listRuns(ctx, res, id, lang) {
2609
+ const control = schedulesOf(ctx);
2610
+ if (!control.get(id)) return sendNotFound(res, id, lang);
2611
+ sendJson(res, 200, { runs: control.runs(id, RUNS_LIMIT) });
2612
+ }
2613
+ async function createSchedule(ctx, req, res, lang) {
2614
+ const body = await readJsonBody(req);
2615
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2616
+ const parsed = parseCreate(body.value, lang);
2617
+ if (!parsed.ok) return sendError(res, 400, "bad-schedule", parsed.message);
2618
+ const control = schedulesOf(ctx);
2619
+ const saved = await control.create(parsed.value, {
2620
+ bypassAcknowledged: bypassAcknowledged(body.value)
2621
+ });
2622
+ sendJson(res, 200, toSaveResponse(control, saved));
2623
+ }
2624
+ async function patchSchedule(ctx, req, res, id, lang) {
2625
+ const control = schedulesOf(ctx);
2626
+ if (!control.get(id)) return sendNotFound(res, id, lang);
2627
+ const body = await readJsonBody(req);
2628
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2629
+ const parsed = parseUpdate(body.value, lang);
2630
+ if (!parsed.ok) return sendError(res, 400, "bad-schedule", parsed.message);
2631
+ const saved = await control.update(id, parsed.value, {
2632
+ bypassAcknowledged: bypassAcknowledged(body.value)
2633
+ });
2634
+ sendJson(res, 200, toSaveResponse(control, saved));
2635
+ }
2636
+ async function deleteSchedule(ctx, res, id, _lang) {
2637
+ const removed = await schedulesOf(ctx).remove(id);
2638
+ sendJson(res, 200, { removed });
2639
+ }
2640
+ async function runSchedule(ctx, res, id, lang) {
2641
+ const control = schedulesOf(ctx);
2642
+ if (!control.get(id)) return sendNotFound(res, id, lang);
2643
+ const result = await control.fire(id, { manual: true });
2644
+ const payload = {
2645
+ status: result.status,
2646
+ exitCode: result.exitCode,
2647
+ ...result.run ? { run: result.run } : {},
2648
+ ...result.detail === void 0 ? {} : { detail: result.detail }
2649
+ };
2650
+ sendJson(res, 200, payload);
2651
+ }
2652
+ async function fixSchedule(ctx, req, res, id, lang) {
2653
+ const control = schedulesOf(ctx);
2654
+ const def = control.get(id);
2655
+ if (!def) return sendNotFound(res, id, lang);
2656
+ const body = await readJsonBody(req);
2657
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2658
+ const parsed = parseFix(body.value, lang);
2659
+ if (!parsed.ok) return sendError(res, 400, "bad-schedule", parsed.message);
2660
+ const run = control.runs(id, RUNS_LIMIT).find((one) => one.id === parsed.value);
2661
+ if (!run) {
2662
+ return sendError(
2663
+ res,
2664
+ 404,
2665
+ "not-found",
2666
+ t("web.schedule_run_unknown", { runId: parsed.value }, lang)
2667
+ );
2668
+ }
2669
+ const existing = new Set(def.allowRules);
2670
+ const added = [];
2671
+ for (const item of run.pendingApprovals) {
2672
+ if (!item.suggestedRule || existing.has(item.suggestedRule)) continue;
2673
+ existing.add(item.suggestedRule);
2674
+ added.push(item.suggestedRule);
2675
+ }
2676
+ if (added.length === 0) {
2677
+ return sendJson(res, 200, {
2678
+ added: [],
2679
+ row: toWireRow(control, def)
2680
+ });
2681
+ }
2682
+ const saved = await control.update(id, { allowRules: [...existing] });
2683
+ const next = saved.schedule ?? def;
2684
+ sendJson(res, 200, {
2685
+ added,
2686
+ row: toWireRow(control, next)
2687
+ });
2688
+ }
2689
+ function getRecording(ctx, res, id, runId, lang) {
2690
+ const control = schedulesOf(ctx);
2691
+ if (!control.get(id)) return sendNotFound(res, id, lang);
2692
+ const run = control.runs(id, RUNS_LIMIT).find((one) => one.id === runId);
2693
+ if (!run) {
2694
+ return sendError(res, 404, "not-found", t("web.schedule_run_unknown", { runId }, lang));
2695
+ }
2696
+ sendJson(res, 200, toRecording(run));
2697
+ }
2698
+ function toRecording(run) {
2699
+ if (run.logPath === void 0) {
2700
+ return { runId: run.id, frames: [], missing: true, truncated: false };
2701
+ }
2702
+ const frames = readRecording(run.logPath);
2703
+ if (frames.length === 0) {
2704
+ return { runId: run.id, frames: [], missing: true, truncated: false };
2705
+ }
2706
+ const truncated = frames.length > MAX_RECORDING_FRAMES;
2707
+ return {
2708
+ runId: run.id,
2709
+ frames: truncated ? frames.slice(0, MAX_RECORDING_FRAMES) : frames,
2710
+ missing: false,
2711
+ truncated
2712
+ };
2713
+ }
2714
+ function toSaveResponse(control, saved) {
2715
+ return {
2716
+ ok: saved.issues.length === 0,
2717
+ issues: saved.issues,
2718
+ ...saved.schedule ? { row: toWireRow(control, saved.schedule) } : {},
2719
+ ...saved.registration ? { registration: saved.registration } : {}
2720
+ };
2721
+ }
2722
+ function sendNotFound(res, id, lang) {
2723
+ sendError(res, 404, "unknown-schedule", t("web.schedule_unknown", { id }, lang));
2724
+ }
2725
+
2726
+ // src/skill-import.ts
2727
+ function pathOf(payload) {
2728
+ return typeof payload["path"] === "string" ? payload["path"].trim() : "";
2729
+ }
2730
+ function toWireIssues2(issues) {
2731
+ return (issues ?? []).map((i) => ({ path: i.path, message: i.message }));
2732
+ }
2733
+ function toWireEntry(entry) {
2734
+ return {
2735
+ name: entry.name,
2736
+ category: entry.category,
2737
+ description: entry.description,
2738
+ files: entry.files,
2739
+ bytes: entry.bytes,
2740
+ conflict: entry.conflict ?? null
2741
+ };
2742
+ }
2743
+ async function previewSkillImport(ctx, req, res, lang) {
2744
+ const body = await readJsonBody(req);
2745
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2746
+ const source = pathOf(asRecord(body.value));
2747
+ const outcome = await ctx.runtime.skillImport.preview(source, lang);
2748
+ if (!outcome.ok) {
2749
+ return sendJson(res, 200, {
2750
+ ok: false,
2751
+ source,
2752
+ form: null,
2753
+ skills: [],
2754
+ issues: toWireIssues2(outcome.issues),
2755
+ token: "",
2756
+ detail: outcome.detail,
2757
+ reason: outcome.reason
2758
+ });
2759
+ }
2760
+ const preview = outcome.preview;
2761
+ sendJson(res, 200, {
2762
+ ok: true,
2763
+ source: preview.source,
2764
+ form: preview.form,
2765
+ skills: preview.skills.map(toWireEntry),
2766
+ issues: toWireIssues2(preview.issues),
2767
+ token: preview.token,
2768
+ detail: null,
2769
+ reason: null
2770
+ });
2771
+ }
2772
+ async function importSkills(ctx, req, res, lang) {
2773
+ const body = await readJsonBody(req);
2774
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2775
+ const payload = asRecord(body.value);
2776
+ const source = pathOf(payload);
2777
+ const token = typeof payload["token"] === "string" ? payload["token"] : "";
2778
+ const outcome = await ctx.runtime.skillImport.import(source, token, lang);
2779
+ if (!outcome.ok) {
2780
+ return sendJson(res, 200, {
2781
+ ok: false,
2782
+ imported: [],
2783
+ skipped: toWireIssues2(outcome.issues),
2784
+ detail: outcome.detail,
2785
+ reason: outcome.reason
2786
+ });
2787
+ }
2788
+ sendJson(res, 200, {
2789
+ // 一个都没落地时**回 `ok: false`**:这一格答的是「这次导入达成了没有」,
2790
+ // 而不是「这次请求跑通了没有」。全都撞名的那一次要是回 true,界面上会出现
2791
+ // 一句「导入成功」加一张空列表 —— 判据同 `WireMcpReconnectResponse.ok`
2792
+ // 那条「别从别的字段反推」
2793
+ ok: outcome.imported.length > 0,
2794
+ imported: [...outcome.imported],
2795
+ skipped: toWireIssues2(outcome.skipped),
2796
+ detail: null,
2797
+ reason: null
2798
+ });
2799
+ }
2800
+
2801
+ // src/sse.ts
2802
+ var DEFAULT_HEARTBEAT_MS = 15e3;
2803
+ function parseLastEventId(req) {
2804
+ const raw = req.headers["last-event-id"];
2805
+ const value = Array.isArray(raw) ? raw[0] : raw;
2806
+ if (typeof value !== "string" || value.trim().length === 0) return void 0;
2807
+ const parsed = Number(value.trim());
2808
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : void 0;
2809
+ }
2810
+ var SseConnection = class {
2811
+ constructor(res, heartbeatMs = DEFAULT_HEARTBEAT_MS) {
2812
+ this.res = res;
2813
+ res.writeHead(200, {
2814
+ "Content-Type": "text/event-stream; charset=utf-8",
2815
+ "Cache-Control": "no-cache, no-transform",
2816
+ Connection: "keep-alive",
2817
+ "X-Accel-Buffering": "no",
2818
+ "Referrer-Policy": "no-referrer",
2819
+ "X-Content-Type-Options": "nosniff"
2820
+ });
2821
+ res.flushHeaders();
2822
+ this.heartbeat = setInterval(() => this.ping(), heartbeatMs);
2823
+ this.heartbeat.unref();
2824
+ }
2825
+ res;
2826
+ heartbeat;
2827
+ closed = false;
2828
+ /**
2829
+ * 发一帧。
2830
+ *
2831
+ * **`seq: 0` 不写 `id:` 行**:连接级帧(`connected` / `stream-reset`)不占广播
2832
+ * 序号,写进 `id:` 就会把这个连接的续传游标推到 0,下次重连时 `Last-Event-ID: 0`
2833
+ * 等于说「我什么都没收到」。
2834
+ */
2835
+ send(frame) {
2836
+ if (this.closed) return;
2837
+ const body = frame.seq > 0 ? `id: ${frame.seq}
2838
+ ` : "";
2839
+ this.res.write(`${body}data: ${JSON.stringify(frame)}
2840
+
2841
+ `);
2842
+ }
2843
+ /** 注释行心跳:SSE 规范里以 `:` 开头的行被客户端忽略,纯粹用来探活 */
2844
+ ping() {
2845
+ if (this.closed) return;
2846
+ this.res.write(": ping\n\n");
2847
+ }
2848
+ /** 幂等:客户端断开和服务端 close 会各调一次 */
2849
+ close() {
2850
+ if (this.closed) return;
2851
+ this.closed = true;
2852
+ clearInterval(this.heartbeat);
2853
+ this.res.end();
2854
+ }
2855
+ };
2856
+ var TASK_TAIL_BYTES = 8 * 1024;
2857
+ var LIVE_TASKS = {
2858
+ list: (sessionId) => listBackgroundTasks(sessionId),
2859
+ output: (sessionId, id, since) => backgroundTaskOutput(sessionId, id, since)
2860
+ };
2861
+ function toWireTask(sessionId, info, registry) {
2862
+ const since = Math.max(0, info.outputBytes - TASK_TAIL_BYTES);
2863
+ const chunk = registry.output(sessionId, info.id, since);
2864
+ if (!chunk) return { info, output: "", omittedBytes: info.outputBytes };
2865
+ return {
2866
+ info,
2867
+ output: chunk.output,
2868
+ omittedBytes: Math.max(0, chunk.nextCursor - chunk.output.length)
2869
+ };
2870
+ }
2871
+ function collectTasks(sessionId, registry) {
2872
+ return { tasks: registry.list(sessionId).map((info) => toWireTask(sessionId, info, registry)) };
2873
+ }
2874
+ function getTasks(ctx, res, sessionId, _lang, registry = LIVE_TASKS) {
2875
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
2876
+ return sendUnknownSession(res, sessionId);
2877
+ }
2878
+ sendJson(res, 200, collectTasks(sessionId, registry));
2879
+ }
2880
+ var DIR_PAGE_LIMIT = 1e3;
2881
+ function workspaceAnchors(workspaces) {
2882
+ const seen = /* @__PURE__ */ new Set();
2883
+ const entries = [];
2884
+ const known = new Set(workspaces.known().map((one) => resolve(one.root)));
2885
+ const push = (raw, group) => {
2886
+ const path = resolve(raw);
2887
+ if (seen.has(path)) return;
2888
+ seen.add(path);
2889
+ entries.push({
2890
+ name: displayName2(path),
2891
+ path,
2892
+ known: known.has(path),
2893
+ readable: canList(path),
2894
+ group
2895
+ });
2896
+ };
2897
+ push(homedir(), "home");
2898
+ push(process.cwd(), "cwd");
2899
+ for (const one of workspaces.known()) push(one.root, "known");
2900
+ return {
2901
+ // 锚屏不是一个目录,所以这两格是 null(判据在契约那两个字段上)
2902
+ path: null,
2903
+ parent: null,
2904
+ entries,
2905
+ // 锚这一发**不截断**:`known` 上游就是 ≤20 条,加上 home / cwd 也就 22 行
2906
+ omitted: 0,
2907
+ // 锚这一发也**不过滤**:三组锚是「用户自己去过的地方」,`~/.config/some-tree`
2908
+ // 真在已知清单里的话它照样画出来 —— 那个开关管的是「往下点一层」看得见什么
2909
+ hidden: 0
2910
+ };
2911
+ }
2912
+ function listWorkspaceDirs(workspaces, raw, opts = {}) {
2913
+ if (raw.length === 0 || !isAbsolute(raw)) {
2914
+ return { ok: false, reason: "not-absolute", path: raw };
2915
+ }
2916
+ const path = resolve(raw);
2917
+ let dirents;
2918
+ try {
2919
+ dirents = readdirSync(path, { withFileTypes: true });
2920
+ } catch (cause) {
2921
+ return { ok: false, reason: readdirFailure(cause), path };
2922
+ }
2923
+ const known = new Set(workspaces.known().map((one) => resolve(one.root)));
2924
+ const kept = [];
2925
+ let omitted = 0;
2926
+ let hidden = 0;
2927
+ for (const dirent of dirents) {
2928
+ if (!dirent.isDirectory()) continue;
2929
+ if (!opts.hidden && dirent.name.startsWith(".")) {
2930
+ hidden += 1;
2931
+ continue;
2932
+ }
2933
+ if (kept.length >= DIR_PAGE_LIMIT) {
2934
+ omitted += 1;
2935
+ continue;
2936
+ }
2937
+ const child = resolve(path, dirent.name);
2938
+ kept.push({
2939
+ name: dirent.name,
2940
+ path: child,
2941
+ known: known.has(child),
2942
+ // 列不开的行**照样画出来**,只是点不动 —— 藏掉的话用户会以为它不存在
2943
+ readable: canList(child),
2944
+ group: "child"
2945
+ });
2946
+ }
2947
+ return {
2948
+ ok: true,
2949
+ view: {
2950
+ path,
2951
+ // 已经在根上时 `dirname` 回它自己 —— 那时没有「上一层」可去
2952
+ parent: parentOf(path),
2953
+ entries: kept,
2954
+ omitted,
2955
+ // 带 `?hidden=1` 那一发这一格必然是 0:那时上面那一支根本不进
2956
+ hidden
2957
+ }
2958
+ };
2959
+ }
2960
+ function readdirFailure(cause) {
2961
+ const code = cause?.code;
2962
+ if (code === "EACCES" || code === "EPERM") return "denied";
2963
+ if (code === "ENOTDIR") return "not-a-directory";
2964
+ return "missing";
2965
+ }
2966
+ function canList(path) {
2967
+ try {
2968
+ accessSync(path, constants.R_OK | constants.X_OK);
2969
+ return true;
2970
+ } catch {
2971
+ return false;
2972
+ }
2973
+ }
2974
+ function parentOf(path) {
2975
+ const up = dirname(path);
2976
+ return up === path ? null : up;
2977
+ }
2978
+ function displayName2(path) {
2979
+ return basename(path) || path;
2980
+ }
2981
+
2982
+ // src/workspace/dirs.ts
2983
+ function browseWorkspaceDirs(ctx, res, rawUrl, lang) {
2984
+ const query = new URL(rawUrl ?? "/", "http://127.0.0.1").searchParams;
2985
+ const path = dirsQuery(query);
2986
+ if (path === void 0) {
2987
+ return sendJson(res, 200, workspaceAnchors(ctx.runtime.workspaces));
2988
+ }
2989
+ const outcome = listWorkspaceDirs(ctx.runtime.workspaces, path, {
2990
+ hidden: hiddenQuery(query)
2991
+ });
2992
+ if (outcome.ok) return sendJson(res, 200, outcome.view);
2993
+ if (outcome.reason === "denied") {
2994
+ return sendError(
2995
+ res,
2996
+ 403,
2997
+ "workspace-denied",
2998
+ t("web.dirs_denied", { path: outcome.path }, lang)
2999
+ );
3000
+ }
3001
+ if (outcome.reason === "missing") {
3002
+ return sendError(res, 404, "not-found", t("web.dirs_missing", { path: outcome.path }, lang));
3003
+ }
3004
+ sendError(
3005
+ res,
3006
+ 400,
3007
+ "bad-workspace",
3008
+ outcome.reason === "not-absolute" ? t("web.dirs_not_absolute", { path: outcome.path }, lang) : t("web.dirs_not_a_directory", { path: outcome.path }, lang)
3009
+ );
3010
+ }
3011
+ function dirsQuery(query) {
3012
+ const value = query.get("path")?.trim();
3013
+ return value ? value : void 0;
3014
+ }
3015
+ function hiddenQuery(query) {
3016
+ const value = query.get("hidden")?.trim().toLowerCase();
3017
+ return value === "1" || value === "true";
3018
+ }
3019
+ async function createWorkspaceDir(req, res, lang) {
3020
+ const body = await readJsonBody(req);
3021
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3022
+ const payload = asRecord(body.value);
3023
+ const parent = typeof payload["parent"] === "string" ? payload["parent"].trim() : "";
3024
+ const name = typeof payload["name"] === "string" ? payload["name"].trim() : "";
3025
+ if (parent.length === 0 || !isAbsolute(parent)) {
3026
+ return sendError(
3027
+ res,
3028
+ 400,
3029
+ "bad-workspace",
3030
+ t("web.mkdir_parent_not_absolute", { parent }, lang)
3031
+ );
3032
+ }
3033
+ if (!isSingleSegment(name)) {
3034
+ return sendError(res, 400, "bad-workspace", t("web.mkdir_bad_name", { name }, lang));
3035
+ }
3036
+ const path = resolve(parent, name);
3037
+ try {
3038
+ mkdirSync(path);
3039
+ } catch (cause) {
3040
+ return sendError(res, ...mkdirFailure(cause, path, lang));
3041
+ }
3042
+ sendJson(res, 200, {
3043
+ entry: {
3044
+ // `basename` 就是 `name`(上面校验过它是单段),这儿不再算一次
3045
+ name,
3046
+ path,
3047
+ // 两个字段**钉死**,判据在 `WireCreateWorkspaceResponse` 上:刚建的空目录
3048
+ // 必然读得开,而且它**不在**「已知工作区」清单里(这条路不记那一笔)
3049
+ readable: true,
3050
+ known: false,
3051
+ group: "child"
3052
+ }
3053
+ });
3054
+ }
3055
+ function isSingleSegment(name) {
3056
+ if (name.length === 0) return false;
3057
+ if (name === "." || name === "..") return false;
3058
+ return !name.includes("/") && !name.includes(sep);
3059
+ }
3060
+ function mkdirFailure(cause, path, lang) {
3061
+ const code = cause?.code;
3062
+ if (code === "EEXIST") return [409, "workspace-exists", t("web.mkdir_exists", { path }, lang)];
3063
+ if (code === "EACCES" || code === "EPERM") {
3064
+ return [403, "workspace-denied", t("web.mkdir_denied", { path }, lang)];
3065
+ }
3066
+ if (code === "ENOENT") return [404, "not-found", t("web.mkdir_parent_missing", { path }, lang)];
3067
+ return [500, "internal", t("web.mkdir_failed", { path }, lang)];
3068
+ }
3069
+ var GIT_TIMEOUT_MS = 1e4;
3070
+ var GIT_MAX_BUFFER = 16 * 1024 * 1024;
3071
+ function runGit(cwd, args) {
3072
+ return new Promise((resolve4) => {
3073
+ execFile(
3074
+ "git",
3075
+ [...args],
3076
+ { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, encoding: "buffer" },
3077
+ (err, stdout) => {
3078
+ if (err) return resolve4({ ok: false, error: err.message });
3079
+ resolve4({ ok: true, stdout });
3080
+ }
3081
+ );
3082
+ });
3083
+ }
3084
+ async function repoRoot(cwd) {
3085
+ const result = await runGit(cwd, ["rev-parse", "--show-toplevel"]);
3086
+ if (!result.ok) return null;
3087
+ const root = result.stdout.toString("utf-8").trim();
3088
+ return root.length > 0 ? root : null;
3089
+ }
3090
+ async function hasHead(root) {
3091
+ return (await runGit(root, ["rev-parse", "--verify", "--quiet", "HEAD"])).ok;
3092
+ }
3093
+ var NULL_SHA = /^0+$/;
3094
+ async function rawChanges(root) {
3095
+ const result = await runGit(root, ["diff", "--raw", "-z", "HEAD", "--"]);
3096
+ if (!result.ok) return [];
3097
+ return parseRawZ(result.stdout.toString("utf-8"));
3098
+ }
3099
+ function parseRawZ(text) {
3100
+ const tokens = text.split("\0");
3101
+ const changes = [];
3102
+ let i = 0;
3103
+ while (i < tokens.length) {
3104
+ const meta = tokens[i];
3105
+ if (meta === void 0 || !meta.startsWith(":")) {
3106
+ i += 1;
3107
+ continue;
3108
+ }
3109
+ const parts = meta.slice(1).split(" ");
3110
+ const oldSha = parts[2] ?? "";
3111
+ const status = (parts[4] ?? "").trim();
3112
+ const renamed = status.startsWith("R") || status.startsWith("C");
3113
+ const first = tokens[i + 1];
3114
+ const second = renamed ? tokens[i + 2] : void 0;
3115
+ i += renamed ? 3 : 2;
3116
+ if (first === void 0 || renamed && second === void 0) break;
3117
+ const path = renamed ? second : first;
3118
+ changes.push({
3119
+ status: status.charAt(0),
3120
+ path,
3121
+ ...renamed ? { oldPath: first } : {},
3122
+ oldSha: NULL_SHA.test(oldSha) ? null : oldSha
3123
+ });
3124
+ }
3125
+ return changes;
3126
+ }
3127
+ async function treeBlobSizes(root) {
3128
+ const sizes = /* @__PURE__ */ new Map();
3129
+ const result = await runGit(root, ["ls-tree", "-r", "-l", "-z", "HEAD"]);
3130
+ if (!result.ok) return sizes;
3131
+ for (const record of splitZ(result.stdout.toString("utf-8"))) {
3132
+ const tab = record.indexOf(" ");
3133
+ if (tab < 0) continue;
3134
+ const columns = record.slice(0, tab).split(/\s+/).filter(Boolean);
3135
+ const sha = columns[2];
3136
+ const size = Number(columns[3]);
3137
+ if (columns[1] === "blob" && sha && Number.isFinite(size)) sizes.set(sha, size);
3138
+ }
3139
+ return sizes;
3140
+ }
3141
+ async function untrackedFiles(root) {
3142
+ const result = await runGit(root, ["ls-files", "-z", "--others", "--exclude-standard"]);
3143
+ if (!result.ok) return [];
3144
+ return splitZ(result.stdout.toString("utf-8"));
3145
+ }
3146
+ async function trackedFiles(root) {
3147
+ const result = await runGit(root, ["ls-files", "-z"]);
3148
+ if (!result.ok) return [];
3149
+ return splitZ(result.stdout.toString("utf-8"));
3150
+ }
3151
+ async function catFileBlob(root, sha) {
3152
+ const result = await runGit(root, ["cat-file", "blob", sha]);
3153
+ return result.ok ? result.stdout : null;
3154
+ }
3155
+ function splitZ(text) {
3156
+ return text.split("\0").filter((s) => s.length > 0);
3157
+ }
3158
+
3159
+ // src/workspace/diff.ts
3160
+ var MAX_DIFF_BYTES = 512 * 1024;
3161
+ var MAX_DIFF_FILES = 200;
3162
+ var BINARY_SNIFF_BYTES = 8e3;
3163
+ var ABSENT = { kind: "absent" };
3164
+ async function collectWorkspaceDiff(opts) {
3165
+ const root = await repoRoot(opts.workDir);
3166
+ return root === null ? await checkpointDiff(opts) : await gitDiff(root);
3167
+ }
3168
+ async function gitDiff(root) {
3169
+ const wanted = await collectGitPaths(root);
3170
+ const notes = [];
3171
+ const truncated = wanted.length > MAX_DIFF_FILES;
3172
+ const picked = truncated ? wanted.slice(0, MAX_DIFF_FILES) : wanted;
3173
+ if (truncated) notes.push("truncated");
3174
+ if (wanted.length === 0) notes.push("no-changes");
3175
+ const sizes = picked.some((p) => p.oldSha !== null) ? await treeBlobSizes(root) : /* @__PURE__ */ new Map();
3176
+ const files = [];
3177
+ for (const item of picked) {
3178
+ const old = await gitSide(root, item.oldSha, sizes);
3179
+ const fresh = await worktreeSide(join(root, ...item.path.split("/")));
3180
+ files.push(describe2(item.path, old, fresh));
3181
+ }
3182
+ return { source: "git", root, files, truncated, notes };
3183
+ }
3184
+ async function collectGitPaths(root) {
3185
+ const byPath = /* @__PURE__ */ new Map();
3186
+ if (await hasHead(root)) {
3187
+ for (const change of await rawChanges(root)) {
3188
+ if (change.oldPath !== void 0) {
3189
+ byPath.set(change.oldPath, { path: change.oldPath, oldSha: change.oldSha });
3190
+ }
3191
+ byPath.set(change.path, {
3192
+ path: change.path,
3193
+ oldSha: change.oldPath === void 0 ? change.oldSha : null
3194
+ });
3195
+ }
3196
+ } else {
3197
+ for (const path of await trackedFiles(root)) byPath.set(path, { path, oldSha: null });
3198
+ }
3199
+ for (const path of await untrackedFiles(root)) {
3200
+ if (!byPath.has(path)) byPath.set(path, { path, oldSha: null });
3201
+ }
3202
+ return [...byPath.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
3203
+ }
3204
+ async function gitSide(root, sha, sizes) {
3205
+ if (sha === null) return ABSENT;
3206
+ const known = sizes.get(sha);
3207
+ if (known !== void 0 && known > MAX_DIFF_BYTES) return { kind: "oversize", bytes: known };
3208
+ const data = await catFileBlob(root, sha);
3209
+ if (data === null) return { kind: "oversize", bytes: known ?? -1 };
3210
+ return { kind: "content", data };
3211
+ }
3212
+ async function checkpointDiff(opts) {
3213
+ const notes = ["not-a-git-repo"];
3214
+ const paths = await checkpointPaths(opts.checkpoints);
3215
+ if (paths.length === 0) {
3216
+ notes.push(opts.checkpoints === null ? "no-workspace-history" : "no-changes");
3217
+ return { source: "none", root: opts.workDir, files: [], truncated: false, notes };
3218
+ }
3219
+ const truncated = paths.length > MAX_DIFF_FILES;
3220
+ const picked = truncated ? paths.slice(0, MAX_DIFF_FILES) : paths;
3221
+ if (truncated) notes.push("truncated");
3222
+ const files = [];
3223
+ for (const absolute of picked) {
3224
+ const fresh = await worktreeSide(absolute);
3225
+ files.push(
3226
+ describe2(displayPath(opts.workDir, absolute), ABSENT, fresh, {
3227
+ status: fresh.kind === "absent" ? "deleted" : "modified",
3228
+ omitted: "no-snapshot"
3229
+ })
3230
+ );
3231
+ }
3232
+ return { source: "checkpoints", root: opts.workDir, files, truncated, notes };
3233
+ }
3234
+ async function checkpointPaths(checkpoints) {
3235
+ if (!checkpoints) return [];
3236
+ const paths = /* @__PURE__ */ new Set();
3237
+ try {
3238
+ for (const summary of await checkpoints.list()) {
3239
+ const preview = await checkpoints.preview(summary.turnIndex);
3240
+ if (!preview) continue;
3241
+ for (const plan of [...preview.files, ...preview.conflicts]) {
3242
+ if (plan.action !== "skip") paths.add(plan.path);
3243
+ }
3244
+ }
3245
+ } catch {
3246
+ }
3247
+ return [...paths].sort();
3248
+ }
3249
+ function displayPath(workDir, absolute) {
3250
+ const rel = relative(workDir, absolute);
3251
+ if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) return absolute;
3252
+ return rel.split(sep).join("/");
3253
+ }
3254
+ async function worktreeSide(absolute) {
3255
+ try {
3256
+ const info = await stat(absolute);
3257
+ if (!info.isFile()) return ABSENT;
3258
+ if (info.size > MAX_DIFF_BYTES) return { kind: "oversize", bytes: info.size };
3259
+ return { kind: "content", data: await readFile(absolute) };
3260
+ } catch {
3261
+ return ABSENT;
3262
+ }
3263
+ }
3264
+ function describe2(path, old, fresh, override = {}) {
3265
+ const status = override.status ?? (old.kind === "absent" ? "added" : fresh.kind === "absent" ? "deleted" : "modified");
3266
+ const oldBytes = bytesOf(old);
3267
+ const newBytes = bytesOf(fresh);
3268
+ const hard = omitReason(old, fresh);
3269
+ if (hard !== void 0) {
3270
+ return { path, status, oldContent: null, newContent: null, oldBytes, newBytes, omitted: hard };
3271
+ }
3272
+ return {
3273
+ path,
3274
+ status,
3275
+ oldContent: textOf(old),
3276
+ newContent: textOf(fresh),
3277
+ oldBytes,
3278
+ newBytes,
3279
+ ...override.omitted === void 0 ? {} : { omitted: override.omitted }
3280
+ };
3281
+ }
3282
+ function bytesOf(side) {
3283
+ if (side.kind === "absent") return null;
3284
+ if (side.kind === "oversize") return side.bytes < 0 ? null : side.bytes;
3285
+ return side.data.byteLength;
3286
+ }
3287
+ function textOf(side) {
3288
+ return side.kind === "content" ? side.data.toString("utf-8") : null;
3289
+ }
3290
+ function omitReason(old, fresh) {
3291
+ if (isBinary(old) || isBinary(fresh)) return "binary";
3292
+ if (old.kind === "oversize" || fresh.kind === "oversize") return "too-large";
3293
+ return void 0;
3294
+ }
3295
+ function isBinary(side) {
3296
+ return side.kind === "content" && side.data.subarray(0, BINARY_SNIFF_BYTES).includes(0);
3297
+ }
3298
+
3299
+ // src/workspace/handlers.ts
3300
+ async function createSession(ctx, req, res, lang) {
3301
+ const body = await readJsonBody(req);
3302
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3303
+ const payload = asRecord(body.value);
3304
+ const field = readWorkspaceField(payload, lang);
3305
+ if (!field.ok) return sendError(res, 400, "bad-workspace", field.message);
3306
+ const defer = payload["deferWorkspace"] === true;
3307
+ if (defer && field.dir !== null) {
3308
+ return sendError(res, 400, "bad-request", t("web.workspace_defer_with_root", void 0, lang));
3309
+ }
3310
+ if (defer && payload["create"] !== true) {
3311
+ return sendError(
3312
+ res,
3313
+ 400,
3314
+ "bad-request",
3315
+ t("web.workspace_defer_needs_create", void 0, lang)
3316
+ );
3317
+ }
3318
+ if (field.dir === null && payload["create"] !== true) {
3319
+ const sessionId = ctx.runtime.sessionId;
3320
+ const bound = ctx.runtime.workspaces.of(sessionId);
3321
+ return sendJson(res, 200, {
3322
+ sessionId,
3323
+ created: false,
3324
+ workspace: bound ? toWireSessionWorkspace(bound) : null
3325
+ });
3326
+ }
3327
+ const role = typeof payload["role"] === "string" ? payload["role"].trim() : "";
3328
+ const outcome = ctx.runtime.sessionFactory.create({
3329
+ workspace: field.dir,
3330
+ // 建会话那一路也会渲染文案(路径不对 / 目录不存在 那几句在 runtime 的
3331
+ // `SessionWorkspaces.bind()` 里),所以 `lang` 一路递进工厂 —— 判据见
3332
+ // `routes.ts` 文件头「三、`lang` 怎么穿」
3333
+ ...lang === void 0 ? {} : { lang },
3334
+ // `create: true` = 别复用。只认 `=== true`:一个拼错的值该走保守那一侧
3335
+ // (复用),而不是凭空多建一个会话出来
3336
+ reuse: payload["create"] !== true,
3337
+ ...role === "" ? {} : { role }
3338
+ });
3339
+ if (!outcome.ok) {
3340
+ if (outcome.reason === "unavailable") {
3341
+ const mapped2 = unavailableToHttp(lang);
3342
+ return sendError(res, mapped2.status, mapped2.code, mapped2.message);
3343
+ }
3344
+ if (outcome.reason === "unknown-role") {
3345
+ const mapped2 = unknownRoleToHttp(outcome.detail, lang);
3346
+ return sendError(res, mapped2.status, mapped2.code, mapped2.message);
3347
+ }
3348
+ const mapped = bindFailureToHttp(outcome, lang);
3349
+ return sendError(res, mapped.status, mapped.code, mapped.message);
3350
+ }
3351
+ ctx.hub.register(outcome.live.session);
3352
+ if (defer) ctx.runtime.workspaces.defer(outcome.live.sessionId);
3353
+ sendJson(res, 200, {
3354
+ sessionId: outcome.live.sessionId,
3355
+ created: outcome.created,
3356
+ workspace: outcome.live.workspace ? toWireSessionWorkspace(outcome.live.workspace) : null
3357
+ });
3358
+ }
3359
+ function sessionWorkspace(ctx, res, sessionId, lang) {
3360
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
3361
+ return sendUnknownSession(res, sessionId);
3362
+ }
3363
+ const state = ctx.runtime.workspaces.stateOf(sessionId);
3364
+ if (state !== "none") {
3365
+ return sendJson(res, 200, {
3366
+ // **复用 `toWireBinding` / `toWireSessionWorkspace`**,不在这儿另拼一份:
3367
+ // 那三处不对称(`extraRoots` / `trusted` / `skippedInstructionFiles`)少一个,
3368
+ // 界面上就少一句决定 18 要求必须说出来的话,而编译器拦不住少发一个可选字段的投影
3369
+ binding: toWireBinding(state, ctx.runtime.workspaces.of(sessionId))
3370
+ });
3371
+ }
3372
+ if (ctx.hub.has(sessionId) || ctx.hub.isCooled(sessionId)) {
3373
+ return sendJson(res, 200, {
3374
+ binding: { state: "none" }
3375
+ });
3376
+ }
3377
+ sendError(res, 409, "not-live-session", t("web.workspace_not_live", void 0, lang));
3378
+ }
3379
+ async function bindWorkspace(ctx, req, res, sessionId, lang) {
3380
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
3381
+ return sendUnknownSession(res, sessionId);
3382
+ }
3383
+ const body = await readJsonBody(req);
3384
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3385
+ const parsed = readBindWorkspaceBody(asRecord(body.value), lang);
3386
+ if (!parsed.ok) return sendError(res, 400, "bad-workspace", parsed.message);
3387
+ const state = ctx.runtime.workspaces.stateOf(sessionId);
3388
+ if (state !== "unbound") {
3389
+ return sendError(
3390
+ res,
3391
+ 409,
3392
+ "workspace-locked",
3393
+ t("web.workspace_already_decided", void 0, lang)
3394
+ );
3395
+ }
3396
+ if (parsed.kind === "none") {
3397
+ ctx.runtime.workspaces.decideNone(sessionId);
3398
+ return sendJson(res, 200, {
3399
+ binding: { state: "none" }
3400
+ });
3401
+ }
3402
+ const outcome = ctx.runtime.workspaces.bind(sessionId, parsed.root, lang);
3403
+ if (!outcome.ok) {
3404
+ const mapped = bindFailureToHttp(outcome, lang);
3405
+ return sendError(res, mapped.status, mapped.code, mapped.message);
3406
+ }
3407
+ sendJson(res, 200, {
3408
+ binding: { state: "bound", workspace: toWireSessionWorkspace(outcome.bound) }
3409
+ });
3410
+ }
3411
+ async function sessionDiff(ctx, res, sessionId, _lang) {
3412
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
3413
+ return sendUnknownSession(res, sessionId);
3414
+ }
3415
+ const bound = ctx.runtime.workspaces.of(sessionId);
3416
+ if (!bound) {
3417
+ return sendJson(res, 200, {
3418
+ source: "none",
3419
+ root: "",
3420
+ files: [],
3421
+ truncated: false,
3422
+ notes: ["no-workspace"]
3423
+ });
3424
+ }
3425
+ sendJson(
3426
+ res,
3427
+ 200,
3428
+ await collectWorkspaceDiff({
3429
+ workDir: bound.workspace.root,
3430
+ // **这个会话自己那一份**,不是 `EpochRuntime.checkpoints`(引导会话那份)。
3431
+ // 非 git 目录那条退路就是拿检查点当改动清单,而 `workDir` 已经是这个会话
3432
+ // 绑的那个 —— 两样来自两个会话的话,画出来的是「A 改过的文件」挂在
3433
+ // 「B 的根目录」下,一屏全是查无此文件。判据同三条回退端点(方案 30 §9.5)。
3434
+ //
3435
+ // 拿不到(被冷却过、上个进程留下的)就是 null:diff 退化成
3436
+ // `no-workspace-history`。**空清单比错清单强**
3437
+ checkpoints: checkpointsOf(ctx, sessionId)
3438
+ })
3439
+ );
3440
+ }
3441
+
3442
+ // src/routes.ts
3443
+ var DENY_MESSAGE = {
3444
+ "missing-credential": "\u7F3A\u5C11\u51ED\u636E\uFF1A\u7528 epoch web \u6253\u5370\u7684\u90A3\u6761\u5E26 ?token= \u7684\u5730\u5740\u6253\u5F00",
3445
+ "bad-token": "token \u4E0D\u5BF9\u3002\u670D\u52A1\u6BCF\u6B21\u542F\u52A8\u90FD\u4F1A\u6362\u4E00\u4E2A\uFF0C\u8BF7\u7528\u6700\u65B0\u90A3\u6761\u5730\u5740",
3446
+ "bad-origin": "Origin \u4E0D\u88AB\u5141\u8BB8\uFF08\u9632 DNS rebinding\uFF09",
3447
+ "bad-host": "Host \u4E0E\u672C\u670D\u52A1\u76D1\u542C\u7684\u5730\u5740\u5BF9\u4E0D\u4E0A\uFF08\u9632 DNS rebinding\uFF09"
3448
+ };
3449
+ function createRequestHandler(opts) {
3450
+ return (req, res) => {
3451
+ dispatch(opts, req, res).catch(() => {
3452
+ if (!res.headersSent) sendError(res, 500, "internal", "\u670D\u52A1\u7AEF\u5185\u90E8\u9519\u8BEF");
3453
+ else res.destroy();
3454
+ });
3455
+ };
3456
+ }
3457
+ async function dispatch(opts, req, res) {
3458
+ const { ctx, guard } = opts;
3459
+ const pathname = pathnameOf(req.url);
3460
+ const method = (req.method ?? "GET").toUpperCase();
3461
+ if (pathname === "/api/health" && isRead(method)) return health(ctx, res);
3462
+ const verdict = guard(toAuthView(req));
3463
+ if (!verdict.ok) {
3464
+ return sendError(res, verdict.status, verdict.reason, DENY_MESSAGE[verdict.reason]);
3465
+ }
3466
+ if (verdict.setCookie) res.setHeader("Set-Cookie", verdict.setCookie);
3467
+ if (verdict.setCookie && isRead(method) && !pathname.startsWith("/api/")) {
3468
+ return redirectWithoutToken(res, req.url);
3469
+ }
3470
+ const segments = splitPath(pathname);
3471
+ if (segments[0] === "api") return handleApi(opts, req, res, method, segments);
3472
+ if (!isRead(method)) return methodNotAllowed(res, "GET, HEAD");
3473
+ return handleStatic(opts, res, pathname);
3474
+ }
3475
+ function isRead(method) {
3476
+ return method === "GET" || method === "HEAD";
3477
+ }
3478
+ function splitPath(pathname) {
3479
+ return pathname.split("/").filter((s) => s.length > 0).map((s) => {
3480
+ try {
3481
+ return decodeURIComponent(s);
3482
+ } catch {
3483
+ return s;
3484
+ }
3485
+ });
3486
+ }
3487
+ async function handleApi(opts, req, res, method, segments) {
3488
+ const { ctx } = opts;
3489
+ const lang = langOf(req.url);
3490
+ const [, resource, first, second, detail, tail] = segments;
3491
+ if (resource === "config" && isRead(method)) return config(ctx, res, lang);
3492
+ if (resource === "events" && method === "GET") return openStream(opts, req, res);
3493
+ if (resource === "sessions") {
3494
+ return handleSessions(opts, req, res, method, first, second, detail, lang);
3495
+ }
3496
+ if (resource === "approvals" && first !== void 0 && method === "POST") {
3497
+ return respondApproval(ctx, req, res, first);
3498
+ }
3499
+ if (resource === "questions" && first !== void 0 && method === "POST") {
3500
+ return respondQuestion(ctx, req, res, first);
3501
+ }
3502
+ if (resource === "artifacts" && first !== void 0 && second !== void 0 && isRead(method)) {
3503
+ return getArtifact(ctx, res, first, second);
3504
+ }
3505
+ if (resource === "mcp" && first === "config" && second === void 0 && isRead(method)) {
3506
+ return readConfig(ctx, res, lang);
3507
+ }
3508
+ if (resource === "mcp" && first === "config" && second === void 0 && method === "PUT") {
3509
+ return saveConfig(ctx, req, res, lang);
3510
+ }
3511
+ if (resource === "mcp" && first === "config" && second === "apply" && method === "POST") {
3512
+ return applyConfig(ctx, req, res, lang);
3513
+ }
3514
+ if (resource === "mcp" && first !== void 0 && second === "reconnect" && method === "POST") {
3515
+ return reconnectMcp(ctx, res, first, lang);
3516
+ }
3517
+ if (resource === "mcp" && first === void 0 && method === "POST") {
3518
+ return addMcp(ctx, req, res, lang);
3519
+ }
3520
+ if (resource === "roles" && first === void 0 && method === "POST") {
3521
+ return addRole(ctx, req, res, lang);
3522
+ }
3523
+ if (resource === "skills" && first === "import" && second === "preview" && method === "POST") {
3524
+ return previewSkillImport(ctx, req, res, lang);
3525
+ }
3526
+ if (resource === "skills" && first === "import" && second === void 0 && method === "POST") {
3527
+ return importSkills(ctx, req, res, lang);
3528
+ }
3529
+ if (resource === "providers" && first === void 0 && isRead(method)) {
3530
+ return listProviders(ctx, res);
3531
+ }
3532
+ if (resource === "providers" && first !== void 0 && second === "models" && method === "POST") {
3533
+ return discoverProviderModels(ctx, req, res, first, lang);
3534
+ }
3535
+ if (resource === "workspaces" && first === "dirs" && isRead(method)) {
3536
+ return browseWorkspaceDirs(ctx, res, req.url, lang);
3537
+ }
3538
+ if (resource === "workspaces" && first === void 0 && method === "POST") {
3539
+ return createWorkspaceDir(req, res, lang);
3540
+ }
3541
+ if (resource === "schedules") {
3542
+ return handleSchedules(ctx, req, res, method, first, second, detail, tail, lang);
3543
+ }
3544
+ sendNoEndpoint(res, method, `/${segments.join("/")}`);
3545
+ }
3546
+ async function handleSchedules(ctx, req, res, method, scheduleId, action, runId, tail, lang) {
3547
+ if (scheduleId === void 0) {
3548
+ if (isRead(method)) return listSchedules(ctx, res);
3549
+ if (method === "POST") return createSchedule(ctx, req, res, lang);
3550
+ return methodNotAllowed(res, "GET, POST");
3551
+ }
3552
+ if (action === void 0) {
3553
+ if (isRead(method)) return getSchedule(ctx, res, scheduleId, lang);
3554
+ if (method === "PATCH") return patchSchedule(ctx, req, res, scheduleId, lang);
3555
+ if (method === "DELETE") return deleteSchedule(ctx, res, scheduleId);
3556
+ return methodNotAllowed(res, "GET, PATCH, DELETE");
3557
+ }
3558
+ if (action === "runs") {
3559
+ if (runId === void 0 && isRead(method)) return listRuns(ctx, res, scheduleId, lang);
3560
+ if (runId !== void 0 && tail === "recording" && isRead(method)) {
3561
+ return getRecording(ctx, res, scheduleId, runId, lang);
3562
+ }
3563
+ return methodNotAllowed(res, "GET");
3564
+ }
3565
+ if (action === "run" && method === "POST")
3566
+ return runSchedule(ctx, res, scheduleId, lang);
3567
+ if (action === "fix" && method === "POST") {
3568
+ return fixSchedule(ctx, req, res, scheduleId, lang);
3569
+ }
3570
+ sendNoEndpoint(res, method, `/api/schedules/${scheduleId}`);
3571
+ }
3572
+ async function handleSessions(opts, req, res, method, sessionId, action, detail, lang) {
3573
+ const { ctx } = opts;
3574
+ if (sessionId === void 0) {
3575
+ if (isRead(method)) return listSessions(ctx, res, searchQuery(req.url));
3576
+ if (method === "POST") return createSession(ctx, req, res, lang);
3577
+ return methodNotAllowed(res, "GET, POST");
3578
+ }
3579
+ if (action === void 0) {
3580
+ if (isRead(method)) return getSession(ctx, res, sessionId);
3581
+ if (method === "DELETE") return deleteSession(ctx, res, sessionId, lang);
3582
+ if (method === "PATCH") return patchSession(ctx, req, res, sessionId, lang);
3583
+ return methodNotAllowed(res, "GET, DELETE, PATCH");
3584
+ }
3585
+ if (action === "messages") {
3586
+ if (isRead(method)) return listMessages(ctx, res, sessionId);
3587
+ if (method === "POST") return postMessage(ctx, req, res, sessionId, lang);
3588
+ return methodNotAllowed(res, "GET, POST");
3589
+ }
3590
+ if (action === "abort" && method === "POST") return abortSession(ctx, res, sessionId);
3591
+ if (action === "approvals" && isRead(method)) return listApprovals(ctx, res, sessionId);
3592
+ if (action === "questions" && isRead(method)) return listQuestions(ctx, res, sessionId);
3593
+ if (action === "capabilities" && isRead(method))
3594
+ return listCapabilities(ctx, res, sessionId);
3595
+ if (action === "commands" && isRead(method)) return listCommands(ctx, res, sessionId);
3596
+ if (action === "security" && isRead(method)) return getSecurity(ctx, res, sessionId);
3597
+ if (action === "tools" && isRead(method)) return getTools(ctx, res, sessionId);
3598
+ if (action === "skills" && detail !== void 0 && isRead(method)) {
3599
+ return getSkillBody(ctx, res, sessionId, detail, lang);
3600
+ }
3601
+ if (action === "settings" && isRead(method)) return getSettings(ctx, res, sessionId);
3602
+ if (action === "settings" && method === "POST") {
3603
+ return writeSetting(ctx, req, res, sessionId, lang);
3604
+ }
3605
+ if (action === "model" && isRead(method)) return getModel(ctx, res, sessionId);
3606
+ if (action === "model" && method === "POST")
3607
+ return setModel(ctx, req, res, sessionId, lang);
3608
+ if (action === "artifacts" && isRead(method)) return getArtifacts(ctx, res, sessionId);
3609
+ if (action === "tasks" && isRead(method)) return getTasks(ctx, res, sessionId);
3610
+ if (action === "plan" && isRead(method)) return getPlan(ctx, res, sessionId);
3611
+ if (action === "plan" && method === "POST")
3612
+ return setPlanMode(ctx, req, res, sessionId, lang);
3613
+ if (action === "permission" && isRead(method)) {
3614
+ return getPermission(ctx, res, sessionId);
3615
+ }
3616
+ if (action === "permission" && method === "POST") {
3617
+ return setPermission(ctx, req, res, sessionId, lang);
3618
+ }
3619
+ if (action === "checkpoints" && isRead(method)) {
3620
+ return detail === void 0 ? listCheckpoints(ctx, res, sessionId) : previewCheckpoint(ctx, res, sessionId, detail, lang);
3621
+ }
3622
+ if (action === "rewind" && method === "POST")
3623
+ return rewind(ctx, req, res, sessionId, lang);
3624
+ if (action === "workspace" && isRead(method)) {
3625
+ return sessionWorkspace(ctx, res, sessionId, lang);
3626
+ }
3627
+ if (action === "workspace" && method === "POST") {
3628
+ return bindWorkspace(ctx, req, res, sessionId, lang);
3629
+ }
3630
+ if (action === "diff" && isRead(method)) return sessionDiff(ctx, res, sessionId);
3631
+ sendNoEndpoint(res, method, `/api/sessions/${sessionId}`);
3632
+ }
3633
+ function openStream(opts, req, res) {
3634
+ const connection = new SseConnection(res, opts.heartbeatMs);
3635
+ const unsubscribe = opts.ctx.hub.subscribe(
3636
+ (frame) => connection.send(frame),
3637
+ parseLastEventId(req)
3638
+ );
3639
+ const finish = () => {
3640
+ unsubscribe();
3641
+ connection.close();
3642
+ };
3643
+ req.on("close", finish);
3644
+ res.on("close", finish);
3645
+ }
3646
+ function handleStatic(opts, res, pathname) {
3647
+ if (opts.webRoot) {
3648
+ const file = resolveStaticPath(opts.webRoot, pathname);
3649
+ if (file) return sendFile(res, file, { "Content-Type": staticContentType(file) });
3650
+ }
3651
+ if (pathname !== "/") return sendError(res, 404, "not-found", `\u627E\u4E0D\u5230 ${pathname}`);
3652
+ const html = placeholderPage(opts.ctx.version);
3653
+ res.writeHead(200, {
3654
+ ...baseHeaders(),
3655
+ "Content-Type": "text/html; charset=utf-8",
3656
+ "Content-Length": Buffer.byteLength(html)
3657
+ });
3658
+ res.end(html);
3659
+ }
3660
+ function searchQuery(rawUrl) {
3661
+ const value = new URL(rawUrl ?? "/", "http://127.0.0.1").searchParams.get("q")?.trim();
3662
+ return value ? value : void 0;
3663
+ }
3664
+ function redirectWithoutToken(res, rawUrl) {
3665
+ const url = new URL(rawUrl ?? "/", "http://127.0.0.1");
3666
+ url.searchParams.delete(WIRE_AUTH_TOKEN_PARAM);
3667
+ res.writeHead(303, { ...baseHeaders(), Location: `${url.pathname}${url.search}` });
3668
+ res.end();
3669
+ }
3670
+ function sendNoEndpoint(res, method, path) {
3671
+ sendError(res, 404, "not-found", `\u6CA1\u6709\u8FD9\u4E2A\u7AEF\u70B9\uFF1A${method} ${path}`);
3672
+ }
3673
+ function methodNotAllowed(res, allow) {
3674
+ sendError(res, 405, "method-not-allowed", `\u53EA\u652F\u6301 ${allow}`, { Allow: allow });
3675
+ }
3676
+ function defaultWebRoot(baseDir = dirname(fileURLToPath(import.meta.url)), exists = existsSync) {
3677
+ return [join(baseDir, "web"), join(baseDir, "..", "dist", "web")].find(
3678
+ (dir) => exists(join(dir, "index.html"))
3679
+ );
3680
+ }
3681
+
3682
+ // src/index.ts
3683
+ async function createWebServer(opts) {
3684
+ const binding = decideBinding({
3685
+ ...opts.host === void 0 ? {} : { host: opts.host },
3686
+ ...opts.port === void 0 ? {} : { port: opts.port },
3687
+ ...opts.token === void 0 ? {} : { token: opts.token }
3688
+ });
3689
+ if (!binding.ok) return binding;
3690
+ const hub = new SessionHub({
3691
+ ...opts.ringCapacity === void 0 ? {} : { ringCapacity: opts.ringCapacity },
3692
+ ...opts.idleGraceMs === void 0 ? {} : { idleGraceMs: opts.idleGraceMs },
3693
+ ...opts.clock === void 0 ? {} : { clock: opts.clock },
3694
+ ...opts.maxActiveSessions === void 0 ? {} : { maxActiveSessions: opts.maxActiveSessions },
3695
+ // 被挤走的那个会话,引擎那一侧的东西也要放掉(方案 30 §2.3 的上限)。
3696
+ // 不接这条线的话,上限只挡住了 Hub 那张表变长,而对话历史、`AgentLoop`、
3697
+ // 审批桥全留着 —— 那正是冷却要还回来的东西。判据见 `SessionHubOptions.onCooled`
3698
+ onCooled: (sessionId) => opts.runtime.sessionFactory.release(sessionId)
3699
+ });
3700
+ if (opts.runtime.session) hub.register(opts.runtime.session);
3701
+ let handle = null;
3702
+ const server = createServer((req, res) => {
3703
+ if (handle) return handle(req, res);
3704
+ res.writeHead(503, { "Cache-Control": "no-store" });
3705
+ res.end();
3706
+ });
3707
+ const webRoot = opts.webRoot ?? defaultWebRoot();
3708
+ const listened = await listen(server, binding.host, binding.port);
3709
+ if (!listened.ok) return listened;
3710
+ handle = buildHandler(opts, hub, binding, listened.port, webRoot);
3711
+ return {
3712
+ ok: true,
3713
+ server: {
3714
+ url: firstScreenUrl(binding.host, listened.port, binding.token, opts.ui ?? {}),
3715
+ port: listened.port,
3716
+ host: binding.host,
3717
+ token: binding.token,
3718
+ ...webRoot === void 0 ? {} : { webRoot },
3719
+ // 两个都是直接转 hub 上那一对,**没有中间层** —— 转出去的就是浏览器
3720
+ // 收到的那条流和 `GET /api/sessions` 读的那张表,不是它们的复制品
3721
+ observe: (sink) => hub.observe(sink),
3722
+ snapshot: () => hub.listSessions(),
3723
+ close: makeCloser(server, hub, opts.runtime)
3724
+ }
3725
+ };
3726
+ }
3727
+ function buildHandler(opts, hub, binding, port, webRoot) {
3728
+ return createRequestHandler({
3729
+ ctx: {
3730
+ hub,
3731
+ runtime: opts.runtime,
3732
+ version: opts.version,
3733
+ // 缺省问引擎要(方案 54 §二)。`??` 而不是 `||`:宿主传空字符串是它自己
3734
+ // 的错,静默替换成引擎那个会把「我明明配了别处」变成一个查不出的怪现象
3735
+ artifactsRoot: opts.artifactsRoot ?? opts.runtime.artifactsRoot,
3736
+ // 绑定策略那一格原样递下去(2026-08-18)。**同一个 `binding.lanExposed`
3737
+ // 同时喂着下面 `createAuthGuard`(放宽 Host 白名单)和 `POST /api/roles`
3738
+ // 那道 403** —— 一个事实一个来源,判据在 `ApiContext.lanExposed` 上
3739
+ lanExposed: binding.lanExposed
3740
+ },
3741
+ guard: createAuthGuard({ token: binding.token, port, lanExposed: binding.lanExposed }),
3742
+ ...webRoot === void 0 ? {} : { webRoot },
3743
+ ...opts.heartbeatMs === void 0 ? {} : { heartbeatMs: opts.heartbeatMs }
3744
+ });
3745
+ }
3746
+ function listen(server, host, port) {
3747
+ return new Promise((resolve4) => {
3748
+ const onError = (err) => {
3749
+ server.removeListener("error", onError);
3750
+ resolve4({ ok: false, error: describeListenError(err, host, port) });
3751
+ };
3752
+ server.once("error", onError);
3753
+ server.listen(port, host, () => {
3754
+ server.removeListener("error", onError);
3755
+ const address = server.address();
3756
+ resolve4({ ok: true, port: typeof address === "object" && address ? address.port : port });
3757
+ });
3758
+ });
3759
+ }
3760
+ function describeListenError(err, host, port) {
3761
+ if (err.code === "EADDRINUSE") {
3762
+ return `\u7AEF\u53E3 ${port} \u5DF2\u88AB\u5360\u7528\uFF0C\u6362\u4E00\u4E2A\uFF1Aepoch web --port ${port + 1}`;
3763
+ }
3764
+ if (err.code === "EACCES") {
3765
+ return `\u6CA1\u6709\u6743\u9650\u76D1\u542C ${host}:${port}\uFF081024 \u4EE5\u4E0B\u7684\u7AEF\u53E3\u9700\u8981\u7BA1\u7406\u5458\u6743\u9650\uFF09`;
3766
+ }
3767
+ if (err.code === "EADDRNOTAVAIL") {
3768
+ return `\u672C\u673A\u6CA1\u6709 ${host} \u8FD9\u4E2A\u5730\u5740`;
3769
+ }
3770
+ return `\u76D1\u542C ${host}:${port} \u5931\u8D25\uFF1A${err.message}`;
3771
+ }
3772
+ function makeCloser(server, hub, runtime) {
3773
+ let closing = null;
3774
+ return () => {
3775
+ closing ??= (async () => {
3776
+ hub.shutdown();
3777
+ server.closeAllConnections();
3778
+ await new Promise((resolve4) => server.close(() => resolve4()));
3779
+ await runtime.dispose();
3780
+ })();
3781
+ return closing;
3782
+ };
3783
+ }
3784
+
3785
+ export { DEFAULT_MAX_ACTIVE_SESSIONS, DEFAULT_MAX_QUEUED_MESSAGES, DEFAULT_RING_CAPACITY, DEFAULT_WEB_HOST, DEFAULT_WEB_PORT, EnvelopeRing, MAX_DIFF_BYTES, MAX_DIFF_FILES, MAX_RECORDING_FRAMES, MAX_SKILL_BODY_BYTES, SessionHub, TASK_TAIL_BYTES, UI_LANG_PARAM, UI_THEME_PARAM, collectCapabilities, collectSecurity, collectSettings, collectTasks, collectWorkspaceDiff, createAuthGuard, createWebServer, decideBinding, defaultWebRoot, expandUserContent, firstScreenUrl, generateToken, readRewindInput, resolveArtifactPath, toWireCheckpoint, toWireCommand, toWirePreview, toWireRewindResult, toWireRow, toWireSkillBody, toWireTask, unavailableToHttp };