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