@epoch-agent/server 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { createServer } from 'http';
2
+ import { t, ApprovalRelay, QuestionRelay, serializeStream, withRoleScope, withModelScope, withToolScope, nextRunAt, skillIndexDescription, SANDBOX_EXCLUDES, SANDBOX_COVERS, isSystemOpenable, backgroundTaskOutput, listBackgroundTasks, providerLabel, MAX_REASON_CHARS, MAX_OBJECTIVE_CHARS, MAX_GOAL_MAX_ROUNDS, DEFAULT_GOAL_MAX_ROUNDS, SCHEDULE_DEFAULT_ALLOWLIST, SCHEDULE_DEFAULTS, readRecording } from '@epoch-agent/runtime';
2
3
  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';
4
+ import { PLAN_OUTCOMES, WIRE_AUTH_COOKIE, WIRE_AUTH_COOKIE_ATTRS, WIRE_AUTH_TOKEN_PARAM, extractAllMentions, MAX_ATTACH_FILES, utf8ByteLength, MAX_SESSION_REFS, attachSessionSurface, isWireRewindScope, WIRE_REWIND_SCOPES, MAX_ATTACH_TOTAL_BYTES, MAX_ATTACH_BYTES, WIRE_LANG_PARAM, isLang, isQuestionAnswerMap, isProviderType, apiKeyEnvVar, WIRE_FILE_STAT_MAX, WIRE_SETTING_WRITE_LAYERS, isWirePlanAction, WIRE_PLAN_ACTIONS, isPermissionLevel, PERMISSION_LEVELS, collectPendingApprovals, unfixedRules, isWireSettingWriteLayer, parseModelRef, WIRE_GOAL_ACTIONS, OPERATION_TYPES, isOperationType } from '@epoch-agent/protocol';
5
5
  import { resolve, join, relative, isAbsolute, sep, dirname, basename, extname } from 'path';
6
6
  import { existsSync, realpathSync, statSync, mkdirSync, createReadStream, readdirSync, accessSync, constants } from 'fs';
7
7
  import { homedir } from 'os';
8
+ import { execFile, spawn } from 'child_process';
8
9
  import { stat, readFile } from 'fs/promises';
9
- import { execFile } from 'child_process';
10
10
  import { fileURLToPath } from 'url';
11
11
 
12
12
  // src/index.ts
@@ -100,16 +100,25 @@ function decideBinding(req = {}) {
100
100
  const host = req.host?.trim() || DEFAULT_WEB_HOST;
101
101
  const port = req.port ?? DEFAULT_WEB_PORT;
102
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)}` };
103
+ return {
104
+ ok: false,
105
+ error: t("web.bad_port", { value: String(req.port) }, void 0)
106
+ };
104
107
  }
105
108
  const lanExposed = !isLoopbackHost(host);
106
109
  const token = req.token?.trim();
107
110
  if (lanExposed && !token) {
108
111
  return {
109
112
  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
+ error: [
114
+ t("web.lan_needs_token", { host }, void 0),
115
+ " " + t("web.lan_needs_token_local", void 0, void 0),
116
+ " " + t(
117
+ "web.lan_needs_token_lan",
118
+ { cmd: 'epoch web --host 0.0.0.0 --token "$(openssl rand -base64 32)"' },
119
+ void 0
120
+ )
121
+ ].join("\n")
113
122
  };
114
123
  }
115
124
  return { ok: true, host, port, lanExposed, token: token || generateToken() };
@@ -127,7 +136,6 @@ function firstScreenUrl(host, port, token, ui = {}) {
127
136
  if (ui.lang) params.push(`${UI_LANG_PARAM}=${encodeURIComponent(ui.lang)}`);
128
137
  return `http://${authority}:${port}/?${params.join("&")}`;
129
138
  }
130
-
131
139
  // src/ring.ts
132
140
  var DEFAULT_RING_CAPACITY = 512;
133
141
  var EnvelopeRing = class {
@@ -138,53 +146,21 @@ var EnvelopeRing = class {
138
146
  }
139
147
  }
140
148
  capacity;
141
- /**
142
- * 按 seq 升序、**连续无洞**的一段。
143
- *
144
- * 连续性是 `since()` 能只靠首尾两个 seq 判定的前提:每个广播帧都让全局计数器
145
- * 恰好 +1,所以缓冲里存的一定是 `[latest-size+1, latest]` 这个闭区间。
146
- * 哪天出现「某些帧不进缓冲」的需求,这条前提就断了,`since()` 必须跟着改。
147
- */
148
149
  frames = [];
149
- /**
150
- * 收一帧。**只收广播帧**(`seq >= 1`)。
151
- *
152
- * 连接级帧(`connected` / `stream-reset`,`seq: 0`)不进来:它们只发给刚接上来
153
- * 的那一个连接,进了缓冲就会在下一次续传时被当成历史补给别人。
154
- */
155
150
  push(frame) {
156
151
  if (frame.seq < 1) throw new Error(`\u8FDE\u63A5\u7EA7\u5E27\uFF08seq=${frame.seq}\uFF09\u4E0D\u8BE5\u8FDB\u73AF\u5F62\u7F13\u51B2`);
157
152
  this.frames.push(frame);
158
153
  if (this.frames.length > this.capacity) this.frames.shift();
159
154
  }
160
- /** 缓冲里最新那一帧的 seq;空缓冲是 0(= 还没发过任何广播帧) */
161
155
  get latestSeq() {
162
156
  return this.frames[this.frames.length - 1]?.seq ?? 0;
163
157
  }
164
- /** 缓冲里最老那一帧的 seq;空缓冲是 undefined */
165
158
  get oldestSeq() {
166
159
  return this.frames[0]?.seq;
167
160
  }
