dsh-deeppilot 0.2.2 → 0.4.0

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
@@ -1,9 +1,8 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { createServer } from "node:http";
3
- import { createPrivateKey, randomBytes, randomUUID, sign, timingSafeEqual } from "node:crypto";
4
- import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { createHash, createPrivateKey, randomBytes, randomUUID, sign, timingSafeEqual } from "node:crypto";
4
+ import { access, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
5
5
  import { dirname, join, resolve } from "node:path";
6
- import z from "@deepseek-ai/schemastery";
7
6
  import { WebSocketServer } from "ws";
8
7
  import { homedir, networkInterfaces } from "node:os";
9
8
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
@@ -13,6 +12,7 @@ import { spawn } from "node:child_process";
13
12
  import { constants } from "node:fs";
14
13
  import { fileURLToPath } from "node:url";
15
14
  import { request } from "node:https";
15
+ import z from "@deepseek-ai/schemastery";
16
16
  //#region src/token.ts
17
17
  /** Expand a leading ~ using the process home directory. */
18
18
  function expandHome(p) {
@@ -59,13 +59,30 @@ async function loadOrCreateToken(tokenPath) {
59
59
  try {
60
60
  const existing = (await readFile(full, "utf8")).trim();
61
61
  if (existing.length >= 32) return existing;
62
- } catch {}
62
+ await preserveCorruptSidecar(full);
63
+ throw new Error(`pairing token is malformed at ${full} (length=${existing.length}); original preserved as ${full}.corrupt`);
64
+ } catch (error) {
65
+ if (error.code !== "ENOENT") throw error;
66
+ }
63
67
  const token = randomBytes(32).toString("base64url");
64
68
  await mkdir(dirname(full), { recursive: true });
65
69
  await writeFile(full, token + "\n", { mode: 384 });
66
70
  return token;
67
71
  }
68
72
  /**
73
+ * Copy a malformed auth-token file to `<path>.corrupt` so a future operator
74
+ * can inspect what was on disk at the moment of corruption. Best-effort:
75
+ * a copy failure (permissions, full disk, ...) must not block the loud
76
+ * throw that actually surfaces the issue.
77
+ */
78
+ async function preserveCorruptSidecar(full) {
79
+ try {
80
+ const original = await readFile(full);
81
+ const sidecar = `${full}.corrupt`;
82
+ await writeFile(sidecar, original, { mode: 384 });
83
+ } catch {}
84
+ }
85
+ /**
69
86
  * Generate a fresh pairing token and replace the stored one, invalidating
70
87
  * every copy of the old secret. The write goes to a same-directory temp file
71
88
  * renamed over the target so a crash can never leave a truncated token file.
@@ -82,12 +99,12 @@ async function writeNewToken(tokenPath) {
82
99
  /** Constant-time token comparison; both sides are high-entropy secrets. */
83
100
  function tokenMatches(presented, expected) {
84
101
  if (!presented) return false;
85
- const a = Buffer.from(presented);
86
102
  const b = Buffer.from(expected);
87
- if (a.length !== b.length) {
103
+ if (Buffer.byteLength(presented, "utf8") !== b.length) {
88
104
  timingSafeEqual(b, b);
89
105
  return false;
90
106
  }
107
+ const a = Buffer.from(presented);
91
108
  return timingSafeEqual(a, b);
92
109
  }
93
110
  /** Hex shape of an APNs device token as delivered by iOS (usually 64 chars). */
@@ -219,7 +236,7 @@ var DeviceStore = class DeviceStore {
219
236
  }
220
237
  };
221
238
  //#endregion
222
- //#region src/connection.ts
239
+ //#region src/connection-policy.ts
223
240
  const AUTH_TIMEOUT_MS = 5e3;
224
241
  const IMAGE_MEDIA_TYPES = /* @__PURE__ */ new Set([
225
242
  "image/png",
@@ -227,19 +244,43 @@ const IMAGE_MEDIA_TYPES = /* @__PURE__ */ new Set([
227
244
  "image/webp",
228
245
  "image/gif"
229
246
  ]);
230
- const MAX_PROMPT_IMAGES = 4;
231
- const MAX_BASE64_CHARS_PER_IMAGE = 8388608;
232
- /** Bounds a single prompt's text; the frame itself is capped by ws maxPayload. */
233
- const MAX_PROMPT_TEXT_CHARS = 262144;
234
- const MAX_DEVICE_ID_CHARS = 128;
235
- const MAX_DEVICE_NAME_CHARS = 64;
236
- const MAX_APP_VERSION_CHARS = 32;
237
247
  function sanitizeDeviceField(value, maxChars) {
238
248
  return (typeof value === "string" ? value : String(value ?? "")).replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, maxChars);
239
249
  }
240
250
  function helloTokenAccepted(transportAuthenticated, presentedToken, expectedToken) {
241
251
  return transportAuthenticated === true || tokenMatches(presentedToken, expectedToken);
242
252
  }
253
+ function sanitizeImageName(value) {
254
+ return value.replace(/[\u0000-\u001F\u007F]/g, "").trim().slice(0, 120);
255
+ }
256
+ /** Error code for a failed approval/question response outcome. */
257
+ function pendingResponseErrorCode(reason) {
258
+ switch (reason) {
259
+ case "not-pending": return "E_NOT_FOUND";
260
+ case "bad-response": return "E_PROTOCOL";
261
+ case "transport": return "E_INTERNAL";
262
+ }
263
+ }
264
+ /** Human-readable failure detail; `question not pending` must only ever mean
265
+ * "nothing pending", never "the host rejected the answer". */
266
+ function pendingResponseMessage(kind, reason) {
267
+ switch (reason) {
268
+ case "not-pending": return kind + " not pending";
269
+ case "bad-response": return kind + " answer rejected by host: answer does not match the asked questions";
270
+ case "transport": return "host connection failed while answering " + kind;
271
+ }
272
+ }
273
+ function managementErrorCode(kind) {
274
+ switch (kind) {
275
+ case "unsupported": return "E_UNSUPPORTED";
276
+ case "not-found": return "E_NOT_FOUND";
277
+ case "busy": return "E_BUSY";
278
+ case "invalid": return "E_PROTOCOL";
279
+ case "internal": return "E_INTERNAL";
280
+ }
281
+ }
282
+ //#endregion
283
+ //#region src/connection.ts
243
284
  /**
244
285
  * One connected phone. Implements BridgeSink so the HostBridge can push
245
286
  * projected frames and replays. Bearer/query credentials may authenticate the
@@ -249,6 +290,7 @@ var BridgeConnection = class {
249
290
  ws;
250
291
  deps;
251
292
  authenticated = false;
293
+ closed = false;
252
294
  helloTimer;
253
295
  openSessions = /* @__PURE__ */ new Set();
254
296
  /** Sanitized device identity from hello; needed for push registration. */
@@ -272,6 +314,19 @@ var BridgeConnection = class {
272
314
  terminate() {
273
315
  this.ws.terminate();
274
316
  }
317
+ /** Protocol-compliant idle timeout: let the peer observe a normal 1001 close. */
318
+ closeIdle() {
319
+ this.close(1001, "idle timeout");
320
+ }
321
+ /** Announce an orderly plugin/data-plane shutdown before closing the socket. */
322
+ closeForServerStop() {
323
+ this.fail(void 0, "E_INTERNAL", "server stopping");
324
+ this.close(1001, "server stopping");
325
+ }
326
+ /** Used by dependency-lifecycle cleanup to avoid closing a replacement bridge. */
327
+ isAttachedTo(bridge) {
328
+ return this.deps.bridge === bridge;
329
+ }
275
330
  /** Device identity once hello succeeded; undefined before that. */
276
331
  get connectedDeviceId() {
277
332
  return this.authenticated ? this.deviceId : void 0;
@@ -293,6 +348,8 @@ var BridgeConnection = class {
293
348
  return this.deps.bridge.currentCursor();
294
349
  }
295
350
  onClose() {
351
+ if (this.closed) return;
352
+ this.closed = true;
296
353
  if (this.helloTimer !== void 0) clearTimeout(this.helloTimer);
297
354
  for (const id of this.openSessions) this.deps.bridge.markSinkClosed(this, id);
298
355
  this.openSessions.clear();
@@ -300,6 +357,7 @@ var BridgeConnection = class {
300
357
  if (this.authenticated) this.deps.bridge.removeSink(this);
301
358
  }
302
359
  close(code, reason) {
360
+ if (this.closed) return;
303
361
  try {
304
362
  this.ws.close(code, reason);
305
363
  } catch {
@@ -315,7 +373,12 @@ var BridgeConnection = class {
315
373
  ...seq !== void 0 ? { seq } : {},
316
374
  payload
317
375
  };
318
- if (this.ws.readyState === this.ws.OPEN) this.ws.send(JSON.stringify(envelope));
376
+ if (this.ws.readyState !== this.ws.OPEN) return;
377
+ if (this.ws.bufferedAmount > 4194304) {
378
+ this.close(1013, "client too slow");
379
+ return;
380
+ }
381
+ this.ws.send(JSON.stringify(envelope));
319
382
  }
320
383
  fail(id, code, message) {
321
384
  this.send("s2c.error", {
@@ -330,6 +393,10 @@ var BridgeConnection = class {
330
393
  }
331
394
  async onMessage(raw) {
332
395
  this.lastActivity = Date.now();
396
+ if (!this.authenticated && raw.length > 65536) {
397
+ this.close(1009, "pre-auth frame too large");
398
+ return;
399
+ }
333
400
  let env;
334
401
  try {
335
402
  env = JSON.parse(raw);
@@ -524,15 +591,15 @@ var BridgeConnection = class {
524
591
  const text = typeof p?.text === "string" ? p.text : "";
525
592
  const rawImages = Array.isArray(p?.images) ? p.images : [];
526
593
  if (!p?.sessionId || text.trim().length === 0 && rawImages.length === 0) return this.fail(env.id, "E_PROTOCOL", "sessionId and text or images required");
527
- if (text.length > MAX_PROMPT_TEXT_CHARS) return this.fail(env.id, "E_PROTOCOL", "prompt text too long");
528
- if (rawImages.length > MAX_PROMPT_IMAGES) return this.fail(env.id, "E_PROTOCOL", "too many images");
594
+ if (text.length > 262144) return this.fail(env.id, "E_PROTOCOL", "prompt text too long");
595
+ if (rawImages.length > 4) return this.fail(env.id, "E_PROTOCOL", "too many images");
529
596
  const images = [];
530
597
  for (const image of rawImages) {
531
- if (!IMAGE_MEDIA_TYPES.has(String(image?.mediaType)) || typeof image?.data !== "string" || image.data.length === 0 || image.data.length > MAX_BASE64_CHARS_PER_IMAGE) return this.fail(env.id, "E_PROTOCOL", "invalid image attachment");
598
+ if (!IMAGE_MEDIA_TYPES.has(String(image?.mediaType)) || typeof image?.data !== "string" || image.data.length === 0 || image.data.length > 8388608) return this.fail(env.id, "E_PROTOCOL", "invalid image attachment");
532
599
  images.push({
533
600
  mediaType: image.mediaType,
534
601
  data: image.data,
535
- ...typeof image.name === "string" && image.name.trim().length > 0 ? { name: image.name.trim().slice(0, 120) } : {}
602
+ ...typeof image.name === "string" && sanitizeImageName(image.name).length > 0 ? { name: sanitizeImageName(image.name) } : {}
536
603
  });
537
604
  }
538
605
  const userSeq = await this.deps.bridge.sendPrompt(p.sessionId, text, images);
@@ -591,14 +658,14 @@ var BridgeConnection = class {
591
658
  this.close(4403, "deviceId required");
592
659
  return;
593
660
  }
594
- const deviceId = sanitizeDeviceField(p.deviceId, MAX_DEVICE_ID_CHARS);
661
+ const deviceId = sanitizeDeviceField(p.deviceId, 128);
595
662
  if (!deviceId) {
596
663
  this.fail(env.id, "E_PROTOCOL", "deviceId required");
597
664
  this.close(4403, "deviceId required");
598
665
  return;
599
666
  }
600
- const deviceName = sanitizeDeviceField(p.deviceName, MAX_DEVICE_NAME_CHARS) || "unknown";
601
- const appVersion = sanitizeDeviceField(p.appVersion, MAX_APP_VERSION_CHARS) || "unknown";
667
+ const deviceName = sanitizeDeviceField(p.deviceName, 64) || "unknown";
668
+ const appVersion = sanitizeDeviceField(p.appVersion, 32) || "unknown";
602
669
  this.authenticated = true;
603
670
  this.deviceId = deviceId;
604
671
  if (this.helloTimer !== void 0) clearTimeout(this.helloTimer);
@@ -624,34 +691,8 @@ var BridgeConnection = class {
624
691
  }
625
692
  }
626
693
  };
627
- /** Error code for a failed approval/question response outcome. */
628
- function pendingResponseErrorCode(reason) {
629
- switch (reason) {
630
- case "not-pending": return "E_NOT_FOUND";
631
- case "bad-response": return "E_PROTOCOL";
632
- case "transport": return "E_INTERNAL";
633
- }
634
- }
635
- /** Human-readable failure detail; `question not pending` must only ever mean
636
- * "nothing pending", never "the host rejected the answer". */
637
- function pendingResponseMessage(kind, reason) {
638
- switch (reason) {
639
- case "not-pending": return kind + " not pending";
640
- case "bad-response": return kind + " answer rejected by host: answer does not match the asked questions";
641
- case "transport": return "host connection failed while answering " + kind;
642
- }
643
- }
644
- function managementErrorCode(kind) {
645
- switch (kind) {
646
- case "unsupported": return "E_UNSUPPORTED";
647
- case "not-found": return "E_NOT_FOUND";
648
- case "busy": return "E_BUSY";
649
- case "invalid": return "E_PROTOCOL";
650
- case "internal": return "E_INTERNAL";
651
- }
652
- }
653
694
  //#endregion
654
- //#region src/host-bridge.ts
695
+ //#region src/host-api.ts
655
696
  /**
656
697
  * Subagent sessions are host-internal workers of a parent conversation.
657
698
  * They must never surface on the phone: not in the project/session list,
@@ -662,170 +703,623 @@ function isSubagentRow(row) {
662
703
  }
663
704
  function unwrapStreamItem(item) {
664
705
  const nested = item.payload;
665
- if (nested && typeof nested === "object" && typeof nested.type === "string") return nested.rpcId || !item.rpcId ? nested : {
706
+ if (nested && typeof nested === "object" && typeof nested.type === "string") return item.rpcId ? {
666
707
  ...nested,
667
708
  rpcId: item.rpcId
668
- };
709
+ } : nested;
669
710
  return item;
670
711
  }
671
- const MAX_RING_DEFAULT = 2e3;
672
- /**
673
- * Process-wide bridge state: session mirror, pending approvals/questions,
674
- * and the per-device replay ring. Consumes the in-process mux/host streams
675
- * and fans projected pushes out to every registered sink.
676
- */
677
- let BRIDGE_SEQ = 0;
678
- var HostBridge = class {
679
- apiProxy;
680
- historyBufferMax;
681
- id = ++BRIDGE_SEQ;
682
- summaries = /* @__PURE__ */ new Map();
683
- approvals = /* @__PURE__ */ new Map();
684
- questions = /* @__PURE__ */ new Map();
685
- archivedSessionIds = /* @__PURE__ */ new Set();
686
- subagentSessionIds = /* @__PURE__ */ new Set();
687
- sinks = /* @__PURE__ */ new Set();
688
- ring = [];
689
- cursor = 0;
690
- abort = new AbortController();
691
- constructor(apiProxy, historyBufferMax = MAX_RING_DEFAULT) {
692
- this.apiProxy = apiProxy;
693
- this.historyBufferMax = historyBufferMax;
694
- }
695
- pushOutlet;
696
- /**
697
- * Wire the offline-push fan-out. Present ⇒ welcome advertises the `push`
698
- * capability and notify-worthy events are mirrored to APNs.
699
- */
700
- setPushOutlet(outlet) {
701
- this.pushOutlet = outlet;
702
- }
703
- get capabilities() {
704
- return {
705
- historyPaging: true,
706
- replay: true,
707
- approvals: true,
708
- questions: true,
709
- pendingSnapshot: true,
710
- models: typeof this.apiProxy.sessions.models === "function" && typeof this.apiProxy.sessions.selectModel === "function",
711
- sessionManagement: typeof this.apiProxy.sessions.rename === "function" && typeof this.apiProxy.workspace?.archiveSession === "function",
712
- projectSelection: typeof this.apiProxy.workspace?.list === "function" && typeof this.apiProxy.workspace?.create === "function",
713
- push: this.pushOutlet?.isAvailable() === true
712
+ //#endregion
713
+ //#region src/host-event-projection.ts
714
+ const MAX_MESSAGE_PROJECTION_BYTES = 262144;
715
+ function projectEvent(sessionId, event) {
716
+ switch (event.type) {
717
+ case "turn/start": return {
718
+ kind: "turn.start",
719
+ data: {}
714
720
  };
715
- }
716
- diagnostic(message) {
717
- console.log("[deeppilot] " + message);
718
- }
719
- currentCursor() {
720
- return this.cursor;
721
- }
722
- addSink(sink) {
723
- this.sinks.add(sink);
724
- }
725
- removeSink(sink) {
726
- this.sinks.delete(sink);
727
- }
728
- /** Whether the ring still holds everything after the cursor. */
729
- canResumeFrom(cursor) {
730
- const oldest = this.ring.length > 0 ? this.ring[0].seq : this.cursor + 1;
731
- return cursor <= this.cursor && cursor + 1 >= oldest;
732
- }
733
- sinkSessions = /* @__PURE__ */ new Map();
734
- lastAssistantText = /* @__PURE__ */ new Map();
735
- /** Mark a sink as actively viewing a session (suppresses its turn notifications). */
736
- markSinkOpen(sink, sessionId) {
737
- let set = this.sinkSessions.get(sink);
738
- if (!set) {
739
- set = /* @__PURE__ */ new Set();
740
- this.sinkSessions.set(sink, set);
741
- }
742
- set.add(sessionId);
743
- }
744
- markSinkClosed(sink, sessionId) {
745
- this.sinkSessions.get(sink)?.delete(sessionId);
746
- }
747
- dropSinkSessions(sink) {
748
- this.sinkSessions.delete(sink);
749
- }
750
- isViewedBy(sink, sessionId) {
751
- return this.sinkSessions.get(sink)?.has(sessionId) ?? false;
752
- }
753
- /** F-9: when a turn completes, notify every device not viewing the session. */
754
- emitTurnCompletedNotify(sessionId, ok) {
755
- if (this.subagentSessionIds.has(sessionId)) return;
756
- const row = this.summaries.get(sessionId);
757
- const title = ok ? "任务完成" : "任务异常结束";
758
- const body = this.lastAssistantText.get(sessionId) ?? row?.title ?? "";
759
- const truncatedBody = body.length > 120 ? body.slice(0, 119) + "…" : body;
760
- const category = ok ? "turn.completed" : "session.error";
761
- const notificationId = "n-" + (this.cursor + 1);
762
- this.record("s2c.notify", {
763
- notificationId,
764
- category,
765
- sessionId,
766
- title,
767
- body: truncatedBody,
768
- ts: Date.now()
769
- }, (sink) => this.isViewedBy(sink, sessionId));
770
- this.fanOutPush({
771
- notificationId,
772
- category,
773
- sessionId,
774
- title,
775
- body: truncatedBody
776
- });
777
- }
778
- /**
779
- * Mirror one notification-worthy event to offline devices. Fire-and-forget:
780
- * push failures must never block or break the WS data plane.
781
- */
782
- fanOutPush(notification) {
783
- try {
784
- this.pushOutlet?.fanOut(notification);
785
- } catch {}
786
- }
787
- /** Remember the latest assistant text so notifications can quote it. */
788
- captureAssistantText(sessionId, event) {
789
- if (event.type !== "assistant/message") return;
790
- const text = messageText(event.data).trim();
791
- if (text.length > 0) this.lastAssistantText.set(sessionId, text.slice(-160));
792
- }
793
- /**
794
- * Replay buffered pushes after the given cursor; false when the gap is
795
- * unrecoverable. Frames go to `target` only — replaying into every sink
796
- * duplicated the whole window onto devices that never asked for it.
797
- */
798
- resumeFrom(cursor, target) {
799
- const oldest = this.ring.length > 0 ? this.ring[0].seq : this.cursor + 1;
800
- if (cursor + 1 < oldest) return false;
801
- const receivers = target !== void 0 ? [target] : [...this.sinks];
802
- for (const entry of this.ring) if (entry.seq > cursor) for (const sink of receivers) sink.replay([entry]);
803
- for (const sink of receivers) sink.replayDone();
804
- return true;
805
- }
806
- record(type, payload, except) {
807
- this.cursor += 1;
808
- const entry = {
809
- seq: this.cursor,
810
- type,
811
- payload
721
+ case "turn/end": return {
722
+ kind: "turn.end",
723
+ data: { ok: event.data?.reason?.kind === "completed" }
812
724
  };
813
- this.ring.push(entry);
814
- if (this.ring.length > this.historyBufferMax) this.ring.splice(0, this.ring.length - this.historyBufferMax);
815
- for (const sink of this.sinks) {
816
- if (except && except(sink)) continue;
817
- sink.push(type, payload, entry.seq);
725
+ case "user/message": return {
726
+ kind: "message.final",
727
+ data: { ...limitMessageProjection({
728
+ seq: event.seq,
729
+ role: userRoleOf(event.data),
730
+ text: messageText(event.data),
731
+ ...attachmentProjection(event.data),
732
+ ...contextProjectionOf(event.data),
733
+ ts: tsOf(event)
734
+ }) }
735
+ };
736
+ case "assistant/chunk":
737
+ if (chunkTypeOf(event.data) === "reasoning-delta") return {
738
+ kind: "thinking.delta",
739
+ data: limitRealtimeText({
740
+ text: chunkText(event.data),
741
+ ts: tsOf(event)
742
+ })
743
+ };
744
+ return {
745
+ kind: "message.delta",
746
+ data: limitRealtimeText({
747
+ text: chunkText(event.data),
748
+ ts: tsOf(event)
749
+ })
750
+ };
751
+ case "assistant/message": {
752
+ const text = messageText(event.data);
753
+ const thinking = messageThinking(event.data);
754
+ if (!text.trim() && !thinking.trim()) return null;
755
+ return {
756
+ kind: "message.final",
757
+ data: { ...limitMessageProjection({
758
+ seq: event.seq,
759
+ role: "assistant",
760
+ text,
761
+ ...thinking ? { thinking } : {},
762
+ ts: tsOf(event)
763
+ }) }
764
+ };
765
+ }
766
+ case "tool/call": {
767
+ const data = event.data;
768
+ return {
769
+ kind: "tool.start",
770
+ data: {
771
+ seq: event.seq,
772
+ role: "tool",
773
+ tool: {
774
+ name: String(data?.name ?? "tool"),
775
+ state: "running",
776
+ summary: summarizeArgs(data?.arguments),
777
+ ...data?.callId ? { callId: String(data.callId) } : {}
778
+ },
779
+ ts: tsOf(event)
780
+ }
781
+ };
782
+ }
783
+ case "tool/result": {
784
+ const data = event.data;
785
+ return {
786
+ kind: "tool.end",
787
+ data: {
788
+ seq: event.seq,
789
+ role: "tool",
790
+ ok: !event.data || data?.error === void 0,
791
+ ...data?.callId ? { callId: String(data.callId) } : {},
792
+ ts: tsOf(event)
793
+ }
794
+ };
795
+ }
796
+ default: return null;
797
+ }
798
+ }
799
+ function tsOf(event) {
800
+ return typeof event.time === "number" ? event.time : Date.now();
801
+ }
802
+ /** Read the durable message source off one user/message payload. Handles both
803
+ * bare-message payloads and older `{message: {...}}` wrappers; undefined when
804
+ * the shape carries no readable source (legacy hosts). */
805
+ function userMessageSource(data) {
806
+ if (!data || typeof data !== "object") return void 0;
807
+ const obj = data;
808
+ if (obj.source && typeof obj.source === "object") return obj.source;
809
+ if (obj.message && typeof obj.message === "object" && obj.message.source && typeof obj.message.source === "object") return obj.message.source;
810
+ }
811
+ /** Wire role for one user/message payload. A payload without any readable
812
+ * source degrades to 'user' so history written by older hosts stays visible;
813
+ * a present source follows the host's own trajectory rule — anything whose
814
+ * `kind` is not 'user' is injected context and projects as 'system'. */
815
+ function userRoleOf(data) {
816
+ const source = userMessageSource(data);
817
+ if (!source) return "user";
818
+ return source.kind === "user" ? "user" : "system";
819
+ }
820
+ /** Producer name of one injected-context source, mirroring how the DSH client
821
+ * runtime derives its trajectory label: plugin name, skill name, instruction
822
+ * paths, session-reference labels, or the raw kind as fallback. */
823
+ function contextLabelOf(source) {
824
+ const kind = typeof source.kind === "string" ? source.kind : "";
825
+ const joined = (member) => {
826
+ const list = source[member];
827
+ if (!Array.isArray(list)) return void 0;
828
+ const names = list.flatMap((entry) => {
829
+ if (!entry || typeof entry !== "object") return [];
830
+ const record = entry;
831
+ return [typeof record.label === "string" ? record.label : typeof record.path === "string" ? record.path : ""];
832
+ }).filter((name) => name.length > 0);
833
+ return names.length > 0 ? names.join(", ") : void 0;
834
+ };
835
+ switch (kind) {
836
+ case "session-reference": return joined("references") ?? (kind || void 0);
837
+ case "agent-instructions": return joined("changes") ?? (kind || void 0);
838
+ case "plugin": return typeof source.plugin === "string" && source.plugin.length > 0 ? source.plugin : kind || void 0;
839
+ case "skill-invocation": return typeof source.name === "string" && source.name.length > 0 ? source.name : kind || void 0;
840
+ default: return kind || void 0;
841
+ }
842
+ }
843
+ /** Semantic ContextForm declared by the producer ('snapshot', 'notice', …);
844
+ * anything unrecognized stays undefined so clients render it opaque. */
845
+ function contextFormOf(source) {
846
+ if (typeof source.form !== "string" || source.form.length === 0) return void 0;
847
+ return [
848
+ "instructions",
849
+ "catalog",
850
+ "snapshot",
851
+ "notice",
852
+ "relay",
853
+ "recall"
854
+ ].includes(source.form) ? source.form : void 0;
855
+ }
856
+ /** Optional `context` metadata for one system row; {} on user rows. */
857
+ function contextProjectionOf(data) {
858
+ if (userRoleOf(data) !== "system") return {};
859
+ const source = userMessageSource(data);
860
+ if (!source) return {};
861
+ const label = contextLabelOf(source);
862
+ const form = contextFormOf(source);
863
+ if (!label && !form) return {};
864
+ return { context: {
865
+ ...label ? { label } : {},
866
+ ...form ? { form } : {}
867
+ } };
868
+ }
869
+ /** Extract plain text from user/assistant message payloads across shapes. */
870
+ function messageText(data) {
871
+ if (typeof data === "string") return data;
872
+ if (!data || typeof data !== "object") return "";
873
+ const obj = data;
874
+ if (typeof obj.text === "string") return obj.text;
875
+ if (obj.message && typeof obj.message === "object") return messageText(obj.message);
876
+ return contentText(obj.content);
877
+ }
878
+ function contentText(content) {
879
+ if (typeof content === "string") return content;
880
+ if (Array.isArray(content)) return content.map((part) => {
881
+ if (typeof part === "string") return part;
882
+ if (part && typeof part === "object") {
883
+ const piece = part;
884
+ if (piece.type === "text" && typeof piece.text === "string") return piece.text;
885
+ }
886
+ return "";
887
+ }).join("");
888
+ return "";
889
+ }
890
+ function messageAttachments(data) {
891
+ if (!data || typeof data !== "object") return [];
892
+ const obj = data;
893
+ if (obj.message && typeof obj.message === "object") return messageAttachments(obj.message);
894
+ if (!Array.isArray(obj.content)) return [];
895
+ return obj.content.flatMap((part) => {
896
+ if (!part || typeof part !== "object") return [];
897
+ const block = part;
898
+ if (block.type !== "image" || !block.attachment) return [];
899
+ const attachmentId = typeof block.attachment.attachmentId === "string" && block.attachment.attachmentId.length > 0 ? block.attachment.attachmentId : void 0;
900
+ const width = typeof block.attachment.width === "number" && Number.isFinite(block.attachment.width) ? block.attachment.width : void 0;
901
+ const height = typeof block.attachment.height === "number" && Number.isFinite(block.attachment.height) ? block.attachment.height : void 0;
902
+ return [{
903
+ kind: "image",
904
+ ...typeof block.attachment.name === "string" ? { name: block.attachment.name } : {},
905
+ ...typeof block.attachment.mediaType === "string" ? { mediaType: block.attachment.mediaType } : {},
906
+ ...attachmentId ? { attachmentId } : {},
907
+ ...width !== void 0 ? { width } : {},
908
+ ...height !== void 0 ? { height } : {}
909
+ }];
910
+ });
911
+ }
912
+ function attachmentProjection(data) {
913
+ const attachments = messageAttachments(data);
914
+ return attachments.length > 0 ? { attachments } : {};
915
+ }
916
+ /** Extract reasoning ("thinking") text from assistant message payloads. */
917
+ function messageThinking(data) {
918
+ if (!data || typeof data !== "object") return "";
919
+ const obj = data;
920
+ if (obj.message && typeof obj.message === "object") return messageThinking(obj.message);
921
+ return reasoningContent(obj.content);
922
+ }
923
+ function reasoningContent(content) {
924
+ if (!Array.isArray(content)) return "";
925
+ return content.map((part) => {
926
+ if (part && typeof part === "object") {
927
+ const piece = part;
928
+ if (piece.type === "reasoning" && typeof piece.text === "string") return piece.text;
929
+ }
930
+ return "";
931
+ }).join("");
932
+ }
933
+ /** Stream chunk type of an assistant/chunk payload ('' when unwrapped). */
934
+ function chunkTypeOf(data) {
935
+ if (!data || typeof data !== "object") return "";
936
+ const obj = data;
937
+ if (obj.chunk && typeof obj.chunk === "object") return String(obj.chunk.type ?? "");
938
+ return "text-delta";
939
+ }
940
+ function chunkText(data) {
941
+ if (!data || typeof data !== "object") return "";
942
+ const obj = data;
943
+ if (obj.chunk && typeof obj.chunk === "object") {
944
+ const inner = obj.chunk;
945
+ if ((inner.type === "text-delta" || inner.type === "reasoning-delta") && typeof inner.text === "string") return inner.text;
946
+ return "";
947
+ }
948
+ const direct = data;
949
+ return typeof direct.text === "string" ? direct.text : "";
950
+ }
951
+ function summarizeArgs(raw) {
952
+ if (typeof raw !== "string" || raw.length === 0) return "";
953
+ try {
954
+ const parsed = JSON.parse(raw);
955
+ const parts = [];
956
+ for (const [key, value] of Object.entries(parsed)) if (typeof value === "string") parts.push(key + "=" + truncate(value.replace(/\s+/g, " "), 60));
957
+ return truncate(parts.join(" "), 90);
958
+ } catch {
959
+ return truncate(raw, 90);
960
+ }
961
+ }
962
+ function truncate(text, max) {
963
+ return text.length <= max ? text : text.slice(0, max - 1) + "…";
964
+ }
965
+ function projectHistory(events) {
966
+ const messages = [];
967
+ const toolByCall = /* @__PURE__ */ new Map();
968
+ for (const entry of events) {
969
+ const event = entry.event;
970
+ const base = {
971
+ seq: event.seq,
972
+ ts: tsOf(event)
973
+ };
974
+ switch (event.type) {
975
+ case "user/message":
976
+ messages.push({
977
+ ...base,
978
+ role: userRoleOf(event.data),
979
+ text: messageText(event.data),
980
+ ...attachmentProjection(event.data),
981
+ ...contextProjectionOf(event.data)
982
+ });
983
+ break;
984
+ case "assistant/message": {
985
+ const text = messageText(event.data);
986
+ const thinking = messageThinking(event.data);
987
+ if (!text.trim() && !thinking.trim()) break;
988
+ messages.push({
989
+ ...base,
990
+ role: "assistant",
991
+ text,
992
+ ...thinking ? { thinking } : {}
993
+ });
994
+ break;
995
+ }
996
+ case "tool/call": {
997
+ const data = event.data;
998
+ const row = {
999
+ ...base,
1000
+ role: "tool",
1001
+ tool: {
1002
+ name: String(data?.name ?? "tool"),
1003
+ state: "running",
1004
+ summary: summarizeArgs(data?.arguments)
1005
+ }
1006
+ };
1007
+ messages.push(row);
1008
+ if (data?.callId) toolByCall.set(String(data.callId), row);
1009
+ break;
1010
+ }
1011
+ case "tool/result": {
1012
+ const data = event.data;
1013
+ const callId = data?.callId ? String(data.callId) : void 0;
1014
+ const target = callId ? toolByCall.get(callId) : void 0;
1015
+ const failed = data?.error !== void 0;
1016
+ const summary = failed ? "失败" : summarizeResult(data?.message?.content);
1017
+ if (target?.tool) target.tool = {
1018
+ ...target.tool,
1019
+ state: failed ? "error" : "ok",
1020
+ summary
1021
+ };
1022
+ else messages.push({
1023
+ ...base,
1024
+ role: "tool",
1025
+ tool: {
1026
+ name: "result",
1027
+ state: failed ? "error" : "ok",
1028
+ summary
1029
+ }
1030
+ });
1031
+ break;
1032
+ }
1033
+ }
1034
+ }
1035
+ return messages.sort((a, b) => a.seq - b.seq).map(limitMessageProjection);
1036
+ }
1037
+ /**
1038
+ * Enforce PROTOCOL.md's per-message 256 KB ceiling by UTF-8 JSON byte size.
1039
+ * Keep structural identity and attachment references intact; progressively
1040
+ * shorten human-readable fields until the serialized projection fits.
1041
+ */
1042
+ function limitMessageProjection(message) {
1043
+ if (jsonBytes(message) <= 262144) return message;
1044
+ const next = {
1045
+ ...message,
1046
+ ...message.tool ? { tool: {
1047
+ ...message.tool,
1048
+ name: truncateUtf8(message.tool.name, 4096),
1049
+ summary: truncateUtf8(message.tool.summary, 65536)
1050
+ } } : {},
1051
+ ...message.attachments ? { attachments: message.attachments.slice(0, 16).map((attachment) => ({
1052
+ ...attachment,
1053
+ ...attachment.name ? { name: truncateUtf8(attachment.name, 4096) } : {},
1054
+ ...attachment.mediaType ? { mediaType: truncateUtf8(attachment.mediaType, 256) } : {},
1055
+ ...attachment.attachmentId ? { attachmentId: truncateUtf8(attachment.attachmentId, 4096) } : {}
1056
+ })) } : {},
1057
+ ...message.context ? { context: {
1058
+ ...message.context.label ? { label: truncateUtf8(message.context.label, 8192) } : {},
1059
+ ...message.context.form ? { form: truncateUtf8(message.context.form, 256) } : {}
1060
+ } } : {},
1061
+ truncated: true
1062
+ };
1063
+ const textFields = [];
1064
+ if (typeof next.text === "string") textFields.push({
1065
+ get: () => next.text ?? "",
1066
+ set: (value) => {
1067
+ next.text = value;
1068
+ }
1069
+ });
1070
+ if (typeof next.thinking === "string") textFields.push({
1071
+ get: () => next.thinking ?? "",
1072
+ set: (value) => {
1073
+ next.thinking = value;
1074
+ }
1075
+ });
1076
+ if (next.tool) textFields.push({
1077
+ get: () => next.tool?.summary ?? "",
1078
+ set: (value) => {
1079
+ if (next.tool) next.tool.summary = value;
1080
+ }
1081
+ });
1082
+ if (next.context?.label) textFields.push({
1083
+ get: () => next.context?.label ?? "",
1084
+ set: (value) => {
1085
+ if (next.context) next.context.label = value;
1086
+ }
1087
+ });
1088
+ while (jsonBytes(next) > MAX_MESSAGE_PROJECTION_BYTES) {
1089
+ const largest = textFields.map((field) => ({
1090
+ field,
1091
+ bytes: Buffer.byteLength(field.get(), "utf8")
1092
+ })).sort((a, b) => b.bytes - a.bytes)[0];
1093
+ if (largest && largest.bytes > 0) {
1094
+ largest.field.set(truncateUtf8(largest.field.get(), Math.floor(largest.bytes / 2)));
1095
+ continue;
1096
+ }
1097
+ if (next.attachments && next.attachments.length > 0) {
1098
+ next.attachments = next.attachments.slice(0, -1);
1099
+ continue;
1100
+ }
1101
+ break;
1102
+ }
1103
+ return next;
1104
+ }
1105
+ function limitRealtimeText(data) {
1106
+ if (jsonBytes(data) <= 262144) return data;
1107
+ let text = data.text;
1108
+ const next = {
1109
+ ...data,
1110
+ truncated: true
1111
+ };
1112
+ while (jsonBytes(next) > 262144 && text.length > 0) {
1113
+ text = truncateUtf8(text, Math.floor(Buffer.byteLength(text, "utf8") / 2));
1114
+ next.text = text;
1115
+ }
1116
+ return next;
1117
+ }
1118
+ function jsonBytes(value) {
1119
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
1120
+ }
1121
+ function truncateUtf8(value, maxBytes) {
1122
+ if (maxBytes <= 0) return "";
1123
+ if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
1124
+ let low = 0;
1125
+ let high = value.length;
1126
+ while (low < high) {
1127
+ const mid = Math.ceil((low + high) / 2);
1128
+ const candidate = value.slice(0, mid);
1129
+ if (Buffer.byteLength(candidate, "utf8") <= maxBytes) low = mid;
1130
+ else high = mid - 1;
1131
+ }
1132
+ let end = low;
1133
+ if (end > 0 && /[\uD800-\uDBFF]/.test(value[end - 1])) end -= 1;
1134
+ return value.slice(0, end);
1135
+ }
1136
+ function summarizeResult(content) {
1137
+ return truncate(contentText(content).replace(/\s+/g, " ").trim(), 90);
1138
+ }
1139
+ //#endregion
1140
+ //#region src/host-bridge.ts
1141
+ const MAX_RING_DEFAULT = 2e3;
1142
+ /**
1143
+ * Process-wide bridge state: session mirror, pending approvals/questions,
1144
+ * and the per-device replay ring. Consumes the in-process mux/host streams
1145
+ * and fans projected pushes out to every registered sink.
1146
+ */
1147
+ let BRIDGE_SEQ = 0;
1148
+ var HostBridge = class {
1149
+ apiProxy;
1150
+ historyBufferMax;
1151
+ id = ++BRIDGE_SEQ;
1152
+ summaries = /* @__PURE__ */ new Map();
1153
+ approvals = /* @__PURE__ */ new Map();
1154
+ questions = /* @__PURE__ */ new Map();
1155
+ archivedSessionIds = /* @__PURE__ */ new Set();
1156
+ subagentSessionIds = /* @__PURE__ */ new Set();
1157
+ sinks = /* @__PURE__ */ new Set();
1158
+ ring = [];
1159
+ cursor = 0;
1160
+ userReceiptSeq = 0;
1161
+ abort = new AbortController();
1162
+ started = false;
1163
+ disposed = false;
1164
+ constructor(apiProxy, historyBufferMax = MAX_RING_DEFAULT) {
1165
+ this.apiProxy = apiProxy;
1166
+ this.historyBufferMax = historyBufferMax;
1167
+ }
1168
+ pushOutlet;
1169
+ /**
1170
+ * Wire the offline-push fan-out. Present ⇒ welcome advertises the `push`
1171
+ * capability and notify-worthy events are mirrored to APNs.
1172
+ */
1173
+ setPushOutlet(outlet) {
1174
+ this.pushOutlet = outlet;
1175
+ }
1176
+ get capabilities() {
1177
+ return {
1178
+ historyPaging: true,
1179
+ replay: true,
1180
+ approvals: true,
1181
+ questions: true,
1182
+ pendingSnapshot: true,
1183
+ notifyAllCategories: true,
1184
+ models: typeof this.apiProxy.sessions.models === "function" && typeof this.apiProxy.sessions.selectModel === "function",
1185
+ sessionManagement: typeof this.apiProxy.sessions.rename === "function" && typeof this.apiProxy.workspace?.archiveSession === "function",
1186
+ projectSelection: typeof this.apiProxy.workspace?.list === "function" && typeof this.apiProxy.workspace?.create === "function",
1187
+ push: this.pushOutlet?.isAvailable() === true
1188
+ };
1189
+ }
1190
+ diagnostic(message) {
1191
+ console.log("[deeppilot] " + message);
1192
+ }
1193
+ currentCursor() {
1194
+ return this.cursor;
1195
+ }
1196
+ addSink(sink) {
1197
+ this.sinks.add(sink);
1198
+ }
1199
+ removeSink(sink) {
1200
+ this.sinks.delete(sink);
1201
+ }
1202
+ /** Whether the ring still holds everything after the cursor. */
1203
+ canResumeFrom(cursor) {
1204
+ const oldest = this.ring.length > 0 ? this.ring[0].seq : this.cursor + 1;
1205
+ return cursor <= this.cursor && cursor + 1 >= oldest;
1206
+ }
1207
+ sinkSessions = /* @__PURE__ */ new Map();
1208
+ lastAssistantText = /* @__PURE__ */ new Map();
1209
+ /** Mark a sink as actively viewing a session (suppresses its turn notifications). */
1210
+ markSinkOpen(sink, sessionId) {
1211
+ let set = this.sinkSessions.get(sink);
1212
+ if (!set) {
1213
+ set = /* @__PURE__ */ new Set();
1214
+ this.sinkSessions.set(sink, set);
1215
+ }
1216
+ set.add(sessionId);
1217
+ }
1218
+ markSinkClosed(sink, sessionId) {
1219
+ this.sinkSessions.get(sink)?.delete(sessionId);
1220
+ }
1221
+ dropSinkSessions(sink) {
1222
+ this.sinkSessions.delete(sink);
1223
+ }
1224
+ isViewedBy(sink, sessionId) {
1225
+ return this.sinkSessions.get(sink)?.has(sessionId) ?? false;
1226
+ }
1227
+ /** F-9: when a notification-worthy event fires, mirror it to every
1228
+ * online device that is not currently viewing the session (the s2c.notify
1229
+ * frame counts toward the seq cursor and joins the replay ring per
1230
+ * PROTOCOL §6 + §7), then fan the same payload out to offline devices
1231
+ * holding an APNs token. */
1232
+ emitNotify(args) {
1233
+ if (this.subagentSessionIds.has(args.sessionId)) return;
1234
+ const body = args.body.length > 120 ? args.body.slice(0, 119) + "…" : args.body;
1235
+ this.record("s2c.notify", {
1236
+ notificationId: args.notificationId,
1237
+ category: args.category,
1238
+ sessionId: args.sessionId,
1239
+ title: args.title,
1240
+ body,
1241
+ ts: Date.now()
1242
+ }, (sink) => this.isViewedBy(sink, args.sessionId));
1243
+ this.fanOutPush({
1244
+ notificationId: args.notificationId,
1245
+ category: args.category,
1246
+ sessionId: args.sessionId,
1247
+ title: args.title,
1248
+ body
1249
+ });
1250
+ }
1251
+ /** F-9: when a turn completes, notify every device not viewing the session. */
1252
+ emitTurnCompletedNotify(sessionId, ok) {
1253
+ if (this.subagentSessionIds.has(sessionId)) return;
1254
+ const row = this.summaries.get(sessionId);
1255
+ const title = ok ? "任务完成" : "任务异常结束";
1256
+ const body = this.lastAssistantText.get(sessionId) ?? row?.title ?? "";
1257
+ this.emitNotify({
1258
+ sessionId,
1259
+ category: ok ? "turn.completed" : "session.error",
1260
+ title,
1261
+ body,
1262
+ notificationId: "n-" + (this.cursor + 1)
1263
+ });
1264
+ }
1265
+ /**
1266
+ * Mirror one notification-worthy event to offline devices. Fire-and-forget:
1267
+ * push failures must never block or break the WS data plane.
1268
+ */
1269
+ fanOutPush(notification) {
1270
+ try {
1271
+ this.pushOutlet?.fanOut(notification);
1272
+ } catch {}
1273
+ }
1274
+ /** Remember the latest assistant text so notifications can quote it. */
1275
+ captureAssistantText(sessionId, event) {
1276
+ if (event.type !== "assistant/message") return;
1277
+ const text = messageText(event.data).trim();
1278
+ if (text.length > 0) this.lastAssistantText.set(sessionId, text.slice(-160));
1279
+ }
1280
+ /**
1281
+ * Replay buffered pushes after the given cursor; false when the gap is
1282
+ * unrecoverable. Frames go to `target` only — replaying into every sink
1283
+ * duplicated the whole window onto devices that never asked for it.
1284
+ */
1285
+ resumeFrom(cursor, target) {
1286
+ const oldest = this.ring.length > 0 ? this.ring[0].seq : this.cursor + 1;
1287
+ if (cursor + 1 < oldest) return false;
1288
+ const receivers = target !== void 0 ? [target] : [...this.sinks];
1289
+ for (const entry of this.ring) if (entry.seq > cursor) for (const sink of receivers) sink.replay([entry]);
1290
+ for (const sink of receivers) sink.replayDone();
1291
+ return true;
1292
+ }
1293
+ record(type, payload, except) {
1294
+ if (this.disposed) return;
1295
+ this.cursor += 1;
1296
+ const entry = {
1297
+ seq: this.cursor,
1298
+ type,
1299
+ payload
1300
+ };
1301
+ this.ring.push(entry);
1302
+ if (this.ring.length > this.historyBufferMax) this.ring.splice(0, this.ring.length - this.historyBufferMax);
1303
+ for (const sink of this.sinks) {
1304
+ if (except && except(sink)) continue;
1305
+ sink.push(type, payload, entry.seq);
818
1306
  }
819
1307
  }
820
1308
  /** Start consuming host + mux streams. Idempotent; aborts on dispose(). */
821
1309
  start() {
1310
+ if (this.started || this.disposed) return;
1311
+ this.started = true;
822
1312
  this.runHostStream();
823
1313
  this.runMuxStream();
824
1314
  this.refreshSummaries();
825
1315
  }
826
1316
  dispose() {
1317
+ if (this.disposed) return;
1318
+ this.disposed = true;
827
1319
  this.abort.abort();
828
1320
  this.sinks.clear();
1321
+ this.sinkSessions.clear();
1322
+ this.pushOutlet = void 0;
829
1323
  }
830
1324
  async runHostStream() {
831
1325
  try {
@@ -898,27 +1392,28 @@ var HostBridge = class {
898
1392
  if (!p.approvalId || !frame.rpcId) break;
899
1393
  const toolName = String(p.toolName ?? "tool");
900
1394
  const summary = String(p.reason ?? "");
1395
+ const sessionId = String(p.sessionId ?? "");
901
1396
  this.approvals.set(p.approvalId, {
902
1397
  rpcId: frame.rpcId,
903
- sessionId: String(p.sessionId ?? ""),
1398
+ sessionId,
904
1399
  toolName,
905
1400
  reason: summary
906
1401
  });
907
1402
  this.record("s2c.pending.approval", {
908
1403
  requestId: p.approvalId,
909
- sessionId: String(p.sessionId ?? ""),
1404
+ sessionId,
910
1405
  toolName,
911
1406
  summary,
912
1407
  riskLevel: riskOf(toolName)
913
1408
  });
914
- this.fanOutPush({
915
- notificationId: "apr-" + p.approvalId,
1409
+ this.emitNotify({
1410
+ sessionId,
916
1411
  category: "approval.required",
917
- sessionId: String(p.sessionId ?? ""),
918
1412
  title: "需要批准",
919
- body: toolName + ": " + summary
1413
+ body: toolName + ": " + summary,
1414
+ notificationId: "apr-" + p.approvalId
920
1415
  });
921
- this.bumpPendingFlags(String(p.sessionId ?? ""));
1416
+ this.bumpPendingFlags(sessionId);
922
1417
  break;
923
1418
  }
924
1419
  case "approval/resolved": {
@@ -945,12 +1440,12 @@ var HostBridge = class {
945
1440
  sessionId,
946
1441
  questions: p?.questions ?? []
947
1442
  });
948
- this.fanOutPush({
949
- notificationId: requestId,
950
- category: "question.asked",
1443
+ this.emitNotify({
951
1444
  sessionId,
1445
+ category: "question.asked",
952
1446
  title: "有问题需要回答",
953
- body: firstQuestionText(p?.questions)
1447
+ body: firstQuestionText(p?.questions),
1448
+ notificationId: requestId
954
1449
  });
955
1450
  this.bumpPendingFlags(sessionId);
956
1451
  break;
@@ -1005,7 +1500,7 @@ var HostBridge = class {
1005
1500
  this.subagentSessionIds = subagentIds;
1006
1501
  this.summaries = next;
1007
1502
  const removedIds = [...previousIds].filter((id) => !next.has(id));
1008
- for (const id of removedIds) this.lastAssistantText.delete(id);
1503
+ for (const id of this.lastAssistantText.keys()) if (!next.has(id)) this.lastAssistantText.delete(id);
1009
1504
  this.record("s2c.sessions.delta", {
1010
1505
  upserted: [...next.values()],
1011
1506
  removedIds
@@ -1507,9 +2002,10 @@ var HostBridge = class {
1507
2002
  row.lastActivityTs = Date.now();
1508
2003
  this.pushSummary(row);
1509
2004
  }
2005
+ this.userReceiptSeq += 1;
1510
2006
  return {
1511
2007
  ok: true,
1512
- value: Date.now()
2008
+ value: this.userReceiptSeq
1513
2009
  };
1514
2010
  } catch (error) {
1515
2011
  return {
@@ -1564,403 +2060,153 @@ var HostBridge = class {
1564
2060
  const pending = this.questions.get(requestId);
1565
2061
  if (!pending) return {
1566
2062
  ok: false,
1567
- reason: "not-pending"
1568
- };
1569
- this.questions.delete(requestId);
1570
- try {
1571
- const receipt = await this.apiProxy.respond({
1572
- type: "client-response",
1573
- rpcId: pending.rpcId,
1574
- result: {
1575
- ok: true,
1576
- value: {
1577
- sessionId: pending.sessionId,
1578
- answer: { answers: normalizeAnswerItems(answers, pending.questions) }
1579
- }
1580
- }
1581
- });
1582
- if (!Boolean(receipt?.accepted)) {
1583
- const failure = receiptFailureReason(receipt);
1584
- if (failure !== "not-pending" && !this.questions.has(requestId)) this.questions.set(requestId, pending);
1585
- return {
1586
- ok: false,
1587
- reason: failure
1588
- };
1589
- }
1590
- this.bumpPendingFlags(pending.sessionId);
1591
- return { ok: true };
1592
- } catch {
1593
- if (!this.questions.has(requestId)) this.questions.set(requestId, pending);
1594
- return {
1595
- ok: false,
1596
- reason: "transport"
1597
- };
1598
- }
1599
- }
1600
- };
1601
- /**
1602
- * The host validates question answers strictly (core dsh-user-questions via
1603
- * apiProxy): a present-but-empty `custom` fails `matchesQuestions`, and a
1604
- * single-select question rejects `custom` combined with a selection. Clients
1605
- * may send lenient shapes (the phone historically always attached
1606
- * `"custom": ""`, which made EVERY option-only answer fail), so normalize to
1607
- * exactly what the host accepts before forwarding.
1608
- */
1609
- function normalizeAnswerItems(raw, questions) {
1610
- if (!Array.isArray(raw)) return [];
1611
- const askedById = /* @__PURE__ */ new Map();
1612
- if (Array.isArray(questions)) {
1613
- for (const q of questions) if (typeof q === "object" && q !== null && typeof q.id === "string") askedById.set(q.id, q);
1614
- }
1615
- const items = [];
1616
- for (const entry of raw) {
1617
- if (typeof entry !== "object" || entry === null) continue;
1618
- const r = entry;
1619
- if (typeof r.id !== "string") continue;
1620
- const selected = [...new Set(Array.isArray(r.selected) ? r.selected.filter((s) => typeof s === "string") : [])];
1621
- const customText = typeof r.custom === "string" ? r.custom : "";
1622
- let custom;
1623
- if (customText.trim().length > 0) custom = customText;
1624
- if (custom !== void 0 && selected.length > 0 && askedById.get(r.id)?.multiSelect !== true) custom = void 0;
1625
- items.push({
1626
- id: r.id,
1627
- selected,
1628
- ...custom !== void 0 ? { custom } : {}
1629
- });
1630
- }
1631
- return items;
1632
- }
1633
- /** Map an apiProxy respond receipt onto the failure vocabulary. */
1634
- function receiptFailureReason(receipt) {
1635
- return receipt?.reason === "not-pending" ? "not-pending" : "bad-response";
1636
- }
1637
- function clampTail(n) {
1638
- if (!Number.isFinite(n)) return 100;
1639
- return Math.max(10, Math.min(500, Math.floor(n)));
1640
- }
1641
- function localTimeZone() {
1642
- try {
1643
- return new Intl.DateTimeFormat().resolvedOptions().timeZone || void 0;
1644
- } catch {
1645
- return;
1646
- }
1647
- }
1648
- function riskOf(toolName) {
1649
- if (/bash|pwsh|terminal/.test(toolName)) return "write";
1650
- if (/edit|write|str_replace|create/.test(toolName)) return "write";
1651
- if (/delete|remove|kill/.test(toolName)) return "destructive";
1652
- return "read";
1653
- }
1654
- /** First question's text for the push banner; the questions payload shape is
1655
- * host-version dependent, so extract defensively. */
1656
- function firstQuestionText(questions) {
1657
- if (!Array.isArray(questions) || questions.length === 0) return "Agent 等待你的输入";
1658
- const first = questions[0];
1659
- return String(first?.question ?? "").trim() || "Agent 等待你的输入";
1660
- }
1661
- const TODO_STATUSES = /* @__PURE__ */ new Set([
1662
- "pending",
1663
- "in_progress",
1664
- "completed"
1665
- ]);
1666
- /** Validate a host todo projection once; progress counts and the full
1667
- * checklist both derive from this sanitized list so they never disagree. */
1668
- function sanitizeTodoItems(items) {
1669
- if (!items) return [];
1670
- return items.map((i) => ({
1671
- content: String(i.content ?? "").trim(),
1672
- status: String(i.status ?? "")
1673
- })).filter((i) => i.content.length > 0 && TODO_STATUSES.has(i.status)).slice(0, 100).map((i) => ({
1674
- content: i.content,
1675
- status: i.status
1676
- }));
1677
- }
1678
- function toSummary(row, approvals, questions, workspace) {
1679
- const values = row.projections?.values ?? {};
1680
- const todos = Array.isArray(values.todos) ? values.todos : null;
1681
- let pendingApproval = false;
1682
- for (const pending of approvals.values()) if (pending.sessionId === row.sessionId) pendingApproval = true;
1683
- let pendingQuestion = false;
1684
- for (const pending of questions.values()) if (pending.sessionId === row.sessionId) pendingQuestion = true;
1685
- const cwd = typeof row.cwd === "string" ? row.cwd : "";
1686
- const label = workspace?.title ?? (cwd ? cwd.split("/").filter(Boolean).pop() : void 0);
1687
- const todoItems = sanitizeTodoItems(todos);
1688
- return {
1689
- id: row.sessionId,
1690
- title: typeof values.title === "string" ? values.title : "",
1691
- status: row.running ? "running" : row.blank ? "unknown" : "idle",
1692
- lastActivityTs: Number(row.updatedAt ?? Date.now()),
1693
- todos: todoItems.length > 0 ? {
1694
- done: todoItems.filter((i) => i.status === "completed").length,
1695
- total: todoItems.length
1696
- } : null,
1697
- todoItems: todoItems.length > 0 ? todoItems : null,
1698
- pendingApproval,
1699
- pendingQuestion,
1700
- workspaceLabel: label ?? null,
1701
- workspaceId: workspace?.workspaceId ?? null,
1702
- workspacePath: workspace?.path ?? (cwd || null)
1703
- };
1704
- }
1705
- function projectWorkspace(workspace) {
1706
- return {
1707
- id: String(workspace.workspaceId),
1708
- title: String(workspace.title),
1709
- path: String(workspace.path),
1710
- sessionIds: (workspace.sessionIds ?? []).map(String)
1711
- };
1712
- }
1713
- /** Project one raw session event into a protocol push, when it maps to one. */
1714
- function projectEvent(sessionId, event) {
1715
- switch (event.type) {
1716
- case "turn/start": return {
1717
- kind: "turn.start",
1718
- data: {}
1719
- };
1720
- case "turn/end": return {
1721
- kind: "turn.end",
1722
- data: { ok: event.data?.reason?.kind === "completed" }
1723
- };
1724
- case "user/message": return {
1725
- kind: "message.final",
1726
- data: {
1727
- seq: event.seq,
1728
- role: userRoleOf(event.data),
1729
- text: messageText(event.data),
1730
- ...attachmentProjection(event.data),
1731
- ...contextProjectionOf(event.data),
1732
- ts: tsOf(event)
1733
- }
1734
- };
1735
- case "assistant/chunk":
1736
- if (chunkTypeOf(event.data) === "reasoning-delta") return {
1737
- kind: "thinking.delta",
1738
- data: {
1739
- text: chunkText(event.data),
1740
- ts: tsOf(event)
1741
- }
1742
- };
1743
- return {
1744
- kind: "message.delta",
1745
- data: {
1746
- text: chunkText(event.data),
1747
- ts: tsOf(event)
1748
- }
1749
- };
1750
- case "assistant/message": {
1751
- const text = messageText(event.data);
1752
- const thinking = messageThinking(event.data);
1753
- if (!text.trim() && !thinking.trim()) return null;
1754
- return {
1755
- kind: "message.final",
1756
- data: {
1757
- seq: event.seq,
1758
- role: "assistant",
1759
- text,
1760
- ...thinking ? { thinking } : {},
1761
- ts: tsOf(event)
1762
- }
1763
- };
1764
- }
1765
- case "tool/call": {
1766
- const data = event.data;
1767
- return {
1768
- kind: "tool.start",
1769
- data: {
1770
- seq: event.seq,
1771
- role: "tool",
1772
- tool: {
1773
- name: String(data?.name ?? "tool"),
1774
- state: "running",
1775
- summary: summarizeArgs(data?.arguments),
1776
- ...data?.callId ? { callId: String(data.callId) } : {}
1777
- },
1778
- ts: tsOf(event)
2063
+ reason: "not-pending"
2064
+ };
2065
+ this.questions.delete(requestId);
2066
+ try {
2067
+ const receipt = await this.apiProxy.respond({
2068
+ type: "client-response",
2069
+ rpcId: pending.rpcId,
2070
+ result: {
2071
+ ok: true,
2072
+ value: {
2073
+ sessionId: pending.sessionId,
2074
+ answer: { answers: normalizeAnswerItems(answers, pending.questions) }
2075
+ }
1779
2076
  }
1780
- };
1781
- }
1782
- case "tool/result": {
1783
- const data = event.data;
2077
+ });
2078
+ if (!Boolean(receipt?.accepted)) {
2079
+ const failure = receiptFailureReason(receipt);
2080
+ if (failure !== "not-pending" && !this.questions.has(requestId)) this.questions.set(requestId, pending);
2081
+ return {
2082
+ ok: false,
2083
+ reason: failure
2084
+ };
2085
+ }
2086
+ this.bumpPendingFlags(pending.sessionId);
2087
+ return { ok: true };
2088
+ } catch {
2089
+ if (!this.questions.has(requestId)) this.questions.set(requestId, pending);
1784
2090
  return {
1785
- kind: "tool.end",
1786
- data: {
1787
- seq: event.seq,
1788
- role: "tool",
1789
- ok: !event.data || data?.error === void 0,
1790
- ...data?.callId ? { callId: String(data.callId) } : {},
1791
- ts: tsOf(event)
1792
- }
2091
+ ok: false,
2092
+ reason: "transport"
1793
2093
  };
1794
2094
  }
1795
- default: return null;
1796
2095
  }
1797
- }
1798
- function tsOf(event) {
1799
- return typeof event.time === "number" ? event.time : Date.now();
1800
- }
1801
- /** Read the durable message source off one user/message payload. Handles both
1802
- * bare-message payloads and older `{message: {...}}` wrappers; undefined when
1803
- * the shape carries no readable source (legacy hosts). */
1804
- function userMessageSource(data) {
1805
- if (!data || typeof data !== "object") return void 0;
1806
- const obj = data;
1807
- if (obj.source && typeof obj.source === "object") return obj.source;
1808
- if (obj.message && typeof obj.message === "object" && obj.message.source && typeof obj.message.source === "object") return obj.message.source;
1809
- }
1810
- /** Wire role for one user/message payload. A payload without any readable
1811
- * source degrades to 'user' so history written by older hosts stays visible;
1812
- * a present source follows the host's own trajectory rule — anything whose
1813
- * `kind` is not 'user' is injected context and projects as 'system'. */
1814
- function userRoleOf(data) {
1815
- const source = userMessageSource(data);
1816
- if (!source) return "user";
1817
- return source.kind === "user" ? "user" : "system";
1818
- }
1819
- /** Producer name of one injected-context source, mirroring how the DSH client
1820
- * runtime derives its trajectory label: plugin name, skill name, instruction
1821
- * paths, session-reference labels, or the raw kind as fallback. */
1822
- function contextLabelOf(source) {
1823
- const kind = typeof source.kind === "string" ? source.kind : "";
1824
- const joined = (member) => {
1825
- const list = source[member];
1826
- if (!Array.isArray(list)) return void 0;
1827
- const names = list.flatMap((entry) => {
1828
- if (!entry || typeof entry !== "object") return [];
1829
- const record = entry;
1830
- return [typeof record.label === "string" ? record.label : typeof record.path === "string" ? record.path : ""];
1831
- }).filter((name) => name.length > 0);
1832
- return names.length > 0 ? names.join(", ") : void 0;
1833
- };
1834
- switch (kind) {
1835
- case "session-reference": return joined("references") ?? (kind || void 0);
1836
- case "agent-instructions": return joined("changes") ?? (kind || void 0);
1837
- case "plugin": return typeof source.plugin === "string" && source.plugin.length > 0 ? source.plugin : kind || void 0;
1838
- case "skill-invocation": return typeof source.name === "string" && source.name.length > 0 ? source.name : kind || void 0;
1839
- default: return kind || void 0;
2096
+ };
2097
+ /**
2098
+ * The host validates question answers strictly (core dsh-user-questions via
2099
+ * apiProxy): a present-but-empty `custom` fails `matchesQuestions`, and a
2100
+ * single-select question rejects `custom` combined with a selection. Clients
2101
+ * may send lenient shapes (the phone historically always attached
2102
+ * `"custom": ""`, which made EVERY option-only answer fail), so normalize to
2103
+ * exactly what the host accepts before forwarding.
2104
+ */
2105
+ function normalizeAnswerItems(raw, questions) {
2106
+ if (!Array.isArray(raw)) return [];
2107
+ const askedById = /* @__PURE__ */ new Map();
2108
+ if (Array.isArray(questions)) {
2109
+ for (const q of questions) if (typeof q === "object" && q !== null && typeof q.id === "string") askedById.set(q.id, q);
1840
2110
  }
2111
+ const items = [];
2112
+ for (const entry of raw) {
2113
+ if (typeof entry !== "object" || entry === null) continue;
2114
+ const r = entry;
2115
+ if (typeof r.id !== "string") continue;
2116
+ const selected = [...new Set(Array.isArray(r.selected) ? r.selected.filter((s) => typeof s === "string") : [])];
2117
+ const customText = typeof r.custom === "string" ? r.custom : "";
2118
+ let custom;
2119
+ if (customText.trim().length > 0) custom = customText;
2120
+ if (custom !== void 0 && selected.length > 0 && askedById.get(r.id)?.multiSelect !== true) custom = void 0;
2121
+ items.push({
2122
+ id: r.id,
2123
+ selected,
2124
+ ...custom !== void 0 ? { custom } : {}
2125
+ });
2126
+ }
2127
+ return items;
1841
2128
  }
1842
- /** Semantic ContextForm declared by the producer ('snapshot', 'notice', …);
1843
- * anything unrecognized stays undefined so clients render it opaque. */
1844
- function contextFormOf(source) {
1845
- if (typeof source.form !== "string" || source.form.length === 0) return void 0;
1846
- return [
1847
- "instructions",
1848
- "catalog",
1849
- "snapshot",
1850
- "notice",
1851
- "relay",
1852
- "recall"
1853
- ].includes(source.form) ? source.form : void 0;
1854
- }
1855
- /** Optional `context` metadata for one system row; {} on user rows. */
1856
- function contextProjectionOf(data) {
1857
- if (userRoleOf(data) !== "system") return {};
1858
- const source = userMessageSource(data);
1859
- if (!source) return {};
1860
- const label = contextLabelOf(source);
1861
- const form = contextFormOf(source);
1862
- if (!label && !form) return {};
1863
- return { context: {
1864
- ...label ? { label } : {},
1865
- ...form ? { form } : {}
1866
- } };
1867
- }
1868
- /** Extract plain text from user/assistant message payloads across shapes. */
1869
- function messageText(data) {
1870
- if (typeof data === "string") return data;
1871
- if (!data || typeof data !== "object") return "";
1872
- const obj = data;
1873
- if (typeof obj.text === "string") return obj.text;
1874
- if (obj.message && typeof obj.message === "object") return messageText(obj.message);
1875
- return contentText(obj.content);
1876
- }
1877
- function contentText(content) {
1878
- if (typeof content === "string") return content;
1879
- if (Array.isArray(content)) return content.map((part) => {
1880
- if (typeof part === "string") return part;
1881
- if (part && typeof part === "object") {
1882
- const piece = part;
1883
- if (piece.type === "text" && typeof piece.text === "string") return piece.text;
1884
- }
1885
- return "";
1886
- }).join("");
1887
- return "";
1888
- }
1889
- function messageAttachments(data) {
1890
- if (!data || typeof data !== "object") return [];
1891
- const obj = data;
1892
- if (obj.message && typeof obj.message === "object") return messageAttachments(obj.message);
1893
- if (!Array.isArray(obj.content)) return [];
1894
- return obj.content.flatMap((part) => {
1895
- if (!part || typeof part !== "object") return [];
1896
- const block = part;
1897
- if (block.type !== "image" || !block.attachment) return [];
1898
- const attachmentId = typeof block.attachment.attachmentId === "string" && block.attachment.attachmentId.length > 0 ? block.attachment.attachmentId : void 0;
1899
- const width = typeof block.attachment.width === "number" && Number.isFinite(block.attachment.width) ? block.attachment.width : void 0;
1900
- const height = typeof block.attachment.height === "number" && Number.isFinite(block.attachment.height) ? block.attachment.height : void 0;
1901
- return [{
1902
- kind: "image",
1903
- ...typeof block.attachment.name === "string" ? { name: block.attachment.name } : {},
1904
- ...typeof block.attachment.mediaType === "string" ? { mediaType: block.attachment.mediaType } : {},
1905
- ...attachmentId ? { attachmentId } : {},
1906
- ...width !== void 0 ? { width } : {},
1907
- ...height !== void 0 ? { height } : {}
1908
- }];
1909
- });
2129
+ /** Map an apiProxy respond receipt onto the failure vocabulary. */
2130
+ function receiptFailureReason(receipt) {
2131
+ return receipt?.reason === "not-pending" ? "not-pending" : "bad-response";
1910
2132
  }
1911
- function attachmentProjection(data) {
1912
- const attachments = messageAttachments(data);
1913
- return attachments.length > 0 ? { attachments } : {};
2133
+ function clampTail(n) {
2134
+ if (!Number.isFinite(n)) return 100;
2135
+ return Math.max(10, Math.min(500, Math.floor(n)));
1914
2136
  }
1915
- /** Extract reasoning ("thinking") text from assistant message payloads. */
1916
- function messageThinking(data) {
1917
- if (!data || typeof data !== "object") return "";
1918
- const obj = data;
1919
- if (obj.message && typeof obj.message === "object") return messageThinking(obj.message);
1920
- return reasoningContent(obj.content);
2137
+ function localTimeZone() {
2138
+ try {
2139
+ return new Intl.DateTimeFormat().resolvedOptions().timeZone || void 0;
2140
+ } catch {
2141
+ return;
2142
+ }
1921
2143
  }
1922
- function reasoningContent(content) {
1923
- if (!Array.isArray(content)) return "";
1924
- return content.map((part) => {
1925
- if (part && typeof part === "object") {
1926
- const piece = part;
1927
- if (piece.type === "reasoning" && typeof piece.text === "string") return piece.text;
1928
- }
1929
- return "";
1930
- }).join("");
2144
+ function riskOf(toolName) {
2145
+ if (/bash|pwsh|terminal/.test(toolName)) return "write";
2146
+ if (/edit|write|str_replace|create/.test(toolName)) return "write";
2147
+ if (/delete|remove|kill/.test(toolName)) return "destructive";
2148
+ return "read";
1931
2149
  }
1932
- /** Stream chunk type of an assistant/chunk payload ('' when unwrapped). */
1933
- function chunkTypeOf(data) {
1934
- if (!data || typeof data !== "object") return "";
1935
- const obj = data;
1936
- if (obj.chunk && typeof obj.chunk === "object") return String(obj.chunk.type ?? "");
1937
- return "text-delta";
2150
+ /** First question's text for the push banner; the questions payload shape is
2151
+ * host-version dependent, so extract defensively. */
2152
+ function firstQuestionText(questions) {
2153
+ if (!Array.isArray(questions) || questions.length === 0) return "Agent 等待你的输入";
2154
+ const first = questions[0];
2155
+ return String(first?.question ?? "").trim() || "Agent 等待你的输入";
1938
2156
  }
1939
- function chunkText(data) {
1940
- if (!data || typeof data !== "object") return "";
1941
- const obj = data;
1942
- if (obj.chunk && typeof obj.chunk === "object") {
1943
- const inner = obj.chunk;
1944
- if ((inner.type === "text-delta" || inner.type === "reasoning-delta") && typeof inner.text === "string") return inner.text;
1945
- return "";
1946
- }
1947
- const direct = data;
1948
- return typeof direct.text === "string" ? direct.text : "";
2157
+ const TODO_STATUSES = /* @__PURE__ */ new Set([
2158
+ "pending",
2159
+ "in_progress",
2160
+ "completed"
2161
+ ]);
2162
+ /** Validate a host todo projection once; progress counts and the full
2163
+ * checklist both derive from this sanitized list so they never disagree. */
2164
+ function sanitizeTodoItems(items) {
2165
+ if (!items) return [];
2166
+ return items.map((i) => ({
2167
+ content: String(i.content ?? "").trim(),
2168
+ status: String(i.status ?? "")
2169
+ })).filter((i) => i.content.length > 0 && TODO_STATUSES.has(i.status)).slice(0, 100).map((i) => ({
2170
+ content: i.content,
2171
+ status: i.status
2172
+ }));
1949
2173
  }
1950
- function summarizeArgs(raw) {
1951
- if (typeof raw !== "string" || raw.length === 0) return "";
1952
- try {
1953
- const parsed = JSON.parse(raw);
1954
- const parts = [];
1955
- for (const [key, value] of Object.entries(parsed)) if (typeof value === "string") parts.push(key + "=" + truncate(value.replace(/\s+/g, " "), 60));
1956
- return truncate(parts.join(" "), 90);
1957
- } catch {
1958
- return truncate(raw, 90);
1959
- }
2174
+ function toSummary(row, approvals, questions, workspace) {
2175
+ const values = row.projections?.values ?? {};
2176
+ const todos = Array.isArray(values.todos) ? values.todos : null;
2177
+ let pendingApproval = false;
2178
+ for (const pending of approvals.values()) if (pending.sessionId === row.sessionId) pendingApproval = true;
2179
+ let pendingQuestion = false;
2180
+ for (const pending of questions.values()) if (pending.sessionId === row.sessionId) pendingQuestion = true;
2181
+ const cwd = typeof row.cwd === "string" ? row.cwd : "";
2182
+ const label = workspace?.title ?? (cwd ? cwd.split("/").filter(Boolean).pop() : void 0);
2183
+ const todoItems = sanitizeTodoItems(todos);
2184
+ return {
2185
+ id: row.sessionId,
2186
+ title: typeof values.title === "string" ? values.title : "",
2187
+ status: row.running ? "running" : "idle",
2188
+ lastActivityTs: Number(row.updatedAt ?? Date.now()),
2189
+ todos: todoItems.length > 0 ? {
2190
+ done: todoItems.filter((i) => i.status === "completed").length,
2191
+ total: todoItems.length
2192
+ } : null,
2193
+ todoItems: todoItems.length > 0 ? todoItems : null,
2194
+ pendingApproval,
2195
+ pendingQuestion,
2196
+ workspaceLabel: label ?? null,
2197
+ workspaceId: workspace?.workspaceId ?? null,
2198
+ workspacePath: workspace?.path ?? (cwd || null)
2199
+ };
1960
2200
  }
1961
- function truncate(text, max) {
1962
- return text.length <= max ? text : text.slice(0, max - 1) + "…";
2201
+ function projectWorkspace(workspace) {
2202
+ return {
2203
+ id: String(workspace.workspaceId),
2204
+ title: String(workspace.title),
2205
+ path: String(workspace.path),
2206
+ sessionIds: (workspace.sessionIds ?? []).map(String)
2207
+ };
1963
2208
  }
2209
+ /** Project one raw session event into a protocol push, when it maps to one. */
1964
2210
  function hostModelError(error) {
1965
2211
  const message = error.message ?? error.code;
1966
2212
  switch (error.code) {
@@ -2061,81 +2307,6 @@ function projectSessionModels(value) {
2061
2307
  };
2062
2308
  }
2063
2309
  /** Project a history page (raw events) into MessageProjection rows. */
2064
- function projectHistory(events) {
2065
- const messages = [];
2066
- const toolByCall = /* @__PURE__ */ new Map();
2067
- for (const entry of events) {
2068
- const event = entry.event;
2069
- const base = {
2070
- seq: event.seq,
2071
- ts: tsOf(event)
2072
- };
2073
- switch (event.type) {
2074
- case "user/message":
2075
- messages.push({
2076
- ...base,
2077
- role: userRoleOf(event.data),
2078
- text: messageText(event.data),
2079
- ...attachmentProjection(event.data),
2080
- ...contextProjectionOf(event.data)
2081
- });
2082
- break;
2083
- case "assistant/message": {
2084
- const text = messageText(event.data);
2085
- const thinking = messageThinking(event.data);
2086
- if (!text.trim() && !thinking.trim()) break;
2087
- messages.push({
2088
- ...base,
2089
- role: "assistant",
2090
- text,
2091
- ...thinking ? { thinking } : {}
2092
- });
2093
- break;
2094
- }
2095
- case "tool/call": {
2096
- const data = event.data;
2097
- const row = {
2098
- ...base,
2099
- role: "tool",
2100
- tool: {
2101
- name: String(data?.name ?? "tool"),
2102
- state: "running",
2103
- summary: summarizeArgs(data?.arguments)
2104
- }
2105
- };
2106
- messages.push(row);
2107
- if (data?.callId) toolByCall.set(String(data.callId), row);
2108
- break;
2109
- }
2110
- case "tool/result": {
2111
- const data = event.data;
2112
- const callId = data?.callId ? String(data.callId) : void 0;
2113
- const target = callId ? toolByCall.get(callId) : void 0;
2114
- const failed = data?.error !== void 0;
2115
- const summary = failed ? "失败" : summarizeResult(data?.message?.content);
2116
- if (target?.tool) target.tool = {
2117
- ...target.tool,
2118
- state: failed ? "error" : "ok",
2119
- summary
2120
- };
2121
- else messages.push({
2122
- ...base,
2123
- role: "tool",
2124
- tool: {
2125
- name: "result",
2126
- state: failed ? "error" : "ok",
2127
- summary
2128
- }
2129
- });
2130
- break;
2131
- }
2132
- }
2133
- }
2134
- return messages.sort((a, b) => a.seq - b.seq);
2135
- }
2136
- function summarizeResult(content) {
2137
- return truncate(contentText(content).replace(/\s+/g, " ").trim(), 90);
2138
- }
2139
2310
  //#endregion
2140
2311
  //#region src/report-service.ts
2141
2312
  /**
@@ -2195,9 +2366,16 @@ function str(source, key, field) {
2195
2366
  if (typeof value !== "string") reject(field);
2196
2367
  return value;
2197
2368
  }
2198
- function num(source, key, field) {
2369
+ /**
2370
+ * Non-negative integer: counters and timestamps (activeConnections,
2371
+ * historyBufferMax, updatedAt, lastSeenTs, protocolVersion, etc.). A bare
2372
+ * `typeof number` check accepts 1.5, -1, and 1e20 — all of which then
2373
+ * surface verbatim on the settings page and break any sort or arithmetic
2374
+ * the UI does.
2375
+ */
2376
+ function int(source, key, field) {
2199
2377
  const value = source[key];
2200
- if (typeof value !== "number" || !Number.isFinite(value)) reject(field);
2378
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 0) reject(field);
2201
2379
  return value;
2202
2380
  }
2203
2381
  function bool(source, key, field) {
@@ -2218,15 +2396,15 @@ function parseDevice(value) {
2218
2396
  if (environment !== "development" && environment !== "production") reject("device.apns.environment");
2219
2397
  apns = {
2220
2398
  environment,
2221
- updatedAt: num(a, "updatedAt", "device.apns.updatedAt")
2399
+ updatedAt: int(a, "updatedAt", "device.apns.updatedAt")
2222
2400
  };
2223
2401
  }
2224
2402
  return {
2225
2403
  deviceId: str(s, "deviceId", "device.deviceId"),
2226
2404
  deviceName: str(s, "deviceName", "device.deviceName"),
2227
2405
  appVersion: str(s, "appVersion", "device.appVersion"),
2228
- firstSeenTs: num(s, "firstSeenTs", "device.firstSeenTs"),
2229
- lastSeenTs: num(s, "lastSeenTs", "device.lastSeenTs"),
2406
+ firstSeenTs: int(s, "firstSeenTs", "device.firstSeenTs"),
2407
+ lastSeenTs: int(s, "lastSeenTs", "device.lastSeenTs"),
2230
2408
  ...apns ? { apns } : {}
2231
2409
  };
2232
2410
  }
@@ -2256,7 +2434,7 @@ function parseRemote(value) {
2256
2434
  ...typeof publicURL === "string" ? { publicURL } : {},
2257
2435
  ...typeof authURL === "string" ? { authURL } : {},
2258
2436
  ...typeof message === "string" ? { message } : {},
2259
- updatedAt: num(s, "updatedAt", "remote.updatedAt")
2437
+ updatedAt: int(s, "updatedAt", "remote.updatedAt")
2260
2438
  };
2261
2439
  }
2262
2440
  function parseRelayTestStep(value) {
@@ -2264,7 +2442,9 @@ function parseRelayTestStep(value) {
2264
2442
  const id = str(st, "id", "step.id");
2265
2443
  if (id !== "health" && id !== "enroll") reject("step.id");
2266
2444
  const latencyMs = st.latencyMs;
2267
- if (latencyMs !== void 0 && typeof latencyMs !== "number") reject("step.latencyMs");
2445
+ if (latencyMs !== void 0) {
2446
+ if (typeof latencyMs !== "number" || !Number.isFinite(latencyMs) || !Number.isInteger(latencyMs) || latencyMs < 0) reject("step.latencyMs");
2447
+ }
2268
2448
  return {
2269
2449
  id,
2270
2450
  ok: bool(st, "ok", "step.ok"),
@@ -2307,7 +2487,7 @@ function parsePushTestResult(value) {
2307
2487
  environment: str(r, "environment", "result.environment"),
2308
2488
  outcome: str(r, "outcome", "result.outcome"),
2309
2489
  ...typeof reason === "string" && reason.length > 0 ? { reason } : {},
2310
- ...typeof tokenFingerprint === "string" && /^[0-9a-f]{1,32}$/.test(tokenFingerprint) ? { tokenFingerprint } : {}
2490
+ ...typeof tokenFingerprint === "string" && /^[0-9a-f]{10}$/.test(tokenFingerprint) ? { tokenFingerprint } : {}
2311
2491
  };
2312
2492
  });
2313
2493
  const message = s.message;
@@ -2326,7 +2506,7 @@ function parseReport(value) {
2326
2506
  if (!Array.isArray(lanAddresses) || lanAddresses.some((value) => typeof value !== "string")) reject("lanAddresses");
2327
2507
  const releaseUrl = s.releaseUrl;
2328
2508
  return {
2329
- protocolVersion: num(s, "protocolVersion", "protocolVersion"),
2509
+ protocolVersion: int(s, "protocolVersion", "protocolVersion"),
2330
2510
  serverVersion: str(s, "serverVersion", "serverVersion"),
2331
2511
  pluginVersion: str(s, "pluginVersion", "pluginVersion"),
2332
2512
  ...s.updateAvailable === true ? { updateAvailable: true } : {},
@@ -2334,8 +2514,8 @@ function parseReport(value) {
2334
2514
  enabled: bool(s, "enabled", "enabled"),
2335
2515
  tokenPath: str(s, "tokenPath", "tokenPath"),
2336
2516
  tokenReady: bool(s, "tokenReady", "tokenReady"),
2337
- activeConnections: num(s, "activeConnections", "activeConnections"),
2338
- historyBufferMax: num(s, "historyBufferMax", "historyBufferMax"),
2517
+ activeConnections: int(s, "activeConnections", "activeConnections"),
2518
+ historyBufferMax: int(s, "historyBufferMax", "historyBufferMax"),
2339
2519
  debug: bool(s, "debug", "debug"),
2340
2520
  lanAddresses,
2341
2521
  remote: parseRemote(s.remote),
@@ -2541,10 +2721,9 @@ async function runRelayProbe(options) {
2541
2721
  });
2542
2722
  }
2543
2723
  }
2544
- const executed = steps.filter((step) => step.ok !== void 0);
2545
2724
  return {
2546
2725
  url: base,
2547
- overall: steps.length > 0 && steps.some((step) => step.id === "health" && step.ok) && executed.every((step) => step.ok) ? "ok" : "failed",
2726
+ overall: steps.length > 0 && steps.some((step) => step.id === "health" && step.ok) && steps.every((step) => step.ok) ? "ok" : "failed",
2548
2727
  tokenIssued,
2549
2728
  steps
2550
2729
  };
@@ -2566,6 +2745,10 @@ async function runRelayProbe(options) {
2566
2745
  * Privacy: logs carry outcomes and masked token prefixes only — never message
2567
2746
  * bodies or full tokens.
2568
2747
  */
2748
+ /** Classify Apple's reason without losing recoverable configuration errors. */
2749
+ function classifyApnsReason(reason) {
2750
+ return reason === "Unregistered" || reason === "ExpiredToken" ? "invalid-token" : "failed";
2751
+ }
2569
2752
  const PROVIDER_TOKEN_TTL_MS = 3e6;
2570
2753
  const REQUEST_TIMEOUT_MS = 1e4;
2571
2754
  /** base64url without padding. */
@@ -2606,7 +2789,10 @@ function apnsPayload(notification) {
2606
2789
  }
2607
2790
  /** collapse-id accepts ≤64 bytes of ASCII; keep it stable per session+event. */
2608
2791
  function collapseIdFor(notification) {
2609
- return `${notification.category}:${notification.sessionId}`.replace(/[^a-zA-Z0-9.:-]/g, "").slice(0, 64);
2792
+ const raw = `${notification.category}:${notification.sessionId}`;
2793
+ const readable = raw.replace(/[^a-zA-Z0-9.:-]/g, "");
2794
+ const digest = createHash("sha256").update(raw, "utf8").digest("hex").slice(0, 12);
2795
+ return `${readable.slice(0, 51)}:${digest}`;
2610
2796
  }
2611
2797
  function authorityFor(environment) {
2612
2798
  return environment === "production" ? "api.push.apple.com" : "api.sandbox.push.apple.com";
@@ -2721,7 +2907,8 @@ var ApnsClient = class {
2721
2907
  reason = String(JSON.parse(responseBody).reason ?? "");
2722
2908
  } catch {}
2723
2909
  if (status !== 200 && !reason) reason = "HTTP " + String(status);
2724
- if (reason === "Unregistered" || reason === "BadDeviceToken") return settle("invalid-token", reason);
2910
+ const outcome = classifyApnsReason(reason);
2911
+ if (outcome === "invalid-token") return settle(outcome, reason);
2725
2912
  if (this.debug) this.log(`apns rejected status=${status} reason=${reason}`);
2726
2913
  settle("failed", reason);
2727
2914
  });
@@ -2850,6 +3037,14 @@ const RESTART_DELAYS_MS = [
2850
3037
  16e3,
2851
3038
  3e4
2852
3039
  ];
3040
+ /**
3041
+ * Throttle for configuration-level failures (helper binary missing, state dir
3042
+ * unwritable). The environment will not self-heal between attempts, so the
3043
+ * previous "1s..30s exponential" backoff was a CPU/for-loop on a misconfigured
3044
+ * Host. 60s matches the APNs sender-failure throttle on the host plugin
3045
+ * (index.ts SENDER_FAILURE_RETRY_MS) and the relay enrollment throttle.
3046
+ */
3047
+ const UNAVAILABLE_RETRY_MS = 6e4;
2853
3048
  const DEFAULT_REMOTE_HOSTNAME = "dsh-deeppilot";
2854
3049
  /** Preserve custom node names while migrating every pre-DeepPilot default. */
2855
3050
  function normalizeRemoteHostname(value) {
@@ -2882,15 +3077,29 @@ function parseHelperEvent(line) {
2882
3077
  return null;
2883
3078
  }
2884
3079
  }
3080
+ function isTailscaleAuthURL(value) {
3081
+ if (!value) return false;
3082
+ try {
3083
+ const url = new URL(value);
3084
+ return url.protocol === "https:" && (url.hostname === "login.tailscale.com" || url.hostname.endsWith(".login.tailscale.com"));
3085
+ } catch {
3086
+ return false;
3087
+ }
3088
+ }
3089
+ /** Translate Node's platform/architecture names to the GOOS/GOARCH directory
3090
+ * names used by the committed helper matrix. */
3091
+ function bundledHelperPlatformDir(platform = process.platform, arch = process.arch) {
3092
+ return `${platform === "win32" ? "windows" : platform}-${arch === "x64" ? "amd64" : arch}`;
3093
+ }
2885
3094
  /** Build the list of candidate locations for the embedded tunnel helper, in
2886
3095
  * priority order. The first existing executable wins at start() time. The
2887
3096
  * order matters: explicit config (handled by the caller) > npm install
2888
3097
  * layout > DSH-bundled layout > user data dir. */
2889
- function bundledHelperCandidates() {
3098
+ function bundledHelperCandidates(platform = process.platform, arch = process.arch) {
2890
3099
  const here = dirname(fileURLToPath(import.meta.url));
2891
3100
  const pkgRoot = resolve(here, "..");
2892
- const fileName = process.platform === "win32" ? "dsh-deeppilot-tunnel.exe" : "dsh-deeppilot-tunnel";
2893
- const platformDir = `${process.platform}-${process.arch}`;
3101
+ const fileName = platform === "win32" ? "dsh-deeppilot-tunnel.exe" : "dsh-deeppilot-tunnel";
3102
+ const platformDir = bundledHelperPlatformDir(platform, arch);
2894
3103
  const candidates = [];
2895
3104
  candidates.push(resolve(pkgRoot, "bin", platformDir, fileName));
2896
3105
  candidates.push(resolve(pkgRoot, "..", "..", "..", "node_modules", "dsh-deeppilot", "bin", platformDir, fileName));
@@ -2948,6 +3157,7 @@ var RemoteSupervisor = class {
2948
3157
  phase: "unavailable",
2949
3158
  message
2950
3159
  });
3160
+ this.scheduleRestart(originURL, "unavailable");
2951
3161
  return;
2952
3162
  }
2953
3163
  try {
@@ -2961,6 +3171,7 @@ var RemoteSupervisor = class {
2961
3171
  phase: "unavailable",
2962
3172
  message: `cannot create remote state dir: ${String(error)}`
2963
3173
  });
3174
+ this.scheduleRestart(originURL, "unavailable");
2964
3175
  return;
2965
3176
  }
2966
3177
  if (this.stopping) return;
@@ -3030,7 +3241,7 @@ var RemoteSupervisor = class {
3030
3241
  phase: "error",
3031
3242
  message: detail || `helper exited (${signal ?? String(code)})`
3032
3243
  });
3033
- this.scheduleRestart(originURL);
3244
+ this.scheduleRestart(originURL, "crash");
3034
3245
  });
3035
3246
  }
3036
3247
  async dispose() {
@@ -3064,20 +3275,24 @@ var RemoteSupervisor = class {
3064
3275
  acceptLine(line) {
3065
3276
  const event = parseHelperEvent(line);
3066
3277
  if (event === null || event.phase === void 0) return;
3278
+ if (event.phase === "login_required") {
3279
+ if (this.statusValue.phase === "online" || !isTailscaleAuthURL(event.authURL)) return;
3280
+ }
3067
3281
  if (event.phase === "online") this.restartAttempt = 0;
3068
3282
  this.setStatus({
3069
3283
  ...event,
3070
3284
  phase: event.phase
3071
3285
  });
3072
3286
  }
3073
- scheduleRestart(originURL) {
3287
+ scheduleRestart(originURL, kind = "crash") {
3074
3288
  if (this.stopping || this.restartTimer !== void 0) return;
3075
- const delay = RESTART_DELAYS_MS[Math.min(this.restartAttempt, RESTART_DELAYS_MS.length - 1)];
3076
- this.restartAttempt += 1;
3289
+ const delay = kind === "unavailable" ? UNAVAILABLE_RETRY_MS : RESTART_DELAYS_MS[Math.min(this.restartAttempt, RESTART_DELAYS_MS.length - 1)];
3290
+ if (kind === "crash") this.restartAttempt += 1;
3077
3291
  this.restartTimer = setTimeout(() => {
3078
3292
  this.restartTimer = void 0;
3079
3293
  this.start(originURL);
3080
3294
  }, delay);
3295
+ this.restartTimer.unref?.();
3081
3296
  }
3082
3297
  setStatus(next) {
3083
3298
  const cleared = next.phase === "online" ? {
@@ -3319,23 +3534,7 @@ var UpdateChecker = class {
3319
3534
  dispose() {}
3320
3535
  };
3321
3536
  //#endregion
3322
- //#region src/index.ts
3323
- /**
3324
- * dsh-deeppilot — data bridge between the DSH host and DeepPilot
3325
- * clients. Registers exactly one WebSocket upgrade route (/phone) plus an
3326
- * optional health probe (/phone/health) on the existing web server. The web
3327
- * UI is never touched.
3328
- *
3329
- * Data plane: an in-process HostBridge consumes apiProxy.events.mux()/host()
3330
- * streams, mirrors session summaries, tracks pending approvals/questions,
3331
- * and fans projected protocol-v1 pushes out to every connected device.
3332
- *
3333
- * Protocol: src/protocol.ts, v1. The private app repository carries the
3334
- * matching normative document and Swift models.
3335
- */
3336
- const name = "deeppilot";
3337
- /** No eager service requirement: profiles without a web stack simply skip. */
3338
- const inject = [];
3537
+ //#region src/config.ts
3339
3538
  /** Operator-run relay used by distributed builds; overridable via config. */
3340
3539
  const DEFAULT_RELAY_URL = "https://pilot.hailab.dev";
3341
3540
  const Config = z.object({
@@ -3346,11 +3545,15 @@ const Config = z.object({
3346
3545
  debug: z.boolean().default(false),
3347
3546
  remote: z.object({
3348
3547
  enabled: z.boolean().default(false),
3349
- provider: z.string().default("tailscale-funnel"),
3548
+ provider: z.union(["tailscale-funnel"]).default("tailscale-funnel"),
3350
3549
  hostname: z.string().default(DEFAULT_REMOTE_HOSTNAME),
3351
3550
  statePath: z.string().default(join(bridgeDataDir(), "tailscale")),
3352
3551
  helperPath: z.string().default(""),
3353
- funnelPort: z.natural().default(443)
3552
+ funnelPort: z.union([
3553
+ 443,
3554
+ 8443,
3555
+ 1e4
3556
+ ]).default(443)
3354
3557
  }).default({
3355
3558
  enabled: false,
3356
3559
  provider: "tailscale-funnel",
@@ -3360,7 +3563,11 @@ const Config = z.object({
3360
3563
  funnelPort: 443
3361
3564
  }),
3362
3565
  push: z.object({
3363
- provider: z.string().default("none"),
3566
+ provider: z.union([
3567
+ "none",
3568
+ "apns",
3569
+ "relay"
3570
+ ]).default("none"),
3364
3571
  teamId: z.string().default(""),
3365
3572
  keyId: z.string().default(""),
3366
3573
  keyPath: z.string().default(join(bridgeDataDir(), "apns", "AuthKey.p8")),
@@ -3377,6 +3584,75 @@ const Config = z.object({
3377
3584
  relayToken: ""
3378
3585
  })
3379
3586
  });
3587
+ /**
3588
+ * Cordis hands the second argument in different shapes depending on host
3589
+ * composition: a reactive options getter, the resolved config value, or
3590
+ * nothing when the patch row omits `config`. Normalize all of them.
3591
+ */
3592
+ function normalizeOptions(options) {
3593
+ if (typeof options === "function") return options();
3594
+ if (options && typeof options === "object") return options;
3595
+ return Config(void 0) ?? {};
3596
+ }
3597
+ //#endregion
3598
+ //#region src/phone-http.ts
3599
+ function rejectUpgrade(socket, status, reason) {
3600
+ const body = JSON.stringify({ error: reason });
3601
+ const statusText = {
3602
+ 401: "Unauthorized",
3603
+ 429: "Too Many Requests",
3604
+ 500: "Internal Server Error",
3605
+ 503: "Service Unavailable"
3606
+ };
3607
+ const authenticate = status === 401 ? "WWW-Authenticate: Bearer realm=\"deeppilot\"\r\n" : "";
3608
+ socket.end("HTTP/1.1 " + status + " " + (statusText[status] ?? "Error") + "\r\n" + authenticate + "Content-Type: application/json\r\nContent-Length: " + Buffer.byteLength(body) + "\r\nConnection: close\r\n\r\n" + body);
3609
+ }
3610
+ /** Authorization is preferred; the query form remains for older app builds. */
3611
+ function requestToken(req) {
3612
+ const authorization = req.headers.authorization;
3613
+ if (typeof authorization === "string") {
3614
+ const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
3615
+ if (match?.[1]) return match[1];
3616
+ }
3617
+ try {
3618
+ return new URL(req.url ?? "/", "http://phone.local").searchParams.get("token");
3619
+ } catch {
3620
+ return null;
3621
+ }
3622
+ }
3623
+ //#endregion
3624
+ //#region src/push-policy.ts
3625
+ /** Prune only when the provider supplies an authoritative token-lifecycle verdict. */
3626
+ function shouldPrunePushToken(outcome, reason) {
3627
+ return outcome === "invalid-token" && (reason === "Unregistered" || reason === "ExpiredToken");
3628
+ }
3629
+ /**
3630
+ * Zero-touch relay self-heal: HTTP 401 means the relay no longer honors the
3631
+ * cached credential. Only auto-enrolled cells with a still-current token may
3632
+ * re-derive it; an explicitly configured relay token remains user-owned
3633
+ * configuration and is never silently rewritten.
3634
+ */
3635
+ function shouldReEnrollRelayToken(transport, outcome, reason, opts) {
3636
+ return transport === "relay" && outcome === "failed" && reason === "HTTP 401" && opts.hasEnrollKey && opts.usedCellToken && opts.tokenStillCurrent;
3637
+ }
3638
+ //#endregion
3639
+ //#region src/index.ts
3640
+ /**
3641
+ * dsh-deeppilot — data bridge between the DSH host and DeepPilot
3642
+ * clients. Registers exactly one WebSocket upgrade route (/phone) plus an
3643
+ * optional health probe (/phone/health) on the existing web server. The web
3644
+ * UI is never touched.
3645
+ *
3646
+ * Data plane: an in-process HostBridge consumes apiProxy.events.mux()/host()
3647
+ * streams, mirrors session summaries, tracks pending approvals/questions,
3648
+ * and fans projected protocol-v1 pushes out to every connected device.
3649
+ *
3650
+ * Protocol: PROTOCOL.md is normative; src/protocol.ts and the private app's
3651
+ * Swift models mirror that v1 contract.
3652
+ */
3653
+ const name = "deeppilot";
3654
+ /** No eager service requirement: profiles without a web stack simply skip. */
3655
+ const inject = [];
3380
3656
  const SERVER_VERSION = readOwnPackageVersion();
3381
3657
  const MAX_CLIENT_CONNECTIONS = 16;
3382
3658
  /**
@@ -3401,33 +3677,6 @@ function readOwnPackageVersion() {
3401
3677
  if (typeof envVersion === "string" && envVersion.length > 0) return envVersion;
3402
3678
  return "0.0.0+unknown";
3403
3679
  }
3404
- function rejectUpgrade(socket, status, reason) {
3405
- const body = JSON.stringify({ error: reason });
3406
- socket.end("HTTP/1.1 " + status + " Forbidden\r\nContent-Type: application/json\r\nContent-Length: " + Buffer.byteLength(body) + "\r\nConnection: close\r\n\r\n" + body);
3407
- }
3408
- /** Authorization is preferred; the query form remains for older app builds. */
3409
- function requestToken(req) {
3410
- const authorization = req.headers.authorization;
3411
- if (typeof authorization === "string") {
3412
- const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
3413
- if (match?.[1]) return match[1];
3414
- }
3415
- try {
3416
- return new URL(req.url ?? "/", "http://phone.local").searchParams.get("token");
3417
- } catch {
3418
- return null;
3419
- }
3420
- }
3421
- /**
3422
- * Cordis hands the second argument in different shapes depending on host
3423
- * composition: a reactive options getter, the resolved config value, or
3424
- * nothing when the patch row omits `config`. Normalize all of them.
3425
- */
3426
- function normalizeOptions(options) {
3427
- if (typeof options === "function") return options();
3428
- if (options && typeof options === "object") return options;
3429
- return Config(void 0) ?? {};
3430
- }
3431
3680
  function apply(ctx, options) {
3432
3681
  const cfg = normalizeOptions(options);
3433
3682
  const log = (message) => {
@@ -3456,16 +3705,22 @@ function apply(ctx, options) {
3456
3705
  const dataDir = bridgeDataDir();
3457
3706
  const pushRelayPath = join(dataDir, "push-relay.json");
3458
3707
  const enrollmentCell = {};
3708
+ let enrollmentWriteTail = Promise.resolve();
3459
3709
  function persistEnrollment() {
3460
- (async () => {
3710
+ const snapshot = JSON.stringify({
3711
+ version: 1,
3712
+ ...enrollmentCell
3713
+ }, null, 2) + "\n";
3714
+ enrollmentWriteTail = enrollmentWriteTail.then(async () => {
3715
+ const tempPath = pushRelayPath + "." + randomBytes(6).toString("hex") + ".tmp";
3461
3716
  try {
3462
3717
  await mkdir(dataDir, { recursive: true });
3463
- await writeFile(pushRelayPath, JSON.stringify({
3464
- version: 1,
3465
- ...enrollmentCell
3466
- }, null, 2) + "\n", { mode: 384 });
3467
- } catch {}
3468
- })();
3718
+ await writeFile(tempPath, snapshot, { mode: 384 });
3719
+ await rename(tempPath, pushRelayPath);
3720
+ } catch {
3721
+ await unlink(tempPath).catch(() => {});
3722
+ }
3723
+ });
3469
3724
  }
3470
3725
  /** Fired from BridgeConnection when an app presents its built-in key. */
3471
3726
  const handlePushEnrollKey = async (enrollKey) => {
@@ -3478,7 +3733,7 @@ function apply(ctx, options) {
3478
3733
  }
3479
3734
  }
3480
3735
  persistEnrollment();
3481
- const url = (currentConfig().push?.relayUrl ?? "").trim() || DEFAULT_RELAY_URL;
3736
+ const url = (currentConfig().push?.relayUrl ?? "").trim() || "https://pilot.hailab.dev";
3482
3737
  await ensureRelayEnrolled(url);
3483
3738
  };
3484
3739
  const auth = {
@@ -3510,7 +3765,9 @@ function apply(ctx, options) {
3510
3765
  if (raw.autoRelay === true) enrollmentCell.autoRelay = true;
3511
3766
  } catch {}
3512
3767
  } catch (error) {
3513
- log("auth material unavailable, bridge degraded: " + String(error));
3768
+ const message = String(error);
3769
+ if (message.includes("pairing token is malformed at")) console.warn("[deeppilot] " + message);
3770
+ log("auth material unavailable, bridge degraded: " + message);
3514
3771
  return {
3515
3772
  token: null,
3516
3773
  devices: null
@@ -3610,12 +3867,23 @@ function apply(ctx, options) {
3610
3867
  };
3611
3868
  };
3612
3869
  const connections = /* @__PURE__ */ new Set();
3870
+ const closeConnectionsForBridge = (bridge) => {
3871
+ for (const connection of connections) {
3872
+ if (!connection.isAttachedTo(bridge)) continue;
3873
+ connection.closeForServerStop();
3874
+ connections.delete(connection);
3875
+ }
3876
+ };
3877
+ const closeAllConnections = () => {
3878
+ for (const connection of connections) connection.closeForServerStop();
3879
+ connections.clear();
3880
+ };
3613
3881
  const resolvePushConfig = (config) => {
3614
3882
  const push = config.push ?? {};
3615
3883
  const configured = push.provider ?? "none";
3616
3884
  const effectiveProvider = configured === "none" && enrollmentCell.autoRelay === true ? "relay" : configured;
3617
3885
  if (effectiveProvider === "relay") {
3618
- const url = (push.relayUrl ?? "").trim() || DEFAULT_RELAY_URL;
3886
+ const url = (push.relayUrl ?? "").trim() || "https://pilot.hailab.dev";
3619
3887
  const token = (push.relayToken ?? "").trim() || enrollmentCell.token || "";
3620
3888
  if (!/^https:\/\//i.test(url)) return {
3621
3889
  ok: false,
@@ -3777,7 +4045,8 @@ function apply(ctx, options) {
3777
4045
  * - each device is delivered on ITS registered environment (the build
3778
4046
  * kind it self-reported), so sandbox and production devices coexist;
3779
4047
  * - the device's per-category switches suppress muted categories;
3780
- * - Unregistered/BadDeviceToken outcomes prune the stored token.
4048
+ * - only APNs' terminal Unregistered/ExpiredToken verdicts prune storage;
4049
+ * BadDeviceToken may be an environment mismatch and stays diagnosable.
3781
4050
  */
3782
4051
  const makePushOutlet = () => ({
3783
4052
  isAvailable: () => {
@@ -3790,7 +4059,7 @@ function apply(ctx, options) {
3790
4059
  (async () => {
3791
4060
  let resolved = resolvePushConfig(currentConfig());
3792
4061
  if (!resolved.ok && resolved.reason === "relay token not enrolled yet") {
3793
- const relayUrl = (currentConfig().push?.relayUrl ?? "").trim() || DEFAULT_RELAY_URL;
4062
+ const relayUrl = (currentConfig().push?.relayUrl ?? "").trim() || "https://pilot.hailab.dev";
3794
4063
  await ensureRelayEnrolled(relayUrl);
3795
4064
  resolved = resolvePushConfig(currentConfig());
3796
4065
  }
@@ -3820,6 +4089,10 @@ function apply(ctx, options) {
3820
4089
  log(`push(${transport}) ${notification.category}: no offline targets (connected=${connectedIds.size}, tokenized=${tokenized})`);
3821
4090
  return;
3822
4091
  }
4092
+ const relayUrl = resolved.value.kind === "relay" ? resolved.value.url : void 0;
4093
+ const relayTokenUsed = resolved.value.kind === "relay" ? resolved.value.token : void 0;
4094
+ const usedCellToken = relayTokenUsed !== void 0 && relayTokenUsed === enrollmentCell.token;
4095
+ const hasEnrollKey = Boolean(enrollmentCell.enrollKey);
3823
4096
  for (const device of candidates) {
3824
4097
  const registration = device.apns;
3825
4098
  send({
@@ -3828,9 +4101,20 @@ function apply(ctx, options) {
3828
4101
  notification
3829
4102
  }).then(({ outcome, reason }) => {
3830
4103
  log(`push(${transport}) ${notification.category} → "${device.deviceName}" [${registration.environment}] = ${outcome}${reason ? " (" + reason + ")" : ""}`);
3831
- if (outcome === "invalid-token") {
4104
+ if (shouldPrunePushToken(outcome, reason)) {
3832
4105
  devices.clearPushToken(device.deviceId);
3833
4106
  log(`push: pruned stale token of "${device.deviceName}" (${reason ?? "unknown"}) — app re-registers on next launch`);
4107
+ return;
4108
+ }
4109
+ if (relayUrl !== void 0 && shouldReEnrollRelayToken(transport, outcome, reason, {
4110
+ usedCellToken,
4111
+ hasEnrollKey,
4112
+ tokenStillCurrent: enrollmentCell.token === relayTokenUsed
4113
+ })) {
4114
+ enrollmentCell.token = void 0;
4115
+ persistEnrollment();
4116
+ log("push relay credential rejected (HTTP 401); re-enrolling");
4117
+ ensureRelayEnrolled(relayUrl);
3834
4118
  }
3835
4119
  }).catch(() => {});
3836
4120
  }
@@ -3905,7 +4189,7 @@ function apply(ctx, options) {
3905
4189
  message: `当前推送模式不是中继(provider=${configured})。启用方式二选一:① 零配置——在 ios/project.yml 填写 DSPushEnrollKey(与服务器 RELAY_ENROLL_KEY 一致)并重新安装 App,打开 App 即自动启用;② 手动——将 push.provider 设为 relay 并填入 relayToken`
3906
4190
  }]
3907
4191
  };
3908
- const url = (push.relayUrl ?? "").trim() || DEFAULT_RELAY_URL;
4192
+ const url = (push.relayUrl ?? "").trim() || "https://pilot.hailab.dev";
3909
4193
  if (!/^https:\/\//i.test(url)) return {
3910
4194
  url,
3911
4195
  overall: "failed",
@@ -3935,6 +4219,7 @@ function apply(ctx, options) {
3935
4219
  return await runPushSelfTest();
3936
4220
  });
3937
4221
  const state = {};
4222
+ let pendingUpgrades = 0;
3938
4223
  const handleUpgrade = (req, socket, head) => {
3939
4224
  (async () => {
3940
4225
  try {
@@ -3942,44 +4227,53 @@ function apply(ctx, options) {
3942
4227
  rejectUpgrade(socket, 503, "bridge disabled");
3943
4228
  return;
3944
4229
  }
3945
- if (connections.size >= MAX_CLIENT_CONNECTIONS) {
4230
+ if (connections.size + pendingUpgrades >= MAX_CLIENT_CONNECTIONS) {
3946
4231
  rejectUpgrade(socket, 429, "too many connections");
3947
4232
  return;
3948
4233
  }
3949
- const { devices } = await ready;
3950
- const token = auth.token;
3951
- if (!token || !devices) {
3952
- rejectUpgrade(socket, 503, "bridge degraded");
3953
- return;
3954
- }
3955
- const presentedToken = requestToken(req);
3956
- if (presentedToken !== null && !tokenMatches(presentedToken, token)) {
3957
- rejectUpgrade(socket, 401, "invalid token");
3958
- return;
3959
- }
3960
- const bridge = state.bridge;
3961
- if (!bridge) {
3962
- rejectUpgrade(socket, 503, "bridge not ready");
3963
- return;
3964
- }
3965
- if (auth.token !== token) {
3966
- rejectUpgrade(socket, 401, "invalid token");
3967
- return;
3968
- }
3969
- wss.handleUpgrade(req, socket, head, (ws) => {
3970
- const connection = new BridgeConnection(ws, {
3971
- bridge,
3972
- devices,
3973
- serverVersion: SERVER_VERSION,
3974
- expectedToken: token,
3975
- transportAuthenticated: presentedToken !== null,
3976
- log,
3977
- debug: currentConfig().debug === true,
3978
- onClosed: (closed) => connections.delete(closed),
3979
- onPushEnrollKey: handlePushEnrollKey
4234
+ pendingUpgrades += 1;
4235
+ try {
4236
+ const { devices } = await ready;
4237
+ const token = auth.token;
4238
+ if (!token || !devices) {
4239
+ rejectUpgrade(socket, 503, "bridge degraded");
4240
+ return;
4241
+ }
4242
+ const presentedToken = requestToken(req);
4243
+ if (presentedToken !== null && !tokenMatches(presentedToken, token)) {
4244
+ rejectUpgrade(socket, 401, "invalid token");
4245
+ return;
4246
+ }
4247
+ const bridge = state.bridge;
4248
+ if (!bridge) {
4249
+ rejectUpgrade(socket, 503, "bridge not ready");
4250
+ return;
4251
+ }
4252
+ if (auth.token !== token) {
4253
+ rejectUpgrade(socket, 401, "invalid token");
4254
+ return;
4255
+ }
4256
+ wss.handleUpgrade(req, socket, head, (ws) => {
4257
+ if (auth.token !== token || state.bridge !== bridge) {
4258
+ ws.close(1012, "bridge changed");
4259
+ return;
4260
+ }
4261
+ const connection = new BridgeConnection(ws, {
4262
+ bridge,
4263
+ devices,
4264
+ serverVersion: SERVER_VERSION,
4265
+ expectedToken: token,
4266
+ transportAuthenticated: presentedToken !== null,
4267
+ log,
4268
+ debug: currentConfig().debug === true,
4269
+ onClosed: (closed) => connections.delete(closed),
4270
+ onPushEnrollKey: handlePushEnrollKey
4271
+ });
4272
+ connections.add(connection);
3980
4273
  });
3981
- connections.add(connection);
3982
- });
4274
+ } finally {
4275
+ pendingUpgrades -= 1;
4276
+ }
3983
4277
  } catch (error) {
3984
4278
  log("upgrade failed: " + String(error));
3985
4279
  rejectUpgrade(socket, 500, "internal error");
@@ -4033,7 +4327,11 @@ function apply(ctx, options) {
4033
4327
  state.bridge = bridge;
4034
4328
  bridge.start();
4035
4329
  log("data plane active (mux + host streams)");
4036
- apiCtx.effect(() => () => bridge.dispose(), "deeppilot: host streams");
4330
+ apiCtx.effect(() => () => {
4331
+ closeConnectionsForBridge(bridge);
4332
+ if (state.bridge === bridge) state.bridge = void 0;
4333
+ bridge.dispose();
4334
+ }, "deeppilot: host streams");
4037
4335
  });
4038
4336
  ctx.inject(["webServer"], (sub) => {
4039
4337
  const webCtx = sub;
@@ -4055,7 +4353,7 @@ function apply(ctx, options) {
4055
4353
  const now = Date.now();
4056
4354
  for (const connection of connections) if (connection.isStale(now, 6e4)) {
4057
4355
  log("dropping stale connection");
4058
- connection.terminate();
4356
+ connection.closeIdle();
4059
4357
  connections.delete(connection);
4060
4358
  }
4061
4359
  }, 3e4);
@@ -4141,8 +4439,23 @@ function apply(ctx, options) {
4141
4439
  if (enabledNow()) log("/phone WebSocket registered");
4142
4440
  else log("bridge disabled; /phone refuses connections until re-enabled and restarted");
4143
4441
  });
4442
+ ctx.effect(() => async () => {
4443
+ closeAllConnections();
4444
+ const bridge = state.bridge;
4445
+ state.bridge = void 0;
4446
+ bridge?.dispose();
4447
+ const sender = cachedSender;
4448
+ cachedSender = void 0;
4449
+ updateChecker.dispose();
4450
+ const wssClosed = new Promise((resolve) => wss.close(() => resolve()));
4451
+ await Promise.allSettled([
4452
+ enrollmentWriteTail,
4453
+ sender?.dispose?.() ?? Promise.resolve(),
4454
+ wssClosed
4455
+ ]);
4456
+ }, "deeppilot: process resources");
4144
4457
  }
4145
4458
  //#endregion
4146
- export { Config, HostBridge, apply, inject, name, requestToken };
4459
+ export { Config, HostBridge, apply, inject, name, requestToken, shouldPrunePushToken, shouldReEnrollRelayToken };
4147
4460
 
4148
4461
  //# sourceMappingURL=index.js.map