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/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: sessionPayload,
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
- if ((args.category === "turn.completed" || args.category === "session.error") && this.subagentSessionIds.has(args.sessionId)) return;
6024
- const body = args.body.length > 120 ? args.body.slice(0, 119) + "…" : args.body;
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
- /** Remember the latest assistant text so notifications can quote it. */
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.slice(-160));
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)) break;
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: projection.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) return;
6574
- for (const [key, value] of Object.entries(baseline.values ?? {})) this.applyProjection(sessionId, key, value);
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: { sessionId }
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 response = await this.connection().createSharedFetchHandler("/api").fetch(new Request("http://localhost/api/session.export?includeDescendants=true&sessionId=" + encodeURIComponent(sessionId), { signal }), this.owner());
7962
- if (!response.ok || !response.body || !response.headers.get("content-type")?.includes("application/zip")) throw fail("command/export-unavailable", "主机未能生成会话日志归档");
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
- export: (request, signal) => this.commandApi.export(request.payload.sessionId, signal)
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.inspect(sessionId);
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: request.payload?.beforeSeq,
8117
- limit: normalizePageLimit(request.payload?.maxMessages)
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)");