dsh-deeppilot 0.9.15 → 0.9.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/README.zh-CN.md +3 -1
- package/bin/SHA256SUMS +6 -6
- package/bin/darwin-amd64/dsh-deeppilot-tunnel +0 -0
- package/bin/darwin-arm64/dsh-deeppilot-tunnel +0 -0
- package/bin/linux-amd64/dsh-deeppilot-tunnel +0 -0
- package/bin/linux-arm64/dsh-deeppilot-tunnel +0 -0
- package/bin/windows-amd64/dsh-deeppilot-tunnel.exe +0 -0
- package/bin/windows-arm64/dsh-deeppilot-tunnel.exe +0 -0
- package/docs/NPM_PUBLISHING.md +21 -13
- package/lib/client.js +10 -79
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +44 -1
- package/lib/index.js +279 -23
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
package/lib/index.d.ts
CHANGED
|
@@ -406,6 +406,26 @@ interface WelcomeCapabilities {
|
|
|
406
406
|
* see binary-frame.ts). Plugin-level, independent of the host. Absent ⇒ the
|
|
407
407
|
* client must keep requesting base64 text frames. */
|
|
408
408
|
binaryFrames?: boolean;
|
|
409
|
+
/** Bridge honours `watchSessions` on c2s.auth.prove and c2s.session.watch:
|
|
410
|
+
* a connection that declared an interest set only receives
|
|
411
|
+
* s2c.session.event / s2c.session.queue (live and replayed) for sessions it
|
|
412
|
+
* opened or watches. Plugin-level. Absent ⇒ the client gets every
|
|
413
|
+
* session's events, as before. */
|
|
414
|
+
sessionInterest?: boolean;
|
|
415
|
+
/** Bridge compresses downlink frames over 8 KB as binary format 2
|
|
416
|
+
* (`[2][deflate-raw(envelope JSON)]`) once the client listed "deflate-raw"
|
|
417
|
+
* in `acceptEncoding` on c2s.auth.prove. Plugin-level. Absent ⇒ the field is
|
|
418
|
+
* ignored and every JSON frame stays a text frame. */
|
|
419
|
+
compressedFrames?: boolean;
|
|
420
|
+
/** Bridge answers c2s.session.open carrying `sinceSeq` with an incremental
|
|
421
|
+
* tail (`incremental: true`) holding only rows changed after that seq, or
|
|
422
|
+
* with a full tail when it cannot prove the delta complete. Plugin-level. */
|
|
423
|
+
incrementalTail?: boolean;
|
|
424
|
+
/** Bridge includes `routes` (current LAN endpoints with their certificate
|
|
425
|
+
* fingerprint, and the public Funnel URL) in s2c.welcome so the client can
|
|
426
|
+
* prefer the LAN on the same network and fall back to Funnel elsewhere.
|
|
427
|
+
* Plugin-level. */
|
|
428
|
+
routeHints?: boolean;
|
|
409
429
|
}
|
|
410
430
|
type SessionStatus = "running" | "idle" | "error" | "unknown";
|
|
411
431
|
/**
|
|
@@ -1122,6 +1142,14 @@ interface BridgeSink {
|
|
|
1122
1142
|
* every push/replay delivery; unknown broadcast types are denied.
|
|
1123
1143
|
*/
|
|
1124
1144
|
canReceive(scope: DeviceScope): boolean;
|
|
1145
|
+
/**
|
|
1146
|
+
* Interest filter (capability `sessionInterest`): whether this sink wants a
|
|
1147
|
+
* frame it is already permitted to receive. Connections that declared an
|
|
1148
|
+
* interest set drop other sessions' events here. Absent ⇒ wants everything.
|
|
1149
|
+
* The bridge consults it when sizing a replay window; sinks apply it again
|
|
1150
|
+
* on every push.
|
|
1151
|
+
*/
|
|
1152
|
+
accepts?(type: string, payload: unknown): boolean;
|
|
1125
1153
|
}
|
|
1126
1154
|
/**
|
|
1127
1155
|
* Offline push fan-out (F-9 离线推送). The bridge forwards every
|
|
@@ -1219,6 +1247,13 @@ export declare class HostBridge {
|
|
|
1219
1247
|
private readonly log?;
|
|
1220
1248
|
readonly id: number;
|
|
1221
1249
|
private summaries;
|
|
1250
|
+
/**
|
|
1251
|
+
* 列表推送节流(连接传输第二批 P5)。每个会话最近一次推出去的摘要指纹,
|
|
1252
|
+
* 用来丢掉内容完全相同的重复推送;只改了 `stats` 的变化按 SUMMARY_STATS_THROTTLE_MS
|
|
1253
|
+
* 合并,状态、标题、任务清单、待处理标记、活动描述的变化照旧立即推。
|
|
1254
|
+
*/
|
|
1255
|
+
private readonly summaryPushed;
|
|
1256
|
+
private readonly summaryStatsTimers;
|
|
1222
1257
|
private activeTools;
|
|
1223
1258
|
private approvals;
|
|
1224
1259
|
private questions;
|
|
@@ -1307,6 +1342,12 @@ export declare class HostBridge {
|
|
|
1307
1342
|
* reader without interactions.respond never gets the missed approval
|
|
1308
1343
|
* frames back (R1/P2).
|
|
1309
1344
|
*/
|
|
1345
|
+
/**
|
|
1346
|
+
* 按序补发游标之后的帧。先按权限与关注集合筛出该 sink 真正会收到的帧,
|
|
1347
|
+
* 估算字节数超过 REPLAY_RESYNC_BYTES 时改发 `s2c.resync`:补发是同步一次性
|
|
1348
|
+
* 写出的,经 Funnel 时插件这侧看不到积压,大补发会把之后所有请求响应堵在
|
|
1349
|
+
* 链路上(实测 450 帧堵 37 秒)。重新同步让客户端只拉列表和正在看的会话。
|
|
1350
|
+
*/
|
|
1310
1351
|
resumeFrom(cursor: number, target?: BridgeSink): boolean;
|
|
1311
1352
|
refreshLiveActivities(): void;
|
|
1312
1353
|
private record;
|
|
@@ -1345,6 +1386,8 @@ export declare class HostBridge {
|
|
|
1345
1386
|
private applyProjection;
|
|
1346
1387
|
private bumpPendingFlags;
|
|
1347
1388
|
private pushSummary;
|
|
1389
|
+
private noteSummaryPushed;
|
|
1390
|
+
private forgetSummaryPush;
|
|
1348
1391
|
listSessions(): SessionSummary[];
|
|
1349
1392
|
/**
|
|
1350
1393
|
* Archived rows from the last sessions.list, newest first. Served only on
|
|
@@ -1361,7 +1404,7 @@ export declare class HostBridge {
|
|
|
1361
1404
|
/** Resolve only the exact invocation in this session; never guess from the latest tool. */
|
|
1362
1405
|
private loadApprovalArguments;
|
|
1363
1406
|
/** Tail history for an opened session; pushes s2c.session.tail to the sink. */
|
|
1364
|
-
openSession(sink: BridgeSink, sessionId: string, tailCount: number): Promise<boolean>;
|
|
1407
|
+
openSession(sink: BridgeSink, sessionId: string, tailCount: number, sinceSeq?: number): Promise<boolean>;
|
|
1365
1408
|
/**
|
|
1366
1409
|
* Pull one session's projection baseline and fold every key
|
|
1367
1410
|
* through applyProjection — unknown keys are ignored there, `null` means the
|
package/lib/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomB
|
|
|
3
3
|
import { access, appendFile, chmod, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { WebSocketServer } from "ws";
|
|
6
|
+
import { deflateRawSync } from "node:zlib";
|
|
6
7
|
import { homedir, networkInterfaces } from "node:os";
|
|
7
8
|
import { closeSync, constants, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
8
9
|
import z from "@deepseek-ai/schemastery";
|
|
@@ -292,6 +293,23 @@ function sanitizeImageName(value) {
|
|
|
292
293
|
function sanitizeDocumentField(value, maxChars) {
|
|
293
294
|
return value.replace(/[\u0000-\u001F\u007F]/g, " ").trim().slice(0, maxChars);
|
|
294
295
|
}
|
|
296
|
+
/**
|
|
297
|
+
* 解析关注集合:字符串数组、去重、去空、超长 ID 丢弃,最多取前 16 个。
|
|
298
|
+
* 不合法时返回 undefined。auth.prove 上宁可退回全量广播也不因优化字段拒绝认证;
|
|
299
|
+
* c2s.session.watch 由行校验先拒绝非法载荷。
|
|
300
|
+
*/
|
|
301
|
+
function parseWatchSessions(value) {
|
|
302
|
+
if (!Array.isArray(value)) return void 0;
|
|
303
|
+
const ids = /* @__PURE__ */ new Set();
|
|
304
|
+
for (const item of value) {
|
|
305
|
+
if (typeof item !== "string") continue;
|
|
306
|
+
const id = item.trim();
|
|
307
|
+
if (id.length === 0 || id.length > 256) continue;
|
|
308
|
+
ids.add(id);
|
|
309
|
+
if (ids.size >= 16) break;
|
|
310
|
+
}
|
|
311
|
+
return [...ids];
|
|
312
|
+
}
|
|
295
313
|
/** 二进制模式下导出与附件的单块字节数。连接在发送缓冲降到 256 KiB 以下才发
|
|
296
314
|
* 下一块,缓冲峰值约 512 KiB,远低于 4 MiB 的慢客户端断开线。 */
|
|
297
315
|
const BINARY_CHUNK_BYTES = 262144;
|
|
@@ -305,6 +323,13 @@ function encodeBinaryFrame(header, body) {
|
|
|
305
323
|
frame.set(body, 5 + headerBytes.length);
|
|
306
324
|
return frame;
|
|
307
325
|
}
|
|
326
|
+
function encodeCompressedFrame(json) {
|
|
327
|
+
const deflated = deflateRawSync(Buffer.from(json, "utf8"), { level: 1 });
|
|
328
|
+
const frame = Buffer.allocUnsafe(1 + deflated.length);
|
|
329
|
+
frame.writeUInt8(2, 0);
|
|
330
|
+
deflated.copy(frame, 1);
|
|
331
|
+
return frame;
|
|
332
|
+
}
|
|
308
333
|
// src/auth-rate-limit.ts
|
|
309
334
|
const DEFAULT_AUTH_RATE_POLICY = {
|
|
310
335
|
windowMs: 6e4,
|
|
@@ -2229,7 +2254,12 @@ function withoutEditBodies(edit) {
|
|
|
2229
2254
|
function truncate(text, max) {
|
|
2230
2255
|
return text.length <= max ? text : text.slice(0, max - 1) + "…";
|
|
2231
2256
|
}
|
|
2232
|
-
|
|
2257
|
+
/**
|
|
2258
|
+
* `touched`(可选,增量 tail 用):记录每一行最后一次被哪个事件改动,键为行的 seq。
|
|
2259
|
+
* 行由自身事件生成时等于它的 seq;工具结果合并回较早的工具调用行时更新为结果事件的
|
|
2260
|
+
* seq。增量 tail 据此找出「sinceSeq 之后改动过的行」,而不只是 seq 更大的行。
|
|
2261
|
+
*/
|
|
2262
|
+
function projectHistory(events, touched) {
|
|
2233
2263
|
const messages = [];
|
|
2234
2264
|
const toolByCall = /* @__PURE__ */ new Map();
|
|
2235
2265
|
for (const entry of events) {
|
|
@@ -2238,6 +2268,7 @@ function projectHistory(events) {
|
|
|
2238
2268
|
seq: event.seq,
|
|
2239
2269
|
ts: tsOf(event)
|
|
2240
2270
|
};
|
|
2271
|
+
touched?.set(event.seq, Math.max(touched.get(event.seq) ?? event.seq, event.seq));
|
|
2241
2272
|
switch (event.type) {
|
|
2242
2273
|
case "system/message": break;
|
|
2243
2274
|
case "user/message":
|
|
@@ -2291,6 +2322,7 @@ function projectHistory(events) {
|
|
|
2291
2322
|
summary
|
|
2292
2323
|
};
|
|
2293
2324
|
if (attachments.attachments) Object.assign(target, attachments);
|
|
2325
|
+
touched?.set(target.seq, Math.max(touched.get(target.seq) ?? target.seq, event.seq));
|
|
2294
2326
|
} else messages.push({
|
|
2295
2327
|
...base,
|
|
2296
2328
|
role: "tool",
|
|
@@ -2611,12 +2643,13 @@ const sessionRows = [
|
|
|
2611
2643
|
const invalid = requireSessionId(checked.value);
|
|
2612
2644
|
if (invalid) return invalid;
|
|
2613
2645
|
if (!isOptionalField(checked.value, "tailCount", (v) => isInteger(v, 1, 1e4))) return reject$1("E_PROTOCOL", "invalid tailCount");
|
|
2646
|
+
if (!isOptionalField(checked.value, "sinceSeq", (v) => typeof v === "number" && Number.isFinite(v) && v >= 0)) return reject$1("E_PROTOCOL", "invalid sinceSeq");
|
|
2614
2647
|
return checked;
|
|
2615
2648
|
},
|
|
2616
2649
|
handle: async (ctx, payload) => {
|
|
2617
2650
|
const sessionId = payload.sessionId;
|
|
2618
2651
|
const buffered = ctx.bufferOpenEvents(sessionId);
|
|
2619
|
-
if (!await ctx.bridge.openSession(ctx, sessionId, payload.tailCount ?? 100)) {
|
|
2652
|
+
if (!await ctx.bridge.openSession(ctx, sessionId, payload.tailCount ?? 100, payload.sinceSeq)) {
|
|
2620
2653
|
ctx.discardOpenBuffer(sessionId, buffered);
|
|
2621
2654
|
return ctx.fail("E_NOT_FOUND", "session history unavailable");
|
|
2622
2655
|
}
|
|
@@ -2638,6 +2671,24 @@ const sessionRows = [
|
|
|
2638
2671
|
ctx.send("s2c.ack", {});
|
|
2639
2672
|
}
|
|
2640
2673
|
},
|
|
2674
|
+
{
|
|
2675
|
+
type: "c2s.session.watch",
|
|
2676
|
+
stage: "authenticated",
|
|
2677
|
+
scopes: READ,
|
|
2678
|
+
capability: "sessionInterest",
|
|
2679
|
+
doc: "PROTOCOL.md c2s.session.watch",
|
|
2680
|
+
validate: (payload) => {
|
|
2681
|
+
const checked = payloadObject(payload);
|
|
2682
|
+
if (!checked.ok) return checked;
|
|
2683
|
+
const ids = checked.value.sessionIds;
|
|
2684
|
+
if (!Array.isArray(ids) || ids.length > 16 || !ids.every((id) => isText(id, 256))) return reject$1("E_PROTOCOL", `sessionIds must be an array of at most 16 session ids`);
|
|
2685
|
+
return checked;
|
|
2686
|
+
},
|
|
2687
|
+
handle: (ctx, payload) => {
|
|
2688
|
+
ctx.setWatchedSessions(parseWatchSessions(payload.sessionIds) ?? []);
|
|
2689
|
+
ctx.send("s2c.ack", {});
|
|
2690
|
+
}
|
|
2691
|
+
},
|
|
2641
2692
|
{
|
|
2642
2693
|
type: "c2s.session.create",
|
|
2643
2694
|
stage: "authenticated",
|
|
@@ -4076,6 +4127,8 @@ var ConnectionGate = class {
|
|
|
4076
4127
|
scopes: this.scopes,
|
|
4077
4128
|
widgetClient: this.widgetClient,
|
|
4078
4129
|
resumeCursor: this.widgetClient ? void 0 : resumeCursor,
|
|
4130
|
+
watchSessions: this.widgetClient ? void 0 : parseWatchSessions(p.watchSessions),
|
|
4131
|
+
acceptsDeflate: Array.isArray(p.acceptEncoding) && p.acceptEncoding.includes("deflate-raw"),
|
|
4079
4132
|
deviceName,
|
|
4080
4133
|
appVersion
|
|
4081
4134
|
});
|
|
@@ -4130,6 +4183,7 @@ var ConnectionGate = class {
|
|
|
4130
4183
|
fail: (code, message) => host.fail(env.id, code, message),
|
|
4131
4184
|
close: (code, reason) => host.close(code, reason),
|
|
4132
4185
|
canReceive: (scope) => host.canReceive(scope),
|
|
4186
|
+
accepts: (type, payload) => host.accepts(type, payload),
|
|
4133
4187
|
push: (type, payload, seq) => host.push(type, payload, seq),
|
|
4134
4188
|
lastCursor: () => host.lastCursor(),
|
|
4135
4189
|
replay: (entries) => host.replay(entries),
|
|
@@ -4139,6 +4193,7 @@ var ConnectionGate = class {
|
|
|
4139
4193
|
discardOpenBuffer: (sessionId, buffer) => host.discardOpenBuffer(sessionId, buffer),
|
|
4140
4194
|
flushOpenBuffer: (sessionId, buffer) => host.flushOpenBuffer(sessionId, buffer),
|
|
4141
4195
|
closeOpenSession: (sessionId) => host.closeOpenSession(sessionId),
|
|
4196
|
+
setWatchedSessions: (sessionIds) => host.setWatchedSessions(sessionIds),
|
|
4142
4197
|
nextLiveActivityGeneration: () => host.nextLiveActivityGeneration(),
|
|
4143
4198
|
get liveActivityGeneration() {
|
|
4144
4199
|
return host.liveActivityGeneration;
|
|
@@ -4175,6 +4230,14 @@ var BridgeConnection = class {
|
|
|
4175
4230
|
*/
|
|
4176
4231
|
openingSessionEvents = /* @__PURE__ */ new Map();
|
|
4177
4232
|
openSessions = /* @__PURE__ */ new Set();
|
|
4233
|
+
/**
|
|
4234
|
+
* 关注集合(能力 `sessionInterest`):客户端本机有缓存、需要保持最新的会话。
|
|
4235
|
+
* undefined = 旧客户端从未声明,照旧收全部会话事件。来源:auth.prove 的
|
|
4236
|
+
* `watchSessions`(须在重放前生效)与之后的 c2s.session.watch(整体替换)。
|
|
4237
|
+
*/
|
|
4238
|
+
watchedSessions;
|
|
4239
|
+
/** 客户端认证时声明接受 deflate-raw(能力 `compressedFrames`);认证前一律文本帧。 */
|
|
4240
|
+
compressDownlink = false;
|
|
4178
4241
|
liveActivityRegistrationGeneration = 0;
|
|
4179
4242
|
pendingLiveActivityId;
|
|
4180
4243
|
lastActivity = Date.now();
|
|
@@ -4230,6 +4293,8 @@ var BridgeConnection = class {
|
|
|
4230
4293
|
fail: (id, code, message) => connection.fail(id, code, message),
|
|
4231
4294
|
close: (code, reason) => connection.close(code, reason),
|
|
4232
4295
|
canReceive: (scope) => connection.canReceive(scope),
|
|
4296
|
+
accepts: (type, payload) => connection.accepts(type, payload),
|
|
4297
|
+
setWatchedSessions: (sessionIds) => connection.setWatchedSessions(sessionIds),
|
|
4233
4298
|
push: (type, payload, seq) => connection.push(type, payload, seq),
|
|
4234
4299
|
lastCursor: () => connection.lastCursor(),
|
|
4235
4300
|
replay: (entries) => connection.replay(entries),
|
|
@@ -4315,7 +4380,23 @@ var BridgeConnection = class {
|
|
|
4315
4380
|
canReceive(scope) {
|
|
4316
4381
|
return this.identity?.scopes.has(scope) ?? false;
|
|
4317
4382
|
}
|
|
4383
|
+
/**
|
|
4384
|
+
* 关注过滤:只管按会话投递的两类帧。正在打开(tail 未发出、事件进缓冲)、
|
|
4385
|
+
* 已打开或在关注集合里的会话放行;未声明关注集合的连接全部放行。
|
|
4386
|
+
*/
|
|
4387
|
+
accepts(type, payload) {
|
|
4388
|
+
if (this.watchedSessions === void 0) return true;
|
|
4389
|
+
if (type !== "s2c.session.event" && type !== "s2c.session.queue") return true;
|
|
4390
|
+
const sessionId = payload?.sessionId;
|
|
4391
|
+
if (typeof sessionId !== "string") return true;
|
|
4392
|
+
return this.openSessions.has(sessionId) || this.openingSessionEvents.has(sessionId) || this.watchedSessions.has(sessionId);
|
|
4393
|
+
}
|
|
4394
|
+
/** c2s.session.watch:整体替换关注集合;也让从未声明的连接切到按关注投递。 */
|
|
4395
|
+
setWatchedSessions(sessionIds) {
|
|
4396
|
+
this.watchedSessions = new Set(sessionIds);
|
|
4397
|
+
}
|
|
4318
4398
|
push(type, payload, seq) {
|
|
4399
|
+
if (!this.accepts(type, payload)) return;
|
|
4319
4400
|
if (type === "s2c.notify") payload = {
|
|
4320
4401
|
...payload,
|
|
4321
4402
|
hostAudience: this.deps.audience
|
|
@@ -4352,8 +4433,11 @@ var BridgeConnection = class {
|
|
|
4352
4433
|
/** 认证成功:门只做安全判定,欢迎与重放归连接(数据面)。 */
|
|
4353
4434
|
onAuthenticated(identity) {
|
|
4354
4435
|
this.identity = identity;
|
|
4436
|
+
if (identity.watchSessions !== void 0) this.watchedSessions = new Set(identity.watchSessions);
|
|
4437
|
+
this.compressDownlink = identity.acceptsDeflate;
|
|
4355
4438
|
const cursor = identity.resumeCursor;
|
|
4356
4439
|
const canResume = cursor !== void 0 && this.deps.bridge.canResumeFrom(cursor);
|
|
4440
|
+
const routes = identity.widgetClient ? void 0 : this.deps.routes?.();
|
|
4357
4441
|
this.send("s2c.welcome", {
|
|
4358
4442
|
protocolVersion: 2,
|
|
4359
4443
|
serverVersion: this.deps.serverVersion,
|
|
@@ -4361,7 +4445,8 @@ var BridgeConnection = class {
|
|
|
4361
4445
|
scopes: [...identity.scopes],
|
|
4362
4446
|
capabilities: this.deps.bridge.capabilities,
|
|
4363
4447
|
cursor: this.deps.bridge.currentCursor(),
|
|
4364
|
-
resumed: canResume
|
|
4448
|
+
resumed: canResume,
|
|
4449
|
+
...routes !== void 0 ? { routes } : {}
|
|
4365
4450
|
});
|
|
4366
4451
|
if (!identity.widgetClient) this.deps.bridge.addSink(this);
|
|
4367
4452
|
if (cursor !== void 0) {
|
|
@@ -4411,7 +4496,16 @@ var BridgeConnection = class {
|
|
|
4411
4496
|
this.close(1013, "client too slow");
|
|
4412
4497
|
return;
|
|
4413
4498
|
}
|
|
4414
|
-
|
|
4499
|
+
if (body !== void 0) {
|
|
4500
|
+
this.ws.send(encodeBinaryFrame(envelope, body));
|
|
4501
|
+
return;
|
|
4502
|
+
}
|
|
4503
|
+
const json = JSON.stringify(envelope);
|
|
4504
|
+
if (this.compressDownlink && Buffer.byteLength(json) > 8192) {
|
|
4505
|
+
this.ws.send(encodeCompressedFrame(json));
|
|
4506
|
+
return;
|
|
4507
|
+
}
|
|
4508
|
+
this.ws.send(json);
|
|
4415
4509
|
}
|
|
4416
4510
|
fail(id, code, message) {
|
|
4417
4511
|
this.send("s2c.error", {
|
|
@@ -5495,7 +5589,9 @@ function pushContent(notification, mode) {
|
|
|
5495
5589
|
* Relay connectivity self-test used by the settings page "测试中继" button.
|
|
5496
5590
|
*
|
|
5497
5591
|
* Two steps mirror what zero-touch enrollment actually does:
|
|
5498
|
-
* 1. health — GET {url}/healthz → is the relay reachable?
|
|
5592
|
+
* 1. health — GET {url}/healthz → is the relay reachable? The
|
|
5593
|
+
* relay's `version` (relay 0.2.0+) is appended to the message for
|
|
5594
|
+
* troubleshooting; it never affects the verdict.
|
|
5499
5595
|
* 2. enroll — POST {url}/v1/enroll → does the shared key grant a token?
|
|
5500
5596
|
*
|
|
5501
5597
|
* The enroll step doubles as a repair path: a token it issues is handed back
|
|
@@ -5505,6 +5601,16 @@ function pushContent(notification, mode) {
|
|
|
5505
5601
|
* Transport is injectable (`fetchImpl`) so tests never touch the network.
|
|
5506
5602
|
*/
|
|
5507
5603
|
const DEFAULT_TIMEOUT_MS = 6e3;
|
|
5604
|
+
/**
|
|
5605
|
+
* semver-shaped characters only, bounded: the value comes from a remote
|
|
5606
|
+
* server and is rendered verbatim in the settings page.
|
|
5607
|
+
*/
|
|
5608
|
+
const RELAY_VERSION_PATTERN = /^[0-9A-Za-z.+-]{1,32}$/;
|
|
5609
|
+
/** Display-only relay version from a /healthz body; undefined for older relays or unsafe values. */
|
|
5610
|
+
function relayVersionOf(body) {
|
|
5611
|
+
const version = body?.version;
|
|
5612
|
+
return typeof version === "string" && RELAY_VERSION_PATTERN.test(version) ? version : void 0;
|
|
5613
|
+
}
|
|
5508
5614
|
async function requestJson(fetchImpl, url, init, timeoutMs) {
|
|
5509
5615
|
const response = await fetchImpl(url, {
|
|
5510
5616
|
...init,
|
|
@@ -5529,13 +5635,16 @@ async function runRelayProbe(options) {
|
|
|
5529
5635
|
const startedAt = Date.now();
|
|
5530
5636
|
const { status, body } = await requestJson(fetchImpl, `${base}/healthz`, { method: "GET" }, timeoutMs);
|
|
5531
5637
|
const latencyMs = Date.now() - startedAt;
|
|
5532
|
-
if (status === 200 && body?.ok === true)
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5638
|
+
if (status === 200 && body?.ok === true) {
|
|
5639
|
+
const version = relayVersionOf(body);
|
|
5640
|
+
const message = version ? `中继服务可达(版本 ${version})` : "中继服务可达";
|
|
5641
|
+
steps.push({
|
|
5642
|
+
id: "health",
|
|
5643
|
+
ok: true,
|
|
5644
|
+
message,
|
|
5645
|
+
latencyMs
|
|
5646
|
+
});
|
|
5647
|
+
} else steps.push({
|
|
5539
5648
|
id: "health",
|
|
5540
5649
|
ok: false,
|
|
5541
5650
|
message: `中继响应异常(HTTP ${status})`,
|
|
@@ -6279,7 +6388,7 @@ var TransportReconciler = class {
|
|
|
6279
6388
|
function createLocalTransport(deps) {
|
|
6280
6389
|
return new TransportReconciler({
|
|
6281
6390
|
keyOf: (config) => ({
|
|
6282
|
-
enabled: config.enabled === true
|
|
6391
|
+
enabled: config.enabled === true,
|
|
6283
6392
|
port: normalizeLocalPort(config.local?.port)
|
|
6284
6393
|
}),
|
|
6285
6394
|
skipWhenDisabled: true,
|
|
@@ -6347,7 +6456,7 @@ function createLocalTransport(deps) {
|
|
|
6347
6456
|
};
|
|
6348
6457
|
}
|
|
6349
6458
|
}, deps.config, deps.log, {
|
|
6350
|
-
phase: deps.config().enabled === true
|
|
6459
|
+
phase: deps.config().enabled === true ? "starting" : "disabled",
|
|
6351
6460
|
port: normalizeLocalPort(deps.config().local?.port),
|
|
6352
6461
|
endpoints: [],
|
|
6353
6462
|
updatedAt: Date.now()
|
|
@@ -7166,6 +7275,10 @@ function capabilityBits(proxy, pushOutlet) {
|
|
|
7166
7275
|
historyPaging: true,
|
|
7167
7276
|
replay: true,
|
|
7168
7277
|
binaryFrames: true,
|
|
7278
|
+
sessionInterest: true,
|
|
7279
|
+
compressedFrames: true,
|
|
7280
|
+
incrementalTail: true,
|
|
7281
|
+
routeHints: true,
|
|
7169
7282
|
approvals: true,
|
|
7170
7283
|
questions: true,
|
|
7171
7284
|
pendingSnapshot: true,
|
|
@@ -7443,6 +7556,10 @@ var AssistantStream = class {
|
|
|
7443
7556
|
};
|
|
7444
7557
|
// src/host-bridge.ts
|
|
7445
7558
|
const MAX_RING_DEFAULT = 2e3;
|
|
7559
|
+
/** 补发量超过它就改发 s2c.resync(经 Funnel 实测吞吐 40~80 KB/s,约 6~12 秒)。 */
|
|
7560
|
+
const REPLAY_RESYNC_BYTES = 524288;
|
|
7561
|
+
/** 只改了用量统计的列表推送,同一会话这段时间内最多推一次(第二批 P5)。 */
|
|
7562
|
+
const SUMMARY_STATS_THROTTLE_MS = 5e3;
|
|
7446
7563
|
/**
|
|
7447
7564
|
* Process-wide bridge state: session mirror, pending approvals/questions,
|
|
7448
7565
|
* and the per-device replay ring. Consumes the in-process mux/host streams
|
|
@@ -7455,6 +7572,13 @@ var HostBridge = class {
|
|
|
7455
7572
|
log;
|
|
7456
7573
|
id = ++BRIDGE_SEQ;
|
|
7457
7574
|
summaries = /* @__PURE__ */ new Map();
|
|
7575
|
+
/**
|
|
7576
|
+
* 列表推送节流(连接传输第二批 P5)。每个会话最近一次推出去的摘要指纹,
|
|
7577
|
+
* 用来丢掉内容完全相同的重复推送;只改了 `stats` 的变化按 SUMMARY_STATS_THROTTLE_MS
|
|
7578
|
+
* 合并,状态、标题、任务清单、待处理标记、活动描述的变化照旧立即推。
|
|
7579
|
+
*/
|
|
7580
|
+
summaryPushed = /* @__PURE__ */ new Map();
|
|
7581
|
+
summaryStatsTimers = /* @__PURE__ */ new Map();
|
|
7458
7582
|
activeTools = /* @__PURE__ */ new Map();
|
|
7459
7583
|
approvals = /* @__PURE__ */ new Map();
|
|
7460
7584
|
questions = /* @__PURE__ */ new Map();
|
|
@@ -7647,16 +7771,35 @@ var HostBridge = class {
|
|
|
7647
7771
|
* reader without interactions.respond never gets the missed approval
|
|
7648
7772
|
* frames back (R1/P2).
|
|
7649
7773
|
*/
|
|
7774
|
+
/**
|
|
7775
|
+
* 按序补发游标之后的帧。先按权限与关注集合筛出该 sink 真正会收到的帧,
|
|
7776
|
+
* 估算字节数超过 REPLAY_RESYNC_BYTES 时改发 `s2c.resync`:补发是同步一次性
|
|
7777
|
+
* 写出的,经 Funnel 时插件这侧看不到积压,大补发会把之后所有请求响应堵在
|
|
7778
|
+
* 链路上(实测 450 帧堵 37 秒)。重新同步让客户端只拉列表和正在看的会话。
|
|
7779
|
+
*/
|
|
7650
7780
|
resumeFrom(cursor, target) {
|
|
7651
7781
|
const oldest = this.ring.length > 0 ? this.ring[0].seq : this.cursor + 1;
|
|
7652
7782
|
if (cursor + 1 < oldest) return false;
|
|
7653
7783
|
const receivers = target !== void 0 ? [target] : [...this.sinks];
|
|
7654
|
-
for (const
|
|
7655
|
-
const
|
|
7656
|
-
|
|
7657
|
-
|
|
7784
|
+
for (const sink of receivers) {
|
|
7785
|
+
const window = [];
|
|
7786
|
+
let bytes = 0;
|
|
7787
|
+
for (const entry of this.ring) {
|
|
7788
|
+
if (entry.seq <= cursor) continue;
|
|
7789
|
+
const need = pushScopeFor(entry.type, entry.payload);
|
|
7790
|
+
if (need === void 0 || !sink.canReceive(need)) continue;
|
|
7791
|
+
if (sink.accepts && !sink.accepts(entry.type, entry.payload)) continue;
|
|
7792
|
+
window.push(entry);
|
|
7793
|
+
bytes += Buffer.byteLength(JSON.stringify(entry.payload) ?? "");
|
|
7794
|
+
}
|
|
7795
|
+
if (bytes > 524288) {
|
|
7796
|
+
this.log?.(`resume window ${bytes}B over ${REPLAY_RESYNC_BYTES}B; sending resync`);
|
|
7797
|
+
sink.resync();
|
|
7798
|
+
continue;
|
|
7799
|
+
}
|
|
7800
|
+
for (const entry of window) sink.replay([entry]);
|
|
7801
|
+
sink.replayDone();
|
|
7658
7802
|
}
|
|
7659
|
-
for (const sink of receivers) sink.replayDone();
|
|
7660
7803
|
return true;
|
|
7661
7804
|
}
|
|
7662
7805
|
refreshLiveActivities() {
|
|
@@ -7745,6 +7888,8 @@ var HostBridge = class {
|
|
|
7745
7888
|
if (this.disposed) return;
|
|
7746
7889
|
this.disposed = true;
|
|
7747
7890
|
this.abort.abort();
|
|
7891
|
+
for (const timer of this.summaryStatsTimers.values()) clearTimeout(timer);
|
|
7892
|
+
this.summaryStatsTimers.clear();
|
|
7748
7893
|
this.assistantStream.dispose();
|
|
7749
7894
|
this.sinks.clear();
|
|
7750
7895
|
this.sinkSessions.clear();
|
|
@@ -7999,6 +8144,8 @@ var HostBridge = class {
|
|
|
7999
8144
|
this.archivedSummaries = nextArchived;
|
|
8000
8145
|
const removedIds = [...previousIds].filter((id) => !next.has(id));
|
|
8001
8146
|
for (const id of this.lastAssistantText.keys()) if (!next.has(id)) this.lastAssistantText.delete(id);
|
|
8147
|
+
for (const id of removedIds) this.forgetSummaryPush(id);
|
|
8148
|
+
for (const row of next.values()) this.noteSummaryPushed(row);
|
|
8002
8149
|
this.record("s2c.sessions.delta", {
|
|
8003
8150
|
upserted: [...next.values()],
|
|
8004
8151
|
removedIds
|
|
@@ -8118,11 +8265,62 @@ var HostBridge = class {
|
|
|
8118
8265
|
this.pushSummary(row);
|
|
8119
8266
|
}
|
|
8120
8267
|
pushSummary(row) {
|
|
8268
|
+
const full = JSON.stringify(row);
|
|
8269
|
+
const previous = this.summaryPushed.get(row.id);
|
|
8270
|
+
if (previous?.full === full) return;
|
|
8271
|
+
const withoutStats = JSON.stringify({
|
|
8272
|
+
...row,
|
|
8273
|
+
stats: void 0
|
|
8274
|
+
});
|
|
8275
|
+
const now = Date.now();
|
|
8276
|
+
if (previous !== void 0 && previous.withoutStats === withoutStats && now - previous.at < 5e3) {
|
|
8277
|
+
if (!this.summaryStatsTimers.has(row.id)) {
|
|
8278
|
+
const timer = setTimeout(() => {
|
|
8279
|
+
this.summaryStatsTimers.delete(row.id);
|
|
8280
|
+
const latest = this.summaries.get(row.id);
|
|
8281
|
+
if (latest && !this.disposed) this.pushSummary(latest);
|
|
8282
|
+
}, SUMMARY_STATS_THROTTLE_MS - (now - previous.at));
|
|
8283
|
+
timer.unref?.();
|
|
8284
|
+
this.summaryStatsTimers.set(row.id, timer);
|
|
8285
|
+
}
|
|
8286
|
+
return;
|
|
8287
|
+
}
|
|
8288
|
+
const pending = this.summaryStatsTimers.get(row.id);
|
|
8289
|
+
if (pending !== void 0) {
|
|
8290
|
+
clearTimeout(pending);
|
|
8291
|
+
this.summaryStatsTimers.delete(row.id);
|
|
8292
|
+
}
|
|
8293
|
+
this.summaryPushed.set(row.id, {
|
|
8294
|
+
full,
|
|
8295
|
+
withoutStats,
|
|
8296
|
+
at: now
|
|
8297
|
+
});
|
|
8121
8298
|
this.record("s2c.sessions.delta", {
|
|
8122
8299
|
upserted: [row],
|
|
8123
8300
|
removedIds: []
|
|
8124
8301
|
});
|
|
8125
8302
|
}
|
|
8303
|
+
noteSummaryPushed(row) {
|
|
8304
|
+
const pending = this.summaryStatsTimers.get(row.id);
|
|
8305
|
+
if (pending !== void 0) {
|
|
8306
|
+
clearTimeout(pending);
|
|
8307
|
+
this.summaryStatsTimers.delete(row.id);
|
|
8308
|
+
}
|
|
8309
|
+
this.summaryPushed.set(row.id, {
|
|
8310
|
+
full: JSON.stringify(row),
|
|
8311
|
+
withoutStats: JSON.stringify({
|
|
8312
|
+
...row,
|
|
8313
|
+
stats: void 0
|
|
8314
|
+
}),
|
|
8315
|
+
at: Date.now()
|
|
8316
|
+
});
|
|
8317
|
+
}
|
|
8318
|
+
forgetSummaryPush(sessionId) {
|
|
8319
|
+
const pending = this.summaryStatsTimers.get(sessionId);
|
|
8320
|
+
if (pending !== void 0) clearTimeout(pending);
|
|
8321
|
+
this.summaryStatsTimers.delete(sessionId);
|
|
8322
|
+
this.summaryPushed.delete(sessionId);
|
|
8323
|
+
}
|
|
8126
8324
|
listSessions() {
|
|
8127
8325
|
return [...this.summaries.values()].sort((a, b) => b.lastActivityTs - a.lastActivityTs);
|
|
8128
8326
|
}
|
|
@@ -8186,7 +8384,7 @@ var HostBridge = class {
|
|
|
8186
8384
|
} catch {}
|
|
8187
8385
|
}
|
|
8188
8386
|
/** Tail history for an opened session; pushes s2c.session.tail to the sink. */
|
|
8189
|
-
async openSession(sink, sessionId, tailCount) {
|
|
8387
|
+
async openSession(sink, sessionId, tailCount, sinceSeq) {
|
|
8190
8388
|
try {
|
|
8191
8389
|
const response = await this.apiProxy.sessions.history({
|
|
8192
8390
|
rpcId: randomUUID(),
|
|
@@ -8197,14 +8395,26 @@ var HostBridge = class {
|
|
|
8197
8395
|
});
|
|
8198
8396
|
if (!response.result || !response.result.ok) return false;
|
|
8199
8397
|
const result = response.result.value;
|
|
8200
|
-
const
|
|
8398
|
+
const events = result.events ?? [];
|
|
8399
|
+
const touched = /* @__PURE__ */ new Map();
|
|
8400
|
+
const projected = projectHistory(events, touched);
|
|
8401
|
+
const page = limitSessionPageMessages(projected);
|
|
8201
8402
|
const messages = page.messages;
|
|
8202
8403
|
const oldestSeq = messages.length > 0 ? messages[0].seq : 0;
|
|
8203
|
-
|
|
8404
|
+
const hasMore = Boolean(result.hasMore) || page.dropped > 0;
|
|
8405
|
+
const delta = sinceSeq === void 0 ? void 0 : incrementalTail(events, projected, touched, sinceSeq);
|
|
8406
|
+
sink.push("s2c.session.tail", delta === void 0 ? {
|
|
8204
8407
|
sessionId,
|
|
8205
8408
|
messages,
|
|
8206
8409
|
oldestSeq,
|
|
8207
|
-
hasMore
|
|
8410
|
+
hasMore
|
|
8411
|
+
} : {
|
|
8412
|
+
sessionId,
|
|
8413
|
+
messages: delta,
|
|
8414
|
+
oldestSeq,
|
|
8415
|
+
hasMore,
|
|
8416
|
+
incremental: true,
|
|
8417
|
+
sinceSeq
|
|
8208
8418
|
});
|
|
8209
8419
|
this.deriveTitleFallback(sessionId, messages);
|
|
8210
8420
|
this.refreshProjections(sessionId).then(() => this.pushQueueSnapshot(sink, sessionId));
|
|
@@ -8487,6 +8697,7 @@ var HostBridge = class {
|
|
|
8487
8697
|
this.archivedSessionIds = new Set((response.result.value.archivedSessionIds ?? []).map(String));
|
|
8488
8698
|
this.summaries.delete(sessionId);
|
|
8489
8699
|
this.lastAssistantText.delete(sessionId);
|
|
8700
|
+
this.forgetSummaryPush(sessionId);
|
|
8490
8701
|
this.record("s2c.sessions.delta", {
|
|
8491
8702
|
upserted: [],
|
|
8492
8703
|
removedIds: [sessionId]
|
|
@@ -9769,6 +9980,25 @@ function receiptFailureReason(receipt) {
|
|
|
9769
9980
|
* 区间与 wire 行校验(tailCount 1..10000、limit 1..500)共用投影 module 的
|
|
9770
9981
|
* 常量——「一页能要多少条消息」本是一条规则,原先散在三处。
|
|
9771
9982
|
*/
|
|
9983
|
+
/**
|
|
9984
|
+
* 增量 tail(能力 `incrementalTail`):返回 sinceSeq 之后改动过的行;不能保证完整时
|
|
9985
|
+
* 返回 undefined,调用方回退全量。三种回退:
|
|
9986
|
+
* - 窗口没覆盖到 sinceSeq(最旧事件比它新):中间可能有事件没取到;
|
|
9987
|
+
* - sinceSeq 比窗口里最新事件还新:客户端的视图和主机对不上(例如历史被改写);
|
|
9988
|
+
* - 增量本身超过单页 900 KB 预算。
|
|
9989
|
+
*/
|
|
9990
|
+
function incrementalTail(events, projected, touched, sinceSeq) {
|
|
9991
|
+
if (events.length === 0) return void 0;
|
|
9992
|
+
let oldest = Infinity;
|
|
9993
|
+
let newest = -Infinity;
|
|
9994
|
+
for (const { event } of events) {
|
|
9995
|
+
if (event.seq < oldest) oldest = event.seq;
|
|
9996
|
+
if (event.seq > newest) newest = event.seq;
|
|
9997
|
+
}
|
|
9998
|
+
if (oldest > sinceSeq || sinceSeq > newest) return void 0;
|
|
9999
|
+
const limited = limitSessionPageMessages(projected.filter((message) => (touched.get(message.seq) ?? message.seq) > sinceSeq));
|
|
10000
|
+
return limited.dropped > 0 ? void 0 : limited.messages;
|
|
10001
|
+
}
|
|
9772
10002
|
function clampTail(n) {
|
|
9773
10003
|
if (!Number.isFinite(n)) return 100;
|
|
9774
10004
|
return Math.max(10, Math.min(500, Math.floor(n)));
|
|
@@ -12091,6 +12321,25 @@ function requestClientIdentity(req) {
|
|
|
12091
12321
|
}
|
|
12092
12322
|
return normalizedAddress(req.socket.remoteAddress) ?? "unknown";
|
|
12093
12323
|
}
|
|
12324
|
+
// src/welcome-routes.ts
|
|
12325
|
+
/**
|
|
12326
|
+
* welcome.routes(能力 routeHints)的纯计算:局域网监听在线且有指纹时给 RFC1918 地址
|
|
12327
|
+
* (顺序沿用 localLANIPv4Addresses 的物理网卡优先),Funnel 在线时给公网地址;两者都没有
|
|
12328
|
+
* 时返回 undefined,welcome 不带该字段。
|
|
12329
|
+
*/
|
|
12330
|
+
function welcomeRoutes(local, remote, addresses) {
|
|
12331
|
+
const lanAddresses = addresses.filter(isPrivateIPv4);
|
|
12332
|
+
const lan = local.phase === "online" && local.tlsFingerprint && lanAddresses.length > 0 ? {
|
|
12333
|
+
urls: localEndpointURLs(lanAddresses, local.port),
|
|
12334
|
+
tlsFingerprint: local.tlsFingerprint
|
|
12335
|
+
} : void 0;
|
|
12336
|
+
const remoteURL = remote.phase === "online" ? remote.publicURL : void 0;
|
|
12337
|
+
if (lan === void 0 && remoteURL === void 0) return void 0;
|
|
12338
|
+
return {
|
|
12339
|
+
...lan ? { lan } : {},
|
|
12340
|
+
...remoteURL ? { remote: { url: remoteURL } } : {}
|
|
12341
|
+
};
|
|
12342
|
+
}
|
|
12094
12343
|
// src/lan-tls.ts
|
|
12095
12344
|
const LAN_TLS_COMMON_NAME = "dsh-deeppilot";
|
|
12096
12345
|
const LAN_TLS_KEY_FILE = "key.pem";
|
|
@@ -12733,6 +12982,7 @@ function apply(ctx, options) {
|
|
|
12733
12982
|
try {
|
|
12734
12983
|
const connection = new BridgeConnection(ws, {
|
|
12735
12984
|
bridge,
|
|
12985
|
+
routes: phoneRoutes,
|
|
12736
12986
|
devices,
|
|
12737
12987
|
serverVersion: SERVER_VERSION,
|
|
12738
12988
|
audience,
|
|
@@ -12821,6 +13071,12 @@ function apply(ctx, options) {
|
|
|
12821
13071
|
dataDir,
|
|
12822
13072
|
log
|
|
12823
13073
|
});
|
|
13074
|
+
/**
|
|
13075
|
+
* welcome.routes(能力 routeHints):手机据此在同一 Wi-Fi 下先走局域网、在外走 Funnel。
|
|
13076
|
+
* 局域网只给 RFC1918 地址(不含 tailnet 的 100.64/10:手机未入 tailnet 时只会白等超时),
|
|
13077
|
+
* 物理网卡优先;Funnel 只在 online 时给。每次 welcome 现算:地址与 Funnel 状态都会变。
|
|
13078
|
+
*/
|
|
13079
|
+
const phoneRoutes = () => welcomeRoutes(localTransport.status(), remoteTransport.status(), localLANIPv4Addresses());
|
|
12824
13080
|
const localStatus = (addresses) => {
|
|
12825
13081
|
const state = localTransport.status();
|
|
12826
13082
|
return {
|