168
- /** 现存帧数 */
169
161
  get size() {
170
162
  return this.frames.length;
171
163
  }
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
164
  since(lastEventId) {
189
165
  if (!Number.isInteger(lastEventId) || lastEventId < 1) {
190
166
  return [];
@@ -200,29 +176,14 @@ var DEFAULT_MAX_ACTIVE_SESSIONS = 8;
200
176
  var DEFAULT_MAX_QUEUED_MESSAGES = 8;
201
177
  var SessionRegistry = class {
202
178
  entries = /* @__PURE__ */ new Map();
203
- /**
204
- * 被冷却过的 id。**只留 id,不留会话对象** —— 留着对象就等于什么都没释放,
205
- * 而释放内存里那份对话历史正是冷却的全部意义。
206
- *
207
- * 它存在的唯一理由是让「往一个被冷却的会话发消息」能回一句准话
208
- * (409 `session-cooled`)而不是 404「没有这个会话」—— 后者会让调用方
209
- * 以为自己拼错了 id。插入序即冷却序,超出容量时从最早的开始丢。
210
- */
211
179
  cooled = /* @__PURE__ */ new Set();
212
180
  maxActive;
213
181
  maxQueued;
214
- /** 单调递增的逻辑刻度,见 `SessionEntry.lastActiveTick` */
215
182
  tick = 0;
216
183
  constructor(opts = {}) {
217
184
  this.maxActive = Math.max(1, opts.maxActive ?? DEFAULT_MAX_ACTIVE_SESSIONS);
218
185
  this.maxQueued = Math.max(0, opts.maxQueued ?? DEFAULT_MAX_QUEUED_MESSAGES);
219
186
  }
220
- /**
221
- * 把一个会话放进表里。重复注册同一个 id 是空操作。
222
- *
223
- * 注册一个**曾被冷却**的 id 就是「重新打开它」:从冷却名单里摘掉,
224
- * 之后它和别的活跃会话没有区别(历史由 `GET /messages` 回放,不在这里恢复)。
225
- */
226
187
  register(session) {
227
188
  const id = session.sessionId;
228
189
  if (this.entries.has(id)) return { added: false, cooled: null };
@@ -246,54 +207,33 @@ var SessionRegistry = class {
246
207
  has(sessionId) {
247
208
  return this.entries.has(sessionId);
248
209
  }
249
- /** 这个 id 是被冷却掉的(而不是从来没存在过)吗 */
250
210
  isCooled(sessionId) {
251
211
  return this.cooled.has(sessionId);
252
212
  }
253
213
  get size() {
254
214
  return this.entries.size;
255
215
  }
256
- /** 遍历。插入序 = 注册序 */
257
216
  all() {
258
217
  return this.entries.entries();
259
218
  }
260
- /** 从表里摘掉(会话被删除时)。**不进冷却名单** —— 它是真没了,不是被挤走 */
261
219
  remove(sessionId) {
262
220
  const entry = this.entries.get(sessionId);
263
221
  if (entry) this.entries.delete(sessionId);
264
222
  this.cooled.delete(sessionId);
265
223
  return entry;
266
224
  }
267
- /** 记一次活动。开轮和收轮都要调 —— LRU 挑的就是这个刻度最小的那个 */
268
225
  touch(sessionId) {
269
226
  const entry = this.entries.get(sessionId);
270
227
  if (entry) entry.lastActiveTick = ++this.tick;
271
228
  }
272
- /**
273
- * 排一条消息。
274
- *
275
- * @returns `false` = 队列满了,调用方回 409。**不悄悄丢**:丢掉的表现是
276
- * 用户发了一条消息,服务端回 202,然后什么都没发生
277
- */
278
229
  enqueue(entry, queued) {
279
230
  if (entry.queue.length >= this.maxQueued) return false;
280
231
  entry.queue.push(queued);
281
232
  return true;
282
233
  }
283
- /** 取下一条排队的消息。空队列时 `undefined` */
284
234
  dequeue(entry) {
285
235
  return entry.queue.shift();
286
236
  }
287
- /**
288
- * 挤掉一个最久没动过的**空闲**会话。
289
- *
290
- * 三条判据都不能少:
291
- * - **只挑 idle 的**:冷却一个正在跑的会话等于中途把它的历史扔了
292
- * - **不挑刚注册的那个**(`keep`):否则「注册第 9 个」的结果可能是
293
- * 「第 9 个自己被冷却」,调用方会看到一个刚建好就不见了的会话
294
- * - **一个都挑不出来就不挤**(全在跑):上限是软的。硬挤的代价是丢一轮
295
- * 正在跑的对话,那比多占一份内存严重得多
296
- */
297
237
  coolIfCrowded(keep) {
298
238
  if (this.entries.size <= this.maxActive) return null;
299
239
  let victim = null;
@@ -310,7 +250,6 @@ var SessionRegistry = class {
310
250
  this.rememberCooled(victim);
311
251
  return victim;
312
252
  }
313
- /** 冷却名单也要有界 —— 它只是给错误信息用的,不该跟着进程一直长 */
314
253
  rememberCooled(sessionId) {
315
254
  this.cooled.add(sessionId);
316
255
  const limit = this.maxActive * 4;
@@ -382,12 +321,10 @@ function bindFailureToHttp(failure, lang) {
382
321
  }
383
322
  return { status: 400, code: "bad-workspace", message: failure.detail };
384
323
  }
385
-
386
324
  // src/sessions/summary.ts
387
325
  function toWireFinish(event, ts, turnStartedAt) {
388
326
  return {
389
327
  reason: event.reason,
390
- // 起点不知道就给 null,**不给 0** —— 0 在界面上是「0s」,那是个假事实
391
328
  durationMs: turnStartedAt === null ? null : Math.max(0, ts - turnStartedAt),
392
329
  runUsage: event.runUsage ?? null
393
330
  };
@@ -397,10 +334,6 @@ var OFFLINE = {
397
334
  state: "idle",
398
335
  pendingApprovals: 0,
399
336
  pendingQuestions: 0,
400
- // 「从什么时候起被拦住」在这一档也是 null,理由和上面那条一样:挂起的账只活在
401
- // Hub 的内存里。⚠️ 它**不是**「没在等」——是「这个进程不知道」,而两者在读的
402
- // 那一侧下一步相同(那一格整个不画),所以归同一档。判据全文在
403
- // `WireSessionSummary.pendingSince` 的最后一节
404
337
  pendingSince: null,
405
338
  turnStartedAt: null,
406
339
  lastFinish: null
@@ -417,26 +350,14 @@ function toWireSummary(id, hub, row, extra = {}) {
417
350
  messageCount: row?.messageCount ?? 0,
418
351
  costUsd: row?.costUsd ?? null,
419
352
  startedAt: row?.startedAt ?? null,
420
- // 和 `startedAt` 同生共死:两者的 null 都只有一个含义 ——「DB 里查不到这段
421
- // 会话」。**这一层不拿 `startedAt` 兜它**:兜了就等于把「不知道」写成
422
- // 「刚建出来那一刻动过」,而那正是加这一列之前界面上那句假话
423
353
  updatedAt: row?.updatedAt ?? null,
424
354
  endedAt: row?.endedAt ?? null,
425
355
  pendingApprovals: state.pendingApprovals,
426
356
  pendingQuestions: state.pendingQuestions,
427
- // 原样透传,这一层**一个字都不加工**:两张登记表合成一格那一步在 Hub 里
428
- // 就着两个 relay 做完了(`earlierOf`),在这儿再合一次就是第二个口径 ——
429
- // 同下面回合那两格的判据
430
357
  pendingSince: state.pendingSince,
431
- // 这一格是**两个真源真的合在一起**的唯一一处(其余每一格各归各家)。
432
- // 合法和它推翻了哪一条上一轮的规矩,全在 `toWireSummaryWorkspace` 上
433
358
  workspace: toWireSummaryWorkspace(extra.workspace ?? null, live, row),
434
- // 回合那两格原样透传,这一层不做任何加工:`durationMs` 的减法在 Hub 里
435
- // 就着那一帧算完了(见 `hub.ts` 的 `finishOf`),在这儿再算一次就是第二个口径
436
359
  turnStartedAt: state.turnStartedAt,
437
360
  lastFinish: state.lastFinish,
438
- // 同 `workspace` 那一格:不给就是 null,**不去 DB 兜一手**。身份决定 system
439
- // prompt 和工具表,只有活着的那份内存说得出它现在是谁
440
361
  role: extra.role ?? null,
441
362
  ...extra.snippet === void 0 ? {} : { snippet: extra.snippet }
442
363
  };
@@ -487,28 +408,16 @@ function compareSummaries(a, b) {
487
408
  if (at !== bt) return bt - at;
488
409
  return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
489
410
  }
490
-
491
411
  // src/hub.ts
492
412
  var SessionHub = class {
493
413
  ring;
494
414
  idleGraceMs;
495
415
  clock;
496
- /** 见 {@link SessionHubOptions.onCooled} */
497
416
  onCooled;
498
417
  registry;
499
418
  sinks = /* @__PURE__ */ new Set();
500
- /**
501
- * 同进程旁听者(方案 54 §三)。**故意是第二个集合,而不是 `sinks` 上的一个标记位。**
502
- *
503
- * 标记位那种写法要求每一处 `sinks.size` 都改成「数一下里面有几个不是标记的」,
504
- * 而那四处判定里有两处(`reap()` / `drainQueue()`)的错法是**静默的**:
505
- * 挂起的审批永远不被放弃、没人看着的时候凭空开始跑一条排队的消息。
506
- * 两个集合的写法让那四处一个字都不用改。
507
- */
508
419
  observers = /* @__PURE__ */ new Set();
509
- /** requestId → sessionId。`POST /api/approvals/:requestId` 上没有会话 id */
510
420
  requestOwner = /* @__PURE__ */ new Map();
511
- /** 全局广播游标。0 是保留值(连接级帧),所以广播帧从 1 开始 */
512
421
  seq = 0;
513
422
  graceTimer = null;
514
423
  closed = false;
@@ -522,13 +431,6 @@ var SessionHub = class {
522
431
  ...opts.maxQueuedMessages === void 0 ? {} : { maxQueued: opts.maxQueuedMessages }
523
432
  });
524
433
  }
525
- /**
526
- * 把一个会话交给 Hub 管。重复注册同一个 id 是空操作。
527
- *
528
- * 活跃会话超过上限时,最久没动过的**空闲**会话会被顺带冷却掉 ——
529
- * 它从此不在 Hub 里,但**仍然在会话列表里**(列表的另一半来自 DB),
530
- * 重新打开时历史走 `GET /api/sessions/:id/messages` 回放。
531
- */
532
434
  register(session) {
533
435
  const { added, cooled } = this.registry.register(session);
534
436
  if (!added) return;
@@ -539,15 +441,12 @@ var SessionHub = class {
539
441
  has(sessionId) {
540
442
  return this.registry.has(sessionId);
541
443
  }
542
- /** 这个 id 是被冷却掉的(而不是从来没存在过)吗 */
543
444
  isCooled(sessionId) {
544
445
  return this.registry.isCooled(sessionId);
545
446
  }
546
- /** 不认识的会话按 idle 报 —— 调用方要区分的话先问 `has()` */
547
447
  stateOf(sessionId) {
548
448
  return this.registry.get(sessionId)?.state ?? "idle";
549
449
  }
550
- /** Hub 手里这些会话此刻的样子。Map 的插入序 = 注册序 */
551
450
  listSessions() {
552
451
  return [...this.registry.all()].map(([id, entry]) => ({
553
452
  id,
@@ -560,14 +459,6 @@ var SessionHub = class {
560
459
  lastFinish: entry.lastFinish
561
460
  }));
562
461
  }
563
- /**
564
- * 把一个会话从 Hub 里摘掉(`DELETE /api/sessions/:id`)。
565
- *
566
- * 收尾按 §4.3 那套:**放弃**挂起的请求(不是逐个 deny)+ 中止在跑的回合 +
567
- * 丢掉排队的消息。删掉的会话不进冷却名单 —— 它是真没了,不是被挤走。
568
- *
569
- * @returns Hub 里本来有没有它
570
- */
571
462
  unregister(sessionId) {
572
463
  const entry = this.registry.remove(sessionId);
573
464
  if (!entry) return false;
@@ -580,20 +471,9 @@ var SessionHub = class {
580
471
  }
581
472
  return true;
582
473
  }
583
- /** 当前活着的 SSE 连接数。给 `/api/config` 和用例看 */
584
474
  get connectionCount() {
585
475
  return this.sinks.size;
586
476
  }
587
- /**
588
- * 接一条 SSE 连接。
589
- *
590
- * 同步完成三件事,顺序不能换:先 `connected`(把响应头刷出去、证明流通了),
591
- * 再按 `Last-Event-ID` 补发或回 `stream-reset`,最后才登记进扇出集合 ——
592
- * 反过来的话补发和实时帧会交织,页面上就是一段乱序的对话。
593
- *
594
- * @param lastEventId 客户端带上来的断点。省略 = 全新的流(历史归 GET /messages)
595
- * @returns 取消订阅。**幂等**,连接关闭和服务 close 会各调一次
596
- */
597
477
  subscribe(sink, lastEventId) {
598
478
  sink(this.connectionFrame({ type: "connected" }));
599
479
  if (lastEventId !== void 0) {
@@ -614,24 +494,6 @@ var SessionHub = class {
614
494
  if (this.sinks.size === 0) this.armGrace();
615
495
  };
616
496
  }
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
497
  observe(sink) {
636
498
  if (this.closed) return () => {
637
499
  };
@@ -643,21 +505,6 @@ var SessionHub = class {
643
505
  this.observers.delete(sink);
644
506
  };
645
507
  }
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
508
  start(sessionId, message, decorate) {
662
509
  const entry = this.registry.get(sessionId);
663
510
  if (!entry) {
@@ -673,15 +520,6 @@ var SessionHub = class {
673
520
  this.beginTurn(entry, queued);
674
521
  return { ok: true, queued: false };
675
522
  }
676
- /**
677
- * 中止本轮,并**丢掉排在后面的消息**。
678
- *
679
- * 排队的一起丢是刻意的:用户按下「停」要的是「现在别干了」,而不是
680
- * 「停掉这一轮然后立刻开始下一轮」—— 后者的表现是按了停之后 agent 又动起来了。
681
- *
682
- * @returns 是否真有一轮在跑(**只看这个**,丢掉的队列不算)。
683
- * `false` 表示这一轮本来就没在跑,不是失败
684
- */
685
523
  abort(sessionId) {
686
524
  const entry = this.registry.get(sessionId);
687
525
  if (!entry) return false;
@@ -690,20 +528,12 @@ var SessionHub = class {
690
528
  entry.controller.abort();
691
529
  return true;
692
530
  }
693
- /** 补拉挂起的审批 —— 新连上来的页面靠它把弹层恢复出来(§4.3) */
694
531
  listPending(sessionId) {
695
532
  return this.registry.get(sessionId)?.relay.listPending() ?? [];
696
533
  }
697
- /** 补拉挂起的提问(方案 34 验收 9)。与 `listPending` 同构 */
698
534
  listPendingQuestions(sessionId) {
699
535
  return this.registry.get(sessionId)?.questions.listPending() ?? [];
700
536
  }
701
- /**
702
- * 答复一个审批。
703
- *
704
- * @returns `false` = 这个 requestId 不认识(重复答复 / 上一轮的 ID / 已被放弃)。
705
- * 调用方该回 404 而不是 500:用户在两个标签页上各点一下就会走到这里。
706
- */
707
537
  respondApproval(requestId, outcome, note) {
708
538
  const sessionId = this.requestOwner.get(requestId);
709
539
  const entry = sessionId === void 0 ? void 0 : this.registry.get(sessionId);
@@ -714,16 +544,6 @@ var SessionHub = class {
714
544
  this.syncApprovalState(entry);
715
545
  return true;
716
546
  }
717
- /**
718
- * 答复一个提问(方案 34)。
719
- *
720
- * **另一个方法而不是给 `respondApproval` 加个联合入参**:两者的载荷没有交集,
721
- * 合成一个之后路由层要先猜「这个 requestId 是审批还是提问」,
722
- * 而猜错的表现是一个拼错的答案被判成合法的 `deny`。
723
- *
724
- * @returns `false` = 这个 requestId 不认识(重复答复 / 上一轮的 / 已被放弃)。
725
- * 调用方回 404,同审批。
726
- */
727
547
  respondQuestion(requestId, answer) {
728
548
  const sessionId = this.requestOwner.get(requestId);
729
549
  const entry = sessionId === void 0 ? void 0 : this.registry.get(sessionId);
@@ -734,12 +554,6 @@ var SessionHub = class {
734
554
  this.syncApprovalState(entry);
735
555
  return true;
736
556
  }
737
- /**
738
- * 收摊:撤掉倒计时、放弃所有挂起的审批、中止所有在跑的回合。
739
- *
740
- * 进程收到 SIGINT 时立即调它(§4.3 最后一行),之后宿主再 `dispose()`。
741
- * 幂等。
742
- */
743
557
  shutdown() {
744
558
  if (this.closed) return;
745
559
  this.closed = true;
@@ -748,17 +562,6 @@ var SessionHub = class {
748
562
  this.sinks.clear();
749
563
  this.observers.clear();
750
564
  }
751
- // -------------------------------------------------------------------------
752
- // 私有:发帧
753
- // -------------------------------------------------------------------------
754
- /**
755
- * 广播一帧:占一个全局序号、进环形缓冲、扇出给所有连接和旁听者。
756
- *
757
- * **回传发出去的那个信封**(调用方绝大多数不看)。这是「一轮跑了多久」
758
- * 唯一合法的取数口:`clock` 上钉着「每产出一个信封恰好读一次」的不变量
759
- * (见 `sessions/registry.ts` 的 `lastActiveTick`),想知道某一帧的时刻
760
- * 只能从那一帧上拿,**不许再读一次挂钟**。
761
- */
762
565
  publish(sessionId, event) {
763
566
  const frame = { seq: ++this.seq, sessionId, ts: this.clock(), event };
764
567
  this.ring.push(frame);
@@ -766,30 +569,14 @@ var SessionHub = class {
766
569
  for (const sink of this.observers) sink(frame);
767
570
  return frame;
768
571
  }
769
- /** 连接级帧:`seq: 0`、没有 `sessionId`、不进缓冲、不写 SSE 的 `id:` */
770
572
  connectionFrame(event) {
771
573
  return { seq: 0, ts: this.clock(), event };
772
574
  }
773
- /**
774
- * 状态迁移。**只在真的变了的时候发帧** —— 否则并行审批会刷出一串重复状态。
775
- *
776
- * 回传那一帧的时刻(没发帧时 undefined),给 {@link beginTurn} 记回合起点用。
777
- */
778
575
  setState(entry, state) {
779
576
  if (entry.state === state) return void 0;
780
577
  entry.state = state;
781
578
  return this.publish(entry.session.sessionId, { type: "session-state", state }).ts;
782
579
  }
783
- /**
784
- * 按「还挂着几条请求」把状态摆正。
785
- *
786
- * 只在回合真的在跑的时候动(`controller !== null`):回合已经收尾之后
787
- * 状态该是 idle,这里不能把它拽回 running。
788
- *
789
- * **审批优先于提问**:两者同时挂着时报 `awaiting-approval`。理由是界面上
790
- * 审批层压在提问层上面(它是安全边界,不该被一个选择题挡住),
791
- * 而状态栏那句话得和用户眼前看到的那个框对上。
792
- */
793
580
  syncApprovalState(entry) {
794
581
  if (!entry.controller) return;
795
582
  if (entry.relay.pendingCount > 0) {
@@ -798,15 +585,6 @@ var SessionHub = class {
798
585
  }
799
586
  this.setState(entry, entry.questions.pendingCount > 0 ? "awaiting-question" : "running");
800
587
  }
801
- // -------------------------------------------------------------------------
802
- // 私有:跑一轮
803
- // -------------------------------------------------------------------------
804
- /**
805
- * 开一轮:换一副新的登记表、拉起中止闸、广播状态、然后驱动。
806
- *
807
- * 状态帧必须在 `start()` 返回前就广播出去:POST 拿到 202 之后前端立刻可能
808
- * 再问一次状态,而第二个标签页只靠这一帧才知道「现在正在跑」。
809
- */
810
588
  beginTurn(entry, queued) {
811
589
  entry.relay = new ApprovalRelay();
812
590
  entry.questions = new QuestionRelay();
@@ -816,18 +594,6 @@ var SessionHub = class {
816
594
  entry.turnStartedAt = this.setState(entry, "running") ?? null;
817
595
  void this.drive(entry, queued);
818
596
  }
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
597
  async drive(entry, queued) {
832
598
  const sessionId = entry.session.sessionId;
833
599
  const signal = entry.controller?.signal;
@@ -856,12 +622,6 @@ var SessionHub = class {
856
622
  this.drainQueue(entry);
857
623
  }
858
624
  }
859
- /**
860
- * 一轮跑完,把排在后面的那条接上(方案 30 §2.3 第 4 条)。
861
- *
862
- * 三个前提缺一不可,缺了就是「浏览器早就关了,服务端自己接着聊」:
863
- * 会话还在表里(没被删)、Hub 没收摊、且**还有连接活着**。
864
- */
865
625
  drainQueue(entry) {
866
626
  if (this.closed || !this.registry.has(entry.session.sessionId)) return;
867
627
  if (this.sinks.size === 0) {
@@ -871,9 +631,6 @@ var SessionHub = class {
871
631
  const next = this.registry.dequeue(entry);
872
632
  if (next !== void 0) this.beginTurn(entry, next);
873
633
  }
874
- // -------------------------------------------------------------------------
875
- // 私有:全断之后的收尾(§4.3)
876
- // -------------------------------------------------------------------------
877
634
  armGrace() {
878
635
  if (this.closed || this.graceTimer) return;
879
636
  this.graceTimer = setTimeout(() => {
@@ -887,13 +644,6 @@ var SessionHub = class {
887
644
  clearTimeout(this.graceTimer);
888
645
  this.graceTimer = null;
889
646
  }
890
- /**
891
- * 放弃挂起的审批并中止在跑的回合。
892
- *
893
- * **`abandon()` 而不是逐个 `deny`**:前者一个 promise 都不 resolve,随后的
894
- * `abort()` 让流收尾,引擎侧的 finally 才去兜底 —— 模型看到的是「这一轮被中止
895
- * 了」而不是「用户否了我五次」。后者会让模型换个方式再试一遍。
896
- */
897
647
  reap() {
898
648
  if (this.sinks.size > 0 && !this.closed) return;
899
649
  for (const [, entry] of this.registry.all()) {
@@ -964,16 +714,20 @@ function sendJson(res, status, body, extra = {}) {
964
714
  });
965
715
  res.end(payload);
966
716
  }
717
+ function sendEmpty(res, status, extra = {}) {
718
+ res.writeHead(status, { ...BASE_HEADERS, ...extra });
719
+ res.end();
720
+ }
967
721
  function sendError(res, status, code, message, extra = {}) {
968
722
  sendJson(res, status, { error: { code, message } }, extra);
969
723
  }
970
- function sendUnknownSession(res, sessionId) {
971
- sendError(res, 404, "unknown-session", `\u6CA1\u6709\u4F1A\u8BDD ${sessionId}`);
724
+ function sendUnknownSession(res, sessionId, lang) {
725
+ sendError(res, 404, "unknown-session", t("web.unknown_session", { id: sessionId }, lang));
972
726
  }
973
727
  function baseHeaders() {
974
728
  return { ...BASE_HEADERS };
975
729
  }
976
- async function readJsonBody(req, limit = JSON_BODY_LIMIT) {
730
+ async function readJsonBody(req, limit = JSON_BODY_LIMIT, lang) {
977
731
  const chunks = [];
978
732
  let size = 0;
979
733
  try {
@@ -982,19 +736,31 @@ async function readJsonBody(req, limit = JSON_BODY_LIMIT) {
982
736
  size += buf.length;
983
737
  if (size > limit) {
984
738
  req.destroy();
985
- return { ok: false, status: 413, message: `\u8BF7\u6C42\u4F53\u8D85\u8FC7 ${limit} \u5B57\u8282` };
739
+ return {
740
+ ok: false,
741
+ status: 413,
742
+ message: t("web.body_too_large", { limit }, lang)
743
+ };
986
744
  }
987
745
  chunks.push(buf);
988
746
  }
989
747
  } catch (err) {
990
- return { ok: false, status: 400, message: `\u8BFB\u53D6\u8BF7\u6C42\u4F53\u5931\u8D25\uFF1A${describe(err)}` };
748
+ return {
749
+ ok: false,
750
+ status: 400,
751
+ message: t("web.body_read_failed", { reason: describe(err) }, lang)
752
+ };
991
753
  }
992
754
  const text = Buffer.concat(chunks).toString("utf8");
993
755
  if (text.trim().length === 0) return { ok: true, value: {} };
994
756
  try {
995
757
  return { ok: true, value: JSON.parse(text) };
996
758
  } catch (err) {
997
- return { ok: false, status: 400, message: `\u8BF7\u6C42\u4F53\u4E0D\u662F\u5408\u6CD5 JSON\uFF1A${describe(err)}` };
759
+ return {
760
+ ok: false,
761
+ status: 400,
762
+ message: t("web.body_bad_json", { reason: describe(err) }, lang)
763
+ };
998
764
  }
999
765
  }
1000
766
  function describe(err) {
@@ -1003,7 +769,6 @@ function describe(err) {
1003
769
  function asRecord(value) {
1004
770
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
1005
771
  }
1006
-
1007
772
  // src/assets.ts
1008
773
  var INLINE_ARTIFACT_TYPES = /* @__PURE__ */ new Map([
1009
774
  [".png", "image/png"],
@@ -1087,9 +852,9 @@ function resolveStaticPath(webRoot, pathname) {
1087
852
  return null;
1088
853
  }
1089
854
  }
1090
- function placeholderPage(version) {
855
+ function placeholderPage(version, lang) {
1091
856
  return `<!doctype html>
1092
- <html lang="zh-CN">
857
+ <html lang="${t("web.placeholder_html_lang", void 0, lang)}">
1093
858
  <head>
1094
859
  <meta charset="utf-8">
1095
860
  <meta name="viewport" content="width=device-width,initial-scale=1">
@@ -1104,10 +869,21 @@ code{background:#1b2027;padding:.1rem .35rem;border-radius:3px}
1104
869
  </head>
1105
870
  <body><main>
1106
871
  <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>
872
+ <p>${t("web.placeholder_ready", void 0, lang)}${t(
873
+ "web.placeholder_needs_build",
874
+ {
875
+ no_build: `<strong>${t("web.placeholder_no_build", void 0, lang)}</strong>`,
876
+ cmd: "<code>pnpm build</code>"
877
+ },
878
+ lang
879
+ )}</p>
880
+ <p>${t(
881
+ "web.placeholder_rest",
882
+ {
883
+ endpoints: ["/api/health", "/api/config", "/api/events"].map((path) => `<code>GET ${path}</code>`).join(t("web.placeholder_endpoint_sep", void 0, lang))
884
+ },
885
+ lang
886
+ )}</p>
1111
887
  </main></body></html>`;
1112
888
  }
1113
889
  function escapeHtml(text) {
@@ -1125,25 +901,26 @@ function builtinConnectors(tools) {
1125
901
  return [...counts.entries()].map(([name, toolCount]) => ({ name, toolCount })).sort((a, b) => a.name.localeCompare(b.name));
1126
902
  }
1127
903
  function toWireRole(role, notices) {
904
+ const cutOf = (kind) => notices.find((n) => n.role === role.name && n.kind === kind)?.cut ?? [];
1128
905
  return {
1129
906
  name: role.name,
1130
907
  description: role.description,
1131
908
  source: role.source,
1132
- // `?? null` 而不是 `?? []`:不给 tools 的角色是「不限」,空数组是
1133
- // 「一个都不给」。合并成同一个值之后,`general` 会显示成一个废掉的角色
1134
909
  tools: role.tools ?? null,
910
+ skills: role.skills ?? null,
1135
911
  maxTurns: role.maxTurns ?? null,
1136
- toolsCut: notices.find((n) => n.role === role.name)?.cut ?? []
912
+ toolsCut: cutOf("tools"),
913
+ skillsCut: cutOf("skills")
1137
914
  };
1138
915
  }
1139
- function toWireSkill(skill) {
916
+ function toWireSkill(skill, residency) {
1140
917
  const description = skillIndexDescription(skill.description);
1141
918
  return {
1142
919
  name: skill.name,
1143
920
  category: skill.category || "general",
1144
921
  description,
1145
- // 索引里的那一行是 `- <名字>: <描述>`,按它整行估才是这条技能的真实占用
1146
- tokens: estimateIndexTokens(`- ${skill.name}: ${description}`),
922
+ tokens: estimateIndexTokens(` - ${skill.name}: ${description}`),
923
+ residency,
1147
924
  scope: skill.scope,
1148
925
  type: skill.type
1149
926
  };
@@ -1159,22 +936,18 @@ function toWireMcp(server) {
1159
936
  toolCount: server.toolCount,
1160
937
  reconnectAttempts: server.reconnectAttempts,
1161
938
  ...server.lastError ? { lastError: server.lastError } : {},
1162
- // **原样转发,这一层不判也不补**:来源是装配层的结论(`connectAll(…, source)`),
1163
- // 在这儿再猜一次(比如「名字里有 `__` 就算宿主的」)等于给同一件事第二个答案
1164
939
  source: server.source
1165
940
  };
1166
941
  }
1167
- function collectCapabilities(runtime, tools, bound) {
942
+ function collectCapabilities(runtime, tools, bound, role) {
943
+ const residency = runtime.skillIndexResidency(role);
1168
944
  return {
1169
945
  roles: runtime.agentRoles.map((r) => toWireRole(r, runtime.agentRoleNotices)),
1170
- skills: runtime.skills.map(toWireSkill),
946
+ skills: runtime.skills.map((s) => toWireSkill(s, residency.get(s.name) ?? "indexed")),
1171
947
  builtinConnectors: builtinConnectors(tools),
1172
948
  mcpServers: runtime.mcpServers.map(toWireMcp),
1173
949
  project: {
1174
950
  root: bound?.workspace.root ?? null,
1175
- // 没绑工作区时报 `false` 而不是 `true`:界面拿它决定要不要画那句
1176
- // 「不受信任的目录连读都不读」,而没有工作区时那句话是对的
1177
- // —— 项目层确实一个字都没读
1178
951
  trusted: bound?.trust.trusted ?? false
1179
952
  }
1180
953
  };
@@ -1201,8 +974,6 @@ function toWireCommand(def) {
1201
974
  return {
1202
975
  name: def.name,
1203
976
  description: def.description,
1204
- // 没写就不带这个键(不是空串):空串会被画成一个空的参数提示位,
1205
- // 于是「这条命令不吃参数」和「作者忘了写提示」看起来一样
1206
977
  ...def.argumentHint ? { argumentHint: def.argumentHint } : {}
1207
978
  };
1208
979
  }
@@ -1247,14 +1018,179 @@ function replaceLeadingText(message, prompt) {
1247
1018
  const head = { type: "text", text: prompt };
1248
1019
  return [head, ...message.slice(1)];
1249
1020
  }
1250
- function toWireSandbox(iso) {
1021
+ var MAX_CANDIDATES = 20;
1022
+ var MAX_CANDIDATE_LIMIT = 50;
1023
+ function visibilityOf(ctx, sessionId) {
1024
+ const root = ctx.runtime.workspaces.of(sessionId)?.projectContext.rootDir;
1025
+ return {
1026
+ currentSessionId: sessionId,
1027
+ ...root ? { workspaceCwd: root } : {},
1028
+ allWorkspaces: ctx.runtime.config.sessionSearch?.allWorkspaces === true
1029
+ };
1030
+ }
1031
+ function toWireCandidate(row) {
1032
+ return {
1033
+ sessionId: row.sessionId,
1034
+ title: row.title,
1035
+ ...row.cwd ? { cwd: row.cwd } : {},
1036
+ updatedAt: row.updatedAt,
1037
+ messageCount: row.messageCount,
1038
+ sameWorkspace: row.sameWorkspace
1039
+ };
1040
+ }
1041
+ function toWireReference(part) {
1042
+ const { type: _type, text: _text, ...rest } = part;
1043
+ return rest;
1044
+ }
1045
+ function candidateQuery(rawUrl) {
1046
+ const params = new URL(rawUrl ?? "/", "http://127.0.0.1").searchParams;
1047
+ const asked = Number.parseInt(params.get("limit") ?? "", 10);
1048
+ const limit = Number.isFinite(asked) && asked > 0 ? Math.min(asked, MAX_CANDIDATE_LIMIT) : MAX_CANDIDATES;
1049
+ return { query: params.get("q")?.trim() ?? "", limit };
1050
+ }
1051
+ function listReferenceCandidates(ctx, res, sessionId, rawUrl, lang) {
1052
+ const references = ctx.runtime.sessionReferences;
1053
+ if (!references) {
1054
+ return sendError(
1055
+ res,
1056
+ 503,
1057
+ "no-session-store",
1058
+ t("web.reference_store_unavailable", void 0, lang)
1059
+ );
1060
+ }
1061
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1062
+ return sendUnknownSession(res, sessionId);
1063
+ }
1064
+ const { query, limit } = candidateQuery(rawUrl);
1065
+ const rows = references.candidates({ ...visibilityOf(ctx, sessionId), query, limit });
1066
+ sendJson(res, 200, {
1067
+ candidates: rows.map(toWireCandidate)
1068
+ });
1069
+ }
1070
+ function workspaceRootOf(ctx, sessionId) {
1071
+ return ctx.runtime.workspaces.of(sessionId)?.projectContext.rootDir ?? null;
1072
+ }
1073
+ function listFileCandidates(ctx, res, sessionId, rawUrl, lang) {
1074
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1075
+ return sendUnknownSession(res, sessionId);
1076
+ }
1077
+ const root = workspaceRootOf(ctx, sessionId);
1078
+ if (!root) {
1079
+ return sendError(
1080
+ res,
1081
+ 409,
1082
+ "no-workspace",
1083
+ t("web.file_candidates_no_workspace", void 0, lang)
1084
+ );
1085
+ }
1086
+ const { query, limit } = candidateQuery(rawUrl);
1087
+ const result = ctx.runtime.workspaceFiles.candidates(query, limit, root);
1088
+ if (!result) {
1089
+ return sendError(
1090
+ res,
1091
+ 503,
1092
+ "file-list-unavailable",
1093
+ t("web.file_list_unavailable", void 0, lang)
1094
+ );
1095
+ }
1096
+ sendJson(res, 200, {
1097
+ candidates: result.candidates.map((path) => ({ path })),
1098
+ unmentionable: result.unmentionable,
1099
+ truncated: result.truncated
1100
+ });
1101
+ }
1102
+ function attachMentions(ctx, sessionId, message) {
1103
+ const text = leadingText2(message);
1104
+ if (text === null) return { message, references: [], files: [] };
1105
+ const mentions = extractAllMentions(text);
1106
+ if (mentions.files.length === 0 && mentions.sessions.length === 0) {
1107
+ return { message, references: [], files: [] };
1108
+ }
1109
+ const parts = [];
1110
+ let used = 0;
1111
+ const fileReceipts = [];
1112
+ for (const [index, path] of mentions.files.entries()) {
1113
+ const part = index >= MAX_ATTACH_FILES ? { type: "file", path, omitted: "over-limit" } : readOneFile(ctx, sessionId, path, used);
1114
+ parts.push(part);
1115
+ if (part.text !== void 0) used += utf8ByteLength(part.text);
1116
+ fileReceipts.push(toWireFileReference(part));
1117
+ }
1118
+ const refParts = [];
1119
+ for (const [index, ref] of mentions.sessions.entries()) {
1120
+ if (index >= MAX_SESSION_REFS) {
1121
+ refParts.push({ type: "session", ref, omitted: "too-many" });
1122
+ continue;
1123
+ }
1124
+ const outcome = resolveOne(ctx, sessionId, ref, used);
1125
+ refParts.push(outcome);
1126
+ if (outcome.text !== void 0) used += utf8ByteLength(outcome.text);
1127
+ }
1128
+ parts.push(...refParts);
1129
+ return {
1130
+ message: [...parts, ...asParts(message)],
1131
+ references: refParts.map(toWireReference),
1132
+ files: fileReceipts
1133
+ };
1134
+ }
1135
+ function readOneFile(ctx, sessionId, path, usedBytes) {
1136
+ const remaining = MAX_ATTACH_TOTAL_BYTES - usedBytes;
1137
+ if (remaining <= 0) return { type: "file", path, omitted: "over-limit" };
1138
+ const budget = Math.min(MAX_ATTACH_BYTES, remaining);
1139
+ const root = workspaceRootOf(ctx, sessionId);
1140
+ if (!root) return { type: "file", path, omitted: "denied" };
1141
+ const out = ctx.runtime.workspaceFiles.readFile(path, root);
1142
+ if (!out.ok) {
1143
+ return {
1144
+ type: "file",
1145
+ path,
1146
+ omitted: out.reason,
1147
+ ...out.bytes === void 0 ? {} : { bytes: out.bytes }
1148
+ };
1149
+ }
1150
+ if (out.bytes <= budget) {
1151
+ return { type: "file", path, text: out.text, bytes: out.bytes };
1152
+ }
1153
+ const raw = new TextEncoder().encode(out.text);
1154
+ return {
1155
+ type: "file",
1156
+ path,
1157
+ text: new TextDecoder().decode(raw.slice(0, budget)),
1158
+ truncatedTo: budget,
1159
+ bytes: out.bytes
1160
+ };
1161
+ }
1162
+ function resolveOne(ctx, sessionId, ref, usedBytes) {
1163
+ const references = ctx.runtime.sessionReferences;
1164
+ if (!references) return { type: "session", ref, omitted: "invalid-reference" };
1165
+ const resolved = references.resolve({ ...visibilityOf(ctx, sessionId), ref });
1166
+ if (resolved.reason || !resolved.sessionId) {
1167
+ return { type: "session", ref, omitted: resolved.reason ?? "invalid-reference" };
1168
+ }
1169
+ const surface = references.surface(resolved.sessionId);
1170
+ if (!surface) return { type: "session", ref, omitted: "invalid-reference" };
1171
+ return attachSessionSurface(ref, surface, usedBytes).part;
1172
+ }
1173
+ function toWireFileReference(part) {
1174
+ const { type: _type, text: _text, ...rest } = part;
1175
+ return rest;
1176
+ }
1177
+ function leadingText2(message) {
1178
+ if (typeof message === "string") return message;
1179
+ const first = message[0];
1180
+ return first && first.type === "text" ? first.text : null;
1181
+ }
1182
+ function asParts(message) {
1183
+ return typeof message === "string" ? [{ type: "text", text: message }] : message;
1184
+ }
1185
+ function toWireSandbox(iso, terminalEnabled) {
1251
1186
  return {
1252
1187
  level: iso.level,
1253
1188
  backend: iso.backend,
1254
1189
  reason: iso.reason,
1255
1190
  platform: iso.platform,
1256
1191
  covers: SANDBOX_COVERS,
1257
- excludes: SANDBOX_EXCLUDES
1192
+ excludes: SANDBOX_EXCLUDES,
1193
+ terminalEnabled
1258
1194
  };
1259
1195
  }
1260
1196
  function toWireRule(rule) {
@@ -1274,6 +1210,16 @@ function toWireAudit(audit) {
1274
1210
  }));
1275
1211
  return { rows, dropped: audit.dropped };
1276
1212
  }
1213
+ function toWireApproval(row) {
1214
+ return {
1215
+ id: row.id,
1216
+ toolName: row.toolName,
1217
+ target: row.target,
1218
+ scope: row.scope,
1219
+ decision: row.decision,
1220
+ at: row.createdAt
1221
+ };
1222
+ }
1277
1223
  function toWireManaged(managed) {
1278
1224
  return {
1279
1225
  present: managed.present,
@@ -1284,15 +1230,12 @@ function toWireManaged(managed) {
1284
1230
  }
1285
1231
  var PERMISSIONS_UNAVAILABLE = {
1286
1232
  level: "unknown",
1287
- // 配置里那一档同样说不出来(方案 56 §1.3):`PermissionsControl` 整个没起来,
1288
- // 而这两格都是从它上面读的。⚠️ **不在这儿回落成 `default`** —— 那是一个
1289
- // 看起来确切、其实没人核过的值;`unknown` 会被界面原样印出来,正是要的效果。
1290
- // `configuredLayer` 跟着报 `unknown`:那一档的含义就是「分不出」
1291
1233
  configured: "unknown",
1292
1234
  configuredLayer: "unknown",
1293
1235
  rules: [],
1294
1236
  shadows: [],
1295
- managed: { present: false, path: "", rulesOnly: false, bypassDisabled: false }
1237
+ managed: { present: false, path: "", rulesOnly: false, bypassDisabled: false },
1238
+ cached: []
1296
1239
  };
1297
1240
  function toWirePolicy(policy) {
1298
1241
  const dirs = policy.dirs.map((d) => ({
@@ -1324,36 +1267,19 @@ function toWireTrustList(known, workspaces, currentRoot) {
1324
1267
  function collectSecurity(runtime, workspaces, bound, session) {
1325
1268
  const perms = runtime.permissions;
1326
1269
  return {
1327
- // `null` 是第三种状态(沙箱没建起来),不是「仅进程隔离」
1328
- sandbox: runtime.isolation ? toWireSandbox(runtime.isolation) : null,
1270
+ sandbox: runtime.isolation ? toWireSandbox(runtime.isolation, runtime.config.sandbox?.terminal !== false) : null,
1329
1271
  permission: perms ? {
1330
- /*
1331
- * **这个会话**那一档,取不到才退回进程那一份。
1332
- *
1333
- * 退回去的那一支不是兜底措辞,是一句真话:这个进程没在跑那段会话
1334
- * (只剩历史 / 被冷却)时,「它此刻是哪一档」没有答案,而进程那一档
1335
- * 是这台机器上此刻真在生效的那个。判据逐字同 `permission.ts` 的
1336
- * `permissionState()`。⚠️ 下面四样**刻意不跟着分家**,见文件头。
1337
- */
1338
1272
  level: session?.level() ?? perms.level(),
1339
- /*
1340
- * 配置里那一档(方案 56 §1.3)。**刻意不跟着上面那一格走会话** ——
1341
- * 它答的是「新会话开出来是哪一档」,一个进程一份。这两格经常真的
1342
- * 不同(在底栏改过一次档就当场分家),而那正是界面画两行的理由:
1343
- * 只画一行的话,「此刻在跑的是 bypass」和「配置里赢在②的是 default」
1344
- * 会被混成一句话
1345
- */
1346
1273
  configured: perms.configured().level,
1347
1274
  configuredLayer: perms.configured().layer,
1348
1275
  rules: perms.rules().map(toWireRule),
1349
1276
  shadows: perms.shadows().map(toWireShadow),
1350
- managed: toWireManaged(perms.managed())
1277
+ managed: toWireManaged(perms.managed()),
1278
+ cached: perms.approvals().map(toWireApproval)
1351
1279
  } : PERMISSIONS_UNAVAILABLE,
1352
1280
  workspace: bound ? toWireSecurityWorkspace(bound) : null,
1353
1281
  knownWorkspaces: toWireTrustList(workspaces.known(), workspaces, bound?.workspace.root ?? null),
1354
1282
  policy: toWirePolicy(runtime.policy),
1355
- // 权限层起不来时是**空账**而不是别的什么:没有权限层就等于什么都没判过,
1356
- // 而那句话是真的(同 runtime 那边 `audit()` 的兜底,两处必须一致)
1357
1283
  audit: perms ? toWireAudit(perms.audit()) : { rows: [], dropped: 0 }
1358
1284
  };
1359
1285
  }
@@ -1371,7 +1297,6 @@ function unknownRoleToHttp(role, lang) {
1371
1297
  message: t("web.session_unknown_role", { role }, lang)
1372
1298
  };
1373
1299
  }
1374
-
1375
1300
  // src/sessions/revive.ts
1376
1301
  function persistedDecision(row) {
1377
1302
  if (!row) return null;
@@ -1385,7 +1310,6 @@ function reviveSession(ctx, sessionId, lang) {
1385
1310
  const outcome = ctx.runtime.sessionFactory.revive({
1386
1311
  sessionId,
1387
1312
  decision: persistedDecision(ctx.runtime.sessions?.get(sessionId) ?? null),
1388
- // 绑不上那句 `detail` 跟着请求语言走,同建会话那条路(方案 58 PR-2)
1389
1313
  ...lang === void 0 ? {} : { lang }
1390
1314
  });
1391
1315
  if (outcome.ok) {
@@ -1405,7 +1329,9 @@ function toWireSettingRow(row) {
1405
1329
  layer: row.layer,
1406
1330
  chain: row.chain.map(toWireSettingStep),
1407
1331
  overridable: row.overridable,
1408
- writes: row.writes.map(toWireSettingWrite)
1332
+ writes: row.writes.map(toWireSettingWrite),
1333
+ ...row.valueKind === void 0 ? {} : { valueKind: row.valueKind },
1334
+ ...row.choices === void 0 ? {} : { choices: [...row.choices] }
1409
1335
  };
1410
1336
  }
1411
1337
  function toWireSettingStep(step) {
@@ -1486,23 +1412,18 @@ function collectTools(runtime, sessionId, session) {
1486
1412
  for (const row of runtime.permissions?.gates(level) ?? []) gates.set(row.name, row);
1487
1413
  }
1488
1414
  const tools = runtime.tools.map((tool) => {
1489
- const gate = gates.get(tool.name);
1415
+ const gate2 = gates.get(tool.name);
1490
1416
  return {
1491
1417
  name: tool.name,
1492
1418
  description: tool.description,
1493
1419
  source: tool.source,
1494
- // 类型只有 `gates` 那一路知道(core 的 `mapOperation()` 三级回退)。
1495
- // 拿不到时回 `command` ——「最保守的那一类」而不是「最常见的那一类」:
1496
- // 这一格此刻只被界面拿去分组,而分错组的代价远小于按工具名前缀猜一个
1497
- // (那种猜法在第一个不按前缀命名的工具上就错,判据在 `WireToolSummary.source`)
1498
- type: gate?.type ?? "command",
1420
+ type: gate2?.type ?? "command",
1499
1421
  calls: counts.get(tool.name) ?? 0,
1500
- ...gate === void 0 ? {} : { gate: toWireGate(gate) }
1422
+ ...gate2 === void 0 ? {} : { gate: toWireGate(gate2) }
1501
1423
  };
1502
1424
  });
1503
1425
  return { tools, level: level ?? "unknown" };
1504
1426
  }
1505
-
1506
1427
  // src/api.ts
1507
1428
  var APPROVAL_OUTCOMES = /* @__PURE__ */ new Set([
1508
1429
  "allow-once",
@@ -1524,40 +1445,23 @@ function config(ctx, res, lang) {
1524
1445
  workspace: bound ? toWireSessionWorkspace(bound) : null,
1525
1446
  knownWorkspaces: toWireKnownWorkspaces(runtime.workspaces.known()),
1526
1447
  usageScope: runtime.usageScope,
1527
- // 原样转发,**一个字段都不在这儿重算**:窗口是 `AgentLoop` 手里那一份本体、
1528
- // 阈值走的是 core 那个「装配层唯一的入口」。在这一层拿 `runtime.config` 拼一份
1529
- // 出来,就是给同一条线第二个说法(同 `artifactsRoot` 那条判据)
1530
1448
  compression: runtime.compression,
1531
- // 没配就**不带这个键**(决定 8)。写成 `?? null` 之类的话,浏览器那边
1532
- // 「配没配上限」就要靠判空值,而金额显不显示正是挂在这一个判断上
1533
1449
  ...runtime.config.budget?.maxCostUsd !== void 0 ? { maxCostUsd: runtime.config.budget.maxCostUsd } : {},
1534
- // 品牌词标同上:**没配就不带这个键**(决定 19)。侧栏那一格「显不显示」
1535
- // 挂在这一个判断上 —— 给一个 `null` 或者空串的话,「宿主没给」就成了
1536
- // 浏览器要自己解读的东西,而它的硬约束是不给就什么都不画
1537
1450
  ...runtime.config.brand !== void 0 ? { brand: runtime.config.brand } : {},
1451
+ ...runtime.config.sidebarMenu !== void 0 ? { sidebarMenu: runtime.config.sidebarMenu } : {},
1538
1452
  provider: runtime.providerInfo,
1539
1453
  tools: runtime.tools,
1540
- // 请求带了语言、而且宿主给得出「换个语言说一遍」时才走那条;两个条件缺一个
1541
- // 就是 `diagnosticList`(进程语言)——判据在 `WebRuntimeView.diagnosticsIn` 上
1542
1454
  diagnostics: (lang && runtime.diagnosticsIn?.(lang)) ?? runtime.diagnosticList,
1543
1455
  about: {
1544
- // 版本从 `ctx.version` 取 —— 那是**宿主传进来的**那一个(`/api/health` 回的
1545
- // 也是它)。在这儿读 `package.json` 会给同一个数第二个说法,而嵌入宿主的
1546
- // 版本号本来就不是我们的版本号
1547
1456
  version: ctx.version,
1548
1457
  nodeVersion: process.version,
1549
1458
  platform: `${process.platform}-${process.arch}`,
1550
1459
  homeDir: runtime.config.homeDir,
1551
1460
  dbPath: runtime.config.dbPath,
1552
- // 缺省档(字面量 `default`)时**不带这个键** —— 判据在 `WireAbout.profile`:
1553
- // 恒发一行「配置档:default」对没换过档的人是噪声
1554
1461
  ...runtime.config.profile !== "default" ? { profile: runtime.config.profile } : {}
1555
1462
  },
1556
- // 绑在回环之外了没有(2026-08-18)。**原样转发 `ctx.lanExposed`,这一层不判
1557
- // 也不改名** —— 它是一个事实,不是一个结论(判据在 `WireConfigResponse.lanExposed`
1558
- // 上)。今天的消费方只有能力页那张「新建身份」:它据此在**按下去之前**画出
1559
- // 「这一档下建不了」,而真正拦住那一发的是 `role-add.ts` 里那道 403
1560
- lanExposed: ctx.lanExposed
1463
+ lanExposed: ctx.lanExposed,
1464
+ nativeDirPicker: ctx.nativeDirPicker
1561
1465
  });
1562
1466
  }
1563
1467
  function listSessions(ctx, res, query, _lang) {
@@ -1565,22 +1469,15 @@ function listSessions(ctx, res, query, _lang) {
1565
1469
  ctx.hub.listSessions(),
1566
1470
  ctx.runtime.sessions,
1567
1471
  query ?? null,
1568
- // 每行带上**这个进程手里**那份绑定(决定 18)—— 侧栏按「任务 / 空间」分组要用它。
1569
- // ⚠️ 手里没有不等于那一行就说不出地盘:会话库 v7 之后落盘的那次决定会接着答
1570
- // (顺序在 `sessions/summary.ts` 的 `toWireSummaryWorkspace` 那张表上)
1571
1472
  (id) => ctx.runtime.workspaces.of(id),
1572
- // 以及它以什么身份跑(2026-08-18)。只有活着的那几行答得出 —— 身份只活在
1573
- // 内存里。⚠️ **2026-08-19 起这句话和 `workspace` 那格不再是同一条边界**:
1574
- // 那一格落盘了,这一格没有 —— 那是一笔明写的欠账,记在
1575
- // `WireSessionSummary.role` 上(复活的旧会话会以 `general` 接着跑)
1576
1473
  (id) => ctx.runtime.sessionFactory.get(id)?.role ?? null
1577
1474
  );
1578
1475
  sendJson(res, 200, { sessions });
1579
1476
  }
1580
- function getSession(ctx, res, sessionId, _lang) {
1477
+ function getSession(ctx, res, sessionId, lang) {
1581
1478
  const hub = ctx.hub.listSessions().find((one) => one.id === sessionId);
1582
1479
  const row = ctx.runtime.sessions?.get(sessionId) ?? null;
1583
- if (hub === void 0 && row === null) return sendUnknownSession(res, sessionId);
1480
+ if (hub === void 0 && row === null) return sendUnknownSession(res, sessionId, lang);
1584
1481
  sendJson(res, 200, {
1585
1482
  session: toWireSummary(sessionId, hub, row, {
1586
1483
  workspace: ctx.runtime.workspaces.of(sessionId),
@@ -1608,11 +1505,11 @@ async function deleteSession(ctx, res, sessionId, lang) {
1608
1505
  );
1609
1506
  }
1610
1507
  const outcome = await catalog.delete(sessionId);
1611
- if (!outcome.deleted && !inHub) return sendUnknownSession(res, sessionId);
1508
+ if (!outcome.deleted && !inHub) return sendUnknownSession(res, sessionId, lang);
1612
1509
  sendJson(res, 200, outcome);
1613
1510
  }
1614
1511
  async function patchSession(ctx, req, res, sessionId, lang) {
1615
- const body = await readJsonBody(req);
1512
+ const body = await readJsonBody(req, void 0, lang);
1616
1513
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
1617
1514
  const raw = asRecord(body.value).title;
1618
1515
  const title = typeof raw === "string" ? raw.trim() : "";
@@ -1629,7 +1526,7 @@ async function patchSession(ctx, req, res, sessionId, lang) {
1629
1526
  );
1630
1527
  }
1631
1528
  const row = catalog.rename(sessionId, title);
1632
- if (!row) return sendUnknownSession(res, sessionId);
1529
+ if (!row) return sendUnknownSession(res, sessionId, lang);
1633
1530
  const hub = ctx.hub.listSessions().find((s) => s.id === sessionId);
1634
1531
  sendJson(res, 200, {
1635
1532
  session: toWireSummary(sessionId, hub, row, {
@@ -1638,9 +1535,9 @@ async function patchSession(ctx, req, res, sessionId, lang) {
1638
1535
  })
1639
1536
  });
1640
1537
  }
1641
- function listMessages(ctx, res, sessionId, _lang) {
1538
+ function listMessages(ctx, res, sessionId, lang) {
1642
1539
  if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1643
- return sendUnknownSession(res, sessionId);
1540
+ return sendUnknownSession(res, sessionId, lang);
1644
1541
  }
1645
1542
  const messages = ctx.runtime.sessionStore?.loadMessages(sessionId) ?? [];
1646
1543
  sendJson(res, 200, { messages });
@@ -1652,14 +1549,14 @@ async function postMessage(ctx, req, res, sessionId, lang) {
1652
1549
  return sendError(res, revived.status, revived.code, revived.message);
1653
1550
  }
1654
1551
  if (!revived.ok && !ctx.hub.isCooled(sessionId)) {
1655
- return sendUnknownSession(res, sessionId);
1552
+ return sendUnknownSession(res, sessionId, lang);
1656
1553
  }
1657
1554
  }
1658
- const body = await readJsonBody(req);
1555
+ const body = await readJsonBody(req, void 0, lang);
1659
1556
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
1660
1557
  const message = parseUserContent(asRecord(body.value).message);
1661
1558
  if (message === null) {
1662
- return sendError(res, 400, "bad-message", "message \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\u6216 EpochContentPart[]");
1559
+ return sendError(res, 400, "bad-message", t("web.bad_message", void 0, lang));
1663
1560
  }
1664
1561
  const live = ctx.runtime.sessionFactory.get(sessionId);
1665
1562
  const picked = pickTurnRole(live, asRecord(body.value)["role"]);
@@ -1668,21 +1565,22 @@ async function postMessage(ctx, req, res, sessionId, lang) {
1668
1565
  return sendError(res, mapped.status, mapped.code, mapped.message);
1669
1566
  }
1670
1567
  const expansion = expandUserContent(ctx.runtime, message, live?.model ?? null, picked.role);
1671
- const started = ctx.hub.start(sessionId, expansion.message, expansion.decorate);
1568
+ const attached = attachMentions(ctx, sessionId, expansion.message);
1569
+ const started = ctx.hub.start(sessionId, attached.message, expansion.decorate);
1672
1570
  if (started.ok) {
1673
1571
  return sendJson(res, 202, {
1674
1572
  accepted: true,
1675
1573
  queued: started.queued,
1676
- // 不是命令时**整个键缺席**(不是空对象):界面据此区分「认成命令了、
1677
- // 只是没话要说」和「原样发给模型了」,见 `WireSendMessageResponse.command`
1678
- ...expansion.notice ? { command: expansion.notice } : {}
1574
+ ...expansion.notice ? { command: expansion.notice } : {},
1575
+ ...attached.references.length > 0 ? { references: attached.references } : {},
1576
+ ...attached.files.length > 0 ? { files: attached.files } : {}
1679
1577
  });
1680
1578
  }
1681
- if (started.reason === "unknown-session") return sendUnknownSession(res, sessionId);
1579
+ if (started.reason === "unknown-session") return sendUnknownSession(res, sessionId, lang);
1682
1580
  if (started.reason === "cooled") {
1683
1581
  return sendError(res, 409, "session-cooled", t("web.session_cooled", void 0, lang));
1684
1582
  }
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`, {
1583
+ sendError(res, 409, "busy", t("web.session_busy", { state: started.state }, lang), {
1686
1584
  "X-Epoch-Turn-State": started.state
1687
1585
  });
1688
1586
  }
@@ -1692,40 +1590,45 @@ function pickTurnRole(live, raw) {
1692
1590
  if (!live?.roles.has(name)) return { ok: false, detail: name };
1693
1591
  return { ok: true, role: { control: live.roles, name } };
1694
1592
  }
1695
- function abortSession(ctx, res, sessionId, _lang) {
1696
- if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId);
1593
+ function abortSession(ctx, res, sessionId, lang) {
1594
+ if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId, lang);
1697
1595
  sendJson(res, 200, { aborted: ctx.hub.abort(sessionId) });
1698
1596
  }
1699
- function listApprovals(ctx, res, sessionId, _lang) {
1700
- if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId);
1597
+ function listApprovals(ctx, res, sessionId, lang) {
1598
+ if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId, lang);
1701
1599
  sendJson(res, 200, {
1702
1600
  state: ctx.hub.stateOf(sessionId),
1703
1601
  approvals: ctx.hub.listPending(sessionId)
1704
1602
  });
1705
1603
  }
1706
- async function respondApproval(ctx, req, res, requestId, _lang) {
1707
- const body = await readJsonBody(req);
1604
+ async function respondApproval(ctx, req, res, requestId, lang) {
1605
+ const body = await readJsonBody(req, void 0, lang);
1708
1606
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
1709
1607
  const payload = asRecord(body.value);
1710
1608
  const outcome = payload.outcome;
1711
1609
  if (typeof outcome !== "string" || !APPROVAL_OUTCOMES.has(outcome)) {
1712
- return sendError(res, 400, "bad-outcome", `outcome \u5FC5\u987B\u662F ${[...APPROVAL_OUTCOMES].join("/")}`);
1610
+ return sendError(
1611
+ res,
1612
+ 400,
1613
+ "bad-outcome",
1614
+ t("web.bad_outcome", { choices: [...APPROVAL_OUTCOMES].join("/") }, lang)
1615
+ );
1713
1616
  }
1714
1617
  const note = typeof payload.note === "string" ? payload.note : void 0;
1715
1618
  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`);
1619
+ return sendError(res, 404, "unknown-request", t("web.approval_gone", { id: requestId }, lang));
1717
1620
  }
1718
1621
  sendJson(res, 200, { resolved: true });
1719
1622
  }
1720
- function listQuestions(ctx, res, sessionId, _lang) {
1721
- if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId);
1623
+ function listQuestions(ctx, res, sessionId, lang) {
1624
+ if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId, lang);
1722
1625
  sendJson(res, 200, {
1723
1626
  state: ctx.hub.stateOf(sessionId),
1724
1627
  questions: ctx.hub.listPendingQuestions(sessionId)
1725
1628
  });
1726
1629
  }
1727
- async function respondQuestion(ctx, req, res, requestId, _lang) {
1728
- const body = await readJsonBody(req);
1630
+ async function respondQuestion(ctx, req, res, requestId, lang) {
1631
+ const body = await readJsonBody(req, void 0, lang);
1729
1632
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
1730
1633
  const payload = asRecord(body.value);
1731
1634
  if (!isQuestionAnswerMap(payload.answers)) {
@@ -1733,7 +1636,7 @@ async function respondQuestion(ctx, req, res, requestId, _lang) {
1733
1636
  res,
1734
1637
  400,
1735
1638
  "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}'
1639
+ t("web.bad_answers", { json: '{"answers":{},"skipped":true}' }, lang)
1737
1640
  );
1738
1641
  }
1739
1642
  const answer = {
@@ -1741,47 +1644,44 @@ async function respondQuestion(ctx, req, res, requestId, _lang) {
1741
1644
  ...payload.skipped === true ? { skipped: true } : {}
1742
1645
  };
1743
1646
  if (!ctx.hub.respondQuestion(requestId, answer)) {
1744
- return sendError(res, 404, "unknown-request", `\u63D0\u95EE ${requestId} \u5DF2\u88AB\u7B54\u590D\u6216\u5DF2\u5931\u6548`);
1647
+ return sendError(res, 404, "unknown-request", t("web.question_gone", { id: requestId }, lang));
1745
1648
  }
1746
1649
  sendJson(res, 200, { resolved: true });
1747
1650
  }
1748
- function listCapabilities(ctx, res, sessionId, _lang) {
1651
+ function listCapabilities(ctx, res, sessionId, lang) {
1749
1652
  if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1750
- return sendUnknownSession(res, sessionId);
1653
+ return sendUnknownSession(res, sessionId, lang);
1751
1654
  }
1752
1655
  const payload = collectCapabilities(
1753
1656
  ctx.runtime,
1754
1657
  ctx.runtime.tools,
1755
- ctx.runtime.workspaces.of(sessionId)
1658
+ ctx.runtime.workspaces.of(sessionId),
1659
+ ctx.runtime.sessionFactory.get(sessionId)?.role?.name ?? null
1756
1660
  );
1757
1661
  sendJson(res, 200, payload);
1758
1662
  }
1759
- function listCommands(ctx, res, sessionId, _lang) {
1663
+ function listCommands(ctx, res, sessionId, lang) {
1760
1664
  if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1761
- return sendUnknownSession(res, sessionId);
1665
+ return sendUnknownSession(res, sessionId, lang);
1762
1666
  }
1763
1667
  const commands = ctx.runtime.commands.list.map(toWireCommand);
1764
1668
  sendJson(res, 200, { commands });
1765
1669
  }
1766
- function getSecurity(ctx, res, sessionId, _lang) {
1670
+ function getSecurity(ctx, res, sessionId, lang) {
1767
1671
  if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1768
- return sendUnknownSession(res, sessionId);
1672
+ return sendUnknownSession(res, sessionId, lang);
1769
1673
  }
1770
1674
  const payload = collectSecurity(
1771
1675
  ctx.runtime,
1772
1676
  ctx.runtime.workspaces,
1773
1677
  ctx.runtime.workspaces.of(sessionId),
1774
- // **档位按会话取**(2026-08-16 那一轮之后 `PermissionManager` 一个会话一份)。
1775
- // 少了这个实参,这一屏印的是引导会话那一档,而那张卡的色带跟着一起答错人 ——
1776
- // 判据全文在 `security.ts` 文件头。工厂手里没有(只剩历史)时传 null,
1777
- // 投影自己退回进程那一份
1778
1678
  ctx.runtime.sessionFactory.get(sessionId)?.permissions ?? null
1779
1679
  );
1780
1680
  sendJson(res, 200, payload);
1781
1681
  }
1782
- function getTools(ctx, res, sessionId, _lang) {
1682
+ function getTools(ctx, res, sessionId, lang) {
1783
1683
  if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1784
- return sendUnknownSession(res, sessionId);
1684
+ return sendUnknownSession(res, sessionId, lang);
1785
1685
  }
1786
1686
  const payload = collectTools(
1787
1687
  ctx.runtime,
@@ -1790,17 +1690,17 @@ function getTools(ctx, res, sessionId, _lang) {
1790
1690
  );
1791
1691
  sendJson(res, 200, payload);
1792
1692
  }
1793
- function getSettings(ctx, res, sessionId, _lang) {
1794
- if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1795
- return sendUnknownSession(res, sessionId);
1693
+ function getSettings(ctx, res, sessionId, lang) {
1694
+ if (!ctx.hub.has(sessionId)) {
1695
+ return sendUnknownSession(res, sessionId, lang);
1796
1696
  }
1797
1697
  sendJson(res, 200, collectSettings(ctx.runtime, sessionId));
1798
1698
  }
1799
1699
  async function writeSetting(ctx, req, res, sessionId, lang) {
1800
- if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1801
- return sendUnknownSession(res, sessionId);
1700
+ if (!ctx.hub.has(sessionId)) {
1701
+ return sendUnknownSession(res, sessionId, lang);
1802
1702
  }
1803
- const body = await readJsonBody(req);
1703
+ const body = await readJsonBody(req, void 0, lang);
1804
1704
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
1805
1705
  const input = readSettingWriteBody(asRecord(body.value));
1806
1706
  if (!input.ok) {
@@ -1823,22 +1723,20 @@ async function writeSetting(ctx, req, res, sessionId, lang) {
1823
1723
  }
1824
1724
  sendJson(res, 200, toWireSettingsWrite(report));
1825
1725
  }
1826
- function getArtifacts(ctx, res, sessionId, _lang) {
1726
+ function getArtifacts(ctx, res, sessionId, lang) {
1827
1727
  if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
1828
- return sendUnknownSession(res, sessionId);
1728
+ return sendUnknownSession(res, sessionId, lang);
1829
1729
  }
1830
1730
  const root = ctx.runtime.workspaces.of(sessionId)?.workspace.root ?? null;
1831
1731
  const changes = root === null ? [] : ctx.runtime.sessionStore?.fileChanges(sessionId, root) ?? [];
1832
1732
  sendJson(res, 200, { changes, root });
1833
1733
  }
1834
- function getArtifact(ctx, res, sessionId, name, _lang) {
1734
+ function getArtifact(ctx, res, sessionId, name, lang) {
1835
1735
  const path = resolveArtifactPath(ctx.artifactsRoot, sessionId, name);
1836
- if (!path) return sendError(res, 404, "not-found", "artifact \u4E0D\u5B58\u5728");
1736
+ if (!path) return sendError(res, 404, "not-found", t("web.artifact_missing", void 0, lang));
1837
1737
  const { type, inline } = artifactDisposition(path);
1838
1738
  sendFile(res, path, {
1839
1739
  "Content-Type": type,
1840
- // 不认识的类型一律 attachment:让浏览器把 artifact 当 HTML 渲染,
1841
- // 等于在本服务的同源里执行任意脚本,而这个源上挂着全部 agent API
1842
1740
  "Content-Disposition": inline ? "inline" : `attachment; filename="${encodeURIComponent(name)}"`
1843
1741
  });
1844
1742
  }
@@ -1847,11 +1745,42 @@ function parseUserContent(raw) {
1847
1745
  if (Array.isArray(raw) && raw.length > 0) return raw;
1848
1746
  return null;
1849
1747
  }
1748
+ async function revokeApprovalCache(ctx, req, res, sessionId, lang) {
1749
+ if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId, lang);
1750
+ const perms = ctx.runtime.permissions;
1751
+ if (!perms) {
1752
+ return sendError(
1753
+ res,
1754
+ 503,
1755
+ "no-permission-layer",
1756
+ t("web.permission_unavailable", void 0, lang)
1757
+ );
1758
+ }
1759
+ const body = await readJsonBody(req);
1760
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
1761
+ const id = asRecord(body.value)["id"];
1762
+ if (typeof id !== "string" || id === "") {
1763
+ return sendError(res, 400, "bad-id", t("web.approval_cache_bad_id", void 0, lang));
1764
+ }
1765
+ const result = perms.revokeApproval(id);
1766
+ if (!result.ok && result.reason === "no-permission-layer") {
1767
+ return sendError(
1768
+ res,
1769
+ 503,
1770
+ "no-permission-layer",
1771
+ t("web.permission_unavailable", void 0, lang)
1772
+ );
1773
+ }
1774
+ const payload = {
1775
+ revoked: result.ok,
1776
+ cached: perms.approvals().map(toWireApproval)
1777
+ };
1778
+ sendJson(res, 200, payload);
1779
+ }
1850
1780
  function toWireCheckpoint(summary) {
1851
1781
  return {
1852
1782
  turnIndex: summary.turnIndex,
1853
1783
  createdAt: summary.createdAt,
1854
- // 用户自己那句原话,**照原样过** —— 它不是我们写的字,不进任何 catalog
1855
1784
  preview: summary.preview,
1856
1785
  fileCount: summary.fileCount,
1857
1786
  incomplete: summary.incomplete
@@ -1865,7 +1794,6 @@ function toWirePreview(preview) {
1865
1794
  turnIndex: preview.turnIndex,
1866
1795
  createdAt: preview.createdAt,
1867
1796
  preview: preview.preview,
1868
- // 引擎算好的那个布尔,**不在这一层从 cursor 重算** —— 镜像上压根没有 cursor
1869
1797
  canRewindConversation: preview.canRewindConversation,
1870
1798
  incomplete: preview.incomplete,
1871
1799
  files: preview.files.map(toWirePlan),
@@ -1878,9 +1806,6 @@ function toWireOutcome(outcome) {
1878
1806
  deleted: [...outcome.deleted],
1879
1807
  recreated: [...outcome.recreated],
1880
1808
  skippedConflicts: [...outcome.skippedConflicts],
1881
- // 成功时**整个不给这个键**(同 `note` / `skipped` 的规矩):契约里它就是
1882
- // 「可以不给」,而 `failed: undefined` 会被 JSON.stringify 抹掉之后长得一样,
1883
- // 写成条件展开是把「没有失败」这件事说明白
1884
1809
  ...outcome.failed === void 0 ? {} : { failed: outcome.failed }
1885
1810
  };
1886
1811
  }
@@ -1917,8 +1842,6 @@ function readRewindInput(body, lang) {
1917
1842
  const overwrite = raw === void 0 ? void 0 : raw;
1918
1843
  return {
1919
1844
  ok: true,
1920
- // 空数组和「没给」在引擎那边是一回事(`new Set(opts.overwrite ?? [])`),
1921
- // 但这里照样原样转:客户端发了空数组是它在说「一个都不覆盖」,那是一句话
1922
1845
  value: { turnIndex, scope, ...overwrite === void 0 ? {} : { overwrite } }
1923
1846
  };
1924
1847
  }
@@ -1980,12 +1903,200 @@ async function rewind(ctx, req, res, sessionId, lang) {
1980
1903
  const { turnIndex, scope, overwrite } = input.value;
1981
1904
  const result = await control.rewind(turnIndex, {
1982
1905
  scope,
1983
- // ⚠️ **只转用户点过头的那几个**。这里绝不能顺手把 `preview.conflicts` 并进去 ——
1984
- // 那正是「全部覆盖」那个按钮的等价物,而它毁掉的是用户自己刚写的东西
1985
1906
  ...overwrite === void 0 ? {} : { overwrite }
1986
1907
  });
1987
1908
  sendJson(res, 200, toWireRewindResult(result));
1988
1909
  }
1910
+ // src/context-budget.ts
1911
+ function toolSourceLookup(tools) {
1912
+ const byName = new Map(tools.map((tool) => [tool.name, tool.source]));
1913
+ return (name) => byName.get(name);
1914
+ }
1915
+ function contextBreakdownOf(ctx, live) {
1916
+ const historyText = live.session.getHistory().map((m) => m.content).join("\n");
1917
+ return {
1918
+ breakdown: live.agent.contextBreakdown({
1919
+ historyText,
1920
+ toolSource: toolSourceLookup(ctx.runtime.tools)
1921
+ })
1922
+ };
1923
+ }
1924
+ function getContext(ctx, res, sessionId, lang) {
1925
+ const live = ctx.runtime.sessionFactory.get(sessionId);
1926
+ if (!live) return sendUnknownSession(res, sessionId, lang);
1927
+ sendJson(res, 200, contextBreakdownOf(ctx, live));
1928
+ }
1929
+ async function postCompact(ctx, req, res, sessionId, lang) {
1930
+ const live = ctx.runtime.sessionFactory.get(sessionId);
1931
+ if (!live) return sendUnknownSession(res, sessionId, lang);
1932
+ const body = await readJsonBody(req);
1933
+ const raw = body.ok ? asRecord(body.value)["instruction"] : void 0;
1934
+ const instruction = typeof raw === "string" && raw.trim() ? raw.trim() : void 0;
1935
+ const result = await live.session.compact(instruction);
1936
+ sendJson(res, 200, {
1937
+ ran: result.ran,
1938
+ before: result.before,
1939
+ after: result.after,
1940
+ ...result.reason === void 0 ? {} : { reason: result.reason }
1941
+ });
1942
+ }
1943
+ function str(value) {
1944
+ return typeof value === "string" ? value : null;
1945
+ }
1946
+ function rounds(value) {
1947
+ return typeof value === "number" && Number.isInteger(value) ? value : null;
1948
+ }
1949
+ function parseCreate(body, lang) {
1950
+ const raw = asRecord(body);
1951
+ const objective = str(raw.objective);
1952
+ if (objective === null) {
1953
+ return { ok: false, message: t("web.goal_bad_objective", void 0, lang) };
1954
+ }
1955
+ if (raw.maxRounds === void 0) return { ok: true, value: { objective } };
1956
+ const maxRounds = rounds(raw.maxRounds);
1957
+ if (maxRounds === null) {
1958
+ return { ok: false, message: t("web.goal_bad_rounds", void 0, lang) };
1959
+ }
1960
+ return { ok: true, value: { objective, maxRounds } };
1961
+ }
1962
+ function parseUpdate(body, lang) {
1963
+ const raw = asRecord(body);
1964
+ const action = str(raw.action);
1965
+ if (action === null || !WIRE_GOAL_ACTIONS.includes(action)) {
1966
+ return {
1967
+ ok: false,
1968
+ message: t(
1969
+ "web.goal_bad_action",
1970
+ { action: action ?? String(raw.action), known: WIRE_GOAL_ACTIONS.join(" / ") },
1971
+ lang
1972
+ )
1973
+ };
1974
+ }
1975
+ if (action === "pause") return { ok: true, value: { action: "pause" } };
1976
+ if (action === "resume") return { ok: true, value: { action: "resume" } };
1977
+ if (action === "edit") {
1978
+ const objective = str(raw.objective);
1979
+ return objective === null ? { ok: false, message: t("web.goal_bad_objective", void 0, lang) } : { ok: true, value: { action: "edit", objective } };
1980
+ }
1981
+ if (action === "budget") {
1982
+ const maxRounds = rounds(raw.maxRounds);
1983
+ return maxRounds === null ? { ok: false, message: t("web.goal_bad_rounds", void 0, lang) } : { ok: true, value: { action: "budget", maxRounds } };
1984
+ }
1985
+ const evidence = str(raw.evidence);
1986
+ return evidence === null ? { ok: false, message: t("web.goal_bad_evidence", void 0, lang) } : { ok: true, value: { action: "complete", evidence } };
1987
+ }
1988
+ // src/goal/handlers.ts
1989
+ var BLOCKED_SCAN_LIMIT = 50;
1990
+ function goalsOf(ctx) {
1991
+ return ctx.runtime.goalCatalog;
1992
+ }
1993
+ function limitsOf() {
1994
+ return {
1995
+ defaultMaxRounds: DEFAULT_GOAL_MAX_ROUNDS,
1996
+ maxRounds: MAX_GOAL_MAX_ROUNDS,
1997
+ maxObjectiveChars: MAX_OBJECTIVE_CHARS,
1998
+ maxReasonChars: MAX_REASON_CHARS
1999
+ };
2000
+ }
2001
+ function toWireGoal(goal) {
2002
+ return {
2003
+ objective: goal.objective,
2004
+ phase: goal.phase,
2005
+ ...goal.block ? { block: { code: goal.block.code, message: goal.block.message } } : {},
2006
+ maxRounds: goal.maxRounds,
2007
+ roundsUsed: goal.roundsUsed,
2008
+ ...goal.completeEvidence === void 0 ? {} : { completeEvidence: goal.completeEvidence },
2009
+ createdAt: goal.createdAt,
2010
+ updatedAt: goal.updatedAt
2011
+ };
2012
+ }
2013
+ function toWriteResponse(result) {
2014
+ return result.ok ? { ok: true, goal: toWireGoal(result.goal) } : { ok: false, goal: null, reason: result.reason };
2015
+ }
2016
+ function gate(ctx, res, sessionId, lang) {
2017
+ const goals = goalsOf(ctx);
2018
+ if (!goals) {
2019
+ sendError(res, 503, "no-goal-store", t("web.goal_store_unavailable", void 0, lang));
2020
+ return null;
2021
+ }
2022
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
2023
+ sendUnknownSession(res, sessionId);
2024
+ return null;
2025
+ }
2026
+ return goals;
2027
+ }
2028
+ function getGoal(ctx, res, sessionId, lang) {
2029
+ const goals = gate(ctx, res, sessionId, lang);
2030
+ if (!goals) return;
2031
+ const goal = goals.current(sessionId);
2032
+ sendJson(res, 200, {
2033
+ goal: goal ? toWireGoal(goal) : null,
2034
+ limits: limitsOf()
2035
+ });
2036
+ }
2037
+ async function createGoal(ctx, req, res, sessionId, lang) {
2038
+ const goals = gate(ctx, res, sessionId, lang);
2039
+ if (!goals) return;
2040
+ const body = await readJsonBody(req);
2041
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2042
+ const parsed = parseCreate(body.value, lang);
2043
+ if (!parsed.ok) return sendError(res, 400, "bad-goal", parsed.message);
2044
+ const { objective, maxRounds } = parsed.value;
2045
+ const result = maxRounds === void 0 ? goals.create(sessionId, objective) : goals.create(sessionId, objective, maxRounds);
2046
+ sendJson(res, 200, toWriteResponse(result));
2047
+ }
2048
+ async function patchGoal(ctx, req, res, sessionId, lang) {
2049
+ const goals = gate(ctx, res, sessionId, lang);
2050
+ if (!goals) return;
2051
+ const body = await readJsonBody(req);
2052
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2053
+ const parsed = parseUpdate(body.value, lang);
2054
+ if (!parsed.ok) return sendError(res, 400, "bad-goal", parsed.message);
2055
+ sendJson(res, 200, toWriteResponse(apply(goals, sessionId, parsed.value)));
2056
+ }
2057
+ function apply(goals, sessionId, patch) {
2058
+ switch (patch.action) {
2059
+ case "edit":
2060
+ return goals.edit(sessionId, patch.objective);
2061
+ case "budget":
2062
+ return goals.setBudget(sessionId, patch.maxRounds);
2063
+ case "pause":
2064
+ return goals.pause(sessionId);
2065
+ case "resume":
2066
+ return goals.resume(sessionId);
2067
+ case "complete":
2068
+ return goals.complete(sessionId, patch.evidence);
2069
+ default: {
2070
+ const exhaustive = patch;
2071
+ return exhaustive;
2072
+ }
2073
+ }
2074
+ }
2075
+ async function deleteGoal(ctx, res, sessionId, lang) {
2076
+ const goals = gate(ctx, res, sessionId, lang);
2077
+ if (!goals) return;
2078
+ const result = goals.clear(sessionId);
2079
+ sendJson(
2080
+ res,
2081
+ 200,
2082
+ result.ok ? { ok: true, cleared: result.cleared } : { ok: false, cleared: false, reason: result.reason }
2083
+ );
2084
+ }
2085
+ function listBlockedGoals(ctx, res, _lang) {
2086
+ const goals = goalsOf(ctx);
2087
+ const rows = ctx.runtime.sessions?.list(BLOCKED_SCAN_LIMIT) ?? [];
2088
+ if (!goals) return sendJson(res, 200, { items: [] });
2089
+ const items = [];
2090
+ for (const row of rows) {
2091
+ const goal = goals.current(row.id);
2092
+ if (!goal || goal.phase !== "blocked") continue;
2093
+ items.push({ sessionId: row.id, sessionTitle: row.title, goal: toWireGoal(goal) });
2094
+ }
2095
+ items.sort(
2096
+ (a, b) => a.goal.updatedAt === b.goal.updatedAt ? a.sessionId < b.sessionId ? -1 : 1 : a.goal.updatedAt - b.goal.updatedAt
2097
+ );
2098
+ sendJson(res, 200, { items });
2099
+ }
1989
2100
  function readConfig(ctx, res, lang) {
1990
2101
  if (ctx.lanExposed) return sendLanExposed(res, lang);
1991
2102
  const outcome = ctx.runtime.mcp.readConfig();
@@ -2051,8 +2162,6 @@ async function applyConfig(ctx, req, res, lang) {
2051
2162
  changes: outcome.changes.map((change) => ({
2052
2163
  name: change.name,
2053
2164
  action: change.action,
2054
- // 投影借的是能力页那一栏**同一个函数**(`toWireMcp`):这几行会被界面
2055
- // 直接换进那张列表,形状对不上的话它们在那一栏里长得和别的行不一样
2056
2165
  ...change.status === void 0 ? {} : { server: toWireMcp(change.status) },
2057
2166
  ...change.reason === void 0 ? {} : { reason: change.reason }
2058
2167
  }))
@@ -2167,12 +2276,7 @@ function getModel(ctx, res, sessionId, _lang) {
2167
2276
  const { runtime } = ctx;
2168
2277
  const selection = runtime.sessionFactory.get(sessionId)?.model.get();
2169
2278
  sendJson(res, 200, {
2170
- // 问不到就是**这个进程没在跑这段会话**(只剩历史 / 被冷却了 / provider 层
2171
- // 起不来)。三种情形合成一个 null 照旧是刻意的:界面在三档下该做的事
2172
- // 一模一样(不画菜单)
2173
2279
  selection: selection ? toWireSelection(selection) : null,
2174
- // 空串当没配。`configured` 的语义是「`reset` 会把你送回哪儿」,
2175
- // 而一个空字符串答不了这个问题 —— 那时菜单上那一项整个不画
2176
2280
  configured: runtime.config.model || null
2177
2281
  });
2178
2282
  }
@@ -2190,10 +2294,6 @@ async function setModel(ctx, req, res, sessionId, lang) {
2190
2294
  return sendError(res, 400, "bad-model", t("web.model_bad_request", void 0, lang));
2191
2295
  }
2192
2296
  const selectionCtx = {
2193
- // **问的是这段会话自己的历史**(2026-08-16)。上一版读的是
2194
- // `ctx.runtime.session` —— 引导会话那一段。这条路只对引导会话开放的时候
2195
- // 那是对的;现在它对任意一个活着的会话开放,拿别人的历史当闸门的表现是:
2196
- // A 那段里有图片,于是 B 换一个不认图的模型会被一道莫名其妙的闸拦下来
2197
2297
  hasImages: sessionHasImages(live.session),
2198
2298
  ...input.usedTokens === void 0 ? {} : { usedTokens: input.usedTokens }
2199
2299
  };
@@ -2202,8 +2302,6 @@ async function setModel(ctx, req, res, sessionId, lang) {
2202
2302
  ok: true,
2203
2303
  selection: toWireSelection(result.selection),
2204
2304
  contextLength: result.contextLength,
2205
- // **空数组也照发**,不判长度:那是界面的事。在这儿吞掉一个空数组
2206
- // 等于让「没有代价」和「这个字段不存在」在契约上长得一样
2207
2305
  caveats: result.caveats
2208
2306
  } : { ok: false, rejection: result.rejection };
2209
2307
  sendJson(res, 200, payload);
@@ -2248,7 +2346,6 @@ async function setPlanMode(ctx, req, res, sessionId, lang) {
2248
2346
  changed
2249
2347
  });
2250
2348
  }
2251
-
2252
2349
  // src/permission.ts
2253
2350
  var REFUSAL = {
2254
2351
  "managed-bypass-disabled": "managed"
@@ -2259,12 +2356,7 @@ function blockedLevels(managed) {
2259
2356
  function permissionState(ctx, sessionId) {
2260
2357
  const perms = ctx.runtime.sessionFactory.get(sessionId)?.permissions ?? null;
2261
2358
  return {
2262
- // 权限层没起来 / 这个进程没在跑这段会话时退回 `config.permission`,
2263
- // 那仍然是一句真话:那时配置里那一档就是这台机器上的档位
2264
2359
  level: perms?.level() ?? ctx.runtime.config.permission,
2265
- // 改得动 = 这个会话在这个进程里活着、**而且**它有权限层。两种成因合成一个
2266
- // 布尔,判据在 `WirePermissionState.editable` 上:界面在两档下要做的事
2267
- // 一模一样(画回只读事实)
2268
2360
  editable: perms !== null,
2269
2361
  blocked: perms ? blockedLevels(perms.managed()) : [],
2270
2362
  planMode: planModeState(ctx, sessionId)
@@ -2298,12 +2390,16 @@ async function setPermission(ctx, req, res, sessionId, lang) {
2298
2390
  );
2299
2391
  }
2300
2392
  const before = perms.level();
2301
- const result = perms.setLevel(level);
2393
+ const result = perms.setLevel(level, { remember: true });
2302
2394
  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 };
2395
+ const payload = result.ok ? {
2396
+ ok: true,
2397
+ changed: state.level !== before,
2398
+ state,
2399
+ ...result.notRemembered ? { notRemembered: result.notRemembered } : {}
2400
+ } : { ok: false, refused: REFUSAL[result.reason], changed: false, state };
2304
2401
  sendJson(res, 200, payload);
2305
2402
  }
2306
-
2307
2403
  // src/plan.ts
2308
2404
  function getPlan(ctx, res, sessionId, _lang) {
2309
2405
  if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
@@ -2319,12 +2415,193 @@ function getPlan(ctx, res, sessionId, _lang) {
2319
2415
  function normalize(markdown) {
2320
2416
  return markdown?.trim() ? markdown : null;
2321
2417
  }
2322
- function toWireProvider(option) {
2418
+ function toWireCounts(counts) {
2419
+ return {
2420
+ commands: counts.commands,
2421
+ roles: counts.roles,
2422
+ skills: counts.skills,
2423
+ hooks: counts.hooks,
2424
+ denyRules: counts.denyRules,
2425
+ mcpServers: counts.mcpServers
2426
+ };
2427
+ }
2428
+ function toWirePluginEntry(entry) {
2429
+ return {
2430
+ name: entry.name,
2431
+ version: entry.version,
2432
+ source: entry.source,
2433
+ sourceType: entry.sourceType,
2434
+ path: entry.path,
2435
+ linked: entry.linked,
2436
+ enabled: entry.enabled,
2437
+ installedAt: entry.installedAt,
2438
+ marketplace: entry.marketplace ?? null,
2439
+ active: entry.active,
2440
+ counts: entry.counts ? toWireCounts(entry.counts) : null
2441
+ };
2442
+ }
2443
+ function toWirePluginHit(hit, installedNames) {
2444
+ return {
2445
+ marketplace: hit.marketplace,
2446
+ ref: hit.ref,
2447
+ name: hit.entry.name,
2448
+ description: hit.entry.description ?? "",
2449
+ category: hit.entry.category ?? null,
2450
+ keywords: [...hit.entry.keywords ?? []],
2451
+ sourceType: hit.sourceType ?? null,
2452
+ installable: hit.installable,
2453
+ installed: installedNames.has(hit.entry.name)
2454
+ };
2455
+ }
2456
+ function toWirePreview2(preview) {
2457
+ const { manifest, source, inventory } = preview;
2458
+ return {
2459
+ name: manifest.name,
2460
+ version: manifest.version,
2461
+ description: manifest.description ?? "",
2462
+ source: source.raw,
2463
+ sourceType: source.type,
2464
+ checksum: source.sha256 ?? null,
2465
+ author: manifest.author?.name ?? null,
2466
+ homepage: manifest.homepage ?? null,
2467
+ commands: [...inventory.commands],
2468
+ roles: [...inventory.roles],
2469
+ skills: [...inventory.skills],
2470
+ hooks: inventory.hooks.map((h) => ({ type: h.type, count: h.count })),
2471
+ denyRules: inventory.denyRules,
2472
+ mcpServers: [...inventory.mcpServers],
2473
+ ignoredBuckets: [...inventory.ignoredBuckets],
2474
+ hasJsTools: inventory.jsTools,
2475
+ conflict: preview.conflict ? { name: preview.conflict.name, version: preview.conflict.version } : null
2476
+ };
2477
+ }
2478
+ function controlOf(ctx, res, lang) {
2479
+ const control = ctx.runtime.plugins;
2480
+ if (!control) {
2481
+ sendError(res, 503, "no-plugin-control", t("web.plugin_not_available", void 0, lang));
2482
+ return null;
2483
+ }
2484
+ return control;
2485
+ }
2486
+ function refusedByLan(ctx, res, lang) {
2487
+ if (!ctx.lanExposed) return false;
2488
+ sendError(
2489
+ res,
2490
+ 403,
2491
+ "plugin-write-lan-exposed",
2492
+ t("web.plugin_write_lan_exposed", void 0, lang)
2493
+ );
2494
+ return true;
2495
+ }
2496
+ function fieldOf(payload, key) {
2497
+ const value = payload[key];
2498
+ return typeof value === "string" ? value.trim() : "";
2499
+ }
2500
+ function listPlugins(ctx, res, lang) {
2501
+ const control = controlOf(ctx, res, lang);
2502
+ if (!control) return;
2503
+ const installed = control.list();
2504
+ const installedNames = new Set(installed.map((p) => p.name));
2505
+ sendJson(res, 200, {
2506
+ installed: installed.map(toWirePluginEntry),
2507
+ hits: control.search().map((hit) => toWirePluginHit(hit, installedNames)),
2508
+ pendingRestart: control.pendingRestart
2509
+ });
2510
+ }
2511
+ function sendPreview(res, outcome) {
2512
+ if (!outcome.ok) {
2513
+ return sendJson(res, 200, {
2514
+ ok: false,
2515
+ preview: null,
2516
+ token: null,
2517
+ reason: outcome.reason,
2518
+ detail: outcome.detail
2519
+ });
2520
+ }
2521
+ sendJson(res, 200, {
2522
+ ok: true,
2523
+ preview: toWirePreview2(outcome.preview),
2524
+ token: outcome.token,
2525
+ reason: null,
2526
+ detail: null
2527
+ });
2528
+ }
2529
+ async function previewPluginInstall(ctx, req, res, lang) {
2530
+ if (refusedByLan(ctx, res, lang)) return;
2531
+ const control = controlOf(ctx, res, lang);
2532
+ if (!control) return;
2533
+ const body = await readJsonBody(req);
2534
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2535
+ sendPreview(res, await control.preview(fieldOf(asRecord(body.value), "ref"), lang));
2536
+ }
2537
+ async function previewPluginUpdate(ctx, req, res, lang) {
2538
+ if (refusedByLan(ctx, res, lang)) return;
2539
+ const control = controlOf(ctx, res, lang);
2540
+ if (!control) return;
2541
+ const body = await readJsonBody(req);
2542
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2543
+ sendPreview(res, await control.previewUpdate(fieldOf(asRecord(body.value), "name"), lang));
2544
+ }
2545
+ function sendAction(res, control, outcome) {
2546
+ if (!outcome.ok) {
2547
+ return sendJson(res, 200, {
2548
+ ok: false,
2549
+ entry: null,
2550
+ pendingRestart: control.pendingRestart,
2551
+ detail: outcome.detail,
2552
+ reason: outcome.reason
2553
+ });
2554
+ }
2555
+ const fresh = "record" in outcome ? control.list().find((p) => p.name === outcome.record.name) : void 0;
2556
+ sendJson(res, 200, {
2557
+ ok: true,
2558
+ entry: fresh ? toWirePluginEntry(fresh) : null,
2559
+ pendingRestart: control.pendingRestart,
2560
+ detail: "detail" in outcome ? outcome.detail : null,
2561
+ reason: null
2562
+ });
2563
+ }
2564
+ async function installPluginFromMarket(ctx, req, res, lang) {
2565
+ if (refusedByLan(ctx, res, lang)) return;
2566
+ const control = controlOf(ctx, res, lang);
2567
+ if (!control) return;
2568
+ const body = await readJsonBody(req);
2569
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2570
+ const payload = asRecord(body.value);
2571
+ sendAction(
2572
+ res,
2573
+ control,
2574
+ await control.install(fieldOf(payload, "ref"), fieldOf(payload, "token"), lang)
2575
+ );
2576
+ }
2577
+ async function updateInstalledPlugin(ctx, req, res, lang) {
2578
+ if (refusedByLan(ctx, res, lang)) return;
2579
+ const control = controlOf(ctx, res, lang);
2580
+ if (!control) return;
2581
+ const body = await readJsonBody(req);
2582
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2583
+ const payload = asRecord(body.value);
2584
+ sendAction(
2585
+ res,
2586
+ control,
2587
+ await control.update(fieldOf(payload, "name"), fieldOf(payload, "token"), lang)
2588
+ );
2589
+ }
2590
+ async function uninstallPluginByName(ctx, req, res, lang) {
2591
+ if (refusedByLan(ctx, res, lang)) return;
2592
+ const control = controlOf(ctx, res, lang);
2593
+ if (!control) return;
2594
+ const body = await readJsonBody(req);
2595
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2596
+ sendAction(res, control, await control.uninstall(fieldOf(asRecord(body.value), "name"), lang));
2597
+ }
2598
+ function toWireProvider(option, lang) {
2323
2599
  return {
2324
2600
  type: option.type,
2325
- label: option.label,
2601
+ label: providerLabel(option, lang),
2326
2602
  envVar: option.envVar,
2327
- hasKey: option.hasKey
2603
+ hasKey: option.hasKey,
2604
+ keyHint: option.keyHint
2328
2605
  };
2329
2606
  }
2330
2607
  function toWireSuggestions(view) {
@@ -2335,9 +2612,9 @@ function toWireSuggestions(view) {
2335
2612
  status: view.status
2336
2613
  };
2337
2614
  }
2338
- function listProviders(ctx, res, _lang) {
2615
+ function listProviders(ctx, res, lang) {
2339
2616
  sendJson(res, 200, {
2340
- providers: ctx.runtime.modelCatalog.providers().map(toWireProvider)
2617
+ providers: ctx.runtime.modelCatalog.providers().map((o) => toWireProvider(o, lang))
2341
2618
  });
2342
2619
  }
2343
2620
  async function discoverProviderModels(ctx, req, res, type, lang) {
@@ -2352,6 +2629,36 @@ async function discoverProviderModels(ctx, req, res, type, lang) {
2352
2629
  suggestions: toWireSuggestions(suggestions)
2353
2630
  });
2354
2631
  }
2632
+ async function setProviderKey(ctx, req, res, type, lang) {
2633
+ if (!isProviderType(type)) {
2634
+ return sendError(res, 404, "unknown-provider", t("web.provider_unknown", { name: type }, lang));
2635
+ }
2636
+ const envVar = apiKeyEnvVar(type);
2637
+ if (envVar === null) {
2638
+ return sendError(
2639
+ res,
2640
+ 400,
2641
+ "key-not-applicable",
2642
+ t("web.provider_key_not_applicable", { name: type }, lang)
2643
+ );
2644
+ }
2645
+ const body = await readJsonBody(req);
2646
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2647
+ const raw = asRecord(body.value)["apiKey"];
2648
+ const apiKey = typeof raw === "string" ? raw.trim() : "";
2649
+ if (apiKey === "") {
2650
+ return sendError(res, 400, "empty-key", t("web.provider_key_empty", void 0, lang));
2651
+ }
2652
+ const written = await ctx.runtime.modelCatalog.setKey(type, apiKey);
2653
+ sendJson(res, 200, {
2654
+ provider: type,
2655
+ hasKey: true,
2656
+ keyHint: written.keyHint,
2657
+ backend: written.backend,
2658
+ encrypted: written.encrypted,
2659
+ envPath: written.envPath
2660
+ });
2661
+ }
2355
2662
  async function addRole(ctx, req, res, lang) {
2356
2663
  if (ctx.lanExposed) {
2357
2664
  return sendError(
@@ -2369,19 +2676,14 @@ async function addRole(ctx, req, res, lang) {
2369
2676
  name: typeof payload["name"] === "string" ? payload["name"].trim() : "",
2370
2677
  description: typeof payload["description"] === "string" ? payload["description"].trim() : "",
2371
2678
  ...typeof payload["prompt"] === "string" ? { prompt: payload["prompt"] } : {},
2372
- // ⚠️ `tools` 用 `Array.isArray` 而不是「有没有给」:一个空数组是**一档真实的
2373
- // 意思**(一个工具都不给),而 `undefined` 是「不限」。两者合成同一个值之后,
2374
- // 一个用户明确清空了工具表的身份会拿到全部工具
2375
2679
  ...Array.isArray(payload["tools"]) ? { tools: stringList2(payload["tools"]) } : {},
2680
+ ...Array.isArray(payload["skills"]) ? { skills: stringList2(payload["skills"]) } : {},
2376
2681
  ...typeof payload["maxTurns"] === "number" ? { maxTurns: payload["maxTurns"] } : {}
2377
2682
  },
2378
2683
  lang
2379
2684
  );
2380
2685
  if (!outcome.ok) return sendError(res, ...addFailure2(outcome.reason, outcome.detail, lang));
2381
2686
  sendJson(res, 200, {
2382
- // 投影走 `toWireRole`,**和能力页那一栏同一个函数** —— 各写一份的话,
2383
- // 刚插进列表的那一行和刷新之后那一行会在 `tools: null` / `toolsCut` 这种
2384
- // 事上长得不一样,而那是这一屏最容易被当成 bug 的差别
2385
2687
  role: toWireRole(outcome.role, ctx.runtime.agentRoleNotices),
2386
2688
  path: outcome.path
2387
2689
  });
@@ -2529,7 +2831,7 @@ function optionals(raw, lang) {
2529
2831
  function bypassAcknowledged(body) {
2530
2832
  return asRecord(body)["bypassAcknowledged"] === true;
2531
2833
  }
2532
- function parseCreate(body, lang) {
2834
+ function parseCreate2(body, lang) {
2533
2835
  const raw = asRecord(body);
2534
2836
  const name = raw["name"];
2535
2837
  const prompt = raw["prompt"];
@@ -2550,12 +2852,10 @@ function parseCreate(body, lang) {
2550
2852
  if (!rest.ok) return rest;
2551
2853
  return {
2552
2854
  ok: true,
2553
- // 顺序有意义:`rest` 里那几格已经收窄过,五个必填的**后写**,
2554
- // 于是一个把 `name` 同时写在两处的请求体不会得到两个答案
2555
2855
  value: { ...rest.value, name, prompt, permission, trigger, maxBudgetUsd: budget }
2556
2856
  };
2557
2857
  }
2558
- function parseUpdate(body, lang) {
2858
+ function parseUpdate2(body, lang) {
2559
2859
  return optionals(asRecord(body), lang);
2560
2860
  }
2561
2861
  function parseFix(body, lang) {
@@ -2565,13 +2865,17 @@ function parseFix(body, lang) {
2565
2865
  }
2566
2866
  return { ok: true, value: runId };
2567
2867
  }
2568
-
2569
2868
  // src/schedule/handlers.ts
2570
2869
  var RECENT_RUNS_LIMIT = 100;
2571
2870
  var RUNS_LIMIT = 20;
2572
2871
  var MAX_RECORDING_FRAMES = 4e3;
2573
- function schedulesOf(ctx) {
2574
- return ctx.runtime.schedules;
2872
+ function schedulesOf(ctx, res, lang) {
2873
+ const control = ctx.runtime.schedules;
2874
+ if (!control.storeAvailable) {
2875
+ sendError(res, 503, "no-schedule-store", t("web.schedule_store_unavailable", void 0, lang));
2876
+ return null;
2877
+ }
2878
+ return control;
2575
2879
  }
2576
2880
  function toWireRow(control, def) {
2577
2881
  return {
@@ -2589,56 +2893,71 @@ function defaultsOf() {
2589
2893
  allowRules: SCHEDULE_DEFAULT_ALLOWLIST.allowRules
2590
2894
  };
2591
2895
  }
2592
- async function listSchedules(ctx, res, _lang) {
2593
- const control = schedulesOf(ctx);
2896
+ async function listSchedules(ctx, res, lang) {
2897
+ const control = schedulesOf(ctx, res, lang);
2898
+ if (!control) return;
2899
+ const capability = await control.capability();
2594
2900
  const payload = {
2595
2901
  schedules: control.list().map((def) => toWireRow(control, def)),
2596
- capability: await control.capability(),
2902
+ capability: { backend: capability.backend, canRegister: capability.canRegister },
2597
2903
  defaults: defaultsOf(),
2598
2904
  recentRuns: control.recentRuns(RECENT_RUNS_LIMIT)
2599
2905
  };
2600
2906
  sendJson(res, 200, payload);
2601
2907
  }
2908
+ function listPending(ctx, res, lang) {
2909
+ const control = schedulesOf(ctx, res, lang);
2910
+ if (!control) return;
2911
+ const items = collectPendingApprovals(control.list(), control.recentRuns(RECENT_RUNS_LIMIT));
2912
+ sendJson(res, 200, { items });
2913
+ }
2602
2914
  function getSchedule(ctx, res, id, lang) {
2603
- const control = schedulesOf(ctx);
2915
+ const control = schedulesOf(ctx, res, lang);
2916
+ if (!control) return;
2604
2917
  const def = control.get(id);
2605
2918
  if (!def) return sendNotFound(res, id, lang);
2606
2919
  sendJson(res, 200, { row: toWireRow(control, def) });
2607
2920
  }
2608
2921
  function listRuns(ctx, res, id, lang) {
2609
- const control = schedulesOf(ctx);
2922
+ const control = schedulesOf(ctx, res, lang);
2923
+ if (!control) return;
2610
2924
  if (!control.get(id)) return sendNotFound(res, id, lang);
2611
2925
  sendJson(res, 200, { runs: control.runs(id, RUNS_LIMIT) });
2612
2926
  }
2613
2927
  async function createSchedule(ctx, req, res, lang) {
2614
2928
  const body = await readJsonBody(req);
2615
2929
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2616
- const parsed = parseCreate(body.value, lang);
2930
+ const parsed = parseCreate2(body.value, lang);
2617
2931
  if (!parsed.ok) return sendError(res, 400, "bad-schedule", parsed.message);
2618
- const control = schedulesOf(ctx);
2932
+ const control = schedulesOf(ctx, res, lang);
2933
+ if (!control) return;
2619
2934
  const saved = await control.create(parsed.value, {
2620
2935
  bypassAcknowledged: bypassAcknowledged(body.value)
2621
2936
  });
2622
2937
  sendJson(res, 200, toSaveResponse(control, saved));
2623
2938
  }
2624
2939
  async function patchSchedule(ctx, req, res, id, lang) {
2625
- const control = schedulesOf(ctx);
2940
+ const control = schedulesOf(ctx, res, lang);
2941
+ if (!control) return;
2626
2942
  if (!control.get(id)) return sendNotFound(res, id, lang);
2627
2943
  const body = await readJsonBody(req);
2628
2944
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2629
- const parsed = parseUpdate(body.value, lang);
2945
+ const parsed = parseUpdate2(body.value, lang);
2630
2946
  if (!parsed.ok) return sendError(res, 400, "bad-schedule", parsed.message);
2631
2947
  const saved = await control.update(id, parsed.value, {
2632
2948
  bypassAcknowledged: bypassAcknowledged(body.value)
2633
2949
  });
2634
2950
  sendJson(res, 200, toSaveResponse(control, saved));
2635
2951
  }
2636
- async function deleteSchedule(ctx, res, id, _lang) {
2637
- const removed = await schedulesOf(ctx).remove(id);
2952
+ async function deleteSchedule(ctx, res, id, lang) {
2953
+ const control = schedulesOf(ctx, res, lang);
2954
+ if (!control) return;
2955
+ const removed = await control.remove(id);
2638
2956
  sendJson(res, 200, { removed });
2639
2957
  }
2640
2958
  async function runSchedule(ctx, res, id, lang) {
2641
- const control = schedulesOf(ctx);
2959
+ const control = schedulesOf(ctx, res, lang);
2960
+ if (!control) return;
2642
2961
  if (!control.get(id)) return sendNotFound(res, id, lang);
2643
2962
  const result = await control.fire(id, { manual: true });
2644
2963
  const payload = {
@@ -2650,7 +2969,8 @@ async function runSchedule(ctx, res, id, lang) {
2650
2969
  sendJson(res, 200, payload);
2651
2970
  }
2652
2971
  async function fixSchedule(ctx, req, res, id, lang) {
2653
- const control = schedulesOf(ctx);
2972
+ const control = schedulesOf(ctx, res, lang);
2973
+ if (!control) return;
2654
2974
  const def = control.get(id);
2655
2975
  if (!def) return sendNotFound(res, id, lang);
2656
2976
  const body = await readJsonBody(req);
@@ -2666,20 +2986,14 @@ async function fixSchedule(ctx, req, res, id, lang) {
2666
2986
  t("web.schedule_run_unknown", { runId: parsed.value }, lang)
2667
2987
  );
2668
2988
  }
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
- }
2989
+ const added = unfixedRules(def.allowRules, run.pendingApprovals);
2676
2990
  if (added.length === 0) {
2677
2991
  return sendJson(res, 200, {
2678
2992
  added: [],
2679
2993
  row: toWireRow(control, def)
2680
2994
  });
2681
2995
  }
2682
- const saved = await control.update(id, { allowRules: [...existing] });
2996
+ const saved = await control.update(id, { allowRules: [...def.allowRules, ...added] });
2683
2997
  const next = saved.schedule ?? def;
2684
2998
  sendJson(res, 200, {
2685
2999
  added,
@@ -2687,7 +3001,8 @@ async function fixSchedule(ctx, req, res, id, lang) {
2687
3001
  });
2688
3002
  }
2689
3003
  function getRecording(ctx, res, id, runId, lang) {
2690
- const control = schedulesOf(ctx);
3004
+ const control = schedulesOf(ctx, res, lang);
3005
+ if (!control) return;
2691
3006
  if (!control.get(id)) return sendNotFound(res, id, lang);
2692
3007
  const run = control.runs(id, RUNS_LIMIT).find((one) => one.id === runId);
2693
3008
  if (!run) {
@@ -2722,7 +3037,6 @@ function toSaveResponse(control, saved) {
2722
3037
  function sendNotFound(res, id, lang) {
2723
3038
  sendError(res, 404, "unknown-schedule", t("web.schedule_unknown", { id }, lang));
2724
3039
  }
2725
-
2726
3040
  // src/skill-import.ts
2727
3041
  function pathOf(payload) {
2728
3042
  return typeof payload["path"] === "string" ? payload["path"].trim() : "";
@@ -2744,7 +3058,7 @@ async function previewSkillImport(ctx, req, res, lang) {
2744
3058
  const body = await readJsonBody(req);
2745
3059
  if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
2746
3060
  const source = pathOf(asRecord(body.value));
2747
- const outcome = await ctx.runtime.skillImport.preview(source, lang);
3061
+ const outcome = await ctx.runtime.skillWrite.preview(source, lang);
2748
3062
  if (!outcome.ok) {
2749
3063
  return sendJson(res, 200, {
2750
3064
  ok: false,
@@ -2775,7 +3089,7 @@ async function importSkills(ctx, req, res, lang) {
2775
3089
  const payload = asRecord(body.value);
2776
3090
  const source = pathOf(payload);
2777
3091
  const token = typeof payload["token"] === "string" ? payload["token"] : "";
2778
- const outcome = await ctx.runtime.skillImport.import(source, token, lang);
3092
+ const outcome = await ctx.runtime.skillWrite.import(source, token, lang);
2779
3093
  if (!outcome.ok) {
2780
3094
  return sendJson(res, 200, {
2781
3095
  ok: false,
@@ -2786,10 +3100,6 @@ async function importSkills(ctx, req, res, lang) {
2786
3100
  });
2787
3101
  }
2788
3102
  sendJson(res, 200, {
2789
- // 一个都没落地时**回 `ok: false`**:这一格答的是「这次导入达成了没有」,
2790
- // 而不是「这次请求跑通了没有」。全都撞名的那一次要是回 true,界面上会出现
2791
- // 一句「导入成功」加一张空列表 —— 判据同 `WireMcpReconnectResponse.ok`
2792
- // 那条「别从别的字段反推」
2793
3103
  ok: outcome.imported.length > 0,
2794
3104
  imported: [...outcome.imported],
2795
3105
  skipped: toWireIssues2(outcome.skipped),
@@ -2797,7 +3107,30 @@ async function importSkills(ctx, req, res, lang) {
2797
3107
  reason: null
2798
3108
  });
2799
3109
  }
2800
-
3110
+ // src/skill-remove.ts
3111
+ async function removeSkill(ctx, req, res, lang) {
3112
+ const body = await readJsonBody(req);
3113
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3114
+ const payload = asRecord(body.value);
3115
+ const name = typeof payload["name"] === "string" ? payload["name"].trim() : "";
3116
+ const outcome = await ctx.runtime.skillWrite.remove(name, lang);
3117
+ if (!outcome.ok) {
3118
+ return sendJson(res, 200, {
3119
+ ok: false,
3120
+ name: "",
3121
+ path: null,
3122
+ detail: outcome.detail,
3123
+ reason: outcome.reason
3124
+ });
3125
+ }
3126
+ sendJson(res, 200, {
3127
+ ok: true,
3128
+ name: outcome.name,
3129
+ path: outcome.path,
3130
+ detail: null,
3131
+ reason: null
3132
+ });
3133
+ }
2801
3134
  // src/sse.ts
2802
3135
  var DEFAULT_HEARTBEAT_MS = 15e3;
2803
3136
  function parseLastEventId(req) {
@@ -2825,13 +3158,6 @@ var SseConnection = class {
2825
3158
  res;
2826
3159
  heartbeat;
2827
3160
  closed = false;
2828
- /**
2829
- * 发一帧。
2830
- *
2831
- * **`seq: 0` 不写 `id:` 行**:连接级帧(`connected` / `stream-reset`)不占广播
2832
- * 序号,写进 `id:` 就会把这个连接的续传游标推到 0,下次重连时 `Last-Event-ID: 0`
2833
- * 等于说「我什么都没收到」。
2834
- */
2835
3161
  send(frame) {
2836
3162
  if (this.closed) return;
2837
3163
  const body = frame.seq > 0 ? `id: ${frame.seq}
@@ -2840,12 +3166,10 @@ var SseConnection = class {
2840
3166
 
2841
3167
  `);
2842
3168
  }
2843
- /** 注释行心跳:SSE 规范里以 `:` 开头的行被客户端忽略,纯粹用来探活 */
2844
3169
  ping() {
2845
3170
  if (this.closed) return;
2846
3171
  this.res.write(": ping\n\n");
2847
3172
  }
2848
- /** 幂等:客户端断开和服务端 close 会各调一次 */
2849
3173
  close() {
2850
3174
  if (this.closed) return;
2851
3175
  this.closed = true;
@@ -2878,6 +3202,9 @@ function getTasks(ctx, res, sessionId, _lang, registry = LIVE_TASKS) {
2878
3202
  sendJson(res, 200, collectTasks(sessionId, registry));
2879
3203
  }
2880
3204
  var DIR_PAGE_LIMIT = 1e3;
3205
+ function workspaceHome() {
3206
+ return homedir();
3207
+ }
2881
3208
  function workspaceAnchors(workspaces) {
2882
3209
  const seen = /* @__PURE__ */ new Set();
2883
3210
  const entries = [];
@@ -2898,14 +3225,10 @@ function workspaceAnchors(workspaces) {
2898
3225
  push(process.cwd(), "cwd");
2899
3226
  for (const one of workspaces.known()) push(one.root, "known");
2900
3227
  return {
2901
- // 锚屏不是一个目录,所以这两格是 null(判据在契约那两个字段上)
2902
3228
  path: null,
2903
3229
  parent: null,
2904
3230
  entries,
2905
- // 锚这一发**不截断**:`known` 上游就是 ≤20 条,加上 home / cwd 也就 22 行
2906
3231
  omitted: 0,
2907
- // 锚这一发也**不过滤**:三组锚是「用户自己去过的地方」,`~/.config/some-tree`
2908
- // 真在已知清单里的话它照样画出来 —— 那个开关管的是「往下点一层」看得见什么
2909
3232
  hidden: 0
2910
3233
  };
2911
3234
  }
@@ -2939,7 +3262,6 @@ function listWorkspaceDirs(workspaces, raw, opts = {}) {
2939
3262
  name: dirent.name,
2940
3263
  path: child,
2941
3264
  known: known.has(child),
2942
- // 列不开的行**照样画出来**,只是点不动 —— 藏掉的话用户会以为它不存在
2943
3265
  readable: canList(child),
2944
3266
  group: "child"
2945
3267
  });
@@ -2948,11 +3270,9 @@ function listWorkspaceDirs(workspaces, raw, opts = {}) {
2948
3270
  ok: true,
2949
3271
  view: {
2950
3272
  path,
2951
- // 已经在根上时 `dirname` 回它自己 —— 那时没有「上一层」可去
2952
3273
  parent: parentOf(path),
2953
3274
  entries: kept,
2954
3275
  omitted,
2955
- // 带 `?hidden=1` 那一发这一格必然是 0:那时上面那一支根本不进
2956
3276
  hidden
2957
3277
  }
2958
3278
  };
@@ -2978,11 +3298,127 @@ function parentOf(path) {
2978
3298
  function displayName2(path) {
2979
3299
  return basename(path) || path;
2980
3300
  }
2981
-
3301
+ function present(value) {
3302
+ return value !== void 0 && value !== "";
3303
+ }
3304
+ function resolveNativeDirPicker(facts) {
3305
+ return isSameMachine(facts);
3306
+ }
3307
+ function isSameMachine(facts) {
3308
+ if (facts.lanExposed) return false;
3309
+ if (present(facts.env.SSH_CONNECTION) || present(facts.env.SSH_TTY)) return false;
3310
+ return facts.platform === "darwin" || facts.platform === "win32";
3311
+ }
3312
+ function nativePickerCommands(platform, prompt) {
3313
+ if (platform === "darwin") {
3314
+ return [
3315
+ {
3316
+ command: "osascript",
3317
+ args: [
3318
+ "-e",
3319
+ `set epochChosenFolder to choose folder with prompt ${asAppleScriptString(prompt)}`,
3320
+ "-e",
3321
+ "POSIX path of epochChosenFolder"
3322
+ ]
3323
+ }
3324
+ ];
3325
+ }
3326
+ if (platform === "win32") {
3327
+ const script = win32PickScript(prompt);
3328
+ return [
3329
+ { command: "pwsh", args: ["-NoProfile", "-STA", "-Command", script] },
3330
+ { command: "powershell.exe", args: ["-NoProfile", "-STA", "-Command", script] }
3331
+ ];
3332
+ }
3333
+ return null;
3334
+ }
3335
+ function win32PickScript(prompt) {
3336
+ return [
3337
+ "Add-Type -AssemblyName System.Windows.Forms",
3338
+ "$d = New-Object System.Windows.Forms.FolderBrowserDialog",
3339
+ `$d.Description = ${asPowerShellString(prompt)}`,
3340
+ "$d.ShowNewFolderButton = $true",
3341
+ "if ($d.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) { exit 1 }",
3342
+ "[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($d.SelectedPath))"
3343
+ ].join("; ");
3344
+ }
3345
+ function asAppleScriptString(text) {
3346
+ return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
3347
+ }
3348
+ function asPowerShellString(text) {
3349
+ return `'${text.replace(/'/g, "''")}'`;
3350
+ }
3351
+ function decodeNativePick(platform, stdout) {
3352
+ const raw = stdout.trim();
3353
+ if (platform !== "win32") return raw;
3354
+ return Buffer.from(raw, "base64").toString("utf-8").trim();
3355
+ }
3356
+ function isPickCanceled(platform, code, stderr) {
3357
+ if (code !== 1) return false;
3358
+ if (platform === "win32") return true;
3359
+ return /-128|user canceled/i.test(stderr);
3360
+ }
3361
+ var inFlight = false;
3362
+ async function pickNativeDirectory(opts) {
3363
+ const platform = opts.platform ?? process.platform;
3364
+ const candidates = nativePickerCommands(platform, opts.prompt);
3365
+ if (!candidates) {
3366
+ return { ok: false, reason: "unsupported", message: `unsupported platform: ${platform}` };
3367
+ }
3368
+ if (inFlight) {
3369
+ return { ok: false, reason: "busy", message: "a directory picker is already open" };
3370
+ }
3371
+ const run = opts.runner ?? execFileRunner;
3372
+ inFlight = true;
3373
+ try {
3374
+ let last = "";
3375
+ for (const [index, candidate] of candidates.entries()) {
3376
+ const attempt = await run(candidate.command, candidate.args, opts.signal);
3377
+ if (attempt.missing) {
3378
+ last = attempt.message || `${candidate.command} not found`;
3379
+ if (index < candidates.length - 1) continue;
3380
+ return { ok: false, reason: "failed", message: last };
3381
+ }
3382
+ if (!attempt.code) {
3383
+ const path = decodeNativePick(platform, attempt.stdout);
3384
+ return path ? { ok: true, path } : { ok: false, reason: "failed", message: "picker returned an empty path" };
3385
+ }
3386
+ if (isPickCanceled(platform, attempt.code, attempt.stderr)) {
3387
+ return { ok: true, canceled: true };
3388
+ }
3389
+ return { ok: false, reason: "failed", message: attempt.stderr.trim() || attempt.message };
3390
+ }
3391
+ return { ok: false, reason: "failed", message: last };
3392
+ } finally {
3393
+ inFlight = false;
3394
+ }
3395
+ }
3396
+ var execFileRunner = (command, args, signal) => new Promise((done) => {
3397
+ execFile(
3398
+ command,
3399
+ [...args],
3400
+ {
3401
+ ...signal ? { signal } : {},
3402
+ encoding: "utf-8",
3403
+ windowsHide: true,
3404
+ maxBuffer: 1024 * 1024
3405
+ },
3406
+ (error, stdout, stderr) => {
3407
+ const err = error;
3408
+ done({
3409
+ code: err?.code ?? 0,
3410
+ stdout,
3411
+ stderr,
3412
+ missing: err?.code === "ENOENT",
3413
+ message: err?.message ?? ""
3414
+ });
3415
+ }
3416
+ );
3417
+ });
2982
3418
  // src/workspace/dirs.ts
2983
3419
  function browseWorkspaceDirs(ctx, res, rawUrl, lang) {
2984
3420
  const query = new URL(rawUrl ?? "/", "http://127.0.0.1").searchParams;
2985
- const path = dirsQuery(query);
3421
+ const path = dirsQuery(query) ?? atQuery(query);
2986
3422
  if (path === void 0) {
2987
3423
  return sendJson(res, 200, workspaceAnchors(ctx.runtime.workspaces));
2988
3424
  }
@@ -3012,6 +3448,9 @@ function dirsQuery(query) {
3012
3448
  const value = query.get("path")?.trim();
3013
3449
  return value ? value : void 0;
3014
3450
  }
3451
+ function atQuery(query) {
3452
+ return query.get("at")?.trim().toLowerCase() === "home" ? workspaceHome() : void 0;
3453
+ }
3015
3454
  function hiddenQuery(query) {
3016
3455
  const value = query.get("hidden")?.trim().toLowerCase();
3017
3456
  return value === "1" || value === "true";
@@ -3041,17 +3480,42 @@ async function createWorkspaceDir(req, res, lang) {
3041
3480
  }
3042
3481
  sendJson(res, 200, {
3043
3482
  entry: {
3044
- // `basename` 就是 `name`(上面校验过它是单段),这儿不再算一次
3045
3483
  name,
3046
3484
  path,
3047
- // 两个字段**钉死**,判据在 `WireCreateWorkspaceResponse` 上:刚建的空目录
3048
- // 必然读得开,而且它**不在**「已知工作区」清单里(这条路不记那一笔)
3049
3485
  readable: true,
3050
3486
  known: false,
3051
3487
  group: "child"
3052
3488
  }
3053
3489
  });
3054
3490
  }
3491
+ async function pickWorkspaceDir(ctx, req, res, lang) {
3492
+ if (!ctx.nativeDirPicker) {
3493
+ return sendError(res, 403, "native-picker-unavailable", t("web.pick_unavailable", {}, lang));
3494
+ }
3495
+ const abort = new AbortController();
3496
+ req.on("close", () => abort.abort());
3497
+ const outcome = await pickNativeDirectory({
3498
+ prompt: t("web.pick_prompt", {}, lang),
3499
+ signal: abort.signal
3500
+ });
3501
+ if (res.writableEnded || abort.signal.aborted) return;
3502
+ if (outcome.ok) {
3503
+ return sendJson(
3504
+ res,
3505
+ 200,
3506
+ outcome.path === void 0 ? { canceled: true } : { path: outcome.path }
3507
+ );
3508
+ }
3509
+ if (outcome.reason === "busy") {
3510
+ return sendError(res, 409, "native-picker-busy", t("web.pick_busy", {}, lang));
3511
+ }
3512
+ sendError(
3513
+ res,
3514
+ 500,
3515
+ "native-picker-failed",
3516
+ t("web.pick_failed", { detail: outcome.message }, lang)
3517
+ );
3518
+ }
3055
3519
  function isSingleSegment(name) {
3056
3520
  if (name.length === 0) return false;
3057
3521
  if (name === "." || name === "..") return false;
@@ -3066,6 +3530,195 @@ function mkdirFailure(cause, path, lang) {
3066
3530
  if (code === "ENOENT") return [404, "not-found", t("web.mkdir_parent_missing", { path }, lang)];
3067
3531
  return [500, "internal", t("web.mkdir_failed", { path }, lang)];
3068
3532
  }
3533
+ var MAX_TEXT_BYTES = 2 * 1024 * 1024;
3534
+ var VIEWABLE = /* @__PURE__ */ new Map([
3535
+ [".png", { kind: "image", mime: "image/png" }],
3536
+ [".jpg", { kind: "image", mime: "image/jpeg" }],
3537
+ [".jpeg", { kind: "image", mime: "image/jpeg" }],
3538
+ [".gif", { kind: "image", mime: "image/gif" }],
3539
+ [".webp", { kind: "image", mime: "image/webp" }],
3540
+ [".bmp", { kind: "image", mime: "image/bmp" }],
3541
+ [".ico", { kind: "image", mime: "image/x-icon" }],
3542
+ [".avif", { kind: "image", mime: "image/avif" }],
3543
+ [".pdf", { kind: "pdf", mime: "application/pdf" }],
3544
+ [".mp3", { kind: "audio", mime: "audio/mpeg" }],
3545
+ [".wav", { kind: "audio", mime: "audio/wav" }],
3546
+ [".ogg", { kind: "audio", mime: "audio/ogg" }],
3547
+ [".m4a", { kind: "audio", mime: "audio/mp4" }],
3548
+ [".flac", { kind: "audio", mime: "audio/flac" }],
3549
+ [".mp4", { kind: "video", mime: "video/mp4" }],
3550
+ [".webm", { kind: "video", mime: "video/webm" }],
3551
+ [".mov", { kind: "video", mime: "video/quicktime" }]
3552
+ ]);
3553
+ function kindOf(stat2) {
3554
+ if (stat2.kind === "text") return "text";
3555
+ return VIEWABLE.get(extname(stat2.rel).toLowerCase())?.kind ?? "opaque";
3556
+ }
3557
+ function toWireRefusal(reason) {
3558
+ return reason;
3559
+ }
3560
+ function statusFor(refusal) {
3561
+ if (refusal === "denied") return 403;
3562
+ if (refusal === "missing") return 404;
3563
+ if (refusal === "too-large") return 413;
3564
+ return refusal === "not-a-file" ? 400 : 503;
3565
+ }
3566
+ function describeRefusal(refusal, lang) {
3567
+ if (refusal === "denied") return t("web.file_view_denied", void 0, lang);
3568
+ if (refusal === "missing") return t("web.file_view_missing", void 0, lang);
3569
+ if (refusal === "not-a-file") return t("web.file_view_not_a_file", void 0, lang);
3570
+ if (refusal === "too-large") {
3571
+ return t("web.file_view_too_large", { max: String(MAX_TEXT_BYTES) }, lang);
3572
+ }
3573
+ return t("web.file_view_unreadable", void 0, lang);
3574
+ }
3575
+ function resolveViewRoot(ctx, res, sessionId, lang) {
3576
+ if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
3577
+ sendUnknownSession(res, sessionId);
3578
+ return null;
3579
+ }
3580
+ const root = workspaceRootOf(ctx, sessionId);
3581
+ if (!root) {
3582
+ sendError(res, 409, "no-workspace", t("web.file_view_no_workspace", void 0, lang));
3583
+ return null;
3584
+ }
3585
+ return root;
3586
+ }
3587
+ function singlePath(res, rawUrl, lang) {
3588
+ const params = new URLSearchParams((rawUrl ?? "").split("?")[1] ?? "");
3589
+ const path = params.get("path")?.trim();
3590
+ if (!path) {
3591
+ sendError(res, 400, "missing-path", t("web.file_view_missing_path", void 0, lang));
3592
+ return null;
3593
+ }
3594
+ return path;
3595
+ }
3596
+ function statFiles(ctx, res, sessionId, rawUrl, lang) {
3597
+ const root = resolveViewRoot(ctx, res, sessionId, lang);
3598
+ if (root === null) return;
3599
+ const params = new URLSearchParams((rawUrl ?? "").split("?")[1] ?? "");
3600
+ const asked = params.getAll("path").filter((p) => p.trim() !== "");
3601
+ const taken = asked.slice(0, WIRE_FILE_STAT_MAX);
3602
+ const files = taken.map((path) => {
3603
+ const stat2 = ctx.runtime.workspaceFiles.statFile(path, root);
3604
+ if (!stat2.ok) return { path, ok: false, refusal: toWireRefusal(stat2.reason) };
3605
+ return {
3606
+ path,
3607
+ ok: true,
3608
+ kind: kindOf(stat2),
3609
+ bytes: stat2.bytes,
3610
+ mtimeMs: stat2.mtimeMs,
3611
+ openable: ctx.sameMachine && isSystemOpenable(stat2.rel)
3612
+ };
3613
+ });
3614
+ sendJson(res, 200, {
3615
+ files,
3616
+ overLimit: asked.length - taken.length,
3617
+ root
3618
+ });
3619
+ }
3620
+ function readFileText(ctx, res, sessionId, rawUrl, lang) {
3621
+ const root = resolveViewRoot(ctx, res, sessionId, lang);
3622
+ if (root === null) return;
3623
+ const path = singlePath(res, rawUrl, lang);
3624
+ if (path === null) return;
3625
+ const stat2 = ctx.runtime.workspaceFiles.statFile(path, root);
3626
+ if (!stat2.ok) {
3627
+ const refusal = toWireRefusal(stat2.reason);
3628
+ return sendError(res, statusFor(refusal), refusal, describeRefusal(refusal, lang));
3629
+ }
3630
+ if (stat2.kind !== "text") {
3631
+ return sendError(res, 415, "not-text", t("web.file_view_not_text", void 0, lang));
3632
+ }
3633
+ if (stat2.bytes > MAX_TEXT_BYTES) {
3634
+ return sendError(res, 413, "too-large", describeRefusal("too-large", lang));
3635
+ }
3636
+ const out = ctx.runtime.workspaceFiles.readFile(stat2.rel, root);
3637
+ if (!out.ok) {
3638
+ const refusal = out.reason === "binary" ? "unreadable" : out.reason;
3639
+ return sendError(res, statusFor(refusal), refusal, describeRefusal(refusal, lang));
3640
+ }
3641
+ sendJson(res, 200, {
3642
+ path,
3643
+ text: out.text,
3644
+ bytes: out.bytes
3645
+ });
3646
+ }
3647
+ function readFileBytes(ctx, res, sessionId, rawUrl, headOnly, lang) {
3648
+ const root = resolveViewRoot(ctx, res, sessionId, lang);
3649
+ if (root === null) return;
3650
+ const path = singlePath(res, rawUrl, lang);
3651
+ if (path === null) return;
3652
+ const stat2 = ctx.runtime.workspaceFiles.statFile(path, root);
3653
+ if (!stat2.ok) {
3654
+ const refusal = toWireRefusal(stat2.reason);
3655
+ return sendError(res, statusFor(refusal), refusal, describeRefusal(refusal, lang));
3656
+ }
3657
+ const known = VIEWABLE.get(extname(stat2.rel).toLowerCase());
3658
+ const inline = known !== void 0 && stat2.kind === "binary";
3659
+ const headers = {
3660
+ "Content-Type": inline ? known.mime : "application/octet-stream",
3661
+ "X-Content-Type-Options": "nosniff",
3662
+ "Content-Disposition": inline ? "inline" : `attachment; filename*=UTF-8''${encodeURIComponent(basenameOf(stat2.rel))}`
3663
+ };
3664
+ if (inline && known.kind === "pdf") headers["Content-Security-Policy"] = "sandbox";
3665
+ if (headOnly) {
3666
+ return sendEmpty(res, 200, {
3667
+ ...headers,
3668
+ "Content-Length": String(stat2.bytes)
3669
+ });
3670
+ }
3671
+ sendFile(res, stat2.absPath, headers);
3672
+ }
3673
+ function basenameOf(rel) {
3674
+ const cut = rel.lastIndexOf("/");
3675
+ return cut < 0 ? rel : rel.slice(cut + 1);
3676
+ }
3677
+ // src/workspace/file-open.ts
3678
+ function describeOpenRefusal(refusal, lang) {
3679
+ if (refusal === "extension-not-allowed") {
3680
+ return t("web.file_open_extension_not_allowed", void 0, lang);
3681
+ }
3682
+ if (refusal === "not-same-machine") return t("web.file_open_not_same_machine", void 0, lang);
3683
+ if (refusal === "launch-failed") return t("web.file_open_launch_failed", void 0, lang);
3684
+ return describeRefusal(refusal, lang);
3685
+ }
3686
+ function refuse(res, refusal, lang) {
3687
+ sendJson(res, 200, {
3688
+ ok: false,
3689
+ refusal,
3690
+ message: describeOpenRefusal(refusal, lang)
3691
+ });
3692
+ }
3693
+ async function openFileWithSystemApp(ctx, req, res, sessionId, lang) {
3694
+ if (!ctx.sameMachine) {
3695
+ return sendError(res, 403, "not-same-machine", describeOpenRefusal("not-same-machine", lang));
3696
+ }
3697
+ const root = resolveViewRoot(ctx, res, sessionId, lang);
3698
+ if (root === null) return;
3699
+ const body = await readJsonBody(req);
3700
+ if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
3701
+ const payload = asRecord(body.value);
3702
+ const path = typeof payload["path"] === "string" ? payload["path"].trim() : "";
3703
+ if (path === "") {
3704
+ return sendError(res, 400, "missing-path", t("web.file_view_missing_path", void 0, lang));
3705
+ }
3706
+ const plan = ctx.runtime.workspaceFiles.planSystemOpen(path, root);
3707
+ if (!plan.ok) return refuse(res, plan.reason, lang);
3708
+ try {
3709
+ const child = spawn(plan.command, [...plan.args], { stdio: "ignore", detached: true });
3710
+ await new Promise((done, fail) => {
3711
+ child.on("error", fail);
3712
+ child.on("spawn", () => {
3713
+ child.unref();
3714
+ done();
3715
+ });
3716
+ });
3717
+ } catch {
3718
+ return refuse(res, "launch-failed", lang);
3719
+ }
3720
+ sendJson(res, 200, { ok: true });
3721
+ }
3069
3722
  var GIT_TIMEOUT_MS = 1e4;
3070
3723
  var GIT_MAX_BUFFER = 16 * 1024 * 1024;
3071
3724
  function runGit(cwd, args) {
@@ -3155,7 +3808,6 @@ async function catFileBlob(root, sha) {
3155
3808
  function splitZ(text) {
3156
3809
  return text.split("\0").filter((s) => s.length > 0);
3157
3810
  }
3158
-
3159
3811
  // src/workspace/diff.ts
3160
3812
  var MAX_DIFF_BYTES = 512 * 1024;
3161
3813
  var MAX_DIFF_FILES = 200;
@@ -3295,7 +3947,6 @@ function omitReason(old, fresh) {
3295
3947
  function isBinary(side) {
3296
3948
  return side.kind === "content" && side.data.subarray(0, BINARY_SNIFF_BYTES).includes(0);
3297
3949
  }
3298
-
3299
3950
  // src/workspace/handlers.ts
3300
3951
  async function createSession(ctx, req, res, lang) {
3301
3952
  const body = await readJsonBody(req);
@@ -3327,12 +3978,7 @@ async function createSession(ctx, req, res, lang) {
3327
3978
  const role = typeof payload["role"] === "string" ? payload["role"].trim() : "";
3328
3979
  const outcome = ctx.runtime.sessionFactory.create({
3329
3980
  workspace: field.dir,
3330
- // 建会话那一路也会渲染文案(路径不对 / 目录不存在 那几句在 runtime 的
3331
- // `SessionWorkspaces.bind()` 里),所以 `lang` 一路递进工厂 —— 判据见
3332
- // `routes.ts` 文件头「三、`lang` 怎么穿」
3333
3981
  ...lang === void 0 ? {} : { lang },
3334
- // `create: true` = 别复用。只认 `=== true`:一个拼错的值该走保守那一侧
3335
- // (复用),而不是凭空多建一个会话出来
3336
3982
  reuse: payload["create"] !== true,
3337
3983
  ...role === "" ? {} : { role }
3338
3984
  });
@@ -3363,9 +4009,6 @@ function sessionWorkspace(ctx, res, sessionId, lang) {
3363
4009
  const state = ctx.runtime.workspaces.stateOf(sessionId);
3364
4010
  if (state !== "none") {
3365
4011
  return sendJson(res, 200, {
3366
- // **复用 `toWireBinding` / `toWireSessionWorkspace`**,不在这儿另拼一份:
3367
- // 那三处不对称(`extraRoots` / `trusted` / `skippedInstructionFiles`)少一个,
3368
- // 界面上就少一句决定 18 要求必须说出来的话,而编译器拦不住少发一个可选字段的投影
3369
4012
  binding: toWireBinding(state, ctx.runtime.workspaces.of(sessionId))
3370
4013
  });
3371
4014
  }
@@ -3393,6 +4036,12 @@ async function bindWorkspace(ctx, req, res, sessionId, lang) {
3393
4036
  t("web.workspace_already_decided", void 0, lang)
3394
4037
  );
3395
4038
  }
4039
+ const turn = ctx.hub.stateOf(sessionId);
4040
+ if (turn !== "idle") {
4041
+ return sendError(res, 409, "busy", t("web.workspace_turn_running", void 0, lang), {
4042
+ "X-Epoch-Turn-State": turn
4043
+ });
4044
+ }
3396
4045
  if (parsed.kind === "none") {
3397
4046
  ctx.runtime.workspaces.decideNone(sessionId);
3398
4047
  return sendJson(res, 200, {
@@ -3427,50 +4076,49 @@ async function sessionDiff(ctx, res, sessionId, _lang) {
3427
4076
  200,
3428
4077
  await collectWorkspaceDiff({
3429
4078
  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
4079
  checkpoints: checkpointsOf(ctx, sessionId)
3438
4080
  })
3439
4081
  );
3440
4082
  }
3441
-
3442
4083
  // 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"
4084
+ var DENY_KEY = {
4085
+ "missing-credential": "web.route.deny_missing_credential",
4086
+ "bad-token": "web.route.deny_bad_token",
4087
+ "bad-origin": "web.route.deny_bad_origin",
4088
+ "bad-host": "web.route.deny_bad_host"
3448
4089
  };
3449
4090
  function createRequestHandler(opts) {
3450
4091
  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();
4092
+ const lang = langOf(req.url);
4093
+ dispatch(opts, req, res, lang).catch(() => {
4094
+ if (!res.headersSent) {
4095
+ sendError(res, 500, "internal", t("web.route.internal_error", void 0, lang));
4096
+ } else res.destroy();
3454
4097
  });
3455
4098
  };
3456
4099
  }
3457
- async function dispatch(opts, req, res) {
4100
+ async function dispatch(opts, req, res, lang) {
3458
4101
  const { ctx, guard } = opts;
3459
4102
  const pathname = pathnameOf(req.url);
3460
4103
  const method = (req.method ?? "GET").toUpperCase();
3461
4104
  if (pathname === "/api/health" && isRead(method)) return health(ctx, res);
3462
4105
  const verdict = guard(toAuthView(req));
3463
4106
  if (!verdict.ok) {
3464
- return sendError(res, verdict.status, verdict.reason, DENY_MESSAGE[verdict.reason]);
4107
+ return sendError(
4108
+ res,
4109
+ verdict.status,
4110
+ verdict.reason,
4111
+ t(DENY_KEY[verdict.reason], void 0, lang)
4112
+ );
3465
4113
  }
3466
4114
  if (verdict.setCookie) res.setHeader("Set-Cookie", verdict.setCookie);
3467
4115
  if (verdict.setCookie && isRead(method) && !pathname.startsWith("/api/")) {
3468
4116
  return redirectWithoutToken(res, req.url);
3469
4117
  }
3470
4118
  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);
4119
+ if (segments[0] === "api") return handleApi(opts, req, res, method, segments, lang);
4120
+ if (!isRead(method)) return methodNotAllowed(res, "GET, HEAD", lang);
4121
+ return handleStatic(opts, res, pathname, lang);
3474
4122
  }
3475
4123
  function isRead(method) {
3476
4124
  return method === "GET" || method === "HEAD";
@@ -3484,9 +4132,8 @@ function splitPath(pathname) {
3484
4132
  }
3485
4133
  });
3486
4134
  }
3487
- async function handleApi(opts, req, res, method, segments) {
4135
+ async function handleApi(opts, req, res, method, segments, lang) {
3488
4136
  const { ctx } = opts;
3489
- const lang = langOf(req.url);
3490
4137
  const [, resource, first, second, detail, tail] = segments;
3491
4138
  if (resource === "config" && isRead(method)) return config(ctx, res, lang);
3492
4139
  if (resource === "events" && method === "GET") return openStream(opts, req, res);
@@ -3494,13 +4141,13 @@ async function handleApi(opts, req, res, method, segments) {
3494
4141
  return handleSessions(opts, req, res, method, first, second, detail, lang);
3495
4142
  }
3496
4143
  if (resource === "approvals" && first !== void 0 && method === "POST") {
3497
- return respondApproval(ctx, req, res, first);
4144
+ return respondApproval(ctx, req, res, first, lang);
3498
4145
  }
3499
4146
  if (resource === "questions" && first !== void 0 && method === "POST") {
3500
- return respondQuestion(ctx, req, res, first);
4147
+ return respondQuestion(ctx, req, res, first, lang);
3501
4148
  }
3502
4149
  if (resource === "artifacts" && first !== void 0 && second !== void 0 && isRead(method)) {
3503
- return getArtifact(ctx, res, first, second);
4150
+ return getArtifact(ctx, res, first, second, lang);
3504
4151
  }
3505
4152
  if (resource === "mcp" && first === "config" && second === void 0 && isRead(method)) {
3506
4153
  return readConfig(ctx, res, lang);
@@ -3526,88 +4173,155 @@ async function handleApi(opts, req, res, method, segments) {
3526
4173
  if (resource === "skills" && first === "import" && second === void 0 && method === "POST") {
3527
4174
  return importSkills(ctx, req, res, lang);
3528
4175
  }
4176
+ if (resource === "skills" && first === "remove" && second === void 0 && method === "POST") {
4177
+ return removeSkill(ctx, req, res, lang);
4178
+ }
4179
+ if (resource === "plugins" && first === void 0 && isRead(method)) {
4180
+ return listPlugins(ctx, res, lang);
4181
+ }
4182
+ if (resource === "plugins" && first === "install" && second === "preview" && method === "POST") {
4183
+ return previewPluginInstall(ctx, req, res, lang);
4184
+ }
4185
+ if (resource === "plugins" && first === "install" && second === void 0 && method === "POST") {
4186
+ return installPluginFromMarket(ctx, req, res, lang);
4187
+ }
4188
+ if (resource === "plugins" && first === "update" && second === "preview" && method === "POST") {
4189
+ return previewPluginUpdate(ctx, req, res, lang);
4190
+ }
4191
+ if (resource === "plugins" && first === "update" && second === void 0 && method === "POST") {
4192
+ return updateInstalledPlugin(ctx, req, res, lang);
4193
+ }
4194
+ if (resource === "plugins" && first === "uninstall" && second === void 0 && method === "POST") {
4195
+ return uninstallPluginByName(ctx, req, res, lang);
4196
+ }
3529
4197
  if (resource === "providers" && first === void 0 && isRead(method)) {
3530
- return listProviders(ctx, res);
4198
+ return listProviders(ctx, res, lang);
3531
4199
  }
3532
4200
  if (resource === "providers" && first !== void 0 && second === "models" && method === "POST") {
3533
4201
  return discoverProviderModels(ctx, req, res, first, lang);
3534
4202
  }
4203
+ if (resource === "providers" && first !== void 0 && second === "key" && method === "POST") {
4204
+ return setProviderKey(ctx, req, res, first, lang);
4205
+ }
3535
4206
  if (resource === "workspaces" && first === "dirs" && isRead(method)) {
3536
4207
  return browseWorkspaceDirs(ctx, res, req.url, lang);
3537
4208
  }
3538
4209
  if (resource === "workspaces" && first === void 0 && method === "POST") {
3539
4210
  return createWorkspaceDir(req, res, lang);
3540
4211
  }
4212
+ if (resource === "workspaces" && first === "pick" && method === "POST") {
4213
+ return pickWorkspaceDir(ctx, req, res, lang);
4214
+ }
3541
4215
  if (resource === "schedules") {
3542
4216
  return handleSchedules(ctx, req, res, method, first, second, detail, tail, lang);
3543
4217
  }
3544
- sendNoEndpoint(res, method, `/${segments.join("/")}`);
4218
+ if (resource === "goals") {
4219
+ if (first === "blocked" && second === void 0) {
4220
+ if (isRead(method)) return listBlockedGoals(ctx, res);
4221
+ return methodNotAllowed(res, "GET", lang);
4222
+ }
4223
+ return sendNoEndpoint(res, method, `/api/goals/${first ?? ""}`, lang);
4224
+ }
4225
+ sendNoEndpoint(res, method, `/${segments.join("/")}`, lang);
3545
4226
  }
3546
4227
  async function handleSchedules(ctx, req, res, method, scheduleId, action, runId, tail, lang) {
3547
4228
  if (scheduleId === void 0) {
3548
- if (isRead(method)) return listSchedules(ctx, res);
4229
+ if (isRead(method)) return listSchedules(ctx, res, lang);
3549
4230
  if (method === "POST") return createSchedule(ctx, req, res, lang);
3550
- return methodNotAllowed(res, "GET, POST");
4231
+ return methodNotAllowed(res, "GET, POST", lang);
4232
+ }
4233
+ if (action === void 0 && scheduleId === "pending") {
4234
+ if (isRead(method)) return listPending(ctx, res, lang);
4235
+ return methodNotAllowed(res, "GET", lang);
3551
4236
  }
3552
4237
  if (action === void 0) {
3553
4238
  if (isRead(method)) return getSchedule(ctx, res, scheduleId, lang);
3554
4239
  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");
4240
+ if (method === "DELETE") return deleteSchedule(ctx, res, scheduleId, lang);
4241
+ return methodNotAllowed(res, "GET, PATCH, DELETE", lang);
3557
4242
  }
3558
4243
  if (action === "runs") {
3559
4244
  if (runId === void 0 && isRead(method)) return listRuns(ctx, res, scheduleId, lang);
3560
4245
  if (runId !== void 0 && tail === "recording" && isRead(method)) {
3561
4246
  return getRecording(ctx, res, scheduleId, runId, lang);
3562
4247
  }
3563
- return methodNotAllowed(res, "GET");
4248
+ return methodNotAllowed(res, "GET", lang);
3564
4249
  }
3565
4250
  if (action === "run" && method === "POST")
3566
4251
  return runSchedule(ctx, res, scheduleId, lang);
3567
4252
  if (action === "fix" && method === "POST") {
3568
4253
  return fixSchedule(ctx, req, res, scheduleId, lang);
3569
4254
  }
3570
- sendNoEndpoint(res, method, `/api/schedules/${scheduleId}`);
4255
+ sendNoEndpoint(res, method, `/api/schedules/${scheduleId}`, lang);
3571
4256
  }
3572
4257
  async function handleSessions(opts, req, res, method, sessionId, action, detail, lang) {
3573
4258
  const { ctx } = opts;
3574
4259
  if (sessionId === void 0) {
3575
4260
  if (isRead(method)) return listSessions(ctx, res, searchQuery(req.url));
3576
4261
  if (method === "POST") return createSession(ctx, req, res, lang);
3577
- return methodNotAllowed(res, "GET, POST");
4262
+ return methodNotAllowed(res, "GET, POST", lang);
3578
4263
  }
3579
4264
  if (action === void 0) {
3580
- if (isRead(method)) return getSession(ctx, res, sessionId);
4265
+ if (isRead(method)) return getSession(ctx, res, sessionId, lang);
3581
4266
  if (method === "DELETE") return deleteSession(ctx, res, sessionId, lang);
3582
4267
  if (method === "PATCH") return patchSession(ctx, req, res, sessionId, lang);
3583
- return methodNotAllowed(res, "GET, DELETE, PATCH");
4268
+ return methodNotAllowed(res, "GET, DELETE, PATCH", lang);
3584
4269
  }
3585
4270
  if (action === "messages") {
3586
- if (isRead(method)) return listMessages(ctx, res, sessionId);
4271
+ if (isRead(method)) return listMessages(ctx, res, sessionId, lang);
3587
4272
  if (method === "POST") return postMessage(ctx, req, res, sessionId, lang);
3588
- return methodNotAllowed(res, "GET, POST");
4273
+ return methodNotAllowed(res, "GET, POST", lang);
4274
+ }
4275
+ if (action === "abort" && method === "POST") return abortSession(ctx, res, sessionId, lang);
4276
+ if (action === "reference-candidates" && isRead(method)) {
4277
+ return listReferenceCandidates(ctx, res, sessionId, req.url, lang);
4278
+ }
4279
+ if (action === "file-candidates" && isRead(method)) {
4280
+ return listFileCandidates(ctx, res, sessionId, req.url, lang);
4281
+ }
4282
+ if (action === "file-stat" && isRead(method)) {
4283
+ return statFiles(ctx, res, sessionId, req.url, lang);
4284
+ }
4285
+ if (action === "file" && isRead(method)) {
4286
+ return readFileText(ctx, res, sessionId, req.url, lang);
4287
+ }
4288
+ if (action === "file-bytes" && isRead(method)) {
4289
+ return readFileBytes(ctx, res, sessionId, req.url, method === "HEAD", lang);
3589
4290
  }
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);
4291
+ if (action === "file-open" && method === "POST") {
4292
+ return openFileWithSystemApp(ctx, req, res, sessionId, lang);
4293
+ }
4294
+ if (action === "approvals" && isRead(method)) return listApprovals(ctx, res, sessionId, lang);
4295
+ if (action === "questions" && isRead(method)) return listQuestions(ctx, res, sessionId, lang);
3593
4296
  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);
4297
+ return listCapabilities(ctx, res, sessionId, lang);
4298
+ if (action === "commands" && isRead(method)) return listCommands(ctx, res, sessionId, lang);
4299
+ if (action === "security" && isRead(method)) return getSecurity(ctx, res, sessionId, lang);
4300
+ if (action === "tools" && isRead(method)) return getTools(ctx, res, sessionId, lang);
3598
4301
  if (action === "skills" && detail !== void 0 && isRead(method)) {
3599
4302
  return getSkillBody(ctx, res, sessionId, detail, lang);
3600
4303
  }
3601
- if (action === "settings" && isRead(method)) return getSettings(ctx, res, sessionId);
4304
+ if (action === "settings" && isRead(method)) return getSettings(ctx, res, sessionId, lang);
3602
4305
  if (action === "settings" && method === "POST") {
3603
4306
  return writeSetting(ctx, req, res, sessionId, lang);
3604
4307
  }
3605
4308
  if (action === "model" && isRead(method)) return getModel(ctx, res, sessionId);
3606
4309
  if (action === "model" && method === "POST")
3607
4310
  return setModel(ctx, req, res, sessionId, lang);
3608
- if (action === "artifacts" && isRead(method)) return getArtifacts(ctx, res, sessionId);
4311
+ if (action === "artifacts" && isRead(method)) return getArtifacts(ctx, res, sessionId, lang);
3609
4312
  if (action === "tasks" && isRead(method)) return getTasks(ctx, res, sessionId);
4313
+ if (action === "context" && isRead(method)) return getContext(ctx, res, sessionId, lang);
4314
+ if (action === "compact" && method === "POST") {
4315
+ return postCompact(ctx, req, res, sessionId, lang);
4316
+ }
3610
4317
  if (action === "plan" && isRead(method)) return getPlan(ctx, res, sessionId);
4318
+ if (action === "goal") {
4319
+ if (isRead(method)) return getGoal(ctx, res, sessionId, lang);
4320
+ if (method === "POST") return createGoal(ctx, req, res, sessionId, lang);
4321
+ if (method === "PATCH") return patchGoal(ctx, req, res, sessionId, lang);
4322
+ if (method === "DELETE") return deleteGoal(ctx, res, sessionId, lang);
4323
+ return methodNotAllowed(res, "GET, POST, PATCH, DELETE", lang);
4324
+ }
3611
4325
  if (action === "plan" && method === "POST")
3612
4326
  return setPlanMode(ctx, req, res, sessionId, lang);
3613
4327
  if (action === "permission" && isRead(method)) {
@@ -3616,6 +4330,9 @@ async function handleSessions(opts, req, res, method, sessionId, action, detail,
3616
4330
  if (action === "permission" && method === "POST") {
3617
4331
  return setPermission(ctx, req, res, sessionId, lang);
3618
4332
  }
4333
+ if (action === "approval-cache" && method === "POST") {
4334
+ return revokeApprovalCache(ctx, req, res, sessionId, lang);
4335
+ }
3619
4336
  if (action === "checkpoints" && isRead(method)) {
3620
4337
  return detail === void 0 ? listCheckpoints(ctx, res, sessionId) : previewCheckpoint(ctx, res, sessionId, detail, lang);
3621
4338
  }
@@ -3628,7 +4345,7 @@ async function handleSessions(opts, req, res, method, sessionId, action, detail,
3628
4345
  return bindWorkspace(ctx, req, res, sessionId, lang);
3629
4346
  }
3630
4347
  if (action === "diff" && isRead(method)) return sessionDiff(ctx, res, sessionId);
3631
- sendNoEndpoint(res, method, `/api/sessions/${sessionId}`);
4348
+ sendNoEndpoint(res, method, `/api/sessions/${sessionId}`, lang);
3632
4349
  }
3633
4350
  function openStream(opts, req, res) {
3634
4351
  const connection = new SseConnection(res, opts.heartbeatMs);
@@ -3643,13 +4360,20 @@ function openStream(opts, req, res) {
3643
4360
  req.on("close", finish);
3644
4361
  res.on("close", finish);
3645
4362
  }
3646
- function handleStatic(opts, res, pathname) {
4363
+ function handleStatic(opts, res, pathname, lang) {
3647
4364
  if (opts.webRoot) {
3648
4365
  const file = resolveStaticPath(opts.webRoot, pathname);
3649
4366
  if (file) return sendFile(res, file, { "Content-Type": staticContentType(file) });
3650
4367
  }
3651
- if (pathname !== "/") return sendError(res, 404, "not-found", `\u627E\u4E0D\u5230 ${pathname}`);
3652
- const html = placeholderPage(opts.ctx.version);
4368
+ if (pathname !== "/") {
4369
+ return sendError(
4370
+ res,
4371
+ 404,
4372
+ "not-found",
4373
+ t("web.route.static_not_found", { path: pathname }, lang)
4374
+ );
4375
+ }
4376
+ const html = placeholderPage(opts.ctx.version, lang);
3653
4377
  res.writeHead(200, {
3654
4378
  ...baseHeaders(),
3655
4379
  "Content-Type": "text/html; charset=utf-8",
@@ -3667,18 +4391,19 @@ function redirectWithoutToken(res, rawUrl) {
3667
4391
  res.writeHead(303, { ...baseHeaders(), Location: `${url.pathname}${url.search}` });
3668
4392
  res.end();
3669
4393
  }
3670
- function sendNoEndpoint(res, method, path) {
3671
- sendError(res, 404, "not-found", `\u6CA1\u6709\u8FD9\u4E2A\u7AEF\u70B9\uFF1A${method} ${path}`);
4394
+ function sendNoEndpoint(res, method, path, lang) {
4395
+ sendError(res, 404, "not-found", t("web.no_endpoint", { method, path }, lang));
3672
4396
  }
3673
- function methodNotAllowed(res, allow) {
3674
- sendError(res, 405, "method-not-allowed", `\u53EA\u652F\u6301 ${allow}`, { Allow: allow });
4397
+ function methodNotAllowed(res, allow, lang) {
4398
+ sendError(res, 405, "method-not-allowed", t("web.method_not_allowed", { allow }, lang), {
4399
+ Allow: allow
4400
+ });
3675
4401
  }
3676
4402
  function defaultWebRoot(baseDir = dirname(fileURLToPath(import.meta.url)), exists = existsSync) {
3677
4403
  return [join(baseDir, "web"), join(baseDir, "..", "dist", "web")].find(
3678
4404
  (dir) => exists(join(dir, "index.html"))
3679
4405
  );
3680
4406
  }
3681
-
3682
4407
  // src/index.ts
3683
4408
  async function createWebServer(opts) {
3684
4409
  const binding = decideBinding({
@@ -3692,9 +4417,6 @@ async function createWebServer(opts) {
3692
4417
  ...opts.idleGraceMs === void 0 ? {} : { idleGraceMs: opts.idleGraceMs },
3693
4418
  ...opts.clock === void 0 ? {} : { clock: opts.clock },
3694
4419
  ...opts.maxActiveSessions === void 0 ? {} : { maxActiveSessions: opts.maxActiveSessions },
3695
- // 被挤走的那个会话,引擎那一侧的东西也要放掉(方案 30 §2.3 的上限)。
3696
- // 不接这条线的话,上限只挡住了 Hub 那张表变长,而对话历史、`AgentLoop`、
3697
- // 审批桥全留着 —— 那正是冷却要还回来的东西。判据见 `SessionHubOptions.onCooled`
3698
4420
  onCooled: (sessionId) => opts.runtime.sessionFactory.release(sessionId)
3699
4421
  });
3700
4422
  if (opts.runtime.session) hub.register(opts.runtime.session);
@@ -3716,8 +4438,6 @@ async function createWebServer(opts) {
3716
4438
  host: binding.host,
3717
4439
  token: binding.token,
3718
4440
  ...webRoot === void 0 ? {} : { webRoot },
3719
- // 两个都是直接转 hub 上那一对,**没有中间层** —— 转出去的就是浏览器
3720
- // 收到的那条流和 `GET /api/sessions` 读的那张表,不是它们的复制品
3721
4441
  observe: (sink) => hub.observe(sink),
3722
4442
  snapshot: () => hub.listSessions(),
3723
4443
  close: makeCloser(server, hub, opts.runtime)
@@ -3725,18 +4445,20 @@ async function createWebServer(opts) {
3725
4445
  };
3726
4446
  }
3727
4447
  function buildHandler(opts, hub, binding, port, webRoot) {
4448
+ const machineFacts = {
4449
+ lanExposed: binding.lanExposed,
4450
+ platform: process.platform,
4451
+ env: process.env
4452
+ };
3728
4453
  return createRequestHandler({
3729
4454
  ctx: {
3730
4455
  hub,
3731
4456
  runtime: opts.runtime,
3732
4457
  version: opts.version,
3733
- // 缺省问引擎要(方案 54 §二)。`??` 而不是 `||`:宿主传空字符串是它自己
3734
- // 的错,静默替换成引擎那个会把「我明明配了别处」变成一个查不出的怪现象
3735
4458
  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
4459
+ lanExposed: binding.lanExposed,
4460
+ nativeDirPicker: resolveNativeDirPicker(machineFacts),
4461
+ sameMachine: isSameMachine(machineFacts)
3740
4462
  },
3741
4463
  guard: createAuthGuard({ token: binding.token, port, lanExposed: binding.lanExposed }),
3742
4464
  ...webRoot === void 0 ? {} : { webRoot },
@@ -3759,15 +4481,15 @@ function listen(server, host, port) {
3759
4481
  }
3760
4482
  function describeListenError(err, host, port) {
3761
4483
  if (err.code === "EADDRINUSE") {
3762
- return `\u7AEF\u53E3 ${port} \u5DF2\u88AB\u5360\u7528\uFF0C\u6362\u4E00\u4E2A\uFF1Aepoch web --port ${port + 1}`;
4484
+ return t("web.listen_in_use", { port, next: port + 1 }, void 0);
3763
4485
  }
3764
4486
  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`;
4487
+ return t("web.listen_no_permission", { host, port }, void 0);
3766
4488
  }
3767
4489
  if (err.code === "EADDRNOTAVAIL") {
3768
- return `\u672C\u673A\u6CA1\u6709 ${host} \u8FD9\u4E2A\u5730\u5740`;
4490
+ return t("web.listen_no_address", { host }, void 0);
3769
4491
  }
3770
- return `\u76D1\u542C ${host}:${port} \u5931\u8D25\uFF1A${err.message}`;
4492
+ return t("web.listen_failed", { host, port, reason: err.message }, void 0);
3771
4493
  }
3772
4494
  function makeCloser(server, hub, runtime) {
3773
4495
  let closing = null;
@@ -3782,4 +4504,4 @@ function makeCloser(server, hub, runtime) {
3782
4504
  };
3783
4505
  }
3784
4506
 
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 };
4507
+ export { DEFAULT_MAX_ACTIVE_SESSIONS, DEFAULT_MAX_QUEUED_MESSAGES, DEFAULT_RING_CAPACITY, DEFAULT_WEB_HOST, DEFAULT_WEB_PORT, EnvelopeRing, MAX_CANDIDATES, MAX_DIFF_BYTES, MAX_DIFF_FILES, MAX_RECORDING_FRAMES, MAX_SKILL_BODY_BYTES, SessionHub, TASK_TAIL_BYTES, UI_LANG_PARAM, UI_THEME_PARAM, attachMentions, collectCapabilities, collectSecurity, collectSettings, collectTasks, collectWorkspaceDiff, createAuthGuard, createWebServer, decideBinding, defaultWebRoot, expandUserContent, firstScreenUrl, generateToken, isSameMachine, listFileCandidates, listReferenceCandidates, readRewindInput, resolveArtifactPath, resolveNativeDirPicker, toWireCheckpoint, toWireCommand, toWirePluginEntry, toWirePreview, toWireRewindResult, toWireRow, toWireSkillBody, toWireTask, unavailableToHttp };