dsh-deeppilot 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/darwin-amd64/dsh-deeppilot-tunnel +0 -0
- package/bin/linux-amd64/dsh-deeppilot-tunnel +0 -0
- package/bin/linux-arm64/dsh-deeppilot-tunnel +0 -0
- package/lib/client.js +1757 -1348
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +75 -47
- package/lib/index.js +1099 -786
- 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 {
|
|
@@ -1564,403 +2060,153 @@ var HostBridge = class {
|
|
|
1564
2060
|
const pending = this.questions.get(requestId);
|
|
1565
2061
|
if (!pending) return {
|
|
1566
2062
|
ok: false,
|
|
1567
|
-
reason: "not-pending"
|
|
1568
|
-
};
|
|
1569
|
-
this.questions.delete(requestId);
|
|
1570
|
-
try {
|
|
1571
|
-
const receipt = await this.apiProxy.respond({
|
|
1572
|
-
type: "client-response",
|
|
1573
|
-
rpcId: pending.rpcId,
|
|
1574
|
-
result: {
|
|
1575
|
-
ok: true,
|
|
1576
|
-
value: {
|
|
1577
|
-
sessionId: pending.sessionId,
|
|
1578
|
-
answer: { answers: normalizeAnswerItems(answers, pending.questions) }
|
|
1579
|
-
}
|
|
1580
|
-
}
|
|
1581
|
-
});
|
|
1582
|
-
if (!Boolean(receipt?.accepted)) {
|
|
1583
|
-
const failure = receiptFailureReason(receipt);
|
|
1584
|
-
if (failure !== "not-pending" && !this.questions.has(requestId)) this.questions.set(requestId, pending);
|
|
1585
|
-
return {
|
|
1586
|
-
ok: false,
|
|
1587
|
-
reason: failure
|
|
1588
|
-
};
|
|
1589
|
-
}
|
|
1590
|
-
this.bumpPendingFlags(pending.sessionId);
|
|
1591
|
-
return { ok: true };
|
|
1592
|
-
} catch {
|
|
1593
|
-
if (!this.questions.has(requestId)) this.questions.set(requestId, pending);
|
|
1594
|
-
return {
|
|
1595
|
-
ok: false,
|
|
1596
|
-
reason: "transport"
|
|
1597
|
-
};
|
|
1598
|
-
}
|
|
1599
|
-
}
|
|
1600
|
-
};
|
|
1601
|
-
/**
|
|
1602
|
-
* The host validates question answers strictly (core dsh-user-questions via
|
|
1603
|
-
* apiProxy): a present-but-empty `custom` fails `matchesQuestions`, and a
|
|
1604
|
-
* single-select question rejects `custom` combined with a selection. Clients
|
|
1605
|
-
* may send lenient shapes (the phone historically always attached
|
|
1606
|
-
* `"custom": ""`, which made EVERY option-only answer fail), so normalize to
|
|
1607
|
-
* exactly what the host accepts before forwarding.
|
|
1608
|
-
*/
|
|
1609
|
-
function normalizeAnswerItems(raw, questions) {
|
|
1610
|
-
if (!Array.isArray(raw)) return [];
|
|
1611
|
-
const askedById = /* @__PURE__ */ new Map();
|
|
1612
|
-
if (Array.isArray(questions)) {
|
|
1613
|
-
for (const q of questions) if (typeof q === "object" && q !== null && typeof q.id === "string") askedById.set(q.id, q);
|
|
1614
|
-
}
|
|
1615
|
-
const items = [];
|
|
1616
|
-
for (const entry of raw) {
|
|
1617
|
-
if (typeof entry !== "object" || entry === null) continue;
|
|
1618
|
-
const r = entry;
|
|
1619
|
-
if (typeof r.id !== "string") continue;
|
|
1620
|
-
const selected = [...new Set(Array.isArray(r.selected) ? r.selected.filter((s) => typeof s === "string") : [])];
|
|
1621
|
-
const customText = typeof r.custom === "string" ? r.custom : "";
|
|
1622
|
-
let custom;
|
|
1623
|
-
if (customText.trim().length > 0) custom = customText;
|
|
1624
|
-
if (custom !== void 0 && selected.length > 0 && askedById.get(r.id)?.multiSelect !== true) custom = void 0;
|
|
1625
|
-
items.push({
|
|
1626
|
-
id: r.id,
|
|
1627
|
-
selected,
|
|
1628
|
-
...custom !== void 0 ? { custom } : {}
|
|
1629
|
-
});
|
|
1630
|
-
}
|
|
1631
|
-
return items;
|
|
1632
|
-
}
|
|
1633
|
-
/** Map an apiProxy respond receipt onto the failure vocabulary. */
|
|
1634
|
-
function receiptFailureReason(receipt) {
|
|
1635
|
-
return receipt?.reason === "not-pending" ? "not-pending" : "bad-response";
|
|
1636
|
-
}
|
|
1637
|
-
function clampTail(n) {
|
|
1638
|
-
if (!Number.isFinite(n)) return 100;
|
|
1639
|
-
return Math.max(10, Math.min(500, Math.floor(n)));
|
|
1640
|
-
}
|
|
1641
|
-
function localTimeZone() {
|
|
1642
|
-
try {
|
|
1643
|
-
return new Intl.DateTimeFormat().resolvedOptions().timeZone || void 0;
|
|
1644
|
-
} catch {
|
|
1645
|
-
return;
|
|
1646
|
-
}
|
|
1647
|
-
}
|
|
1648
|
-
function riskOf(toolName) {
|
|
1649
|
-
if (/bash|pwsh|terminal/.test(toolName)) return "write";
|
|
1650
|
-
if (/edit|write|str_replace|create/.test(toolName)) return "write";
|
|
1651
|
-
if (/delete|remove|kill/.test(toolName)) return "destructive";
|
|
1652
|
-
return "read";
|
|
1653
|
-
}
|
|
1654
|
-
/** First question's text for the push banner; the questions payload shape is
|
|
1655
|
-
* host-version dependent, so extract defensively. */
|
|
1656
|
-
function firstQuestionText(questions) {
|
|
1657
|
-
if (!Array.isArray(questions) || questions.length === 0) return "Agent 等待你的输入";
|
|
1658
|
-
const first = questions[0];
|
|
1659
|
-
return String(first?.question ?? "").trim() || "Agent 等待你的输入";
|
|
1660
|
-
}
|
|
1661
|
-
const TODO_STATUSES = /* @__PURE__ */ new Set([
|
|
1662
|
-
"pending",
|
|
1663
|
-
"in_progress",
|
|
1664
|
-
"completed"
|
|
1665
|
-
]);
|
|
1666
|
-
/** Validate a host todo projection once; progress counts and the full
|
|
1667
|
-
* checklist both derive from this sanitized list so they never disagree. */
|
|
1668
|
-
function sanitizeTodoItems(items) {
|
|
1669
|
-
if (!items) return [];
|
|
1670
|
-
return items.map((i) => ({
|
|
1671
|
-
content: String(i.content ?? "").trim(),
|
|
1672
|
-
status: String(i.status ?? "")
|
|
1673
|
-
})).filter((i) => i.content.length > 0 && TODO_STATUSES.has(i.status)).slice(0, 100).map((i) => ({
|
|
1674
|
-
content: i.content,
|
|
1675
|
-
status: i.status
|
|
1676
|
-
}));
|
|
1677
|
-
}
|
|
1678
|
-
function toSummary(row, approvals, questions, workspace) {
|
|
1679
|
-
const values = row.projections?.values ?? {};
|
|
1680
|
-
const todos = Array.isArray(values.todos) ? values.todos : null;
|
|
1681
|
-
let pendingApproval = false;
|
|
1682
|
-
for (const pending of approvals.values()) if (pending.sessionId === row.sessionId) pendingApproval = true;
|
|
1683
|
-
let pendingQuestion = false;
|
|
1684
|
-
for (const pending of questions.values()) if (pending.sessionId === row.sessionId) pendingQuestion = true;
|
|
1685
|
-
const cwd = typeof row.cwd === "string" ? row.cwd : "";
|
|
1686
|
-
const label = workspace?.title ?? (cwd ? cwd.split("/").filter(Boolean).pop() : void 0);
|
|
1687
|
-
const todoItems = sanitizeTodoItems(todos);
|
|
1688
|
-
return {
|
|
1689
|
-
id: row.sessionId,
|
|
1690
|
-
title: typeof values.title === "string" ? values.title : "",
|
|
1691
|
-
status: row.running ? "running" : row.blank ? "unknown" : "idle",
|
|
1692
|
-
lastActivityTs: Number(row.updatedAt ?? Date.now()),
|
|
1693
|
-
todos: todoItems.length > 0 ? {
|
|
1694
|
-
done: todoItems.filter((i) => i.status === "completed").length,
|
|
1695
|
-
total: todoItems.length
|
|
1696
|
-
} : null,
|
|
1697
|
-
todoItems: todoItems.length > 0 ? todoItems : null,
|
|
1698
|
-
pendingApproval,
|
|
1699
|
-
pendingQuestion,
|
|
1700
|
-
workspaceLabel: label ?? null,
|
|
1701
|
-
workspaceId: workspace?.workspaceId ?? null,
|
|
1702
|
-
workspacePath: workspace?.path ?? (cwd || null)
|
|
1703
|
-
};
|
|
1704
|
-
}
|
|
1705
|
-
function projectWorkspace(workspace) {
|
|
1706
|
-
return {
|
|
1707
|
-
id: String(workspace.workspaceId),
|
|
1708
|
-
title: String(workspace.title),
|
|
1709
|
-
path: String(workspace.path),
|
|
1710
|
-
sessionIds: (workspace.sessionIds ?? []).map(String)
|
|
1711
|
-
};
|
|
1712
|
-
}
|
|
1713
|
-
/** Project one raw session event into a protocol push, when it maps to one. */
|
|
1714
|
-
function projectEvent(sessionId, event) {
|
|
1715
|
-
switch (event.type) {
|
|
1716
|
-
case "turn/start": return {
|
|
1717
|
-
kind: "turn.start",
|
|
1718
|
-
data: {}
|
|
1719
|
-
};
|
|
1720
|
-
case "turn/end": return {
|
|
1721
|
-
kind: "turn.end",
|
|
1722
|
-
data: { ok: event.data?.reason?.kind === "completed" }
|
|
1723
|
-
};
|
|
1724
|
-
case "user/message": return {
|
|
1725
|
-
kind: "message.final",
|
|
1726
|
-
data: {
|
|
1727
|
-
seq: event.seq,
|
|
1728
|
-
role: userRoleOf(event.data),
|
|
1729
|
-
text: messageText(event.data),
|
|
1730
|
-
...attachmentProjection(event.data),
|
|
1731
|
-
...contextProjectionOf(event.data),
|
|
1732
|
-
ts: tsOf(event)
|
|
1733
|
-
}
|
|
1734
|
-
};
|
|
1735
|
-
case "assistant/chunk":
|
|
1736
|
-
if (chunkTypeOf(event.data) === "reasoning-delta") return {
|
|
1737
|
-
kind: "thinking.delta",
|
|
1738
|
-
data: {
|
|
1739
|
-
text: chunkText(event.data),
|
|
1740
|
-
ts: tsOf(event)
|
|
1741
|
-
}
|
|
1742
|
-
};
|
|
1743
|
-
return {
|
|
1744
|
-
kind: "message.delta",
|
|
1745
|
-
data: {
|
|
1746
|
-
text: chunkText(event.data),
|
|
1747
|
-
ts: tsOf(event)
|
|
1748
|
-
}
|
|
1749
|
-
};
|
|
1750
|
-
case "assistant/message": {
|
|
1751
|
-
const text = messageText(event.data);
|
|
1752
|
-
const thinking = messageThinking(event.data);
|
|
1753
|
-
if (!text.trim() && !thinking.trim()) return null;
|
|
1754
|
-
return {
|
|
1755
|
-
kind: "message.final",
|
|
1756
|
-
data: {
|
|
1757
|
-
seq: event.seq,
|
|
1758
|
-
role: "assistant",
|
|
1759
|
-
text,
|
|
1760
|
-
...thinking ? { thinking } : {},
|
|
1761
|
-
ts: tsOf(event)
|
|
1762
|
-
}
|
|
1763
|
-
};
|
|
1764
|
-
}
|
|
1765
|
-
case "tool/call": {
|
|
1766
|
-
const data = event.data;
|
|
1767
|
-
return {
|
|
1768
|
-
kind: "tool.start",
|
|
1769
|
-
data: {
|
|
1770
|
-
seq: event.seq,
|
|
1771
|
-
role: "tool",
|
|
1772
|
-
tool: {
|
|
1773
|
-
name: String(data?.name ?? "tool"),
|
|
1774
|
-
state: "running",
|
|
1775
|
-
summary: summarizeArgs(data?.arguments),
|
|
1776
|
-
...data?.callId ? { callId: String(data.callId) } : {}
|
|
1777
|
-
},
|
|
1778
|
-
ts: tsOf(event)
|
|
2063
|
+
reason: "not-pending"
|
|
2064
|
+
};
|
|
2065
|
+
this.questions.delete(requestId);
|
|
2066
|
+
try {
|
|
2067
|
+
const receipt = await this.apiProxy.respond({
|
|
2068
|
+
type: "client-response",
|
|
2069
|
+
rpcId: pending.rpcId,
|
|
2070
|
+
result: {
|
|
2071
|
+
ok: true,
|
|
2072
|
+
value: {
|
|
2073
|
+
sessionId: pending.sessionId,
|
|
2074
|
+
answer: { answers: normalizeAnswerItems(answers, pending.questions) }
|
|
2075
|
+
}
|
|
1779
2076
|
}
|
|
1780
|
-
};
|
|
1781
|
-
|
|
1782
|
-
|
|
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,15 +3077,29 @@ function parseHelperEvent(line) {
|
|
|
2882
3077
|
return null;
|
|
2883
3078
|
}
|
|
2884
3079
|
}
|
|
3080
|
+
function isTailscaleAuthURL(value) {
|
|
3081
|
+
if (!value) return false;
|
|
3082
|
+
try {
|
|
3083
|
+
const url = new URL(value);
|
|
3084
|
+
return url.protocol === "https:" && (url.hostname === "login.tailscale.com" || url.hostname.endsWith(".login.tailscale.com"));
|
|
3085
|
+
} catch {
|
|
3086
|
+
return false;
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
/** Translate Node's platform/architecture names to the GOOS/GOARCH directory
|
|
3090
|
+
* names used by the committed helper matrix. */
|
|
3091
|
+
function bundledHelperPlatformDir(platform = process.platform, arch = process.arch) {
|
|
3092
|
+
return `${platform === "win32" ? "windows" : platform}-${arch === "x64" ? "amd64" : arch}`;
|
|
3093
|
+
}
|
|
2885
3094
|
/** Build the list of candidate locations for the embedded tunnel helper, in
|
|
2886
3095
|
* priority order. The first existing executable wins at start() time. The
|
|
2887
3096
|
* order matters: explicit config (handled by the caller) > npm install
|
|
2888
3097
|
* layout > DSH-bundled layout > user data dir. */
|
|
2889
|
-
function bundledHelperCandidates() {
|
|
3098
|
+
function bundledHelperCandidates(platform = process.platform, arch = process.arch) {
|
|
2890
3099
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
2891
3100
|
const pkgRoot = resolve(here, "..");
|
|
2892
|
-
const fileName =
|
|
2893
|
-
const platformDir =
|
|
3101
|
+
const fileName = platform === "win32" ? "dsh-deeppilot-tunnel.exe" : "dsh-deeppilot-tunnel";
|
|
3102
|
+
const platformDir = bundledHelperPlatformDir(platform, arch);
|
|
2894
3103
|
const candidates = [];
|
|
2895
3104
|
candidates.push(resolve(pkgRoot, "bin", platformDir, fileName));
|
|
2896
3105
|
candidates.push(resolve(pkgRoot, "..", "..", "..", "node_modules", "dsh-deeppilot", "bin", platformDir, fileName));
|
|
@@ -2948,6 +3157,7 @@ var RemoteSupervisor = class {
|
|
|
2948
3157
|
phase: "unavailable",
|
|
2949
3158
|
message
|
|
2950
3159
|
});
|
|
3160
|
+
this.scheduleRestart(originURL, "unavailable");
|
|
2951
3161
|
return;
|
|
2952
3162
|
}
|
|
2953
3163
|
try {
|
|
@@ -2961,6 +3171,7 @@ var RemoteSupervisor = class {
|
|
|
2961
3171
|
phase: "unavailable",
|
|
2962
3172
|
message: `cannot create remote state dir: ${String(error)}`
|
|
2963
3173
|
});
|
|
3174
|
+
this.scheduleRestart(originURL, "unavailable");
|
|
2964
3175
|
return;
|
|
2965
3176
|
}
|
|
2966
3177
|
if (this.stopping) return;
|
|
@@ -3030,7 +3241,7 @@ var RemoteSupervisor = class {
|
|
|
3030
3241
|
phase: "error",
|
|
3031
3242
|
message: detail || `helper exited (${signal ?? String(code)})`
|
|
3032
3243
|
});
|
|
3033
|
-
this.scheduleRestart(originURL);
|
|
3244
|
+
this.scheduleRestart(originURL, "crash");
|
|
3034
3245
|
});
|
|
3035
3246
|
}
|
|
3036
3247
|
async dispose() {
|
|
@@ -3064,20 +3275,24 @@ var RemoteSupervisor = class {
|
|
|
3064
3275
|
acceptLine(line) {
|
|
3065
3276
|
const event = parseHelperEvent(line);
|
|
3066
3277
|
if (event === null || event.phase === void 0) return;
|
|
3278
|
+
if (event.phase === "login_required") {
|
|
3279
|
+
if (this.statusValue.phase === "online" || !isTailscaleAuthURL(event.authURL)) return;
|
|
3280
|
+
}
|
|
3067
3281
|
if (event.phase === "online") this.restartAttempt = 0;
|
|
3068
3282
|
this.setStatus({
|
|
3069
3283
|
...event,
|
|
3070
3284
|
phase: event.phase
|
|
3071
3285
|
});
|
|
3072
3286
|
}
|
|
3073
|
-
scheduleRestart(originURL) {
|
|
3287
|
+
scheduleRestart(originURL, kind = "crash") {
|
|
3074
3288
|
if (this.stopping || this.restartTimer !== void 0) return;
|
|
3075
|
-
const delay = RESTART_DELAYS_MS[Math.min(this.restartAttempt, RESTART_DELAYS_MS.length - 1)];
|
|
3076
|
-
this.restartAttempt += 1;
|
|
3289
|
+
const delay = kind === "unavailable" ? UNAVAILABLE_RETRY_MS : RESTART_DELAYS_MS[Math.min(this.restartAttempt, RESTART_DELAYS_MS.length - 1)];
|
|
3290
|
+
if (kind === "crash") this.restartAttempt += 1;
|
|
3077
3291
|
this.restartTimer = setTimeout(() => {
|
|
3078
3292
|
this.restartTimer = void 0;
|
|
3079
3293
|
this.start(originURL);
|
|
3080
3294
|
}, delay);
|
|
3295
|
+
this.restartTimer.unref?.();
|
|
3081
3296
|
}
|
|
3082
3297
|
setStatus(next) {
|
|
3083
3298
|
const cleared = next.phase === "online" ? {
|
|
@@ -3319,23 +3534,7 @@ var UpdateChecker = class {
|
|
|
3319
3534
|
dispose() {}
|
|
3320
3535
|
};
|
|
3321
3536
|
//#endregion
|
|
3322
|
-
//#region src/
|
|
3323
|
-
/**
|
|
3324
|
-
* dsh-deeppilot — data bridge between the DSH host and DeepPilot
|
|
3325
|
-
* clients. Registers exactly one WebSocket upgrade route (/phone) plus an
|
|
3326
|
-
* optional health probe (/phone/health) on the existing web server. The web
|
|
3327
|
-
* UI is never touched.
|
|
3328
|
-
*
|
|
3329
|
-
* Data plane: an in-process HostBridge consumes apiProxy.events.mux()/host()
|
|
3330
|
-
* streams, mirrors session summaries, tracks pending approvals/questions,
|
|
3331
|
-
* and fans projected protocol-v1 pushes out to every connected device.
|
|
3332
|
-
*
|
|
3333
|
-
* Protocol: src/protocol.ts, v1. The private app repository carries the
|
|
3334
|
-
* matching normative document and Swift models.
|
|
3335
|
-
*/
|
|
3336
|
-
const name = "deeppilot";
|
|
3337
|
-
/** No eager service requirement: profiles without a web stack simply skip. */
|
|
3338
|
-
const inject = [];
|
|
3537
|
+
//#region src/config.ts
|
|
3339
3538
|
/** Operator-run relay used by distributed builds; overridable via config. */
|
|
3340
3539
|
const DEFAULT_RELAY_URL = "https://pilot.hailab.dev";
|
|
3341
3540
|
const Config = z.object({
|
|
@@ -3346,11 +3545,15 @@ const Config = z.object({
|
|
|
3346
3545
|
debug: z.boolean().default(false),
|
|
3347
3546
|
remote: z.object({
|
|
3348
3547
|
enabled: z.boolean().default(false),
|
|
3349
|
-
provider: z.
|
|
3548
|
+
provider: z.union(["tailscale-funnel"]).default("tailscale-funnel"),
|
|
3350
3549
|
hostname: z.string().default(DEFAULT_REMOTE_HOSTNAME),
|
|
3351
3550
|
statePath: z.string().default(join(bridgeDataDir(), "tailscale")),
|
|
3352
3551
|
helperPath: z.string().default(""),
|
|
3353
|
-
funnelPort: z.
|
|
3552
|
+
funnelPort: z.union([
|
|
3553
|
+
443,
|
|
3554
|
+
8443,
|
|
3555
|
+
1e4
|
|
3556
|
+
]).default(443)
|
|
3354
3557
|
}).default({
|
|
3355
3558
|
enabled: false,
|
|
3356
3559
|
provider: "tailscale-funnel",
|
|
@@ -3360,7 +3563,11 @@ const Config = z.object({
|
|
|
3360
3563
|
funnelPort: 443
|
|
3361
3564
|
}),
|
|
3362
3565
|
push: z.object({
|
|
3363
|
-
provider: z.
|
|
3566
|
+
provider: z.union([
|
|
3567
|
+
"none",
|
|
3568
|
+
"apns",
|
|
3569
|
+
"relay"
|
|
3570
|
+
]).default("none"),
|
|
3364
3571
|
teamId: z.string().default(""),
|
|
3365
3572
|
keyId: z.string().default(""),
|
|
3366
3573
|
keyPath: z.string().default(join(bridgeDataDir(), "apns", "AuthKey.p8")),
|
|
@@ -3377,6 +3584,75 @@ const Config = z.object({
|
|
|
3377
3584
|
relayToken: ""
|
|
3378
3585
|
})
|
|
3379
3586
|
});
|
|
3587
|
+
/**
|
|
3588
|
+
* Cordis hands the second argument in different shapes depending on host
|
|
3589
|
+
* composition: a reactive options getter, the resolved config value, or
|
|
3590
|
+
* nothing when the patch row omits `config`. Normalize all of them.
|
|
3591
|
+
*/
|
|
3592
|
+
function normalizeOptions(options) {
|
|
3593
|
+
if (typeof options === "function") return options();
|
|
3594
|
+
if (options && typeof options === "object") return options;
|
|
3595
|
+
return Config(void 0) ?? {};
|
|
3596
|
+
}
|
|
3597
|
+
//#endregion
|
|
3598
|
+
//#region src/phone-http.ts
|
|
3599
|
+
function rejectUpgrade(socket, status, reason) {
|
|
3600
|
+
const body = JSON.stringify({ error: reason });
|
|
3601
|
+
const statusText = {
|
|
3602
|
+
401: "Unauthorized",
|
|
3603
|
+
429: "Too Many Requests",
|
|
3604
|
+
500: "Internal Server Error",
|
|
3605
|
+
503: "Service Unavailable"
|
|
3606
|
+
};
|
|
3607
|
+
const authenticate = status === 401 ? "WWW-Authenticate: Bearer realm=\"deeppilot\"\r\n" : "";
|
|
3608
|
+
socket.end("HTTP/1.1 " + status + " " + (statusText[status] ?? "Error") + "\r\n" + authenticate + "Content-Type: application/json\r\nContent-Length: " + Buffer.byteLength(body) + "\r\nConnection: close\r\n\r\n" + body);
|
|
3609
|
+
}
|
|
3610
|
+
/** Authorization is preferred; the query form remains for older app builds. */
|
|
3611
|
+
function requestToken(req) {
|
|
3612
|
+
const authorization = req.headers.authorization;
|
|
3613
|
+
if (typeof authorization === "string") {
|
|
3614
|
+
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
|
|
3615
|
+
if (match?.[1]) return match[1];
|
|
3616
|
+
}
|
|
3617
|
+
try {
|
|
3618
|
+
return new URL(req.url ?? "/", "http://phone.local").searchParams.get("token");
|
|
3619
|
+
} catch {
|
|
3620
|
+
return null;
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
//#endregion
|
|
3624
|
+
//#region src/push-policy.ts
|
|
3625
|
+
/** Prune only when the provider supplies an authoritative token-lifecycle verdict. */
|
|
3626
|
+
function shouldPrunePushToken(outcome, reason) {
|
|
3627
|
+
return outcome === "invalid-token" && (reason === "Unregistered" || reason === "ExpiredToken");
|
|
3628
|
+
}
|
|
3629
|
+
/**
|
|
3630
|
+
* Zero-touch relay self-heal: HTTP 401 means the relay no longer honors the
|
|
3631
|
+
* cached credential. Only auto-enrolled cells with a still-current token may
|
|
3632
|
+
* re-derive it; an explicitly configured relay token remains user-owned
|
|
3633
|
+
* configuration and is never silently rewritten.
|
|
3634
|
+
*/
|
|
3635
|
+
function shouldReEnrollRelayToken(transport, outcome, reason, opts) {
|
|
3636
|
+
return transport === "relay" && outcome === "failed" && reason === "HTTP 401" && opts.hasEnrollKey && opts.usedCellToken && opts.tokenStillCurrent;
|
|
3637
|
+
}
|
|
3638
|
+
//#endregion
|
|
3639
|
+
//#region src/index.ts
|
|
3640
|
+
/**
|
|
3641
|
+
* dsh-deeppilot — data bridge between the DSH host and DeepPilot
|
|
3642
|
+
* clients. Registers exactly one WebSocket upgrade route (/phone) plus an
|
|
3643
|
+
* optional health probe (/phone/health) on the existing web server. The web
|
|
3644
|
+
* UI is never touched.
|
|
3645
|
+
*
|
|
3646
|
+
* Data plane: an in-process HostBridge consumes apiProxy.events.mux()/host()
|
|
3647
|
+
* streams, mirrors session summaries, tracks pending approvals/questions,
|
|
3648
|
+
* and fans projected protocol-v1 pushes out to every connected device.
|
|
3649
|
+
*
|
|
3650
|
+
* Protocol: PROTOCOL.md is normative; src/protocol.ts and the private app's
|
|
3651
|
+
* Swift models mirror that v1 contract.
|
|
3652
|
+
*/
|
|
3653
|
+
const name = "deeppilot";
|
|
3654
|
+
/** No eager service requirement: profiles without a web stack simply skip. */
|
|
3655
|
+
const inject = [];
|
|
3380
3656
|
const SERVER_VERSION = readOwnPackageVersion();
|
|
3381
3657
|
const MAX_CLIENT_CONNECTIONS = 16;
|
|
3382
3658
|
/**
|
|
@@ -3401,33 +3677,6 @@ function readOwnPackageVersion() {
|
|
|
3401
3677
|
if (typeof envVersion === "string" && envVersion.length > 0) return envVersion;
|
|
3402
3678
|
return "0.0.0+unknown";
|
|
3403
3679
|
}
|
|
3404
|
-
function rejectUpgrade(socket, status, reason) {
|
|
3405
|
-
const body = JSON.stringify({ error: reason });
|
|
3406
|
-
socket.end("HTTP/1.1 " + status + " Forbidden\r\nContent-Type: application/json\r\nContent-Length: " + Buffer.byteLength(body) + "\r\nConnection: close\r\n\r\n" + body);
|
|
3407
|
-
}
|
|
3408
|
-
/** Authorization is preferred; the query form remains for older app builds. */
|
|
3409
|
-
function requestToken(req) {
|
|
3410
|
-
const authorization = req.headers.authorization;
|
|
3411
|
-
if (typeof authorization === "string") {
|
|
3412
|
-
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
|
|
3413
|
-
if (match?.[1]) return match[1];
|
|
3414
|
-
}
|
|
3415
|
-
try {
|
|
3416
|
-
return new URL(req.url ?? "/", "http://phone.local").searchParams.get("token");
|
|
3417
|
-
} catch {
|
|
3418
|
-
return null;
|
|
3419
|
-
}
|
|
3420
|
-
}
|
|
3421
|
-
/**
|
|
3422
|
-
* Cordis hands the second argument in different shapes depending on host
|
|
3423
|
-
* composition: a reactive options getter, the resolved config value, or
|
|
3424
|
-
* nothing when the patch row omits `config`. Normalize all of them.
|
|
3425
|
-
*/
|
|
3426
|
-
function normalizeOptions(options) {
|
|
3427
|
-
if (typeof options === "function") return options();
|
|
3428
|
-
if (options && typeof options === "object") return options;
|
|
3429
|
-
return Config(void 0) ?? {};
|
|
3430
|
-
}
|
|
3431
3680
|
function apply(ctx, options) {
|
|
3432
3681
|
const cfg = normalizeOptions(options);
|
|
3433
3682
|
const log = (message) => {
|
|
@@ -3456,16 +3705,22 @@ function apply(ctx, options) {
|
|
|
3456
3705
|
const dataDir = bridgeDataDir();
|
|
3457
3706
|
const pushRelayPath = join(dataDir, "push-relay.json");
|
|
3458
3707
|
const enrollmentCell = {};
|
|
3708
|
+
let enrollmentWriteTail = Promise.resolve();
|
|
3459
3709
|
function persistEnrollment() {
|
|
3460
|
-
|
|
3710
|
+
const snapshot = JSON.stringify({
|
|
3711
|
+
version: 1,
|
|
3712
|
+
...enrollmentCell
|
|
3713
|
+
}, null, 2) + "\n";
|
|
3714
|
+
enrollmentWriteTail = enrollmentWriteTail.then(async () => {
|
|
3715
|
+
const tempPath = pushRelayPath + "." + randomBytes(6).toString("hex") + ".tmp";
|
|
3461
3716
|
try {
|
|
3462
3717
|
await mkdir(dataDir, { recursive: true });
|
|
3463
|
-
await writeFile(
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
}
|
|
3468
|
-
})
|
|
3718
|
+
await writeFile(tempPath, snapshot, { mode: 384 });
|
|
3719
|
+
await rename(tempPath, pushRelayPath);
|
|
3720
|
+
} catch {
|
|
3721
|
+
await unlink(tempPath).catch(() => {});
|
|
3722
|
+
}
|
|
3723
|
+
});
|
|
3469
3724
|
}
|
|
3470
3725
|
/** Fired from BridgeConnection when an app presents its built-in key. */
|
|
3471
3726
|
const handlePushEnrollKey = async (enrollKey) => {
|
|
@@ -3478,7 +3733,7 @@ function apply(ctx, options) {
|
|
|
3478
3733
|
}
|
|
3479
3734
|
}
|
|
3480
3735
|
persistEnrollment();
|
|
3481
|
-
const url = (currentConfig().push?.relayUrl ?? "").trim() ||
|
|
3736
|
+
const url = (currentConfig().push?.relayUrl ?? "").trim() || "https://pilot.hailab.dev";
|
|
3482
3737
|
await ensureRelayEnrolled(url);
|
|
3483
3738
|
};
|
|
3484
3739
|
const auth = {
|
|
@@ -3510,7 +3765,9 @@ function apply(ctx, options) {
|
|
|
3510
3765
|
if (raw.autoRelay === true) enrollmentCell.autoRelay = true;
|
|
3511
3766
|
} catch {}
|
|
3512
3767
|
} catch (error) {
|
|
3513
|
-
|
|
3768
|
+
const message = String(error);
|
|
3769
|
+
if (message.includes("pairing token is malformed at")) console.warn("[deeppilot] " + message);
|
|
3770
|
+
log("auth material unavailable, bridge degraded: " + message);
|
|
3514
3771
|
return {
|
|
3515
3772
|
token: null,
|
|
3516
3773
|
devices: null
|
|
@@ -3610,12 +3867,23 @@ function apply(ctx, options) {
|
|
|
3610
3867
|
};
|
|
3611
3868
|
};
|
|
3612
3869
|
const connections = /* @__PURE__ */ new Set();
|
|
3870
|
+
const closeConnectionsForBridge = (bridge) => {
|
|
3871
|
+
for (const connection of connections) {
|
|
3872
|
+
if (!connection.isAttachedTo(bridge)) continue;
|
|
3873
|
+
connection.closeForServerStop();
|
|
3874
|
+
connections.delete(connection);
|
|
3875
|
+
}
|
|
3876
|
+
};
|
|
3877
|
+
const closeAllConnections = () => {
|
|
3878
|
+
for (const connection of connections) connection.closeForServerStop();
|
|
3879
|
+
connections.clear();
|
|
3880
|
+
};
|
|
3613
3881
|
const resolvePushConfig = (config) => {
|
|
3614
3882
|
const push = config.push ?? {};
|
|
3615
3883
|
const configured = push.provider ?? "none";
|
|
3616
3884
|
const effectiveProvider = configured === "none" && enrollmentCell.autoRelay === true ? "relay" : configured;
|
|
3617
3885
|
if (effectiveProvider === "relay") {
|
|
3618
|
-
const url = (push.relayUrl ?? "").trim() ||
|
|
3886
|
+
const url = (push.relayUrl ?? "").trim() || "https://pilot.hailab.dev";
|
|
3619
3887
|
const token = (push.relayToken ?? "").trim() || enrollmentCell.token || "";
|
|
3620
3888
|
if (!/^https:\/\//i.test(url)) return {
|
|
3621
3889
|
ok: false,
|
|
@@ -3777,7 +4045,8 @@ function apply(ctx, options) {
|
|
|
3777
4045
|
* - each device is delivered on ITS registered environment (the build
|
|
3778
4046
|
* kind it self-reported), so sandbox and production devices coexist;
|
|
3779
4047
|
* - the device's per-category switches suppress muted categories;
|
|
3780
|
-
* - Unregistered/
|
|
4048
|
+
* - only APNs' terminal Unregistered/ExpiredToken verdicts prune storage;
|
|
4049
|
+
* BadDeviceToken may be an environment mismatch and stays diagnosable.
|
|
3781
4050
|
*/
|
|
3782
4051
|
const makePushOutlet = () => ({
|
|
3783
4052
|
isAvailable: () => {
|
|
@@ -3790,7 +4059,7 @@ function apply(ctx, options) {
|
|
|
3790
4059
|
(async () => {
|
|
3791
4060
|
let resolved = resolvePushConfig(currentConfig());
|
|
3792
4061
|
if (!resolved.ok && resolved.reason === "relay token not enrolled yet") {
|
|
3793
|
-
const relayUrl = (currentConfig().push?.relayUrl ?? "").trim() ||
|
|
4062
|
+
const relayUrl = (currentConfig().push?.relayUrl ?? "").trim() || "https://pilot.hailab.dev";
|
|
3794
4063
|
await ensureRelayEnrolled(relayUrl);
|
|
3795
4064
|
resolved = resolvePushConfig(currentConfig());
|
|
3796
4065
|
}
|
|
@@ -3820,6 +4089,10 @@ function apply(ctx, options) {
|
|
|
3820
4089
|
log(`push(${transport}) ${notification.category}: no offline targets (connected=${connectedIds.size}, tokenized=${tokenized})`);
|
|
3821
4090
|
return;
|
|
3822
4091
|
}
|
|
4092
|
+
const relayUrl = resolved.value.kind === "relay" ? resolved.value.url : void 0;
|
|
4093
|
+
const relayTokenUsed = resolved.value.kind === "relay" ? resolved.value.token : void 0;
|
|
4094
|
+
const usedCellToken = relayTokenUsed !== void 0 && relayTokenUsed === enrollmentCell.token;
|
|
4095
|
+
const hasEnrollKey = Boolean(enrollmentCell.enrollKey);
|
|
3823
4096
|
for (const device of candidates) {
|
|
3824
4097
|
const registration = device.apns;
|
|
3825
4098
|
send({
|
|
@@ -3828,9 +4101,20 @@ function apply(ctx, options) {
|
|
|
3828
4101
|
notification
|
|
3829
4102
|
}).then(({ outcome, reason }) => {
|
|
3830
4103
|
log(`push(${transport}) ${notification.category} → "${device.deviceName}" [${registration.environment}] = ${outcome}${reason ? " (" + reason + ")" : ""}`);
|
|
3831
|
-
if (outcome
|
|
4104
|
+
if (shouldPrunePushToken(outcome, reason)) {
|
|
3832
4105
|
devices.clearPushToken(device.deviceId);
|
|
3833
4106
|
log(`push: pruned stale token of "${device.deviceName}" (${reason ?? "unknown"}) — app re-registers on next launch`);
|
|
4107
|
+
return;
|
|
4108
|
+
}
|
|
4109
|
+
if (relayUrl !== void 0 && shouldReEnrollRelayToken(transport, outcome, reason, {
|
|
4110
|
+
usedCellToken,
|
|
4111
|
+
hasEnrollKey,
|
|
4112
|
+
tokenStillCurrent: enrollmentCell.token === relayTokenUsed
|
|
4113
|
+
})) {
|
|
4114
|
+
enrollmentCell.token = void 0;
|
|
4115
|
+
persistEnrollment();
|
|
4116
|
+
log("push relay credential rejected (HTTP 401); re-enrolling");
|
|
4117
|
+
ensureRelayEnrolled(relayUrl);
|
|
3834
4118
|
}
|
|
3835
4119
|
}).catch(() => {});
|
|
3836
4120
|
}
|
|
@@ -3905,7 +4189,7 @@ function apply(ctx, options) {
|
|
|
3905
4189
|
message: `当前推送模式不是中继(provider=${configured})。启用方式二选一:① 零配置——在 ios/project.yml 填写 DSPushEnrollKey(与服务器 RELAY_ENROLL_KEY 一致)并重新安装 App,打开 App 即自动启用;② 手动——将 push.provider 设为 relay 并填入 relayToken`
|
|
3906
4190
|
}]
|
|
3907
4191
|
};
|
|
3908
|
-
const url = (push.relayUrl ?? "").trim() ||
|
|
4192
|
+
const url = (push.relayUrl ?? "").trim() || "https://pilot.hailab.dev";
|
|
3909
4193
|
if (!/^https:\/\//i.test(url)) return {
|
|
3910
4194
|
url,
|
|
3911
4195
|
overall: "failed",
|
|
@@ -3935,6 +4219,7 @@ function apply(ctx, options) {
|
|
|
3935
4219
|
return await runPushSelfTest();
|
|
3936
4220
|
});
|
|
3937
4221
|
const state = {};
|
|
4222
|
+
let pendingUpgrades = 0;
|
|
3938
4223
|
const handleUpgrade = (req, socket, head) => {
|
|
3939
4224
|
(async () => {
|
|
3940
4225
|
try {
|
|
@@ -3942,44 +4227,53 @@ function apply(ctx, options) {
|
|
|
3942
4227
|
rejectUpgrade(socket, 503, "bridge disabled");
|
|
3943
4228
|
return;
|
|
3944
4229
|
}
|
|
3945
|
-
if (connections.size >= MAX_CLIENT_CONNECTIONS) {
|
|
4230
|
+
if (connections.size + pendingUpgrades >= MAX_CLIENT_CONNECTIONS) {
|
|
3946
4231
|
rejectUpgrade(socket, 429, "too many connections");
|
|
3947
4232
|
return;
|
|
3948
4233
|
}
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
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
|
-
|
|
4234
|
+
pendingUpgrades += 1;
|
|
4235
|
+
try {
|
|
4236
|
+
const { devices } = await ready;
|
|
4237
|
+
const token = auth.token;
|
|
4238
|
+
if (!token || !devices) {
|
|
4239
|
+
rejectUpgrade(socket, 503, "bridge degraded");
|
|
4240
|
+
return;
|
|
4241
|
+
}
|
|
4242
|
+
const presentedToken = requestToken(req);
|
|
4243
|
+
if (presentedToken !== null && !tokenMatches(presentedToken, token)) {
|
|
4244
|
+
rejectUpgrade(socket, 401, "invalid token");
|
|
4245
|
+
return;
|
|
4246
|
+
}
|
|
4247
|
+
const bridge = state.bridge;
|
|
4248
|
+
if (!bridge) {
|
|
4249
|
+
rejectUpgrade(socket, 503, "bridge not ready");
|
|
4250
|
+
return;
|
|
4251
|
+
}
|
|
4252
|
+
if (auth.token !== token) {
|
|
4253
|
+
rejectUpgrade(socket, 401, "invalid token");
|
|
4254
|
+
return;
|
|
4255
|
+
}
|
|
4256
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
4257
|
+
if (auth.token !== token || state.bridge !== bridge) {
|
|
4258
|
+
ws.close(1012, "bridge changed");
|
|
4259
|
+
return;
|
|
4260
|
+
}
|
|
4261
|
+
const connection = new BridgeConnection(ws, {
|
|
4262
|
+
bridge,
|
|
4263
|
+
devices,
|
|
4264
|
+
serverVersion: SERVER_VERSION,
|
|
4265
|
+
expectedToken: token,
|
|
4266
|
+
transportAuthenticated: presentedToken !== null,
|
|
4267
|
+
log,
|
|
4268
|
+
debug: currentConfig().debug === true,
|
|
4269
|
+
onClosed: (closed) => connections.delete(closed),
|
|
4270
|
+
onPushEnrollKey: handlePushEnrollKey
|
|
4271
|
+
});
|
|
4272
|
+
connections.add(connection);
|
|
3980
4273
|
});
|
|
3981
|
-
|
|
3982
|
-
|
|
4274
|
+
} finally {
|
|
4275
|
+
pendingUpgrades -= 1;
|
|
4276
|
+
}
|
|
3983
4277
|
} catch (error) {
|
|
3984
4278
|
log("upgrade failed: " + String(error));
|
|
3985
4279
|
rejectUpgrade(socket, 500, "internal error");
|
|
@@ -4033,7 +4327,11 @@ function apply(ctx, options) {
|
|
|
4033
4327
|
state.bridge = bridge;
|
|
4034
4328
|
bridge.start();
|
|
4035
4329
|
log("data plane active (mux + host streams)");
|
|
4036
|
-
apiCtx.effect(() => () =>
|
|
4330
|
+
apiCtx.effect(() => () => {
|
|
4331
|
+
closeConnectionsForBridge(bridge);
|
|
4332
|
+
if (state.bridge === bridge) state.bridge = void 0;
|
|
4333
|
+
bridge.dispose();
|
|
4334
|
+
}, "deeppilot: host streams");
|
|
4037
4335
|
});
|
|
4038
4336
|
ctx.inject(["webServer"], (sub) => {
|
|
4039
4337
|
const webCtx = sub;
|
|
@@ -4055,7 +4353,7 @@ function apply(ctx, options) {
|
|
|
4055
4353
|
const now = Date.now();
|
|
4056
4354
|
for (const connection of connections) if (connection.isStale(now, 6e4)) {
|
|
4057
4355
|
log("dropping stale connection");
|
|
4058
|
-
connection.
|
|
4356
|
+
connection.closeIdle();
|
|
4059
4357
|
connections.delete(connection);
|
|
4060
4358
|
}
|
|
4061
4359
|
}, 3e4);
|
|
@@ -4141,8 +4439,23 @@ function apply(ctx, options) {
|
|
|
4141
4439
|
if (enabledNow()) log("/phone WebSocket registered");
|
|
4142
4440
|
else log("bridge disabled; /phone refuses connections until re-enabled and restarted");
|
|
4143
4441
|
});
|
|
4442
|
+
ctx.effect(() => async () => {
|
|
4443
|
+
closeAllConnections();
|
|
4444
|
+
const bridge = state.bridge;
|
|
4445
|
+
state.bridge = void 0;
|
|
4446
|
+
bridge?.dispose();
|
|
4447
|
+
const sender = cachedSender;
|
|
4448
|
+
cachedSender = void 0;
|
|
4449
|
+
updateChecker.dispose();
|
|
4450
|
+
const wssClosed = new Promise((resolve) => wss.close(() => resolve()));
|
|
4451
|
+
await Promise.allSettled([
|
|
4452
|
+
enrollmentWriteTail,
|
|
4453
|
+
sender?.dispose?.() ?? Promise.resolve(),
|
|
4454
|
+
wssClosed
|
|
4455
|
+
]);
|
|
4456
|
+
}, "deeppilot: process resources");
|
|
4144
4457
|
}
|
|
4145
4458
|
//#endregion
|
|
4146
|
-
export { Config, HostBridge, apply, inject, name, requestToken };
|
|
4459
|
+
export { Config, HostBridge, apply, inject, name, requestToken, shouldPrunePushToken, shouldReEnrollRelayToken };
|
|
4147
4460
|
|
|
4148
4461
|
//# sourceMappingURL=index.js.map
|