dsh-deeppilot 0.3.0 → 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/client.js +1757 -1348
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +75 -47
- package/lib/index.js +1092 -784
- package/lib/index.js.map +1 -1
- package/package.json +2 -3
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
|
-
|
|
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 (
|
|
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
|
|
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 >
|
|
528
|
-
if (rawImages.length >
|
|
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 >
|
|
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
|
|
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,
|
|
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,
|
|
601
|
-
const appVersion = sanitizeDeviceField(p.appVersion,
|
|
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-
|
|
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
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
717
|
-
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
|
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
|
|
1404
|
+
sessionId,
|
|
910
1405
|
toolName,
|
|
911
1406
|
summary,
|
|
912
1407
|
riskLevel: riskOf(toolName)
|
|
913
1408
|
});
|
|
914
|
-
this.
|
|
915
|
-
|
|
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(
|
|
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.
|
|
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
|
|
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:
|
|
2008
|
+
value: this.userReceiptSeq
|
|
1513
2009
|
};
|
|
1514
2010
|
} catch (error) {
|
|
1515
2011
|
return {
|
|
@@ -1563,404 +2059,154 @@ var HostBridge = class {
|
|
|
1563
2059
|
async respondQuestion(requestId, answers) {
|
|
1564
2060
|
const pending = this.questions.get(requestId);
|
|
1565
2061
|
if (!pending) return {
|
|
1566
|
-
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)
|
|
2062
|
+
ok: false,
|
|
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
|
-
|
|
1783
|
-
|
|
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
|
-
|
|
1786
|
-
|
|
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
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
*
|
|
1803
|
-
*
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
if (
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
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
|
-
/**
|
|
1843
|
-
|
|
1844
|
-
|
|
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
|
|
1912
|
-
|
|
1913
|
-
return
|
|
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
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
2137
|
+
function localTimeZone() {
|
|
2138
|
+
try {
|
|
2139
|
+
return new Intl.DateTimeFormat().resolvedOptions().timeZone || void 0;
|
|
2140
|
+
} catch {
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
1921
2143
|
}
|
|
1922
|
-
function
|
|
1923
|
-
if (
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
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
|
-
/**
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
return "
|
|
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
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
return
|
|
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
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
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
|
|
1962
|
-
return
|
|
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
|
-
|
|
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:
|
|
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:
|
|
2229
|
-
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:
|
|
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
|
|
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]{
|
|
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:
|
|
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:
|
|
2338
|
-
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) &&
|
|
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
|
-
|
|
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
|
-
|
|
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,6 +3077,15 @@ 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
|
+
}
|
|
2885
3089
|
/** Translate Node's platform/architecture names to the GOOS/GOARCH directory
|
|
2886
3090
|
* names used by the committed helper matrix. */
|
|
2887
3091
|
function bundledHelperPlatformDir(platform = process.platform, arch = process.arch) {
|
|
@@ -2953,6 +3157,7 @@ var RemoteSupervisor = class {
|
|
|
2953
3157
|
phase: "unavailable",
|
|
2954
3158
|
message
|
|
2955
3159
|
});
|
|
3160
|
+
this.scheduleRestart(originURL, "unavailable");
|
|
2956
3161
|
return;
|
|
2957
3162
|
}
|
|
2958
3163
|
try {
|
|
@@ -2966,6 +3171,7 @@ var RemoteSupervisor = class {
|
|
|
2966
3171
|
phase: "unavailable",
|
|
2967
3172
|
message: `cannot create remote state dir: ${String(error)}`
|
|
2968
3173
|
});
|
|
3174
|
+
this.scheduleRestart(originURL, "unavailable");
|
|
2969
3175
|
return;
|
|
2970
3176
|
}
|
|
2971
3177
|
if (this.stopping) return;
|
|
@@ -3035,7 +3241,7 @@ var RemoteSupervisor = class {
|
|
|
3035
3241
|
phase: "error",
|
|
3036
3242
|
message: detail || `helper exited (${signal ?? String(code)})`
|
|
3037
3243
|
});
|
|
3038
|
-
this.scheduleRestart(originURL);
|
|
3244
|
+
this.scheduleRestart(originURL, "crash");
|
|
3039
3245
|
});
|
|
3040
3246
|
}
|
|
3041
3247
|
async dispose() {
|
|
@@ -3069,20 +3275,24 @@ var RemoteSupervisor = class {
|
|
|
3069
3275
|
acceptLine(line) {
|
|
3070
3276
|
const event = parseHelperEvent(line);
|
|
3071
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
|
+
}
|
|
3072
3281
|
if (event.phase === "online") this.restartAttempt = 0;
|
|
3073
3282
|
this.setStatus({
|
|
3074
3283
|
...event,
|
|
3075
3284
|
phase: event.phase
|
|
3076
3285
|
});
|
|
3077
3286
|
}
|
|
3078
|
-
scheduleRestart(originURL) {
|
|
3287
|
+
scheduleRestart(originURL, kind = "crash") {
|
|
3079
3288
|
if (this.stopping || this.restartTimer !== void 0) return;
|
|
3080
|
-
const delay = RESTART_DELAYS_MS[Math.min(this.restartAttempt, RESTART_DELAYS_MS.length - 1)];
|
|
3081
|
-
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;
|
|
3082
3291
|
this.restartTimer = setTimeout(() => {
|
|
3083
3292
|
this.restartTimer = void 0;
|
|
3084
3293
|
this.start(originURL);
|
|
3085
3294
|
}, delay);
|
|
3295
|
+
this.restartTimer.unref?.();
|
|
3086
3296
|
}
|
|
3087
3297
|
setStatus(next) {
|
|
3088
3298
|
const cleared = next.phase === "online" ? {
|
|
@@ -3324,23 +3534,7 @@ var UpdateChecker = class {
|
|
|
3324
3534
|
dispose() {}
|
|
3325
3535
|
};
|
|
3326
3536
|
//#endregion
|
|
3327
|
-
//#region src/
|
|
3328
|
-
/**
|
|
3329
|
-
* dsh-deeppilot — data bridge between the DSH host and DeepPilot
|
|
3330
|
-
* clients. Registers exactly one WebSocket upgrade route (/phone) plus an
|
|
3331
|
-
* optional health probe (/phone/health) on the existing web server. The web
|
|
3332
|
-
* UI is never touched.
|
|
3333
|
-
*
|
|
3334
|
-
* Data plane: an in-process HostBridge consumes apiProxy.events.mux()/host()
|
|
3335
|
-
* streams, mirrors session summaries, tracks pending approvals/questions,
|
|
3336
|
-
* and fans projected protocol-v1 pushes out to every connected device.
|
|
3337
|
-
*
|
|
3338
|
-
* Protocol: src/protocol.ts, v1. The private app repository carries the
|
|
3339
|
-
* matching normative document and Swift models.
|
|
3340
|
-
*/
|
|
3341
|
-
const name = "deeppilot";
|
|
3342
|
-
/** No eager service requirement: profiles without a web stack simply skip. */
|
|
3343
|
-
const inject = [];
|
|
3537
|
+
//#region src/config.ts
|
|
3344
3538
|
/** Operator-run relay used by distributed builds; overridable via config. */
|
|
3345
3539
|
const DEFAULT_RELAY_URL = "https://pilot.hailab.dev";
|
|
3346
3540
|
const Config = z.object({
|
|
@@ -3351,11 +3545,15 @@ const Config = z.object({
|
|
|
3351
3545
|
debug: z.boolean().default(false),
|
|
3352
3546
|
remote: z.object({
|
|
3353
3547
|
enabled: z.boolean().default(false),
|
|
3354
|
-
provider: z.
|
|
3548
|
+
provider: z.union(["tailscale-funnel"]).default("tailscale-funnel"),
|
|
3355
3549
|
hostname: z.string().default(DEFAULT_REMOTE_HOSTNAME),
|
|
3356
3550
|
statePath: z.string().default(join(bridgeDataDir(), "tailscale")),
|
|
3357
3551
|
helperPath: z.string().default(""),
|
|
3358
|
-
funnelPort: z.
|
|
3552
|
+
funnelPort: z.union([
|
|
3553
|
+
443,
|
|
3554
|
+
8443,
|
|
3555
|
+
1e4
|
|
3556
|
+
]).default(443)
|
|
3359
3557
|
}).default({
|
|
3360
3558
|
enabled: false,
|
|
3361
3559
|
provider: "tailscale-funnel",
|
|
@@ -3365,7 +3563,11 @@ const Config = z.object({
|
|
|
3365
3563
|
funnelPort: 443
|
|
3366
3564
|
}),
|
|
3367
3565
|
push: z.object({
|
|
3368
|
-
provider: z.
|
|
3566
|
+
provider: z.union([
|
|
3567
|
+
"none",
|
|
3568
|
+
"apns",
|
|
3569
|
+
"relay"
|
|
3570
|
+
]).default("none"),
|
|
3369
3571
|
teamId: z.string().default(""),
|
|
3370
3572
|
keyId: z.string().default(""),
|
|
3371
3573
|
keyPath: z.string().default(join(bridgeDataDir(), "apns", "AuthKey.p8")),
|
|
@@ -3382,6 +3584,75 @@ const Config = z.object({
|
|
|
3382
3584
|
relayToken: ""
|
|
3383
3585
|
})
|
|
3384
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 = [];
|
|
3385
3656
|
const SERVER_VERSION = readOwnPackageVersion();
|
|
3386
3657
|
const MAX_CLIENT_CONNECTIONS = 16;
|
|
3387
3658
|
/**
|
|
@@ -3406,33 +3677,6 @@ function readOwnPackageVersion() {
|
|
|
3406
3677
|
if (typeof envVersion === "string" && envVersion.length > 0) return envVersion;
|
|
3407
3678
|
return "0.0.0+unknown";
|
|
3408
3679
|
}
|
|
3409
|
-
function rejectUpgrade(socket, status, reason) {
|
|
3410
|
-
const body = JSON.stringify({ error: reason });
|
|
3411
|
-
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);
|
|
3412
|
-
}
|
|
3413
|
-
/** Authorization is preferred; the query form remains for older app builds. */
|
|
3414
|
-
function requestToken(req) {
|
|
3415
|
-
const authorization = req.headers.authorization;
|
|
3416
|
-
if (typeof authorization === "string") {
|
|
3417
|
-
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
|
|
3418
|
-
if (match?.[1]) return match[1];
|
|
3419
|
-
}
|
|
3420
|
-
try {
|
|
3421
|
-
return new URL(req.url ?? "/", "http://phone.local").searchParams.get("token");
|
|
3422
|
-
} catch {
|
|
3423
|
-
return null;
|
|
3424
|
-
}
|
|
3425
|
-
}
|
|
3426
|
-
/**
|
|
3427
|
-
* Cordis hands the second argument in different shapes depending on host
|
|
3428
|
-
* composition: a reactive options getter, the resolved config value, or
|
|
3429
|
-
* nothing when the patch row omits `config`. Normalize all of them.
|
|
3430
|
-
*/
|
|
3431
|
-
function normalizeOptions(options) {
|
|
3432
|
-
if (typeof options === "function") return options();
|
|
3433
|
-
if (options && typeof options === "object") return options;
|
|
3434
|
-
return Config(void 0) ?? {};
|
|
3435
|
-
}
|
|
3436
3680
|
function apply(ctx, options) {
|
|
3437
3681
|
const cfg = normalizeOptions(options);
|
|
3438
3682
|
const log = (message) => {
|
|
@@ -3461,16 +3705,22 @@ function apply(ctx, options) {
|
|
|
3461
3705
|
const dataDir = bridgeDataDir();
|
|
3462
3706
|
const pushRelayPath = join(dataDir, "push-relay.json");
|
|
3463
3707
|
const enrollmentCell = {};
|
|
3708
|
+
let enrollmentWriteTail = Promise.resolve();
|
|
3464
3709
|
function persistEnrollment() {
|
|
3465
|
-
|
|
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";
|
|
3466
3716
|
try {
|
|
3467
3717
|
await mkdir(dataDir, { recursive: true });
|
|
3468
|
-
await writeFile(
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
}
|
|
3473
|
-
})
|
|
3718
|
+
await writeFile(tempPath, snapshot, { mode: 384 });
|
|
3719
|
+
await rename(tempPath, pushRelayPath);
|
|
3720
|
+
} catch {
|
|
3721
|
+
await unlink(tempPath).catch(() => {});
|
|
3722
|
+
}
|
|
3723
|
+
});
|
|
3474
3724
|
}
|
|
3475
3725
|
/** Fired from BridgeConnection when an app presents its built-in key. */
|
|
3476
3726
|
const handlePushEnrollKey = async (enrollKey) => {
|
|
@@ -3483,7 +3733,7 @@ function apply(ctx, options) {
|
|
|
3483
3733
|
}
|
|
3484
3734
|
}
|
|
3485
3735
|
persistEnrollment();
|
|
3486
|
-
const url = (currentConfig().push?.relayUrl ?? "").trim() ||
|
|
3736
|
+
const url = (currentConfig().push?.relayUrl ?? "").trim() || "https://pilot.hailab.dev";
|
|
3487
3737
|
await ensureRelayEnrolled(url);
|
|
3488
3738
|
};
|
|
3489
3739
|
const auth = {
|
|
@@ -3515,7 +3765,9 @@ function apply(ctx, options) {
|
|
|
3515
3765
|
if (raw.autoRelay === true) enrollmentCell.autoRelay = true;
|
|
3516
3766
|
} catch {}
|
|
3517
3767
|
} catch (error) {
|
|
3518
|
-
|
|
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);
|
|
3519
3771
|
return {
|
|
3520
3772
|
token: null,
|
|
3521
3773
|
devices: null
|
|
@@ -3615,12 +3867,23 @@ function apply(ctx, options) {
|
|
|
3615
3867
|
};
|
|
3616
3868
|
};
|
|
3617
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
|
+
};
|
|
3618
3881
|
const resolvePushConfig = (config) => {
|
|
3619
3882
|
const push = config.push ?? {};
|
|
3620
3883
|
const configured = push.provider ?? "none";
|
|
3621
3884
|
const effectiveProvider = configured === "none" && enrollmentCell.autoRelay === true ? "relay" : configured;
|
|
3622
3885
|
if (effectiveProvider === "relay") {
|
|
3623
|
-
const url = (push.relayUrl ?? "").trim() ||
|
|
3886
|
+
const url = (push.relayUrl ?? "").trim() || "https://pilot.hailab.dev";
|
|
3624
3887
|
const token = (push.relayToken ?? "").trim() || enrollmentCell.token || "";
|
|
3625
3888
|
if (!/^https:\/\//i.test(url)) return {
|
|
3626
3889
|
ok: false,
|
|
@@ -3782,7 +4045,8 @@ function apply(ctx, options) {
|
|
|
3782
4045
|
* - each device is delivered on ITS registered environment (the build
|
|
3783
4046
|
* kind it self-reported), so sandbox and production devices coexist;
|
|
3784
4047
|
* - the device's per-category switches suppress muted categories;
|
|
3785
|
-
* - Unregistered/
|
|
4048
|
+
* - only APNs' terminal Unregistered/ExpiredToken verdicts prune storage;
|
|
4049
|
+
* BadDeviceToken may be an environment mismatch and stays diagnosable.
|
|
3786
4050
|
*/
|
|
3787
4051
|
const makePushOutlet = () => ({
|
|
3788
4052
|
isAvailable: () => {
|
|
@@ -3795,7 +4059,7 @@ function apply(ctx, options) {
|
|
|
3795
4059
|
(async () => {
|
|
3796
4060
|
let resolved = resolvePushConfig(currentConfig());
|
|
3797
4061
|
if (!resolved.ok && resolved.reason === "relay token not enrolled yet") {
|
|
3798
|
-
const relayUrl = (currentConfig().push?.relayUrl ?? "").trim() ||
|
|
4062
|
+
const relayUrl = (currentConfig().push?.relayUrl ?? "").trim() || "https://pilot.hailab.dev";
|
|
3799
4063
|
await ensureRelayEnrolled(relayUrl);
|
|
3800
4064
|
resolved = resolvePushConfig(currentConfig());
|
|
3801
4065
|
}
|
|
@@ -3825,6 +4089,10 @@ function apply(ctx, options) {
|
|
|
3825
4089
|
log(`push(${transport}) ${notification.category}: no offline targets (connected=${connectedIds.size}, tokenized=${tokenized})`);
|
|
3826
4090
|
return;
|
|
3827
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);
|
|
3828
4096
|
for (const device of candidates) {
|
|
3829
4097
|
const registration = device.apns;
|
|
3830
4098
|
send({
|
|
@@ -3833,9 +4101,20 @@ function apply(ctx, options) {
|
|
|
3833
4101
|
notification
|
|
3834
4102
|
}).then(({ outcome, reason }) => {
|
|
3835
4103
|
log(`push(${transport}) ${notification.category} → "${device.deviceName}" [${registration.environment}] = ${outcome}${reason ? " (" + reason + ")" : ""}`);
|
|
3836
|
-
if (outcome
|
|
4104
|
+
if (shouldPrunePushToken(outcome, reason)) {
|
|
3837
4105
|
devices.clearPushToken(device.deviceId);
|
|
3838
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);
|
|
3839
4118
|
}
|
|
3840
4119
|
}).catch(() => {});
|
|
3841
4120
|
}
|
|
@@ -3910,7 +4189,7 @@ function apply(ctx, options) {
|
|
|
3910
4189
|
message: `当前推送模式不是中继(provider=${configured})。启用方式二选一:① 零配置——在 ios/project.yml 填写 DSPushEnrollKey(与服务器 RELAY_ENROLL_KEY 一致)并重新安装 App,打开 App 即自动启用;② 手动——将 push.provider 设为 relay 并填入 relayToken`
|
|
3911
4190
|
}]
|
|
3912
4191
|
};
|
|
3913
|
-
const url = (push.relayUrl ?? "").trim() ||
|
|
4192
|
+
const url = (push.relayUrl ?? "").trim() || "https://pilot.hailab.dev";
|
|
3914
4193
|
if (!/^https:\/\//i.test(url)) return {
|
|
3915
4194
|
url,
|
|
3916
4195
|
overall: "failed",
|
|
@@ -3940,6 +4219,7 @@ function apply(ctx, options) {
|
|
|
3940
4219
|
return await runPushSelfTest();
|
|
3941
4220
|
});
|
|
3942
4221
|
const state = {};
|
|
4222
|
+
let pendingUpgrades = 0;
|
|
3943
4223
|
const handleUpgrade = (req, socket, head) => {
|
|
3944
4224
|
(async () => {
|
|
3945
4225
|
try {
|
|
@@ -3947,44 +4227,53 @@ function apply(ctx, options) {
|
|
|
3947
4227
|
rejectUpgrade(socket, 503, "bridge disabled");
|
|
3948
4228
|
return;
|
|
3949
4229
|
}
|
|
3950
|
-
if (connections.size >= MAX_CLIENT_CONNECTIONS) {
|
|
4230
|
+
if (connections.size + pendingUpgrades >= MAX_CLIENT_CONNECTIONS) {
|
|
3951
4231
|
rejectUpgrade(socket, 429, "too many connections");
|
|
3952
4232
|
return;
|
|
3953
4233
|
}
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
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);
|
|
3985
4273
|
});
|
|
3986
|
-
|
|
3987
|
-
|
|
4274
|
+
} finally {
|
|
4275
|
+
pendingUpgrades -= 1;
|
|
4276
|
+
}
|
|
3988
4277
|
} catch (error) {
|
|
3989
4278
|
log("upgrade failed: " + String(error));
|
|
3990
4279
|
rejectUpgrade(socket, 500, "internal error");
|
|
@@ -4038,7 +4327,11 @@ function apply(ctx, options) {
|
|
|
4038
4327
|
state.bridge = bridge;
|
|
4039
4328
|
bridge.start();
|
|
4040
4329
|
log("data plane active (mux + host streams)");
|
|
4041
|
-
apiCtx.effect(() => () =>
|
|
4330
|
+
apiCtx.effect(() => () => {
|
|
4331
|
+
closeConnectionsForBridge(bridge);
|
|
4332
|
+
if (state.bridge === bridge) state.bridge = void 0;
|
|
4333
|
+
bridge.dispose();
|
|
4334
|
+
}, "deeppilot: host streams");
|
|
4042
4335
|
});
|
|
4043
4336
|
ctx.inject(["webServer"], (sub) => {
|
|
4044
4337
|
const webCtx = sub;
|
|
@@ -4060,7 +4353,7 @@ function apply(ctx, options) {
|
|
|
4060
4353
|
const now = Date.now();
|
|
4061
4354
|
for (const connection of connections) if (connection.isStale(now, 6e4)) {
|
|
4062
4355
|
log("dropping stale connection");
|
|
4063
|
-
connection.
|
|
4356
|
+
connection.closeIdle();
|
|
4064
4357
|
connections.delete(connection);
|
|
4065
4358
|
}
|
|
4066
4359
|
}, 3e4);
|
|
@@ -4146,8 +4439,23 @@ function apply(ctx, options) {
|
|
|
4146
4439
|
if (enabledNow()) log("/phone WebSocket registered");
|
|
4147
4440
|
else log("bridge disabled; /phone refuses connections until re-enabled and restarted");
|
|
4148
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");
|
|
4149
4457
|
}
|
|
4150
4458
|
//#endregion
|
|
4151
|
-
export { Config, HostBridge, apply, inject, name, requestToken };
|
|
4459
|
+
export { Config, HostBridge, apply, inject, name, requestToken, shouldPrunePushToken, shouldReEnrollRelayToken };
|
|
4152
4460
|
|
|
4153
4461
|
//# sourceMappingURL=index.js.map
|