@wudao-org/server 0.0.0-stage → 0.4.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1645 @@
1
+ // @wudao-org/server — the localhost WS protocol server for an AgentDriver (M43-A
2
+ // §2). node:http serves the static frontend (placeholder when absent) and
3
+ // carries the WS endpoint; `ws` is the ONLY new runtime dependency. The server
4
+ // is transport-only: it relays the driver's two channels (append-only history,
5
+ // small full-snapshot status) and ops, and owns none of the agent logic — the
6
+ // driver is injected as the structural UiDriverPort below (the CLI's
7
+ // AgentDriver satisfies it; this package never imports the CLI, keeping the
8
+ // workspace graph acyclic).
9
+ import { createServer } from "node:http";
10
+ import { createHash, randomBytes } from "node:crypto";
11
+ import { readFile, stat } from "node:fs/promises";
12
+ import { extname, join, normalize, sep } from "node:path";
13
+ import { WebSocket, WebSocketServer } from "ws";
14
+ export { createRecoveryJournal, foldRecoveryLines, classifyRecovery, createRecoveryCoordinator, RECOVERY_INTERVAL_MS, RECOVERY_CONCURRENCY, RECOVERY_ROTATION_BOUND, } from "./recovery.js";
15
+ /** Loopback hostnames the Host header may carry (DNS-rebinding guard). */
16
+ const ALLOWED_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
17
+ /** Close codes (documented in PROTOCOL.md). */
18
+ const CLOSE_WRONG_HOST = 4403;
19
+ const CLOSE_AUTH_FAILED = 4401;
20
+ const CLOSE_SINGLE_CLIENT = 4000;
21
+ /** ω99-225: the resume verdict relayed verbatim — a structural mirror of the
22
+ * CLI's ResumeOutcome (this package never imports the CLI; the shapes are
23
+ * kept identical by the twin tests in each package). `occupied` carries the
24
+ * live lease holder's pid so the web can tell a busy session from a missing
25
+ * one instead of guessing off the old bare boolean. */
26
+ import { PET_LANE_SESSION_ID, } from "./ports.js";
27
+ // 多 driver 并发弧 B —— 车道表(lane table):driver 具名槽泛化为
28
+ // main + 副车道(key=sessionId)的可寻址集合;装配/订阅簿记在 lanes.ts。
29
+ import { assertMountableLaneSid, createLaneTable, MAIN_LANE_SID } from "./lanes.js";
30
+ import { petTurnsFromHistory } from "./pet-turns.js";
31
+ export { PET_LANE_SESSION_ID } from "./ports.js";
32
+ // 拆分运动(借鉴弧 δ 顺带件,file-size 棘轮):petTurnsFromHistory 纯函数
33
+ // 外提至 pet-turns.ts(逐字节搬家;本行 re-export 维持既有公共面)。
34
+ export { petTurnsFromHistory } from "./pet-turns.js";
35
+ const CONTENT_TYPES = {
36
+ ".html": "text/html; charset=utf-8",
37
+ ".js": "text/javascript; charset=utf-8",
38
+ ".css": "text/css; charset=utf-8",
39
+ ".json": "application/json; charset=utf-8",
40
+ ".svg": "image/svg+xml",
41
+ ".png": "image/png",
42
+ ".ico": "image/x-icon",
43
+ };
44
+ // Host header → hostname (strip the :port; keep the bracketed v6 form).
45
+ function hostName(host) {
46
+ if (host === undefined)
47
+ return "";
48
+ if (host.startsWith("["))
49
+ return host.slice(0, host.indexOf("]") + 1);
50
+ const idx = host.indexOf(":");
51
+ return idx === -1 ? host : host.slice(0, idx);
52
+ }
53
+ // ω-3a — the mutation idempotency vault (adapted from nanobot's WS vault,
54
+ // channels/websocket/runtime.py: _WebUIRequestOperation + the request map).
55
+ // Client MUTATIONS carrying a client-instance qualifier are keyed by
56
+ // `cid` + the op envelope's id: a retry with the same payload digest JOINS
57
+ // the in-flight execution (never re-executes), a retry after completion
58
+ // replays the cached result, and the same id with a different payload gets
59
+ // an explicit conflict error. Reads are NEVER vaulted — a TTL-cached read
60
+ // would serve stale data (the vault is for exactly-once side effects, not
61
+ // freshness); ops without a cid (old clients) keep today's behavior.
62
+ // 多 driver 并发弧 B 的顺带件(file-size 棘轮:index.ts 白名单帽只收不
63
+ // 涨):isVaultedMutation / canonicalJson / VaultEntry 三段纯函数/类型外提
64
+ // 至 vault.ts(逐字节搬家),re-export 门面维持既有公共面。
65
+ import { canonicalJson, isVaultedMutation } from "./vault.js";
66
+ export { isVaultedMutation } from "./vault.js";
67
+ // PB-2(探针基础设施弧)— ProbeBus 的 WS 中继(三读面 op + {t:"probe"} 帧)。
68
+ import { createProbeRelay } from "./probe.js";
69
+ export async function createUiServer(opts) {
70
+ const host = opts.host ?? "127.0.0.1";
71
+ if (host !== "127.0.0.1" && host !== "localhost" && host !== "::1") {
72
+ throw new Error(`wudao ui refuses to bind ${host}: loopback only (v1-A)`);
73
+ }
74
+ // 多 driver 弧 B —— 副车道 key 的构造期校验(host 校验同位、同族:任何
75
+ // 保留字/坏形状都在开端口之前拒,绝不带病上车)。
76
+ for (const sid of opts.laneDrivers?.keys() ?? [])
77
+ assertMountableLaneSid(sid);
78
+ const token = opts.token ?? randomBytes(32).toString("base64url");
79
+ const authTimeoutMs = opts.authTimeoutMs ?? 5_000;
80
+ const pingIntervalMs = opts.pingIntervalMs ?? 30_000;
81
+ const modes = opts.modes ?? ["workspace", "ask", "auto", "plan"];
82
+ const idemTtlMs = opts.idempotency?.ttlMs ?? 300_000;
83
+ const idemMaxEntries = opts.idempotency?.maxEntries ?? 256;
84
+ const idemNow = opts.idempotency?.now ?? (() => Date.now());
85
+ // ω99-225: byte-stable defaults — with no injection the ONLY new behavior
86
+ // is the orphan condition firing (a live app never trips it: its sidecar's
87
+ // ppid is the app, hidden-tray webviews keep their WS, and a booting app
88
+ // holds ppid != 1 through the connect window).
89
+ const getPpid = opts.getPpid ?? (() => process.ppid);
90
+ const exitFn = opts.exit ?? ((code) => process.exit(code));
91
+ const orphanGraceMs = opts.orphanGraceMs ?? 20_000;
92
+ // ω-3a — the vault itself. Keyed by `<cid>:<op id>`: the envelope's id is
93
+ // per-client-process monotonic (a page reload restarts it at 1), so the
94
+ // client-instance qualifier keeps a reloaded page from joining or
95
+ // conflicting with the previous page's ops. In-memory by design (a server
96
+ // restart loses it — same trade as nanobot; the client replays and the op
97
+ // simply re-executes). Pruned lazily on access: no timers to unref.
98
+ const vault = new Map();
99
+ const pruneVault = () => {
100
+ const now = idemNow();
101
+ for (const [key, entry] of vault) {
102
+ if (entry.done && entry.completedAt !== null && now - entry.completedAt >= idemTtlMs) {
103
+ vault.delete(key);
104
+ }
105
+ }
106
+ const completed = [...vault.entries()]
107
+ .filter(([, entry]) => entry.done && entry.completedAt !== null)
108
+ .sort(([, a], [, b]) => a.completedAt - b.completedAt);
109
+ for (const [key] of completed.slice(0, Math.max(0, completed.length - idemMaxEntries))) {
110
+ vault.delete(key);
111
+ }
112
+ };
113
+ // The one authenticated client slot (v1-A: single connection; a second
114
+ // client is rejected with a reason). Reconnects re-run the auth handshake
115
+ // and get a full history replay.
116
+ let client = null;
117
+ const send = (frame) => {
118
+ if (client !== null && client.readyState === WebSocket.OPEN)
119
+ client.send(JSON.stringify(frame));
120
+ };
121
+ const probeRelay = createProbeRelay({ bus: opts.probeBus, send });
122
+ // ω99-225 — orphan bookkeeping. authedConnections counts AUTHED
123
+ // sockets (v1-A: 0 or 1 — a rejected second client or an auth-timeout close
124
+ // never counts); orphanArmedAt stamps the moment that count hit zero. It is
125
+ // armed from BOOT too, so a sidecar whose app died between spawn and the
126
+ // first connect still self-exits; every auth disarms (a tray-hidden
127
+ // webview's WS keeps it disarmed — main.rs hides the window, never kills
128
+ // the page).
129
+ let authedConnections = 0;
130
+ let orphanArmedAt = Date.now();
131
+ const http = createServer((req, res) => {
132
+ if (!ALLOWED_HOSTS.has(hostName(req.headers.host))) {
133
+ res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
134
+ res.end("forbidden: unrecognized Host");
135
+ return;
136
+ }
137
+ void handleStatic(req, res, opts.staticDir);
138
+ });
139
+ const wss = new WebSocketServer({ noServer: true });
140
+ http.on("upgrade", (req, socket, head) => {
141
+ if (!ALLOWED_HOSTS.has(hostName(req.headers.host))) {
142
+ socket.write(`HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n`);
143
+ socket.destroy();
144
+ return;
145
+ }
146
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
147
+ });
148
+ wss.on("connection", (ws) => {
149
+ let authed = false;
150
+ if (client !== null && client.readyState === WebSocket.OPEN) {
151
+ ws.close(CLOSE_SINGLE_CLIENT, "another client is already connected");
152
+ return;
153
+ }
154
+ const authTimer = setTimeout(() => {
155
+ if (!authed)
156
+ ws.close(CLOSE_AUTH_FAILED, "auth timeout");
157
+ }, authTimeoutMs);
158
+ authTimer.unref?.();
159
+ ws.on("message", (data) => {
160
+ let frame;
161
+ try {
162
+ frame = JSON.parse(String(data));
163
+ }
164
+ catch {
165
+ if (!authed)
166
+ ws.close(CLOSE_AUTH_FAILED, "first frame must be JSON auth");
167
+ return;
168
+ }
169
+ if (!authed) {
170
+ // First frame MUST be the shared-secret auth. Browsers cannot set
171
+ // custom WS headers, so the token rides the URL fragment and the page
172
+ // JS sends it here — it never appears in any request line.
173
+ if (typeof frame === "object" && frame !== null && frame.t === "auth" &&
174
+ frame.token === token) {
175
+ authed = true;
176
+ clearTimeout(authTimer);
177
+ client = ws;
178
+ // ω99-225: a live client disarms the orphan sweep (reconnect
179
+ // within the grace window is the normal app restart flap).
180
+ authedConnections += 1;
181
+ orphanArmedAt = null;
182
+ // ω99-235: #5 — the post-auth initial state frame rides the same
183
+ // dual-lane push (#4): a reconnecting client gets BOTH statuses.
184
+ pushState();
185
+ send({ t: "history", entries: opts.driver.getHistory(), reset: true });
186
+ // 多 driver 弧 B —— 副车道的初始重放:带 sid + reset,紧跟主车道
187
+ // 重放之后(lane-aware 客户端据此恢复每车道转录;主车道帧不带 sid
188
+ // ——重连语义与今日字节不变)。无副车道时零帧(缺省零变化)。
189
+ for (const r of laneTable.replay()) {
190
+ send({ t: "history", entries: r.entries, reset: true, sid: r.sid });
191
+ }
192
+ return;
193
+ }
194
+ clearTimeout(authTimer);
195
+ ws.close(CLOSE_AUTH_FAILED, "unauthenticated");
196
+ return;
197
+ }
198
+ handleFrame(frame, ws);
199
+ });
200
+ ws.on("pong", () => {
201
+ ws.alive = true;
202
+ });
203
+ ws.on("close", () => {
204
+ clearTimeout(authTimer);
205
+ if (client === ws)
206
+ client = null;
207
+ // ω99-225: only an AUTHED connection was counted — its close re-arms
208
+ // the orphan clock (a fresh timestamp, so a flap never inherits the
209
+ // previous drop's elapsed grace).
210
+ if (authed) {
211
+ authedConnections -= 1;
212
+ if (authedConnections === 0)
213
+ orphanArmedAt = Date.now();
214
+ }
215
+ });
216
+ ws.on("error", () => { });
217
+ });
218
+ // Op dispatch (after auth): each {t:"op", id, op} gets exactly one result.
219
+ // ω-3a: for vaulted mutations the reply is intercepted — the FIRST reply
220
+ // settles the vault entry (result cached, joiners flushed); the dispatch
221
+ // below is otherwise untouched, so per-connection execution order stays
222
+ // the synchronous arrival order it always was (no lock introduced). `ws`
223
+ // is the REQUESTING socket: delivery is connection-bound (nanobot's
224
+ // model) — when the requester is no longer the authenticated client, its
225
+ // own delivery is dropped (the vault cache serves the retry) instead of
226
+ // landing on the new client as a duplicate.
227
+ const handleFrame = (frame, ws) => {
228
+ if (typeof frame !== "object" || frame === null || frame.t !== "op")
229
+ return;
230
+ const { id, op } = frame;
231
+ let reply = (ok, err) => {
232
+ send({ t: "result", id, ok, ...(err !== undefined ? { err } : {}) });
233
+ };
234
+ if (typeof op !== "object" || op === null) {
235
+ reply(undefined, "malformed op");
236
+ return;
237
+ }
238
+ const o = op;
239
+ const cid = frame.cid;
240
+ if (typeof id === "number" && typeof cid === "string" && cid.length >= 1 && cid.length <= 128 && isVaultedMutation(o)) {
241
+ const key = `${cid}:${id}`;
242
+ // The digest covers the whole op object — action AND fields in one
243
+ // hash (digest equality implies action equality, the stronger form of
244
+ // nanobot's action-or-digest conflict check).
245
+ const digest = createHash("sha256").update(canonicalJson(o), "utf8").digest("hex");
246
+ pruneVault();
247
+ const existing = vault.get(key);
248
+ if (existing !== undefined) {
249
+ if (existing.digest !== digest) {
250
+ reply(undefined, `conflict: op id ${String(id)} was already used for a different mutation`);
251
+ return;
252
+ }
253
+ if (existing.done) {
254
+ // Completed within the TTL (prune dropped the expired ones): serve
255
+ // the cached outcome — the retry must not re-execute.
256
+ reply(existing.ok, existing.err);
257
+ return;
258
+ }
259
+ // In-flight: JOIN the original execution — await its reply, never
260
+ // re-execute. The joiner's own reply closure carries the same id and
261
+ // send() targets whatever socket is authenticated by then.
262
+ existing.joiners.push(reply);
263
+ return;
264
+ }
265
+ const entry = {
266
+ action: String(o.op),
267
+ digest,
268
+ done: false,
269
+ ok: undefined,
270
+ err: undefined,
271
+ completedAt: null,
272
+ joiners: [],
273
+ };
274
+ vault.set(key, entry);
275
+ const inner = reply;
276
+ const requester = ws;
277
+ reply = (ok, err) => {
278
+ entry.done = true;
279
+ entry.ok = ok;
280
+ entry.err = err;
281
+ entry.completedAt = idemNow();
282
+ // Connection-bound delivery: the original frame's result goes to its
283
+ // own socket — and to nobody else when that socket is gone (a retry
284
+ // joined, or the outcome waits in the cache for one).
285
+ if (requester === client)
286
+ inner(ok, err);
287
+ for (const joiner of entry.joiners.splice(0))
288
+ joiner(ok, err);
289
+ pruneVault();
290
+ };
291
+ }
292
+ // 多 driver 并发弧 B —— 写 op 的 sid 寻址:op 对象携带可选 `sid` 字段时
293
+ // 路由到对应车道;缺省 = 主车道(今日行为字节不变——老客户端从不发
294
+ // sid)。typed 门(字符串)→ 车道表查找(未知 sid 干净拒)——resume/
295
+ // sessionContinue 的「id 直传 → optional port → relay」三段门范式。vault
296
+ // 的 digest 覆盖整个 op 对象(含 sid):同一 cid+id 打不同车道 =
297
+ // conflict,每 (cid, id, sid) 恰好执行一次。
298
+ let lane = opts.driver;
299
+ if (o.sid !== undefined) {
300
+ if (typeof o.sid !== "string") {
301
+ reply(undefined, "sid must be a string when present");
302
+ return;
303
+ }
304
+ const entry = laneTable.lanes.get(o.sid);
305
+ if (entry === undefined) {
306
+ // 0.4.0 集成缝:车道工厂——首次 resume 到未挂 sid 时开车道而非拒。
307
+ if (opts.laneFactory === undefined || typeof o.id !== "string" || o.id === "") {
308
+ reply(undefined, `unknown lane sid: ${o.sid}`);
309
+ return;
310
+ }
311
+ void opts.laneFactory(o.id, o.sid).then((laneDriver) => {
312
+ laneTable.mountLane(o.sid, laneDriver);
313
+ // 重入:车道现挂好,走同一 switch——直接调 laneDriver 的 resume
314
+ void laneDriver.resume(o.id).then((outcome) => reply(outcome), (err) => reply(undefined, errText(err)));
315
+ }, (err) => reply(undefined, `lane open failed: ${errText(err)}`));
316
+ return;
317
+ }
318
+ lane = entry.driver;
319
+ }
320
+ try {
321
+ switch (o.op) {
322
+ case "submit": {
323
+ if (typeof o.text !== "string") {
324
+ reply(undefined, "submit needs a text string");
325
+ return;
326
+ }
327
+ // M79 §4: the optional `images` field (protocol v1 increment — old
328
+ // clients omit it, no behavior change). Shape-validated here; the
329
+ // refs were minted by attachmentSave and the driver carries them
330
+ // onto the user turn.
331
+ let images;
332
+ if (o.images !== undefined) {
333
+ if (!Array.isArray(o.images)) {
334
+ reply(undefined, "submit images must be an array when present");
335
+ return;
336
+ }
337
+ for (const img of o.images) {
338
+ if (!isImageBlockShape(img)) {
339
+ reply(undefined, "submit images entries must be {kind:'image', ref:{attachmentId, mediaType, byteLength}}");
340
+ return;
341
+ }
342
+ }
343
+ images = o.images;
344
+ }
345
+ // ω99-55: the optional `files` field (same increment discipline).
346
+ // Blocks were minted by fileSave; the driver carries them onto the
347
+ // user turn, and request build projects them to handle text.
348
+ let files;
349
+ if (o.files !== undefined) {
350
+ if (!Array.isArray(o.files)) {
351
+ reply(undefined, "submit files must be an array when present");
352
+ return;
353
+ }
354
+ for (const f of o.files) {
355
+ if (!isFileBlockShape(f)) {
356
+ reply(undefined, "submit files entries must be {kind:'file', name, bytes, attachmentId:'sha256:<64 hex>'}");
357
+ return;
358
+ }
359
+ }
360
+ files = o.files;
361
+ }
362
+ // ω99-242 invariant 7 — a MANUAL client submit invalidates the
363
+ // pending recovery of the session the user took over (BEFORE the
364
+ // engine sees it, openwork's forward-seam order). Permission and
365
+ // form replies ride answerPermission — a different op that
366
+ // deliberately never lands here: a reply may UNLOCK a tracked
367
+ // blocked turn, so it must not cancel anything.
368
+ // 多 driver 弧 B —— recovery 失效缝只在主车道触发:noteUserInput 取
369
+ // 消的是「用户接管的主会话」的待恢复;副车道的 submit 落在另一个
370
+ // 会话上,不碰主会话的恢复意图。
371
+ if (lane === opts.driver && opts.recovery !== undefined)
372
+ opts.recovery.noteUserInput();
373
+ reply(lane.submit(o.text, {
374
+ ...(o.plan === true ? { plan: true } : {}),
375
+ ...(o.expand === false ? { expand: false } : {}),
376
+ ...(images !== undefined ? { images } : {}),
377
+ ...(files !== undefined ? { files } : {}),
378
+ }));
379
+ return;
380
+ }
381
+ case "interrupt":
382
+ lane.interrupt();
383
+ reply(true);
384
+ return;
385
+ case "setMode": {
386
+ if (typeof o.mode !== "string" || !modes.includes(o.mode)) {
387
+ reply(undefined, `setMode: mode must be one of ${modes.join(", ")}`);
388
+ return;
389
+ }
390
+ lane.setMode(o.mode);
391
+ reply(true);
392
+ return;
393
+ }
394
+ case "answerPermission":
395
+ if (typeof o.allow !== "boolean") {
396
+ reply(undefined, "answerPermission needs allow: boolean");
397
+ return;
398
+ }
399
+ if (typeof o.reason !== "string" && o.reason !== undefined) {
400
+ reply(undefined, "answerPermission reason must be a string");
401
+ return;
402
+ }
403
+ // 0.3.14 #1: the ask answer's scope — "session"/"always" widen the
404
+ // allow into a recorded rule (anything else = allow once).
405
+ if (o.scope !== undefined && o.scope !== "session" && o.scope !== "always") {
406
+ reply(undefined, "answerPermission scope must be \"session\" | \"always\"");
407
+ return;
408
+ }
409
+ reply(lane.answerPermission(o.allow, typeof o.reason === "string" ? o.reason : undefined, o.scope));
410
+ return;
411
+ // 借鉴弧 α2(CC 2.1.269 思想)— steering 未读撤回:CAS 面(index+text
412
+ // 同帧相符才删)。OPTIONAL like searchSessions:未挂车道的老 driver 干净
413
+ // 拒答(真实车道由 ui host 的 ask-lane 包装挂上)。
414
+ case "recallSteer":
415
+ if (typeof o.index !== "number" || !Number.isInteger(o.index) || o.index < 0) {
416
+ reply(undefined, "recallSteer needs index: non-negative integer");
417
+ return;
418
+ }
419
+ if (typeof o.text !== "string") {
420
+ reply(undefined, "recallSteer needs text: string");
421
+ return;
422
+ }
423
+ if (lane.recallSteer === undefined) {
424
+ reply(undefined, "recallSteer not supported by this driver");
425
+ return;
426
+ }
427
+ reply(lane.recallSteer(o.index, o.text));
428
+ return;
429
+ // 借鉴弧 α1(Codex 0.154 思想)— 回答停泊中的 ask_user 澄清卡:带文
430
+ // =用户的回答(或点选的 chip 标签);空/缺 = 解散(工具收到 error 结
431
+ // 果→「自行决定并继续」)。OPTIONAL 同上。
432
+ case "answerAskUser":
433
+ if (o.answer !== undefined && typeof o.answer !== "string") {
434
+ reply(undefined, "answerAskUser answer must be a string");
435
+ return;
436
+ }
437
+ if (lane.answerAskUser === undefined) {
438
+ reply(undefined, "answerAskUser not supported by this driver");
439
+ return;
440
+ }
441
+ reply(lane.answerAskUser(typeof o.answer === "string" ? o.answer : undefined));
442
+ return;
443
+ // 借鉴弧 δ(penguin 367ab084 思想)— 运行中工具调用转后台:callId 取
444
+ // 状态帧 busyTools 行;缺省=第一个可转的在飞行。typed 裁决联合原样
445
+ // 回(ok / not_detachable / not_in_flight)。收敛写(重放对已转/已
446
+ // 落定的调用答 not_in_flight)→ vaulted 同 recallSteer。
447
+ case "detachToolCall":
448
+ if (o.callId !== undefined && typeof o.callId !== "string") {
449
+ reply(undefined, "detachToolCall callId must be a string");
450
+ return;
451
+ }
452
+ if (lane.detachToolCall === undefined) {
453
+ reply(undefined, "detachToolCall not supported by this driver");
454
+ return;
455
+ }
456
+ reply(lane.detachToolCall(typeof o.callId === "string" ? o.callId : undefined));
457
+ return;
458
+ // ω99-235: #3 — the pet lane's ops: OPTIONAL on the server (the settings
459
+ // ops' contract — absent petDriver ⇒ a clean "unsupported" error, the
460
+ // web relay then degrades to the main lane). Shape validation mirrors
461
+ // the main-lane twins exactly; routing NEVER touches the main driver.
462
+ case "petSubmit":
463
+ if (opts.petDriver === undefined) {
464
+ reply(undefined, "pet ops not supported by this driver");
465
+ return;
466
+ }
467
+ if (typeof o.text !== "string") {
468
+ reply(undefined, "petSubmit needs a text string");
469
+ return;
470
+ }
471
+ // ω99-237: #1 — slash rejection: the lane invariant guards at the
472
+ // PROTOCOL boundary, not just the UI. A slash through petSubmit
473
+ // would ride the driver's submit slash routing (session rotation /
474
+ // clear boundaries) and strand the fixed lane id "pet" on a dead
475
+ // chain — ω99-235's "pet lane never changes id" dies exactly here.
476
+ // The server is the contract executor: every client (current or
477
+ // old web) gets the same refusal. Trim-then-test — leading
478
+ // whitespace must not smuggle a command past the gate. The MAIN
479
+ // lane's submit keeps slash routing untouched (commands are the
480
+ // main lane's feature — the contrast pin).
481
+ if (o.text.trim().startsWith("/")) {
482
+ reply(undefined, "petSubmit rejects slash commands — the pet lane has a fixed session; use the main window for commands");
483
+ return;
484
+ }
485
+ // text-only v1: the pet composer's images/files/plan lanes are not
486
+ // opened here (Bubble is a pure send face — the honesty note).
487
+ reply(opts.petDriver.submit(o.text));
488
+ return;
489
+ case "petInterrupt":
490
+ if (opts.petDriver === undefined) {
491
+ reply(undefined, "pet ops not supported by this driver");
492
+ return;
493
+ }
494
+ opts.petDriver.interrupt();
495
+ reply(true);
496
+ return;
497
+ case "petAnswerPermission":
498
+ if (opts.petDriver === undefined) {
499
+ reply(undefined, "pet ops not supported by this driver");
500
+ return;
501
+ }
502
+ if (typeof o.allow !== "boolean") {
503
+ reply(undefined, "petAnswerPermission needs allow: boolean");
504
+ return;
505
+ }
506
+ if (typeof o.reason !== "string" && o.reason !== undefined) {
507
+ reply(undefined, "petAnswerPermission reason must be a string");
508
+ return;
509
+ }
510
+ if (o.scope !== undefined && o.scope !== "session" && o.scope !== "always") {
511
+ reply(undefined, "petAnswerPermission scope must be \"session\" | \"always\"");
512
+ return;
513
+ }
514
+ reply(opts.petDriver.answerPermission(o.allow, typeof o.reason === "string" ? o.reason : undefined, o.scope));
515
+ return;
516
+ // ω99-271 — the lane's history read (标记见 #2,端口注释): a READ, never
517
+ // vaulted (the 260 memoryList precedent — vaulted-mirror's reads list
518
+ // carries it). Pairs the driver's pet.jsonl history into chat turns
519
+ // (ω99-272 起全保真无帽,标记见 #3) so the pet window can rehydrate what
520
+ // its local transcript cannot survive (window reload / restart) — the
521
+ // tail window no longer truncates, but it still cannot outlive the page.
522
+ // Routing NEVER touches the main driver (the lane-isolation pin in the
523
+ // 271 test watches main getHistory's call count stay flat).
524
+ case "petHistory": {
525
+ if (opts.petDriver === undefined) {
526
+ reply(undefined, "pet ops not supported by this driver");
527
+ return;
528
+ }
529
+ try {
530
+ reply(petTurnsFromHistory(opts.petDriver.getHistory()));
531
+ }
532
+ catch (e) {
533
+ reply(undefined, `petHistory failed: ${e.message}`);
534
+ }
535
+ return;
536
+ }
537
+ case "listSlashCommands":
538
+ reply(opts.driver.listSlashCommands());
539
+ return;
540
+ // ω99-242 — the recovery reconciliation surface: per-session rows
541
+ // counted by the coordinator from the journal's settle records (the
542
+ // web renders 「已验证完成 X / 待续 Y」 off this — structured data,
543
+ // never fabricated). A READ (never vaulted). The FIRST server-answered
544
+ // op: the coordinator is host-mounted server state (like petDriver),
545
+ // so there is no driver round-trip — the shape validation contract of
546
+ // the driver ops does not apply; the reply is the coordinator's own.
547
+ case "recoveryStatus":
548
+ if (opts.recovery === undefined) {
549
+ reply(undefined, "recovery not supported by this server");
550
+ return;
551
+ }
552
+ reply(opts.recovery.status());
553
+ return;
554
+ // PB-2 — 探针遥测读面三 op(server-answered,recoveryStatus 同族;纯读不 vault)。
555
+ case "probeStream":
556
+ case "probeRecent":
557
+ case "probeStats":
558
+ case "probeEnable":
559
+ probeRelay.handleOp(o, reply);
560
+ return;
561
+ case "sessions":
562
+ void opts.driver.sessions().then(
563
+ // ω99-235: #6 — the pet lane's row (id === the fixed "pet") carries
564
+ // lane:"pet" — the main window's session list renders the 小精灵
565
+ // badge off it (and its click refuses to resume: the lane is live
566
+ // in the sprite window, the lease refuses adoption). Every other
567
+ // row passes through untouched (no lane field at all).
568
+ // 多 driver 弧 B —— 标记泛化:行 id 命中任何已挂副车道的 sid 同样
569
+ // 标 lane:"<sid>"(pet 先例的推广——占用中的会话不 resume、UI 走
570
+ // 车道切换);无副车道时标记集只剩 pet(今日字节形状)。
571
+ (rows) => {
572
+ const laneMarks = new Map([[PET_LANE_SESSION_ID, "pet"]]);
573
+ for (const sid of laneTable.lanes.keys()) {
574
+ if (sid !== MAIN_LANE_SID)
575
+ laneMarks.set(sid, sid);
576
+ }
577
+ reply(rows.map((row) => {
578
+ if (typeof row !== "object" || row === null)
579
+ return row;
580
+ const id = row.id;
581
+ const mark = typeof id === "string" ? laneMarks.get(id) : undefined;
582
+ return mark !== undefined ? { ...row, lane: mark } : row;
583
+ }));
584
+ }, (err) => reply(undefined, errText(err)));
585
+ return;
586
+ // ω99-277(续,标记见上)— 弹框全文搜索:q 直传、命中原样回(sessions
587
+ // case 的 relay 范式;optional port 的干净拒答形同 branchLeaves)。
588
+ case "sessionSearch": {
589
+ if (typeof o.q !== "string") {
590
+ reply(undefined, "sessionSearch needs a q string");
591
+ return;
592
+ }
593
+ if (opts.driver.searchSessions === undefined) {
594
+ reply(undefined, "sessionSearch not supported by this driver");
595
+ return;
596
+ }
597
+ void opts.driver.searchSessions(o.q).then((hits) => reply(hits), (err) => reply(undefined, errText(err)));
598
+ return;
599
+ }
600
+ // ω99-279(续,标记见上)— 弹框清理车道:id 直传、删除结果原样回
601
+ // (277 #3 同形:typed 门 → optional port → relay)。收敛删除不进
602
+ // vaulting(isVaultedMutation 零动——census 读表 +1 的裁定面)。
603
+ case "sessionDelete": {
604
+ if (typeof o.id !== "string") {
605
+ reply(undefined, "sessionDelete needs an id string");
606
+ return;
607
+ }
608
+ if (opts.driver.deleteSession === undefined) {
609
+ reply(undefined, "sessionDelete not supported by this driver");
610
+ return;
611
+ }
612
+ void opts.driver.deleteSession(o.id).then((result) => reply(result), (err) => reply(undefined, errText(err)));
613
+ return;
614
+ }
615
+ // ω99-283(续,标记见上)— 弹框回收站面:列表零参直通、恢复 id 直传
616
+ // 结果联合原样回(sessionDelete 同款三段门:typed 门 → optional
617
+ // port → relay)。恢复收敛(重落 target-exists/not-found 不动盘)
618
+ // ——读表语义,isVaultedMutation 零动(census 读表 +2 的裁定面)。
619
+ case "sessionTrashList": {
620
+ if (opts.driver.trashSessions === undefined) {
621
+ reply(undefined, "sessionTrashList not supported by this driver");
622
+ return;
623
+ }
624
+ void opts.driver.trashSessions().then((rows) => reply(rows), (err) => reply(undefined, errText(err)));
625
+ return;
626
+ }
627
+ case "sessionRestore": {
628
+ if (typeof o.id !== "string") {
629
+ reply(undefined, "sessionRestore needs an id string");
630
+ return;
631
+ }
632
+ if (opts.driver.restoreSession === undefined) {
633
+ reply(undefined, "sessionRestore not supported by this driver");
634
+ return;
635
+ }
636
+ void opts.driver.restoreSession(o.id).then((result) => reply(result), (err) => reply(undefined, errText(err)));
637
+ return;
638
+ }
639
+ // ω99-286(续,标记见上)— 断点续跑:id 直传、结果联合原样回
640
+ // (sessionDelete/sessionRestore 同款三段门:typed 门 → optional
641
+ // port → relay)。启动语义不进 vaulting(isVaultedMutation 零动——
642
+ // driver 的 busy/尾形门让 mid-flap 重放收敛 started:false,census
643
+ // 读表 +1 的裁定面)。
644
+ case "sessionContinue": {
645
+ if (typeof o.id !== "string") {
646
+ reply(undefined, "sessionContinue needs an id string");
647
+ return;
648
+ }
649
+ if (opts.driver.continueSession === undefined) {
650
+ reply(undefined, "sessionContinue not supported by this driver");
651
+ return;
652
+ }
653
+ void opts.driver.continueSession(o.id).then((result) => reply(result), (err) => reply(undefined, errText(err)));
654
+ return;
655
+ }
656
+ // ω99-313 — 定时任务与提醒的读面:零参直通、行数组原样回
657
+ //(sessionSearch 的 relay 范式;optional port 的干净拒答形)。
658
+ // 纯读,never vaulted(census 读表 +1 的裁定面)。
659
+ case "schedulesList":
660
+ if (opts.driver.schedulesList === undefined) {
661
+ reply(undefined, "schedulesList not supported by this driver");
662
+ return;
663
+ }
664
+ void opts.driver.schedulesList().then((rows) => reply(rows), (err) => reply(undefined, errText(err)));
665
+ return;
666
+ // ω99-317 — 管理面三写:typed 形状门在 optional-port 检查之后、
667
+ // driver 之前(fileRevert 范式);结果联合 {ok,message} 原样回(web
668
+ // 侧按 ok 分叉通知语气)。setEnabled 与 runNow 是 VAULTED mutations。
669
+ case "schedulesSetEnabled":
670
+ if (opts.driver.schedulesSetEnabled === undefined) {
671
+ reply(undefined, "schedulesSetEnabled not supported by this driver");
672
+ return;
673
+ }
674
+ if (typeof o.id !== "string" || o.id.trim() === "") {
675
+ reply(undefined, "schedulesSetEnabled needs a non-empty id string");
676
+ return;
677
+ }
678
+ if (typeof o.enabled !== "boolean") {
679
+ reply(undefined, "schedulesSetEnabled needs an enabled boolean");
680
+ return;
681
+ }
682
+ void opts.driver.schedulesSetEnabled(o.id, o.enabled).then((verdict) => reply(verdict), (err) => reply(undefined, errText(err)));
683
+ return;
684
+ case "schedulesRemove":
685
+ if (opts.driver.schedulesRemove === undefined) {
686
+ reply(undefined, "schedulesRemove not supported by this driver");
687
+ return;
688
+ }
689
+ if (typeof o.id !== "string" || o.id.trim() === "") {
690
+ reply(undefined, "schedulesRemove needs a non-empty id string");
691
+ return;
692
+ }
693
+ void opts.driver.schedulesRemove(o.id).then((verdict) => reply(verdict), (err) => reply(undefined, errText(err)));
694
+ return;
695
+ case "schedulesRunNow":
696
+ if (opts.driver.schedulesRunNow === undefined) {
697
+ reply(undefined, "schedulesRunNow not supported by this driver");
698
+ return;
699
+ }
700
+ if (typeof o.id !== "string" || o.id.trim() === "") {
701
+ reply(undefined, "schedulesRunNow needs a non-empty id string");
702
+ return;
703
+ }
704
+ if (o.resetBreaker !== undefined && typeof o.resetBreaker !== "boolean") {
705
+ reply(undefined, "schedulesRunNow resetBreaker must be a boolean");
706
+ return;
707
+ }
708
+ void opts.driver.schedulesRunNow(o.id, o.resetBreaker === undefined ? undefined : { resetBreaker: o.resetBreaker }).then((verdict) => reply(verdict), (err) => reply(undefined, errText(err)));
709
+ return;
710
+ case "resume": {
711
+ if (typeof o.id !== "string") {
712
+ reply(undefined, "resume needs an id string");
713
+ return;
714
+ }
715
+ // 小精灵会话弧 — resume 的 fork 形(追问):先把目标换成字节副本 id
716
+ // 再 resume(driver.forkSession 内完成 fork+resume 两段,一次回执;
717
+ // 原会话文件永不以写模式打开)。v1 不与 sid 车道组合——上面的通用
718
+ // sid 路由按原始 id 开车道,对 fork 是错的目标,干净拒答(fail-closed)。
719
+ if (o.fork === true) {
720
+ if (o.sid !== undefined) {
721
+ reply(undefined, "resume fork 不支持 sid 车道(v1)");
722
+ return;
723
+ }
724
+ if (opts.driver.forkSession === undefined) {
725
+ reply(undefined, "fork not supported by this driver");
726
+ return;
727
+ }
728
+ void opts.driver.forkSession(o.id).then((result) => reply(result), (err) => reply(undefined, errText(err)));
729
+ return;
730
+ }
731
+ // ω99-225: #2 — the outcome object relays verbatim (occupied carries
732
+ // the holder pid; the web branches its toast on it). No boolean to
733
+ // guess from anymore.
734
+ void opts.driver.resume(o.id).then((outcome) => reply(outcome), (err) => reply(undefined, errText(err)));
735
+ return;
736
+ }
737
+ case "rewind": {
738
+ const t = o.target;
739
+ if (t !== "list" && t !== "last" && typeof t !== "number") {
740
+ reply(undefined, 'rewind target must be a number | "last" | "list"');
741
+ return;
742
+ }
743
+ void opts.driver.rewind(t).then((summary) => reply(summary), (err) => reply(undefined, errText(err)));
744
+ return;
745
+ }
746
+ // M53 §1: /edit <n> arming, /tree, /switch — the branching ops.
747
+ case "edit": {
748
+ if (typeof o.n !== "number" || !Number.isInteger(o.n) || o.n < 0) {
749
+ reply(undefined, "edit needs an integer n ≥ 1 (0 = disarm)");
750
+ return;
751
+ }
752
+ // ω99-258: n:0 disarms a pending arm (the web banner's cancel) —
753
+ // the op is vaulted like the arming form; a disarm replay converges.
754
+ if (o.n === 0) {
755
+ if (opts.driver.disarmEdit === undefined) {
756
+ reply(undefined, "edit disarm not supported by this driver");
757
+ return;
758
+ }
759
+ reply(opts.driver.disarmEdit());
760
+ return;
761
+ }
762
+ reply(opts.driver.beginEdit(o.n) ?? null);
763
+ return;
764
+ }
765
+ case "tree":
766
+ void opts.driver.tree().then((text) => reply(text), (err) => reply(undefined, errText(err)));
767
+ return;
768
+ case "switch": {
769
+ if (o.n !== undefined && (typeof o.n !== "number" || !Number.isInteger(o.n) || o.n < 1)) {
770
+ reply(undefined, "switch n must be an integer ≥ 1 when present");
771
+ return;
772
+ }
773
+ void opts.driver.switchBranch(o.n).then((text) => reply(text), (err) => reply(undefined, errText(err)));
774
+ return;
775
+ }
776
+ // ω99-258: the structured leaf table (read op — never vaulted, the
777
+ // recentWorkspaces read shape): tree()'s data twin for the web panel.
778
+ case "branchLeaves":
779
+ if (opts.driver.branchLeaves === undefined) {
780
+ reply(undefined, "branchLeaves not supported by this driver");
781
+ return;
782
+ }
783
+ void opts.driver.branchLeaves().then((leaves) => reply(leaves), (err) => reply(undefined, errText(err)));
784
+ return;
785
+ // ω99-260: the memory faces. memoryList is a READ (the branchLeaves
786
+ // shape: optional port, clean refusal, never vaulted); memoryDecide
787
+ // is the DECISION op — vaulted (isVaultedMutation), typed shape gates
788
+ // (non-empty id string + the ok|del enum) BEFORE the driver call.
789
+ case "memoryList":
790
+ if (opts.driver.memoryList === undefined) {
791
+ reply(undefined, "memoryList not supported by this driver");
792
+ return;
793
+ }
794
+ void opts.driver.memoryList().then((table) => reply(table), (err) => reply(undefined, errText(err)));
795
+ return;
796
+ case "memoryDecide": {
797
+ if (opts.driver.memoryDecide === undefined) {
798
+ reply(undefined, "memoryDecide not supported by this driver");
799
+ return;
800
+ }
801
+ if (typeof o.id !== "string" || o.id.trim() === "") {
802
+ reply(undefined, "memoryDecide needs a non-empty id string");
803
+ return;
804
+ }
805
+ if (o.action !== "ok" && o.action !== "del") {
806
+ reply(undefined, 'memoryDecide action must be "ok" | "del"');
807
+ return;
808
+ }
809
+ void opts.driver.memoryDecide(o.id, o.action).then((verdict) => reply(verdict), (err) => reply(undefined, errText(err)));
810
+ return;
811
+ }
812
+ // ω99-264: the memory EDIT op — the panel's 改写 key. Vaulted like
813
+ // the decision (isVaultedMutation); typed shape gates (non-empty id
814
+ // string + non-empty content string — 改写不得成空记忆: empty and
815
+ // whitespace-only both refuse) BEFORE the driver call. The content
816
+ // travels verbatim (no server-side trim rewrite — the driver's
817
+ // short-circuit is byte equality).
818
+ case "memoryEdit": {
819
+ if (opts.driver.memoryEdit === undefined) {
820
+ reply(undefined, "memoryEdit not supported by this driver");
821
+ return;
822
+ }
823
+ if (typeof o.id !== "string" || o.id.trim() === "") {
824
+ reply(undefined, "memoryEdit needs a non-empty id string");
825
+ return;
826
+ }
827
+ if (typeof o.content !== "string" || o.content.trim() === "") {
828
+ reply(undefined, "memoryEdit needs a non-empty content string");
829
+ return;
830
+ }
831
+ void opts.driver.memoryEdit(o.id, o.content).then((verdict) => reply(verdict), (err) => reply(undefined, errText(err)));
832
+ return;
833
+ }
834
+ // 永续 v6 wave2(reef C1):门控自改进面。list 是读(memoryList 同
835
+ // 形:optional port, clean refusal, never vaulted);五个决策是写
836
+ // 副作用(vaulted——isVaultedMutation 的 improvementFace 分支)。
837
+ // 形状门在到达驱动之前:action 枚举 + 决策必带非空 id。
838
+ case "improvementFace": {
839
+ if (opts.driver.improvementFace === undefined) {
840
+ reply(undefined, "improvementFace not supported by this driver");
841
+ return;
842
+ }
843
+ const IMPROVE_ACTIONS = new Set(["list", "consent", "ignore", "activate", "reject", "undo"]);
844
+ if (typeof o.action !== "string" || !IMPROVE_ACTIONS.has(o.action)) {
845
+ reply(undefined, 'improvementFace action must be "list" | "consent" | "ignore" | "activate" | "reject" | "undo"');
846
+ return;
847
+ }
848
+ if (o.action !== "list" && (typeof o.id !== "string" || o.id.trim() === "")) {
849
+ reply(undefined, `improvementFace ${o.action} needs a non-empty id string`);
850
+ return;
851
+ }
852
+ void opts.driver.improvementFace(o.action, typeof o.id === "string" ? o.id : undefined).then((verdict) => reply(verdict), (err) => reply(undefined, errText(err)));
853
+ return;
854
+ }
855
+ // M63 §B — the settings/credentials ops. Auth is already done (ops
856
+ // only flow post-auth); the reply path strips any `key` field so a
857
+ // secret NEVER rides back to the client, whatever the handler did.
858
+ case "settingsStatus":
859
+ if (opts.driver.settingsStatus === undefined) {
860
+ reply(undefined, "settings ops not supported by this driver");
861
+ return;
862
+ }
863
+ void opts.driver.settingsStatus().then((status) => reply(stripSecrets(status)), (err) => reply(undefined, errText(err)));
864
+ return;
865
+ case "settingsSaveKey": {
866
+ if (opts.driver.settingsSaveKey === undefined) {
867
+ reply(undefined, "settings ops not supported by this driver");
868
+ return;
869
+ }
870
+ if (typeof o.provider !== "string" || o.provider.trim() === "") {
871
+ reply(undefined, "settingsSaveKey needs a provider string");
872
+ return;
873
+ }
874
+ if (typeof o.key !== "string" || o.key.trim() === "") {
875
+ reply(undefined, "settingsSaveKey needs a non-empty key string");
876
+ return;
877
+ }
878
+ void opts.driver.settingsSaveKey(o.provider, o.key).then((result) => reply(stripSecrets(result)), (err) => reply(undefined, errText(err)));
879
+ return;
880
+ }
881
+ case "settingsTestConnection": {
882
+ if (opts.driver.settingsTestConnection === undefined) {
883
+ reply(undefined, "settings ops not supported by this driver");
884
+ return;
885
+ }
886
+ if (typeof o.provider !== "string" || o.provider.trim() === "") {
887
+ reply(undefined, "settingsTestConnection needs a provider string");
888
+ return;
889
+ }
890
+ if (o.key !== undefined && typeof o.key !== "string") {
891
+ reply(undefined, "settingsTestConnection key must be a string");
892
+ return;
893
+ }
894
+ if (o.model !== undefined && typeof o.model !== "string") {
895
+ reply(undefined, "settingsTestConnection model must be a string");
896
+ return;
897
+ }
898
+ const testOpts = {
899
+ ...(typeof o.key === "string" ? { key: o.key } : {}),
900
+ ...(typeof o.model === "string" ? { model: o.model } : {}),
901
+ };
902
+ void opts.driver.settingsTestConnection(o.provider, testOpts).then((result) => reply(stripSecrets(result)), (err) => reply(undefined, errText(err)));
903
+ return;
904
+ }
905
+ case "settingsSet": {
906
+ if (opts.driver.settingsSet === undefined) {
907
+ reply(undefined, "settings ops not supported by this driver");
908
+ return;
909
+ }
910
+ if (typeof o.key !== "string" || o.key.trim() === "") {
911
+ reply(undefined, "settingsSet needs a key string");
912
+ return;
913
+ }
914
+ void opts.driver.settingsSet(o.key, o.value).then((result) => reply(stripSecrets(result)), (err) => reply(undefined, errText(err)));
915
+ return;
916
+ }
917
+ // Provider management §5 — the registry ops. Same contract as the
918
+ // settings ops: shape validation here, semantics in the cli handler
919
+ // (provider-registry-ops.ts), and the reply path strips secrets so a
920
+ // saved config or a probe key never rides back to the client.
921
+ case "settingsSaveProvider": {
922
+ if (opts.driver.settingsSaveProvider === undefined) {
923
+ reply(undefined, "settings ops not supported by this driver");
924
+ return;
925
+ }
926
+ if (typeof o.id !== "string" || o.id.trim() === "") {
927
+ reply(undefined, "settingsSaveProvider needs an id string");
928
+ return;
929
+ }
930
+ if (typeof o.config !== "object" || o.config === null || Array.isArray(o.config)) {
931
+ reply(undefined, "settingsSaveProvider config must be an object");
932
+ return;
933
+ }
934
+ void opts.driver.settingsSaveProvider(o.id, o.config).then((result) => reply(stripSecrets(result)), (err) => reply(undefined, errText(err)));
935
+ return;
936
+ }
937
+ case "settingsDeleteProvider": {
938
+ if (opts.driver.settingsDeleteProvider === undefined) {
939
+ reply(undefined, "settings ops not supported by this driver");
940
+ return;
941
+ }
942
+ if (typeof o.id !== "string" || o.id.trim() === "") {
943
+ reply(undefined, "settingsDeleteProvider needs an id string");
944
+ return;
945
+ }
946
+ void opts.driver.settingsDeleteProvider(o.id).then((result) => reply(stripSecrets(result)), (err) => reply(undefined, errText(err)));
947
+ return;
948
+ }
949
+ case "settingsListModels": {
950
+ if (opts.driver.settingsListModels === undefined) {
951
+ reply(undefined, "settings ops not supported by this driver");
952
+ return;
953
+ }
954
+ if (typeof o.provider !== "string" || o.provider.trim() === "") {
955
+ reply(undefined, "settingsListModels needs a provider string");
956
+ return;
957
+ }
958
+ if (o.key !== undefined && typeof o.key !== "string") {
959
+ reply(undefined, "settingsListModels key must be a string");
960
+ return;
961
+ }
962
+ const listOpts = typeof o.key === "string" ? { key: o.key } : {};
963
+ void opts.driver.settingsListModels(o.provider, listOpts).then((result) => reply(stripSecrets(result)), (err) => reply(undefined, errText(err)));
964
+ return;
965
+ }
966
+ // M75-ux1 §2 — the workspace switcher's recents (read, or add+persist
967
+ // through the same handler; the reply is the plain list, no secrets).
968
+ case "recentWorkspaces": {
969
+ if (opts.driver.recentWorkspaces === undefined) {
970
+ reply(undefined, "recent workspaces not supported by this driver");
971
+ return;
972
+ }
973
+ if (o.add !== undefined && typeof o.add !== "string") {
974
+ reply(undefined, "recentWorkspaces add must be a string when present");
975
+ return;
976
+ }
977
+ void opts.driver.recentWorkspaces(typeof o.add === "string" ? o.add : undefined).then((result) => reply(result), (err) => reply(undefined, errText(err)));
978
+ return;
979
+ }
980
+ // M77-workspace §1 — same-window directory switch.
981
+ case "switchWorkspace": {
982
+ if (opts.driver.switchWorkspace === undefined) {
983
+ reply(undefined, "workspace switching not supported by this driver");
984
+ return;
985
+ }
986
+ if (typeof o.path !== "string" || o.path === "") {
987
+ reply(undefined, "switchWorkspace path must be a non-empty string");
988
+ return;
989
+ }
990
+ void opts.driver.switchWorkspace(o.path).then((result) => reply(result), (err) => reply(undefined, errText(err)));
991
+ return;
992
+ }
993
+ // M74-ide §1 — the file-panel data ops. Same op contract as the
994
+ // settings ops (auth-gated, exactly one result, optional on the
995
+ // driver port); validation here is shape-only — containment and the
996
+ // sensitive-path list live in the driver handler (agent-driver.ts).
997
+ case "fileTree": {
998
+ if (opts.driver.fileTree === undefined) {
999
+ reply(undefined, "file ops not supported by this driver");
1000
+ return;
1001
+ }
1002
+ if (typeof o.path !== "string") {
1003
+ reply(undefined, "fileTree needs a path string");
1004
+ return;
1005
+ }
1006
+ void opts.driver.fileTree(o.path).then((result) => reply(result), (err) => reply(undefined, errText(err)));
1007
+ return;
1008
+ }
1009
+ case "fileRead": {
1010
+ if (opts.driver.fileRead === undefined) {
1011
+ reply(undefined, "file ops not supported by this driver");
1012
+ return;
1013
+ }
1014
+ if (typeof o.path !== "string") {
1015
+ reply(undefined, "fileRead needs a path string");
1016
+ return;
1017
+ }
1018
+ void opts.driver.fileRead(o.path).then((result) => reply(result), (err) => reply(undefined, errText(err)));
1019
+ return;
1020
+ }
1021
+ // 高亮/编辑 spec §3 — the viewer's save. Same op contract as the
1022
+ // reads above (auth-gated, exactly one result, optional driver
1023
+ // method); shape validation only — containment/sensitive/CAS live
1024
+ // in the driver handler (agent-driver.ts fileWriteContent).
1025
+ case "fileWrite": {
1026
+ if (opts.driver.fileWrite === undefined) {
1027
+ reply(undefined, "file ops not supported by this driver");
1028
+ return;
1029
+ }
1030
+ if (typeof o.path !== "string" || typeof o.content !== "string" || typeof o.base !== "string") {
1031
+ reply(undefined, "fileWrite needs path, content and base strings");
1032
+ return;
1033
+ }
1034
+ void opts.driver.fileWrite(o.path, o.content, o.base).then((result) => reply(result), (err) => reply(undefined, errText(err)));
1035
+ return;
1036
+ }
1037
+ // ω99-314 — the file-level time machine's restore op: typed shape
1038
+ // gates (non-empty path string + 64-hex sha) BEFORE the optional-port
1039
+ // relay; the verdict union relays verbatim (the web branches its
1040
+ // toast/confirm on stale and the refusal reasons). VAULTED — a mid-flap
1041
+ // replay joins the original execution, never a second write.
1042
+ case "fileRevert": {
1043
+ if (opts.driver.fileRevert === undefined) {
1044
+ reply(undefined, "fileRevert not supported by this driver");
1045
+ return;
1046
+ }
1047
+ if (typeof o.path !== "string" || o.path.trim() === "") {
1048
+ reply(undefined, "fileRevert needs a non-empty path string");
1049
+ return;
1050
+ }
1051
+ if (typeof o.sha !== "string" || !/^[0-9a-f]{64}$/.test(o.sha)) {
1052
+ reply(undefined, "fileRevert sha must be a 64-char lowercase hex string");
1053
+ return;
1054
+ }
1055
+ void opts.driver.fileRevert(o.path, o.sha).then((verdict) => reply(verdict), (err) => reply(undefined, errText(err)));
1056
+ return;
1057
+ }
1058
+ // workspace-ux B1 — the panel-root ops: same op contract as the file
1059
+ // ops (auth-gated, exactly one result, optional on the driver port).
1060
+ // Shape validation only: the exists-and-directory check and the
1061
+ // recents write live in the driver handler (agent-driver.ts).
1062
+ case "panelRootSet": {
1063
+ if (opts.driver.panelRootSet === undefined) {
1064
+ reply(undefined, "panel root ops not supported by this driver");
1065
+ return;
1066
+ }
1067
+ if (typeof o.path !== "string" || o.path === "") {
1068
+ reply(undefined, "panelRootSet path must be a non-empty string");
1069
+ return;
1070
+ }
1071
+ void opts.driver.panelRootSet(o.path).then((result) => reply(result), (err) => reply(undefined, errText(err)));
1072
+ return;
1073
+ }
1074
+ case "agentRootStatus": {
1075
+ if (opts.driver.agentRootStatus === undefined) {
1076
+ reply(undefined, "panel root ops not supported by this driver");
1077
+ return;
1078
+ }
1079
+ void opts.driver.agentRootStatus().then((result) => reply(result), (err) => reply(undefined, errText(err)));
1080
+ return;
1081
+ }
1082
+ // workspace-ux B2 — the agent-root switch: same op contract as
1083
+ // panelRootSet (auth-gated, exactly one result, optional on the
1084
+ // driver port). Shape validation only: the exists-and-directory
1085
+ // gate, the busy queue, and the notice/note live in the driver
1086
+ // handler (agent-driver.ts).
1087
+ case "agentRootSwitch": {
1088
+ if (opts.driver.agentRootSwitch === undefined) {
1089
+ reply(undefined, "agent root ops not supported by this driver");
1090
+ return;
1091
+ }
1092
+ if (typeof o.path !== "string" || o.path === "") {
1093
+ reply(undefined, "agentRootSwitch path must be a non-empty string");
1094
+ return;
1095
+ }
1096
+ void opts.driver.agentRootSwitch(o.path).then((result) => reply(result), (err) => reply(undefined, errText(err)));
1097
+ return;
1098
+ }
1099
+ // M76-ux2 §8 — the StatusBar ctx hover's breakdown: a read-only
1100
+ // pass-through over driver state (no validation surface — the payload
1101
+ // is the four plain numbers the driver computed).
1102
+ case "ctxInfo": {
1103
+ if (opts.driver.ctxInfo === undefined) {
1104
+ reply(undefined, "ctx info not supported by this driver");
1105
+ return;
1106
+ }
1107
+ void opts.driver.ctxInfo().then((result) => reply(result), (err) => reply(undefined, errText(err)));
1108
+ return;
1109
+ }
1110
+ // 140(监控要看全):会话解剖——系统提示词 + 消息链全量(监控面板的
1111
+ // 数据源)。纯读、可选方法(老 driver 拒答族);载荷可能大,loopback
1112
+ // 单用户无分页(面板侧截断渲染)。
1113
+ case "sessionAnatomy": {
1114
+ if (opts.driver.sessionAnatomy === undefined) {
1115
+ reply(undefined, "session anatomy not supported by this driver");
1116
+ return;
1117
+ }
1118
+ void opts.driver.sessionAnatomy().then((result) => reply(result), (err) => reply(undefined, errText(err)));
1119
+ return;
1120
+ }
1121
+ // ω99-213: #2 — the away recap. Same op contract as the file ops
1122
+ // (auth-gated, exactly one result, optional driver method); shape
1123
+ // validation only — the session-identity / mark / threshold gates and
1124
+ // the stats/narrative split live in the driver handler. An ok:undefined
1125
+ // reply is LEGITIMATE (zero action), distinct from an err string.
1126
+ case "recap": {
1127
+ if (opts.driver.recap === undefined) {
1128
+ reply(undefined, "recap not supported by this driver");
1129
+ return;
1130
+ }
1131
+ if (typeof o.sessionId !== "string" || o.sessionId === ""
1132
+ || typeof o.sinceMark !== "number" || !Number.isInteger(o.sinceMark) || o.sinceMark < 0) {
1133
+ reply(undefined, "recap needs a sessionId string and a non-negative integer sinceMark");
1134
+ return;
1135
+ }
1136
+ const sinceTs = typeof o.sinceTs === "number" ? o.sinceTs : undefined;
1137
+ void opts.driver.recap(o.sessionId, o.sinceMark, sinceTs).then((result) => reply(result), (err) => reply(undefined, errText(err)));
1138
+ return;
1139
+ }
1140
+ // ω99-316: /btw 侧问(PROTOCOL "sideQuestion")。Same op contract as
1141
+ // recap (auth-gated, exactly one result, optional driver method);
1142
+ // shape validation only — the session-identity / empty-text gates
1143
+ // live in the driver handler. READ lane: the detached side loop
1144
+ // never writes the session (M24 — a replay just re-asks), so it is
1145
+ // never vaulted.
1146
+ case "sideQuestion": {
1147
+ if (opts.driver.sideQuestion === undefined) {
1148
+ reply(undefined, "sideQuestion not supported by this driver");
1149
+ return;
1150
+ }
1151
+ if (typeof o.sessionId !== "string" || o.sessionId === "") {
1152
+ reply(undefined, "sideQuestion needs a sessionId string");
1153
+ return;
1154
+ }
1155
+ if (typeof o.text !== "string" || o.text.trim() === "") {
1156
+ reply(undefined, "sideQuestion needs a non-empty text string");
1157
+ return;
1158
+ }
1159
+ void opts.driver.sideQuestion(o.sessionId, o.text).then((result) => reply(result), (err) => reply(undefined, errText(err)));
1160
+ return;
1161
+ }
1162
+ // M79 §4 — the attachment ops. Shape validation only (the settings/
1163
+ // file-ops contract): count/size/media admission lives in the
1164
+ // driver's AttachmentStore, BEFORE any write; unknown ids reject in
1165
+ // the store's path guard.
1166
+ case "attachmentSave": {
1167
+ if (opts.driver.attachmentSave === undefined) {
1168
+ reply(undefined, "attachment ops not supported by this driver");
1169
+ return;
1170
+ }
1171
+ if (!Array.isArray(o.inputs) || o.inputs.length === 0) {
1172
+ reply(undefined, "attachmentSave needs a non-empty inputs array of {data, mediaType}");
1173
+ return;
1174
+ }
1175
+ for (const input of o.inputs) {
1176
+ if (typeof input !== "object" || input === null
1177
+ || typeof input.data !== "string"
1178
+ || typeof input.mediaType !== "string") {
1179
+ reply(undefined, "attachmentSave inputs must be {data: base64 string, mediaType: string}");
1180
+ return;
1181
+ }
1182
+ }
1183
+ void opts.driver.attachmentSave(o.inputs).then((result) => reply(result), (err) => reply(undefined, errText(err)));
1184
+ return;
1185
+ }
1186
+ case "attachmentGet": {
1187
+ if (opts.driver.attachmentGet === undefined) {
1188
+ reply(undefined, "attachment ops not supported by this driver");
1189
+ return;
1190
+ }
1191
+ const ref = o.ref;
1192
+ if (typeof ref !== "object" || ref === null
1193
+ || typeof ref.attachmentId !== "string"
1194
+ || typeof ref.mediaType !== "string"
1195
+ || typeof ref.byteLength !== "number") {
1196
+ reply(undefined, "attachmentGet needs a ref {attachmentId, mediaType, byteLength}");
1197
+ return;
1198
+ }
1199
+ void opts.driver.attachmentGet(ref).then((result) => reply(result), (err) => reply(undefined, errText(err)));
1200
+ return;
1201
+ }
1202
+ // ω99-55 — the file lane's save op. Shape validation only (the
1203
+ // settings/file-ops contract): count/name/base64 admission lives in
1204
+ // the driver's AttachmentStore, BEFORE any write; typed admission
1205
+ // errors relay verbatim.
1206
+ case "fileSave": {
1207
+ if (opts.driver.fileSave === undefined) {
1208
+ reply(undefined, "attachment ops not supported by this driver");
1209
+ return;
1210
+ }
1211
+ if (!Array.isArray(o.inputs) || o.inputs.length === 0) {
1212
+ reply(undefined, "fileSave needs a non-empty inputs array of {data, name}");
1213
+ return;
1214
+ }
1215
+ for (const input of o.inputs) {
1216
+ if (typeof input !== "object" || input === null
1217
+ || typeof input.data !== "string"
1218
+ || typeof input.name !== "string") {
1219
+ reply(undefined, "fileSave inputs must be {data: base64 string, name: string}");
1220
+ return;
1221
+ }
1222
+ }
1223
+ void opts.driver.fileSave(o.inputs).then((result) => reply(result), (err) => reply(undefined, errText(err)));
1224
+ return;
1225
+ }
1226
+ // cc-plugins C1 — the plugin-management ops. Shape validation only
1227
+ // (the settings/file-ops contract): the marketplace engine behind
1228
+ // the handler owns the source grammar, the typed failures, and the
1229
+ // busy law for the two directory-deleting ops. Engine rejections
1230
+ // ride the err string verbatim (passthrough).
1231
+ case "pluginList":
1232
+ if (opts.driver.pluginList === undefined) {
1233
+ reply(undefined, "plugin ops not supported by this driver");
1234
+ return;
1235
+ }
1236
+ void opts.driver.pluginList().then((result) => reply(result), (err) => reply(undefined, errText(err)));
1237
+ return;
1238
+ // MCP visibility (the settings page's MCP tab): read-only, secrets
1239
+ // never ride the reply (the op's contract).
1240
+ case "mcpList":
1241
+ if (opts.driver.mcpList === undefined) {
1242
+ reply(undefined, "mcp ops not supported by this driver");
1243
+ return;
1244
+ }
1245
+ void opts.driver.mcpList().then((result) => reply(result), (err) => reply(undefined, errText(err)));
1246
+ return;
1247
+ case "pluginMarketList":
1248
+ if (opts.driver.pluginMarketList === undefined) {
1249
+ reply(undefined, "plugin ops not supported by this driver");
1250
+ return;
1251
+ }
1252
+ void opts.driver.pluginMarketList().then((result) => reply(result), (err) => reply(undefined, errText(err)));
1253
+ return;
1254
+ case "pluginMarketAdd": {
1255
+ if (opts.driver.pluginMarketAdd === undefined) {
1256
+ reply(undefined, "plugin ops not supported by this driver");
1257
+ return;
1258
+ }
1259
+ if (typeof o.source !== "string" || o.source.trim() === "") {
1260
+ reply(undefined, "pluginMarketAdd needs a source string");
1261
+ return;
1262
+ }
1263
+ if (o.name !== undefined && (typeof o.name !== "string" || o.name.trim() === "")) {
1264
+ reply(undefined, "pluginMarketAdd name must be a non-empty string when present");
1265
+ return;
1266
+ }
1267
+ void opts.driver.pluginMarketAdd(o.source, typeof o.name === "string" ? o.name : undefined).then((result) => reply(result), (err) => reply(undefined, errText(err)));
1268
+ return;
1269
+ }
1270
+ case "pluginMarketUpdate": {
1271
+ if (opts.driver.pluginMarketUpdate === undefined) {
1272
+ reply(undefined, "plugin ops not supported by this driver");
1273
+ return;
1274
+ }
1275
+ if (typeof o.name !== "string" || o.name.trim() === "") {
1276
+ reply(undefined, "pluginMarketUpdate needs a name string");
1277
+ return;
1278
+ }
1279
+ void opts.driver.pluginMarketUpdate(o.name).then((result) => reply(result), (err) => reply(undefined, errText(err)));
1280
+ return;
1281
+ }
1282
+ case "pluginMarketRemove": {
1283
+ if (opts.driver.pluginMarketRemove === undefined) {
1284
+ reply(undefined, "plugin ops not supported by this driver");
1285
+ return;
1286
+ }
1287
+ if (typeof o.name !== "string" || o.name.trim() === "") {
1288
+ reply(undefined, "pluginMarketRemove needs a name string");
1289
+ return;
1290
+ }
1291
+ void opts.driver.pluginMarketRemove(o.name).then((result) => reply(result), (err) => reply(undefined, errText(err)));
1292
+ return;
1293
+ }
1294
+ case "longtermMemoryList": {
1295
+ if (opts.driver.longtermMemoryList === undefined) {
1296
+ reply(undefined, "memory ops not supported by this driver");
1297
+ return;
1298
+ }
1299
+ void opts.driver.longtermMemoryList().then((result) => reply(result), (err) => reply(undefined, err instanceof Error ? err.message : String(err)));
1300
+ return;
1301
+ }
1302
+ case "longtermMemoryForget": {
1303
+ if (opts.driver.longtermMemoryForget === undefined) {
1304
+ reply(undefined, "memory ops not supported by this driver");
1305
+ return;
1306
+ }
1307
+ if (typeof o.id !== "string" || o.id.trim() === "") {
1308
+ reply(undefined, "longtermMemoryForget needs an id string");
1309
+ return;
1310
+ }
1311
+ void opts.driver.longtermMemoryForget(o.id).then(() => reply(undefined), (err) => reply(undefined, err instanceof Error ? err.message : String(err)));
1312
+ return;
1313
+ }
1314
+ case "pluginInstallSpec": {
1315
+ if (opts.driver.pluginInstallSpec === undefined) {
1316
+ reply(undefined, "plugin ops not supported by this driver");
1317
+ return;
1318
+ }
1319
+ if (typeof o.spec !== "string" || o.spec.trim() === "") {
1320
+ reply(undefined, "pluginInstallSpec needs a spec string");
1321
+ return;
1322
+ }
1323
+ if (o.preview !== undefined && typeof o.preview !== "boolean") {
1324
+ reply(undefined, "pluginInstallSpec preview must be a boolean when present");
1325
+ return;
1326
+ }
1327
+ // handleFrame 是同步体 — 异步 op 走 promise 形(pluginMarketAdd 同款)
1328
+ void opts.driver.pluginInstallSpec(o.spec, o.preview === true).then((result) => reply(result), (err) => reply(undefined, err instanceof Error ? err.message : String(err)));
1329
+ return;
1330
+ }
1331
+ case "pluginInstall": {
1332
+ if (opts.driver.pluginInstall === undefined) {
1333
+ reply(undefined, "plugin ops not supported by this driver");
1334
+ return;
1335
+ }
1336
+ if (typeof o.name !== "string" || o.name.trim() === "") {
1337
+ reply(undefined, "pluginInstall needs a name string");
1338
+ return;
1339
+ }
1340
+ if (typeof o.market !== "string" || o.market.trim() === "") {
1341
+ reply(undefined, "pluginInstall needs a market string");
1342
+ return;
1343
+ }
1344
+ // Absent scope = user (the engine grammar's default); anything
1345
+ // else must be the two-value union.
1346
+ if (o.scope !== undefined && o.scope !== "user" && o.scope !== "project") {
1347
+ reply(undefined, 'pluginInstall scope must be "user" | "project"');
1348
+ return;
1349
+ }
1350
+ void opts.driver.pluginInstall(o.name, o.market, o.scope === "project" ? "project" : "user").then((result) => reply(result), (err) => reply(undefined, errText(err)));
1351
+ return;
1352
+ }
1353
+ case "pluginUninstall":
1354
+ if (opts.driver.pluginUninstall === undefined) {
1355
+ reply(undefined, "plugin ops not supported by this driver");
1356
+ return;
1357
+ }
1358
+ if (typeof o.id !== "string" || o.id.trim() === "") {
1359
+ reply(undefined, "pluginUninstall needs an id string");
1360
+ return;
1361
+ }
1362
+ void opts.driver.pluginUninstall(o.id).then((result) => reply(result), (err) => reply(undefined, errText(err)));
1363
+ return;
1364
+ case "pluginEnable":
1365
+ case "pluginDisable": {
1366
+ const handler = o.op === "pluginEnable" ? opts.driver.pluginEnable : opts.driver.pluginDisable;
1367
+ if (handler === undefined) {
1368
+ reply(undefined, "plugin ops not supported by this driver");
1369
+ return;
1370
+ }
1371
+ if (typeof o.id !== "string" || o.id.trim() === "") {
1372
+ reply(undefined, `${String(o.op)} needs an id string`);
1373
+ return;
1374
+ }
1375
+ void handler(o.id).then((result) => reply(result), (err) => reply(undefined, errText(err)));
1376
+ return;
1377
+ }
1378
+ // SDD 2026-08-23 persona Task 5 — the persona-management ops. Same
1379
+ // contract as the settings ops: shape validation here, semantics in
1380
+ // the cli handler (settings-ops.ts personaList/…/personaDelete over
1381
+ // the providers package's persona seam). The two writes are VAULTED
1382
+ // (isVaultedMutation); the reads never are.
1383
+ case "personaList":
1384
+ if (opts.driver.personaList === undefined) {
1385
+ reply(undefined, "persona ops not supported by this driver");
1386
+ return;
1387
+ }
1388
+ void opts.driver.personaList().then((result) => reply(result), (err) => reply(undefined, errText(err)));
1389
+ return;
1390
+ case "personaGet": {
1391
+ if (opts.driver.personaGet === undefined) {
1392
+ reply(undefined, "persona ops not supported by this driver");
1393
+ return;
1394
+ }
1395
+ if (typeof o.id !== "string" || o.id.trim() === "") {
1396
+ reply(undefined, "personaGet needs an id string");
1397
+ return;
1398
+ }
1399
+ void opts.driver.personaGet(o.id).then((result) => reply(result), (err) => reply(undefined, errText(err)));
1400
+ return;
1401
+ }
1402
+ case "personaSave": {
1403
+ if (opts.driver.personaSave === undefined) {
1404
+ reply(undefined, "persona ops not supported by this driver");
1405
+ return;
1406
+ }
1407
+ if (typeof o.definition !== "object" || o.definition === null || Array.isArray(o.definition)) {
1408
+ reply(undefined, "personaSave definition must be an object");
1409
+ return;
1410
+ }
1411
+ void opts.driver.personaSave(o.definition).then((result) => reply(result), (err) => reply(undefined, errText(err)));
1412
+ return;
1413
+ }
1414
+ case "personaDelete": {
1415
+ if (opts.driver.personaDelete === undefined) {
1416
+ reply(undefined, "persona ops not supported by this driver");
1417
+ return;
1418
+ }
1419
+ if (typeof o.id !== "string" || o.id.trim() === "") {
1420
+ reply(undefined, "personaDelete needs an id string");
1421
+ return;
1422
+ }
1423
+ if (o.unbind !== undefined && typeof o.unbind !== "boolean") {
1424
+ reply(undefined, "personaDelete unbind must be a boolean when present");
1425
+ return;
1426
+ }
1427
+ void opts.driver.personaDelete(o.id, o.unbind === true ? true : undefined).then((result) => reply(result), (err) => reply(undefined, errText(err)));
1428
+ return;
1429
+ }
1430
+ default:
1431
+ reply(undefined, `unknown op: ${String(o.op)}`);
1432
+ }
1433
+ }
1434
+ catch (err) {
1435
+ reply(undefined, errText(err));
1436
+ }
1437
+ };
1438
+ const errText = (err) => (err instanceof Error ? err.message : String(err));
1439
+ /** M79 §4: the submit envelope's image-block shape check (transport-level;
1440
+ * the driver trusts the refs it minted via attachmentSave). */
1441
+ const isImageBlockShape = (v) => {
1442
+ if (typeof v !== "object" || v === null)
1443
+ return false;
1444
+ const b = v;
1445
+ if (b.kind !== "image" || typeof b.ref !== "object" || b.ref === null)
1446
+ return false;
1447
+ const r = b.ref;
1448
+ return typeof r.attachmentId === "string" && r.attachmentId !== ""
1449
+ && typeof r.mediaType === "string"
1450
+ && typeof r.byteLength === "number";
1451
+ };
1452
+ /** ω99-55: the submit envelope's file-block shape check (transport-level;
1453
+ * the strict sha256 id shape doubles as the path-traversal refusal — a
1454
+ * doctored id never matches, exactly like the store's own guard). */
1455
+ const isFileBlockShape = (v) => {
1456
+ if (typeof v !== "object" || v === null)
1457
+ return false;
1458
+ const b = v;
1459
+ return b.kind === "file"
1460
+ && typeof b.name === "string" && b.name !== ""
1461
+ && typeof b.bytes === "number" && Number.isFinite(b.bytes) && b.bytes >= 0
1462
+ && typeof b.attachmentId === "string" && /^sha256:[a-f0-9]{64}$/.test(b.attachmentId);
1463
+ };
1464
+ // M63 §B: the protocol-boundary key strip — whatever a settings handler
1465
+ // returned, no secret-shaped field (`key`/`apiKey`/`apiKeys`/`keys`)
1466
+ // crosses back to the client, at ANY depth (a nested
1467
+ // {providers:[{apiKey:…}]} must not ride the wire either — review L4).
1468
+ // The handlers are built to return statuses only (counts, never values);
1469
+ // this is the backstop.
1470
+ const SECRET_FIELDS = ["key", "apiKey", "apiKeys", "keys"];
1471
+ const stripSecrets = (result) => {
1472
+ if (Array.isArray(result)) {
1473
+ let changed = false;
1474
+ const out = result.map((item) => {
1475
+ const stripped = stripSecrets(item);
1476
+ if (stripped !== item)
1477
+ changed = true;
1478
+ return stripped;
1479
+ });
1480
+ return changed ? out : result;
1481
+ }
1482
+ if (typeof result !== "object" || result === null)
1483
+ return result;
1484
+ let changed = false;
1485
+ const clone = {};
1486
+ for (const [field, value] of Object.entries(result)) {
1487
+ if (SECRET_FIELDS.includes(field)) {
1488
+ changed = true;
1489
+ continue;
1490
+ }
1491
+ const stripped = stripSecrets(value);
1492
+ if (stripped !== value)
1493
+ changed = true;
1494
+ clone[field] = stripped;
1495
+ }
1496
+ return changed ? clone : result;
1497
+ };
1498
+ // Driver → client relays (wired once; gated on the live authenticated slot).
1499
+ // ω99-235: #4 — ONE state frame carries BOTH lanes — the main driver's snapshot
1500
+ // under `state`, the pet lane's (when this server hosts one) under `pet`.
1501
+ // Each driver's subscription pushes the SAME frame: both statuses are
1502
+ // re-read at push time, so a pet emit never ships a stale main state and
1503
+ // vice versa. No petDriver → the frame is byte-identical to the old shape.
1504
+ // 多 driver 并发弧 B —— 帧面泛化:副车道(laneDrivers/运行时 mountLane 挂载)
1505
+ // 的状态并入同一帧的 OPTIONAL `lanes` 字段(推送时逐车道重读——ω99-235 的
1506
+ // 双读合并纪律从 2 车道推广到 N 车道);无副车道时字段整体缺席,帧与旧
1507
+ // 形状字节相同。车道表(lanes.ts)持有 main + 副车道的订阅簿记;挂载/卸载
1508
+ // 各推一帧(客户端据 lanes 增减感知车道面)。
1509
+ const pushState = () => {
1510
+ const laneStatuses = laneTable.statuses();
1511
+ send({
1512
+ t: "state",
1513
+ state: opts.driver.getStatus(),
1514
+ ...(opts.petDriver !== undefined ? { pet: opts.petDriver.getStatus() } : {}),
1515
+ // 0.4.0 集成缝:laneFactory 在场=车道能力恒声明(空表也算——web 的
1516
+ // capable 判定=lanes 字段存在,鸡生蛋问题:车道只在首次 resume {sid}
1517
+ // 后才创建,但 web 只有看到 lanes 字段才走并发路径发 sid)。
1518
+ ...((laneStatuses !== undefined || opts.laneFactory !== undefined) ? { lanes: laneStatuses ?? {} } : {}),
1519
+ });
1520
+ };
1521
+ const laneTable = createLaneTable({
1522
+ main: opts.driver,
1523
+ onStatus: pushState,
1524
+ // 副车道历史帧带 sid(追加形与 reset 形同规则);主车道帧永不带——老客
1525
+ // 户端只消费主车道帧,主车道中继两行保持字节不变。
1526
+ onSideHistory: (sid, entries, reset) => send(reset ? { t: "history", entries, reset: true, sid } : { t: "history", entries, sid }),
1527
+ });
1528
+ for (const [sid, driver] of opts.laneDrivers ?? [])
1529
+ laneTable.mountLane(sid, driver);
1530
+ opts.petDriver?.subscribe(pushState);
1531
+ opts.driver.onHistory((entries, reset) => send(reset ? { t: "history", entries, reset: true } : { t: "history", entries }));
1532
+ // 137(监督者三件):事件帧带 at(epoch ms,转发时刻=发射同 tick)。裸
1533
+ // LoopEvent 自身无时间戳——消费者按序号即可,监督者做时序分析用本字段。
1534
+ opts.driver.onLoopEvent((event) => send({ t: "event", at: Date.now(), event }));
1535
+ // Heartbeat: ping every interval; a socket that never pongged since the
1536
+ // last sweep is terminated (dead-tab detection).
1537
+ const pinger = setInterval(() => {
1538
+ for (const ws of wss.clients) {
1539
+ const marked = ws;
1540
+ if (marked.alive === false) {
1541
+ ws.terminate();
1542
+ continue;
1543
+ }
1544
+ marked.alive = false;
1545
+ ws.ping();
1546
+ }
1547
+ // ω99-225: #3 — the orphan sweep rides the SAME interval (no new timer).
1548
+ // Double condition, zero false positives: ppid 1 means the parent app is
1549
+ // gone (macOS/Linux reparent to init; the app's tray-hide keeps a live
1550
+ // client, so this arm never fires for it), and the client count zero
1551
+ // means nobody is watching. Both must hold past the grace window — then
1552
+ // one stderr receipt for the operator and a clean exit(0): the residual
1553
+ // leases die with the process and the core lease's dead-pid steal
1554
+ // recovers them, so there is deliberately no lease release here.
1555
+ if (authedConnections === 0 && orphanArmedAt !== null &&
1556
+ getPpid() === 1 && Date.now() - orphanArmedAt >= orphanGraceMs) {
1557
+ clearInterval(pinger);
1558
+ process.stderr.write("wudao-server: parent gone and no clients — orphan self-exit\n");
1559
+ // ω99-242 invariant 3 — the orphan self-exit is a shutdown too: freeze
1560
+ // the in-flight session's recovery intent BEFORE exit(0), or this
1561
+ // path's own kill would erase it. Bounded (stop() is journal appends
1562
+ // only); without a mounted recovery the exit stays exactly as before.
1563
+ if (opts.recovery !== undefined) {
1564
+ void opts.recovery.stop().catch(() => { }).then(() => exitFn(0));
1565
+ return;
1566
+ }
1567
+ exitFn(0);
1568
+ }
1569
+ }, pingIntervalMs);
1570
+ pinger.unref?.();
1571
+ await new Promise((resolve, reject) => {
1572
+ http.once("error", reject);
1573
+ http.listen(opts.port ?? 0, host, () => resolve());
1574
+ });
1575
+ const port = http.address().port;
1576
+ return {
1577
+ port,
1578
+ token,
1579
+ url: `http://127.0.0.1:${port}/#${token}`,
1580
+ mountLane: laneTable.mountLane,
1581
+ closeLane: laneTable.closeLane,
1582
+ close: async () => {
1583
+ clearInterval(pinger);
1584
+ probeRelay.close();
1585
+ // Shutdown must not WAIT on peers: a graceful ws.close() handshake
1586
+ // leaves the process alive until the client ACKs — a dead tab never
1587
+ // does, and Ctrl+C must exit promptly even with a run in flight.
1588
+ // Terminate every client (the web UI's reconnect machinery owns
1589
+ // recovery) and drop any dangling HTTP connections.
1590
+ for (const ws of wss.clients)
1591
+ ws.terminate();
1592
+ wss.close();
1593
+ http.closeAllConnections?.();
1594
+ await new Promise((resolve) => http.close(() => resolve()));
1595
+ },
1596
+ };
1597
+ }
1598
+ // Static serving: <staticDir> when it exists (the built web frontend),
1599
+ // otherwise the protocol placeholder. Path traversal is normalized away.
1600
+ async function handleStatic(req, res, staticDir) {
1601
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
1602
+ if (req.method !== "GET" && req.method !== "HEAD") {
1603
+ res.writeHead(405).end();
1604
+ return;
1605
+ }
1606
+ const wants = url.pathname === "/" ? "/index.html" : url.pathname;
1607
+ const fallback = async () => {
1608
+ if (wants !== "/index.html") {
1609
+ res.writeHead(404).end();
1610
+ return;
1611
+ }
1612
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(placeholderPage());
1613
+ };
1614
+ if (staticDir === undefined) {
1615
+ await fallback();
1616
+ return;
1617
+ }
1618
+ const rel = normalize(decodeURIComponent(wants)).replace(/^(\.\.[/\\])+/, "");
1619
+ const abs = join(staticDir, rel);
1620
+ // Path-containment guard. Separator-agnostic: on win32 join() emits `\`, so
1621
+ // a plain `startsWith(staticDir + "/")` would reject every legit request.
1622
+ const prefix = staticDir.endsWith("/") || staticDir.endsWith("\\")
1623
+ ? staticDir
1624
+ : `${staticDir}${sep}`;
1625
+ if (!abs.startsWith(prefix)) {
1626
+ res.writeHead(403).end();
1627
+ return;
1628
+ }
1629
+ try {
1630
+ const info = await stat(abs);
1631
+ if (!info.isFile()) {
1632
+ await fallback();
1633
+ return;
1634
+ }
1635
+ const body = await readFile(abs);
1636
+ res.writeHead(200, { "content-type": CONTENT_TYPES[extname(abs)] ?? "application/octet-stream" }).end(body);
1637
+ }
1638
+ catch {
1639
+ await fallback();
1640
+ }
1641
+ }
1642
+ // The placeholder template is exported for tests/inspection.
1643
+ import { placeholderPage } from "./placeholder.js";
1644
+ export { placeholderPage };
1645
+ //# sourceMappingURL=index.js.map