dsh-deeppilot 0.9.9 → 0.9.13
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/COMPATIBILITY.md +141 -7
- package/README.md +19 -7
- package/README.zh-CN.md +13 -6
- 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/DSH_022_FEATURES.md +43 -0
- package/docs/DSH_RELEASE_MEMORY.md +403 -9
- package/docs/NPM_PUBLISHING.md +39 -0
- package/lib/client.js +436 -250
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +81 -3
- package/lib/index.js +1203 -36
- package/lib/index.js.map +1 -1
- package/package.json +19 -14
package/lib/index.js
CHANGED
|
@@ -4,9 +4,9 @@ import { access, appendFile, chmod, mkdir, readFile, readdir, rename, rm, stat,
|
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { WebSocketServer } from "ws";
|
|
6
6
|
import { homedir, networkInterfaces } from "node:os";
|
|
7
|
+
import { closeSync, constants, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
7
8
|
import z from "@deepseek-ai/schemastery";
|
|
8
9
|
import { spawn } from "node:child_process";
|
|
9
|
-
import { closeSync, constants, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import { connect } from "node:http2";
|
|
12
12
|
import { createServer } from "node:http";
|
|
@@ -41,7 +41,8 @@ const ERROR_CODES = {
|
|
|
41
41
|
E_NOT_FOUND: "session or request not found",
|
|
42
42
|
E_BUSY: "session is busy",
|
|
43
43
|
E_UNSUPPORTED: "protocol version or capability unsupported",
|
|
44
|
-
E_INTERNAL: "internal error"
|
|
44
|
+
E_INTERNAL: "internal error",
|
|
45
|
+
E_STEER_UNAVAILABLE: "queued item can no longer steer the current turn"
|
|
45
46
|
};
|
|
46
47
|
/** Bridge 未携带 code 的结果(内部守卫失败等)落到这一档。 */
|
|
47
48
|
const WIRE_ERROR_FALLBACK = "E_INTERNAL";
|
|
@@ -116,6 +117,13 @@ const DOMAIN_TABLES = {
|
|
|
116
117
|
"session-not-found": "E_NOT_FOUND",
|
|
117
118
|
"agent-busy": "E_BUSY",
|
|
118
119
|
"session-conflict": "E_BUSY"
|
|
120
|
+
},
|
|
121
|
+
queue: {
|
|
122
|
+
"session/queue-item-not-found": "E_NOT_FOUND",
|
|
123
|
+
"session/steer-unavailable": "E_STEER_UNAVAILABLE",
|
|
124
|
+
"session/agent-busy": "E_BUSY",
|
|
125
|
+
"session/writer-held": "E_BUSY",
|
|
126
|
+
"gateway/bad-request": "E_PROTOCOL"
|
|
119
127
|
}
|
|
120
128
|
};
|
|
121
129
|
/**
|
|
@@ -249,6 +257,10 @@ function isEnvelope(value) {
|
|
|
249
257
|
*/
|
|
250
258
|
const PUSH_SCOPE_BY_TYPE = {
|
|
251
259
|
"s2c.session.event": "sessions.read",
|
|
260
|
+
"s2c.session.delta": "sessions.read",
|
|
261
|
+
"s2c.plugin.update.status": "sessions.read",
|
|
262
|
+
"s2c.plugin.update.progress": "sessions.manage",
|
|
263
|
+
"s2c.session.queue": "sessions.read",
|
|
252
264
|
"s2c.skills.changed": "sessions.read",
|
|
253
265
|
"s2c.commands.changed": "sessions.read",
|
|
254
266
|
"s2c.models.changed": "sessions.read",
|
|
@@ -2560,6 +2572,7 @@ const sessionRows = [
|
|
|
2560
2572
|
if (invalid) return invalid;
|
|
2561
2573
|
const p = checked.value;
|
|
2562
2574
|
if (p.clientSendId !== void 0 && !validSendId(p.clientSendId)) return reject$1("E_PROTOCOL", "invalid clientSendId");
|
|
2575
|
+
if (p.mode !== void 0 && p.mode !== "queue" && p.mode !== "steer") return reject$1("E_PROTOCOL", "invalid prompt mode");
|
|
2563
2576
|
if (!isOptionalField(p, "text", (v) => isText(v, 262144, false)) || !isOptionalField(p, "images", Array.isArray) || !isOptionalField(p, "documents", Array.isArray)) return reject$1("E_PROTOCOL", "invalid prompt fields");
|
|
2564
2577
|
for (const key of ["images", "documents"]) {
|
|
2565
2578
|
const items = p[key];
|
|
@@ -2607,6 +2620,7 @@ const sessionRows = [
|
|
|
2607
2620
|
text,
|
|
2608
2621
|
images,
|
|
2609
2622
|
documents,
|
|
2623
|
+
...p.mode === "steer" ? { mode: "steer" } : {},
|
|
2610
2624
|
...p.clientSendId !== void 0 ? { clientSendId: p.clientSendId } : {}
|
|
2611
2625
|
});
|
|
2612
2626
|
},
|
|
@@ -2616,22 +2630,56 @@ const sessionRows = [
|
|
|
2616
2630
|
const images = payload.images;
|
|
2617
2631
|
const documents = payload.documents;
|
|
2618
2632
|
const clientSendId = payload.clientSendId;
|
|
2633
|
+
const steer = payload.mode === "steer";
|
|
2634
|
+
if (steer && ctx.bridge.capabilities.promptQueue !== true) return ctx.fail("E_UNSUPPORTED", "prompt queue not supported");
|
|
2635
|
+
const mode = steer ? "steer" : "queue";
|
|
2619
2636
|
if (clientSendId !== void 0) {
|
|
2620
2637
|
const receipt = await ctx.bridge.promptDeliveries.dispatch(ctx.deviceId, clientSendId, {
|
|
2621
2638
|
sessionId,
|
|
2622
2639
|
content: {
|
|
2623
2640
|
text,
|
|
2624
2641
|
images,
|
|
2625
|
-
documents
|
|
2642
|
+
documents,
|
|
2643
|
+
...steer ? { mode } : {}
|
|
2626
2644
|
}
|
|
2627
|
-
}, () => ctx.bridge.sendPrompt(sessionId, text, images, documents
|
|
2645
|
+
}, () => ctx.bridge.sendPrompt(sessionId, text, images, documents, {
|
|
2646
|
+
mode,
|
|
2647
|
+
clientSendId
|
|
2648
|
+
}));
|
|
2628
2649
|
ctx.send("s2c.ack", receipt);
|
|
2629
2650
|
return;
|
|
2630
2651
|
}
|
|
2631
|
-
const userSeq = await ctx.bridge.sendPrompt(sessionId, text, images, documents);
|
|
2652
|
+
const userSeq = await ctx.bridge.sendPrompt(sessionId, text, images, documents, { mode });
|
|
2632
2653
|
if (!userSeq.ok) return ctx.fail(userSeq.code, userSeq.message);
|
|
2633
2654
|
ctx.send("s2c.ack", { userSeq: userSeq.value });
|
|
2634
2655
|
}
|
|
2656
|
+
},
|
|
2657
|
+
{
|
|
2658
|
+
type: "c2s.session.queue.update",
|
|
2659
|
+
stage: "authenticated",
|
|
2660
|
+
scopes: ["prompt.send"],
|
|
2661
|
+
capability: "promptQueue",
|
|
2662
|
+
doc: "PROTOCOL.md c2s.session.queue.update",
|
|
2663
|
+
validate: (payload) => {
|
|
2664
|
+
const checked = payloadObject(payload);
|
|
2665
|
+
if (!checked.ok) return checked;
|
|
2666
|
+
const invalid = requireSessionId(checked.value);
|
|
2667
|
+
if (invalid) return invalid;
|
|
2668
|
+
if (!isText(checked.value.itemId, 256)) return reject$1("E_PROTOCOL", "invalid itemId");
|
|
2669
|
+
const action = checked.value.action;
|
|
2670
|
+
if (action === "edit") return reject$1("E_UNSUPPORTED", "queue edit not supported");
|
|
2671
|
+
if (action !== "remove" && action !== "steer") return reject$1("E_PROTOCOL", "invalid queue action");
|
|
2672
|
+
return accept({
|
|
2673
|
+
sessionId: checked.value.sessionId,
|
|
2674
|
+
itemId: checked.value.itemId,
|
|
2675
|
+
action
|
|
2676
|
+
});
|
|
2677
|
+
},
|
|
2678
|
+
handle: async (ctx, payload) => {
|
|
2679
|
+
const result = await ctx.bridge.updateQueue(payload.sessionId, payload.itemId, payload.action);
|
|
2680
|
+
if (!result.ok) return ctx.fail(result.code, result.message);
|
|
2681
|
+
ctx.send("s2c.ack", result.value);
|
|
2682
|
+
}
|
|
2635
2683
|
}
|
|
2636
2684
|
];
|
|
2637
2685
|
//#endregion
|
|
@@ -2701,16 +2749,24 @@ const commandRows = [...[
|
|
|
2701
2749
|
stage: "authenticated",
|
|
2702
2750
|
scopes: ["sessions.read"],
|
|
2703
2751
|
capability: "commands",
|
|
2752
|
+
conditionalCapabilities: ["sessionMarkdown"],
|
|
2704
2753
|
doc: "PROTOCOL.md c2s.session.export",
|
|
2705
|
-
validate:
|
|
2754
|
+
validate: (value) => {
|
|
2755
|
+
const result = sessionPayload(value);
|
|
2756
|
+
if (!result.ok) return result;
|
|
2757
|
+
if (result.value.format !== void 0 && result.value.format !== "zip" && result.value.format !== "markdown") return reject$1("E_PROTOCOL", "导出格式无效");
|
|
2758
|
+
return result;
|
|
2759
|
+
},
|
|
2706
2760
|
handle: async (ctx, payload) => {
|
|
2761
|
+
const format = payload.format;
|
|
2762
|
+
if (format === "markdown" && ctx.bridge.capabilities.sessionMarkdown !== true) return ctx.fail("E_UNSUPPORTED", "当前主机不支持导出对话正文");
|
|
2707
2763
|
const signal = AbortSignal.any([ctx.signal ?? new AbortController().signal, AbortSignal.timeout(6e5)]);
|
|
2708
2764
|
let reader;
|
|
2709
2765
|
const cancel = () => {
|
|
2710
2766
|
reader?.cancel().catch(() => {});
|
|
2711
2767
|
};
|
|
2712
2768
|
try {
|
|
2713
|
-
reader = (await ctx.bridge.exportSession(payload.sessionId, signal)).body.getReader();
|
|
2769
|
+
reader = (format === void 0 ? await ctx.bridge.exportSession(payload.sessionId, signal) : await ctx.bridge.exportSession(payload.sessionId, signal, format)).body.getReader();
|
|
2714
2770
|
signal.addEventListener("abort", cancel, { once: true });
|
|
2715
2771
|
let index = 0, bytes = 0;
|
|
2716
2772
|
while (true) {
|
|
@@ -2730,9 +2786,10 @@ const commandRows = [...[
|
|
|
2730
2786
|
signal.throwIfAborted();
|
|
2731
2787
|
ctx.send("s2c.ack", {
|
|
2732
2788
|
sessionId: payload.sessionId,
|
|
2733
|
-
fileName: "dsh-session-log.zip",
|
|
2789
|
+
fileName: format === "markdown" ? "dsh-session.md" : "dsh-session-log.zip",
|
|
2734
2790
|
chunks: index,
|
|
2735
|
-
bytes
|
|
2791
|
+
bytes,
|
|
2792
|
+
...format === void 0 ? {} : { mediaType: format === "markdown" ? "text/markdown; charset=utf-8" : "application/zip" }
|
|
2736
2793
|
});
|
|
2737
2794
|
} catch (error) {
|
|
2738
2795
|
const failure = hostFailureOf(error, "command/export-cancelled");
|
|
@@ -2749,6 +2806,570 @@ const commandRows = [...[
|
|
|
2749
2806
|
}
|
|
2750
2807
|
}
|
|
2751
2808
|
}];
|
|
2809
|
+
//#endregion
|
|
2810
|
+
//#region src/plugin-update.ts
|
|
2811
|
+
const PACKAGE = "dsh-deeppilot";
|
|
2812
|
+
const PHASES = [
|
|
2813
|
+
"preparing",
|
|
2814
|
+
"installing",
|
|
2815
|
+
"verifying",
|
|
2816
|
+
"restart-required",
|
|
2817
|
+
"done",
|
|
2818
|
+
"rolled-back",
|
|
2819
|
+
"failed"
|
|
2820
|
+
];
|
|
2821
|
+
const CELLS = Symbol.for("deeppilot.plugin-update.cells.v1");
|
|
2822
|
+
const globals = globalThis;
|
|
2823
|
+
const cells = globals[CELLS] ??= /* @__PURE__ */ new Map();
|
|
2824
|
+
const ownerKey = (deviceId) => createHash("sha256").update(deviceId).digest("hex");
|
|
2825
|
+
const validVersion = (value) => typeof value === "string" && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value) && value.length <= 128;
|
|
2826
|
+
const validUpdateRequestId = (value) => typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value);
|
|
2827
|
+
const terminal = (phase) => [
|
|
2828
|
+
"done",
|
|
2829
|
+
"failed",
|
|
2830
|
+
"rolled-back",
|
|
2831
|
+
"restart-required"
|
|
2832
|
+
].includes(phase);
|
|
2833
|
+
const publicRecord = ({ ownerKey: _owner, restartVersion: _restartVersion, restartPhase: _restartPhase, restartBoot: _restartBoot, ...record }) => ({ ...record });
|
|
2834
|
+
var PluginUpdateError = class extends Error {
|
|
2835
|
+
code;
|
|
2836
|
+
constructor(code, message) {
|
|
2837
|
+
super(message);
|
|
2838
|
+
this.code = code;
|
|
2839
|
+
}
|
|
2840
|
+
};
|
|
2841
|
+
/** 精确镜像 alpha2 公开 registryPlan,避免静态依赖让旧宿主无法加载插件;由真实函数对照测试守卫。 */
|
|
2842
|
+
function updateRegistryPlan(requested, configured) {
|
|
2843
|
+
const own = configured.resolved === null ? null : normalizeUpdateRegistry(configured.resolved);
|
|
2844
|
+
const fallbacks = configured.fallbackRegistries.map(normalizeUpdateRegistry);
|
|
2845
|
+
const ownIsPublic = own !== null && (own === "https://registry.npmjs.org/" || fallbacks.includes(own));
|
|
2846
|
+
const keyOf = (registry) => registry === null ? own : normalizeUpdateRegistry(registry);
|
|
2847
|
+
const known = [];
|
|
2848
|
+
const keys = [];
|
|
2849
|
+
for (const registry of [configured.registry, ...configured.fallbackRegistries]) {
|
|
2850
|
+
if (registry === null && !ownIsPublic) continue;
|
|
2851
|
+
const key = keyOf(registry);
|
|
2852
|
+
if (keys.includes(key)) continue;
|
|
2853
|
+
known.push(registry === null ? null : normalizeUpdateRegistry(registry));
|
|
2854
|
+
keys.push(key);
|
|
2855
|
+
}
|
|
2856
|
+
const first = requested === void 0 ? configured.registry : requested;
|
|
2857
|
+
const firstKey = keyOf(first);
|
|
2858
|
+
const normalizedFirst = first === null ? null : normalizeUpdateRegistry(first);
|
|
2859
|
+
if ((first === null || firstKey === own) && !ownIsPublic) return [normalizedFirst];
|
|
2860
|
+
if (!keys.includes(firstKey)) return [normalizedFirst];
|
|
2861
|
+
return [normalizedFirst, ...known.filter((_registry, index) => keys[index] !== firstKey)];
|
|
2862
|
+
}
|
|
2863
|
+
function normalizeUpdateRegistry(registry) {
|
|
2864
|
+
const parsed = new URL(registry);
|
|
2865
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("registry unavailable");
|
|
2866
|
+
if (!parsed.pathname.endsWith("/")) parsed.pathname += "/";
|
|
2867
|
+
return parsed.href;
|
|
2868
|
+
}
|
|
2869
|
+
function updateRegistrySources(requested, configured) {
|
|
2870
|
+
return [...new Set(updateRegistryPlan(requested, configured).map((registry) => {
|
|
2871
|
+
const resolved = registry === null ? configured.resolved : registry;
|
|
2872
|
+
if (!resolved) throw new Error("registry unresolved");
|
|
2873
|
+
return normalizeUpdateRegistry(resolved);
|
|
2874
|
+
}))];
|
|
2875
|
+
}
|
|
2876
|
+
/** 只接受具有完整规范摘要的强 SRI;宽松 base64 解码不能把截断值或弱摘要当作安装凭据。 */
|
|
2877
|
+
function strongIntegrity(value) {
|
|
2878
|
+
if (typeof value !== "string") return void 0;
|
|
2879
|
+
const match = /^(sha256|sha384|sha512)-([A-Za-z0-9+/]+={0,2})$/.exec(value);
|
|
2880
|
+
if (!match) return void 0;
|
|
2881
|
+
const digest = Buffer.from(match[2], "base64");
|
|
2882
|
+
const bytes = {
|
|
2883
|
+
sha256: 32,
|
|
2884
|
+
sha384: 48,
|
|
2885
|
+
sha512: 64
|
|
2886
|
+
}[match[1]];
|
|
2887
|
+
return digest.length === bytes && digest.toString("base64") === match[2] ? value : void 0;
|
|
2888
|
+
}
|
|
2889
|
+
/** 精确版本事实由元数据读取;软件源地址、上游响应体及包管理器诊断不进入对外错误。 */
|
|
2890
|
+
async function readTargetManifestFacts(registry, version, fetcher) {
|
|
2891
|
+
const base = new URL(registry);
|
|
2892
|
+
if (!["http:", "https:"].includes(base.protocol) || base.username || base.password || base.search || base.hash || !validVersion(version)) throw new Error("registry unavailable");
|
|
2893
|
+
base.pathname = base.pathname.replace(/\/?$/, "/") + encodeURIComponent(PACKAGE) + "/" + encodeURIComponent(version);
|
|
2894
|
+
const response = await fetcher(base, {
|
|
2895
|
+
signal: AbortSignal.timeout(1e4),
|
|
2896
|
+
redirect: "error",
|
|
2897
|
+
headers: { accept: "application/json" }
|
|
2898
|
+
});
|
|
2899
|
+
if (!response.ok || !response.body) throw new Error("manifest unavailable");
|
|
2900
|
+
const reader = response.body.getReader();
|
|
2901
|
+
const chunks = [];
|
|
2902
|
+
let bytes = 0;
|
|
2903
|
+
try {
|
|
2904
|
+
while (true) {
|
|
2905
|
+
const next = await reader.read();
|
|
2906
|
+
if (next.done) break;
|
|
2907
|
+
bytes += next.value.length;
|
|
2908
|
+
if (bytes > 1048576) throw new Error("manifest too large");
|
|
2909
|
+
chunks.push(next.value);
|
|
2910
|
+
}
|
|
2911
|
+
} finally {
|
|
2912
|
+
await reader.cancel().catch(() => {});
|
|
2913
|
+
reader.releaseLock();
|
|
2914
|
+
}
|
|
2915
|
+
const manifest = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
2916
|
+
if (manifest.name !== PACKAGE || manifest.version !== version || !manifest.dsh?.bundle) throw new Error("manifest mismatch");
|
|
2917
|
+
const protocol = manifest.deepPilot?.protocolVersion;
|
|
2918
|
+
return {
|
|
2919
|
+
protocolVersion: typeof protocol === "number" && Number.isSafeInteger(protocol) && protocol > 0 ? protocol : void 0,
|
|
2920
|
+
integrity: strongIntegrity(manifest.dist?.integrity)
|
|
2921
|
+
};
|
|
2922
|
+
}
|
|
2923
|
+
/** 检查界面只预览首个软件源的协议;实际安装必须通过全部可回退源的强校验。 */
|
|
2924
|
+
async function readTargetProtocol(registry, version, fetcher = fetch) {
|
|
2925
|
+
return (await readTargetManifestFacts(registry, version, fetcher)).protocolVersion;
|
|
2926
|
+
}
|
|
2927
|
+
var PluginUpdater = class {
|
|
2928
|
+
options;
|
|
2929
|
+
state;
|
|
2930
|
+
path;
|
|
2931
|
+
listVersions;
|
|
2932
|
+
updateBundle;
|
|
2933
|
+
listBundles;
|
|
2934
|
+
registries;
|
|
2935
|
+
constructor(options) {
|
|
2936
|
+
this.options = options;
|
|
2937
|
+
this.path = resolve(options.journalPath);
|
|
2938
|
+
this.listVersions = options.manager.listVersions.bind(options.manager);
|
|
2939
|
+
this.updateBundle = options.manager.updateBundle.bind(options.manager);
|
|
2940
|
+
this.listBundles = options.manager.listBundles.bind(options.manager);
|
|
2941
|
+
this.registries = options.manager.registries.bind(options.manager);
|
|
2942
|
+
let state = cells.get(this.path);
|
|
2943
|
+
if (!state) {
|
|
2944
|
+
state = {
|
|
2945
|
+
bootId: randomUUID(),
|
|
2946
|
+
records: [],
|
|
2947
|
+
listeners: /* @__PURE__ */ new Set(),
|
|
2948
|
+
runningVersion: options.currentVersion,
|
|
2949
|
+
healthy: true
|
|
2950
|
+
};
|
|
2951
|
+
if (existsSync(this.path)) try {
|
|
2952
|
+
if (statSync(this.path).size > 262144) throw new Error("oversized journal");
|
|
2953
|
+
const data = JSON.parse(readFileSync(this.path, "utf8"));
|
|
2954
|
+
if (data.version !== 1 || !Array.isArray(data.records) || data.records.length > 128 || data.records.some((record) => !record || !validUpdateRequestId(record.clientRequestId) || !validVersion(record.fromVersion) || !validVersion(record.toVersion) || !PHASES.includes(record.phase) || !Number.isSafeInteger(record.startedAt) || record.startedAt < 0 || typeof record.message !== "string" || record.message.length > 1024 || !/^[a-f0-9]{64}$/.test(record.ownerKey) || record.restartVersion !== void 0 && !validVersion(record.restartVersion) || record.restartBoot !== void 0 && (typeof record.restartBoot !== "string" || record.restartBoot.length > 128) || record.restartPhase !== void 0 && record.restartPhase !== "done" && record.restartPhase !== "rolled-back") || new Set(data.records.map((record) => record.clientRequestId)).size !== data.records.length) throw new Error("invalid journal");
|
|
2955
|
+
state.records = data.records;
|
|
2956
|
+
} catch {
|
|
2957
|
+
state.healthy = false;
|
|
2958
|
+
}
|
|
2959
|
+
cells.set(this.path, state);
|
|
2960
|
+
}
|
|
2961
|
+
this.state = state;
|
|
2962
|
+
state.runningVersion = options.currentVersion;
|
|
2963
|
+
}
|
|
2964
|
+
save() {
|
|
2965
|
+
mkdirSync(dirname(this.path), {
|
|
2966
|
+
recursive: true,
|
|
2967
|
+
mode: 448
|
|
2968
|
+
});
|
|
2969
|
+
const temp = this.path + "." + randomUUID() + ".tmp";
|
|
2970
|
+
const fd = openSync(temp, "wx", 384);
|
|
2971
|
+
try {
|
|
2972
|
+
writeFileSync(fd, JSON.stringify({
|
|
2973
|
+
version: 1,
|
|
2974
|
+
records: this.state.records
|
|
2975
|
+
}));
|
|
2976
|
+
fsyncSync(fd);
|
|
2977
|
+
} finally {
|
|
2978
|
+
closeSync(fd);
|
|
2979
|
+
}
|
|
2980
|
+
renameSync(temp, this.path);
|
|
2981
|
+
const directory = openSync(dirname(this.path), "r");
|
|
2982
|
+
try {
|
|
2983
|
+
fsyncSync(directory);
|
|
2984
|
+
} catch (error) {
|
|
2985
|
+
if (![
|
|
2986
|
+
"EPERM",
|
|
2987
|
+
"EISDIR",
|
|
2988
|
+
"EINVAL"
|
|
2989
|
+
].includes(String(error.code))) throw error;
|
|
2990
|
+
} finally {
|
|
2991
|
+
closeSync(directory);
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
transition(record, phase, message) {
|
|
2995
|
+
const before = { ...record };
|
|
2996
|
+
record.phase = phase;
|
|
2997
|
+
record.message = message;
|
|
2998
|
+
if (phase === "restart-required") record.restartBoot = this.state.bootId;
|
|
2999
|
+
try {
|
|
3000
|
+
this.save();
|
|
3001
|
+
} catch (error) {
|
|
3002
|
+
Object.assign(record, before);
|
|
3003
|
+
record.restartBoot = before.restartBoot;
|
|
3004
|
+
throw error;
|
|
3005
|
+
}
|
|
3006
|
+
for (const listener of this.state.listeners) try {
|
|
3007
|
+
listener({
|
|
3008
|
+
clientRequestId: record.clientRequestId,
|
|
3009
|
+
phase,
|
|
3010
|
+
message
|
|
3011
|
+
}, record.ownerKey);
|
|
3012
|
+
} catch {}
|
|
3013
|
+
}
|
|
3014
|
+
subscribe(deviceId, listener) {
|
|
3015
|
+
const key = ownerKey(deviceId);
|
|
3016
|
+
const wrapped = (event, owner) => {
|
|
3017
|
+
if (owner === key) listener(event);
|
|
3018
|
+
};
|
|
3019
|
+
this.state.listeners.add(wrapped);
|
|
3020
|
+
return () => {
|
|
3021
|
+
this.state.listeners.delete(wrapped);
|
|
3022
|
+
};
|
|
3023
|
+
}
|
|
3024
|
+
recover() {
|
|
3025
|
+
if (this.state.active || !this.state.healthy) return;
|
|
3026
|
+
for (const record of this.state.records) if (record.phase === "restart-required" && record.restartBoot !== this.state.bootId && this.options.currentVersion === (record.restartVersion ?? record.toVersion)) this.transition(record, record.restartPhase ?? "done", record.restartPhase === "rolled-back" ? "重启后已运行旧版本,宿主回滚完成" : "重启后已运行目标版本,更新完成");
|
|
3027
|
+
else if (!terminal(record.phase)) {
|
|
3028
|
+
if (this.options.currentVersion === record.toVersion) this.transition(record, "done", "已重新连接到目标版本,更新完成");
|
|
3029
|
+
else this.transition(record, "failed", "上次更新已中断,结果无法确认;请检查当前版本,原请求不会自动重试");
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
async check(deviceId) {
|
|
3033
|
+
let reason;
|
|
3034
|
+
try {
|
|
3035
|
+
this.recover();
|
|
3036
|
+
} catch {
|
|
3037
|
+
reason = "更新日志保存失败,暂时无法确认更新结果";
|
|
3038
|
+
}
|
|
3039
|
+
if (!this.state.healthy) reason = "更新日志无法读取,已停用安装以保护上次更新记录";
|
|
3040
|
+
const finish = (status) => {
|
|
3041
|
+
const journal = [...this.state.records].reverse().find((record) => record.ownerKey === ownerKey(deviceId));
|
|
3042
|
+
return {
|
|
3043
|
+
...status,
|
|
3044
|
+
...journal ? { journal: publicRecord(journal) } : {}
|
|
3045
|
+
};
|
|
3046
|
+
};
|
|
3047
|
+
const base = {
|
|
3048
|
+
currentVersion: this.options.currentVersion,
|
|
3049
|
+
protocolVersion: 2,
|
|
3050
|
+
latestVersion: null,
|
|
3051
|
+
updateAvailable: false,
|
|
3052
|
+
compatible: false,
|
|
3053
|
+
protocolCompatible: false,
|
|
3054
|
+
versions: [],
|
|
3055
|
+
checkedAt: Date.now()
|
|
3056
|
+
};
|
|
3057
|
+
try {
|
|
3058
|
+
const listed = await this.listVersions(PACKAGE);
|
|
3059
|
+
if (listed.status !== "listed") return finish({
|
|
3060
|
+
...base,
|
|
3061
|
+
reason: reason ?? "暂时无法读取可更新版本,请稍后重试"
|
|
3062
|
+
});
|
|
3063
|
+
const registry = listed.registry ?? (await this.registries()).resolved;
|
|
3064
|
+
if (!registry) return finish({
|
|
3065
|
+
...base,
|
|
3066
|
+
reason: reason ?? "无法确定软件源,暂时不能核对目标版本协议"
|
|
3067
|
+
});
|
|
3068
|
+
const versions = await Promise.all(listed.versions.slice(0, 10).map(async (entry) => {
|
|
3069
|
+
let protocolVersion;
|
|
3070
|
+
try {
|
|
3071
|
+
protocolVersion = await readTargetProtocol(registry, entry.version, this.options.fetch);
|
|
3072
|
+
} catch {}
|
|
3073
|
+
return {
|
|
3074
|
+
version: entry.version,
|
|
3075
|
+
compatible: entry.bundle && entry.incompatible === void 0,
|
|
3076
|
+
...protocolVersion === void 0 ? {} : { protocolVersion }
|
|
3077
|
+
};
|
|
3078
|
+
}));
|
|
3079
|
+
const latest = versions[0];
|
|
3080
|
+
return finish({
|
|
3081
|
+
...base,
|
|
3082
|
+
latestVersion: latest?.version ?? null,
|
|
3083
|
+
updateAvailable: versions.length > 0,
|
|
3084
|
+
compatible: latest?.compatible ?? false,
|
|
3085
|
+
protocolCompatible: latest?.protocolVersion === 2,
|
|
3086
|
+
versions,
|
|
3087
|
+
...reason ? { reason } : versions.some((entry) => entry.protocolVersion === void 0) ? { reason: "部分目标版本缺少可核验的协议声明,暂时不可安装" } : {}
|
|
3088
|
+
});
|
|
3089
|
+
} catch {
|
|
3090
|
+
return finish({
|
|
3091
|
+
...base,
|
|
3092
|
+
reason: reason ?? "暂时无法检查更新,请确认网络后重试"
|
|
3093
|
+
});
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
/** The apply frame is the phone's explicit confirmation. check() never mutates packages. */
|
|
3097
|
+
async apply(deviceId, version, clientRequestId) {
|
|
3098
|
+
if (!validVersion(version) || !validUpdateRequestId(clientRequestId)) throw new PluginUpdateError("E_PROTOCOL", "更新版本或请求标识无效");
|
|
3099
|
+
if (!this.state.healthy) throw new PluginUpdateError("E_INTERNAL", "更新日志无法读取,暂时不能安装");
|
|
3100
|
+
const previous = this.state.records.find((record) => record.clientRequestId === clientRequestId);
|
|
3101
|
+
if (previous) {
|
|
3102
|
+
if (previous.ownerKey !== ownerKey(deviceId)) throw new PluginUpdateError("E_FORBIDDEN", "更新请求不属于当前设备");
|
|
3103
|
+
if (previous.toVersion !== version) throw new PluginUpdateError("E_PROTOCOL", "同一更新请求不能更换目标版本");
|
|
3104
|
+
this.recover();
|
|
3105
|
+
return publicRecord(previous);
|
|
3106
|
+
}
|
|
3107
|
+
const pending = this.state.pending;
|
|
3108
|
+
if (pending?.record.clientRequestId === clientRequestId) {
|
|
3109
|
+
if (pending.record.ownerKey !== ownerKey(deviceId)) throw new PluginUpdateError("E_FORBIDDEN", "更新请求不属于当前设备");
|
|
3110
|
+
if (pending.record.toVersion !== version) throw new PluginUpdateError("E_PROTOCOL", "同一更新请求不能更换目标版本");
|
|
3111
|
+
return pending.accepted;
|
|
3112
|
+
}
|
|
3113
|
+
if (this.state.active) throw new PluginUpdateError("E_BUSY", "已有插件更新正在进行,请稍后检查结果");
|
|
3114
|
+
if (this.state.records.length >= 128) throw new PluginUpdateError("E_INTERNAL", "更新记录已满,请在电脑端检查后再更新");
|
|
3115
|
+
const record = {
|
|
3116
|
+
clientRequestId,
|
|
3117
|
+
fromVersion: this.options.currentVersion,
|
|
3118
|
+
toVersion: version,
|
|
3119
|
+
phase: "preparing",
|
|
3120
|
+
startedAt: Date.now(),
|
|
3121
|
+
message: "目标校验已通过,准备开始安装",
|
|
3122
|
+
ownerKey: ownerKey(deviceId)
|
|
3123
|
+
};
|
|
3124
|
+
let accept;
|
|
3125
|
+
let refuse;
|
|
3126
|
+
const accepted = new Promise((resolve_, reject_) => {
|
|
3127
|
+
accept = resolve_;
|
|
3128
|
+
refuse = reject_;
|
|
3129
|
+
});
|
|
3130
|
+
this.state.pending = {
|
|
3131
|
+
record,
|
|
3132
|
+
accepted
|
|
3133
|
+
};
|
|
3134
|
+
const operation = Promise.resolve().then(async () => {
|
|
3135
|
+
let registry;
|
|
3136
|
+
try {
|
|
3137
|
+
registry = await this.prepare(record);
|
|
3138
|
+
this.state.records.push(record);
|
|
3139
|
+
try {
|
|
3140
|
+
this.save();
|
|
3141
|
+
} catch {
|
|
3142
|
+
this.state.records.splice(this.state.records.indexOf(record), 1);
|
|
3143
|
+
throw new PluginUpdateError("E_INTERNAL", "更新日志保存失败,尚未开始安装");
|
|
3144
|
+
}
|
|
3145
|
+
} catch (error) {
|
|
3146
|
+
if (this.state.active === operation) this.state.active = void 0;
|
|
3147
|
+
if (this.state.pending?.record === record) this.state.pending = void 0;
|
|
3148
|
+
refuse(error instanceof PluginUpdateError ? error : new PluginUpdateError("E_INTERNAL", "无法核对目标版本,尚未开始安装"));
|
|
3149
|
+
return;
|
|
3150
|
+
}
|
|
3151
|
+
if (this.state.pending?.record === record) this.state.pending = void 0;
|
|
3152
|
+
accept(publicRecord(record));
|
|
3153
|
+
await new Promise((resolve_) => setImmediate(resolve_));
|
|
3154
|
+
try {
|
|
3155
|
+
await this.run(record, registry);
|
|
3156
|
+
} catch {
|
|
3157
|
+
try {
|
|
3158
|
+
this.transition(record, "failed", "更新结果无法确认,请检查当前版本;原请求不会自动重试");
|
|
3159
|
+
} catch {}
|
|
3160
|
+
}
|
|
3161
|
+
}).finally(() => {
|
|
3162
|
+
if (this.state.active === operation) this.state.active = void 0;
|
|
3163
|
+
});
|
|
3164
|
+
this.state.active = operation;
|
|
3165
|
+
return accepted;
|
|
3166
|
+
}
|
|
3167
|
+
/** Useful to isolated tests; production never waits for this from disposal. */
|
|
3168
|
+
async settled() {
|
|
3169
|
+
await this.state.active;
|
|
3170
|
+
}
|
|
3171
|
+
/** 所有可执行目标校验在受理前完成;宿主安装及其回滚处理仍属于独立的后续任务。 */
|
|
3172
|
+
async prepare(record) {
|
|
3173
|
+
let listed;
|
|
3174
|
+
try {
|
|
3175
|
+
listed = await this.listVersions(PACKAGE);
|
|
3176
|
+
} catch {
|
|
3177
|
+
throw new PluginUpdateError("E_INTERNAL", "无法核对可更新版本,尚未开始安装");
|
|
3178
|
+
}
|
|
3179
|
+
if (listed.status !== "listed" || listed.installed !== record.fromVersion) throw new PluginUpdateError("E_PROTOCOL", "安装版本已变化或无法读取,请重新检查更新");
|
|
3180
|
+
const target = listed.versions.find((entry) => entry.version === record.toVersion);
|
|
3181
|
+
if (!target?.bundle || target.incompatible !== void 0) throw new PluginUpdateError("E_UNSUPPORTED", "目标版本不存在或不兼容当前宿主,尚未开始安装");
|
|
3182
|
+
try {
|
|
3183
|
+
const sources = updateRegistrySources(listed.registry, await this.registries());
|
|
3184
|
+
let integrity;
|
|
3185
|
+
for (const source of sources) {
|
|
3186
|
+
const facts = await readTargetManifestFacts(source, record.toVersion, this.options.fetch ?? fetch);
|
|
3187
|
+
if (facts.protocolVersion !== 2) throw new PluginUpdateError("E_UNSUPPORTED", "候选软件源的目标协议不兼容或缺少声明,尚未开始安装");
|
|
3188
|
+
if (!facts.integrity) throw new PluginUpdateError("E_UNSUPPORTED", "候选软件源缺少有效的强完整性摘要,尚未开始安装");
|
|
3189
|
+
if (integrity !== void 0 && facts.integrity !== integrity) throw new PluginUpdateError("E_UNSUPPORTED", "候选软件源的目标包完整性摘要不一致,尚未开始安装");
|
|
3190
|
+
integrity = facts.integrity;
|
|
3191
|
+
}
|
|
3192
|
+
const current = updateRegistrySources(listed.registry, await this.registries());
|
|
3193
|
+
if (JSON.stringify(current) !== JSON.stringify(sources)) throw new PluginUpdateError("E_BUSY", "软件源配置已变化,请重新检查更新");
|
|
3194
|
+
} catch (error) {
|
|
3195
|
+
if (error instanceof PluginUpdateError) throw error;
|
|
3196
|
+
throw new PluginUpdateError("E_INTERNAL", "无法核验全部候选软件源的目标协议与完整性,尚未开始安装");
|
|
3197
|
+
}
|
|
3198
|
+
return listed.registry;
|
|
3199
|
+
}
|
|
3200
|
+
async run(record, registry) {
|
|
3201
|
+
this.transition(record, "installing", "宿主正在安装目标版本,连接可能暂时中断");
|
|
3202
|
+
const outcome = await this.updateBundle(PACKAGE, record.toVersion, {
|
|
3203
|
+
registry,
|
|
3204
|
+
requestId: record.clientRequestId
|
|
3205
|
+
});
|
|
3206
|
+
const result = outcome.result;
|
|
3207
|
+
if (outcome.from !== record.fromVersion || outcome.to !== record.toVersion) return this.transition(record, "failed", "宿主返回的更新版本不一致,请检查当前安装");
|
|
3208
|
+
if (outcome.restore) {
|
|
3209
|
+
const restored = outcome.restore;
|
|
3210
|
+
if (restored.application === "restart-required") {
|
|
3211
|
+
record.restartVersion = record.fromVersion;
|
|
3212
|
+
record.restartPhase = "rolled-back";
|
|
3213
|
+
return this.transition(record, "restart-required", "目标安装失败,旧版已重新安装;请在电脑端重启 DSH 后确认恢复");
|
|
3214
|
+
}
|
|
3215
|
+
if (restored.application === "applied" && (restored.version === void 0 || restored.version === record.fromVersion)) {
|
|
3216
|
+
if ((await this.listBundles()).find((bundle) => bundle.name === PACKAGE)?.version === record.fromVersion) return this.transition(record, "rolled-back", "目标安装失败,宿主已自动恢复旧版本");
|
|
3217
|
+
return this.transition(record, "failed", "宿主已尝试回滚,但无法核对实际旧版本,请在电脑端检查插件");
|
|
3218
|
+
}
|
|
3219
|
+
return this.transition(record, "failed", "目标安装失败,宿主恢复旧版也未完成,请在电脑端检查插件");
|
|
3220
|
+
}
|
|
3221
|
+
if (outcome.step !== "install" || result.application === "failed" || result.application === "cancelled" || result.application === "overridden") return this.transition(record, "failed", installFailureMessage(result));
|
|
3222
|
+
if (result.version !== void 0 && result.version !== record.toVersion) return this.transition(record, "failed", "实际安装版本与目标不一致,请在电脑端检查");
|
|
3223
|
+
if (result.application === "restart-required") return this.transition(record, "restart-required", "目标版本已安装,宿主需要重启;请在电脑端重启 DSH 后重新连接");
|
|
3224
|
+
this.transition(record, "verifying", "正在核对实际安装版本");
|
|
3225
|
+
if ((await this.listBundles()).find((bundle) => bundle.name === PACKAGE)?.version !== record.toVersion) return this.transition(record, "failed", "无法确认实际安装版本,请在电脑端检查");
|
|
3226
|
+
if (this.state.runningVersion !== record.toVersion) return this.transition(record, "restart-required", "目标版本已安装,当前插件尚未重新加载;请在电脑端重启 DSH 后重新连接");
|
|
3227
|
+
this.transition(record, "done", "目标版本已加载,更新完成");
|
|
3228
|
+
}
|
|
3229
|
+
};
|
|
3230
|
+
function installFailureMessage(result) {
|
|
3231
|
+
if (result.error?.code === "incompatible-version" || result.error?.incompatible?.length || result.packageResult?.incompatible?.length) return "目标版本不兼容当前宿主,安装已拒绝(incompatible-version)";
|
|
3232
|
+
if (result.pendingBuilds?.length || result.packageResult?.kind === "build-blocked") return "安装需要在电脑端明确批准构建脚本,更新未完成(build-blocked)";
|
|
3233
|
+
const kind = result.packageResult?.kind;
|
|
3234
|
+
const reasons = {
|
|
3235
|
+
"pnpm-missing": "电脑端缺少包管理器,更新未完成",
|
|
3236
|
+
timeout: "安装等待超时,请检查电脑端后重试",
|
|
3237
|
+
"not-found": "软件源尚未提供目标包,更新未完成",
|
|
3238
|
+
"no-matching-version": "软件源尚未提供目标版本,更新未完成",
|
|
3239
|
+
network: "安装无法连接软件源,请恢复网络后重新检查",
|
|
3240
|
+
"disk-full": "电脑端磁盘空间不足,更新未完成",
|
|
3241
|
+
permission: "电脑端没有写入插件的权限,更新未完成",
|
|
3242
|
+
integrity: "目标包完整性校验失败,更新未完成"
|
|
3243
|
+
};
|
|
3244
|
+
if (kind && reasons[kind]) return `${reasons[kind]}(${kind})`;
|
|
3245
|
+
if (result.application === "cancelled") return "宿主已取消目标版本安装,请检查当前版本";
|
|
3246
|
+
return "宿主未完成目标版本安装,请检查当前版本";
|
|
3247
|
+
}
|
|
3248
|
+
function supportsPluginUpdate(value) {
|
|
3249
|
+
if (!value || typeof value !== "object") return false;
|
|
3250
|
+
return [
|
|
3251
|
+
"listVersions",
|
|
3252
|
+
"updateBundle",
|
|
3253
|
+
"listBundles",
|
|
3254
|
+
"registries"
|
|
3255
|
+
].every((key) => typeof value[key] === "function");
|
|
3256
|
+
}
|
|
3257
|
+
//#endregion
|
|
3258
|
+
//#region src/wire-plugin-update.ts
|
|
3259
|
+
function payloadObject$1(value) {
|
|
3260
|
+
if (value === void 0 || value === null) return {
|
|
3261
|
+
ok: true,
|
|
3262
|
+
value: {}
|
|
3263
|
+
};
|
|
3264
|
+
if (typeof value !== "object" || Array.isArray(value)) return {
|
|
3265
|
+
ok: false,
|
|
3266
|
+
code: "E_PROTOCOL",
|
|
3267
|
+
message: "更新请求必须是对象"
|
|
3268
|
+
};
|
|
3269
|
+
return {
|
|
3270
|
+
ok: true,
|
|
3271
|
+
value
|
|
3272
|
+
};
|
|
3273
|
+
}
|
|
3274
|
+
const updaters = /* @__PURE__ */ new WeakMap();
|
|
3275
|
+
/** Bridge replacement and optional host service discovery can arrive in either order. */
|
|
3276
|
+
function setPluginUpdater(bridge, updater) {
|
|
3277
|
+
if (updater) updaters.set(bridge, updater);
|
|
3278
|
+
else updaters.delete(bridge);
|
|
3279
|
+
}
|
|
3280
|
+
const observers = /* @__PURE__ */ new WeakMap();
|
|
3281
|
+
function observe(ctx, updater, deviceId) {
|
|
3282
|
+
if (!ctx.canReceive("sessions.manage")) return;
|
|
3283
|
+
const signal = ctx.signal;
|
|
3284
|
+
if (signal?.aborted || signal && observers.get(signal)?.has(updater)) return;
|
|
3285
|
+
let stop = () => {};
|
|
3286
|
+
const unsubscribe = updater.subscribe(deviceId, (event) => {
|
|
3287
|
+
if (!ctx.canReceive("sessions.manage")) {
|
|
3288
|
+
stop();
|
|
3289
|
+
return;
|
|
3290
|
+
}
|
|
3291
|
+
ctx.send("s2c.plugin.update.progress", event);
|
|
3292
|
+
if (!signal && finished(event.phase)) stop();
|
|
3293
|
+
});
|
|
3294
|
+
stop = () => {
|
|
3295
|
+
unsubscribe();
|
|
3296
|
+
signal?.removeEventListener("abort", stop);
|
|
3297
|
+
if (signal) observers.get(signal)?.delete(updater);
|
|
3298
|
+
};
|
|
3299
|
+
if (signal) {
|
|
3300
|
+
let connections = observers.get(signal);
|
|
3301
|
+
if (!connections) {
|
|
3302
|
+
connections = /* @__PURE__ */ new Set();
|
|
3303
|
+
observers.set(signal, connections);
|
|
3304
|
+
}
|
|
3305
|
+
connections.add(updater);
|
|
3306
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
const finished = (phase) => phase === void 0 || [
|
|
3310
|
+
"done",
|
|
3311
|
+
"failed",
|
|
3312
|
+
"rolled-back",
|
|
3313
|
+
"restart-required"
|
|
3314
|
+
].includes(phase);
|
|
3315
|
+
const pluginUpdateRows = [{
|
|
3316
|
+
type: "c2s.plugin.update.check",
|
|
3317
|
+
stage: "authenticated",
|
|
3318
|
+
scopes: ["sessions.read"],
|
|
3319
|
+
widgetPolicy: "deny",
|
|
3320
|
+
capability: "pluginUpdate",
|
|
3321
|
+
doc: "PROTOCOL.md c2s.plugin.update.check",
|
|
3322
|
+
validate: payloadObject$1,
|
|
3323
|
+
handle: async (ctx) => {
|
|
3324
|
+
const updater = updaters.get(ctx.bridge);
|
|
3325
|
+
if (!updater) return ctx.fail("E_UNSUPPORTED", "当前宿主不支持插件自更新");
|
|
3326
|
+
if (!ctx.deviceId) return ctx.fail("E_FORBIDDEN", "请先完成设备认证");
|
|
3327
|
+
observe(ctx, updater, ctx.deviceId);
|
|
3328
|
+
const status = await updater.check(ctx.deviceId);
|
|
3329
|
+
ctx.send("s2c.plugin.update.status", status);
|
|
3330
|
+
}
|
|
3331
|
+
}, {
|
|
3332
|
+
type: "c2s.plugin.update.apply",
|
|
3333
|
+
stage: "authenticated",
|
|
3334
|
+
scopes: ["sessions.manage"],
|
|
3335
|
+
widgetPolicy: "deny",
|
|
3336
|
+
capability: "pluginUpdate",
|
|
3337
|
+
doc: "PROTOCOL.md c2s.plugin.update.apply",
|
|
3338
|
+
validate: (value) => {
|
|
3339
|
+
const result = payloadObject$1(value);
|
|
3340
|
+
if (!result.ok) return result;
|
|
3341
|
+
const version = result.value.version;
|
|
3342
|
+
if (typeof version !== "string" || version.length > 128 || version.trim().length === 0 || !validUpdateRequestId(result.value.clientRequestId)) return {
|
|
3343
|
+
ok: false,
|
|
3344
|
+
code: "E_PROTOCOL",
|
|
3345
|
+
message: "更新版本或请求标识无效"
|
|
3346
|
+
};
|
|
3347
|
+
return result;
|
|
3348
|
+
},
|
|
3349
|
+
handle: async (ctx, payload) => {
|
|
3350
|
+
const updater = updaters.get(ctx.bridge);
|
|
3351
|
+
if (!updater) return ctx.fail("E_UNSUPPORTED", "当前宿主不支持插件自更新");
|
|
3352
|
+
if (!ctx.deviceId) return ctx.fail("E_FORBIDDEN", "请先完成设备认证");
|
|
3353
|
+
try {
|
|
3354
|
+
const record = await updater.apply(ctx.deviceId, payload.version, payload.clientRequestId);
|
|
3355
|
+
ctx.send("s2c.ack", {
|
|
3356
|
+
clientRequestId: record.clientRequestId,
|
|
3357
|
+
accepted: true
|
|
3358
|
+
});
|
|
3359
|
+
if (ctx.canReceive("sessions.manage")) {
|
|
3360
|
+
ctx.send("s2c.plugin.update.progress", {
|
|
3361
|
+
clientRequestId: record.clientRequestId,
|
|
3362
|
+
phase: record.phase,
|
|
3363
|
+
message: record.message
|
|
3364
|
+
});
|
|
3365
|
+
if (!finished(record.phase)) observe(ctx, updater, ctx.deviceId);
|
|
3366
|
+
}
|
|
3367
|
+
} catch (error) {
|
|
3368
|
+
if (error instanceof PluginUpdateError) return ctx.fail(error.code, error.message);
|
|
3369
|
+
ctx.fail("E_INTERNAL", "暂时无法启动插件更新,请检查当前版本");
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
}];
|
|
2752
3373
|
/** 第一个缺失的 scope;消息与迁移前一致(`scope <name> required`)。 */
|
|
2753
3374
|
function scopeRejection(row, scopes) {
|
|
2754
3375
|
for (const scope of row.scopes) if (!scopes.has(scope)) return {
|
|
@@ -2828,6 +3449,7 @@ function accept(value) {
|
|
|
2828
3449
|
const WIRE_FRAME_ROWS = [
|
|
2829
3450
|
...sessionRows,
|
|
2830
3451
|
...commandRows,
|
|
3452
|
+
...pluginUpdateRows,
|
|
2831
3453
|
...scheduleRows,
|
|
2832
3454
|
...interactionRows,
|
|
2833
3455
|
...pushRows,
|
|
@@ -3323,7 +3945,7 @@ var BridgeConnection = class {
|
|
|
3323
3945
|
...payload,
|
|
3324
3946
|
hostAudience: this.deps.audience
|
|
3325
3947
|
};
|
|
3326
|
-
if (type === "s2c.session.event") {
|
|
3948
|
+
if (type === "s2c.session.event" || type === "s2c.session.queue") {
|
|
3327
3949
|
const sessionId = payload?.sessionId;
|
|
3328
3950
|
if (typeof sessionId === "string") {
|
|
3329
3951
|
const buffered = this.openingSessionEvents.get(sessionId);
|
|
@@ -3448,6 +4070,30 @@ function normalizeFunnelConnectionLimit(value) {
|
|
|
3448
4070
|
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 16 ? value : 8;
|
|
3449
4071
|
}
|
|
3450
4072
|
//#endregion
|
|
4073
|
+
//#region src/tailscale-version.ts
|
|
4074
|
+
/**
|
|
4075
|
+
* 内嵌 helper 上报的 tailscale.com 模块版本。
|
|
4076
|
+
*
|
|
4077
|
+
* 版本的唯一来源是 helper 启动事件(`helper/main.go` 读 Go 构建信息里的
|
|
4078
|
+
* `tailscale.com` 依赖版本),不是 go.mod、也不是插件常量:用户可能通过
|
|
4079
|
+
* `remote.helperPath` 换成自己编译的 helper,界面必须显示实际在跑的那一个。
|
|
4080
|
+
*
|
|
4081
|
+
* 两条边界都要守卫:
|
|
4082
|
+
* - 宿主侧 `parseHelperEvent` 把 helper 的 stdout 当成不可信输入,格式不对
|
|
4083
|
+
* 就丢弃,避免把任意文本渲染进设置页;
|
|
4084
|
+
* - 设置页侧 `reportSchema` 同样校验 Host RPC 返回值,旧 Host 不带该字段
|
|
4085
|
+
* 时保持 undefined(页面显示「未获取」),不当作错误。
|
|
4086
|
+
*
|
|
4087
|
+
* 接受 Go 模块版本形态:`v1.104.2`、`v1.104.2-pre.1`、
|
|
4088
|
+
* `v0.0.0-20260101000000-abcdef123456`。长度上限防止异常输入撑爆界面。
|
|
4089
|
+
*/
|
|
4090
|
+
const TAILSCALE_VERSION_PATTERN = /^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]{1,64})?$/;
|
|
4091
|
+
const MAX_TAILSCALE_VERSION_LENGTH = 80;
|
|
4092
|
+
/** 判断外部输入是否是合法的 Tailscale 模块版本字符串。 */
|
|
4093
|
+
function isTailscaleVersion(value) {
|
|
4094
|
+
return typeof value === "string" && value.length <= MAX_TAILSCALE_VERSION_LENGTH && TAILSCALE_VERSION_PATTERN.test(value);
|
|
4095
|
+
}
|
|
4096
|
+
//#endregion
|
|
3451
4097
|
//#region src/remote-supervisor.ts
|
|
3452
4098
|
const RESTART_DELAYS_MS = [
|
|
3453
4099
|
1e3,
|
|
@@ -3636,7 +4282,8 @@ function parseHelperEvent(line) {
|
|
|
3636
4282
|
phase: value.phase,
|
|
3637
4283
|
...typeof value.publicURL === "string" ? { publicURL: value.publicURL } : {},
|
|
3638
4284
|
...typeof value.authURL === "string" ? { authURL: value.authURL } : {},
|
|
3639
|
-
...typeof value.message === "string" ? { message: value.message.slice(0, 500) } : {}
|
|
4285
|
+
...typeof value.message === "string" ? { message: value.message.slice(0, 500) } : {},
|
|
4286
|
+
...isTailscaleVersion(value.tailscaleVersion) ? { tailscaleVersion: value.tailscaleVersion } : {}
|
|
3640
4287
|
};
|
|
3641
4288
|
} catch {
|
|
3642
4289
|
return null;
|
|
@@ -5838,6 +6485,50 @@ function scheduleMutationCodec(persistValues) {
|
|
|
5838
6485
|
};
|
|
5839
6486
|
}
|
|
5840
6487
|
//#endregion
|
|
6488
|
+
//#region src/notification-text.ts
|
|
6489
|
+
/**
|
|
6490
|
+
* 通知正文的纯文本投影。
|
|
6491
|
+
*
|
|
6492
|
+
* 为什么需要:s2c.notify 与 APNs 的 body 是"可直接展示"的文本(PROTOCOL §6),
|
|
6493
|
+
* 客户端不得改写;而来源(助手回复、审批原因、提问)都是 Host 给的 Markdown。
|
|
6494
|
+
* 直接截原文会把链接地址、URL 编码的中文路径、代码块符号带进锁屏横幅,
|
|
6495
|
+
* 所以 Bridge 出站前在这里统一转成纯文本。
|
|
6496
|
+
*
|
|
6497
|
+
* 只做"去标记、留可读文字",不追求完整 Markdown 语义;任何无法识别的写法原样保留。
|
|
6498
|
+
*/
|
|
6499
|
+
/** 把 Markdown 拆成段落并逐段转为单行纯文本;清洗后为空的段落被丢弃。 */
|
|
6500
|
+
function notificationParagraphs(markdown) {
|
|
6501
|
+
return stripCodeFences(markdown.replace(/\r\n?/g, "\n")).split(/\n[ \t]*\n/).map(plainParagraph).filter((paragraph) => paragraph.length > 0);
|
|
6502
|
+
}
|
|
6503
|
+
/**
|
|
6504
|
+
* 通知正文:`last` 取最后一个非空段落(任务完成 / 异常结束——回复结尾通常是结论),
|
|
6505
|
+
* `all` 把全部段落合成一行(审批原因、提问原文)。长度上限由调用方负责。
|
|
6506
|
+
*/
|
|
6507
|
+
function notificationPlainText(markdown, pick) {
|
|
6508
|
+
const paragraphs = notificationParagraphs(markdown);
|
|
6509
|
+
return pick === "last" ? paragraphs.at(-1) ?? "" : paragraphs.join(" ");
|
|
6510
|
+
}
|
|
6511
|
+
/** 围栏代码块整体丢弃:横幅里放不下代码,留一半反而更难读。未闭合的围栏丢到文末。 */
|
|
6512
|
+
function stripCodeFences(text) {
|
|
6513
|
+
return text.replace(/^[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:^[ \t]*\1[ \t]*$|(?![\s\S]))/gm, "\n");
|
|
6514
|
+
}
|
|
6515
|
+
function plainParagraph(paragraph) {
|
|
6516
|
+
return decodePercentRuns(stripInline(paragraph.split("\n").filter((line) => !(line.includes("|") && line.includes("-") && /^[\s|:-]+$/.test(line))).filter((line) => !/^\s*([*_-])(\s*\1){2,}\s*$/.test(line)).map((line) => line.replace(/^\s*(>\s?)+/, "").replace(/^\s*#{1,6}\s+/, "").replace(/^\s*([-*+]|\d+[.)])\s+(\[[ xX]\]\s+)?/, "").replace(/^\s*\|(.*?)\|?\s*$/, (_row, cells) => cells.replace(/\s*\|\s*/g, " "))).join(" "))).replace(/\s+/g, " ").trim();
|
|
6517
|
+
}
|
|
6518
|
+
function stripInline(text) {
|
|
6519
|
+
return text.replace(/!\[[^\]]*\]\((?:[^()]|\([^()]*\))*\)/g, "").replace(/\[([^\]]+)\]\((?:[^()]|\([^()]*\))*\)/g, "$1").replace(/\[([^\]]+)\]\[[^\]]*\]/g, "$1").replace(/<((?:https?|mailto):[^>\s]+)>/g, "$1").replace(/<\/?[A-Za-z][^>]*>/g, "").replace(/(`+)([^`]+?)\1/g, "$2").replace(/(\*\*|__|~~)(?=\S)([\s\S]*?\S)\1/g, "$2").replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])/g, "$1$2").replace(/(^|[^\w])_(?=\S)([^_\n]*?\S)_(?!\w)/g, "$1$2");
|
|
6520
|
+
}
|
|
6521
|
+
/** 正文里残留的 %E6%8F%90 这类编码串解码成原字符;解不开(非法 UTF-8)就保持原样。 */
|
|
6522
|
+
function decodePercentRuns(text) {
|
|
6523
|
+
return text.replace(/(?:%[0-9A-Fa-f]{2})+/g, (run) => {
|
|
6524
|
+
try {
|
|
6525
|
+
return decodeURIComponent(run);
|
|
6526
|
+
} catch {
|
|
6527
|
+
return run;
|
|
6528
|
+
}
|
|
6529
|
+
});
|
|
6530
|
+
}
|
|
6531
|
+
//#endregion
|
|
5841
6532
|
//#region src/host-api.ts
|
|
5842
6533
|
/**
|
|
5843
6534
|
* Subagent sessions are host-internal workers of a parent conversation.
|
|
@@ -5863,6 +6554,11 @@ function unwrapStreamItem(item) {
|
|
|
5863
6554
|
}
|
|
5864
6555
|
//#endregion
|
|
5865
6556
|
//#region src/host-capabilities.ts
|
|
6557
|
+
const pluginUpdateProbes = /* @__PURE__ */ new WeakMap();
|
|
6558
|
+
function setPluginUpdateProbe(proxy, probe) {
|
|
6559
|
+
if (probe) pluginUpdateProbes.set(proxy, probe);
|
|
6560
|
+
else pluginUpdateProbes.delete(proxy);
|
|
6561
|
+
}
|
|
5866
6562
|
/** 每位一行:Host 上必须存在的依赖。缺失即该位为 false,相关帧回 E_UNSUPPORTED。 */
|
|
5867
6563
|
const HOST_CAPABILITY_PROBES = {
|
|
5868
6564
|
models: (proxy) => typeof proxy.sessions.models === "function",
|
|
@@ -5872,7 +6568,11 @@ const HOST_CAPABILITY_PROBES = {
|
|
|
5872
6568
|
sessionFork: (proxy) => typeof proxy.sessions.fork === "function",
|
|
5873
6569
|
sessionRestore: (proxy) => typeof proxy.workspace?.unarchiveSession === "function",
|
|
5874
6570
|
projectSelection: (proxy) => typeof proxy.workspace?.list === "function" && typeof proxy.workspace?.create === "function",
|
|
5875
|
-
schedules: (proxy) => proxy.schedule !== void 0
|
|
6571
|
+
schedules: (proxy) => proxy.schedule !== void 0,
|
|
6572
|
+
promptQueue: (proxy) => typeof proxy.sessions.updateQueue === "function",
|
|
6573
|
+
sessionMarkdown: (proxy) => proxy.commands?.markdownAvailable === true && typeof proxy.commands?.export === "function",
|
|
6574
|
+
assistantStream: (proxy) => proxy.assistantStreamAvailable === true,
|
|
6575
|
+
pluginUpdate: (proxy) => pluginUpdateProbes.get(proxy)?.() === true
|
|
5876
6576
|
};
|
|
5877
6577
|
/**
|
|
5878
6578
|
* 按当前 apiProxy 与推送出口算出 welcome 能力位。`push` 位描述「当前离线推送
|
|
@@ -5895,6 +6595,10 @@ function capabilityBits(proxy, pushOutlet) {
|
|
|
5895
6595
|
sessionRestore: HOST_CAPABILITY_PROBES.sessionRestore(proxy),
|
|
5896
6596
|
projectSelection: HOST_CAPABILITY_PROBES.projectSelection(proxy),
|
|
5897
6597
|
schedules: HOST_CAPABILITY_PROBES.schedules(proxy),
|
|
6598
|
+
promptQueue: HOST_CAPABILITY_PROBES.promptQueue(proxy),
|
|
6599
|
+
sessionMarkdown: HOST_CAPABILITY_PROBES.sessionMarkdown(proxy),
|
|
6600
|
+
assistantStream: HOST_CAPABILITY_PROBES.assistantStream(proxy),
|
|
6601
|
+
pluginUpdate: HOST_CAPABILITY_PROBES.pluginUpdate(proxy),
|
|
5898
6602
|
push: pushOutlet?.isAvailable() === true,
|
|
5899
6603
|
widgetPush: true,
|
|
5900
6604
|
liveActivityPush: true,
|
|
@@ -5902,6 +6606,258 @@ function capabilityBits(proxy, pushOutlet) {
|
|
|
5902
6606
|
};
|
|
5903
6607
|
}
|
|
5904
6608
|
//#endregion
|
|
6609
|
+
//#region src/prompt-queue.ts
|
|
6610
|
+
/**
|
|
6611
|
+
* 运行中排队与转向(PROTOCOL.md「运行中排队与转向扩展」)的纯函数部分。
|
|
6612
|
+
*
|
|
6613
|
+
* 职责:
|
|
6614
|
+
* - Host `requestId` 与 wire `clientSendId` 的互转:Bridge 发送带 clientSendId 的
|
|
6615
|
+
* prompt 时以 `dp:` 前缀拼出确定的 requestId,Host 把它记为用户来源的 `rpcId`;
|
|
6616
|
+
* 快照时反向剥掉前缀,发起设备即可把待发送项与队列项对上。
|
|
6617
|
+
* - 把 DSH `inbox` 投影(`{ "next-turn": Message[], "next-step": Message[] }`,
|
|
6618
|
+
* 来源是持久化的 `agent/inbox/spliced` 事件)折叠成 `s2c.session.queue` 的条目。
|
|
6619
|
+
*
|
|
6620
|
+
* 不持有状态:缓存、去重与推送时机在 host-bridge。
|
|
6621
|
+
*/
|
|
6622
|
+
const REQUEST_ID_PREFIX = "dp:";
|
|
6623
|
+
/** 快照正文上限(Unicode 码点)。只用于列表展示,完整正文仍在 Host 日志里。 */
|
|
6624
|
+
const MAX_QUEUE_TEXT_CODE_POINTS = 4096;
|
|
6625
|
+
/** clientSendId -> Host requestId。 */
|
|
6626
|
+
function promptRequestId(clientSendId) {
|
|
6627
|
+
return REQUEST_ID_PREFIX + clientSendId;
|
|
6628
|
+
}
|
|
6629
|
+
/** Host 来源 rpcId -> clientSendId;不是本 Bridge 用 clientSendId 发出的返回 undefined。 */
|
|
6630
|
+
function clientSendIdOf(rpcId) {
|
|
6631
|
+
if (typeof rpcId !== "string" || !rpcId.startsWith(REQUEST_ID_PREFIX)) return void 0;
|
|
6632
|
+
const id = rpcId.slice(3);
|
|
6633
|
+
return validSendId(id) ? id : void 0;
|
|
6634
|
+
}
|
|
6635
|
+
/**
|
|
6636
|
+
* inbox 投影 -> 队列条目,保持 Host 的先进先出顺序:先 `next-turn`(排队),
|
|
6637
|
+
* 再 `next-step`(转向)。值形状不对时返回 undefined,调用方据此不推送,
|
|
6638
|
+
* 而不是把「读不懂」当成「队列为空」——客户端会把消失的项当作已发出。
|
|
6639
|
+
*/
|
|
6640
|
+
function projectQueue(value) {
|
|
6641
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
6642
|
+
const inbox = value;
|
|
6643
|
+
const nextTurn = inbox["next-turn"];
|
|
6644
|
+
const nextStep = inbox["next-step"];
|
|
6645
|
+
if (!Array.isArray(nextTurn) || !Array.isArray(nextStep)) return void 0;
|
|
6646
|
+
const items = [];
|
|
6647
|
+
for (const [list, placement] of [[nextTurn, "queued"], [nextStep, "steering"]]) for (const message of list) {
|
|
6648
|
+
const item = queueItemOf(message, placement);
|
|
6649
|
+
if (item) items.push(item);
|
|
6650
|
+
}
|
|
6651
|
+
return items;
|
|
6652
|
+
}
|
|
6653
|
+
function queueItemOf(message, placement) {
|
|
6654
|
+
if (message === null || typeof message !== "object" || typeof message.id !== "string" || message.id.length === 0) return;
|
|
6655
|
+
const texts = [];
|
|
6656
|
+
let imageCount = 0;
|
|
6657
|
+
let documentCount = 0;
|
|
6658
|
+
for (const block of Array.isArray(message.content) ? message.content : []) {
|
|
6659
|
+
if (block === null || typeof block !== "object") continue;
|
|
6660
|
+
if (block.type === "image") imageCount += 1;
|
|
6661
|
+
else if (block.type === "text" && typeof block.text === "string") {
|
|
6662
|
+
if (projectedDocument(block.text) !== void 0) documentCount += 1;
|
|
6663
|
+
else texts.push(block.text);
|
|
6664
|
+
}
|
|
6665
|
+
}
|
|
6666
|
+
const codePoints = Array.from(texts.join("\n"));
|
|
6667
|
+
const truncated = codePoints.length > MAX_QUEUE_TEXT_CODE_POINTS;
|
|
6668
|
+
const source = message.source;
|
|
6669
|
+
const clientSendId = source?.kind === "user" ? clientSendIdOf(source.rpcId) : void 0;
|
|
6670
|
+
return {
|
|
6671
|
+
itemId: message.id,
|
|
6672
|
+
placement,
|
|
6673
|
+
text: truncated ? codePoints.slice(0, MAX_QUEUE_TEXT_CODE_POINTS).join("") : codePoints.join(""),
|
|
6674
|
+
...truncated ? { truncated: true } : {},
|
|
6675
|
+
imageCount,
|
|
6676
|
+
documentCount,
|
|
6677
|
+
...clientSendId !== void 0 ? { clientSendId } : {}
|
|
6678
|
+
};
|
|
6679
|
+
}
|
|
6680
|
+
/** Bound by serialized UTF-8 size, including escaping, without splitting a Unicode code point. */
|
|
6681
|
+
function bounded(payload) {
|
|
6682
|
+
if (Buffer.byteLength(JSON.stringify(payload)) <= 262144) return payload;
|
|
6683
|
+
const points = Array.from(payload.text);
|
|
6684
|
+
let low = 0;
|
|
6685
|
+
let high = points.length;
|
|
6686
|
+
while (low < high) {
|
|
6687
|
+
const mid = Math.ceil((low + high) / 2);
|
|
6688
|
+
const candidate = {
|
|
6689
|
+
...payload,
|
|
6690
|
+
text: points.slice(0, mid).join(""),
|
|
6691
|
+
truncated: true
|
|
6692
|
+
};
|
|
6693
|
+
if (Buffer.byteLength(JSON.stringify(candidate)) <= 262144) low = mid;
|
|
6694
|
+
else high = mid - 1;
|
|
6695
|
+
}
|
|
6696
|
+
return {
|
|
6697
|
+
...payload,
|
|
6698
|
+
text: points.slice(0, low).join(""),
|
|
6699
|
+
truncated: true
|
|
6700
|
+
};
|
|
6701
|
+
}
|
|
6702
|
+
function isFrame(value) {
|
|
6703
|
+
if (!value || typeof value !== "object") return false;
|
|
6704
|
+
const frame = value;
|
|
6705
|
+
if (typeof frame.attemptId !== "string" || !frame.attemptId || !Number.isSafeInteger(frame.revision)) return false;
|
|
6706
|
+
if (frame.type === "start") return Number.isSafeInteger(frame.turn) && Number.isSafeInteger(frame.step);
|
|
6707
|
+
if (frame.type !== "chunk" && frame.type !== "end") return false;
|
|
6708
|
+
if (!Number.isSafeInteger(frame.index) || frame.index < 0) return false;
|
|
6709
|
+
if (frame.type === "chunk") return Number.isSafeInteger(frame.time) && !!frame.chunk && typeof frame.chunk.type === "string";
|
|
6710
|
+
return frame.outcome?.kind === "abandoned" || frame.outcome?.kind === "committed" && ["assistant/message", "assistant/attempt"].includes(frame.outcome.eventType) && Number.isSafeInteger(frame.outcome.seq);
|
|
6711
|
+
}
|
|
6712
|
+
/**
|
|
6713
|
+
* One process-local attempt per session. Only the small pending window retains text;
|
|
6714
|
+
* the durable session event remains the source of truth for the completed reply.
|
|
6715
|
+
*/
|
|
6716
|
+
var AssistantStream = class {
|
|
6717
|
+
emit;
|
|
6718
|
+
isViewed;
|
|
6719
|
+
attempts = /* @__PURE__ */ new Map();
|
|
6720
|
+
constructor(emit, isViewed) {
|
|
6721
|
+
this.emit = emit;
|
|
6722
|
+
this.isViewed = isViewed;
|
|
6723
|
+
}
|
|
6724
|
+
accept(sessionId, value) {
|
|
6725
|
+
if (!sessionId || !isFrame(value)) return;
|
|
6726
|
+
const frame = value;
|
|
6727
|
+
let state = this.attempts.get(sessionId);
|
|
6728
|
+
if (frame.type === "start") {
|
|
6729
|
+
if (state?.attemptId === frame.attemptId && frame.revision <= state.revision) return;
|
|
6730
|
+
this.abandon(sessionId);
|
|
6731
|
+
state = {
|
|
6732
|
+
attemptId: frame.attemptId,
|
|
6733
|
+
streamId: randomUUID(),
|
|
6734
|
+
revision: frame.revision,
|
|
6735
|
+
nextIndex: 0,
|
|
6736
|
+
turn: frame.turn,
|
|
6737
|
+
step: frame.step,
|
|
6738
|
+
pending: [],
|
|
6739
|
+
pendingBytes: 0
|
|
6740
|
+
};
|
|
6741
|
+
this.attempts.set(sessionId, state);
|
|
6742
|
+
this.emit({
|
|
6743
|
+
sessionId,
|
|
6744
|
+
streamId: state.streamId,
|
|
6745
|
+
kind: "stream.start",
|
|
6746
|
+
text: "",
|
|
6747
|
+
ts: Date.now()
|
|
6748
|
+
});
|
|
6749
|
+
return;
|
|
6750
|
+
}
|
|
6751
|
+
if (!state || state.attemptId !== frame.attemptId || frame.revision <= state.revision || frame.index < state.nextIndex) return;
|
|
6752
|
+
state.revision = frame.revision;
|
|
6753
|
+
if (frame.type === "end") {
|
|
6754
|
+
if (frame.outcome.kind === "committed") this.flush(sessionId);
|
|
6755
|
+
this.clearPending(sessionId);
|
|
6756
|
+
this.attempts.delete(sessionId);
|
|
6757
|
+
this.emit({
|
|
6758
|
+
sessionId,
|
|
6759
|
+
streamId: state.streamId,
|
|
6760
|
+
kind: "stream.end",
|
|
6761
|
+
text: "",
|
|
6762
|
+
ts: Date.now(),
|
|
6763
|
+
outcome: frame.outcome
|
|
6764
|
+
});
|
|
6765
|
+
return;
|
|
6766
|
+
}
|
|
6767
|
+
state.nextIndex = frame.index + 1;
|
|
6768
|
+
if (state.finalSeq !== void 0 || !this.isViewed(sessionId)) return;
|
|
6769
|
+
const kind = frame.chunk.type === "text-delta" ? "message.delta" : frame.chunk.type === "reasoning-delta" ? "thinking.delta" : void 0;
|
|
6770
|
+
if (!kind || typeof frame.chunk.text !== "string" || frame.chunk.text.length === 0) return;
|
|
6771
|
+
const previous = state.pending.at(-1)?.kind === kind ? state.pending.at(-1) : void 0;
|
|
6772
|
+
if (previous?.truncated) return;
|
|
6773
|
+
const payload = bounded({
|
|
6774
|
+
sessionId,
|
|
6775
|
+
streamId: state.streamId,
|
|
6776
|
+
kind,
|
|
6777
|
+
text: (previous?.text ?? "") + frame.chunk.text,
|
|
6778
|
+
ts: frame.time
|
|
6779
|
+
});
|
|
6780
|
+
const next = {
|
|
6781
|
+
kind,
|
|
6782
|
+
text: payload.text,
|
|
6783
|
+
ts: payload.ts,
|
|
6784
|
+
...payload.truncated ? { truncated: true } : {}
|
|
6785
|
+
};
|
|
6786
|
+
const oldBytes = previous ? Buffer.byteLength(JSON.stringify(previous)) : 0;
|
|
6787
|
+
const newBytes = Buffer.byteLength(JSON.stringify(next));
|
|
6788
|
+
if (state.pendingBytes - oldBytes + newBytes > 524288) this.flush(sessionId);
|
|
6789
|
+
if (previous && state.pending.at(-1) === previous) {
|
|
6790
|
+
state.pending[state.pending.length - 1] = next;
|
|
6791
|
+
state.pendingBytes -= oldBytes;
|
|
6792
|
+
} else if (previous) {
|
|
6793
|
+
const tail = bounded({
|
|
6794
|
+
sessionId,
|
|
6795
|
+
streamId: state.streamId,
|
|
6796
|
+
kind,
|
|
6797
|
+
text: frame.chunk.text,
|
|
6798
|
+
ts: frame.time
|
|
6799
|
+
});
|
|
6800
|
+
next.text = tail.text;
|
|
6801
|
+
next.truncated = tail.truncated ? true : void 0;
|
|
6802
|
+
state.pending.push(next);
|
|
6803
|
+
} else state.pending.push(next);
|
|
6804
|
+
state.pendingBytes += Buffer.byteLength(JSON.stringify(next));
|
|
6805
|
+
state.timer ??= setTimeout(() => this.flush(sessionId), 50);
|
|
6806
|
+
state.timer.unref?.();
|
|
6807
|
+
}
|
|
6808
|
+
/** The Host appends the durable event BEFORE publishing committed end. */
|
|
6809
|
+
beforeEvent(sessionId, event) {
|
|
6810
|
+
const state = this.attempts.get(sessionId);
|
|
6811
|
+
if (!state || event.type !== "assistant/message" && event.type !== "assistant/attempt") return void 0;
|
|
6812
|
+
const data = event.data;
|
|
6813
|
+
if (data?.turn !== state.turn || data?.step !== state.step) return void 0;
|
|
6814
|
+
this.flush(sessionId);
|
|
6815
|
+
state.finalSeq = event.seq;
|
|
6816
|
+
return state.streamId;
|
|
6817
|
+
}
|
|
6818
|
+
flush(sessionId) {
|
|
6819
|
+
const state = this.attempts.get(sessionId);
|
|
6820
|
+
if (!state) return;
|
|
6821
|
+
if (state.timer) clearTimeout(state.timer);
|
|
6822
|
+
state.timer = void 0;
|
|
6823
|
+
const pending = state.pending;
|
|
6824
|
+
state.pending = [];
|
|
6825
|
+
state.pendingBytes = 0;
|
|
6826
|
+
for (const text of pending) this.emit({
|
|
6827
|
+
sessionId,
|
|
6828
|
+
streamId: state.streamId,
|
|
6829
|
+
...text
|
|
6830
|
+
});
|
|
6831
|
+
}
|
|
6832
|
+
/** Closing the last viewer must not deliver its buffered text on a later open. */
|
|
6833
|
+
clearPending(sessionId) {
|
|
6834
|
+
const state = this.attempts.get(sessionId);
|
|
6835
|
+
if (!state) return;
|
|
6836
|
+
if (state.timer) clearTimeout(state.timer);
|
|
6837
|
+
state.timer = void 0;
|
|
6838
|
+
state.pending = [];
|
|
6839
|
+
state.pendingBytes = 0;
|
|
6840
|
+
}
|
|
6841
|
+
abandon(sessionId) {
|
|
6842
|
+
const state = this.attempts.get(sessionId);
|
|
6843
|
+
if (!state) return;
|
|
6844
|
+
this.clearPending(sessionId);
|
|
6845
|
+
this.attempts.delete(sessionId);
|
|
6846
|
+
this.emit({
|
|
6847
|
+
sessionId,
|
|
6848
|
+
streamId: state.streamId,
|
|
6849
|
+
kind: "stream.end",
|
|
6850
|
+
text: "",
|
|
6851
|
+
ts: Date.now(),
|
|
6852
|
+
outcome: { kind: "abandoned" }
|
|
6853
|
+
});
|
|
6854
|
+
}
|
|
6855
|
+
dispose() {
|
|
6856
|
+
for (const sessionId of this.attempts.keys()) this.clearPending(sessionId);
|
|
6857
|
+
this.attempts.clear();
|
|
6858
|
+
}
|
|
6859
|
+
};
|
|
6860
|
+
//#endregion
|
|
5905
6861
|
//#region src/host-bridge.ts
|
|
5906
6862
|
const MAX_RING_DEFAULT = 2e3;
|
|
5907
6863
|
/**
|
|
@@ -5924,7 +6880,13 @@ var HostBridge = class {
|
|
|
5924
6880
|
* and restore them. Excluded from `summaries` and from the live broadcast. */
|
|
5925
6881
|
archivedSummaries = /* @__PURE__ */ new Map();
|
|
5926
6882
|
subagentSessionIds = /* @__PURE__ */ new Set();
|
|
6883
|
+
/**
|
|
6884
|
+
* 每个会话最近一次已知的队列(inbox 投影的折叠结果)及其指纹。来源是实时
|
|
6885
|
+
* 投影通知或打开会话时读到的基线;指纹用来丢掉内容没变的重复通知。
|
|
6886
|
+
*/
|
|
6887
|
+
queues = /* @__PURE__ */ new Map();
|
|
5927
6888
|
sinks = /* @__PURE__ */ new Set();
|
|
6889
|
+
assistantStream = new AssistantStream((payload) => this.pushAssistantDelta(payload), (sessionId) => this.hasStreamViewer(sessionId));
|
|
5928
6890
|
ring = [];
|
|
5929
6891
|
cursor = 0;
|
|
5930
6892
|
userReceiptSeq = 0;
|
|
@@ -5988,6 +6950,7 @@ var HostBridge = class {
|
|
|
5988
6950
|
}
|
|
5989
6951
|
removeSink(sink) {
|
|
5990
6952
|
this.sinks.delete(sink);
|
|
6953
|
+
this.dropSinkSessions(sink);
|
|
5991
6954
|
}
|
|
5992
6955
|
/** Whether the ring still holds everything after the cursor. */
|
|
5993
6956
|
canResumeFrom(cursor) {
|
|
@@ -6007,9 +6970,21 @@ var HostBridge = class {
|
|
|
6007
6970
|
}
|
|
6008
6971
|
markSinkClosed(sink, sessionId) {
|
|
6009
6972
|
this.sinkSessions.get(sink)?.delete(sessionId);
|
|
6973
|
+
if (!this.hasStreamViewer(sessionId)) this.assistantStream.clearPending(sessionId);
|
|
6010
6974
|
}
|
|
6011
6975
|
dropSinkSessions(sink) {
|
|
6976
|
+
const sessions = this.sinkSessions.get(sink);
|
|
6012
6977
|
this.sinkSessions.delete(sink);
|
|
6978
|
+
for (const sessionId of sessions ?? []) if (!this.hasStreamViewer(sessionId)) this.assistantStream.clearPending(sessionId);
|
|
6979
|
+
}
|
|
6980
|
+
hasStreamViewer(sessionId) {
|
|
6981
|
+
if (this.disposed || this.subagentSessionIds.has(sessionId)) return false;
|
|
6982
|
+
return [...this.sinks].some((sink) => this.isViewedBy(sink, sessionId) && sink.canReceive("sessions.read"));
|
|
6983
|
+
}
|
|
6984
|
+
/** Instant publications bypass record(): no cursor, ring, notification or push side effect. */
|
|
6985
|
+
pushAssistantDelta(payload) {
|
|
6986
|
+
if (this.disposed || this.subagentSessionIds.has(payload.sessionId)) return;
|
|
6987
|
+
for (const sink of this.sinks) if (this.isViewedBy(sink, payload.sessionId) && sink.canReceive("sessions.read")) sink.push("s2c.session.delta", payload);
|
|
6013
6988
|
}
|
|
6014
6989
|
isViewedBy(sink, sessionId) {
|
|
6015
6990
|
return this.sinkSessions.get(sink)?.has(sessionId) ?? false;
|
|
@@ -6020,8 +6995,10 @@ var HostBridge = class {
|
|
|
6020
6995
|
* PROTOCOL §6 + §7), then fan the same payload out to offline devices
|
|
6021
6996
|
* holding an APNs token. */
|
|
6022
6997
|
emitNotify(args) {
|
|
6023
|
-
|
|
6024
|
-
|
|
6998
|
+
const turnEnd = args.category === "turn.completed" || args.category === "session.error";
|
|
6999
|
+
if (turnEnd && this.subagentSessionIds.has(args.sessionId)) return;
|
|
7000
|
+
const plain = notificationPlainText(args.body, turnEnd ? "last" : "all") || (args.fallbackBody ?? "");
|
|
7001
|
+
const body = plain.length > 120 ? plain.slice(0, 119) + "…" : plain;
|
|
6025
7002
|
const approvalExtras = args.category === "approval.required" && args.toolName ? {
|
|
6026
7003
|
toolName: args.toolName.slice(0, 80),
|
|
6027
7004
|
...args.detail ? { detail: args.detail.slice(0, 600) } : {}
|
|
@@ -6049,12 +7026,12 @@ var HostBridge = class {
|
|
|
6049
7026
|
if (this.subagentSessionIds.has(sessionId)) return;
|
|
6050
7027
|
const row = this.summaries.get(sessionId);
|
|
6051
7028
|
const title = ok ? "任务完成" : "任务异常结束";
|
|
6052
|
-
const body = this.lastAssistantText.get(sessionId) ?? row?.title ?? "";
|
|
6053
7029
|
this.emitNotify({
|
|
6054
7030
|
sessionId,
|
|
6055
7031
|
category: ok ? "turn.completed" : "session.error",
|
|
6056
7032
|
title,
|
|
6057
|
-
body,
|
|
7033
|
+
body: this.lastAssistantText.get(sessionId) ?? "",
|
|
7034
|
+
fallbackBody: row?.title ?? "",
|
|
6058
7035
|
notificationId: "n-" + (this.cursor + 1)
|
|
6059
7036
|
});
|
|
6060
7037
|
}
|
|
@@ -6067,11 +7044,15 @@ var HostBridge = class {
|
|
|
6067
7044
|
this.pushOutlet?.fanOut(notification);
|
|
6068
7045
|
} catch {}
|
|
6069
7046
|
}
|
|
6070
|
-
/**
|
|
7047
|
+
/**
|
|
7048
|
+
* Remember the latest assistant text so notifications can quote it. 存完整原文:
|
|
7049
|
+
* 按字符数截尾会从链接地址中间切开(横幅只剩 URL 编码的路径),段落选择与
|
|
7050
|
+
* Markdown 清洗统一留到 emitNotify。条目随 sessions.list / 归档清理。
|
|
7051
|
+
*/
|
|
6071
7052
|
captureAssistantText(sessionId, event) {
|
|
6072
7053
|
if (event.type !== "assistant/message") return;
|
|
6073
7054
|
const text = messageText(event.data).trim();
|
|
6074
|
-
if (text.length > 0) this.lastAssistantText.set(sessionId, text
|
|
7055
|
+
if (text.length > 0) this.lastAssistantText.set(sessionId, text);
|
|
6075
7056
|
}
|
|
6076
7057
|
/**
|
|
6077
7058
|
* Replay buffered pushes after the given cursor; false when the gap is
|
|
@@ -6150,6 +7131,7 @@ var HostBridge = class {
|
|
|
6150
7131
|
if (this.disposed) return;
|
|
6151
7132
|
this.disposed = true;
|
|
6152
7133
|
this.abort.abort();
|
|
7134
|
+
this.assistantStream.dispose();
|
|
6153
7135
|
this.sinks.clear();
|
|
6154
7136
|
this.sinkSessions.clear();
|
|
6155
7137
|
this.pushOutlet = void 0;
|
|
@@ -6168,7 +7150,9 @@ var HostBridge = class {
|
|
|
6168
7150
|
const frame = unwrapStreamItem(item);
|
|
6169
7151
|
this.onMuxFrame(frame);
|
|
6170
7152
|
}
|
|
6171
|
-
} catch {}
|
|
7153
|
+
} catch {} finally {
|
|
7154
|
+
this.assistantStream.dispose();
|
|
7155
|
+
}
|
|
6172
7156
|
}
|
|
6173
7157
|
onHostFrame(frame) {
|
|
6174
7158
|
switch (frame.type) {
|
|
@@ -6207,12 +7191,30 @@ var HostBridge = class {
|
|
|
6207
7191
|
case "commands/changed":
|
|
6208
7192
|
this.record("s2c.commands.changed", {});
|
|
6209
7193
|
break;
|
|
7194
|
+
case "session/assistant-stream": {
|
|
7195
|
+
const sessionId = String(frame.sessionId ?? "");
|
|
7196
|
+
if (frame.isSubagent === true) this.subagentSessionIds.add(sessionId);
|
|
7197
|
+
if (this.subagentSessionIds.has(sessionId)) {
|
|
7198
|
+
this.assistantStream.abandon(sessionId);
|
|
7199
|
+
break;
|
|
7200
|
+
}
|
|
7201
|
+
this.assistantStream.accept(sessionId, frame.value);
|
|
7202
|
+
break;
|
|
7203
|
+
}
|
|
7204
|
+
case "session/assistant-stream-disposed":
|
|
7205
|
+
this.assistantStream.abandon(String(frame.sessionId ?? ""));
|
|
7206
|
+
break;
|
|
6210
7207
|
case "session/event": {
|
|
6211
7208
|
const event = frame.event;
|
|
6212
7209
|
const sessionId = String(frame.sessionId ?? "");
|
|
6213
7210
|
if (!event || !sessionId) break;
|
|
6214
7211
|
if (frame.isSubagent === true) this.subagentSessionIds.add(sessionId);
|
|
6215
|
-
if (this.subagentSessionIds.has(sessionId))
|
|
7212
|
+
if (this.subagentSessionIds.has(sessionId)) {
|
|
7213
|
+
this.assistantStream.abandon(sessionId);
|
|
7214
|
+
break;
|
|
7215
|
+
}
|
|
7216
|
+
const streamId = this.assistantStream.beforeEvent(sessionId, event);
|
|
7217
|
+
if (event.type === "turn/end") this.assistantStream.abandon(sessionId);
|
|
6216
7218
|
if (event.type === "agent-preset/selected") this.record("s2c.skills.changed", { sessionId });
|
|
6217
7219
|
this.noteActivity(sessionId, event);
|
|
6218
7220
|
this.captureAssistantText(sessionId, event);
|
|
@@ -6222,7 +7224,10 @@ var HostBridge = class {
|
|
|
6222
7224
|
sessionId,
|
|
6223
7225
|
kind: projection.kind,
|
|
6224
7226
|
seq: event.seq,
|
|
6225
|
-
data:
|
|
7227
|
+
data: {
|
|
7228
|
+
...projection.data,
|
|
7229
|
+
...streamId && projection.kind === "message.final" ? { streamId } : {}
|
|
7230
|
+
}
|
|
6226
7231
|
});
|
|
6227
7232
|
if (projection?.kind === "turn.end") this.emitTurnCompletedNotify(sessionId, projection.data.ok === true);
|
|
6228
7233
|
break;
|
|
@@ -6243,6 +7248,7 @@ var HostBridge = class {
|
|
|
6243
7248
|
...current ? { current } : {}
|
|
6244
7249
|
});
|
|
6245
7250
|
}
|
|
7251
|
+
if (p.key === "inbox") this.applyInbox(p.sessionId, p.value, "live");
|
|
6246
7252
|
this.applyProjection(p.sessionId, String(p.key ?? ""), p.value);
|
|
6247
7253
|
break;
|
|
6248
7254
|
}
|
|
@@ -6418,6 +7424,43 @@ var HostBridge = class {
|
|
|
6418
7424
|
row.lastActivityTs = Date.now();
|
|
6419
7425
|
this.pushSummary(row);
|
|
6420
7426
|
}
|
|
7427
|
+
/**
|
|
7428
|
+
* 折叠一次 inbox 投影。只有实时变化才广播 s2c.session.queue;基线只补缓存,
|
|
7429
|
+
* 打开会话的那台设备另由 pushQueueSnapshot 点对点拿到快照。
|
|
7430
|
+
*
|
|
7431
|
+
* 基线只补缺不覆盖:实时通知带的是完整投影值,收到过就以它为准;基线读取与
|
|
7432
|
+
* 实时变化并发时,较早算出的基线不得盖掉较新的实时值。
|
|
7433
|
+
*/
|
|
7434
|
+
applyInbox(sessionId, value, origin) {
|
|
7435
|
+
if (this.capabilities.promptQueue !== true) return;
|
|
7436
|
+
if (this.subagentSessionIds.has(sessionId)) return;
|
|
7437
|
+
if (origin === "baseline" && this.queues.has(sessionId)) return;
|
|
7438
|
+
const items = projectQueue(value);
|
|
7439
|
+
if (items === void 0) return;
|
|
7440
|
+
const fingerprint = JSON.stringify(items);
|
|
7441
|
+
const previous = this.queues.get(sessionId);
|
|
7442
|
+
this.queues.set(sessionId, {
|
|
7443
|
+
items,
|
|
7444
|
+
fingerprint
|
|
7445
|
+
});
|
|
7446
|
+
if (origin === "live" && previous?.fingerprint !== fingerprint) this.record("s2c.session.queue", {
|
|
7447
|
+
sessionId,
|
|
7448
|
+
items
|
|
7449
|
+
});
|
|
7450
|
+
}
|
|
7451
|
+
/**
|
|
7452
|
+
* 打开会话时补发的快照:不占 seq,与 tail 同属打开响应。还没读到过该会话的
|
|
7453
|
+
* 投影(基线失败、Host 不提供或子代理会话)时不发,因为未知不等于空队列。
|
|
7454
|
+
*/
|
|
7455
|
+
pushQueueSnapshot(sink, sessionId) {
|
|
7456
|
+
if (this.disposed) return;
|
|
7457
|
+
const queue = this.queues.get(sessionId);
|
|
7458
|
+
if (queue === void 0) return;
|
|
7459
|
+
sink.push("s2c.session.queue", {
|
|
7460
|
+
sessionId,
|
|
7461
|
+
items: queue.items
|
|
7462
|
+
});
|
|
7463
|
+
}
|
|
6421
7464
|
applyProjection(sessionId, key, value) {
|
|
6422
7465
|
const row = this.summaries.get(sessionId);
|
|
6423
7466
|
if (!row) return;
|
|
@@ -6544,7 +7587,7 @@ var HostBridge = class {
|
|
|
6544
7587
|
hasMore: Boolean(result.hasMore) || page.dropped > 0
|
|
6545
7588
|
});
|
|
6546
7589
|
this.deriveTitleFallback(sessionId, messages);
|
|
6547
|
-
this.refreshProjections(sessionId);
|
|
7590
|
+
this.refreshProjections(sessionId).then(() => this.pushQueueSnapshot(sink, sessionId));
|
|
6548
7591
|
return true;
|
|
6549
7592
|
} catch {
|
|
6550
7593
|
return false;
|
|
@@ -6570,8 +7613,14 @@ var HostBridge = class {
|
|
|
6570
7613
|
return;
|
|
6571
7614
|
}
|
|
6572
7615
|
const baseline = result.value;
|
|
6573
|
-
if (baseline === null || baseline === void 0)
|
|
6574
|
-
|
|
7616
|
+
if (baseline === null || baseline === void 0) {
|
|
7617
|
+
this.queues.delete(sessionId);
|
|
7618
|
+
return;
|
|
7619
|
+
}
|
|
7620
|
+
for (const [key, value] of Object.entries(baseline.values ?? {})) {
|
|
7621
|
+
if (key === "inbox") this.applyInbox(sessionId, value, "baseline");
|
|
7622
|
+
this.applyProjection(sessionId, key, value);
|
|
7623
|
+
}
|
|
6575
7624
|
} catch (error) {
|
|
6576
7625
|
this.diagnostic("sessions.projections threw: " + String(error?.message ?? error));
|
|
6577
7626
|
}
|
|
@@ -6642,12 +7691,15 @@ var HostBridge = class {
|
|
|
6642
7691
|
return wireErrorOf("command", hostFailureOf(error, "command/invalid-response"));
|
|
6643
7692
|
}
|
|
6644
7693
|
}
|
|
6645
|
-
async exportSession(sessionId, signal) {
|
|
7694
|
+
async exportSession(sessionId, signal, format) {
|
|
6646
7695
|
const api = this.apiProxy.commands;
|
|
6647
7696
|
if (!api) throw Object.assign(/* @__PURE__ */ new Error("当前主机不支持日志导出"), { code: "command/export-unsupported" });
|
|
6648
7697
|
return api.export({
|
|
6649
7698
|
rpcId: randomUUID(),
|
|
6650
|
-
payload: {
|
|
7699
|
+
payload: {
|
|
7700
|
+
sessionId,
|
|
7701
|
+
...format === void 0 ? {} : { format }
|
|
7702
|
+
}
|
|
6651
7703
|
}, signal);
|
|
6652
7704
|
}
|
|
6653
7705
|
/** 由会话目录决定技能可见性;不读取正文,也不把本机路径发给移动端。 */
|
|
@@ -6863,6 +7915,47 @@ var HostBridge = class {
|
|
|
6863
7915
|
};
|
|
6864
7916
|
}
|
|
6865
7917
|
}
|
|
7918
|
+
/**
|
|
7919
|
+
* 删除或转向一条排队项。ack 只表示 Host 已提交变更,真实状态以随后的
|
|
7920
|
+
* s2c.session.queue 为准;不做幂等,项已消失时 Host 回 queue-item-not-found。
|
|
7921
|
+
*/
|
|
7922
|
+
async updateQueue(sessionId, itemId, action) {
|
|
7923
|
+
const update = this.apiProxy.sessions.updateQueue;
|
|
7924
|
+
if (typeof update !== "function") return {
|
|
7925
|
+
ok: false,
|
|
7926
|
+
code: "E_UNSUPPORTED",
|
|
7927
|
+
message: "prompt queue unavailable on this host version"
|
|
7928
|
+
};
|
|
7929
|
+
try {
|
|
7930
|
+
const response = await update.call(this.apiProxy.sessions, {
|
|
7931
|
+
rpcId: randomUUID(),
|
|
7932
|
+
payload: {
|
|
7933
|
+
sessionId,
|
|
7934
|
+
itemId,
|
|
7935
|
+
action: { kind: action }
|
|
7936
|
+
}
|
|
7937
|
+
});
|
|
7938
|
+
if (!response.result) return {
|
|
7939
|
+
ok: false,
|
|
7940
|
+
code: "E_INTERNAL",
|
|
7941
|
+
message: "queue update returned no result"
|
|
7942
|
+
};
|
|
7943
|
+
if (!response.result.ok) return wireErrorOf("queue", response.result.error);
|
|
7944
|
+
return {
|
|
7945
|
+
ok: true,
|
|
7946
|
+
value: {
|
|
7947
|
+
sessionId,
|
|
7948
|
+
itemId
|
|
7949
|
+
}
|
|
7950
|
+
};
|
|
7951
|
+
} catch (error) {
|
|
7952
|
+
return {
|
|
7953
|
+
ok: false,
|
|
7954
|
+
code: "E_INTERNAL",
|
|
7955
|
+
message: String(error)
|
|
7956
|
+
};
|
|
7957
|
+
}
|
|
7958
|
+
}
|
|
6866
7959
|
async cancelSession(sessionId) {
|
|
6867
7960
|
const cancel = this.apiProxy.sessions.cancel;
|
|
6868
7961
|
if (typeof cancel !== "function") return {
|
|
@@ -7296,7 +8389,7 @@ var HostBridge = class {
|
|
|
7296
8389
|
replayed: result.replayed
|
|
7297
8390
|
};
|
|
7298
8391
|
}
|
|
7299
|
-
async sendPrompt(sessionId, text, images = [], documents = []) {
|
|
8392
|
+
async sendPrompt(sessionId, text, images = [], documents = [], options = {}) {
|
|
7300
8393
|
try {
|
|
7301
8394
|
const content = [];
|
|
7302
8395
|
if (text.trim().length > 0) content.push({
|
|
@@ -7312,10 +8405,10 @@ var HostBridge = class {
|
|
|
7312
8405
|
text: documentPromptBlock(document)
|
|
7313
8406
|
});
|
|
7314
8407
|
const response = await this.apiProxy.sessions.prompt({
|
|
7315
|
-
rpcId: randomUUID(),
|
|
8408
|
+
rpcId: options.clientSendId !== void 0 ? promptRequestId(options.clientSendId) : randomUUID(),
|
|
7316
8409
|
payload: {
|
|
7317
8410
|
sessionId,
|
|
7318
|
-
mode: "queue",
|
|
8411
|
+
mode: options.mode ?? "queue",
|
|
7319
8412
|
content,
|
|
7320
8413
|
clientTimeZone: localTimeZone()
|
|
7321
8414
|
}
|
|
@@ -7847,6 +8940,11 @@ var DshCommandApi = class {
|
|
|
7847
8940
|
get available() {
|
|
7848
8941
|
return this.ctx.get("commands") !== void 0 && typeof this.ctx.get("typertGateway")?.invoke === "function";
|
|
7849
8942
|
}
|
|
8943
|
+
get markdownAvailable() {
|
|
8944
|
+
const connection = this.connection();
|
|
8945
|
+
const route = connection?.fetchRoutes instanceof Map ? connection.fetchRoutes.get("/api/session.markdown") : void 0;
|
|
8946
|
+
return typeof connection?.createSharedFetchHandler === "function" && route?.methods instanceof Set && route.methods.has("GET") && typeof route.fetch === "function" && typeof this.ctx.get("sessionQuery")?.readSession === "function";
|
|
8947
|
+
}
|
|
7850
8948
|
connection() {
|
|
7851
8949
|
return this.ctx.get("connection");
|
|
7852
8950
|
}
|
|
@@ -7957,9 +9055,13 @@ var DshCommandApi = class {
|
|
|
7957
9055
|
} });
|
|
7958
9056
|
if (result?.ok !== true) throw fail(typeof result?.error?.code === "string" ? result.error.code : "command/invalid-response", typeof result?.error?.message === "string" ? result.error.message : "反馈未能保存");
|
|
7959
9057
|
}
|
|
7960
|
-
async export(sessionId, signal) {
|
|
7961
|
-
const
|
|
7962
|
-
|
|
9058
|
+
async export(sessionId, signal, format) {
|
|
9059
|
+
const markdown = format === "markdown";
|
|
9060
|
+
const path = markdown ? "/api/session.markdown?sessionId=" : "/api/session.export?includeDescendants=true&sessionId=";
|
|
9061
|
+
const response = await this.connection().createSharedFetchHandler("/api").fetch(new Request("http://localhost" + path + encodeURIComponent(sessionId), { signal }), this.owner());
|
|
9062
|
+
const contentType = response.headers.get("content-type");
|
|
9063
|
+
const matches = markdown ? contentType?.split(";")[0]?.trim().toLowerCase() === "text/markdown" : contentType?.includes("application/zip");
|
|
9064
|
+
if (!response.ok || !response.body || !matches) throw fail("command/export-unavailable", markdown ? "主机未能生成会话对话正文" : "主机未能生成会话日志归档");
|
|
7963
9065
|
return response;
|
|
7964
9066
|
}
|
|
7965
9067
|
};
|
|
@@ -7976,6 +9078,15 @@ var DshCommandApi = class {
|
|
|
7976
9078
|
/** Direct-controller adapter with the shape HostBridge consumes. */
|
|
7977
9079
|
var DshApiProxy = class {
|
|
7978
9080
|
ctx;
|
|
9081
|
+
/** Probe the Host's generated reflection, not this adapter's listener registration. */
|
|
9082
|
+
get assistantStreamAvailable() {
|
|
9083
|
+
const typert = this.ctx.get("typert");
|
|
9084
|
+
try {
|
|
9085
|
+
return typert?.getPackage?.("@deepseek-ai/dsh-api-session-controller", "host")?.model?.services?.some((service) => service.key === "sessionController" && service.types?.some((type) => type.name === "SessionAssistantStreamFrame")) === true;
|
|
9086
|
+
} catch {
|
|
9087
|
+
return false;
|
|
9088
|
+
}
|
|
9089
|
+
}
|
|
7979
9090
|
commandApi;
|
|
7980
9091
|
get commands() {
|
|
7981
9092
|
if (!this.commandApi.available) return void 0;
|
|
@@ -7984,7 +9095,8 @@ var DshApiProxy = class {
|
|
|
7984
9095
|
execute: (request) => this.call(() => this.commandApi.execute(request.payload.sessionId, request.payload.line, request.payload.attachments, request.payload.expectedDefinitionId)),
|
|
7985
9096
|
permissions: (request) => this.call(() => this.commandApi.permissions(request.payload.sessionId)),
|
|
7986
9097
|
feedback: (request) => this.call(() => this.commandApi.feedback(request.payload.sessionId, request.payload.text, request.payload.category)),
|
|
7987
|
-
|
|
9098
|
+
markdownAvailable: this.commandApi.markdownAvailable,
|
|
9099
|
+
export: (request, signal) => this.commandApi.export(request.payload.sessionId, signal, request.payload.format)
|
|
7988
9100
|
};
|
|
7989
9101
|
}
|
|
7990
9102
|
session;
|
|
@@ -8002,6 +9114,7 @@ var DshApiProxy = class {
|
|
|
8002
9114
|
this.session = session;
|
|
8003
9115
|
this.workspaceController = ctx.get("workspaceController");
|
|
8004
9116
|
if (this.workspaceController === void 0) delete this.workspace?.unarchiveSession;
|
|
9117
|
+
if (typeof this.session.updateQueue !== "function") delete this.sessions.updateQueue;
|
|
8005
9118
|
this.directoryPicker = ctx.get("directoryPickerController");
|
|
8006
9119
|
this.shouldSurfaceInteraction = options.shouldSurfaceInteraction ?? (() => true);
|
|
8007
9120
|
this.resolveSchedule();
|
|
@@ -8105,16 +9218,33 @@ var DshApiProxy = class {
|
|
|
8105
9218
|
}),
|
|
8106
9219
|
history: async (request) => this.call(async () => {
|
|
8107
9220
|
const sessionId = request.payload.sessionId;
|
|
9221
|
+
const beforeSeq = request.payload?.beforeSeq;
|
|
9222
|
+
const limit = normalizePageLimit(request.payload?.maxMessages);
|
|
9223
|
+
const inspect = this.session.inspect;
|
|
9224
|
+
if (inspect === void 0) {
|
|
9225
|
+
const page = await this.session.page({
|
|
9226
|
+
address: {
|
|
9227
|
+
kind: "session",
|
|
9228
|
+
sessionId
|
|
9229
|
+
},
|
|
9230
|
+
...beforeSeq === void 0 ? {} : { throughSeq: beforeSeq - 1 },
|
|
9231
|
+
maxMessages: limit
|
|
9232
|
+
}, new AbortController().signal);
|
|
9233
|
+
return {
|
|
9234
|
+
events: page.records.map((record) => ({ event: record.event })),
|
|
9235
|
+
hasMore: page.hasMore
|
|
9236
|
+
};
|
|
9237
|
+
}
|
|
8108
9238
|
let inspected;
|
|
8109
9239
|
try {
|
|
8110
|
-
inspected = await this.session
|
|
9240
|
+
inspected = await inspect.call(this.session, sessionId);
|
|
8111
9241
|
} catch (error) {
|
|
8112
9242
|
console.warn(`[deeppilot] session history unavailable for ${JSON.stringify(sessionId)}: ${toError(error).code}: ${toError(error).message}`);
|
|
8113
9243
|
throw error;
|
|
8114
9244
|
}
|
|
8115
9245
|
const window = historyWindow(inspected.events, {
|
|
8116
|
-
beforeSeq
|
|
8117
|
-
limit
|
|
9246
|
+
beforeSeq,
|
|
9247
|
+
limit
|
|
8118
9248
|
});
|
|
8119
9249
|
return {
|
|
8120
9250
|
events: window.events,
|
|
@@ -8131,6 +9261,7 @@ var DshApiProxy = class {
|
|
|
8131
9261
|
selectModel: async (request) => this.call(() => this.session.selectModel(request.payload ?? {})),
|
|
8132
9262
|
rename: async (request) => this.call(() => this.session.rename(request.payload)),
|
|
8133
9263
|
cancel: async (request) => this.call(() => this.session.cancel(request.payload)),
|
|
9264
|
+
updateQueue: async (request) => this.call(() => this.session.updateQueue(request.payload)),
|
|
8134
9265
|
attachment: async (request) => this.call(() => this.session.attachment(request.payload)),
|
|
8135
9266
|
projections: async (request) => this.call(() => this.session.projections(request.payload, new AbortController().signal))
|
|
8136
9267
|
};
|
|
@@ -8198,6 +9329,18 @@ var DshApiProxy = class {
|
|
|
8198
9329
|
event
|
|
8199
9330
|
});
|
|
8200
9331
|
}), { global: true });
|
|
9332
|
+
const offAssistantStream = this.ctx.on("agent/assistant-stream", (({ agent, frame }) => {
|
|
9333
|
+
queue.push({
|
|
9334
|
+
type: "session/assistant-stream",
|
|
9335
|
+
sessionId: String(agent.session.id),
|
|
9336
|
+
isSubagent: agent.session.header?.origin === "subagent",
|
|
9337
|
+
value: frame
|
|
9338
|
+
});
|
|
9339
|
+
}), { global: true });
|
|
9340
|
+
const offAgentDisposed = this.ctx.on("agent/disposed", (({ agent }) => queue.push({
|
|
9341
|
+
type: "session/assistant-stream-disposed",
|
|
9342
|
+
sessionId: String(agent.session.id)
|
|
9343
|
+
})), { global: true });
|
|
8201
9344
|
const offProjection = this.ctx.get("sessionProjections")?.onChanged?.((session, key, value) => {
|
|
8202
9345
|
queue.push({
|
|
8203
9346
|
type: "session/projection",
|
|
@@ -8223,6 +9366,8 @@ var DshApiProxy = class {
|
|
|
8223
9366
|
yield* queue.iterate();
|
|
8224
9367
|
} finally {
|
|
8225
9368
|
offEvent();
|
|
9369
|
+
offAssistantStream();
|
|
9370
|
+
offAgentDisposed();
|
|
8226
9371
|
offProjection?.();
|
|
8227
9372
|
offSkills();
|
|
8228
9373
|
offCommands.forEach((dispose) => dispose());
|
|
@@ -8627,15 +9772,18 @@ function parseRemote(value) {
|
|
|
8627
9772
|
const publicURL = s.publicURL;
|
|
8628
9773
|
const authURL = s.authURL;
|
|
8629
9774
|
const message = s.message;
|
|
9775
|
+
const tailscaleVersion = s.tailscaleVersion;
|
|
8630
9776
|
if (publicURL !== void 0 && typeof publicURL !== "string") reject("remote.publicURL");
|
|
8631
9777
|
if (authURL !== void 0 && typeof authURL !== "string") reject("remote.authURL");
|
|
8632
9778
|
if (message !== void 0 && typeof message !== "string") reject("remote.message");
|
|
9779
|
+
if (tailscaleVersion !== void 0 && !isTailscaleVersion(tailscaleVersion)) reject("remote.tailscaleVersion");
|
|
8633
9780
|
return {
|
|
8634
9781
|
provider,
|
|
8635
9782
|
phase,
|
|
8636
9783
|
...typeof publicURL === "string" ? { publicURL } : {},
|
|
8637
9784
|
...typeof authURL === "string" ? { authURL } : {},
|
|
8638
9785
|
...typeof message === "string" ? { message } : {},
|
|
9786
|
+
...typeof tailscaleVersion === "string" ? { tailscaleVersion } : {},
|
|
8639
9787
|
updatedAt: int(s, "updatedAt", "remote.updatedAt")
|
|
8640
9788
|
};
|
|
8641
9789
|
}
|
|
@@ -9676,6 +10824,23 @@ function apply(ctx, options) {
|
|
|
9676
10824
|
return updated;
|
|
9677
10825
|
}, () => pushGateway.relayTest(), () => pushGateway.selfTest());
|
|
9678
10826
|
const state = {};
|
|
10827
|
+
let pluginUpdater;
|
|
10828
|
+
ctx.inject(["pluginManager"], (sub) => {
|
|
10829
|
+
const manager = sub.pluginManager;
|
|
10830
|
+
if (!supportsPluginUpdate(manager)) return;
|
|
10831
|
+
const updater = new PluginUpdater({
|
|
10832
|
+
manager,
|
|
10833
|
+
currentVersion: SERVER_VERSION,
|
|
10834
|
+
journalPath: join(dataDir, "update-journal.json")
|
|
10835
|
+
});
|
|
10836
|
+
pluginUpdater = updater;
|
|
10837
|
+
if (state.bridge) setPluginUpdater(state.bridge, updater);
|
|
10838
|
+
sub.effect(() => () => {
|
|
10839
|
+
if (pluginUpdater !== updater) return;
|
|
10840
|
+
pluginUpdater = void 0;
|
|
10841
|
+
if (state.bridge) setPluginUpdater(state.bridge, void 0);
|
|
10842
|
+
}, "deeppilot: optional plugin updater");
|
|
10843
|
+
});
|
|
9679
10844
|
let pendingUpgrades = 0;
|
|
9680
10845
|
const authRateLimiter = new AuthRateLimiter();
|
|
9681
10846
|
const readJSONBody = async (req, maxBytes = 16384) => {
|
|
@@ -10007,8 +11172,10 @@ function apply(ctx, options) {
|
|
|
10007
11172
|
log("DSH session bridge unavailable: " + String(error), "warn");
|
|
10008
11173
|
return;
|
|
10009
11174
|
}
|
|
11175
|
+
setPluginUpdateProbe(proxy, () => pluginUpdater !== void 0);
|
|
10010
11176
|
const bridge = new HostBridge(proxy, cfg.historyBufferMax, join(dataDir, "prompt-deliveries-v1.json"), join(dataDir, "schedule-mutations-v1.json"), join(dataDir, "fork-mutations-v1.json"), log);
|
|
10011
11177
|
bridge.setPushOutlet(pushGateway);
|
|
11178
|
+
setPluginUpdater(bridge, pluginUpdater);
|
|
10012
11179
|
state.bridge = bridge;
|
|
10013
11180
|
bridge.start();
|
|
10014
11181
|
log("data plane active (mux + host streams)");
|