@wudao-org/server 0.0.0-stage → 0.4.21
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/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/.tsbuildinfo +1 -0
- package/dist/index.d.ts +95 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1645 -0
- package/dist/index.js.map +1 -0
- package/dist/lanes.d.ts +52 -0
- package/dist/lanes.d.ts.map +1 -0
- package/dist/lanes.js +78 -0
- package/dist/lanes.js.map +1 -0
- package/dist/pet-turns.d.ts +6 -0
- package/dist/pet-turns.d.ts.map +1 -0
- package/dist/pet-turns.js +24 -0
- package/dist/pet-turns.js.map +1 -0
- package/dist/placeholder.d.ts +2 -0
- package/dist/placeholder.d.ts.map +1 -0
- package/dist/placeholder.js +48 -0
- package/dist/placeholder.js.map +1 -0
- package/dist/ports.d.ts +364 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.js +8 -0
- package/dist/ports.js.map +1 -0
- package/dist/probe.d.ts +39 -0
- package/dist/probe.d.ts.map +1 -0
- package/dist/probe.js +96 -0
- package/dist/probe.js.map +1 -0
- package/dist/recovery.d.ts +199 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +424 -0
- package/dist/recovery.js.map +1 -0
- package/dist/vault.d.ts +23 -0
- package/dist/vault.d.ts.map +1 -0
- package/dist/vault.js +117 -0
- package/dist/vault.js.map +1 -0
- package/package.json +43 -3
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
|