@epoch-agent/server 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +360 -81
- package/dist/index.d.ts +839 -15
- package/dist/index.js +871 -585
- package/dist/web/assets/index-Bj3SdU3h.js +366 -0
- package/dist/web/assets/index-cRd3MeBE.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +3 -3
- package/dist/web/assets/index-D3Z9KCql.js +0 -295
- package/dist/web/assets/index-W6_gte_K.css +0 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createServer } from 'http';
|
|
2
|
+
import { t, ApprovalRelay, QuestionRelay, serializeStream, withRoleScope, withModelScope, withToolScope, nextRunAt, skillIndexDescription, SANDBOX_EXCLUDES, SANDBOX_COVERS, 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, 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';
|
|
@@ -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 {
|
|
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:
|
|
111
|
-
|
|
112
|
-
|
|
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()) {
|
|
@@ -967,13 +717,13 @@ function sendJson(res, status, body, extra = {}) {
|
|
|
967
717
|
function sendError(res, status, code, message, extra = {}) {
|
|
968
718
|
sendJson(res, status, { error: { code, message } }, extra);
|
|
969
719
|
}
|
|
970
|
-
function sendUnknownSession(res, sessionId) {
|
|
971
|
-
sendError(res, 404, "unknown-session",
|
|
720
|
+
function sendUnknownSession(res, sessionId, lang) {
|
|
721
|
+
sendError(res, 404, "unknown-session", t("web.unknown_session", { id: sessionId }, lang));
|
|
972
722
|
}
|
|
973
723
|
function baseHeaders() {
|
|
974
724
|
return { ...BASE_HEADERS };
|
|
975
725
|
}
|
|
976
|
-
async function readJsonBody(req, limit = JSON_BODY_LIMIT) {
|
|
726
|
+
async function readJsonBody(req, limit = JSON_BODY_LIMIT, lang) {
|
|
977
727
|
const chunks = [];
|
|
978
728
|
let size = 0;
|
|
979
729
|
try {
|
|
@@ -982,19 +732,31 @@ async function readJsonBody(req, limit = JSON_BODY_LIMIT) {
|
|
|
982
732
|
size += buf.length;
|
|
983
733
|
if (size > limit) {
|
|
984
734
|
req.destroy();
|
|
985
|
-
return {
|
|
735
|
+
return {
|
|
736
|
+
ok: false,
|
|
737
|
+
status: 413,
|
|
738
|
+
message: t("web.body_too_large", { limit }, lang)
|
|
739
|
+
};
|
|
986
740
|
}
|
|
987
741
|
chunks.push(buf);
|
|
988
742
|
}
|
|
989
743
|
} catch (err) {
|
|
990
|
-
return {
|
|
744
|
+
return {
|
|
745
|
+
ok: false,
|
|
746
|
+
status: 400,
|
|
747
|
+
message: t("web.body_read_failed", { reason: describe(err) }, lang)
|
|
748
|
+
};
|
|
991
749
|
}
|
|
992
750
|
const text = Buffer.concat(chunks).toString("utf8");
|
|
993
751
|
if (text.trim().length === 0) return { ok: true, value: {} };
|
|
994
752
|
try {
|
|
995
753
|
return { ok: true, value: JSON.parse(text) };
|
|
996
754
|
} catch (err) {
|
|
997
|
-
return {
|
|
755
|
+
return {
|
|
756
|
+
ok: false,
|
|
757
|
+
status: 400,
|
|
758
|
+
message: t("web.body_bad_json", { reason: describe(err) }, lang)
|
|
759
|
+
};
|
|
998
760
|
}
|
|
999
761
|
}
|
|
1000
762
|
function describe(err) {
|
|
@@ -1003,7 +765,6 @@ function describe(err) {
|
|
|
1003
765
|
function asRecord(value) {
|
|
1004
766
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
1005
767
|
}
|
|
1006
|
-
|
|
1007
768
|
// src/assets.ts
|
|
1008
769
|
var INLINE_ARTIFACT_TYPES = /* @__PURE__ */ new Map([
|
|
1009
770
|
[".png", "image/png"],
|
|
@@ -1087,9 +848,9 @@ function resolveStaticPath(webRoot, pathname) {
|
|
|
1087
848
|
return null;
|
|
1088
849
|
}
|
|
1089
850
|
}
|
|
1090
|
-
function placeholderPage(version) {
|
|
851
|
+
function placeholderPage(version, lang) {
|
|
1091
852
|
return `<!doctype html>
|
|
1092
|
-
<html lang="
|
|
853
|
+
<html lang="${t("web.placeholder_html_lang", void 0, lang)}">
|
|
1093
854
|
<head>
|
|
1094
855
|
<meta charset="utf-8">
|
|
1095
856
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
@@ -1104,10 +865,21 @@ code{background:#1b2027;padding:.1rem .35rem;border-radius:3px}
|
|
|
1104
865
|
</head>
|
|
1105
866
|
<body><main>
|
|
1106
867
|
<h1>Epoch Agent Web \xB7 ${escapeHtml(version)}</h1>
|
|
1107
|
-
<p
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
868
|
+
<p>${t("web.placeholder_ready", void 0, lang)}${t(
|
|
869
|
+
"web.placeholder_needs_build",
|
|
870
|
+
{
|
|
871
|
+
no_build: `<strong>${t("web.placeholder_no_build", void 0, lang)}</strong>`,
|
|
872
|
+
cmd: "<code>pnpm build</code>"
|
|
873
|
+
},
|
|
874
|
+
lang
|
|
875
|
+
)}</p>
|
|
876
|
+
<p>${t(
|
|
877
|
+
"web.placeholder_rest",
|
|
878
|
+
{
|
|
879
|
+
endpoints: ["/api/health", "/api/config", "/api/events"].map((path) => `<code>GET ${path}</code>`).join(t("web.placeholder_endpoint_sep", void 0, lang))
|
|
880
|
+
},
|
|
881
|
+
lang
|
|
882
|
+
)}</p>
|
|
1111
883
|
</main></body></html>`;
|
|
1112
884
|
}
|
|
1113
885
|
function escapeHtml(text) {
|
|
@@ -1125,25 +897,26 @@ function builtinConnectors(tools) {
|
|
|
1125
897
|
return [...counts.entries()].map(([name, toolCount]) => ({ name, toolCount })).sort((a, b) => a.name.localeCompare(b.name));
|
|
1126
898
|
}
|
|
1127
899
|
function toWireRole(role, notices) {
|
|
900
|
+
const cutOf = (kind) => notices.find((n) => n.role === role.name && n.kind === kind)?.cut ?? [];
|
|
1128
901
|
return {
|
|
1129
902
|
name: role.name,
|
|
1130
903
|
description: role.description,
|
|
1131
904
|
source: role.source,
|
|
1132
|
-
// `?? null` 而不是 `?? []`:不给 tools 的角色是「不限」,空数组是
|
|
1133
|
-
// 「一个都不给」。合并成同一个值之后,`general` 会显示成一个废掉的角色
|
|
1134
905
|
tools: role.tools ?? null,
|
|
906
|
+
skills: role.skills ?? null,
|
|
1135
907
|
maxTurns: role.maxTurns ?? null,
|
|
1136
|
-
toolsCut:
|
|
908
|
+
toolsCut: cutOf("tools"),
|
|
909
|
+
skillsCut: cutOf("skills")
|
|
1137
910
|
};
|
|
1138
911
|
}
|
|
1139
|
-
function toWireSkill(skill) {
|
|
912
|
+
function toWireSkill(skill, residency) {
|
|
1140
913
|
const description = skillIndexDescription(skill.description);
|
|
1141
914
|
return {
|
|
1142
915
|
name: skill.name,
|
|
1143
916
|
category: skill.category || "general",
|
|
1144
917
|
description,
|
|
1145
|
-
|
|
1146
|
-
|
|
918
|
+
tokens: estimateIndexTokens(` - ${skill.name}: ${description}`),
|
|
919
|
+
residency,
|
|
1147
920
|
scope: skill.scope,
|
|
1148
921
|
type: skill.type
|
|
1149
922
|
};
|
|
@@ -1159,22 +932,18 @@ function toWireMcp(server) {
|
|
|
1159
932
|
toolCount: server.toolCount,
|
|
1160
933
|
reconnectAttempts: server.reconnectAttempts,
|
|
1161
934
|
...server.lastError ? { lastError: server.lastError } : {},
|
|
1162
|
-
// **原样转发,这一层不判也不补**:来源是装配层的结论(`connectAll(…, source)`),
|
|
1163
|
-
// 在这儿再猜一次(比如「名字里有 `__` 就算宿主的」)等于给同一件事第二个答案
|
|
1164
935
|
source: server.source
|
|
1165
936
|
};
|
|
1166
937
|
}
|
|
1167
|
-
function collectCapabilities(runtime, tools, bound) {
|
|
938
|
+
function collectCapabilities(runtime, tools, bound, role) {
|
|
939
|
+
const residency = runtime.skillIndexResidency(role);
|
|
1168
940
|
return {
|
|
1169
941
|
roles: runtime.agentRoles.map((r) => toWireRole(r, runtime.agentRoleNotices)),
|
|
1170
|
-
skills: runtime.skills.map(toWireSkill),
|
|
942
|
+
skills: runtime.skills.map((s) => toWireSkill(s, residency.get(s.name) ?? "indexed")),
|
|
1171
943
|
builtinConnectors: builtinConnectors(tools),
|
|
1172
944
|
mcpServers: runtime.mcpServers.map(toWireMcp),
|
|
1173
945
|
project: {
|
|
1174
946
|
root: bound?.workspace.root ?? null,
|
|
1175
|
-
// 没绑工作区时报 `false` 而不是 `true`:界面拿它决定要不要画那句
|
|
1176
|
-
// 「不受信任的目录连读都不读」,而没有工作区时那句话是对的
|
|
1177
|
-
// —— 项目层确实一个字都没读
|
|
1178
947
|
trusted: bound?.trust.trusted ?? false
|
|
1179
948
|
}
|
|
1180
949
|
};
|
|
@@ -1201,8 +970,6 @@ function toWireCommand(def) {
|
|
|
1201
970
|
return {
|
|
1202
971
|
name: def.name,
|
|
1203
972
|
description: def.description,
|
|
1204
|
-
// 没写就不带这个键(不是空串):空串会被画成一个空的参数提示位,
|
|
1205
|
-
// 于是「这条命令不吃参数」和「作者忘了写提示」看起来一样
|
|
1206
973
|
...def.argumentHint ? { argumentHint: def.argumentHint } : {}
|
|
1207
974
|
};
|
|
1208
975
|
}
|
|
@@ -1247,14 +1014,179 @@ function replaceLeadingText(message, prompt) {
|
|
|
1247
1014
|
const head = { type: "text", text: prompt };
|
|
1248
1015
|
return [head, ...message.slice(1)];
|
|
1249
1016
|
}
|
|
1250
|
-
|
|
1017
|
+
var MAX_CANDIDATES = 20;
|
|
1018
|
+
var MAX_CANDIDATE_LIMIT = 50;
|
|
1019
|
+
function visibilityOf(ctx, sessionId) {
|
|
1020
|
+
const root = ctx.runtime.workspaces.of(sessionId)?.projectContext.rootDir;
|
|
1021
|
+
return {
|
|
1022
|
+
currentSessionId: sessionId,
|
|
1023
|
+
...root ? { workspaceCwd: root } : {},
|
|
1024
|
+
allWorkspaces: ctx.runtime.config.sessionSearch?.allWorkspaces === true
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
function toWireCandidate(row) {
|
|
1028
|
+
return {
|
|
1029
|
+
sessionId: row.sessionId,
|
|
1030
|
+
title: row.title,
|
|
1031
|
+
...row.cwd ? { cwd: row.cwd } : {},
|
|
1032
|
+
updatedAt: row.updatedAt,
|
|
1033
|
+
messageCount: row.messageCount,
|
|
1034
|
+
sameWorkspace: row.sameWorkspace
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
function toWireReference(part) {
|
|
1038
|
+
const { type: _type, text: _text, ...rest } = part;
|
|
1039
|
+
return rest;
|
|
1040
|
+
}
|
|
1041
|
+
function candidateQuery(rawUrl) {
|
|
1042
|
+
const params = new URL(rawUrl ?? "/", "http://127.0.0.1").searchParams;
|
|
1043
|
+
const asked = Number.parseInt(params.get("limit") ?? "", 10);
|
|
1044
|
+
const limit = Number.isFinite(asked) && asked > 0 ? Math.min(asked, MAX_CANDIDATE_LIMIT) : MAX_CANDIDATES;
|
|
1045
|
+
return { query: params.get("q")?.trim() ?? "", limit };
|
|
1046
|
+
}
|
|
1047
|
+
function listReferenceCandidates(ctx, res, sessionId, rawUrl, lang) {
|
|
1048
|
+
const references = ctx.runtime.sessionReferences;
|
|
1049
|
+
if (!references) {
|
|
1050
|
+
return sendError(
|
|
1051
|
+
res,
|
|
1052
|
+
503,
|
|
1053
|
+
"no-session-store",
|
|
1054
|
+
t("web.reference_store_unavailable", void 0, lang)
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
|
|
1058
|
+
return sendUnknownSession(res, sessionId);
|
|
1059
|
+
}
|
|
1060
|
+
const { query, limit } = candidateQuery(rawUrl);
|
|
1061
|
+
const rows = references.candidates({ ...visibilityOf(ctx, sessionId), query, limit });
|
|
1062
|
+
sendJson(res, 200, {
|
|
1063
|
+
candidates: rows.map(toWireCandidate)
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
function workspaceRootOf(ctx, sessionId) {
|
|
1067
|
+
return ctx.runtime.workspaces.of(sessionId)?.projectContext.rootDir ?? null;
|
|
1068
|
+
}
|
|
1069
|
+
function listFileCandidates(ctx, res, sessionId, rawUrl, lang) {
|
|
1070
|
+
if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
|
|
1071
|
+
return sendUnknownSession(res, sessionId);
|
|
1072
|
+
}
|
|
1073
|
+
const root = workspaceRootOf(ctx, sessionId);
|
|
1074
|
+
if (!root) {
|
|
1075
|
+
return sendError(
|
|
1076
|
+
res,
|
|
1077
|
+
409,
|
|
1078
|
+
"no-workspace",
|
|
1079
|
+
t("web.file_candidates_no_workspace", void 0, lang)
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
const { query, limit } = candidateQuery(rawUrl);
|
|
1083
|
+
const result = ctx.runtime.workspaceFiles.candidates(query, limit, root);
|
|
1084
|
+
if (!result) {
|
|
1085
|
+
return sendError(
|
|
1086
|
+
res,
|
|
1087
|
+
503,
|
|
1088
|
+
"file-list-unavailable",
|
|
1089
|
+
t("web.file_list_unavailable", void 0, lang)
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
sendJson(res, 200, {
|
|
1093
|
+
candidates: result.candidates.map((path) => ({ path })),
|
|
1094
|
+
unmentionable: result.unmentionable,
|
|
1095
|
+
truncated: result.truncated
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
function attachMentions(ctx, sessionId, message) {
|
|
1099
|
+
const text = leadingText2(message);
|
|
1100
|
+
if (text === null) return { message, references: [], files: [] };
|
|
1101
|
+
const mentions = extractAllMentions(text);
|
|
1102
|
+
if (mentions.files.length === 0 && mentions.sessions.length === 0) {
|
|
1103
|
+
return { message, references: [], files: [] };
|
|
1104
|
+
}
|
|
1105
|
+
const parts = [];
|
|
1106
|
+
let used = 0;
|
|
1107
|
+
const fileReceipts = [];
|
|
1108
|
+
for (const [index, path] of mentions.files.entries()) {
|
|
1109
|
+
const part = index >= MAX_ATTACH_FILES ? { type: "file", path, omitted: "over-limit" } : readOneFile(ctx, sessionId, path, used);
|
|
1110
|
+
parts.push(part);
|
|
1111
|
+
if (part.text !== void 0) used += utf8ByteLength(part.text);
|
|
1112
|
+
fileReceipts.push(toWireFileReference(part));
|
|
1113
|
+
}
|
|
1114
|
+
const refParts = [];
|
|
1115
|
+
for (const [index, ref] of mentions.sessions.entries()) {
|
|
1116
|
+
if (index >= MAX_SESSION_REFS) {
|
|
1117
|
+
refParts.push({ type: "session", ref, omitted: "too-many" });
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1120
|
+
const outcome = resolveOne(ctx, sessionId, ref, used);
|
|
1121
|
+
refParts.push(outcome);
|
|
1122
|
+
if (outcome.text !== void 0) used += utf8ByteLength(outcome.text);
|
|
1123
|
+
}
|
|
1124
|
+
parts.push(...refParts);
|
|
1125
|
+
return {
|
|
1126
|
+
message: [...parts, ...asParts(message)],
|
|
1127
|
+
references: refParts.map(toWireReference),
|
|
1128
|
+
files: fileReceipts
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
function readOneFile(ctx, sessionId, path, usedBytes) {
|
|
1132
|
+
const remaining = MAX_ATTACH_TOTAL_BYTES - usedBytes;
|
|
1133
|
+
if (remaining <= 0) return { type: "file", path, omitted: "over-limit" };
|
|
1134
|
+
const budget = Math.min(MAX_ATTACH_BYTES, remaining);
|
|
1135
|
+
const root = workspaceRootOf(ctx, sessionId);
|
|
1136
|
+
if (!root) return { type: "file", path, omitted: "denied" };
|
|
1137
|
+
const out = ctx.runtime.workspaceFiles.readFile(path, root);
|
|
1138
|
+
if (!out.ok) {
|
|
1139
|
+
return {
|
|
1140
|
+
type: "file",
|
|
1141
|
+
path,
|
|
1142
|
+
omitted: out.reason,
|
|
1143
|
+
...out.bytes === void 0 ? {} : { bytes: out.bytes }
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
if (out.bytes <= budget) {
|
|
1147
|
+
return { type: "file", path, text: out.text, bytes: out.bytes };
|
|
1148
|
+
}
|
|
1149
|
+
const raw = new TextEncoder().encode(out.text);
|
|
1150
|
+
return {
|
|
1151
|
+
type: "file",
|
|
1152
|
+
path,
|
|
1153
|
+
text: new TextDecoder().decode(raw.slice(0, budget)),
|
|
1154
|
+
truncatedTo: budget,
|
|
1155
|
+
bytes: out.bytes
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
function resolveOne(ctx, sessionId, ref, usedBytes) {
|
|
1159
|
+
const references = ctx.runtime.sessionReferences;
|
|
1160
|
+
if (!references) return { type: "session", ref, omitted: "invalid-reference" };
|
|
1161
|
+
const resolved = references.resolve({ ...visibilityOf(ctx, sessionId), ref });
|
|
1162
|
+
if (resolved.reason || !resolved.sessionId) {
|
|
1163
|
+
return { type: "session", ref, omitted: resolved.reason ?? "invalid-reference" };
|
|
1164
|
+
}
|
|
1165
|
+
const surface = references.surface(resolved.sessionId);
|
|
1166
|
+
if (!surface) return { type: "session", ref, omitted: "invalid-reference" };
|
|
1167
|
+
return attachSessionSurface(ref, surface, usedBytes).part;
|
|
1168
|
+
}
|
|
1169
|
+
function toWireFileReference(part) {
|
|
1170
|
+
const { type: _type, text: _text, ...rest } = part;
|
|
1171
|
+
return rest;
|
|
1172
|
+
}
|
|
1173
|
+
function leadingText2(message) {
|
|
1174
|
+
if (typeof message === "string") return message;
|
|
1175
|
+
const first = message[0];
|
|
1176
|
+
return first && first.type === "text" ? first.text : null;
|
|
1177
|
+
}
|
|
1178
|
+
function asParts(message) {
|
|
1179
|
+
return typeof message === "string" ? [{ type: "text", text: message }] : message;
|
|
1180
|
+
}
|
|
1181
|
+
function toWireSandbox(iso, terminalEnabled) {
|
|
1251
1182
|
return {
|
|
1252
1183
|
level: iso.level,
|
|
1253
1184
|
backend: iso.backend,
|
|
1254
1185
|
reason: iso.reason,
|
|
1255
1186
|
platform: iso.platform,
|
|
1256
1187
|
covers: SANDBOX_COVERS,
|
|
1257
|
-
excludes: SANDBOX_EXCLUDES
|
|
1188
|
+
excludes: SANDBOX_EXCLUDES,
|
|
1189
|
+
terminalEnabled
|
|
1258
1190
|
};
|
|
1259
1191
|
}
|
|
1260
1192
|
function toWireRule(rule) {
|
|
@@ -1274,6 +1206,16 @@ function toWireAudit(audit) {
|
|
|
1274
1206
|
}));
|
|
1275
1207
|
return { rows, dropped: audit.dropped };
|
|
1276
1208
|
}
|
|
1209
|
+
function toWireApproval(row) {
|
|
1210
|
+
return {
|
|
1211
|
+
id: row.id,
|
|
1212
|
+
toolName: row.toolName,
|
|
1213
|
+
target: row.target,
|
|
1214
|
+
scope: row.scope,
|
|
1215
|
+
decision: row.decision,
|
|
1216
|
+
at: row.createdAt
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1277
1219
|
function toWireManaged(managed) {
|
|
1278
1220
|
return {
|
|
1279
1221
|
present: managed.present,
|
|
@@ -1284,15 +1226,12 @@ function toWireManaged(managed) {
|
|
|
1284
1226
|
}
|
|
1285
1227
|
var PERMISSIONS_UNAVAILABLE = {
|
|
1286
1228
|
level: "unknown",
|
|
1287
|
-
// 配置里那一档同样说不出来(方案 56 §1.3):`PermissionsControl` 整个没起来,
|
|
1288
|
-
// 而这两格都是从它上面读的。⚠️ **不在这儿回落成 `default`** —— 那是一个
|
|
1289
|
-
// 看起来确切、其实没人核过的值;`unknown` 会被界面原样印出来,正是要的效果。
|
|
1290
|
-
// `configuredLayer` 跟着报 `unknown`:那一档的含义就是「分不出」
|
|
1291
1229
|
configured: "unknown",
|
|
1292
1230
|
configuredLayer: "unknown",
|
|
1293
1231
|
rules: [],
|
|
1294
1232
|
shadows: [],
|
|
1295
|
-
managed: { present: false, path: "", rulesOnly: false, bypassDisabled: false }
|
|
1233
|
+
managed: { present: false, path: "", rulesOnly: false, bypassDisabled: false },
|
|
1234
|
+
cached: []
|
|
1296
1235
|
};
|
|
1297
1236
|
function toWirePolicy(policy) {
|
|
1298
1237
|
const dirs = policy.dirs.map((d) => ({
|
|
@@ -1324,36 +1263,19 @@ function toWireTrustList(known, workspaces, currentRoot) {
|
|
|
1324
1263
|
function collectSecurity(runtime, workspaces, bound, session) {
|
|
1325
1264
|
const perms = runtime.permissions;
|
|
1326
1265
|
return {
|
|
1327
|
-
|
|
1328
|
-
sandbox: runtime.isolation ? toWireSandbox(runtime.isolation) : null,
|
|
1266
|
+
sandbox: runtime.isolation ? toWireSandbox(runtime.isolation, runtime.config.sandbox?.terminal !== false) : null,
|
|
1329
1267
|
permission: perms ? {
|
|
1330
|
-
/*
|
|
1331
|
-
* **这个会话**那一档,取不到才退回进程那一份。
|
|
1332
|
-
*
|
|
1333
|
-
* 退回去的那一支不是兜底措辞,是一句真话:这个进程没在跑那段会话
|
|
1334
|
-
* (只剩历史 / 被冷却)时,「它此刻是哪一档」没有答案,而进程那一档
|
|
1335
|
-
* 是这台机器上此刻真在生效的那个。判据逐字同 `permission.ts` 的
|
|
1336
|
-
* `permissionState()`。⚠️ 下面四样**刻意不跟着分家**,见文件头。
|
|
1337
|
-
*/
|
|
1338
1268
|
level: session?.level() ?? perms.level(),
|
|
1339
|
-
/*
|
|
1340
|
-
* 配置里那一档(方案 56 §1.3)。**刻意不跟着上面那一格走会话** ——
|
|
1341
|
-
* 它答的是「新会话开出来是哪一档」,一个进程一份。这两格经常真的
|
|
1342
|
-
* 不同(在底栏改过一次档就当场分家),而那正是界面画两行的理由:
|
|
1343
|
-
* 只画一行的话,「此刻在跑的是 bypass」和「配置里赢在②的是 default」
|
|
1344
|
-
* 会被混成一句话
|
|
1345
|
-
*/
|
|
1346
1269
|
configured: perms.configured().level,
|
|
1347
1270
|
configuredLayer: perms.configured().layer,
|
|
1348
1271
|
rules: perms.rules().map(toWireRule),
|
|
1349
1272
|
shadows: perms.shadows().map(toWireShadow),
|
|
1350
|
-
managed: toWireManaged(perms.managed())
|
|
1273
|
+
managed: toWireManaged(perms.managed()),
|
|
1274
|
+
cached: perms.approvals().map(toWireApproval)
|
|
1351
1275
|
} : PERMISSIONS_UNAVAILABLE,
|
|
1352
1276
|
workspace: bound ? toWireSecurityWorkspace(bound) : null,
|
|
1353
1277
|
knownWorkspaces: toWireTrustList(workspaces.known(), workspaces, bound?.workspace.root ?? null),
|
|
1354
1278
|
policy: toWirePolicy(runtime.policy),
|
|
1355
|
-
// 权限层起不来时是**空账**而不是别的什么:没有权限层就等于什么都没判过,
|
|
1356
|
-
// 而那句话是真的(同 runtime 那边 `audit()` 的兜底,两处必须一致)
|
|
1357
1279
|
audit: perms ? toWireAudit(perms.audit()) : { rows: [], dropped: 0 }
|
|
1358
1280
|
};
|
|
1359
1281
|
}
|
|
@@ -1371,7 +1293,6 @@ function unknownRoleToHttp(role, lang) {
|
|
|
1371
1293
|
message: t("web.session_unknown_role", { role }, lang)
|
|
1372
1294
|
};
|
|
1373
1295
|
}
|
|
1374
|
-
|
|
1375
1296
|
// src/sessions/revive.ts
|
|
1376
1297
|
function persistedDecision(row) {
|
|
1377
1298
|
if (!row) return null;
|
|
@@ -1385,7 +1306,6 @@ function reviveSession(ctx, sessionId, lang) {
|
|
|
1385
1306
|
const outcome = ctx.runtime.sessionFactory.revive({
|
|
1386
1307
|
sessionId,
|
|
1387
1308
|
decision: persistedDecision(ctx.runtime.sessions?.get(sessionId) ?? null),
|
|
1388
|
-
// 绑不上那句 `detail` 跟着请求语言走,同建会话那条路(方案 58 PR-2)
|
|
1389
1309
|
...lang === void 0 ? {} : { lang }
|
|
1390
1310
|
});
|
|
1391
1311
|
if (outcome.ok) {
|
|
@@ -1486,23 +1406,18 @@ function collectTools(runtime, sessionId, session) {
|
|
|
1486
1406
|
for (const row of runtime.permissions?.gates(level) ?? []) gates.set(row.name, row);
|
|
1487
1407
|
}
|
|
1488
1408
|
const tools = runtime.tools.map((tool) => {
|
|
1489
|
-
const
|
|
1409
|
+
const gate2 = gates.get(tool.name);
|
|
1490
1410
|
return {
|
|
1491
1411
|
name: tool.name,
|
|
1492
1412
|
description: tool.description,
|
|
1493
1413
|
source: tool.source,
|
|
1494
|
-
|
|
1495
|
-
// 拿不到时回 `command` ——「最保守的那一类」而不是「最常见的那一类」:
|
|
1496
|
-
// 这一格此刻只被界面拿去分组,而分错组的代价远小于按工具名前缀猜一个
|
|
1497
|
-
// (那种猜法在第一个不按前缀命名的工具上就错,判据在 `WireToolSummary.source`)
|
|
1498
|
-
type: gate?.type ?? "command",
|
|
1414
|
+
type: gate2?.type ?? "command",
|
|
1499
1415
|
calls: counts.get(tool.name) ?? 0,
|
|
1500
|
-
...
|
|
1416
|
+
...gate2 === void 0 ? {} : { gate: toWireGate(gate2) }
|
|
1501
1417
|
};
|
|
1502
1418
|
});
|
|
1503
1419
|
return { tools, level: level ?? "unknown" };
|
|
1504
1420
|
}
|
|
1505
|
-
|
|
1506
1421
|
// src/api.ts
|
|
1507
1422
|
var APPROVAL_OUTCOMES = /* @__PURE__ */ new Set([
|
|
1508
1423
|
"allow-once",
|
|
@@ -1524,39 +1439,20 @@ function config(ctx, res, lang) {
|
|
|
1524
1439
|
workspace: bound ? toWireSessionWorkspace(bound) : null,
|
|
1525
1440
|
knownWorkspaces: toWireKnownWorkspaces(runtime.workspaces.known()),
|
|
1526
1441
|
usageScope: runtime.usageScope,
|
|
1527
|
-
// 原样转发,**一个字段都不在这儿重算**:窗口是 `AgentLoop` 手里那一份本体、
|
|
1528
|
-
// 阈值走的是 core 那个「装配层唯一的入口」。在这一层拿 `runtime.config` 拼一份
|
|
1529
|
-
// 出来,就是给同一条线第二个说法(同 `artifactsRoot` 那条判据)
|
|
1530
1442
|
compression: runtime.compression,
|
|
1531
|
-
// 没配就**不带这个键**(决定 8)。写成 `?? null` 之类的话,浏览器那边
|
|
1532
|
-
// 「配没配上限」就要靠判空值,而金额显不显示正是挂在这一个判断上
|
|
1533
1443
|
...runtime.config.budget?.maxCostUsd !== void 0 ? { maxCostUsd: runtime.config.budget.maxCostUsd } : {},
|
|
1534
|
-
// 品牌词标同上:**没配就不带这个键**(决定 19)。侧栏那一格「显不显示」
|
|
1535
|
-
// 挂在这一个判断上 —— 给一个 `null` 或者空串的话,「宿主没给」就成了
|
|
1536
|
-
// 浏览器要自己解读的东西,而它的硬约束是不给就什么都不画
|
|
1537
1444
|
...runtime.config.brand !== void 0 ? { brand: runtime.config.brand } : {},
|
|
1538
1445
|
provider: runtime.providerInfo,
|
|
1539
1446
|
tools: runtime.tools,
|
|
1540
|
-
// 请求带了语言、而且宿主给得出「换个语言说一遍」时才走那条;两个条件缺一个
|
|
1541
|
-
// 就是 `diagnosticList`(进程语言)——判据在 `WebRuntimeView.diagnosticsIn` 上
|
|
1542
1447
|
diagnostics: (lang && runtime.diagnosticsIn?.(lang)) ?? runtime.diagnosticList,
|
|
1543
1448
|
about: {
|
|
1544
|
-
// 版本从 `ctx.version` 取 —— 那是**宿主传进来的**那一个(`/api/health` 回的
|
|
1545
|
-
// 也是它)。在这儿读 `package.json` 会给同一个数第二个说法,而嵌入宿主的
|
|
1546
|
-
// 版本号本来就不是我们的版本号
|
|
1547
1449
|
version: ctx.version,
|
|
1548
1450
|
nodeVersion: process.version,
|
|
1549
1451
|
platform: `${process.platform}-${process.arch}`,
|
|
1550
1452
|
homeDir: runtime.config.homeDir,
|
|
1551
1453
|
dbPath: runtime.config.dbPath,
|
|
1552
|
-
// 缺省档(字面量 `default`)时**不带这个键** —— 判据在 `WireAbout.profile`:
|
|
1553
|
-
// 恒发一行「配置档:default」对没换过档的人是噪声
|
|
1554
1454
|
...runtime.config.profile !== "default" ? { profile: runtime.config.profile } : {}
|
|
1555
1455
|
},
|
|
1556
|
-
// 绑在回环之外了没有(2026-08-18)。**原样转发 `ctx.lanExposed`,这一层不判
|
|
1557
|
-
// 也不改名** —— 它是一个事实,不是一个结论(判据在 `WireConfigResponse.lanExposed`
|
|
1558
|
-
// 上)。今天的消费方只有能力页那张「新建身份」:它据此在**按下去之前**画出
|
|
1559
|
-
// 「这一档下建不了」,而真正拦住那一发的是 `role-add.ts` 里那道 403
|
|
1560
1456
|
lanExposed: ctx.lanExposed
|
|
1561
1457
|
});
|
|
1562
1458
|
}
|
|
@@ -1565,22 +1461,15 @@ function listSessions(ctx, res, query, _lang) {
|
|
|
1565
1461
|
ctx.hub.listSessions(),
|
|
1566
1462
|
ctx.runtime.sessions,
|
|
1567
1463
|
query ?? null,
|
|
1568
|
-
// 每行带上**这个进程手里**那份绑定(决定 18)—— 侧栏按「任务 / 空间」分组要用它。
|
|
1569
|
-
// ⚠️ 手里没有不等于那一行就说不出地盘:会话库 v7 之后落盘的那次决定会接着答
|
|
1570
|
-
// (顺序在 `sessions/summary.ts` 的 `toWireSummaryWorkspace` 那张表上)
|
|
1571
1464
|
(id) => ctx.runtime.workspaces.of(id),
|
|
1572
|
-
// 以及它以什么身份跑(2026-08-18)。只有活着的那几行答得出 —— 身份只活在
|
|
1573
|
-
// 内存里。⚠️ **2026-08-19 起这句话和 `workspace` 那格不再是同一条边界**:
|
|
1574
|
-
// 那一格落盘了,这一格没有 —— 那是一笔明写的欠账,记在
|
|
1575
|
-
// `WireSessionSummary.role` 上(复活的旧会话会以 `general` 接着跑)
|
|
1576
1465
|
(id) => ctx.runtime.sessionFactory.get(id)?.role ?? null
|
|
1577
1466
|
);
|
|
1578
1467
|
sendJson(res, 200, { sessions });
|
|
1579
1468
|
}
|
|
1580
|
-
function getSession(ctx, res, sessionId,
|
|
1469
|
+
function getSession(ctx, res, sessionId, lang) {
|
|
1581
1470
|
const hub = ctx.hub.listSessions().find((one) => one.id === sessionId);
|
|
1582
1471
|
const row = ctx.runtime.sessions?.get(sessionId) ?? null;
|
|
1583
|
-
if (hub === void 0 && row === null) return sendUnknownSession(res, sessionId);
|
|
1472
|
+
if (hub === void 0 && row === null) return sendUnknownSession(res, sessionId, lang);
|
|
1584
1473
|
sendJson(res, 200, {
|
|
1585
1474
|
session: toWireSummary(sessionId, hub, row, {
|
|
1586
1475
|
workspace: ctx.runtime.workspaces.of(sessionId),
|
|
@@ -1608,11 +1497,11 @@ async function deleteSession(ctx, res, sessionId, lang) {
|
|
|
1608
1497
|
);
|
|
1609
1498
|
}
|
|
1610
1499
|
const outcome = await catalog.delete(sessionId);
|
|
1611
|
-
if (!outcome.deleted && !inHub) return sendUnknownSession(res, sessionId);
|
|
1500
|
+
if (!outcome.deleted && !inHub) return sendUnknownSession(res, sessionId, lang);
|
|
1612
1501
|
sendJson(res, 200, outcome);
|
|
1613
1502
|
}
|
|
1614
1503
|
async function patchSession(ctx, req, res, sessionId, lang) {
|
|
1615
|
-
const body = await readJsonBody(req);
|
|
1504
|
+
const body = await readJsonBody(req, void 0, lang);
|
|
1616
1505
|
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
1617
1506
|
const raw = asRecord(body.value).title;
|
|
1618
1507
|
const title = typeof raw === "string" ? raw.trim() : "";
|
|
@@ -1629,7 +1518,7 @@ async function patchSession(ctx, req, res, sessionId, lang) {
|
|
|
1629
1518
|
);
|
|
1630
1519
|
}
|
|
1631
1520
|
const row = catalog.rename(sessionId, title);
|
|
1632
|
-
if (!row) return sendUnknownSession(res, sessionId);
|
|
1521
|
+
if (!row) return sendUnknownSession(res, sessionId, lang);
|
|
1633
1522
|
const hub = ctx.hub.listSessions().find((s) => s.id === sessionId);
|
|
1634
1523
|
sendJson(res, 200, {
|
|
1635
1524
|
session: toWireSummary(sessionId, hub, row, {
|
|
@@ -1638,9 +1527,9 @@ async function patchSession(ctx, req, res, sessionId, lang) {
|
|
|
1638
1527
|
})
|
|
1639
1528
|
});
|
|
1640
1529
|
}
|
|
1641
|
-
function listMessages(ctx, res, sessionId,
|
|
1530
|
+
function listMessages(ctx, res, sessionId, lang) {
|
|
1642
1531
|
if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
|
|
1643
|
-
return sendUnknownSession(res, sessionId);
|
|
1532
|
+
return sendUnknownSession(res, sessionId, lang);
|
|
1644
1533
|
}
|
|
1645
1534
|
const messages = ctx.runtime.sessionStore?.loadMessages(sessionId) ?? [];
|
|
1646
1535
|
sendJson(res, 200, { messages });
|
|
@@ -1652,14 +1541,14 @@ async function postMessage(ctx, req, res, sessionId, lang) {
|
|
|
1652
1541
|
return sendError(res, revived.status, revived.code, revived.message);
|
|
1653
1542
|
}
|
|
1654
1543
|
if (!revived.ok && !ctx.hub.isCooled(sessionId)) {
|
|
1655
|
-
return sendUnknownSession(res, sessionId);
|
|
1544
|
+
return sendUnknownSession(res, sessionId, lang);
|
|
1656
1545
|
}
|
|
1657
1546
|
}
|
|
1658
|
-
const body = await readJsonBody(req);
|
|
1547
|
+
const body = await readJsonBody(req, void 0, lang);
|
|
1659
1548
|
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
1660
1549
|
const message = parseUserContent(asRecord(body.value).message);
|
|
1661
1550
|
if (message === null) {
|
|
1662
|
-
return sendError(res, 400, "bad-message", "
|
|
1551
|
+
return sendError(res, 400, "bad-message", t("web.bad_message", void 0, lang));
|
|
1663
1552
|
}
|
|
1664
1553
|
const live = ctx.runtime.sessionFactory.get(sessionId);
|
|
1665
1554
|
const picked = pickTurnRole(live, asRecord(body.value)["role"]);
|
|
@@ -1668,21 +1557,22 @@ async function postMessage(ctx, req, res, sessionId, lang) {
|
|
|
1668
1557
|
return sendError(res, mapped.status, mapped.code, mapped.message);
|
|
1669
1558
|
}
|
|
1670
1559
|
const expansion = expandUserContent(ctx.runtime, message, live?.model ?? null, picked.role);
|
|
1671
|
-
const
|
|
1560
|
+
const attached = attachMentions(ctx, sessionId, expansion.message);
|
|
1561
|
+
const started = ctx.hub.start(sessionId, attached.message, expansion.decorate);
|
|
1672
1562
|
if (started.ok) {
|
|
1673
1563
|
return sendJson(res, 202, {
|
|
1674
1564
|
accepted: true,
|
|
1675
1565
|
queued: started.queued,
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
...
|
|
1566
|
+
...expansion.notice ? { command: expansion.notice } : {},
|
|
1567
|
+
...attached.references.length > 0 ? { references: attached.references } : {},
|
|
1568
|
+
...attached.files.length > 0 ? { files: attached.files } : {}
|
|
1679
1569
|
});
|
|
1680
1570
|
}
|
|
1681
|
-
if (started.reason === "unknown-session") return sendUnknownSession(res, sessionId);
|
|
1571
|
+
if (started.reason === "unknown-session") return sendUnknownSession(res, sessionId, lang);
|
|
1682
1572
|
if (started.reason === "cooled") {
|
|
1683
1573
|
return sendError(res, 409, "session-cooled", t("web.session_cooled", void 0, lang));
|
|
1684
1574
|
}
|
|
1685
|
-
sendError(res, 409, "busy",
|
|
1575
|
+
sendError(res, 409, "busy", t("web.session_busy", { state: started.state }, lang), {
|
|
1686
1576
|
"X-Epoch-Turn-State": started.state
|
|
1687
1577
|
});
|
|
1688
1578
|
}
|
|
@@ -1692,40 +1582,45 @@ function pickTurnRole(live, raw) {
|
|
|
1692
1582
|
if (!live?.roles.has(name)) return { ok: false, detail: name };
|
|
1693
1583
|
return { ok: true, role: { control: live.roles, name } };
|
|
1694
1584
|
}
|
|
1695
|
-
function abortSession(ctx, res, sessionId,
|
|
1696
|
-
if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId);
|
|
1585
|
+
function abortSession(ctx, res, sessionId, lang) {
|
|
1586
|
+
if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId, lang);
|
|
1697
1587
|
sendJson(res, 200, { aborted: ctx.hub.abort(sessionId) });
|
|
1698
1588
|
}
|
|
1699
|
-
function listApprovals(ctx, res, sessionId,
|
|
1700
|
-
if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId);
|
|
1589
|
+
function listApprovals(ctx, res, sessionId, lang) {
|
|
1590
|
+
if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId, lang);
|
|
1701
1591
|
sendJson(res, 200, {
|
|
1702
1592
|
state: ctx.hub.stateOf(sessionId),
|
|
1703
1593
|
approvals: ctx.hub.listPending(sessionId)
|
|
1704
1594
|
});
|
|
1705
1595
|
}
|
|
1706
|
-
async function respondApproval(ctx, req, res, requestId,
|
|
1707
|
-
const body = await readJsonBody(req);
|
|
1596
|
+
async function respondApproval(ctx, req, res, requestId, lang) {
|
|
1597
|
+
const body = await readJsonBody(req, void 0, lang);
|
|
1708
1598
|
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
1709
1599
|
const payload = asRecord(body.value);
|
|
1710
1600
|
const outcome = payload.outcome;
|
|
1711
1601
|
if (typeof outcome !== "string" || !APPROVAL_OUTCOMES.has(outcome)) {
|
|
1712
|
-
return sendError(
|
|
1602
|
+
return sendError(
|
|
1603
|
+
res,
|
|
1604
|
+
400,
|
|
1605
|
+
"bad-outcome",
|
|
1606
|
+
t("web.bad_outcome", { choices: [...APPROVAL_OUTCOMES].join("/") }, lang)
|
|
1607
|
+
);
|
|
1713
1608
|
}
|
|
1714
1609
|
const note = typeof payload.note === "string" ? payload.note : void 0;
|
|
1715
1610
|
if (!ctx.hub.respondApproval(requestId, outcome, note)) {
|
|
1716
|
-
return sendError(res, 404, "unknown-request",
|
|
1611
|
+
return sendError(res, 404, "unknown-request", t("web.approval_gone", { id: requestId }, lang));
|
|
1717
1612
|
}
|
|
1718
1613
|
sendJson(res, 200, { resolved: true });
|
|
1719
1614
|
}
|
|
1720
|
-
function listQuestions(ctx, res, sessionId,
|
|
1721
|
-
if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId);
|
|
1615
|
+
function listQuestions(ctx, res, sessionId, lang) {
|
|
1616
|
+
if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId, lang);
|
|
1722
1617
|
sendJson(res, 200, {
|
|
1723
1618
|
state: ctx.hub.stateOf(sessionId),
|
|
1724
1619
|
questions: ctx.hub.listPendingQuestions(sessionId)
|
|
1725
1620
|
});
|
|
1726
1621
|
}
|
|
1727
|
-
async function respondQuestion(ctx, req, res, requestId,
|
|
1728
|
-
const body = await readJsonBody(req);
|
|
1622
|
+
async function respondQuestion(ctx, req, res, requestId, lang) {
|
|
1623
|
+
const body = await readJsonBody(req, void 0, lang);
|
|
1729
1624
|
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
1730
1625
|
const payload = asRecord(body.value);
|
|
1731
1626
|
if (!isQuestionAnswerMap(payload.answers)) {
|
|
@@ -1733,7 +1628,7 @@ async function respondQuestion(ctx, req, res, requestId, _lang) {
|
|
|
1733
1628
|
res,
|
|
1734
1629
|
400,
|
|
1735
1630
|
"bad-answers",
|
|
1736
|
-
|
|
1631
|
+
t("web.bad_answers", { json: '{"answers":{},"skipped":true}' }, lang)
|
|
1737
1632
|
);
|
|
1738
1633
|
}
|
|
1739
1634
|
const answer = {
|
|
@@ -1741,47 +1636,44 @@ async function respondQuestion(ctx, req, res, requestId, _lang) {
|
|
|
1741
1636
|
...payload.skipped === true ? { skipped: true } : {}
|
|
1742
1637
|
};
|
|
1743
1638
|
if (!ctx.hub.respondQuestion(requestId, answer)) {
|
|
1744
|
-
return sendError(res, 404, "unknown-request",
|
|
1639
|
+
return sendError(res, 404, "unknown-request", t("web.question_gone", { id: requestId }, lang));
|
|
1745
1640
|
}
|
|
1746
1641
|
sendJson(res, 200, { resolved: true });
|
|
1747
1642
|
}
|
|
1748
|
-
function listCapabilities(ctx, res, sessionId,
|
|
1643
|
+
function listCapabilities(ctx, res, sessionId, lang) {
|
|
1749
1644
|
if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
|
|
1750
|
-
return sendUnknownSession(res, sessionId);
|
|
1645
|
+
return sendUnknownSession(res, sessionId, lang);
|
|
1751
1646
|
}
|
|
1752
1647
|
const payload = collectCapabilities(
|
|
1753
1648
|
ctx.runtime,
|
|
1754
1649
|
ctx.runtime.tools,
|
|
1755
|
-
ctx.runtime.workspaces.of(sessionId)
|
|
1650
|
+
ctx.runtime.workspaces.of(sessionId),
|
|
1651
|
+
ctx.runtime.sessionFactory.get(sessionId)?.role?.name ?? null
|
|
1756
1652
|
);
|
|
1757
1653
|
sendJson(res, 200, payload);
|
|
1758
1654
|
}
|
|
1759
|
-
function listCommands(ctx, res, sessionId,
|
|
1655
|
+
function listCommands(ctx, res, sessionId, lang) {
|
|
1760
1656
|
if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
|
|
1761
|
-
return sendUnknownSession(res, sessionId);
|
|
1657
|
+
return sendUnknownSession(res, sessionId, lang);
|
|
1762
1658
|
}
|
|
1763
1659
|
const commands = ctx.runtime.commands.list.map(toWireCommand);
|
|
1764
1660
|
sendJson(res, 200, { commands });
|
|
1765
1661
|
}
|
|
1766
|
-
function getSecurity(ctx, res, sessionId,
|
|
1662
|
+
function getSecurity(ctx, res, sessionId, lang) {
|
|
1767
1663
|
if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
|
|
1768
|
-
return sendUnknownSession(res, sessionId);
|
|
1664
|
+
return sendUnknownSession(res, sessionId, lang);
|
|
1769
1665
|
}
|
|
1770
1666
|
const payload = collectSecurity(
|
|
1771
1667
|
ctx.runtime,
|
|
1772
1668
|
ctx.runtime.workspaces,
|
|
1773
1669
|
ctx.runtime.workspaces.of(sessionId),
|
|
1774
|
-
// **档位按会话取**(2026-08-16 那一轮之后 `PermissionManager` 一个会话一份)。
|
|
1775
|
-
// 少了这个实参,这一屏印的是引导会话那一档,而那张卡的色带跟着一起答错人 ——
|
|
1776
|
-
// 判据全文在 `security.ts` 文件头。工厂手里没有(只剩历史)时传 null,
|
|
1777
|
-
// 投影自己退回进程那一份
|
|
1778
1670
|
ctx.runtime.sessionFactory.get(sessionId)?.permissions ?? null
|
|
1779
1671
|
);
|
|
1780
1672
|
sendJson(res, 200, payload);
|
|
1781
1673
|
}
|
|
1782
|
-
function getTools(ctx, res, sessionId,
|
|
1674
|
+
function getTools(ctx, res, sessionId, lang) {
|
|
1783
1675
|
if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
|
|
1784
|
-
return sendUnknownSession(res, sessionId);
|
|
1676
|
+
return sendUnknownSession(res, sessionId, lang);
|
|
1785
1677
|
}
|
|
1786
1678
|
const payload = collectTools(
|
|
1787
1679
|
ctx.runtime,
|
|
@@ -1790,17 +1682,17 @@ function getTools(ctx, res, sessionId, _lang) {
|
|
|
1790
1682
|
);
|
|
1791
1683
|
sendJson(res, 200, payload);
|
|
1792
1684
|
}
|
|
1793
|
-
function getSettings(ctx, res, sessionId,
|
|
1685
|
+
function getSettings(ctx, res, sessionId, lang) {
|
|
1794
1686
|
if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
|
|
1795
|
-
return sendUnknownSession(res, sessionId);
|
|
1687
|
+
return sendUnknownSession(res, sessionId, lang);
|
|
1796
1688
|
}
|
|
1797
1689
|
sendJson(res, 200, collectSettings(ctx.runtime, sessionId));
|
|
1798
1690
|
}
|
|
1799
1691
|
async function writeSetting(ctx, req, res, sessionId, lang) {
|
|
1800
1692
|
if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
|
|
1801
|
-
return sendUnknownSession(res, sessionId);
|
|
1693
|
+
return sendUnknownSession(res, sessionId, lang);
|
|
1802
1694
|
}
|
|
1803
|
-
const body = await readJsonBody(req);
|
|
1695
|
+
const body = await readJsonBody(req, void 0, lang);
|
|
1804
1696
|
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
1805
1697
|
const input = readSettingWriteBody(asRecord(body.value));
|
|
1806
1698
|
if (!input.ok) {
|
|
@@ -1823,22 +1715,20 @@ async function writeSetting(ctx, req, res, sessionId, lang) {
|
|
|
1823
1715
|
}
|
|
1824
1716
|
sendJson(res, 200, toWireSettingsWrite(report));
|
|
1825
1717
|
}
|
|
1826
|
-
function getArtifacts(ctx, res, sessionId,
|
|
1718
|
+
function getArtifacts(ctx, res, sessionId, lang) {
|
|
1827
1719
|
if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
|
|
1828
|
-
return sendUnknownSession(res, sessionId);
|
|
1720
|
+
return sendUnknownSession(res, sessionId, lang);
|
|
1829
1721
|
}
|
|
1830
1722
|
const root = ctx.runtime.workspaces.of(sessionId)?.workspace.root ?? null;
|
|
1831
1723
|
const changes = root === null ? [] : ctx.runtime.sessionStore?.fileChanges(sessionId, root) ?? [];
|
|
1832
1724
|
sendJson(res, 200, { changes, root });
|
|
1833
1725
|
}
|
|
1834
|
-
function getArtifact(ctx, res, sessionId, name,
|
|
1726
|
+
function getArtifact(ctx, res, sessionId, name, lang) {
|
|
1835
1727
|
const path = resolveArtifactPath(ctx.artifactsRoot, sessionId, name);
|
|
1836
|
-
if (!path) return sendError(res, 404, "not-found", "
|
|
1728
|
+
if (!path) return sendError(res, 404, "not-found", t("web.artifact_missing", void 0, lang));
|
|
1837
1729
|
const { type, inline } = artifactDisposition(path);
|
|
1838
1730
|
sendFile(res, path, {
|
|
1839
1731
|
"Content-Type": type,
|
|
1840
|
-
// 不认识的类型一律 attachment:让浏览器把 artifact 当 HTML 渲染,
|
|
1841
|
-
// 等于在本服务的同源里执行任意脚本,而这个源上挂着全部 agent API
|
|
1842
1732
|
"Content-Disposition": inline ? "inline" : `attachment; filename="${encodeURIComponent(name)}"`
|
|
1843
1733
|
});
|
|
1844
1734
|
}
|
|
@@ -1847,11 +1737,42 @@ function parseUserContent(raw) {
|
|
|
1847
1737
|
if (Array.isArray(raw) && raw.length > 0) return raw;
|
|
1848
1738
|
return null;
|
|
1849
1739
|
}
|
|
1740
|
+
async function revokeApprovalCache(ctx, req, res, sessionId, lang) {
|
|
1741
|
+
if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId, lang);
|
|
1742
|
+
const perms = ctx.runtime.permissions;
|
|
1743
|
+
if (!perms) {
|
|
1744
|
+
return sendError(
|
|
1745
|
+
res,
|
|
1746
|
+
503,
|
|
1747
|
+
"no-permission-layer",
|
|
1748
|
+
t("web.permission_unavailable", void 0, lang)
|
|
1749
|
+
);
|
|
1750
|
+
}
|
|
1751
|
+
const body = await readJsonBody(req);
|
|
1752
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
1753
|
+
const id = asRecord(body.value)["id"];
|
|
1754
|
+
if (typeof id !== "string" || id === "") {
|
|
1755
|
+
return sendError(res, 400, "bad-id", t("web.approval_cache_bad_id", void 0, lang));
|
|
1756
|
+
}
|
|
1757
|
+
const result = perms.revokeApproval(id);
|
|
1758
|
+
if (!result.ok && result.reason === "no-permission-layer") {
|
|
1759
|
+
return sendError(
|
|
1760
|
+
res,
|
|
1761
|
+
503,
|
|
1762
|
+
"no-permission-layer",
|
|
1763
|
+
t("web.permission_unavailable", void 0, lang)
|
|
1764
|
+
);
|
|
1765
|
+
}
|
|
1766
|
+
const payload = {
|
|
1767
|
+
revoked: result.ok,
|
|
1768
|
+
cached: perms.approvals().map(toWireApproval)
|
|
1769
|
+
};
|
|
1770
|
+
sendJson(res, 200, payload);
|
|
1771
|
+
}
|
|
1850
1772
|
function toWireCheckpoint(summary) {
|
|
1851
1773
|
return {
|
|
1852
1774
|
turnIndex: summary.turnIndex,
|
|
1853
1775
|
createdAt: summary.createdAt,
|
|
1854
|
-
// 用户自己那句原话,**照原样过** —— 它不是我们写的字,不进任何 catalog
|
|
1855
1776
|
preview: summary.preview,
|
|
1856
1777
|
fileCount: summary.fileCount,
|
|
1857
1778
|
incomplete: summary.incomplete
|
|
@@ -1865,7 +1786,6 @@ function toWirePreview(preview) {
|
|
|
1865
1786
|
turnIndex: preview.turnIndex,
|
|
1866
1787
|
createdAt: preview.createdAt,
|
|
1867
1788
|
preview: preview.preview,
|
|
1868
|
-
// 引擎算好的那个布尔,**不在这一层从 cursor 重算** —— 镜像上压根没有 cursor
|
|
1869
1789
|
canRewindConversation: preview.canRewindConversation,
|
|
1870
1790
|
incomplete: preview.incomplete,
|
|
1871
1791
|
files: preview.files.map(toWirePlan),
|
|
@@ -1878,9 +1798,6 @@ function toWireOutcome(outcome) {
|
|
|
1878
1798
|
deleted: [...outcome.deleted],
|
|
1879
1799
|
recreated: [...outcome.recreated],
|
|
1880
1800
|
skippedConflicts: [...outcome.skippedConflicts],
|
|
1881
|
-
// 成功时**整个不给这个键**(同 `note` / `skipped` 的规矩):契约里它就是
|
|
1882
|
-
// 「可以不给」,而 `failed: undefined` 会被 JSON.stringify 抹掉之后长得一样,
|
|
1883
|
-
// 写成条件展开是把「没有失败」这件事说明白
|
|
1884
1801
|
...outcome.failed === void 0 ? {} : { failed: outcome.failed }
|
|
1885
1802
|
};
|
|
1886
1803
|
}
|
|
@@ -1917,8 +1834,6 @@ function readRewindInput(body, lang) {
|
|
|
1917
1834
|
const overwrite = raw === void 0 ? void 0 : raw;
|
|
1918
1835
|
return {
|
|
1919
1836
|
ok: true,
|
|
1920
|
-
// 空数组和「没给」在引擎那边是一回事(`new Set(opts.overwrite ?? [])`),
|
|
1921
|
-
// 但这里照样原样转:客户端发了空数组是它在说「一个都不覆盖」,那是一句话
|
|
1922
1837
|
value: { turnIndex, scope, ...overwrite === void 0 ? {} : { overwrite } }
|
|
1923
1838
|
};
|
|
1924
1839
|
}
|
|
@@ -1980,12 +1895,200 @@ async function rewind(ctx, req, res, sessionId, lang) {
|
|
|
1980
1895
|
const { turnIndex, scope, overwrite } = input.value;
|
|
1981
1896
|
const result = await control.rewind(turnIndex, {
|
|
1982
1897
|
scope,
|
|
1983
|
-
// ⚠️ **只转用户点过头的那几个**。这里绝不能顺手把 `preview.conflicts` 并进去 ——
|
|
1984
|
-
// 那正是「全部覆盖」那个按钮的等价物,而它毁掉的是用户自己刚写的东西
|
|
1985
1898
|
...overwrite === void 0 ? {} : { overwrite }
|
|
1986
1899
|
});
|
|
1987
1900
|
sendJson(res, 200, toWireRewindResult(result));
|
|
1988
1901
|
}
|
|
1902
|
+
// src/context-budget.ts
|
|
1903
|
+
function toolSourceLookup(tools) {
|
|
1904
|
+
const byName = new Map(tools.map((tool) => [tool.name, tool.source]));
|
|
1905
|
+
return (name) => byName.get(name);
|
|
1906
|
+
}
|
|
1907
|
+
function contextBreakdownOf(ctx, live) {
|
|
1908
|
+
const historyText = live.session.getHistory().map((m) => m.content).join("\n");
|
|
1909
|
+
return {
|
|
1910
|
+
breakdown: live.agent.contextBreakdown({
|
|
1911
|
+
historyText,
|
|
1912
|
+
toolSource: toolSourceLookup(ctx.runtime.tools)
|
|
1913
|
+
})
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
function getContext(ctx, res, sessionId, lang) {
|
|
1917
|
+
const live = ctx.runtime.sessionFactory.get(sessionId);
|
|
1918
|
+
if (!live) return sendUnknownSession(res, sessionId, lang);
|
|
1919
|
+
sendJson(res, 200, contextBreakdownOf(ctx, live));
|
|
1920
|
+
}
|
|
1921
|
+
async function postCompact(ctx, req, res, sessionId, lang) {
|
|
1922
|
+
const live = ctx.runtime.sessionFactory.get(sessionId);
|
|
1923
|
+
if (!live) return sendUnknownSession(res, sessionId, lang);
|
|
1924
|
+
const body = await readJsonBody(req);
|
|
1925
|
+
const raw = body.ok ? asRecord(body.value)["instruction"] : void 0;
|
|
1926
|
+
const instruction = typeof raw === "string" && raw.trim() ? raw.trim() : void 0;
|
|
1927
|
+
const result = await live.session.compact(instruction);
|
|
1928
|
+
sendJson(res, 200, {
|
|
1929
|
+
ran: result.ran,
|
|
1930
|
+
before: result.before,
|
|
1931
|
+
after: result.after,
|
|
1932
|
+
...result.reason === void 0 ? {} : { reason: result.reason }
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
function str(value) {
|
|
1936
|
+
return typeof value === "string" ? value : null;
|
|
1937
|
+
}
|
|
1938
|
+
function rounds(value) {
|
|
1939
|
+
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
|
1940
|
+
}
|
|
1941
|
+
function parseCreate(body, lang) {
|
|
1942
|
+
const raw = asRecord(body);
|
|
1943
|
+
const objective = str(raw.objective);
|
|
1944
|
+
if (objective === null) {
|
|
1945
|
+
return { ok: false, message: t("web.goal_bad_objective", void 0, lang) };
|
|
1946
|
+
}
|
|
1947
|
+
if (raw.maxRounds === void 0) return { ok: true, value: { objective } };
|
|
1948
|
+
const maxRounds = rounds(raw.maxRounds);
|
|
1949
|
+
if (maxRounds === null) {
|
|
1950
|
+
return { ok: false, message: t("web.goal_bad_rounds", void 0, lang) };
|
|
1951
|
+
}
|
|
1952
|
+
return { ok: true, value: { objective, maxRounds } };
|
|
1953
|
+
}
|
|
1954
|
+
function parseUpdate(body, lang) {
|
|
1955
|
+
const raw = asRecord(body);
|
|
1956
|
+
const action = str(raw.action);
|
|
1957
|
+
if (action === null || !WIRE_GOAL_ACTIONS.includes(action)) {
|
|
1958
|
+
return {
|
|
1959
|
+
ok: false,
|
|
1960
|
+
message: t(
|
|
1961
|
+
"web.goal_bad_action",
|
|
1962
|
+
{ action: action ?? String(raw.action), known: WIRE_GOAL_ACTIONS.join(" / ") },
|
|
1963
|
+
lang
|
|
1964
|
+
)
|
|
1965
|
+
};
|
|
1966
|
+
}
|
|
1967
|
+
if (action === "pause") return { ok: true, value: { action: "pause" } };
|
|
1968
|
+
if (action === "resume") return { ok: true, value: { action: "resume" } };
|
|
1969
|
+
if (action === "edit") {
|
|
1970
|
+
const objective = str(raw.objective);
|
|
1971
|
+
return objective === null ? { ok: false, message: t("web.goal_bad_objective", void 0, lang) } : { ok: true, value: { action: "edit", objective } };
|
|
1972
|
+
}
|
|
1973
|
+
if (action === "budget") {
|
|
1974
|
+
const maxRounds = rounds(raw.maxRounds);
|
|
1975
|
+
return maxRounds === null ? { ok: false, message: t("web.goal_bad_rounds", void 0, lang) } : { ok: true, value: { action: "budget", maxRounds } };
|
|
1976
|
+
}
|
|
1977
|
+
const evidence = str(raw.evidence);
|
|
1978
|
+
return evidence === null ? { ok: false, message: t("web.goal_bad_evidence", void 0, lang) } : { ok: true, value: { action: "complete", evidence } };
|
|
1979
|
+
}
|
|
1980
|
+
// src/goal/handlers.ts
|
|
1981
|
+
var BLOCKED_SCAN_LIMIT = 50;
|
|
1982
|
+
function goalsOf(ctx) {
|
|
1983
|
+
return ctx.runtime.goalCatalog;
|
|
1984
|
+
}
|
|
1985
|
+
function limitsOf() {
|
|
1986
|
+
return {
|
|
1987
|
+
defaultMaxRounds: DEFAULT_GOAL_MAX_ROUNDS,
|
|
1988
|
+
maxRounds: MAX_GOAL_MAX_ROUNDS,
|
|
1989
|
+
maxObjectiveChars: MAX_OBJECTIVE_CHARS,
|
|
1990
|
+
maxReasonChars: MAX_REASON_CHARS
|
|
1991
|
+
};
|
|
1992
|
+
}
|
|
1993
|
+
function toWireGoal(goal) {
|
|
1994
|
+
return {
|
|
1995
|
+
objective: goal.objective,
|
|
1996
|
+
phase: goal.phase,
|
|
1997
|
+
...goal.block ? { block: { code: goal.block.code, message: goal.block.message } } : {},
|
|
1998
|
+
maxRounds: goal.maxRounds,
|
|
1999
|
+
roundsUsed: goal.roundsUsed,
|
|
2000
|
+
...goal.completeEvidence === void 0 ? {} : { completeEvidence: goal.completeEvidence },
|
|
2001
|
+
createdAt: goal.createdAt,
|
|
2002
|
+
updatedAt: goal.updatedAt
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
function toWriteResponse(result) {
|
|
2006
|
+
return result.ok ? { ok: true, goal: toWireGoal(result.goal) } : { ok: false, goal: null, reason: result.reason };
|
|
2007
|
+
}
|
|
2008
|
+
function gate(ctx, res, sessionId, lang) {
|
|
2009
|
+
const goals = goalsOf(ctx);
|
|
2010
|
+
if (!goals) {
|
|
2011
|
+
sendError(res, 503, "no-goal-store", t("web.goal_store_unavailable", void 0, lang));
|
|
2012
|
+
return null;
|
|
2013
|
+
}
|
|
2014
|
+
if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
|
|
2015
|
+
sendUnknownSession(res, sessionId);
|
|
2016
|
+
return null;
|
|
2017
|
+
}
|
|
2018
|
+
return goals;
|
|
2019
|
+
}
|
|
2020
|
+
function getGoal(ctx, res, sessionId, lang) {
|
|
2021
|
+
const goals = gate(ctx, res, sessionId, lang);
|
|
2022
|
+
if (!goals) return;
|
|
2023
|
+
const goal = goals.current(sessionId);
|
|
2024
|
+
sendJson(res, 200, {
|
|
2025
|
+
goal: goal ? toWireGoal(goal) : null,
|
|
2026
|
+
limits: limitsOf()
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
async function createGoal(ctx, req, res, sessionId, lang) {
|
|
2030
|
+
const goals = gate(ctx, res, sessionId, lang);
|
|
2031
|
+
if (!goals) return;
|
|
2032
|
+
const body = await readJsonBody(req);
|
|
2033
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
2034
|
+
const parsed = parseCreate(body.value, lang);
|
|
2035
|
+
if (!parsed.ok) return sendError(res, 400, "bad-goal", parsed.message);
|
|
2036
|
+
const { objective, maxRounds } = parsed.value;
|
|
2037
|
+
const result = maxRounds === void 0 ? goals.create(sessionId, objective) : goals.create(sessionId, objective, maxRounds);
|
|
2038
|
+
sendJson(res, 200, toWriteResponse(result));
|
|
2039
|
+
}
|
|
2040
|
+
async function patchGoal(ctx, req, res, sessionId, lang) {
|
|
2041
|
+
const goals = gate(ctx, res, sessionId, lang);
|
|
2042
|
+
if (!goals) return;
|
|
2043
|
+
const body = await readJsonBody(req);
|
|
2044
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
2045
|
+
const parsed = parseUpdate(body.value, lang);
|
|
2046
|
+
if (!parsed.ok) return sendError(res, 400, "bad-goal", parsed.message);
|
|
2047
|
+
sendJson(res, 200, toWriteResponse(apply(goals, sessionId, parsed.value)));
|
|
2048
|
+
}
|
|
2049
|
+
function apply(goals, sessionId, patch) {
|
|
2050
|
+
switch (patch.action) {
|
|
2051
|
+
case "edit":
|
|
2052
|
+
return goals.edit(sessionId, patch.objective);
|
|
2053
|
+
case "budget":
|
|
2054
|
+
return goals.setBudget(sessionId, patch.maxRounds);
|
|
2055
|
+
case "pause":
|
|
2056
|
+
return goals.pause(sessionId);
|
|
2057
|
+
case "resume":
|
|
2058
|
+
return goals.resume(sessionId);
|
|
2059
|
+
case "complete":
|
|
2060
|
+
return goals.complete(sessionId, patch.evidence);
|
|
2061
|
+
default: {
|
|
2062
|
+
const exhaustive = patch;
|
|
2063
|
+
return exhaustive;
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
async function deleteGoal(ctx, res, sessionId, lang) {
|
|
2068
|
+
const goals = gate(ctx, res, sessionId, lang);
|
|
2069
|
+
if (!goals) return;
|
|
2070
|
+
const result = goals.clear(sessionId);
|
|
2071
|
+
sendJson(
|
|
2072
|
+
res,
|
|
2073
|
+
200,
|
|
2074
|
+
result.ok ? { ok: true, cleared: result.cleared } : { ok: false, cleared: false, reason: result.reason }
|
|
2075
|
+
);
|
|
2076
|
+
}
|
|
2077
|
+
function listBlockedGoals(ctx, res, _lang) {
|
|
2078
|
+
const goals = goalsOf(ctx);
|
|
2079
|
+
const rows = ctx.runtime.sessions?.list(BLOCKED_SCAN_LIMIT) ?? [];
|
|
2080
|
+
if (!goals) return sendJson(res, 200, { items: [] });
|
|
2081
|
+
const items = [];
|
|
2082
|
+
for (const row of rows) {
|
|
2083
|
+
const goal = goals.current(row.id);
|
|
2084
|
+
if (!goal || goal.phase !== "blocked") continue;
|
|
2085
|
+
items.push({ sessionId: row.id, sessionTitle: row.title, goal: toWireGoal(goal) });
|
|
2086
|
+
}
|
|
2087
|
+
items.sort(
|
|
2088
|
+
(a, b) => a.goal.updatedAt === b.goal.updatedAt ? a.sessionId < b.sessionId ? -1 : 1 : a.goal.updatedAt - b.goal.updatedAt
|
|
2089
|
+
);
|
|
2090
|
+
sendJson(res, 200, { items });
|
|
2091
|
+
}
|
|
1989
2092
|
function readConfig(ctx, res, lang) {
|
|
1990
2093
|
if (ctx.lanExposed) return sendLanExposed(res, lang);
|
|
1991
2094
|
const outcome = ctx.runtime.mcp.readConfig();
|
|
@@ -2051,8 +2154,6 @@ async function applyConfig(ctx, req, res, lang) {
|
|
|
2051
2154
|
changes: outcome.changes.map((change) => ({
|
|
2052
2155
|
name: change.name,
|
|
2053
2156
|
action: change.action,
|
|
2054
|
-
// 投影借的是能力页那一栏**同一个函数**(`toWireMcp`):这几行会被界面
|
|
2055
|
-
// 直接换进那张列表,形状对不上的话它们在那一栏里长得和别的行不一样
|
|
2056
2157
|
...change.status === void 0 ? {} : { server: toWireMcp(change.status) },
|
|
2057
2158
|
...change.reason === void 0 ? {} : { reason: change.reason }
|
|
2058
2159
|
}))
|
|
@@ -2167,12 +2268,7 @@ function getModel(ctx, res, sessionId, _lang) {
|
|
|
2167
2268
|
const { runtime } = ctx;
|
|
2168
2269
|
const selection = runtime.sessionFactory.get(sessionId)?.model.get();
|
|
2169
2270
|
sendJson(res, 200, {
|
|
2170
|
-
// 问不到就是**这个进程没在跑这段会话**(只剩历史 / 被冷却了 / provider 层
|
|
2171
|
-
// 起不来)。三种情形合成一个 null 照旧是刻意的:界面在三档下该做的事
|
|
2172
|
-
// 一模一样(不画菜单)
|
|
2173
2271
|
selection: selection ? toWireSelection(selection) : null,
|
|
2174
|
-
// 空串当没配。`configured` 的语义是「`reset` 会把你送回哪儿」,
|
|
2175
|
-
// 而一个空字符串答不了这个问题 —— 那时菜单上那一项整个不画
|
|
2176
2272
|
configured: runtime.config.model || null
|
|
2177
2273
|
});
|
|
2178
2274
|
}
|
|
@@ -2190,10 +2286,6 @@ async function setModel(ctx, req, res, sessionId, lang) {
|
|
|
2190
2286
|
return sendError(res, 400, "bad-model", t("web.model_bad_request", void 0, lang));
|
|
2191
2287
|
}
|
|
2192
2288
|
const selectionCtx = {
|
|
2193
|
-
// **问的是这段会话自己的历史**(2026-08-16)。上一版读的是
|
|
2194
|
-
// `ctx.runtime.session` —— 引导会话那一段。这条路只对引导会话开放的时候
|
|
2195
|
-
// 那是对的;现在它对任意一个活着的会话开放,拿别人的历史当闸门的表现是:
|
|
2196
|
-
// A 那段里有图片,于是 B 换一个不认图的模型会被一道莫名其妙的闸拦下来
|
|
2197
2289
|
hasImages: sessionHasImages(live.session),
|
|
2198
2290
|
...input.usedTokens === void 0 ? {} : { usedTokens: input.usedTokens }
|
|
2199
2291
|
};
|
|
@@ -2202,8 +2294,6 @@ async function setModel(ctx, req, res, sessionId, lang) {
|
|
|
2202
2294
|
ok: true,
|
|
2203
2295
|
selection: toWireSelection(result.selection),
|
|
2204
2296
|
contextLength: result.contextLength,
|
|
2205
|
-
// **空数组也照发**,不判长度:那是界面的事。在这儿吞掉一个空数组
|
|
2206
|
-
// 等于让「没有代价」和「这个字段不存在」在契约上长得一样
|
|
2207
2297
|
caveats: result.caveats
|
|
2208
2298
|
} : { ok: false, rejection: result.rejection };
|
|
2209
2299
|
sendJson(res, 200, payload);
|
|
@@ -2248,7 +2338,6 @@ async function setPlanMode(ctx, req, res, sessionId, lang) {
|
|
|
2248
2338
|
changed
|
|
2249
2339
|
});
|
|
2250
2340
|
}
|
|
2251
|
-
|
|
2252
2341
|
// src/permission.ts
|
|
2253
2342
|
var REFUSAL = {
|
|
2254
2343
|
"managed-bypass-disabled": "managed"
|
|
@@ -2259,12 +2348,7 @@ function blockedLevels(managed) {
|
|
|
2259
2348
|
function permissionState(ctx, sessionId) {
|
|
2260
2349
|
const perms = ctx.runtime.sessionFactory.get(sessionId)?.permissions ?? null;
|
|
2261
2350
|
return {
|
|
2262
|
-
// 权限层没起来 / 这个进程没在跑这段会话时退回 `config.permission`,
|
|
2263
|
-
// 那仍然是一句真话:那时配置里那一档就是这台机器上的档位
|
|
2264
2351
|
level: perms?.level() ?? ctx.runtime.config.permission,
|
|
2265
|
-
// 改得动 = 这个会话在这个进程里活着、**而且**它有权限层。两种成因合成一个
|
|
2266
|
-
// 布尔,判据在 `WirePermissionState.editable` 上:界面在两档下要做的事
|
|
2267
|
-
// 一模一样(画回只读事实)
|
|
2268
2352
|
editable: perms !== null,
|
|
2269
2353
|
blocked: perms ? blockedLevels(perms.managed()) : [],
|
|
2270
2354
|
planMode: planModeState(ctx, sessionId)
|
|
@@ -2303,7 +2387,6 @@ async function setPermission(ctx, req, res, sessionId, lang) {
|
|
|
2303
2387
|
const payload = result.ok ? { ok: true, changed: state.level !== before, state } : { ok: false, refused: REFUSAL[result.reason], changed: false, state };
|
|
2304
2388
|
sendJson(res, 200, payload);
|
|
2305
2389
|
}
|
|
2306
|
-
|
|
2307
2390
|
// src/plan.ts
|
|
2308
2391
|
function getPlan(ctx, res, sessionId, _lang) {
|
|
2309
2392
|
if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
|
|
@@ -2319,10 +2402,190 @@ function getPlan(ctx, res, sessionId, _lang) {
|
|
|
2319
2402
|
function normalize(markdown) {
|
|
2320
2403
|
return markdown?.trim() ? markdown : null;
|
|
2321
2404
|
}
|
|
2322
|
-
function
|
|
2405
|
+
function toWireCounts(counts) {
|
|
2406
|
+
return {
|
|
2407
|
+
commands: counts.commands,
|
|
2408
|
+
roles: counts.roles,
|
|
2409
|
+
skills: counts.skills,
|
|
2410
|
+
hooks: counts.hooks,
|
|
2411
|
+
denyRules: counts.denyRules,
|
|
2412
|
+
mcpServers: counts.mcpServers
|
|
2413
|
+
};
|
|
2414
|
+
}
|
|
2415
|
+
function toWirePluginEntry(entry) {
|
|
2416
|
+
return {
|
|
2417
|
+
name: entry.name,
|
|
2418
|
+
version: entry.version,
|
|
2419
|
+
source: entry.source,
|
|
2420
|
+
sourceType: entry.sourceType,
|
|
2421
|
+
path: entry.path,
|
|
2422
|
+
linked: entry.linked,
|
|
2423
|
+
enabled: entry.enabled,
|
|
2424
|
+
installedAt: entry.installedAt,
|
|
2425
|
+
marketplace: entry.marketplace ?? null,
|
|
2426
|
+
active: entry.active,
|
|
2427
|
+
counts: entry.counts ? toWireCounts(entry.counts) : null
|
|
2428
|
+
};
|
|
2429
|
+
}
|
|
2430
|
+
function toWirePluginHit(hit, installedNames) {
|
|
2431
|
+
return {
|
|
2432
|
+
marketplace: hit.marketplace,
|
|
2433
|
+
ref: hit.ref,
|
|
2434
|
+
name: hit.entry.name,
|
|
2435
|
+
description: hit.entry.description ?? "",
|
|
2436
|
+
category: hit.entry.category ?? null,
|
|
2437
|
+
keywords: [...hit.entry.keywords ?? []],
|
|
2438
|
+
sourceType: hit.sourceType ?? null,
|
|
2439
|
+
installable: hit.installable,
|
|
2440
|
+
installed: installedNames.has(hit.entry.name)
|
|
2441
|
+
};
|
|
2442
|
+
}
|
|
2443
|
+
function toWirePreview2(preview) {
|
|
2444
|
+
const { manifest, source, inventory } = preview;
|
|
2445
|
+
return {
|
|
2446
|
+
name: manifest.name,
|
|
2447
|
+
version: manifest.version,
|
|
2448
|
+
description: manifest.description ?? "",
|
|
2449
|
+
source: source.raw,
|
|
2450
|
+
sourceType: source.type,
|
|
2451
|
+
checksum: source.sha256 ?? null,
|
|
2452
|
+
author: manifest.author?.name ?? null,
|
|
2453
|
+
homepage: manifest.homepage ?? null,
|
|
2454
|
+
commands: [...inventory.commands],
|
|
2455
|
+
roles: [...inventory.roles],
|
|
2456
|
+
skills: [...inventory.skills],
|
|
2457
|
+
hooks: inventory.hooks.map((h) => ({ type: h.type, count: h.count })),
|
|
2458
|
+
denyRules: inventory.denyRules,
|
|
2459
|
+
mcpServers: [...inventory.mcpServers],
|
|
2460
|
+
ignoredBuckets: [...inventory.ignoredBuckets],
|
|
2461
|
+
hasJsTools: inventory.jsTools,
|
|
2462
|
+
conflict: preview.conflict ? { name: preview.conflict.name, version: preview.conflict.version } : null
|
|
2463
|
+
};
|
|
2464
|
+
}
|
|
2465
|
+
function controlOf(ctx, res, lang) {
|
|
2466
|
+
const control = ctx.runtime.plugins;
|
|
2467
|
+
if (!control) {
|
|
2468
|
+
sendError(res, 503, "no-plugin-control", t("web.plugin_not_available", void 0, lang));
|
|
2469
|
+
return null;
|
|
2470
|
+
}
|
|
2471
|
+
return control;
|
|
2472
|
+
}
|
|
2473
|
+
function refusedByLan(ctx, res, lang) {
|
|
2474
|
+
if (!ctx.lanExposed) return false;
|
|
2475
|
+
sendError(
|
|
2476
|
+
res,
|
|
2477
|
+
403,
|
|
2478
|
+
"plugin-write-lan-exposed",
|
|
2479
|
+
t("web.plugin_write_lan_exposed", void 0, lang)
|
|
2480
|
+
);
|
|
2481
|
+
return true;
|
|
2482
|
+
}
|
|
2483
|
+
function fieldOf(payload, key) {
|
|
2484
|
+
const value = payload[key];
|
|
2485
|
+
return typeof value === "string" ? value.trim() : "";
|
|
2486
|
+
}
|
|
2487
|
+
function listPlugins(ctx, res, lang) {
|
|
2488
|
+
const control = controlOf(ctx, res, lang);
|
|
2489
|
+
if (!control) return;
|
|
2490
|
+
const installed = control.list();
|
|
2491
|
+
const installedNames = new Set(installed.map((p) => p.name));
|
|
2492
|
+
sendJson(res, 200, {
|
|
2493
|
+
installed: installed.map(toWirePluginEntry),
|
|
2494
|
+
hits: control.search().map((hit) => toWirePluginHit(hit, installedNames)),
|
|
2495
|
+
pendingRestart: control.pendingRestart
|
|
2496
|
+
});
|
|
2497
|
+
}
|
|
2498
|
+
function sendPreview(res, outcome) {
|
|
2499
|
+
if (!outcome.ok) {
|
|
2500
|
+
return sendJson(res, 200, {
|
|
2501
|
+
ok: false,
|
|
2502
|
+
preview: null,
|
|
2503
|
+
token: null,
|
|
2504
|
+
reason: outcome.reason,
|
|
2505
|
+
detail: outcome.detail
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
2508
|
+
sendJson(res, 200, {
|
|
2509
|
+
ok: true,
|
|
2510
|
+
preview: toWirePreview2(outcome.preview),
|
|
2511
|
+
token: outcome.token,
|
|
2512
|
+
reason: null,
|
|
2513
|
+
detail: null
|
|
2514
|
+
});
|
|
2515
|
+
}
|
|
2516
|
+
async function previewPluginInstall(ctx, req, res, lang) {
|
|
2517
|
+
if (refusedByLan(ctx, res, lang)) return;
|
|
2518
|
+
const control = controlOf(ctx, res, lang);
|
|
2519
|
+
if (!control) return;
|
|
2520
|
+
const body = await readJsonBody(req);
|
|
2521
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
2522
|
+
sendPreview(res, await control.preview(fieldOf(asRecord(body.value), "ref"), lang));
|
|
2523
|
+
}
|
|
2524
|
+
async function previewPluginUpdate(ctx, req, res, lang) {
|
|
2525
|
+
if (refusedByLan(ctx, res, lang)) return;
|
|
2526
|
+
const control = controlOf(ctx, res, lang);
|
|
2527
|
+
if (!control) return;
|
|
2528
|
+
const body = await readJsonBody(req);
|
|
2529
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
2530
|
+
sendPreview(res, await control.previewUpdate(fieldOf(asRecord(body.value), "name"), lang));
|
|
2531
|
+
}
|
|
2532
|
+
function sendAction(res, control, outcome) {
|
|
2533
|
+
if (!outcome.ok) {
|
|
2534
|
+
return sendJson(res, 200, {
|
|
2535
|
+
ok: false,
|
|
2536
|
+
entry: null,
|
|
2537
|
+
pendingRestart: control.pendingRestart,
|
|
2538
|
+
detail: outcome.detail,
|
|
2539
|
+
reason: outcome.reason
|
|
2540
|
+
});
|
|
2541
|
+
}
|
|
2542
|
+
const fresh = "record" in outcome ? control.list().find((p) => p.name === outcome.record.name) : void 0;
|
|
2543
|
+
sendJson(res, 200, {
|
|
2544
|
+
ok: true,
|
|
2545
|
+
entry: fresh ? toWirePluginEntry(fresh) : null,
|
|
2546
|
+
pendingRestart: control.pendingRestart,
|
|
2547
|
+
detail: "detail" in outcome ? outcome.detail : null,
|
|
2548
|
+
reason: null
|
|
2549
|
+
});
|
|
2550
|
+
}
|
|
2551
|
+
async function installPluginFromMarket(ctx, req, res, lang) {
|
|
2552
|
+
if (refusedByLan(ctx, res, lang)) return;
|
|
2553
|
+
const control = controlOf(ctx, res, lang);
|
|
2554
|
+
if (!control) return;
|
|
2555
|
+
const body = await readJsonBody(req);
|
|
2556
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
2557
|
+
const payload = asRecord(body.value);
|
|
2558
|
+
sendAction(
|
|
2559
|
+
res,
|
|
2560
|
+
control,
|
|
2561
|
+
await control.install(fieldOf(payload, "ref"), fieldOf(payload, "token"), lang)
|
|
2562
|
+
);
|
|
2563
|
+
}
|
|
2564
|
+
async function updateInstalledPlugin(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.update(fieldOf(payload, "name"), fieldOf(payload, "token"), lang)
|
|
2575
|
+
);
|
|
2576
|
+
}
|
|
2577
|
+
async function uninstallPluginByName(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
|
+
sendAction(res, control, await control.uninstall(fieldOf(asRecord(body.value), "name"), lang));
|
|
2584
|
+
}
|
|
2585
|
+
function toWireProvider(option, lang) {
|
|
2323
2586
|
return {
|
|
2324
2587
|
type: option.type,
|
|
2325
|
-
label: option
|
|
2588
|
+
label: providerLabel(option, lang),
|
|
2326
2589
|
envVar: option.envVar,
|
|
2327
2590
|
hasKey: option.hasKey
|
|
2328
2591
|
};
|
|
@@ -2335,9 +2598,9 @@ function toWireSuggestions(view) {
|
|
|
2335
2598
|
status: view.status
|
|
2336
2599
|
};
|
|
2337
2600
|
}
|
|
2338
|
-
function listProviders(ctx, res,
|
|
2601
|
+
function listProviders(ctx, res, lang) {
|
|
2339
2602
|
sendJson(res, 200, {
|
|
2340
|
-
providers: ctx.runtime.modelCatalog.providers().map(toWireProvider)
|
|
2603
|
+
providers: ctx.runtime.modelCatalog.providers().map((o) => toWireProvider(o, lang))
|
|
2341
2604
|
});
|
|
2342
2605
|
}
|
|
2343
2606
|
async function discoverProviderModels(ctx, req, res, type, lang) {
|
|
@@ -2369,19 +2632,14 @@ async function addRole(ctx, req, res, lang) {
|
|
|
2369
2632
|
name: typeof payload["name"] === "string" ? payload["name"].trim() : "",
|
|
2370
2633
|
description: typeof payload["description"] === "string" ? payload["description"].trim() : "",
|
|
2371
2634
|
...typeof payload["prompt"] === "string" ? { prompt: payload["prompt"] } : {},
|
|
2372
|
-
// ⚠️ `tools` 用 `Array.isArray` 而不是「有没有给」:一个空数组是**一档真实的
|
|
2373
|
-
// 意思**(一个工具都不给),而 `undefined` 是「不限」。两者合成同一个值之后,
|
|
2374
|
-
// 一个用户明确清空了工具表的身份会拿到全部工具
|
|
2375
2635
|
...Array.isArray(payload["tools"]) ? { tools: stringList2(payload["tools"]) } : {},
|
|
2636
|
+
...Array.isArray(payload["skills"]) ? { skills: stringList2(payload["skills"]) } : {},
|
|
2376
2637
|
...typeof payload["maxTurns"] === "number" ? { maxTurns: payload["maxTurns"] } : {}
|
|
2377
2638
|
},
|
|
2378
2639
|
lang
|
|
2379
2640
|
);
|
|
2380
2641
|
if (!outcome.ok) return sendError(res, ...addFailure2(outcome.reason, outcome.detail, lang));
|
|
2381
2642
|
sendJson(res, 200, {
|
|
2382
|
-
// 投影走 `toWireRole`,**和能力页那一栏同一个函数** —— 各写一份的话,
|
|
2383
|
-
// 刚插进列表的那一行和刷新之后那一行会在 `tools: null` / `toolsCut` 这种
|
|
2384
|
-
// 事上长得不一样,而那是这一屏最容易被当成 bug 的差别
|
|
2385
2643
|
role: toWireRole(outcome.role, ctx.runtime.agentRoleNotices),
|
|
2386
2644
|
path: outcome.path
|
|
2387
2645
|
});
|
|
@@ -2529,7 +2787,7 @@ function optionals(raw, lang) {
|
|
|
2529
2787
|
function bypassAcknowledged(body) {
|
|
2530
2788
|
return asRecord(body)["bypassAcknowledged"] === true;
|
|
2531
2789
|
}
|
|
2532
|
-
function
|
|
2790
|
+
function parseCreate2(body, lang) {
|
|
2533
2791
|
const raw = asRecord(body);
|
|
2534
2792
|
const name = raw["name"];
|
|
2535
2793
|
const prompt = raw["prompt"];
|
|
@@ -2550,12 +2808,10 @@ function parseCreate(body, lang) {
|
|
|
2550
2808
|
if (!rest.ok) return rest;
|
|
2551
2809
|
return {
|
|
2552
2810
|
ok: true,
|
|
2553
|
-
// 顺序有意义:`rest` 里那几格已经收窄过,五个必填的**后写**,
|
|
2554
|
-
// 于是一个把 `name` 同时写在两处的请求体不会得到两个答案
|
|
2555
2811
|
value: { ...rest.value, name, prompt, permission, trigger, maxBudgetUsd: budget }
|
|
2556
2812
|
};
|
|
2557
2813
|
}
|
|
2558
|
-
function
|
|
2814
|
+
function parseUpdate2(body, lang) {
|
|
2559
2815
|
return optionals(asRecord(body), lang);
|
|
2560
2816
|
}
|
|
2561
2817
|
function parseFix(body, lang) {
|
|
@@ -2565,13 +2821,17 @@ function parseFix(body, lang) {
|
|
|
2565
2821
|
}
|
|
2566
2822
|
return { ok: true, value: runId };
|
|
2567
2823
|
}
|
|
2568
|
-
|
|
2569
2824
|
// src/schedule/handlers.ts
|
|
2570
2825
|
var RECENT_RUNS_LIMIT = 100;
|
|
2571
2826
|
var RUNS_LIMIT = 20;
|
|
2572
2827
|
var MAX_RECORDING_FRAMES = 4e3;
|
|
2573
|
-
function schedulesOf(ctx) {
|
|
2574
|
-
|
|
2828
|
+
function schedulesOf(ctx, res, lang) {
|
|
2829
|
+
const control = ctx.runtime.schedules;
|
|
2830
|
+
if (!control.storeAvailable) {
|
|
2831
|
+
sendError(res, 503, "no-schedule-store", t("web.schedule_store_unavailable", void 0, lang));
|
|
2832
|
+
return null;
|
|
2833
|
+
}
|
|
2834
|
+
return control;
|
|
2575
2835
|
}
|
|
2576
2836
|
function toWireRow(control, def) {
|
|
2577
2837
|
return {
|
|
@@ -2589,56 +2849,71 @@ function defaultsOf() {
|
|
|
2589
2849
|
allowRules: SCHEDULE_DEFAULT_ALLOWLIST.allowRules
|
|
2590
2850
|
};
|
|
2591
2851
|
}
|
|
2592
|
-
async function listSchedules(ctx, res,
|
|
2593
|
-
const control = schedulesOf(ctx);
|
|
2852
|
+
async function listSchedules(ctx, res, lang) {
|
|
2853
|
+
const control = schedulesOf(ctx, res, lang);
|
|
2854
|
+
if (!control) return;
|
|
2855
|
+
const capability = await control.capability();
|
|
2594
2856
|
const payload = {
|
|
2595
2857
|
schedules: control.list().map((def) => toWireRow(control, def)),
|
|
2596
|
-
capability:
|
|
2858
|
+
capability: { backend: capability.backend, canRegister: capability.canRegister },
|
|
2597
2859
|
defaults: defaultsOf(),
|
|
2598
2860
|
recentRuns: control.recentRuns(RECENT_RUNS_LIMIT)
|
|
2599
2861
|
};
|
|
2600
2862
|
sendJson(res, 200, payload);
|
|
2601
2863
|
}
|
|
2864
|
+
function listPending(ctx, res, lang) {
|
|
2865
|
+
const control = schedulesOf(ctx, res, lang);
|
|
2866
|
+
if (!control) return;
|
|
2867
|
+
const items = collectPendingApprovals(control.list(), control.recentRuns(RECENT_RUNS_LIMIT));
|
|
2868
|
+
sendJson(res, 200, { items });
|
|
2869
|
+
}
|
|
2602
2870
|
function getSchedule(ctx, res, id, lang) {
|
|
2603
|
-
const control = schedulesOf(ctx);
|
|
2871
|
+
const control = schedulesOf(ctx, res, lang);
|
|
2872
|
+
if (!control) return;
|
|
2604
2873
|
const def = control.get(id);
|
|
2605
2874
|
if (!def) return sendNotFound(res, id, lang);
|
|
2606
2875
|
sendJson(res, 200, { row: toWireRow(control, def) });
|
|
2607
2876
|
}
|
|
2608
2877
|
function listRuns(ctx, res, id, lang) {
|
|
2609
|
-
const control = schedulesOf(ctx);
|
|
2878
|
+
const control = schedulesOf(ctx, res, lang);
|
|
2879
|
+
if (!control) return;
|
|
2610
2880
|
if (!control.get(id)) return sendNotFound(res, id, lang);
|
|
2611
2881
|
sendJson(res, 200, { runs: control.runs(id, RUNS_LIMIT) });
|
|
2612
2882
|
}
|
|
2613
2883
|
async function createSchedule(ctx, req, res, lang) {
|
|
2614
2884
|
const body = await readJsonBody(req);
|
|
2615
2885
|
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
2616
|
-
const parsed =
|
|
2886
|
+
const parsed = parseCreate2(body.value, lang);
|
|
2617
2887
|
if (!parsed.ok) return sendError(res, 400, "bad-schedule", parsed.message);
|
|
2618
|
-
const control = schedulesOf(ctx);
|
|
2888
|
+
const control = schedulesOf(ctx, res, lang);
|
|
2889
|
+
if (!control) return;
|
|
2619
2890
|
const saved = await control.create(parsed.value, {
|
|
2620
2891
|
bypassAcknowledged: bypassAcknowledged(body.value)
|
|
2621
2892
|
});
|
|
2622
2893
|
sendJson(res, 200, toSaveResponse(control, saved));
|
|
2623
2894
|
}
|
|
2624
2895
|
async function patchSchedule(ctx, req, res, id, lang) {
|
|
2625
|
-
const control = schedulesOf(ctx);
|
|
2896
|
+
const control = schedulesOf(ctx, res, lang);
|
|
2897
|
+
if (!control) return;
|
|
2626
2898
|
if (!control.get(id)) return sendNotFound(res, id, lang);
|
|
2627
2899
|
const body = await readJsonBody(req);
|
|
2628
2900
|
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
2629
|
-
const parsed =
|
|
2901
|
+
const parsed = parseUpdate2(body.value, lang);
|
|
2630
2902
|
if (!parsed.ok) return sendError(res, 400, "bad-schedule", parsed.message);
|
|
2631
2903
|
const saved = await control.update(id, parsed.value, {
|
|
2632
2904
|
bypassAcknowledged: bypassAcknowledged(body.value)
|
|
2633
2905
|
});
|
|
2634
2906
|
sendJson(res, 200, toSaveResponse(control, saved));
|
|
2635
2907
|
}
|
|
2636
|
-
async function deleteSchedule(ctx, res, id,
|
|
2637
|
-
const
|
|
2908
|
+
async function deleteSchedule(ctx, res, id, lang) {
|
|
2909
|
+
const control = schedulesOf(ctx, res, lang);
|
|
2910
|
+
if (!control) return;
|
|
2911
|
+
const removed = await control.remove(id);
|
|
2638
2912
|
sendJson(res, 200, { removed });
|
|
2639
2913
|
}
|
|
2640
2914
|
async function runSchedule(ctx, res, id, lang) {
|
|
2641
|
-
const control = schedulesOf(ctx);
|
|
2915
|
+
const control = schedulesOf(ctx, res, lang);
|
|
2916
|
+
if (!control) return;
|
|
2642
2917
|
if (!control.get(id)) return sendNotFound(res, id, lang);
|
|
2643
2918
|
const result = await control.fire(id, { manual: true });
|
|
2644
2919
|
const payload = {
|
|
@@ -2650,7 +2925,8 @@ async function runSchedule(ctx, res, id, lang) {
|
|
|
2650
2925
|
sendJson(res, 200, payload);
|
|
2651
2926
|
}
|
|
2652
2927
|
async function fixSchedule(ctx, req, res, id, lang) {
|
|
2653
|
-
const control = schedulesOf(ctx);
|
|
2928
|
+
const control = schedulesOf(ctx, res, lang);
|
|
2929
|
+
if (!control) return;
|
|
2654
2930
|
const def = control.get(id);
|
|
2655
2931
|
if (!def) return sendNotFound(res, id, lang);
|
|
2656
2932
|
const body = await readJsonBody(req);
|
|
@@ -2666,20 +2942,14 @@ async function fixSchedule(ctx, req, res, id, lang) {
|
|
|
2666
2942
|
t("web.schedule_run_unknown", { runId: parsed.value }, lang)
|
|
2667
2943
|
);
|
|
2668
2944
|
}
|
|
2669
|
-
const
|
|
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
|
-
}
|
|
2945
|
+
const added = unfixedRules(def.allowRules, run.pendingApprovals);
|
|
2676
2946
|
if (added.length === 0) {
|
|
2677
2947
|
return sendJson(res, 200, {
|
|
2678
2948
|
added: [],
|
|
2679
2949
|
row: toWireRow(control, def)
|
|
2680
2950
|
});
|
|
2681
2951
|
}
|
|
2682
|
-
const saved = await control.update(id, { allowRules: [...
|
|
2952
|
+
const saved = await control.update(id, { allowRules: [...def.allowRules, ...added] });
|
|
2683
2953
|
const next = saved.schedule ?? def;
|
|
2684
2954
|
sendJson(res, 200, {
|
|
2685
2955
|
added,
|
|
@@ -2687,7 +2957,8 @@ async function fixSchedule(ctx, req, res, id, lang) {
|
|
|
2687
2957
|
});
|
|
2688
2958
|
}
|
|
2689
2959
|
function getRecording(ctx, res, id, runId, lang) {
|
|
2690
|
-
const control = schedulesOf(ctx);
|
|
2960
|
+
const control = schedulesOf(ctx, res, lang);
|
|
2961
|
+
if (!control) return;
|
|
2691
2962
|
if (!control.get(id)) return sendNotFound(res, id, lang);
|
|
2692
2963
|
const run = control.runs(id, RUNS_LIMIT).find((one) => one.id === runId);
|
|
2693
2964
|
if (!run) {
|
|
@@ -2722,7 +2993,6 @@ function toSaveResponse(control, saved) {
|
|
|
2722
2993
|
function sendNotFound(res, id, lang) {
|
|
2723
2994
|
sendError(res, 404, "unknown-schedule", t("web.schedule_unknown", { id }, lang));
|
|
2724
2995
|
}
|
|
2725
|
-
|
|
2726
2996
|
// src/skill-import.ts
|
|
2727
2997
|
function pathOf(payload) {
|
|
2728
2998
|
return typeof payload["path"] === "string" ? payload["path"].trim() : "";
|
|
@@ -2786,10 +3056,6 @@ async function importSkills(ctx, req, res, lang) {
|
|
|
2786
3056
|
});
|
|
2787
3057
|
}
|
|
2788
3058
|
sendJson(res, 200, {
|
|
2789
|
-
// 一个都没落地时**回 `ok: false`**:这一格答的是「这次导入达成了没有」,
|
|
2790
|
-
// 而不是「这次请求跑通了没有」。全都撞名的那一次要是回 true,界面上会出现
|
|
2791
|
-
// 一句「导入成功」加一张空列表 —— 判据同 `WireMcpReconnectResponse.ok`
|
|
2792
|
-
// 那条「别从别的字段反推」
|
|
2793
3059
|
ok: outcome.imported.length > 0,
|
|
2794
3060
|
imported: [...outcome.imported],
|
|
2795
3061
|
skipped: toWireIssues2(outcome.skipped),
|
|
@@ -2797,7 +3063,6 @@ async function importSkills(ctx, req, res, lang) {
|
|
|
2797
3063
|
reason: null
|
|
2798
3064
|
});
|
|
2799
3065
|
}
|
|
2800
|
-
|
|
2801
3066
|
// src/sse.ts
|
|
2802
3067
|
var DEFAULT_HEARTBEAT_MS = 15e3;
|
|
2803
3068
|
function parseLastEventId(req) {
|
|
@@ -2825,13 +3090,6 @@ var SseConnection = class {
|
|
|
2825
3090
|
res;
|
|
2826
3091
|
heartbeat;
|
|
2827
3092
|
closed = false;
|
|
2828
|
-
/**
|
|
2829
|
-
* 发一帧。
|
|
2830
|
-
*
|
|
2831
|
-
* **`seq: 0` 不写 `id:` 行**:连接级帧(`connected` / `stream-reset`)不占广播
|
|
2832
|
-
* 序号,写进 `id:` 就会把这个连接的续传游标推到 0,下次重连时 `Last-Event-ID: 0`
|
|
2833
|
-
* 等于说「我什么都没收到」。
|
|
2834
|
-
*/
|
|
2835
3093
|
send(frame) {
|
|
2836
3094
|
if (this.closed) return;
|
|
2837
3095
|
const body = frame.seq > 0 ? `id: ${frame.seq}
|
|
@@ -2840,12 +3098,10 @@ var SseConnection = class {
|
|
|
2840
3098
|
|
|
2841
3099
|
`);
|
|
2842
3100
|
}
|
|
2843
|
-
/** 注释行心跳:SSE 规范里以 `:` 开头的行被客户端忽略,纯粹用来探活 */
|
|
2844
3101
|
ping() {
|
|
2845
3102
|
if (this.closed) return;
|
|
2846
3103
|
this.res.write(": ping\n\n");
|
|
2847
3104
|
}
|
|
2848
|
-
/** 幂等:客户端断开和服务端 close 会各调一次 */
|
|
2849
3105
|
close() {
|
|
2850
3106
|
if (this.closed) return;
|
|
2851
3107
|
this.closed = true;
|
|
@@ -2898,14 +3154,10 @@ function workspaceAnchors(workspaces) {
|
|
|
2898
3154
|
push(process.cwd(), "cwd");
|
|
2899
3155
|
for (const one of workspaces.known()) push(one.root, "known");
|
|
2900
3156
|
return {
|
|
2901
|
-
// 锚屏不是一个目录,所以这两格是 null(判据在契约那两个字段上)
|
|
2902
3157
|
path: null,
|
|
2903
3158
|
parent: null,
|
|
2904
3159
|
entries,
|
|
2905
|
-
// 锚这一发**不截断**:`known` 上游就是 ≤20 条,加上 home / cwd 也就 22 行
|
|
2906
3160
|
omitted: 0,
|
|
2907
|
-
// 锚这一发也**不过滤**:三组锚是「用户自己去过的地方」,`~/.config/some-tree`
|
|
2908
|
-
// 真在已知清单里的话它照样画出来 —— 那个开关管的是「往下点一层」看得见什么
|
|
2909
3161
|
hidden: 0
|
|
2910
3162
|
};
|
|
2911
3163
|
}
|
|
@@ -2939,7 +3191,6 @@ function listWorkspaceDirs(workspaces, raw, opts = {}) {
|
|
|
2939
3191
|
name: dirent.name,
|
|
2940
3192
|
path: child,
|
|
2941
3193
|
known: known.has(child),
|
|
2942
|
-
// 列不开的行**照样画出来**,只是点不动 —— 藏掉的话用户会以为它不存在
|
|
2943
3194
|
readable: canList(child),
|
|
2944
3195
|
group: "child"
|
|
2945
3196
|
});
|
|
@@ -2948,11 +3199,9 @@ function listWorkspaceDirs(workspaces, raw, opts = {}) {
|
|
|
2948
3199
|
ok: true,
|
|
2949
3200
|
view: {
|
|
2950
3201
|
path,
|
|
2951
|
-
// 已经在根上时 `dirname` 回它自己 —— 那时没有「上一层」可去
|
|
2952
3202
|
parent: parentOf(path),
|
|
2953
3203
|
entries: kept,
|
|
2954
3204
|
omitted,
|
|
2955
|
-
// 带 `?hidden=1` 那一发这一格必然是 0:那时上面那一支根本不进
|
|
2956
3205
|
hidden
|
|
2957
3206
|
}
|
|
2958
3207
|
};
|
|
@@ -2978,7 +3227,6 @@ function parentOf(path) {
|
|
|
2978
3227
|
function displayName2(path) {
|
|
2979
3228
|
return basename(path) || path;
|
|
2980
3229
|
}
|
|
2981
|
-
|
|
2982
3230
|
// src/workspace/dirs.ts
|
|
2983
3231
|
function browseWorkspaceDirs(ctx, res, rawUrl, lang) {
|
|
2984
3232
|
const query = new URL(rawUrl ?? "/", "http://127.0.0.1").searchParams;
|
|
@@ -3041,11 +3289,8 @@ async function createWorkspaceDir(req, res, lang) {
|
|
|
3041
3289
|
}
|
|
3042
3290
|
sendJson(res, 200, {
|
|
3043
3291
|
entry: {
|
|
3044
|
-
// `basename` 就是 `name`(上面校验过它是单段),这儿不再算一次
|
|
3045
3292
|
name,
|
|
3046
3293
|
path,
|
|
3047
|
-
// 两个字段**钉死**,判据在 `WireCreateWorkspaceResponse` 上:刚建的空目录
|
|
3048
|
-
// 必然读得开,而且它**不在**「已知工作区」清单里(这条路不记那一笔)
|
|
3049
3294
|
readable: true,
|
|
3050
3295
|
known: false,
|
|
3051
3296
|
group: "child"
|
|
@@ -3155,7 +3400,6 @@ async function catFileBlob(root, sha) {
|
|
|
3155
3400
|
function splitZ(text) {
|
|
3156
3401
|
return text.split("\0").filter((s) => s.length > 0);
|
|
3157
3402
|
}
|
|
3158
|
-
|
|
3159
3403
|
// src/workspace/diff.ts
|
|
3160
3404
|
var MAX_DIFF_BYTES = 512 * 1024;
|
|
3161
3405
|
var MAX_DIFF_FILES = 200;
|
|
@@ -3295,7 +3539,6 @@ function omitReason(old, fresh) {
|
|
|
3295
3539
|
function isBinary(side) {
|
|
3296
3540
|
return side.kind === "content" && side.data.subarray(0, BINARY_SNIFF_BYTES).includes(0);
|
|
3297
3541
|
}
|
|
3298
|
-
|
|
3299
3542
|
// src/workspace/handlers.ts
|
|
3300
3543
|
async function createSession(ctx, req, res, lang) {
|
|
3301
3544
|
const body = await readJsonBody(req);
|
|
@@ -3327,12 +3570,7 @@ async function createSession(ctx, req, res, lang) {
|
|
|
3327
3570
|
const role = typeof payload["role"] === "string" ? payload["role"].trim() : "";
|
|
3328
3571
|
const outcome = ctx.runtime.sessionFactory.create({
|
|
3329
3572
|
workspace: field.dir,
|
|
3330
|
-
// 建会话那一路也会渲染文案(路径不对 / 目录不存在 那几句在 runtime 的
|
|
3331
|
-
// `SessionWorkspaces.bind()` 里),所以 `lang` 一路递进工厂 —— 判据见
|
|
3332
|
-
// `routes.ts` 文件头「三、`lang` 怎么穿」
|
|
3333
3573
|
...lang === void 0 ? {} : { lang },
|
|
3334
|
-
// `create: true` = 别复用。只认 `=== true`:一个拼错的值该走保守那一侧
|
|
3335
|
-
// (复用),而不是凭空多建一个会话出来
|
|
3336
3574
|
reuse: payload["create"] !== true,
|
|
3337
3575
|
...role === "" ? {} : { role }
|
|
3338
3576
|
});
|
|
@@ -3363,9 +3601,6 @@ function sessionWorkspace(ctx, res, sessionId, lang) {
|
|
|
3363
3601
|
const state = ctx.runtime.workspaces.stateOf(sessionId);
|
|
3364
3602
|
if (state !== "none") {
|
|
3365
3603
|
return sendJson(res, 200, {
|
|
3366
|
-
// **复用 `toWireBinding` / `toWireSessionWorkspace`**,不在这儿另拼一份:
|
|
3367
|
-
// 那三处不对称(`extraRoots` / `trusted` / `skippedInstructionFiles`)少一个,
|
|
3368
|
-
// 界面上就少一句决定 18 要求必须说出来的话,而编译器拦不住少发一个可选字段的投影
|
|
3369
3604
|
binding: toWireBinding(state, ctx.runtime.workspaces.of(sessionId))
|
|
3370
3605
|
});
|
|
3371
3606
|
}
|
|
@@ -3393,6 +3628,12 @@ async function bindWorkspace(ctx, req, res, sessionId, lang) {
|
|
|
3393
3628
|
t("web.workspace_already_decided", void 0, lang)
|
|
3394
3629
|
);
|
|
3395
3630
|
}
|
|
3631
|
+
const turn = ctx.hub.stateOf(sessionId);
|
|
3632
|
+
if (turn !== "idle") {
|
|
3633
|
+
return sendError(res, 409, "busy", t("web.workspace_turn_running", void 0, lang), {
|
|
3634
|
+
"X-Epoch-Turn-State": turn
|
|
3635
|
+
});
|
|
3636
|
+
}
|
|
3396
3637
|
if (parsed.kind === "none") {
|
|
3397
3638
|
ctx.runtime.workspaces.decideNone(sessionId);
|
|
3398
3639
|
return sendJson(res, 200, {
|
|
@@ -3427,50 +3668,49 @@ async function sessionDiff(ctx, res, sessionId, _lang) {
|
|
|
3427
3668
|
200,
|
|
3428
3669
|
await collectWorkspaceDiff({
|
|
3429
3670
|
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
3671
|
checkpoints: checkpointsOf(ctx, sessionId)
|
|
3438
3672
|
})
|
|
3439
3673
|
);
|
|
3440
3674
|
}
|
|
3441
|
-
|
|
3442
3675
|
// src/routes.ts
|
|
3443
|
-
var
|
|
3444
|
-
"missing-credential": "
|
|
3445
|
-
"bad-token": "
|
|
3446
|
-
"bad-origin": "
|
|
3447
|
-
"bad-host": "
|
|
3676
|
+
var DENY_KEY = {
|
|
3677
|
+
"missing-credential": "web.route.deny_missing_credential",
|
|
3678
|
+
"bad-token": "web.route.deny_bad_token",
|
|
3679
|
+
"bad-origin": "web.route.deny_bad_origin",
|
|
3680
|
+
"bad-host": "web.route.deny_bad_host"
|
|
3448
3681
|
};
|
|
3449
3682
|
function createRequestHandler(opts) {
|
|
3450
3683
|
return (req, res) => {
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3684
|
+
const lang = langOf(req.url);
|
|
3685
|
+
dispatch(opts, req, res, lang).catch(() => {
|
|
3686
|
+
if (!res.headersSent) {
|
|
3687
|
+
sendError(res, 500, "internal", t("web.route.internal_error", void 0, lang));
|
|
3688
|
+
} else res.destroy();
|
|
3454
3689
|
});
|
|
3455
3690
|
};
|
|
3456
3691
|
}
|
|
3457
|
-
async function dispatch(opts, req, res) {
|
|
3692
|
+
async function dispatch(opts, req, res, lang) {
|
|
3458
3693
|
const { ctx, guard } = opts;
|
|
3459
3694
|
const pathname = pathnameOf(req.url);
|
|
3460
3695
|
const method = (req.method ?? "GET").toUpperCase();
|
|
3461
3696
|
if (pathname === "/api/health" && isRead(method)) return health(ctx, res);
|
|
3462
3697
|
const verdict = guard(toAuthView(req));
|
|
3463
3698
|
if (!verdict.ok) {
|
|
3464
|
-
return sendError(
|
|
3699
|
+
return sendError(
|
|
3700
|
+
res,
|
|
3701
|
+
verdict.status,
|
|
3702
|
+
verdict.reason,
|
|
3703
|
+
t(DENY_KEY[verdict.reason], void 0, lang)
|
|
3704
|
+
);
|
|
3465
3705
|
}
|
|
3466
3706
|
if (verdict.setCookie) res.setHeader("Set-Cookie", verdict.setCookie);
|
|
3467
3707
|
if (verdict.setCookie && isRead(method) && !pathname.startsWith("/api/")) {
|
|
3468
3708
|
return redirectWithoutToken(res, req.url);
|
|
3469
3709
|
}
|
|
3470
3710
|
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);
|
|
3711
|
+
if (segments[0] === "api") return handleApi(opts, req, res, method, segments, lang);
|
|
3712
|
+
if (!isRead(method)) return methodNotAllowed(res, "GET, HEAD", lang);
|
|
3713
|
+
return handleStatic(opts, res, pathname, lang);
|
|
3474
3714
|
}
|
|
3475
3715
|
function isRead(method) {
|
|
3476
3716
|
return method === "GET" || method === "HEAD";
|
|
@@ -3484,9 +3724,8 @@ function splitPath(pathname) {
|
|
|
3484
3724
|
}
|
|
3485
3725
|
});
|
|
3486
3726
|
}
|
|
3487
|
-
async function handleApi(opts, req, res, method, segments) {
|
|
3727
|
+
async function handleApi(opts, req, res, method, segments, lang) {
|
|
3488
3728
|
const { ctx } = opts;
|
|
3489
|
-
const lang = langOf(req.url);
|
|
3490
3729
|
const [, resource, first, second, detail, tail] = segments;
|
|
3491
3730
|
if (resource === "config" && isRead(method)) return config(ctx, res, lang);
|
|
3492
3731
|
if (resource === "events" && method === "GET") return openStream(opts, req, res);
|
|
@@ -3494,13 +3733,13 @@ async function handleApi(opts, req, res, method, segments) {
|
|
|
3494
3733
|
return handleSessions(opts, req, res, method, first, second, detail, lang);
|
|
3495
3734
|
}
|
|
3496
3735
|
if (resource === "approvals" && first !== void 0 && method === "POST") {
|
|
3497
|
-
return respondApproval(ctx, req, res, first);
|
|
3736
|
+
return respondApproval(ctx, req, res, first, lang);
|
|
3498
3737
|
}
|
|
3499
3738
|
if (resource === "questions" && first !== void 0 && method === "POST") {
|
|
3500
|
-
return respondQuestion(ctx, req, res, first);
|
|
3739
|
+
return respondQuestion(ctx, req, res, first, lang);
|
|
3501
3740
|
}
|
|
3502
3741
|
if (resource === "artifacts" && first !== void 0 && second !== void 0 && isRead(method)) {
|
|
3503
|
-
return getArtifact(ctx, res, first, second);
|
|
3742
|
+
return getArtifact(ctx, res, first, second, lang);
|
|
3504
3743
|
}
|
|
3505
3744
|
if (resource === "mcp" && first === "config" && second === void 0 && isRead(method)) {
|
|
3506
3745
|
return readConfig(ctx, res, lang);
|
|
@@ -3526,8 +3765,26 @@ async function handleApi(opts, req, res, method, segments) {
|
|
|
3526
3765
|
if (resource === "skills" && first === "import" && second === void 0 && method === "POST") {
|
|
3527
3766
|
return importSkills(ctx, req, res, lang);
|
|
3528
3767
|
}
|
|
3768
|
+
if (resource === "plugins" && first === void 0 && isRead(method)) {
|
|
3769
|
+
return listPlugins(ctx, res, lang);
|
|
3770
|
+
}
|
|
3771
|
+
if (resource === "plugins" && first === "install" && second === "preview" && method === "POST") {
|
|
3772
|
+
return previewPluginInstall(ctx, req, res, lang);
|
|
3773
|
+
}
|
|
3774
|
+
if (resource === "plugins" && first === "install" && second === void 0 && method === "POST") {
|
|
3775
|
+
return installPluginFromMarket(ctx, req, res, lang);
|
|
3776
|
+
}
|
|
3777
|
+
if (resource === "plugins" && first === "update" && second === "preview" && method === "POST") {
|
|
3778
|
+
return previewPluginUpdate(ctx, req, res, lang);
|
|
3779
|
+
}
|
|
3780
|
+
if (resource === "plugins" && first === "update" && second === void 0 && method === "POST") {
|
|
3781
|
+
return updateInstalledPlugin(ctx, req, res, lang);
|
|
3782
|
+
}
|
|
3783
|
+
if (resource === "plugins" && first === "uninstall" && second === void 0 && method === "POST") {
|
|
3784
|
+
return uninstallPluginByName(ctx, req, res, lang);
|
|
3785
|
+
}
|
|
3529
3786
|
if (resource === "providers" && first === void 0 && isRead(method)) {
|
|
3530
|
-
return listProviders(ctx, res);
|
|
3787
|
+
return listProviders(ctx, res, lang);
|
|
3531
3788
|
}
|
|
3532
3789
|
if (resource === "providers" && first !== void 0 && second === "models" && method === "POST") {
|
|
3533
3790
|
return discoverProviderModels(ctx, req, res, first, lang);
|
|
@@ -3541,73 +3798,101 @@ async function handleApi(opts, req, res, method, segments) {
|
|
|
3541
3798
|
if (resource === "schedules") {
|
|
3542
3799
|
return handleSchedules(ctx, req, res, method, first, second, detail, tail, lang);
|
|
3543
3800
|
}
|
|
3544
|
-
|
|
3801
|
+
if (resource === "goals") {
|
|
3802
|
+
if (first === "blocked" && second === void 0) {
|
|
3803
|
+
if (isRead(method)) return listBlockedGoals(ctx, res);
|
|
3804
|
+
return methodNotAllowed(res, "GET", lang);
|
|
3805
|
+
}
|
|
3806
|
+
return sendNoEndpoint(res, method, `/api/goals/${first ?? ""}`, lang);
|
|
3807
|
+
}
|
|
3808
|
+
sendNoEndpoint(res, method, `/${segments.join("/")}`, lang);
|
|
3545
3809
|
}
|
|
3546
3810
|
async function handleSchedules(ctx, req, res, method, scheduleId, action, runId, tail, lang) {
|
|
3547
3811
|
if (scheduleId === void 0) {
|
|
3548
|
-
if (isRead(method)) return listSchedules(ctx, res);
|
|
3812
|
+
if (isRead(method)) return listSchedules(ctx, res, lang);
|
|
3549
3813
|
if (method === "POST") return createSchedule(ctx, req, res, lang);
|
|
3550
|
-
return methodNotAllowed(res, "GET, POST");
|
|
3814
|
+
return methodNotAllowed(res, "GET, POST", lang);
|
|
3815
|
+
}
|
|
3816
|
+
if (action === void 0 && scheduleId === "pending") {
|
|
3817
|
+
if (isRead(method)) return listPending(ctx, res, lang);
|
|
3818
|
+
return methodNotAllowed(res, "GET", lang);
|
|
3551
3819
|
}
|
|
3552
3820
|
if (action === void 0) {
|
|
3553
3821
|
if (isRead(method)) return getSchedule(ctx, res, scheduleId, lang);
|
|
3554
3822
|
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");
|
|
3823
|
+
if (method === "DELETE") return deleteSchedule(ctx, res, scheduleId, lang);
|
|
3824
|
+
return methodNotAllowed(res, "GET, PATCH, DELETE", lang);
|
|
3557
3825
|
}
|
|
3558
3826
|
if (action === "runs") {
|
|
3559
3827
|
if (runId === void 0 && isRead(method)) return listRuns(ctx, res, scheduleId, lang);
|
|
3560
3828
|
if (runId !== void 0 && tail === "recording" && isRead(method)) {
|
|
3561
3829
|
return getRecording(ctx, res, scheduleId, runId, lang);
|
|
3562
3830
|
}
|
|
3563
|
-
return methodNotAllowed(res, "GET");
|
|
3831
|
+
return methodNotAllowed(res, "GET", lang);
|
|
3564
3832
|
}
|
|
3565
3833
|
if (action === "run" && method === "POST")
|
|
3566
3834
|
return runSchedule(ctx, res, scheduleId, lang);
|
|
3567
3835
|
if (action === "fix" && method === "POST") {
|
|
3568
3836
|
return fixSchedule(ctx, req, res, scheduleId, lang);
|
|
3569
3837
|
}
|
|
3570
|
-
sendNoEndpoint(res, method, `/api/schedules/${scheduleId}
|
|
3838
|
+
sendNoEndpoint(res, method, `/api/schedules/${scheduleId}`, lang);
|
|
3571
3839
|
}
|
|
3572
3840
|
async function handleSessions(opts, req, res, method, sessionId, action, detail, lang) {
|
|
3573
3841
|
const { ctx } = opts;
|
|
3574
3842
|
if (sessionId === void 0) {
|
|
3575
3843
|
if (isRead(method)) return listSessions(ctx, res, searchQuery(req.url));
|
|
3576
3844
|
if (method === "POST") return createSession(ctx, req, res, lang);
|
|
3577
|
-
return methodNotAllowed(res, "GET, POST");
|
|
3845
|
+
return methodNotAllowed(res, "GET, POST", lang);
|
|
3578
3846
|
}
|
|
3579
3847
|
if (action === void 0) {
|
|
3580
|
-
if (isRead(method)) return getSession(ctx, res, sessionId);
|
|
3848
|
+
if (isRead(method)) return getSession(ctx, res, sessionId, lang);
|
|
3581
3849
|
if (method === "DELETE") return deleteSession(ctx, res, sessionId, lang);
|
|
3582
3850
|
if (method === "PATCH") return patchSession(ctx, req, res, sessionId, lang);
|
|
3583
|
-
return methodNotAllowed(res, "GET, DELETE, PATCH");
|
|
3851
|
+
return methodNotAllowed(res, "GET, DELETE, PATCH", lang);
|
|
3584
3852
|
}
|
|
3585
3853
|
if (action === "messages") {
|
|
3586
|
-
if (isRead(method)) return listMessages(ctx, res, sessionId);
|
|
3854
|
+
if (isRead(method)) return listMessages(ctx, res, sessionId, lang);
|
|
3587
3855
|
if (method === "POST") return postMessage(ctx, req, res, sessionId, lang);
|
|
3588
|
-
return methodNotAllowed(res, "GET, POST");
|
|
3856
|
+
return methodNotAllowed(res, "GET, POST", lang);
|
|
3589
3857
|
}
|
|
3590
|
-
if (action === "abort" && method === "POST") return abortSession(ctx, res, sessionId);
|
|
3591
|
-
if (action === "
|
|
3592
|
-
|
|
3858
|
+
if (action === "abort" && method === "POST") return abortSession(ctx, res, sessionId, lang);
|
|
3859
|
+
if (action === "reference-candidates" && isRead(method)) {
|
|
3860
|
+
return listReferenceCandidates(ctx, res, sessionId, req.url, lang);
|
|
3861
|
+
}
|
|
3862
|
+
if (action === "file-candidates" && isRead(method)) {
|
|
3863
|
+
return listFileCandidates(ctx, res, sessionId, req.url, lang);
|
|
3864
|
+
}
|
|
3865
|
+
if (action === "approvals" && isRead(method)) return listApprovals(ctx, res, sessionId, lang);
|
|
3866
|
+
if (action === "questions" && isRead(method)) return listQuestions(ctx, res, sessionId, lang);
|
|
3593
3867
|
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);
|
|
3868
|
+
return listCapabilities(ctx, res, sessionId, lang);
|
|
3869
|
+
if (action === "commands" && isRead(method)) return listCommands(ctx, res, sessionId, lang);
|
|
3870
|
+
if (action === "security" && isRead(method)) return getSecurity(ctx, res, sessionId, lang);
|
|
3871
|
+
if (action === "tools" && isRead(method)) return getTools(ctx, res, sessionId, lang);
|
|
3598
3872
|
if (action === "skills" && detail !== void 0 && isRead(method)) {
|
|
3599
3873
|
return getSkillBody(ctx, res, sessionId, detail, lang);
|
|
3600
3874
|
}
|
|
3601
|
-
if (action === "settings" && isRead(method)) return getSettings(ctx, res, sessionId);
|
|
3875
|
+
if (action === "settings" && isRead(method)) return getSettings(ctx, res, sessionId, lang);
|
|
3602
3876
|
if (action === "settings" && method === "POST") {
|
|
3603
3877
|
return writeSetting(ctx, req, res, sessionId, lang);
|
|
3604
3878
|
}
|
|
3605
3879
|
if (action === "model" && isRead(method)) return getModel(ctx, res, sessionId);
|
|
3606
3880
|
if (action === "model" && method === "POST")
|
|
3607
3881
|
return setModel(ctx, req, res, sessionId, lang);
|
|
3608
|
-
if (action === "artifacts" && isRead(method)) return getArtifacts(ctx, res, sessionId);
|
|
3882
|
+
if (action === "artifacts" && isRead(method)) return getArtifacts(ctx, res, sessionId, lang);
|
|
3609
3883
|
if (action === "tasks" && isRead(method)) return getTasks(ctx, res, sessionId);
|
|
3884
|
+
if (action === "context" && isRead(method)) return getContext(ctx, res, sessionId, lang);
|
|
3885
|
+
if (action === "compact" && method === "POST") {
|
|
3886
|
+
return postCompact(ctx, req, res, sessionId, lang);
|
|
3887
|
+
}
|
|
3610
3888
|
if (action === "plan" && isRead(method)) return getPlan(ctx, res, sessionId);
|
|
3889
|
+
if (action === "goal") {
|
|
3890
|
+
if (isRead(method)) return getGoal(ctx, res, sessionId, lang);
|
|
3891
|
+
if (method === "POST") return createGoal(ctx, req, res, sessionId, lang);
|
|
3892
|
+
if (method === "PATCH") return patchGoal(ctx, req, res, sessionId, lang);
|
|
3893
|
+
if (method === "DELETE") return deleteGoal(ctx, res, sessionId, lang);
|
|
3894
|
+
return methodNotAllowed(res, "GET, POST, PATCH, DELETE", lang);
|
|
3895
|
+
}
|
|
3611
3896
|
if (action === "plan" && method === "POST")
|
|
3612
3897
|
return setPlanMode(ctx, req, res, sessionId, lang);
|
|
3613
3898
|
if (action === "permission" && isRead(method)) {
|
|
@@ -3616,6 +3901,9 @@ async function handleSessions(opts, req, res, method, sessionId, action, detail,
|
|
|
3616
3901
|
if (action === "permission" && method === "POST") {
|
|
3617
3902
|
return setPermission(ctx, req, res, sessionId, lang);
|
|
3618
3903
|
}
|
|
3904
|
+
if (action === "approval-cache" && method === "POST") {
|
|
3905
|
+
return revokeApprovalCache(ctx, req, res, sessionId, lang);
|
|
3906
|
+
}
|
|
3619
3907
|
if (action === "checkpoints" && isRead(method)) {
|
|
3620
3908
|
return detail === void 0 ? listCheckpoints(ctx, res, sessionId) : previewCheckpoint(ctx, res, sessionId, detail, lang);
|
|
3621
3909
|
}
|
|
@@ -3628,7 +3916,7 @@ async function handleSessions(opts, req, res, method, sessionId, action, detail,
|
|
|
3628
3916
|
return bindWorkspace(ctx, req, res, sessionId, lang);
|
|
3629
3917
|
}
|
|
3630
3918
|
if (action === "diff" && isRead(method)) return sessionDiff(ctx, res, sessionId);
|
|
3631
|
-
sendNoEndpoint(res, method, `/api/sessions/${sessionId}
|
|
3919
|
+
sendNoEndpoint(res, method, `/api/sessions/${sessionId}`, lang);
|
|
3632
3920
|
}
|
|
3633
3921
|
function openStream(opts, req, res) {
|
|
3634
3922
|
const connection = new SseConnection(res, opts.heartbeatMs);
|
|
@@ -3643,13 +3931,20 @@ function openStream(opts, req, res) {
|
|
|
3643
3931
|
req.on("close", finish);
|
|
3644
3932
|
res.on("close", finish);
|
|
3645
3933
|
}
|
|
3646
|
-
function handleStatic(opts, res, pathname) {
|
|
3934
|
+
function handleStatic(opts, res, pathname, lang) {
|
|
3647
3935
|
if (opts.webRoot) {
|
|
3648
3936
|
const file = resolveStaticPath(opts.webRoot, pathname);
|
|
3649
3937
|
if (file) return sendFile(res, file, { "Content-Type": staticContentType(file) });
|
|
3650
3938
|
}
|
|
3651
|
-
if (pathname !== "/")
|
|
3652
|
-
|
|
3939
|
+
if (pathname !== "/") {
|
|
3940
|
+
return sendError(
|
|
3941
|
+
res,
|
|
3942
|
+
404,
|
|
3943
|
+
"not-found",
|
|
3944
|
+
t("web.route.static_not_found", { path: pathname }, lang)
|
|
3945
|
+
);
|
|
3946
|
+
}
|
|
3947
|
+
const html = placeholderPage(opts.ctx.version, lang);
|
|
3653
3948
|
res.writeHead(200, {
|
|
3654
3949
|
...baseHeaders(),
|
|
3655
3950
|
"Content-Type": "text/html; charset=utf-8",
|
|
@@ -3667,18 +3962,19 @@ function redirectWithoutToken(res, rawUrl) {
|
|
|
3667
3962
|
res.writeHead(303, { ...baseHeaders(), Location: `${url.pathname}${url.search}` });
|
|
3668
3963
|
res.end();
|
|
3669
3964
|
}
|
|
3670
|
-
function sendNoEndpoint(res, method, path) {
|
|
3671
|
-
sendError(res, 404, "not-found",
|
|
3965
|
+
function sendNoEndpoint(res, method, path, lang) {
|
|
3966
|
+
sendError(res, 404, "not-found", t("web.no_endpoint", { method, path }, lang));
|
|
3672
3967
|
}
|
|
3673
|
-
function methodNotAllowed(res, allow) {
|
|
3674
|
-
sendError(res, 405, "method-not-allowed",
|
|
3968
|
+
function methodNotAllowed(res, allow, lang) {
|
|
3969
|
+
sendError(res, 405, "method-not-allowed", t("web.method_not_allowed", { allow }, lang), {
|
|
3970
|
+
Allow: allow
|
|
3971
|
+
});
|
|
3675
3972
|
}
|
|
3676
3973
|
function defaultWebRoot(baseDir = dirname(fileURLToPath(import.meta.url)), exists = existsSync) {
|
|
3677
3974
|
return [join(baseDir, "web"), join(baseDir, "..", "dist", "web")].find(
|
|
3678
3975
|
(dir) => exists(join(dir, "index.html"))
|
|
3679
3976
|
);
|
|
3680
3977
|
}
|
|
3681
|
-
|
|
3682
3978
|
// src/index.ts
|
|
3683
3979
|
async function createWebServer(opts) {
|
|
3684
3980
|
const binding = decideBinding({
|
|
@@ -3692,9 +3988,6 @@ async function createWebServer(opts) {
|
|
|
3692
3988
|
...opts.idleGraceMs === void 0 ? {} : { idleGraceMs: opts.idleGraceMs },
|
|
3693
3989
|
...opts.clock === void 0 ? {} : { clock: opts.clock },
|
|
3694
3990
|
...opts.maxActiveSessions === void 0 ? {} : { maxActiveSessions: opts.maxActiveSessions },
|
|
3695
|
-
// 被挤走的那个会话,引擎那一侧的东西也要放掉(方案 30 §2.3 的上限)。
|
|
3696
|
-
// 不接这条线的话,上限只挡住了 Hub 那张表变长,而对话历史、`AgentLoop`、
|
|
3697
|
-
// 审批桥全留着 —— 那正是冷却要还回来的东西。判据见 `SessionHubOptions.onCooled`
|
|
3698
3991
|
onCooled: (sessionId) => opts.runtime.sessionFactory.release(sessionId)
|
|
3699
3992
|
});
|
|
3700
3993
|
if (opts.runtime.session) hub.register(opts.runtime.session);
|
|
@@ -3716,8 +4009,6 @@ async function createWebServer(opts) {
|
|
|
3716
4009
|
host: binding.host,
|
|
3717
4010
|
token: binding.token,
|
|
3718
4011
|
...webRoot === void 0 ? {} : { webRoot },
|
|
3719
|
-
// 两个都是直接转 hub 上那一对,**没有中间层** —— 转出去的就是浏览器
|
|
3720
|
-
// 收到的那条流和 `GET /api/sessions` 读的那张表,不是它们的复制品
|
|
3721
4012
|
observe: (sink) => hub.observe(sink),
|
|
3722
4013
|
snapshot: () => hub.listSessions(),
|
|
3723
4014
|
close: makeCloser(server, hub, opts.runtime)
|
|
@@ -3730,12 +4021,7 @@ function buildHandler(opts, hub, binding, port, webRoot) {
|
|
|
3730
4021
|
hub,
|
|
3731
4022
|
runtime: opts.runtime,
|
|
3732
4023
|
version: opts.version,
|
|
3733
|
-
// 缺省问引擎要(方案 54 §二)。`??` 而不是 `||`:宿主传空字符串是它自己
|
|
3734
|
-
// 的错,静默替换成引擎那个会把「我明明配了别处」变成一个查不出的怪现象
|
|
3735
4024
|
artifactsRoot: opts.artifactsRoot ?? opts.runtime.artifactsRoot,
|
|
3736
|
-
// 绑定策略那一格原样递下去(2026-08-18)。**同一个 `binding.lanExposed`
|
|
3737
|
-
// 同时喂着下面 `createAuthGuard`(放宽 Host 白名单)和 `POST /api/roles`
|
|
3738
|
-
// 那道 403** —— 一个事实一个来源,判据在 `ApiContext.lanExposed` 上
|
|
3739
4025
|
lanExposed: binding.lanExposed
|
|
3740
4026
|
},
|
|
3741
4027
|
guard: createAuthGuard({ token: binding.token, port, lanExposed: binding.lanExposed }),
|
|
@@ -3759,15 +4045,15 @@ function listen(server, host, port) {
|
|
|
3759
4045
|
}
|
|
3760
4046
|
function describeListenError(err, host, port) {
|
|
3761
4047
|
if (err.code === "EADDRINUSE") {
|
|
3762
|
-
return
|
|
4048
|
+
return t("web.listen_in_use", { port, next: port + 1 }, void 0);
|
|
3763
4049
|
}
|
|
3764
4050
|
if (err.code === "EACCES") {
|
|
3765
|
-
return
|
|
4051
|
+
return t("web.listen_no_permission", { host, port }, void 0);
|
|
3766
4052
|
}
|
|
3767
4053
|
if (err.code === "EADDRNOTAVAIL") {
|
|
3768
|
-
return
|
|
4054
|
+
return t("web.listen_no_address", { host }, void 0);
|
|
3769
4055
|
}
|
|
3770
|
-
return
|
|
4056
|
+
return t("web.listen_failed", { host, port, reason: err.message }, void 0);
|
|
3771
4057
|
}
|
|
3772
4058
|
function makeCloser(server, hub, runtime) {
|
|
3773
4059
|
let closing = null;
|
|
@@ -3782,4 +4068,4 @@ function makeCloser(server, hub, runtime) {
|
|
|
3782
4068
|
};
|
|
3783
4069
|
}
|
|
3784
4070
|
|
|
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 };
|
|
4071
|
+
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, listFileCandidates, listReferenceCandidates, readRewindInput, resolveArtifactPath, toWireCheckpoint, toWireCommand, toWirePluginEntry, toWirePreview, toWireRewindResult, toWireRow, toWireSkillBody, toWireTask, unavailableToHttp };
|