dsh-deeppilot 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,9 +1,8 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { createServer } from "node:http";
3
- import { createPrivateKey, randomBytes, randomUUID, sign, timingSafeEqual } from "node:crypto";
4
- import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID, sign, timingSafeEqual, verify } from "node:crypto";
4
+ import { access, chmod, 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,129 @@ 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
+ import { isIP } from "node:net";
17
+ //#region src/device-auth.ts
18
+ const PAIRING_CODE_TTL_MS = 3e5;
19
+ const AUTH_CHALLENGE_TTL_MS = 3e4;
20
+ const DEVICE_SCOPES = [
21
+ "sessions.read",
22
+ "prompt.send",
23
+ "sessions.manage",
24
+ "interactions.respond",
25
+ "notifications.register"
26
+ ];
27
+ const DEFAULT_DEVICE_SCOPES = DEVICE_SCOPES;
28
+ async function loadOrCreateHostAudience(path) {
29
+ try {
30
+ const existing = (await readFile(path, "utf8")).trim();
31
+ if (/^deeppilot:[A-Za-z0-9_-]{22}$/.test(existing)) return existing;
32
+ throw new Error(`host audience is malformed at ${path}`);
33
+ } catch (error) {
34
+ if (error.code !== "ENOENT") throw error;
35
+ }
36
+ const audience = "deeppilot:" + randomBytes(16).toString("base64url");
37
+ await mkdir(dirname(path), { recursive: true });
38
+ await writeFile(path, audience + "\n", { mode: 384 });
39
+ return audience;
40
+ }
41
+ function b64urlText(value) {
42
+ return Buffer.from(value, "utf8").toString("base64url");
43
+ }
44
+ /**
45
+ * Cross-language signature input. Text fields are base64url encoded before
46
+ * joining so names cannot create ambiguous separators. Decimal timestamps
47
+ * and cursor values are finite integers, or `-` when the cursor is absent.
48
+ */
49
+ function canonicalAuthChallenge(fields) {
50
+ const cursor = fields.resumeCursor === void 0 ? "-" : String(fields.resumeCursor);
51
+ return Buffer.from([
52
+ "deeppilot-auth-v2",
53
+ `device-id:${b64urlText(fields.deviceId)}`,
54
+ `nonce:${fields.nonce}`,
55
+ `audience:${b64urlText(fields.audience)}`,
56
+ `issued-at:${fields.issuedAt}`,
57
+ `expires-at:${fields.expiresAt}`,
58
+ `device-name:${b64urlText(fields.deviceName)}`,
59
+ `app-version:${b64urlText(fields.appVersion)}`,
60
+ `resume-cursor:${cursor}`
61
+ ].join("\n"), "utf8");
62
+ }
63
+ /** Accept only an uncompressed ANSI X9.63 P-256 public key (65 bytes). */
64
+ function parseP256PublicKey(encoded) {
65
+ const raw = Buffer.from(encoded, "base64url");
66
+ if (raw.length !== 65 || raw[0] !== 4) throw new TypeError("publicKey must be an uncompressed P-256 X9.63 key");
67
+ const spkiPrefix = Buffer.from("3059301306072a8648ce3d020106082a8648ce3d030107034200", "hex");
68
+ const key = createPublicKey({
69
+ key: Buffer.concat([spkiPrefix, raw]),
70
+ format: "der",
71
+ type: "spki"
72
+ });
73
+ if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") throw new TypeError("publicKey must use P-256");
74
+ return {
75
+ key,
76
+ raw
77
+ };
78
+ }
79
+ function deviceIdForPublicKey(publicKey) {
80
+ const { raw } = parseP256PublicKey(publicKey);
81
+ return createHash("sha256").update(raw).digest("base64url");
82
+ }
83
+ function fingerprintForPublicKey(publicKey) {
84
+ const { raw } = parseP256PublicKey(publicKey);
85
+ return createHash("sha256").update(raw).digest("hex");
86
+ }
87
+ function verifyAuthProof(publicKey, fields, signature) {
88
+ try {
89
+ const { key } = parseP256PublicKey(publicKey);
90
+ const der = Buffer.from(signature, "base64url");
91
+ if (der.length < 64 || der.length > 80) return false;
92
+ return verify("sha256", canonicalAuthChallenge(fields), key, der);
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+ function normalizeDeviceScopes(value) {
98
+ if (!Array.isArray(value)) return [...DEFAULT_DEVICE_SCOPES];
99
+ const allowed = new Set(DEVICE_SCOPES);
100
+ return [...new Set(value.filter((scope) => typeof scope === "string" && allowed.has(scope)))];
101
+ }
102
+ /** One active, single-use pairing grant per plugin runtime. */
103
+ var PairingCodeManager = class {
104
+ active = null;
105
+ issue(now = Date.now()) {
106
+ const grant = {
107
+ code: randomBytes(24).toString("base64url"),
108
+ expiresAt: now + PAIRING_CODE_TTL_MS
109
+ };
110
+ this.active = grant;
111
+ return { ...grant };
112
+ }
113
+ consume(presented, now = Date.now()) {
114
+ const active = this.active;
115
+ if (active === null || now > active.expiresAt) {
116
+ this.active = null;
117
+ return false;
118
+ }
119
+ const expected = Buffer.from(active.code);
120
+ const actual = Buffer.from(presented);
121
+ const matches = expected.length === actual.length && timingSafeEqual(expected, actual);
122
+ if (matches) this.active = null;
123
+ return matches;
124
+ }
125
+ invalidate() {
126
+ this.active = null;
127
+ }
128
+ };
129
+ function createAuthChallenge(audience, now = Date.now()) {
130
+ return {
131
+ nonce: randomBytes(24).toString("base64url"),
132
+ audience,
133
+ issuedAt: now,
134
+ expiresAt: now + AUTH_CHALLENGE_TTL_MS
135
+ };
136
+ }
137
+ //#endregion
16
138
  //#region src/token.ts
17
139
  /** Expand a leading ~ using the process home directory. */
18
140
  function expandHome(p) {
@@ -29,6 +151,16 @@ function dshDataRoot() {
29
151
  function bridgeDataDir() {
30
152
  return resolve(dshDataRoot(), "deeppilot");
31
153
  }
154
+ /** Create or repair the canonical secret-bearing directory as owner-only. */
155
+ async function ensurePrivateBridgeDataDir() {
156
+ const target = bridgeDataDir();
157
+ await mkdir(target, {
158
+ recursive: true,
159
+ mode: 448
160
+ });
161
+ await chmod(target, 448);
162
+ return target;
163
+ }
32
164
  /**
33
165
  * Move the pre-DeepPilot data directory as one atomic directory rename.
34
166
  * Existing canonical data always wins; secrets are never merged or replaced.
@@ -50,46 +182,6 @@ async function migrateLegacyBridgeDataDir() {
50
182
  throw error;
51
183
  }
52
184
  }
53
- /**
54
- * Load the pairing token from disk or generate and persist a fresh one.
55
- * The file is written 0600; the token never appears in logs.
56
- */
57
- async function loadOrCreateToken(tokenPath) {
58
- const full = expandHome(tokenPath);
59
- try {
60
- const existing = (await readFile(full, "utf8")).trim();
61
- if (existing.length >= 32) return existing;
62
- } catch {}
63
- const token = randomBytes(32).toString("base64url");
64
- await mkdir(dirname(full), { recursive: true });
65
- await writeFile(full, token + "\n", { mode: 384 });
66
- return token;
67
- }
68
- /**
69
- * Generate a fresh pairing token and replace the stored one, invalidating
70
- * every copy of the old secret. The write goes to a same-directory temp file
71
- * renamed over the target so a crash can never leave a truncated token file.
72
- */
73
- async function writeNewToken(tokenPath) {
74
- const full = expandHome(tokenPath);
75
- const token = randomBytes(32).toString("base64url");
76
- await mkdir(dirname(full), { recursive: true });
77
- const temp = `${full}.${randomBytes(6).toString("hex")}.tmp`;
78
- await writeFile(temp, token + "\n", { mode: 384 });
79
- await rename(temp, full);
80
- return token;
81
- }
82
- /** Constant-time token comparison; both sides are high-entropy secrets. */
83
- function tokenMatches(presented, expected) {
84
- if (!presented) return false;
85
- const a = Buffer.from(presented);
86
- const b = Buffer.from(expected);
87
- if (a.length !== b.length) {
88
- timingSafeEqual(b, b);
89
- return false;
90
- }
91
- return timingSafeEqual(a, b);
92
- }
93
185
  /** Hex shape of an APNs device token as delivered by iOS (usually 64 chars). */
94
186
  const APNS_TOKEN_PATTERN = /^[0-9a-f]{32,512}$/;
95
187
  function isValidApnsToken(token) {
@@ -117,32 +209,57 @@ var DeviceStore = class DeviceStore {
117
209
  }
118
210
  return store;
119
211
  }
120
- touch(record, now) {
121
- const existing = this.devices.get(record.deviceId);
122
- if (existing) {
123
- existing.lastSeenTs = now;
124
- existing.deviceName = record.deviceName || existing.deviceName;
125
- existing.appVersion = record.appVersion || existing.appVersion;
126
- } else {
127
- if (this.devices.size >= 64) {
128
- let oldestId;
129
- let oldestTs = Number.POSITIVE_INFINITY;
130
- for (const [id, value] of this.devices) if (value.lastSeenTs < oldestTs) {
131
- oldestTs = value.lastSeenTs;
132
- oldestId = id;
133
- }
134
- if (oldestId !== void 0) this.devices.delete(oldestId);
135
- }
136
- this.devices.set(record.deviceId, {
137
- ...record,
138
- firstSeenTs: now,
139
- lastSeenTs: now
140
- });
141
- }
212
+ /** Register one public key after a valid, single-use pairing grant. */
213
+ register(record, now) {
214
+ const deviceId = deviceIdForPublicKey(record.publicKey);
215
+ const existing = this.devices.get(deviceId);
216
+ if (!existing && this.devices.size >= 64) throw new Error("device registry is full");
217
+ const next = {
218
+ deviceId,
219
+ deviceName: record.deviceName,
220
+ appVersion: record.appVersion,
221
+ publicKey: record.publicKey,
222
+ fingerprint: fingerprintForPublicKey(record.publicKey),
223
+ scopes: normalizeDeviceScopes(record.scopes),
224
+ firstSeenTs: existing?.firstSeenTs ?? now,
225
+ lastSeenTs: now,
226
+ ...existing?.apns ? { apns: existing.apns } : {}
227
+ };
228
+ this.devices.set(deviceId, next);
229
+ this.flush();
230
+ return structuredClone(next);
231
+ }
232
+ /** Return an active cryptographic identity. */
233
+ authorized(deviceId) {
234
+ const record = this.devices.get(deviceId);
235
+ if (!record?.publicKey || record.revokedAt !== void 0) return void 0;
236
+ return record;
237
+ }
238
+ markAuthenticated(deviceId, deviceName, appVersion, now) {
239
+ const record = this.authorized(deviceId);
240
+ if (!record) return;
241
+ record.deviceName = deviceName || record.deviceName;
242
+ record.appVersion = appVersion || record.appVersion;
243
+ record.lastSeenTs = now;
244
+ this.flush();
245
+ }
246
+ revoke(deviceId, now) {
247
+ const record = this.devices.get(deviceId);
248
+ if (!record || record.revokedAt !== void 0) return false;
249
+ record.revokedAt = now;
250
+ delete record.apns;
251
+ this.flush();
252
+ return true;
253
+ }
254
+ setScopes(deviceId, scopes) {
255
+ const record = this.authorized(deviceId);
256
+ if (!record) return null;
257
+ record.scopes = normalizeDeviceScopes(scopes);
142
258
  this.flush();
259
+ return [...record.scopes];
143
260
  }
144
261
  list() {
145
- return [...this.devices.values()];
262
+ return [...this.devices.values()].map((record) => structuredClone(record));
146
263
  }
147
264
  /**
148
265
  * Store (or refresh) the APNs registration of a paired device. Idempotent:
@@ -152,17 +269,8 @@ var DeviceStore = class DeviceStore {
152
269
  setPushToken(deviceId, token, environment, categories, now) {
153
270
  const normalized = token.toLowerCase();
154
271
  if (!isValidApnsToken(normalized)) return;
155
- let record = this.devices.get(deviceId);
156
- if (!record) {
157
- record = {
158
- deviceId,
159
- deviceName: "unknown",
160
- appVersion: "unknown",
161
- firstSeenTs: now,
162
- lastSeenTs: now
163
- };
164
- this.devices.set(deviceId, record);
165
- }
272
+ const record = this.authorized(deviceId);
273
+ if (!record) return;
166
274
  const next = {
167
275
  token: normalized,
168
276
  environment,
@@ -185,15 +293,6 @@ var DeviceStore = class DeviceStore {
185
293
  delete record.apns;
186
294
  this.flush();
187
295
  }
188
- /**
189
- * Drop every paired-device record. Used by token rotation: devices paired
190
- * under the old token can no longer authenticate, so keeping their rows
191
- * would paint a misleading "still paired" picture.
192
- */
193
- clear() {
194
- this.devices.clear();
195
- this.flush();
196
- }
197
296
  /** Serialized so concurrent touches can never interleave half-written JSON. */
198
297
  flush() {
199
298
  const next = this.flushTail.then(() => this.writeFile());
@@ -207,7 +306,7 @@ var DeviceStore = class DeviceStore {
207
306
  async writeFile() {
208
307
  const full = expandHome(this.filePath);
209
308
  const body = JSON.stringify({
210
- version: 1,
309
+ version: 2,
211
310
  devices: this.list()
212
311
  }, null, 2);
213
312
  try {
@@ -219,43 +318,83 @@ var DeviceStore = class DeviceStore {
219
318
  }
220
319
  };
221
320
  //#endregion
222
- //#region src/connection.ts
223
- const AUTH_TIMEOUT_MS = 5e3;
321
+ //#region src/connection-policy.ts
322
+ const AUTH_TIMEOUT_MS = 35e3;
224
323
  const IMAGE_MEDIA_TYPES = /* @__PURE__ */ new Set([
225
324
  "image/png",
226
325
  "image/jpeg",
227
326
  "image/webp",
228
327
  "image/gif"
229
328
  ]);
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
329
  function sanitizeDeviceField(value, maxChars) {
238
330
  return (typeof value === "string" ? value : String(value ?? "")).replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, maxChars);
239
331
  }
240
- function helloTokenAccepted(transportAuthenticated, presentedToken, expectedToken) {
241
- return transportAuthenticated === true || tokenMatches(presentedToken, expectedToken);
332
+ function requiredScope(type) {
333
+ if (type === "c2s.ping" || type === "c2s.resume") return void 0;
334
+ if (type === "c2s.session.sendPrompt") return "prompt.send";
335
+ if (type === "c2s.approval.respond" || type === "c2s.question.respond") return "interactions.respond";
336
+ if (type === "c2s.push.register") return "notifications.register";
337
+ if (type === "c2s.workspace.create" || type === "c2s.session.create" || type === "c2s.session.rename" || type === "c2s.session.archive" || type === "c2s.session.cancel" || type === "c2s.session.selectModel") return "sessions.manage";
338
+ if (type.startsWith("c2s.")) return "sessions.read";
339
+ }
340
+ function sanitizeImageName(value) {
341
+ return value.replace(/[\u0000-\u001F\u007F]/g, "").trim().slice(0, 120);
342
+ }
343
+ /** Error code for a failed approval/question response outcome. */
344
+ function pendingResponseErrorCode(reason) {
345
+ switch (reason) {
346
+ case "not-pending": return "E_NOT_FOUND";
347
+ case "bad-response": return "E_PROTOCOL";
348
+ case "transport": return "E_INTERNAL";
349
+ }
350
+ }
351
+ /** Human-readable failure detail; `question not pending` must only ever mean
352
+ * "nothing pending", never "the host rejected the answer". */
353
+ function pendingResponseMessage(kind, reason) {
354
+ switch (reason) {
355
+ case "not-pending": return kind + " not pending";
356
+ case "bad-response": return kind + " answer rejected by host: answer does not match the asked questions";
357
+ case "transport": return "host connection failed while answering " + kind;
358
+ }
359
+ }
360
+ function managementErrorCode(kind) {
361
+ switch (kind) {
362
+ case "unsupported": return "E_UNSUPPORTED";
363
+ case "not-found": return "E_NOT_FOUND";
364
+ case "busy": return "E_BUSY";
365
+ case "invalid": return "E_PROTOCOL";
366
+ case "internal": return "E_INTERNAL";
367
+ }
242
368
  }
369
+ //#endregion
370
+ //#region src/connection.ts
243
371
  /**
244
372
  * One connected phone. Implements BridgeSink so the HostBridge can push
245
- * projected frames and replays. Bearer/query credentials may authenticate the
246
- * HTTP upgrade; otherwise the first hello frame is verified here.
373
+ * projected frames and replays. Every socket starts anonymous, receives one
374
+ * server challenge, and must prove possession of a registered P-256 key.
247
375
  */
248
376
  var BridgeConnection = class {
249
377
  ws;
250
378
  deps;
251
379
  authenticated = false;
380
+ authenticationSettled = false;
381
+ closed = false;
252
382
  helloTimer;
253
383
  openSessions = /* @__PURE__ */ new Set();
384
+ /**
385
+ * Realtime events that arrive while a session's history snapshot is in
386
+ * flight. The wire contract requires tail first; sending these immediately
387
+ * lets the later tail roll the client back over messages it just rendered.
388
+ */
389
+ openingSessionEvents = /* @__PURE__ */ new Map();
254
390
  /** Sanitized device identity from hello; needed for push registration. */
255
391
  deviceId;
392
+ scopes = /* @__PURE__ */ new Set();
393
+ authChallenge;
256
394
  constructor(ws, deps) {
257
395
  this.ws = ws;
258
396
  this.deps = deps;
397
+ this.authChallenge = createAuthChallenge(deps.audience);
259
398
  ws.on("message", (data) => {
260
399
  this.onMessage(String(data));
261
400
  });
@@ -265,18 +404,50 @@ var BridgeConnection = class {
265
404
  });
266
405
  ws.on("error", () => {});
267
406
  this.helloTimer = setTimeout(() => {
268
- if (!this.authenticated) this.close(4402, "auth timeout");
407
+ if (!this.authenticated) {
408
+ this.settleAuthentication(false, "timeout");
409
+ this.close(4402, "auth timeout");
410
+ }
269
411
  }, AUTH_TIMEOUT_MS);
412
+ this.helloTimer.unref();
413
+ this.send("s2c.auth.challenge", this.authChallenge);
270
414
  }
271
415
  /** Hard-drop the socket (server-side stale sweep). */
272
416
  terminate() {
273
417
  this.ws.terminate();
274
418
  }
419
+ /** Protocol-compliant idle timeout: let the peer observe a normal 1001 close. */
420
+ closeIdle() {
421
+ this.close(1001, "idle timeout");
422
+ }
423
+ /** Announce an orderly plugin/data-plane shutdown before closing the socket. */
424
+ closeForServerStop() {
425
+ this.fail(void 0, "E_INTERNAL", "server stopping");
426
+ this.close(1001, "server stopping");
427
+ }
428
+ /** Used by dependency-lifecycle cleanup to avoid closing a replacement bridge. */
429
+ isAttachedTo(bridge) {
430
+ return this.deps.bridge === bridge;
431
+ }
275
432
  /** Device identity once hello succeeded; undefined before that. */
276
433
  get connectedDeviceId() {
277
434
  return this.authenticated ? this.deviceId : void 0;
278
435
  }
279
436
  push(type, payload, seq) {
437
+ if (type === "s2c.session.event") {
438
+ const sessionId = payload?.sessionId;
439
+ if (typeof sessionId === "string") {
440
+ const buffered = this.openingSessionEvents.get(sessionId);
441
+ if (buffered) {
442
+ buffered.push({
443
+ type,
444
+ payload,
445
+ ...seq !== void 0 ? { seq } : {}
446
+ });
447
+ return;
448
+ }
449
+ }
450
+ }
280
451
  if (this.deps.debug === true) this.deps.log("push " + type + " seq=" + String(seq));
281
452
  this.send(type, payload, void 0, seq);
282
453
  }
@@ -293,13 +464,23 @@ var BridgeConnection = class {
293
464
  return this.deps.bridge.currentCursor();
294
465
  }
295
466
  onClose() {
467
+ if (this.closed) return;
468
+ this.closed = true;
469
+ if (!this.authenticationSettled) this.settleAuthentication(false, "closed");
296
470
  if (this.helloTimer !== void 0) clearTimeout(this.helloTimer);
297
471
  for (const id of this.openSessions) this.deps.bridge.markSinkClosed(this, id);
298
472
  this.openSessions.clear();
473
+ this.openingSessionEvents.clear();
299
474
  this.deps.bridge.dropSinkSessions(this);
300
475
  if (this.authenticated) this.deps.bridge.removeSink(this);
301
476
  }
477
+ settleAuthentication(ok, reason) {
478
+ if (this.authenticationSettled) return;
479
+ this.authenticationSettled = true;
480
+ this.deps.onAuthenticationSettled?.(ok, reason);
481
+ }
302
482
  close(code, reason) {
483
+ if (this.closed) return;
303
484
  try {
304
485
  this.ws.close(code, reason);
305
486
  } catch {
@@ -308,14 +489,19 @@ var BridgeConnection = class {
308
489
  }
309
490
  send(type, payload, id, seq) {
310
491
  const envelope = {
311
- v: 1,
492
+ v: 2,
312
493
  type,
313
494
  ts: Date.now(),
314
495
  ...id !== void 0 ? { id } : {},
315
496
  ...seq !== void 0 ? { seq } : {},
316
497
  payload
317
498
  };
318
- if (this.ws.readyState === this.ws.OPEN) this.ws.send(JSON.stringify(envelope));
499
+ if (this.ws.readyState !== this.ws.OPEN) return;
500
+ if (this.ws.bufferedAmount > 4194304) {
501
+ this.close(1013, "client too slow");
502
+ return;
503
+ }
504
+ this.ws.send(JSON.stringify(envelope));
319
505
  }
320
506
  fail(id, code, message) {
321
507
  this.send("s2c.error", {
@@ -330,6 +516,10 @@ var BridgeConnection = class {
330
516
  }
331
517
  async onMessage(raw) {
332
518
  this.lastActivity = Date.now();
519
+ if (!this.authenticated && raw.length > 65536) {
520
+ this.close(1009, "pre-auth frame too large");
521
+ return;
522
+ }
333
523
  let env;
334
524
  try {
335
525
  env = JSON.parse(raw);
@@ -337,7 +527,7 @@ var BridgeConnection = class {
337
527
  this.fail(void 0, "E_PROTOCOL", "frame is not valid JSON");
338
528
  return;
339
529
  }
340
- if (env.v !== 1) {
530
+ if (env.v !== 2) {
341
531
  this.fail(env.id, "E_UNSUPPORTED", "unsupported protocol version");
342
532
  this.close(4500, "protocol version mismatch");
343
533
  return;
@@ -347,13 +537,18 @@ var BridgeConnection = class {
347
537
  this.send("s2c.pong", { serverTime: Date.now() }, env.id);
348
538
  return;
349
539
  }
350
- if (env.type === "c2s.hello.auth") {
351
- await this.hello(env);
540
+ if (env.type === "c2s.auth.prove") {
541
+ await this.prove(env);
352
542
  return;
353
543
  }
354
544
  this.fail(env.id, "E_PROTOCOL", "authenticate first");
355
545
  return;
356
546
  }
547
+ const required = requiredScope(env.type);
548
+ if (required !== void 0 && !this.scopes.has(required)) {
549
+ this.fail(env.id, "E_FORBIDDEN", `scope ${required} required`);
550
+ return;
551
+ }
357
552
  switch (env.type) {
358
553
  case "c2s.ping":
359
554
  this.send("s2c.pong", { serverTime: Date.now() }, env.id);
@@ -402,18 +597,23 @@ var BridgeConnection = class {
402
597
  const p = env.payload;
403
598
  if (!p?.sessionId || typeof p.sessionId !== "string") return this.fail(env.id, "E_PROTOCOL", "sessionId required");
404
599
  const sessionId = p.sessionId;
405
- this.openSessions.add(sessionId);
406
- this.deps.bridge.markSinkOpen(this, sessionId);
600
+ const bufferedEvents = [];
601
+ this.openingSessionEvents.set(sessionId, bufferedEvents);
407
602
  if (!await this.deps.bridge.openSession(this, sessionId, p.tailCount ?? 100)) {
408
- this.openSessions.delete(sessionId);
409
- this.deps.bridge.markSinkClosed(this, sessionId);
603
+ if (this.openingSessionEvents.get(sessionId) === bufferedEvents) this.openingSessionEvents.delete(sessionId);
410
604
  return this.fail(env.id, "E_NOT_FOUND", "session history unavailable");
411
605
  }
606
+ if (this.openingSessionEvents.get(sessionId) !== bufferedEvents) return;
607
+ this.openSessions.add(sessionId);
608
+ this.deps.bridge.markSinkOpen(this, sessionId);
609
+ this.openingSessionEvents.delete(sessionId);
610
+ for (const frame of bufferedEvents) this.push(frame.type, frame.payload, frame.seq);
412
611
  return;
413
612
  }
414
613
  case "c2s.session.close": {
415
614
  const p = env.payload;
416
615
  if (!p?.sessionId) return this.fail(env.id, "E_PROTOCOL", "sessionId required");
616
+ this.openingSessionEvents.delete(p.sessionId);
417
617
  this.openSessions.delete(p.sessionId);
418
618
  this.deps.bridge.markSinkClosed(this, p.sessionId);
419
619
  this.send("s2c.ack", {}, env.id);
@@ -474,7 +674,9 @@ var BridgeConnection = class {
474
674
  case "c2s.session.history": {
475
675
  const p = env.payload;
476
676
  if (!p?.sessionId || typeof p.beforeSeq !== "number") return this.fail(env.id, "E_PROTOCOL", "sessionId and beforeSeq required");
477
- if (!await this.deps.bridge.historyPage(this, p.sessionId, p.beforeSeq, Math.min(p.limit ?? 100, 500))) this.fail(env.id, "E_NOT_FOUND", "history unavailable");
677
+ const page = await this.deps.bridge.historyPage(p.sessionId, p.beforeSeq, Math.min(p.limit ?? 100, 500));
678
+ if (!page) return this.fail(env.id, "E_NOT_FOUND", "history unavailable");
679
+ this.send("s2c.history.page", page, env.id);
478
680
  return;
479
681
  }
480
682
  case "c2s.session.attachment": {
@@ -524,15 +726,15 @@ var BridgeConnection = class {
524
726
  const text = typeof p?.text === "string" ? p.text : "";
525
727
  const rawImages = Array.isArray(p?.images) ? p.images : [];
526
728
  if (!p?.sessionId || text.trim().length === 0 && rawImages.length === 0) return this.fail(env.id, "E_PROTOCOL", "sessionId and text or images required");
527
- if (text.length > MAX_PROMPT_TEXT_CHARS) return this.fail(env.id, "E_PROTOCOL", "prompt text too long");
528
- if (rawImages.length > MAX_PROMPT_IMAGES) return this.fail(env.id, "E_PROTOCOL", "too many images");
729
+ if (text.length > 262144) return this.fail(env.id, "E_PROTOCOL", "prompt text too long");
730
+ if (rawImages.length > 4) return this.fail(env.id, "E_PROTOCOL", "too many images");
529
731
  const images = [];
530
732
  for (const image of rawImages) {
531
- if (!IMAGE_MEDIA_TYPES.has(String(image?.mediaType)) || typeof image?.data !== "string" || image.data.length === 0 || image.data.length > MAX_BASE64_CHARS_PER_IMAGE) return this.fail(env.id, "E_PROTOCOL", "invalid image attachment");
733
+ 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
734
  images.push({
533
735
  mediaType: image.mediaType,
534
736
  data: image.data,
535
- ...typeof image.name === "string" && image.name.trim().length > 0 ? { name: image.name.trim().slice(0, 120) } : {}
737
+ ...typeof image.name === "string" && sanitizeImageName(image.name).length > 0 ? { name: sanitizeImageName(image.name) } : {}
536
738
  });
537
739
  }
538
740
  const userSeq = await this.deps.bridge.sendPrompt(p.sessionId, text, images);
@@ -579,40 +781,51 @@ var BridgeConnection = class {
579
781
  default: this.fail(env.id, "E_PROTOCOL", "unknown type: " + env.type);
580
782
  }
581
783
  }
582
- async hello(env) {
784
+ async prove(env) {
583
785
  const p = env.payload ?? {};
584
- if (!helloTokenAccepted(this.deps.transportAuthenticated, p.token, this.deps.expectedToken)) {
585
- this.fail(env.id, "E_AUTH", "token missing or invalid");
586
- this.close(4401, "invalid token");
587
- return;
588
- }
589
786
  if (!p.deviceId) {
590
787
  this.fail(env.id, "E_PROTOCOL", "deviceId required");
591
788
  this.close(4403, "deviceId required");
592
789
  return;
593
790
  }
594
- const deviceId = sanitizeDeviceField(p.deviceId, MAX_DEVICE_ID_CHARS);
791
+ const deviceId = sanitizeDeviceField(p.deviceId, 128);
595
792
  if (!deviceId) {
596
793
  this.fail(env.id, "E_PROTOCOL", "deviceId required");
597
794
  this.close(4403, "deviceId required");
598
795
  return;
599
796
  }
600
- const deviceName = sanitizeDeviceField(p.deviceName, MAX_DEVICE_NAME_CHARS) || "unknown";
601
- const appVersion = sanitizeDeviceField(p.appVersion, MAX_APP_VERSION_CHARS) || "unknown";
797
+ const deviceName = sanitizeDeviceField(p.deviceName, 64) || "unknown";
798
+ const appVersion = sanitizeDeviceField(p.appVersion, 32) || "unknown";
799
+ const challenge = this.authChallenge;
800
+ const record = this.deps.devices.authorized(deviceId);
801
+ const resumeCursor = typeof p.resumeCursor === "number" && Number.isInteger(p.resumeCursor) && p.resumeCursor >= 0 ? p.resumeCursor : void 0;
802
+ const challengeMatches = p.nonce === challenge.nonce && p.audience === challenge.audience && p.issuedAt === challenge.issuedAt && p.expiresAt === challenge.expiresAt && Date.now() <= challenge.expiresAt;
803
+ if (!(record?.publicKey !== void 0 && typeof p.signature === "string" && challengeMatches && verifyAuthProof(record.publicKey, {
804
+ deviceId,
805
+ deviceName,
806
+ appVersion,
807
+ resumeCursor,
808
+ ...challenge
809
+ }, p.signature)) || record === void 0) {
810
+ this.fail(env.id, "E_AUTH", "device proof missing or invalid");
811
+ this.settleAuthentication(false, "invalid-proof");
812
+ this.close(4401, "invalid device proof");
813
+ return;
814
+ }
815
+ this.settleAuthentication(true, "success");
602
816
  this.authenticated = true;
603
817
  this.deviceId = deviceId;
818
+ this.scopes = new Set(record.scopes ?? []);
604
819
  if (this.helloTimer !== void 0) clearTimeout(this.helloTimer);
605
- this.deps.devices.touch({
606
- deviceId,
607
- deviceName,
608
- appVersion
609
- }, Date.now());
610
- this.deps.log("device paired: " + deviceName + " (" + deviceId + ")");
611
- const cursor = typeof p.resumeCursor === "number" && p.resumeCursor >= 0 ? p.resumeCursor : void 0;
820
+ this.deps.devices.markAuthenticated(deviceId, deviceName, appVersion, Date.now());
821
+ this.deps.onDeviceAuthenticated?.(deviceId);
822
+ const cursor = resumeCursor;
612
823
  const canResume = cursor !== void 0 && this.deps.bridge.canResumeFrom(cursor);
613
824
  this.send("s2c.welcome", {
614
- protocolVersion: 1,
825
+ protocolVersion: 2,
615
826
  serverVersion: this.deps.serverVersion,
827
+ deviceId,
828
+ scopes: [...this.scopes],
616
829
  capabilities: this.deps.bridge.capabilities,
617
830
  cursor: this.deps.bridge.currentCursor(),
618
831
  resumed: canResume
@@ -624,34 +837,8 @@ var BridgeConnection = class {
624
837
  }
625
838
  }
626
839
  };
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
840
  //#endregion
654
- //#region src/host-bridge.ts
841
+ //#region src/host-api.ts
655
842
  /**
656
843
  * Subagent sessions are host-internal workers of a parent conversation.
657
844
  * They must never surface on the phone: not in the project/session list,
@@ -662,117 +849,586 @@ function isSubagentRow(row) {
662
849
  }
663
850
  function unwrapStreamItem(item) {
664
851
  const nested = item.payload;
665
- if (nested && typeof nested === "object" && typeof nested.type === "string") return nested.rpcId || !item.rpcId ? nested : {
852
+ if (nested && typeof nested === "object" && typeof nested.type === "string") return item.rpcId ? {
666
853
  ...nested,
667
854
  rpcId: item.rpcId
668
- };
855
+ } : nested;
669
856
  return item;
670
857
  }
671
- const MAX_RING_DEFAULT = 2e3;
672
- /**
673
- * Process-wide bridge state: session mirror, pending approvals/questions,
674
- * and the per-device replay ring. Consumes the in-process mux/host streams
675
- * and fans projected pushes out to every registered sink.
676
- */
677
- let BRIDGE_SEQ = 0;
678
- var HostBridge = class {
679
- apiProxy;
680
- historyBufferMax;
681
- id = ++BRIDGE_SEQ;
682
- summaries = /* @__PURE__ */ new Map();
683
- approvals = /* @__PURE__ */ new Map();
684
- questions = /* @__PURE__ */ new Map();
685
- archivedSessionIds = /* @__PURE__ */ new Set();
686
- subagentSessionIds = /* @__PURE__ */ new Set();
687
- sinks = /* @__PURE__ */ new Set();
688
- ring = [];
689
- cursor = 0;
690
- abort = new AbortController();
691
- constructor(apiProxy, historyBufferMax = MAX_RING_DEFAULT) {
692
- this.apiProxy = apiProxy;
693
- this.historyBufferMax = historyBufferMax;
694
- }
695
- pushOutlet;
696
- /**
697
- * Wire the offline-push fan-out. Present ⇒ welcome advertises the `push`
698
- * capability and notify-worthy events are mirrored to APNs.
699
- */
700
- setPushOutlet(outlet) {
701
- this.pushOutlet = outlet;
858
+ //#endregion
859
+ //#region src/host-event-projection.ts
860
+ const MAX_MESSAGE_PROJECTION_BYTES = 262144;
861
+ /** One durable host event sequence becomes exactly one phone message row.
862
+ * Keep the last projection when a host history response repeats an event. */
863
+ function canonicalSessionMessages(messages) {
864
+ const bySequence = /* @__PURE__ */ new Map();
865
+ for (const message of messages) bySequence.set(message.seq, message);
866
+ return [...bySequence.values()].sort((a, b) => a.seq - b.seq);
867
+ }
868
+ function limitSessionPageMessages(messages) {
869
+ const canonical = canonicalSessionMessages(messages);
870
+ let bytes = 2;
871
+ const kept = [];
872
+ for (let index = canonical.length - 1; index >= 0; index -= 1) {
873
+ const message = canonical[index];
874
+ const candidateBytes = jsonBytes(message) + (kept.length > 0 ? 1 : 0);
875
+ if (bytes + candidateBytes > 921600) break;
876
+ kept.unshift(message);
877
+ bytes += candidateBytes;
702
878
  }
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
879
+ return {
880
+ messages: kept,
881
+ dropped: canonical.length - kept.length
882
+ };
883
+ }
884
+ function projectEvent(sessionId, event) {
885
+ switch (event.type) {
886
+ case "turn/start": return {
887
+ kind: "turn.start",
888
+ data: {}
714
889
  };
715
- }
716
- diagnostic(message) {
717
- console.log("[deeppilot] " + message);
718
- }
719
- currentCursor() {
720
- return this.cursor;
721
- }
722
- addSink(sink) {
723
- this.sinks.add(sink);
724
- }
725
- removeSink(sink) {
726
- this.sinks.delete(sink);
727
- }
728
- /** Whether the ring still holds everything after the cursor. */
729
- canResumeFrom(cursor) {
730
- const oldest = this.ring.length > 0 ? this.ring[0].seq : this.cursor + 1;
731
- return cursor <= this.cursor && cursor + 1 >= oldest;
732
- }
733
- sinkSessions = /* @__PURE__ */ new Map();
734
- lastAssistantText = /* @__PURE__ */ new Map();
735
- /** Mark a sink as actively viewing a session (suppresses its turn notifications). */
736
- markSinkOpen(sink, sessionId) {
737
- let set = this.sinkSessions.get(sink);
738
- if (!set) {
739
- set = /* @__PURE__ */ new Set();
740
- this.sinkSessions.set(sink, set);
741
- }
742
- set.add(sessionId);
743
- }
744
- markSinkClosed(sink, sessionId) {
745
- this.sinkSessions.get(sink)?.delete(sessionId);
746
- }
747
- dropSinkSessions(sink) {
748
- this.sinkSessions.delete(sink);
749
- }
750
- isViewedBy(sink, sessionId) {
751
- return this.sinkSessions.get(sink)?.has(sessionId) ?? false;
752
- }
890
+ case "turn/end": return {
891
+ kind: "turn.end",
892
+ data: { ok: event.data?.reason?.kind === "completed" }
893
+ };
894
+ case "user/message": return {
895
+ kind: "message.final",
896
+ data: { ...limitMessageProjection({
897
+ seq: event.seq,
898
+ role: userRoleOf(event.data),
899
+ text: messageText(event.data),
900
+ ...attachmentProjection(event.data),
901
+ ...contextProjectionOf(event.data),
902
+ ts: tsOf(event)
903
+ }) }
904
+ };
905
+ case "assistant/chunk":
906
+ if (chunkTypeOf(event.data) === "reasoning-delta") return {
907
+ kind: "thinking.delta",
908
+ data: limitRealtimeText({
909
+ text: chunkText(event.data),
910
+ ts: tsOf(event)
911
+ })
912
+ };
913
+ return {
914
+ kind: "message.delta",
915
+ data: limitRealtimeText({
916
+ text: chunkText(event.data),
917
+ ts: tsOf(event)
918
+ })
919
+ };
920
+ case "assistant/message": {
921
+ const text = messageText(event.data);
922
+ const thinking = messageThinking(event.data);
923
+ if (!text.trim() && !thinking.trim()) return null;
924
+ return {
925
+ kind: "message.final",
926
+ data: { ...limitMessageProjection({
927
+ seq: event.seq,
928
+ role: "assistant",
929
+ text,
930
+ ...thinking ? { thinking } : {},
931
+ ts: tsOf(event)
932
+ }) }
933
+ };
934
+ }
935
+ case "tool/call": {
936
+ const data = event.data;
937
+ return {
938
+ kind: "tool.start",
939
+ data: {
940
+ seq: event.seq,
941
+ role: "tool",
942
+ tool: {
943
+ name: String(data?.name ?? "tool"),
944
+ state: "running",
945
+ summary: summarizeArgs(data?.arguments),
946
+ ...data?.callId ? { callId: String(data.callId) } : {}
947
+ },
948
+ ts: tsOf(event)
949
+ }
950
+ };
951
+ }
952
+ case "tool/result": {
953
+ const data = event.data;
954
+ return {
955
+ kind: "tool.end",
956
+ data: {
957
+ seq: event.seq,
958
+ role: "tool",
959
+ ok: !event.data || data?.error === void 0,
960
+ ...data?.callId ? { callId: String(data.callId) } : {},
961
+ ts: tsOf(event)
962
+ }
963
+ };
964
+ }
965
+ default: return null;
966
+ }
967
+ }
968
+ function tsOf(event) {
969
+ return typeof event.time === "number" ? event.time : Date.now();
970
+ }
971
+ /** Read the durable message source off one user/message payload. Handles both
972
+ * bare-message payloads and older `{message: {...}}` wrappers; undefined when
973
+ * the shape carries no readable source (legacy hosts). */
974
+ function userMessageSource(data) {
975
+ if (!data || typeof data !== "object") return void 0;
976
+ const obj = data;
977
+ if (obj.source && typeof obj.source === "object") return obj.source;
978
+ if (obj.message && typeof obj.message === "object" && obj.message.source && typeof obj.message.source === "object") return obj.message.source;
979
+ }
980
+ /** Wire role for one user/message payload. A payload without any readable
981
+ * source degrades to 'user' so history written by older hosts stays visible;
982
+ * a present source follows the host's own trajectory rule — anything whose
983
+ * `kind` is not 'user' is injected context and projects as 'system'. */
984
+ function userRoleOf(data) {
985
+ const source = userMessageSource(data);
986
+ if (!source) return "user";
987
+ return source.kind === "user" ? "user" : "system";
988
+ }
989
+ /** Producer name of one injected-context source, mirroring how the DSH client
990
+ * runtime derives its trajectory label: plugin name, skill name, instruction
991
+ * paths, session-reference labels, or the raw kind as fallback. */
992
+ function contextLabelOf(source) {
993
+ const kind = typeof source.kind === "string" ? source.kind : "";
994
+ const joined = (member) => {
995
+ const list = source[member];
996
+ if (!Array.isArray(list)) return void 0;
997
+ const names = list.flatMap((entry) => {
998
+ if (!entry || typeof entry !== "object") return [];
999
+ const record = entry;
1000
+ return [typeof record.label === "string" ? record.label : typeof record.path === "string" ? record.path : ""];
1001
+ }).filter((name) => name.length > 0);
1002
+ return names.length > 0 ? names.join(", ") : void 0;
1003
+ };
1004
+ switch (kind) {
1005
+ case "session-reference": return joined("references") ?? (kind || void 0);
1006
+ case "agent-instructions": return joined("changes") ?? (kind || void 0);
1007
+ case "plugin": return typeof source.plugin === "string" && source.plugin.length > 0 ? source.plugin : kind || void 0;
1008
+ case "skill-invocation": return typeof source.name === "string" && source.name.length > 0 ? source.name : kind || void 0;
1009
+ default: return kind || void 0;
1010
+ }
1011
+ }
1012
+ /** Semantic ContextForm declared by the producer ('snapshot', 'notice', …);
1013
+ * anything unrecognized stays undefined so clients render it opaque. */
1014
+ function contextFormOf(source) {
1015
+ if (typeof source.form !== "string" || source.form.length === 0) return void 0;
1016
+ return [
1017
+ "instructions",
1018
+ "catalog",
1019
+ "snapshot",
1020
+ "notice",
1021
+ "relay",
1022
+ "recall"
1023
+ ].includes(source.form) ? source.form : void 0;
1024
+ }
1025
+ /** Optional `context` metadata for one system row; {} on user rows. */
1026
+ function contextProjectionOf(data) {
1027
+ if (userRoleOf(data) !== "system") return {};
1028
+ const source = userMessageSource(data);
1029
+ if (!source) return {};
1030
+ const label = contextLabelOf(source);
1031
+ const form = contextFormOf(source);
1032
+ if (!label && !form) return {};
1033
+ return { context: {
1034
+ ...label ? { label } : {},
1035
+ ...form ? { form } : {}
1036
+ } };
1037
+ }
1038
+ /** Extract plain text from user/assistant message payloads across shapes. */
1039
+ function messageText(data) {
1040
+ if (typeof data === "string") return data;
1041
+ if (!data || typeof data !== "object") return "";
1042
+ const obj = data;
1043
+ if (typeof obj.text === "string") return obj.text;
1044
+ if (obj.message && typeof obj.message === "object") return messageText(obj.message);
1045
+ return contentText(obj.content);
1046
+ }
1047
+ function contentText(content) {
1048
+ if (typeof content === "string") return content;
1049
+ if (Array.isArray(content)) return content.map((part) => {
1050
+ if (typeof part === "string") return part;
1051
+ if (part && typeof part === "object") {
1052
+ const piece = part;
1053
+ if (piece.type === "text" && typeof piece.text === "string") return piece.text;
1054
+ }
1055
+ return "";
1056
+ }).join("");
1057
+ return "";
1058
+ }
1059
+ function messageAttachments(data) {
1060
+ if (!data || typeof data !== "object") return [];
1061
+ const obj = data;
1062
+ if (obj.message && typeof obj.message === "object") return messageAttachments(obj.message);
1063
+ if (!Array.isArray(obj.content)) return [];
1064
+ return obj.content.flatMap((part) => {
1065
+ if (!part || typeof part !== "object") return [];
1066
+ const block = part;
1067
+ if (block.type !== "image" || !block.attachment) return [];
1068
+ const attachmentId = typeof block.attachment.attachmentId === "string" && block.attachment.attachmentId.length > 0 ? block.attachment.attachmentId : void 0;
1069
+ const width = typeof block.attachment.width === "number" && Number.isFinite(block.attachment.width) ? block.attachment.width : void 0;
1070
+ const height = typeof block.attachment.height === "number" && Number.isFinite(block.attachment.height) ? block.attachment.height : void 0;
1071
+ return [{
1072
+ kind: "image",
1073
+ ...typeof block.attachment.name === "string" ? { name: block.attachment.name } : {},
1074
+ ...typeof block.attachment.mediaType === "string" ? { mediaType: block.attachment.mediaType } : {},
1075
+ ...attachmentId ? { attachmentId } : {},
1076
+ ...width !== void 0 ? { width } : {},
1077
+ ...height !== void 0 ? { height } : {}
1078
+ }];
1079
+ });
1080
+ }
1081
+ function attachmentProjection(data) {
1082
+ const attachments = messageAttachments(data);
1083
+ return attachments.length > 0 ? { attachments } : {};
1084
+ }
1085
+ /** Extract reasoning ("thinking") text from assistant message payloads. */
1086
+ function messageThinking(data) {
1087
+ if (!data || typeof data !== "object") return "";
1088
+ const obj = data;
1089
+ if (obj.message && typeof obj.message === "object") return messageThinking(obj.message);
1090
+ return reasoningContent(obj.content);
1091
+ }
1092
+ function reasoningContent(content) {
1093
+ if (!Array.isArray(content)) return "";
1094
+ return content.map((part) => {
1095
+ if (part && typeof part === "object") {
1096
+ const piece = part;
1097
+ if (piece.type === "reasoning" && typeof piece.text === "string") return piece.text;
1098
+ }
1099
+ return "";
1100
+ }).join("");
1101
+ }
1102
+ /** Stream chunk type of an assistant/chunk payload ('' when unwrapped). */
1103
+ function chunkTypeOf(data) {
1104
+ if (!data || typeof data !== "object") return "";
1105
+ const obj = data;
1106
+ if (obj.chunk && typeof obj.chunk === "object") return String(obj.chunk.type ?? "");
1107
+ return "text-delta";
1108
+ }
1109
+ function chunkText(data) {
1110
+ if (!data || typeof data !== "object") return "";
1111
+ const obj = data;
1112
+ if (obj.chunk && typeof obj.chunk === "object") {
1113
+ const inner = obj.chunk;
1114
+ if ((inner.type === "text-delta" || inner.type === "reasoning-delta") && typeof inner.text === "string") return inner.text;
1115
+ return "";
1116
+ }
1117
+ const direct = data;
1118
+ return typeof direct.text === "string" ? direct.text : "";
1119
+ }
1120
+ function summarizeArgs(raw) {
1121
+ if (typeof raw !== "string" || raw.length === 0) return "";
1122
+ try {
1123
+ const parsed = JSON.parse(raw);
1124
+ const parts = [];
1125
+ for (const [key, value] of Object.entries(parsed)) if (typeof value === "string") parts.push(key + "=" + truncate(value.replace(/\s+/g, " "), 60));
1126
+ return truncate(parts.join(" "), 90);
1127
+ } catch {
1128
+ return truncate(raw, 90);
1129
+ }
1130
+ }
1131
+ function truncate(text, max) {
1132
+ return text.length <= max ? text : text.slice(0, max - 1) + "…";
1133
+ }
1134
+ function projectHistory(events) {
1135
+ const messages = [];
1136
+ const toolByCall = /* @__PURE__ */ new Map();
1137
+ for (const entry of events) {
1138
+ const event = entry.event;
1139
+ const base = {
1140
+ seq: event.seq,
1141
+ ts: tsOf(event)
1142
+ };
1143
+ switch (event.type) {
1144
+ case "user/message":
1145
+ messages.push({
1146
+ ...base,
1147
+ role: userRoleOf(event.data),
1148
+ text: messageText(event.data),
1149
+ ...attachmentProjection(event.data),
1150
+ ...contextProjectionOf(event.data)
1151
+ });
1152
+ break;
1153
+ case "assistant/message": {
1154
+ const text = messageText(event.data);
1155
+ const thinking = messageThinking(event.data);
1156
+ if (!text.trim() && !thinking.trim()) break;
1157
+ messages.push({
1158
+ ...base,
1159
+ role: "assistant",
1160
+ text,
1161
+ ...thinking ? { thinking } : {}
1162
+ });
1163
+ break;
1164
+ }
1165
+ case "tool/call": {
1166
+ const data = event.data;
1167
+ const row = {
1168
+ ...base,
1169
+ role: "tool",
1170
+ tool: {
1171
+ name: String(data?.name ?? "tool"),
1172
+ state: "running",
1173
+ summary: summarizeArgs(data?.arguments)
1174
+ }
1175
+ };
1176
+ messages.push(row);
1177
+ if (data?.callId) toolByCall.set(String(data.callId), row);
1178
+ break;
1179
+ }
1180
+ case "tool/result": {
1181
+ const data = event.data;
1182
+ const callId = data?.callId ? String(data.callId) : void 0;
1183
+ const target = callId ? toolByCall.get(callId) : void 0;
1184
+ const failed = data?.error !== void 0;
1185
+ const summary = failed ? "失败" : summarizeResult(data?.message?.content);
1186
+ if (target?.tool) target.tool = {
1187
+ ...target.tool,
1188
+ state: failed ? "error" : "ok",
1189
+ summary
1190
+ };
1191
+ else messages.push({
1192
+ ...base,
1193
+ role: "tool",
1194
+ tool: {
1195
+ name: "result",
1196
+ state: failed ? "error" : "ok",
1197
+ summary
1198
+ }
1199
+ });
1200
+ break;
1201
+ }
1202
+ }
1203
+ }
1204
+ return canonicalSessionMessages(messages.map(limitMessageProjection));
1205
+ }
1206
+ /**
1207
+ * Enforce PROTOCOL.md's per-message 256 KB ceiling by UTF-8 JSON byte size.
1208
+ * Keep structural identity and attachment references intact; progressively
1209
+ * shorten human-readable fields until the serialized projection fits.
1210
+ */
1211
+ function limitMessageProjection(message) {
1212
+ if (jsonBytes(message) <= 262144) return message;
1213
+ const next = {
1214
+ ...message,
1215
+ ...message.tool ? { tool: {
1216
+ ...message.tool,
1217
+ name: truncateUtf8(message.tool.name, 4096),
1218
+ summary: truncateUtf8(message.tool.summary, 65536)
1219
+ } } : {},
1220
+ ...message.attachments ? { attachments: message.attachments.slice(0, 16).map((attachment) => ({
1221
+ ...attachment,
1222
+ ...attachment.name ? { name: truncateUtf8(attachment.name, 4096) } : {},
1223
+ ...attachment.mediaType ? { mediaType: truncateUtf8(attachment.mediaType, 256) } : {},
1224
+ ...attachment.attachmentId ? { attachmentId: truncateUtf8(attachment.attachmentId, 4096) } : {}
1225
+ })) } : {},
1226
+ ...message.context ? { context: {
1227
+ ...message.context.label ? { label: truncateUtf8(message.context.label, 8192) } : {},
1228
+ ...message.context.form ? { form: truncateUtf8(message.context.form, 256) } : {}
1229
+ } } : {},
1230
+ truncated: true
1231
+ };
1232
+ const textFields = [];
1233
+ if (typeof next.text === "string") textFields.push({
1234
+ get: () => next.text ?? "",
1235
+ set: (value) => {
1236
+ next.text = value;
1237
+ }
1238
+ });
1239
+ if (typeof next.thinking === "string") textFields.push({
1240
+ get: () => next.thinking ?? "",
1241
+ set: (value) => {
1242
+ next.thinking = value;
1243
+ }
1244
+ });
1245
+ if (next.tool) textFields.push({
1246
+ get: () => next.tool?.summary ?? "",
1247
+ set: (value) => {
1248
+ if (next.tool) next.tool.summary = value;
1249
+ }
1250
+ });
1251
+ if (next.context?.label) textFields.push({
1252
+ get: () => next.context?.label ?? "",
1253
+ set: (value) => {
1254
+ if (next.context) next.context.label = value;
1255
+ }
1256
+ });
1257
+ while (jsonBytes(next) > MAX_MESSAGE_PROJECTION_BYTES) {
1258
+ const largest = textFields.map((field) => ({
1259
+ field,
1260
+ bytes: Buffer.byteLength(field.get(), "utf8")
1261
+ })).sort((a, b) => b.bytes - a.bytes)[0];
1262
+ if (largest && largest.bytes > 0) {
1263
+ largest.field.set(truncateUtf8(largest.field.get(), Math.floor(largest.bytes / 2)));
1264
+ continue;
1265
+ }
1266
+ if (next.attachments && next.attachments.length > 0) {
1267
+ next.attachments = next.attachments.slice(0, -1);
1268
+ continue;
1269
+ }
1270
+ break;
1271
+ }
1272
+ return next;
1273
+ }
1274
+ function limitRealtimeText(data) {
1275
+ if (jsonBytes(data) <= 262144) return data;
1276
+ let text = data.text;
1277
+ const next = {
1278
+ ...data,
1279
+ truncated: true
1280
+ };
1281
+ while (jsonBytes(next) > 262144 && text.length > 0) {
1282
+ text = truncateUtf8(text, Math.floor(Buffer.byteLength(text, "utf8") / 2));
1283
+ next.text = text;
1284
+ }
1285
+ return next;
1286
+ }
1287
+ function jsonBytes(value) {
1288
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
1289
+ }
1290
+ function truncateUtf8(value, maxBytes) {
1291
+ if (maxBytes <= 0) return "";
1292
+ if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
1293
+ let low = 0;
1294
+ let high = value.length;
1295
+ while (low < high) {
1296
+ const mid = Math.ceil((low + high) / 2);
1297
+ const candidate = value.slice(0, mid);
1298
+ if (Buffer.byteLength(candidate, "utf8") <= maxBytes) low = mid;
1299
+ else high = mid - 1;
1300
+ }
1301
+ let end = low;
1302
+ if (end > 0 && /[\uD800-\uDBFF]/.test(value[end - 1])) end -= 1;
1303
+ return value.slice(0, end);
1304
+ }
1305
+ function summarizeResult(content) {
1306
+ return truncate(contentText(content).replace(/\s+/g, " ").trim(), 90);
1307
+ }
1308
+ //#endregion
1309
+ //#region src/host-bridge.ts
1310
+ const MAX_RING_DEFAULT = 2e3;
1311
+ /**
1312
+ * Process-wide bridge state: session mirror, pending approvals/questions,
1313
+ * and the per-device replay ring. Consumes the in-process mux/host streams
1314
+ * and fans projected pushes out to every registered sink.
1315
+ */
1316
+ let BRIDGE_SEQ = 0;
1317
+ var HostBridge = class {
1318
+ apiProxy;
1319
+ historyBufferMax;
1320
+ id = ++BRIDGE_SEQ;
1321
+ summaries = /* @__PURE__ */ new Map();
1322
+ approvals = /* @__PURE__ */ new Map();
1323
+ questions = /* @__PURE__ */ new Map();
1324
+ archivedSessionIds = /* @__PURE__ */ new Set();
1325
+ subagentSessionIds = /* @__PURE__ */ new Set();
1326
+ sinks = /* @__PURE__ */ new Set();
1327
+ ring = [];
1328
+ cursor = 0;
1329
+ userReceiptSeq = 0;
1330
+ abort = new AbortController();
1331
+ started = false;
1332
+ disposed = false;
1333
+ constructor(apiProxy, historyBufferMax = MAX_RING_DEFAULT) {
1334
+ this.apiProxy = apiProxy;
1335
+ this.historyBufferMax = historyBufferMax;
1336
+ }
1337
+ pushOutlet;
1338
+ /**
1339
+ * Wire the offline-push fan-out. Present ⇒ welcome advertises the `push`
1340
+ * capability and notify-worthy events are mirrored to APNs.
1341
+ */
1342
+ setPushOutlet(outlet) {
1343
+ this.pushOutlet = outlet;
1344
+ }
1345
+ get capabilities() {
1346
+ return {
1347
+ historyPaging: true,
1348
+ replay: true,
1349
+ approvals: true,
1350
+ questions: true,
1351
+ pendingSnapshot: true,
1352
+ notifyAllCategories: true,
1353
+ models: typeof this.apiProxy.sessions.models === "function" && typeof this.apiProxy.sessions.selectModel === "function",
1354
+ sessionManagement: typeof this.apiProxy.sessions.rename === "function" && typeof this.apiProxy.workspace?.archiveSession === "function",
1355
+ projectSelection: typeof this.apiProxy.workspace?.list === "function" && typeof this.apiProxy.workspace?.create === "function",
1356
+ push: this.pushOutlet?.isAvailable() === true
1357
+ };
1358
+ }
1359
+ diagnostic(message) {
1360
+ console.log("[deeppilot] " + message);
1361
+ }
1362
+ currentCursor() {
1363
+ return this.cursor;
1364
+ }
1365
+ addSink(sink) {
1366
+ this.sinks.add(sink);
1367
+ }
1368
+ removeSink(sink) {
1369
+ this.sinks.delete(sink);
1370
+ }
1371
+ /** Whether the ring still holds everything after the cursor. */
1372
+ canResumeFrom(cursor) {
1373
+ const oldest = this.ring.length > 0 ? this.ring[0].seq : this.cursor + 1;
1374
+ return cursor <= this.cursor && cursor + 1 >= oldest;
1375
+ }
1376
+ sinkSessions = /* @__PURE__ */ new Map();
1377
+ lastAssistantText = /* @__PURE__ */ new Map();
1378
+ /** Mark a sink as actively viewing a session (suppresses its turn notifications). */
1379
+ markSinkOpen(sink, sessionId) {
1380
+ let set = this.sinkSessions.get(sink);
1381
+ if (!set) {
1382
+ set = /* @__PURE__ */ new Set();
1383
+ this.sinkSessions.set(sink, set);
1384
+ }
1385
+ set.add(sessionId);
1386
+ }
1387
+ markSinkClosed(sink, sessionId) {
1388
+ this.sinkSessions.get(sink)?.delete(sessionId);
1389
+ }
1390
+ dropSinkSessions(sink) {
1391
+ this.sinkSessions.delete(sink);
1392
+ }
1393
+ isViewedBy(sink, sessionId) {
1394
+ return this.sinkSessions.get(sink)?.has(sessionId) ?? false;
1395
+ }
1396
+ /** F-9: when a notification-worthy event fires, mirror it to every
1397
+ * online device that is not currently viewing the session (the s2c.notify
1398
+ * frame counts toward the seq cursor and joins the replay ring per
1399
+ * PROTOCOL §6 + §7), then fan the same payload out to offline devices
1400
+ * holding an APNs token. */
1401
+ emitNotify(args) {
1402
+ if (this.subagentSessionIds.has(args.sessionId)) return;
1403
+ const body = args.body.length > 120 ? args.body.slice(0, 119) + "…" : args.body;
1404
+ this.record("s2c.notify", {
1405
+ notificationId: args.notificationId,
1406
+ category: args.category,
1407
+ sessionId: args.sessionId,
1408
+ title: args.title,
1409
+ body,
1410
+ ts: Date.now()
1411
+ }, (sink) => this.isViewedBy(sink, args.sessionId));
1412
+ this.fanOutPush({
1413
+ notificationId: args.notificationId,
1414
+ category: args.category,
1415
+ sessionId: args.sessionId,
1416
+ title: args.title,
1417
+ body
1418
+ });
1419
+ }
753
1420
  /** F-9: when a turn completes, notify every device not viewing the session. */
754
1421
  emitTurnCompletedNotify(sessionId, ok) {
755
1422
  if (this.subagentSessionIds.has(sessionId)) return;
756
1423
  const row = this.summaries.get(sessionId);
757
1424
  const title = ok ? "任务完成" : "任务异常结束";
758
1425
  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,
1426
+ this.emitNotify({
765
1427
  sessionId,
1428
+ category: ok ? "turn.completed" : "session.error",
766
1429
  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
1430
+ body,
1431
+ notificationId: "n-" + (this.cursor + 1)
776
1432
  });
777
1433
  }
778
1434
  /**
@@ -804,6 +1460,7 @@ var HostBridge = class {
804
1460
  return true;
805
1461
  }
806
1462
  record(type, payload, except) {
1463
+ if (this.disposed) return;
807
1464
  this.cursor += 1;
808
1465
  const entry = {
809
1466
  seq: this.cursor,
@@ -819,13 +1476,19 @@ var HostBridge = class {
819
1476
  }
820
1477
  /** Start consuming host + mux streams. Idempotent; aborts on dispose(). */
821
1478
  start() {
1479
+ if (this.started || this.disposed) return;
1480
+ this.started = true;
822
1481
  this.runHostStream();
823
1482
  this.runMuxStream();
824
1483
  this.refreshSummaries();
825
1484
  }
826
1485
  dispose() {
1486
+ if (this.disposed) return;
1487
+ this.disposed = true;
827
1488
  this.abort.abort();
828
1489
  this.sinks.clear();
1490
+ this.sinkSessions.clear();
1491
+ this.pushOutlet = void 0;
829
1492
  }
830
1493
  async runHostStream() {
831
1494
  try {
@@ -898,27 +1561,28 @@ var HostBridge = class {
898
1561
  if (!p.approvalId || !frame.rpcId) break;
899
1562
  const toolName = String(p.toolName ?? "tool");
900
1563
  const summary = String(p.reason ?? "");
1564
+ const sessionId = String(p.sessionId ?? "");
901
1565
  this.approvals.set(p.approvalId, {
902
1566
  rpcId: frame.rpcId,
903
- sessionId: String(p.sessionId ?? ""),
1567
+ sessionId,
904
1568
  toolName,
905
1569
  reason: summary
906
1570
  });
907
1571
  this.record("s2c.pending.approval", {
908
1572
  requestId: p.approvalId,
909
- sessionId: String(p.sessionId ?? ""),
1573
+ sessionId,
910
1574
  toolName,
911
1575
  summary,
912
1576
  riskLevel: riskOf(toolName)
913
1577
  });
914
- this.fanOutPush({
915
- notificationId: "apr-" + p.approvalId,
1578
+ this.emitNotify({
1579
+ sessionId,
916
1580
  category: "approval.required",
917
- sessionId: String(p.sessionId ?? ""),
918
1581
  title: "需要批准",
919
- body: toolName + ": " + summary
1582
+ body: toolName + ": " + summary,
1583
+ notificationId: "apr-" + p.approvalId
920
1584
  });
921
- this.bumpPendingFlags(String(p.sessionId ?? ""));
1585
+ this.bumpPendingFlags(sessionId);
922
1586
  break;
923
1587
  }
924
1588
  case "approval/resolved": {
@@ -945,12 +1609,12 @@ var HostBridge = class {
945
1609
  sessionId,
946
1610
  questions: p?.questions ?? []
947
1611
  });
948
- this.fanOutPush({
949
- notificationId: requestId,
950
- category: "question.asked",
1612
+ this.emitNotify({
951
1613
  sessionId,
1614
+ category: "question.asked",
952
1615
  title: "有问题需要回答",
953
- body: firstQuestionText(p?.questions)
1616
+ body: firstQuestionText(p?.questions),
1617
+ notificationId: requestId
954
1618
  });
955
1619
  this.bumpPendingFlags(sessionId);
956
1620
  break;
@@ -1005,7 +1669,7 @@ var HostBridge = class {
1005
1669
  this.subagentSessionIds = subagentIds;
1006
1670
  this.summaries = next;
1007
1671
  const removedIds = [...previousIds].filter((id) => !next.has(id));
1008
- for (const id of removedIds) this.lastAssistantText.delete(id);
1672
+ for (const id of this.lastAssistantText.keys()) if (!next.has(id)) this.lastAssistantText.delete(id);
1009
1673
  this.record("s2c.sessions.delta", {
1010
1674
  upserted: [...next.values()],
1011
1675
  removedIds
@@ -1103,13 +1767,14 @@ var HostBridge = class {
1103
1767
  });
1104
1768
  if (!response.result || !response.result.ok) return false;
1105
1769
  const result = response.result.value;
1106
- const messages = projectHistory(result.events ?? []);
1770
+ const page = limitSessionPageMessages(projectHistory(result.events ?? []));
1771
+ const messages = page.messages;
1107
1772
  const oldestSeq = messages.length > 0 ? messages[0].seq : 0;
1108
1773
  sink.push("s2c.session.tail", {
1109
1774
  sessionId,
1110
1775
  messages,
1111
1776
  oldestSeq,
1112
- hasMore: Boolean(result.hasMore)
1777
+ hasMore: Boolean(result.hasMore) || page.dropped > 0
1113
1778
  });
1114
1779
  this.deriveTitleFallback(sessionId, messages);
1115
1780
  return true;
@@ -1117,7 +1782,7 @@ var HostBridge = class {
1117
1782
  return false;
1118
1783
  }
1119
1784
  }
1120
- async historyPage(sink, sessionId, beforeSeq, limit) {
1785
+ async historyPage(sessionId, beforeSeq, limit) {
1121
1786
  try {
1122
1787
  const response = await this.apiProxy.sessions.history({
1123
1788
  rpcId: randomUUID(),
@@ -1127,17 +1792,16 @@ var HostBridge = class {
1127
1792
  maxMessages: clampTail(limit)
1128
1793
  }
1129
1794
  });
1130
- if (!response.result || !response.result.ok) return false;
1795
+ if (!response.result || !response.result.ok) return null;
1131
1796
  const result = response.result.value;
1132
- const messages = projectHistory(result.events ?? []);
1133
- sink.push("s2c.history.page", {
1797
+ const page = limitSessionPageMessages(projectHistory(result.events ?? []).filter((message) => message.seq < beforeSeq));
1798
+ return {
1134
1799
  sessionId,
1135
- messages,
1136
- hasMore: Boolean(result.hasMore)
1137
- });
1138
- return true;
1800
+ messages: page.messages,
1801
+ hasMore: page.messages.length > 0 && (Boolean(result.hasMore) || page.dropped > 0)
1802
+ };
1139
1803
  } catch {
1140
- return false;
1804
+ return null;
1141
1805
  }
1142
1806
  }
1143
1807
  /** Result of one attachment read-back for the phone. */
@@ -1501,466 +2165,217 @@ var HostBridge = class {
1501
2165
  kind: "internal",
1502
2166
  message: "prompt returned no result"
1503
2167
  };
1504
- if (!response.result.ok) return hostSessionManagementError(response.result.error);
1505
- const row = this.summaries.get(sessionId);
1506
- if (row) {
1507
- row.lastActivityTs = Date.now();
1508
- this.pushSummary(row);
1509
- }
1510
- return {
1511
- ok: true,
1512
- value: Date.now()
1513
- };
1514
- } catch (error) {
1515
- return {
1516
- ok: false,
1517
- kind: "internal",
1518
- message: String(error)
1519
- };
1520
- }
1521
- }
1522
- async respondApproval(requestId, decision, reason) {
1523
- const pending = this.approvals.get(requestId);
1524
- if (!pending) return {
1525
- ok: false,
1526
- reason: "not-pending"
1527
- };
1528
- this.approvals.delete(requestId);
1529
- const outcome = decision === "allow" ? "allowed-once" : "rejected";
1530
- const denialReason = typeof reason === "string" ? reason.trim().slice(0, 500) : "";
1531
- try {
1532
- const receipt = await this.apiProxy.respond({
1533
- type: "client-response",
1534
- rpcId: pending.rpcId,
1535
- result: {
1536
- ok: true,
1537
- value: {
1538
- sessionId: pending.sessionId,
1539
- approvalId: requestId,
1540
- outcome,
1541
- ...denialReason.length > 0 ? { reason: denialReason } : {}
1542
- }
1543
- }
1544
- });
1545
- if (!Boolean(receipt?.accepted)) {
1546
- const failure = receiptFailureReason(receipt);
1547
- if (failure !== "not-pending" && !this.approvals.has(requestId)) this.approvals.set(requestId, pending);
1548
- return {
1549
- ok: false,
1550
- reason: failure
1551
- };
1552
- }
1553
- this.bumpPendingFlags(pending.sessionId);
1554
- return { ok: true };
1555
- } catch {
1556
- if (!this.approvals.has(requestId)) this.approvals.set(requestId, pending);
1557
- return {
1558
- ok: false,
1559
- reason: "transport"
1560
- };
1561
- }
1562
- }
1563
- async respondQuestion(requestId, answers) {
1564
- const pending = this.questions.get(requestId);
1565
- if (!pending) return {
1566
- ok: false,
1567
- reason: "not-pending"
1568
- };
1569
- this.questions.delete(requestId);
1570
- try {
1571
- const receipt = await this.apiProxy.respond({
1572
- type: "client-response",
1573
- rpcId: pending.rpcId,
1574
- result: {
1575
- ok: true,
1576
- value: {
1577
- sessionId: pending.sessionId,
1578
- answer: { answers: normalizeAnswerItems(answers, pending.questions) }
1579
- }
1580
- }
1581
- });
1582
- if (!Boolean(receipt?.accepted)) {
1583
- const failure = receiptFailureReason(receipt);
1584
- if (failure !== "not-pending" && !this.questions.has(requestId)) this.questions.set(requestId, pending);
1585
- return {
1586
- ok: false,
1587
- reason: failure
1588
- };
1589
- }
1590
- this.bumpPendingFlags(pending.sessionId);
1591
- return { ok: true };
1592
- } catch {
1593
- if (!this.questions.has(requestId)) this.questions.set(requestId, pending);
1594
- return {
1595
- ok: false,
1596
- reason: "transport"
1597
- };
1598
- }
1599
- }
1600
- };
1601
- /**
1602
- * The host validates question answers strictly (core dsh-user-questions via
1603
- * apiProxy): a present-but-empty `custom` fails `matchesQuestions`, and a
1604
- * single-select question rejects `custom` combined with a selection. Clients
1605
- * may send lenient shapes (the phone historically always attached
1606
- * `"custom": ""`, which made EVERY option-only answer fail), so normalize to
1607
- * exactly what the host accepts before forwarding.
1608
- */
1609
- function normalizeAnswerItems(raw, questions) {
1610
- if (!Array.isArray(raw)) return [];
1611
- const askedById = /* @__PURE__ */ new Map();
1612
- if (Array.isArray(questions)) {
1613
- for (const q of questions) if (typeof q === "object" && q !== null && typeof q.id === "string") askedById.set(q.id, q);
1614
- }
1615
- const items = [];
1616
- for (const entry of raw) {
1617
- if (typeof entry !== "object" || entry === null) continue;
1618
- const r = entry;
1619
- if (typeof r.id !== "string") continue;
1620
- const selected = [...new Set(Array.isArray(r.selected) ? r.selected.filter((s) => typeof s === "string") : [])];
1621
- const customText = typeof r.custom === "string" ? r.custom : "";
1622
- let custom;
1623
- if (customText.trim().length > 0) custom = customText;
1624
- if (custom !== void 0 && selected.length > 0 && askedById.get(r.id)?.multiSelect !== true) custom = void 0;
1625
- items.push({
1626
- id: r.id,
1627
- selected,
1628
- ...custom !== void 0 ? { custom } : {}
1629
- });
1630
- }
1631
- return items;
1632
- }
1633
- /** Map an apiProxy respond receipt onto the failure vocabulary. */
1634
- function receiptFailureReason(receipt) {
1635
- return receipt?.reason === "not-pending" ? "not-pending" : "bad-response";
1636
- }
1637
- function clampTail(n) {
1638
- if (!Number.isFinite(n)) return 100;
1639
- return Math.max(10, Math.min(500, Math.floor(n)));
1640
- }
1641
- function localTimeZone() {
1642
- try {
1643
- return new Intl.DateTimeFormat().resolvedOptions().timeZone || void 0;
1644
- } catch {
1645
- return;
1646
- }
1647
- }
1648
- function riskOf(toolName) {
1649
- if (/bash|pwsh|terminal/.test(toolName)) return "write";
1650
- if (/edit|write|str_replace|create/.test(toolName)) return "write";
1651
- if (/delete|remove|kill/.test(toolName)) return "destructive";
1652
- return "read";
1653
- }
1654
- /** First question's text for the push banner; the questions payload shape is
1655
- * host-version dependent, so extract defensively. */
1656
- function firstQuestionText(questions) {
1657
- if (!Array.isArray(questions) || questions.length === 0) return "Agent 等待你的输入";
1658
- const first = questions[0];
1659
- return String(first?.question ?? "").trim() || "Agent 等待你的输入";
1660
- }
1661
- const TODO_STATUSES = /* @__PURE__ */ new Set([
1662
- "pending",
1663
- "in_progress",
1664
- "completed"
1665
- ]);
1666
- /** Validate a host todo projection once; progress counts and the full
1667
- * checklist both derive from this sanitized list so they never disagree. */
1668
- function sanitizeTodoItems(items) {
1669
- if (!items) return [];
1670
- return items.map((i) => ({
1671
- content: String(i.content ?? "").trim(),
1672
- status: String(i.status ?? "")
1673
- })).filter((i) => i.content.length > 0 && TODO_STATUSES.has(i.status)).slice(0, 100).map((i) => ({
1674
- content: i.content,
1675
- status: i.status
1676
- }));
1677
- }
1678
- function toSummary(row, approvals, questions, workspace) {
1679
- const values = row.projections?.values ?? {};
1680
- const todos = Array.isArray(values.todos) ? values.todos : null;
1681
- let pendingApproval = false;
1682
- for (const pending of approvals.values()) if (pending.sessionId === row.sessionId) pendingApproval = true;
1683
- let pendingQuestion = false;
1684
- for (const pending of questions.values()) if (pending.sessionId === row.sessionId) pendingQuestion = true;
1685
- const cwd = typeof row.cwd === "string" ? row.cwd : "";
1686
- const label = workspace?.title ?? (cwd ? cwd.split("/").filter(Boolean).pop() : void 0);
1687
- const todoItems = sanitizeTodoItems(todos);
1688
- return {
1689
- id: row.sessionId,
1690
- title: typeof values.title === "string" ? values.title : "",
1691
- status: row.running ? "running" : row.blank ? "unknown" : "idle",
1692
- lastActivityTs: Number(row.updatedAt ?? Date.now()),
1693
- todos: todoItems.length > 0 ? {
1694
- done: todoItems.filter((i) => i.status === "completed").length,
1695
- total: todoItems.length
1696
- } : null,
1697
- todoItems: todoItems.length > 0 ? todoItems : null,
1698
- pendingApproval,
1699
- pendingQuestion,
1700
- workspaceLabel: label ?? null,
1701
- workspaceId: workspace?.workspaceId ?? null,
1702
- workspacePath: workspace?.path ?? (cwd || null)
1703
- };
1704
- }
1705
- function projectWorkspace(workspace) {
1706
- return {
1707
- id: String(workspace.workspaceId),
1708
- title: String(workspace.title),
1709
- path: String(workspace.path),
1710
- sessionIds: (workspace.sessionIds ?? []).map(String)
1711
- };
1712
- }
1713
- /** Project one raw session event into a protocol push, when it maps to one. */
1714
- function projectEvent(sessionId, event) {
1715
- switch (event.type) {
1716
- case "turn/start": return {
1717
- kind: "turn.start",
1718
- data: {}
1719
- };
1720
- case "turn/end": return {
1721
- kind: "turn.end",
1722
- data: { ok: event.data?.reason?.kind === "completed" }
1723
- };
1724
- case "user/message": return {
1725
- kind: "message.final",
1726
- data: {
1727
- seq: event.seq,
1728
- role: userRoleOf(event.data),
1729
- text: messageText(event.data),
1730
- ...attachmentProjection(event.data),
1731
- ...contextProjectionOf(event.data),
1732
- ts: tsOf(event)
1733
- }
1734
- };
1735
- case "assistant/chunk":
1736
- if (chunkTypeOf(event.data) === "reasoning-delta") return {
1737
- kind: "thinking.delta",
1738
- data: {
1739
- text: chunkText(event.data),
1740
- ts: tsOf(event)
1741
- }
1742
- };
1743
- return {
1744
- kind: "message.delta",
1745
- data: {
1746
- text: chunkText(event.data),
1747
- ts: tsOf(event)
1748
- }
1749
- };
1750
- case "assistant/message": {
1751
- const text = messageText(event.data);
1752
- const thinking = messageThinking(event.data);
1753
- if (!text.trim() && !thinking.trim()) return null;
2168
+ if (!response.result.ok) return hostSessionManagementError(response.result.error);
2169
+ const row = this.summaries.get(sessionId);
2170
+ if (row) {
2171
+ row.lastActivityTs = Date.now();
2172
+ this.pushSummary(row);
2173
+ }
2174
+ this.userReceiptSeq += 1;
1754
2175
  return {
1755
- kind: "message.final",
1756
- data: {
1757
- seq: event.seq,
1758
- role: "assistant",
1759
- text,
1760
- ...thinking ? { thinking } : {},
1761
- ts: tsOf(event)
1762
- }
2176
+ ok: true,
2177
+ value: this.userReceiptSeq
1763
2178
  };
1764
- }
1765
- case "tool/call": {
1766
- const data = event.data;
2179
+ } catch (error) {
1767
2180
  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)
1779
- }
2181
+ ok: false,
2182
+ kind: "internal",
2183
+ message: String(error)
1780
2184
  };
1781
2185
  }
1782
- case "tool/result": {
1783
- const data = event.data;
1784
- return {
1785
- kind: "tool.end",
1786
- data: {
1787
- seq: event.seq,
1788
- role: "tool",
1789
- ok: !event.data || data?.error === void 0,
1790
- ...data?.callId ? { callId: String(data.callId) } : {},
1791
- ts: tsOf(event)
2186
+ }
2187
+ async respondApproval(requestId, decision, reason) {
2188
+ const pending = this.approvals.get(requestId);
2189
+ if (!pending) return {
2190
+ ok: false,
2191
+ reason: "not-pending"
2192
+ };
2193
+ this.approvals.delete(requestId);
2194
+ const outcome = decision === "allow" ? "allowed-once" : "rejected";
2195
+ const denialReason = typeof reason === "string" ? reason.trim().slice(0, 500) : "";
2196
+ try {
2197
+ const receipt = await this.apiProxy.respond({
2198
+ type: "client-response",
2199
+ rpcId: pending.rpcId,
2200
+ result: {
2201
+ ok: true,
2202
+ value: {
2203
+ sessionId: pending.sessionId,
2204
+ approvalId: requestId,
2205
+ outcome,
2206
+ ...denialReason.length > 0 ? { reason: denialReason } : {}
2207
+ }
1792
2208
  }
2209
+ });
2210
+ if (!Boolean(receipt?.accepted)) {
2211
+ const failure = receiptFailureReason(receipt);
2212
+ if (failure !== "not-pending" && !this.approvals.has(requestId)) this.approvals.set(requestId, pending);
2213
+ return {
2214
+ ok: false,
2215
+ reason: failure
2216
+ };
2217
+ }
2218
+ this.bumpPendingFlags(pending.sessionId);
2219
+ return { ok: true };
2220
+ } catch {
2221
+ if (!this.approvals.has(requestId)) this.approvals.set(requestId, pending);
2222
+ return {
2223
+ ok: false,
2224
+ reason: "transport"
1793
2225
  };
1794
2226
  }
1795
- default: return null;
1796
- }
1797
- }
1798
- function tsOf(event) {
1799
- return typeof event.time === "number" ? event.time : Date.now();
1800
- }
1801
- /** Read the durable message source off one user/message payload. Handles both
1802
- * bare-message payloads and older `{message: {...}}` wrappers; undefined when
1803
- * the shape carries no readable source (legacy hosts). */
1804
- function userMessageSource(data) {
1805
- if (!data || typeof data !== "object") return void 0;
1806
- const obj = data;
1807
- if (obj.source && typeof obj.source === "object") return obj.source;
1808
- if (obj.message && typeof obj.message === "object" && obj.message.source && typeof obj.message.source === "object") return obj.message.source;
1809
- }
1810
- /** Wire role for one user/message payload. A payload without any readable
1811
- * source degrades to 'user' so history written by older hosts stays visible;
1812
- * a present source follows the host's own trajectory rule — anything whose
1813
- * `kind` is not 'user' is injected context and projects as 'system'. */
1814
- function userRoleOf(data) {
1815
- const source = userMessageSource(data);
1816
- if (!source) return "user";
1817
- return source.kind === "user" ? "user" : "system";
1818
- }
1819
- /** Producer name of one injected-context source, mirroring how the DSH client
1820
- * runtime derives its trajectory label: plugin name, skill name, instruction
1821
- * paths, session-reference labels, or the raw kind as fallback. */
1822
- function contextLabelOf(source) {
1823
- const kind = typeof source.kind === "string" ? source.kind : "";
1824
- const joined = (member) => {
1825
- const list = source[member];
1826
- if (!Array.isArray(list)) return void 0;
1827
- const names = list.flatMap((entry) => {
1828
- if (!entry || typeof entry !== "object") return [];
1829
- const record = entry;
1830
- return [typeof record.label === "string" ? record.label : typeof record.path === "string" ? record.path : ""];
1831
- }).filter((name) => name.length > 0);
1832
- return names.length > 0 ? names.join(", ") : void 0;
1833
- };
1834
- switch (kind) {
1835
- case "session-reference": return joined("references") ?? (kind || void 0);
1836
- case "agent-instructions": return joined("changes") ?? (kind || void 0);
1837
- case "plugin": return typeof source.plugin === "string" && source.plugin.length > 0 ? source.plugin : kind || void 0;
1838
- case "skill-invocation": return typeof source.name === "string" && source.name.length > 0 ? source.name : kind || void 0;
1839
- default: return kind || void 0;
1840
2227
  }
2228
+ async respondQuestion(requestId, answers) {
2229
+ const pending = this.questions.get(requestId);
2230
+ if (!pending) return {
2231
+ ok: false,
2232
+ reason: "not-pending"
2233
+ };
2234
+ this.questions.delete(requestId);
2235
+ try {
2236
+ const receipt = await this.apiProxy.respond({
2237
+ type: "client-response",
2238
+ rpcId: pending.rpcId,
2239
+ result: {
2240
+ ok: true,
2241
+ value: {
2242
+ sessionId: pending.sessionId,
2243
+ answer: { answers: normalizeAnswerItems(answers, pending.questions) }
2244
+ }
2245
+ }
2246
+ });
2247
+ if (!Boolean(receipt?.accepted)) {
2248
+ const failure = receiptFailureReason(receipt);
2249
+ if (failure !== "not-pending" && !this.questions.has(requestId)) this.questions.set(requestId, pending);
2250
+ return {
2251
+ ok: false,
2252
+ reason: failure
2253
+ };
2254
+ }
2255
+ this.bumpPendingFlags(pending.sessionId);
2256
+ return { ok: true };
2257
+ } catch {
2258
+ if (!this.questions.has(requestId)) this.questions.set(requestId, pending);
2259
+ return {
2260
+ ok: false,
2261
+ reason: "transport"
2262
+ };
2263
+ }
2264
+ }
2265
+ };
2266
+ /**
2267
+ * The host validates question answers strictly (core dsh-user-questions via
2268
+ * apiProxy): a present-but-empty `custom` fails `matchesQuestions`, and a
2269
+ * single-select question rejects `custom` combined with a selection. Clients
2270
+ * may send lenient shapes (the phone historically always attached
2271
+ * `"custom": ""`, which made EVERY option-only answer fail), so normalize to
2272
+ * exactly what the host accepts before forwarding.
2273
+ */
2274
+ function normalizeAnswerItems(raw, questions) {
2275
+ if (!Array.isArray(raw)) return [];
2276
+ const askedById = /* @__PURE__ */ new Map();
2277
+ if (Array.isArray(questions)) {
2278
+ for (const q of questions) if (typeof q === "object" && q !== null && typeof q.id === "string") askedById.set(q.id, q);
2279
+ }
2280
+ const items = [];
2281
+ for (const entry of raw) {
2282
+ if (typeof entry !== "object" || entry === null) continue;
2283
+ const r = entry;
2284
+ if (typeof r.id !== "string") continue;
2285
+ const selected = [...new Set(Array.isArray(r.selected) ? r.selected.filter((s) => typeof s === "string") : [])];
2286
+ const customText = typeof r.custom === "string" ? r.custom : "";
2287
+ let custom;
2288
+ if (customText.trim().length > 0) custom = customText;
2289
+ if (custom !== void 0 && selected.length > 0 && askedById.get(r.id)?.multiSelect !== true) custom = void 0;
2290
+ items.push({
2291
+ id: r.id,
2292
+ selected,
2293
+ ...custom !== void 0 ? { custom } : {}
2294
+ });
2295
+ }
2296
+ return items;
1841
2297
  }
1842
- /** Semantic ContextForm declared by the producer ('snapshot', 'notice', …);
1843
- * anything unrecognized stays undefined so clients render it opaque. */
1844
- function contextFormOf(source) {
1845
- if (typeof source.form !== "string" || source.form.length === 0) return void 0;
1846
- return [
1847
- "instructions",
1848
- "catalog",
1849
- "snapshot",
1850
- "notice",
1851
- "relay",
1852
- "recall"
1853
- ].includes(source.form) ? source.form : void 0;
1854
- }
1855
- /** Optional `context` metadata for one system row; {} on user rows. */
1856
- function contextProjectionOf(data) {
1857
- if (userRoleOf(data) !== "system") return {};
1858
- const source = userMessageSource(data);
1859
- if (!source) return {};
1860
- const label = contextLabelOf(source);
1861
- const form = contextFormOf(source);
1862
- if (!label && !form) return {};
1863
- return { context: {
1864
- ...label ? { label } : {},
1865
- ...form ? { form } : {}
1866
- } };
1867
- }
1868
- /** Extract plain text from user/assistant message payloads across shapes. */
1869
- function messageText(data) {
1870
- if (typeof data === "string") return data;
1871
- if (!data || typeof data !== "object") return "";
1872
- const obj = data;
1873
- if (typeof obj.text === "string") return obj.text;
1874
- if (obj.message && typeof obj.message === "object") return messageText(obj.message);
1875
- return contentText(obj.content);
1876
- }
1877
- function contentText(content) {
1878
- if (typeof content === "string") return content;
1879
- if (Array.isArray(content)) return content.map((part) => {
1880
- if (typeof part === "string") return part;
1881
- if (part && typeof part === "object") {
1882
- const piece = part;
1883
- if (piece.type === "text" && typeof piece.text === "string") return piece.text;
1884
- }
1885
- return "";
1886
- }).join("");
1887
- return "";
1888
- }
1889
- function messageAttachments(data) {
1890
- if (!data || typeof data !== "object") return [];
1891
- const obj = data;
1892
- if (obj.message && typeof obj.message === "object") return messageAttachments(obj.message);
1893
- if (!Array.isArray(obj.content)) return [];
1894
- return obj.content.flatMap((part) => {
1895
- if (!part || typeof part !== "object") return [];
1896
- const block = part;
1897
- if (block.type !== "image" || !block.attachment) return [];
1898
- const attachmentId = typeof block.attachment.attachmentId === "string" && block.attachment.attachmentId.length > 0 ? block.attachment.attachmentId : void 0;
1899
- const width = typeof block.attachment.width === "number" && Number.isFinite(block.attachment.width) ? block.attachment.width : void 0;
1900
- const height = typeof block.attachment.height === "number" && Number.isFinite(block.attachment.height) ? block.attachment.height : void 0;
1901
- return [{
1902
- kind: "image",
1903
- ...typeof block.attachment.name === "string" ? { name: block.attachment.name } : {},
1904
- ...typeof block.attachment.mediaType === "string" ? { mediaType: block.attachment.mediaType } : {},
1905
- ...attachmentId ? { attachmentId } : {},
1906
- ...width !== void 0 ? { width } : {},
1907
- ...height !== void 0 ? { height } : {}
1908
- }];
1909
- });
1910
- }
1911
- function attachmentProjection(data) {
1912
- const attachments = messageAttachments(data);
1913
- return attachments.length > 0 ? { attachments } : {};
1914
- }
1915
- /** Extract reasoning ("thinking") text from assistant message payloads. */
1916
- function messageThinking(data) {
1917
- if (!data || typeof data !== "object") return "";
1918
- const obj = data;
1919
- if (obj.message && typeof obj.message === "object") return messageThinking(obj.message);
1920
- return reasoningContent(obj.content);
1921
- }
1922
- function reasoningContent(content) {
1923
- if (!Array.isArray(content)) return "";
1924
- return content.map((part) => {
1925
- if (part && typeof part === "object") {
1926
- const piece = part;
1927
- if (piece.type === "reasoning" && typeof piece.text === "string") return piece.text;
1928
- }
1929
- return "";
1930
- }).join("");
1931
- }
1932
- /** Stream chunk type of an assistant/chunk payload ('' when unwrapped). */
1933
- function chunkTypeOf(data) {
1934
- if (!data || typeof data !== "object") return "";
1935
- const obj = data;
1936
- if (obj.chunk && typeof obj.chunk === "object") return String(obj.chunk.type ?? "");
1937
- return "text-delta";
2298
+ /** Map an apiProxy respond receipt onto the failure vocabulary. */
2299
+ function receiptFailureReason(receipt) {
2300
+ return receipt?.reason === "not-pending" ? "not-pending" : "bad-response";
1938
2301
  }
1939
- function chunkText(data) {
1940
- if (!data || typeof data !== "object") return "";
1941
- const obj = data;
1942
- if (obj.chunk && typeof obj.chunk === "object") {
1943
- const inner = obj.chunk;
1944
- if ((inner.type === "text-delta" || inner.type === "reasoning-delta") && typeof inner.text === "string") return inner.text;
1945
- return "";
1946
- }
1947
- const direct = data;
1948
- return typeof direct.text === "string" ? direct.text : "";
2302
+ function clampTail(n) {
2303
+ if (!Number.isFinite(n)) return 100;
2304
+ return Math.max(10, Math.min(500, Math.floor(n)));
1949
2305
  }
1950
- function summarizeArgs(raw) {
1951
- if (typeof raw !== "string" || raw.length === 0) return "";
2306
+ function localTimeZone() {
1952
2307
  try {
1953
- const parsed = JSON.parse(raw);
1954
- const parts = [];
1955
- for (const [key, value] of Object.entries(parsed)) if (typeof value === "string") parts.push(key + "=" + truncate(value.replace(/\s+/g, " "), 60));
1956
- return truncate(parts.join(" "), 90);
2308
+ return new Intl.DateTimeFormat().resolvedOptions().timeZone || void 0;
1957
2309
  } catch {
1958
- return truncate(raw, 90);
2310
+ return;
1959
2311
  }
1960
2312
  }
1961
- function truncate(text, max) {
1962
- return text.length <= max ? text : text.slice(0, max - 1) + "";
2313
+ function riskOf(toolName) {
2314
+ if (/bash|pwsh|terminal/.test(toolName)) return "write";
2315
+ if (/edit|write|str_replace|create/.test(toolName)) return "write";
2316
+ if (/delete|remove|kill/.test(toolName)) return "destructive";
2317
+ return "read";
2318
+ }
2319
+ /** First question's text for the push banner; the questions payload shape is
2320
+ * host-version dependent, so extract defensively. */
2321
+ function firstQuestionText(questions) {
2322
+ if (!Array.isArray(questions) || questions.length === 0) return "Agent 等待你的输入";
2323
+ const first = questions[0];
2324
+ return String(first?.question ?? "").trim() || "Agent 等待你的输入";
2325
+ }
2326
+ const TODO_STATUSES = /* @__PURE__ */ new Set([
2327
+ "pending",
2328
+ "in_progress",
2329
+ "completed"
2330
+ ]);
2331
+ /** Validate a host todo projection once; progress counts and the full
2332
+ * checklist both derive from this sanitized list so they never disagree. */
2333
+ function sanitizeTodoItems(items) {
2334
+ if (!items) return [];
2335
+ return items.map((i) => ({
2336
+ content: String(i.content ?? "").trim(),
2337
+ status: String(i.status ?? "")
2338
+ })).filter((i) => i.content.length > 0 && TODO_STATUSES.has(i.status)).slice(0, 100).map((i) => ({
2339
+ content: i.content,
2340
+ status: i.status
2341
+ }));
2342
+ }
2343
+ function toSummary(row, approvals, questions, workspace) {
2344
+ const values = row.projections?.values ?? {};
2345
+ const todos = Array.isArray(values.todos) ? values.todos : null;
2346
+ let pendingApproval = false;
2347
+ for (const pending of approvals.values()) if (pending.sessionId === row.sessionId) pendingApproval = true;
2348
+ let pendingQuestion = false;
2349
+ for (const pending of questions.values()) if (pending.sessionId === row.sessionId) pendingQuestion = true;
2350
+ const cwd = typeof row.cwd === "string" ? row.cwd : "";
2351
+ const label = workspace?.title ?? (cwd ? cwd.split("/").filter(Boolean).pop() : void 0);
2352
+ const todoItems = sanitizeTodoItems(todos);
2353
+ return {
2354
+ id: row.sessionId,
2355
+ title: typeof values.title === "string" ? values.title : "",
2356
+ status: row.running ? "running" : "idle",
2357
+ lastActivityTs: Number(row.updatedAt ?? Date.now()),
2358
+ todos: todoItems.length > 0 ? {
2359
+ done: todoItems.filter((i) => i.status === "completed").length,
2360
+ total: todoItems.length
2361
+ } : null,
2362
+ todoItems: todoItems.length > 0 ? todoItems : null,
2363
+ pendingApproval,
2364
+ pendingQuestion,
2365
+ workspaceLabel: label ?? null,
2366
+ workspaceId: workspace?.workspaceId ?? null,
2367
+ workspacePath: workspace?.path ?? (cwd || null)
2368
+ };
2369
+ }
2370
+ function projectWorkspace(workspace) {
2371
+ return {
2372
+ id: String(workspace.workspaceId),
2373
+ title: String(workspace.title),
2374
+ path: String(workspace.path),
2375
+ sessionIds: (workspace.sessionIds ?? []).map(String)
2376
+ };
1963
2377
  }
2378
+ /** Project one raw session event into a protocol push, when it maps to one. */
1964
2379
  function hostModelError(error) {
1965
2380
  const message = error.message ?? error.code;
1966
2381
  switch (error.code) {
@@ -2061,80 +2476,364 @@ function projectSessionModels(value) {
2061
2476
  };
2062
2477
  }
2063
2478
  /** 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)
2479
+ //#endregion
2480
+ //#region src/dsh012-api-proxy.ts
2481
+ /**
2482
+ * Compatibility façade for the DSH 0.1.2 controller API.
2483
+ *
2484
+ * DeepPilot's phone protocol deliberately speaks one stable in-process
2485
+ * `apiProxy` vocabulary. Harness 0.1.2 removed that service in favor of
2486
+ * direct Session/Workspace controllers plus scoped Cordis interaction events.
2487
+ * This adapter rebuilds the small subset the bridge needs from those public
2488
+ * controllers, keeping the protocol implementation isolated from the Host API
2489
+ * migration. It is intentionally Host-only.
2490
+ */
2491
+ /** Direct-controller facade with the exact legacy shape HostBridge consumes. */
2492
+ var Dsh012ApiProxy = class {
2493
+ ctx;
2494
+ session;
2495
+ workspaceController;
2496
+ directoryPicker;
2497
+ interactions = /* @__PURE__ */ new Map();
2498
+ constructor(ctx) {
2499
+ this.ctx = ctx;
2500
+ const session = ctx.get("sessionController");
2501
+ if (session === void 0) throw new Error("dsh 0.1.2 sessionController is unavailable");
2502
+ this.session = session;
2503
+ this.workspaceController = ctx.get("workspaceController");
2504
+ this.directoryPicker = ctx.get("directoryPickerController");
2505
+ }
2506
+ sessions = {
2507
+ list: async () => this.call(async () => {
2508
+ return { items: (await this.session.list({}, new AbortController().signal)).items.map(toPhoneSessionRow).filter((row) => !isSubagentRow(row)) };
2509
+ }),
2510
+ history: async (request) => this.call(async () => {
2511
+ const sessionId = request.payload.sessionId;
2512
+ let inspected;
2513
+ try {
2514
+ inspected = await this.session.inspect(sessionId);
2515
+ } catch (error) {
2516
+ console.warn(`[deeppilot] session history unavailable for ${JSON.stringify(sessionId)}: ${toError(error).code}: ${toError(error).message}`);
2517
+ throw error;
2518
+ }
2519
+ const before = request.payload?.beforeSeq;
2520
+ const limit = Math.max(1, request.payload?.maxMessages ?? 100);
2521
+ const source = inspected.events.filter((event) => typeof event === "object" && event !== null && typeof event.type === "string" && typeof event.seq === "number").filter((event) => before === void 0 || event.seq < before);
2522
+ let end = source.length;
2523
+ let events = [];
2524
+ while (end > 0 && projectHistory(events).length < limit) {
2525
+ const start = Math.max(0, end - limit);
2526
+ events = [...source.slice(start, end).map((event) => ({ event })), ...events];
2527
+ end = start;
2528
+ }
2529
+ let trimmed = false;
2530
+ while (events.length > 0 && projectHistory(events).length > limit) {
2531
+ events = events.slice(1);
2532
+ trimmed = true;
2533
+ }
2534
+ return {
2535
+ events,
2536
+ hasMore: end > 0 || trimmed
2537
+ };
2538
+ }),
2539
+ prompt: async (request) => this.call(() => this.session.prompt({
2540
+ ...request.payload,
2541
+ requestId: request.rpcId ?? randomUUID()
2542
+ }, new AbortController().signal)),
2543
+ create: async (request) => this.call(() => this.session.create(request.payload ?? {})),
2544
+ models: async (request) => this.call(async () => projectModels(await this.session.modelCatalog(), String(request.payload?.sessionId ?? ""), await this.session.list({}, new AbortController().signal))),
2545
+ selectModel: async (request) => this.call(() => this.session.selectModel(request.payload ?? {})),
2546
+ rename: async (request) => this.call(() => this.session.rename(request.payload)),
2547
+ cancel: async (request) => this.call(() => this.session.cancel(request.payload)),
2548
+ attachment: async (request) => this.call(() => this.session.attachment(request.payload))
2549
+ };
2550
+ workspace = {
2551
+ list: async () => this.call(async () => {
2552
+ if (this.workspaceController === void 0) throw unavailable("workspace controller unavailable");
2553
+ const baseline = await readWorkspaceBaseline(this.workspaceController);
2554
+ return {
2555
+ items: baseline.items.map(toWorkspaceView),
2556
+ archivedSessionIds: baseline.archivedSessionIds.map(String)
2557
+ };
2558
+ }),
2559
+ create: async (request) => this.call(async () => {
2560
+ if (this.workspaceController === void 0) throw unavailable("workspace controller unavailable");
2561
+ const value = await this.workspaceController.create(request.payload);
2562
+ return {
2563
+ workspace: toWorkspaceView(value.workspace),
2564
+ created: value.created === true
2565
+ };
2566
+ }),
2567
+ archiveSession: async (request) => this.call(async () => {
2568
+ if (this.workspaceController === void 0) throw unavailable("workspace controller unavailable");
2569
+ return { archivedSessionIds: [...(await this.workspaceController.archiveSession(request.payload)).archivedSessionIds] };
2570
+ })
2571
+ };
2572
+ host = {
2573
+ listDirectory: async (request, signal) => this.call(async () => {
2574
+ if (this.directoryPicker === void 0) throw unavailable("directory picker unavailable");
2575
+ return await this.directoryPicker.list(request.payload?.path, signal ?? new AbortController().signal);
2576
+ }),
2577
+ pickDirectory: async (_request, signal) => this.call(async () => {
2578
+ if (this.directoryPicker === void 0) throw unavailable("directory picker unavailable");
2579
+ return { path: await this.directoryPicker.pick(signal ?? new AbortController().signal) };
2580
+ })
2581
+ };
2582
+ events = {
2583
+ mux: (_request, signal) => this.mux(signal),
2584
+ host: (_request, signal) => this.hostEvents(signal)
2585
+ };
2586
+ async respond(message) {
2587
+ const pending = this.interactions.get(message.rpcId);
2588
+ if (pending === void 0) return {
2589
+ accepted: false,
2590
+ reason: "not-pending"
2072
2591
  };
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)
2592
+ if (!message.result.ok) return {
2593
+ accepted: false,
2594
+ reason: "bad-response"
2595
+ };
2596
+ this.interactions.delete(message.rpcId);
2597
+ pending.resolve(pending.map(message.result.value));
2598
+ return { accepted: true };
2599
+ }
2600
+ async *mux(signal) {
2601
+ const queue = new AsyncFrameQueue(signal);
2602
+ const offEvent = this.ctx.on("session/event", ((session, event) => {
2603
+ queue.push({
2604
+ type: "session/event",
2605
+ sessionId: String(session.id),
2606
+ event
2607
+ });
2608
+ }), { global: true });
2609
+ const offProjection = this.ctx.get("sessionProjections")?.onChanged?.((session, key, value) => {
2610
+ queue.push({
2611
+ type: "session/projection",
2612
+ sessionId: String(session.id),
2613
+ key,
2614
+ value
2615
+ });
2616
+ });
2617
+ const offApproval = this.ctx.on("approval/request", ((request) => {
2618
+ const rpcId = randomUUID();
2619
+ const sessionId = String(request.agent?.session?.id ?? request.agent?.id ?? "");
2620
+ const response = deferred();
2621
+ const abort = () => response.resolve("cancelled");
2622
+ request.signal?.addEventListener("abort", abort, { once: true });
2623
+ this.interactions.set(rpcId, {
2624
+ resolve: response.resolve,
2625
+ map: (value) => {
2626
+ const outcome = value?.outcome;
2627
+ return outcome === "allowed-once" || outcome === "rejected" ? outcome : "unavailable";
2628
+ }
2629
+ });
2630
+ queue.push({
2631
+ type: "approval/requested",
2632
+ rpcId,
2633
+ sessionId,
2634
+ approvalId: rpcId,
2635
+ toolName: String(request.toolName ?? "tool"),
2636
+ reason: String(request.reason ?? "")
2637
+ });
2638
+ return response.promise.finally(() => {
2639
+ request.signal?.removeEventListener("abort", abort);
2640
+ this.interactions.delete(rpcId);
2641
+ queue.push({
2642
+ type: "approval/resolved",
2643
+ approvalId: rpcId
2081
2644
  });
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 } : {}
2645
+ });
2646
+ }), { global: true });
2647
+ const offQuestion = this.ctx.on("user-questions/request", ((request) => {
2648
+ const rpcId = randomUUID();
2649
+ const sessionId = String(request.agent?.session?.id ?? request.agent?.id ?? "");
2650
+ const response = deferred();
2651
+ const abort = () => response.reject(/* @__PURE__ */ new Error("question cancelled"));
2652
+ request.signal?.addEventListener("abort", abort, { once: true });
2653
+ this.interactions.set(rpcId, {
2654
+ resolve: response.resolve,
2655
+ map: (value) => value?.answer ?? value
2656
+ });
2657
+ queue.push({
2658
+ type: "question/requested",
2659
+ rpcId,
2660
+ sessionId,
2661
+ questions: request.questions ?? []
2662
+ });
2663
+ return response.promise.finally(() => {
2664
+ request.signal?.removeEventListener("abort", abort);
2665
+ this.interactions.delete(rpcId);
2666
+ queue.push({
2667
+ type: "question/resolved",
2668
+ questionRpcId: rpcId
2092
2669
  });
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
- }
2670
+ });
2671
+ }), { global: true });
2672
+ try {
2673
+ yield* queue.iterate();
2674
+ } finally {
2675
+ offEvent();
2676
+ offProjection?.();
2677
+ offApproval();
2678
+ offQuestion();
2679
+ queue.close();
2680
+ }
2681
+ }
2682
+ async *hostEvents(signal) {
2683
+ const queue = new AsyncFrameQueue(signal);
2684
+ const listen = (event, type, project) => this.ctx.on(event, ((...args) => queue.push({
2685
+ type,
2686
+ ...project?.(...args) ?? {}
2687
+ })), { global: true });
2688
+ const off = [
2689
+ listen("api-session/added", "host/session-added"),
2690
+ listen("api-session/removed", "host/session-removed"),
2691
+ listen("api-session/status", "host/session-status", (sessionId, running) => ({
2692
+ sessionId: String(sessionId),
2693
+ running: running === true
2694
+ })),
2695
+ listen("api-session/activity", "host/session-added")
2696
+ ];
2697
+ const workspaceAbort = new AbortController();
2698
+ const stop = () => workspaceAbort.abort();
2699
+ signal.addEventListener("abort", stop, { once: true });
2700
+ this.workspaceController === void 0 || (async () => {
2701
+ try {
2702
+ for await (const frame of this.workspaceController.follow(workspaceAbort.signal)) if (frame.type === "archived") queue.push({
2703
+ type: "host/archived-sessions-changed",
2704
+ archivedSessionIds: frame.archivedSessionIds
2129
2705
  });
2130
- break;
2706
+ else if (frame.type !== "baseline") queue.push({ type: "host/workspace-changed" });
2707
+ } catch {}
2708
+ })();
2709
+ try {
2710
+ yield* queue.iterate();
2711
+ } finally {
2712
+ for (const dispose of off) dispose();
2713
+ signal.removeEventListener("abort", stop);
2714
+ workspaceAbort.abort();
2715
+ queue.close();
2716
+ }
2717
+ }
2718
+ async call(invoke) {
2719
+ try {
2720
+ return { result: {
2721
+ ok: true,
2722
+ value: await invoke()
2723
+ } };
2724
+ } catch (error) {
2725
+ return { result: {
2726
+ ok: false,
2727
+ error: toError(error)
2728
+ } };
2729
+ }
2730
+ }
2731
+ };
2732
+ function deferred() {
2733
+ let resolve;
2734
+ let reject;
2735
+ return {
2736
+ promise: new Promise((ok, fail) => {
2737
+ resolve = ok;
2738
+ reject = fail;
2739
+ }),
2740
+ resolve,
2741
+ reject
2742
+ };
2743
+ }
2744
+ var AsyncFrameQueue = class {
2745
+ frames = [];
2746
+ wake;
2747
+ closed = false;
2748
+ constructor(signal) {
2749
+ signal.addEventListener("abort", () => this.close(), { once: true });
2750
+ }
2751
+ push(frame) {
2752
+ if (!this.closed) {
2753
+ this.frames.push(frame);
2754
+ this.wake?.();
2755
+ }
2756
+ }
2757
+ close() {
2758
+ if (!this.closed) {
2759
+ this.closed = true;
2760
+ this.wake?.();
2761
+ }
2762
+ }
2763
+ async *iterate() {
2764
+ while (!this.closed) {
2765
+ const frame = this.frames.shift();
2766
+ if (frame !== void 0) {
2767
+ yield frame;
2768
+ continue;
2131
2769
  }
2770
+ await new Promise((resolve) => {
2771
+ this.wake = resolve;
2772
+ });
2773
+ this.wake = void 0;
2132
2774
  }
2133
2775
  }
2134
- return messages.sort((a, b) => a.seq - b.seq);
2776
+ };
2777
+ function unavailable(message) {
2778
+ return Object.assign(new Error(message), { code: "directory-picker-unavailable" });
2135
2779
  }
2136
- function summarizeResult(content) {
2137
- return truncate(contentText(content).replace(/\s+/g, " ").trim(), 90);
2780
+ function toError(error) {
2781
+ const value = error;
2782
+ return {
2783
+ code: typeof value?.code === "string" ? value.code : "internal",
2784
+ message: typeof value?.message === "string" ? value.message : String(error)
2785
+ };
2786
+ }
2787
+ function toPhoneSessionRow(value) {
2788
+ const row = value;
2789
+ return {
2790
+ sessionId: String(row.sessionId ?? ""),
2791
+ updatedAt: Number(row.updatedAt ?? Date.now()),
2792
+ running: row.running === true,
2793
+ ...row.blank === true ? { blank: true } : {},
2794
+ ...typeof row.cwd === "string" ? { cwd: row.cwd } : {},
2795
+ ...typeof row.origin === "string" ? { origin: row.origin } : {},
2796
+ ...typeof row.parentSessionId === "string" ? { parentSessionId: row.parentSessionId } : {},
2797
+ ...row.projections && typeof row.projections === "object" ? { projections: row.projections } : {}
2798
+ };
2799
+ }
2800
+ function toWorkspaceView(value) {
2801
+ const row = value;
2802
+ return {
2803
+ workspaceId: String(row.workspaceId ?? ""),
2804
+ title: String(row.title ?? ""),
2805
+ path: String(row.path ?? ""),
2806
+ sessionIds: Array.isArray(row.sessionIds) ? row.sessionIds.map(String) : []
2807
+ };
2808
+ }
2809
+ async function readWorkspaceBaseline(controller) {
2810
+ const abort = new AbortController();
2811
+ const iterator = controller.follow(abort.signal)[Symbol.asyncIterator]();
2812
+ try {
2813
+ const baseline = (await iterator.next()).value;
2814
+ if (baseline?.type !== "baseline") throw new Error("workspace follow did not provide a baseline");
2815
+ return {
2816
+ items: (baseline.value?.items ?? []).map(toWorkspaceView),
2817
+ archivedSessionIds: (baseline.value?.archivedSessionIds ?? []).map(String)
2818
+ };
2819
+ } finally {
2820
+ abort.abort();
2821
+ await iterator.return?.();
2822
+ }
2823
+ }
2824
+ async function projectModels(catalog, sessionId, list) {
2825
+ const value = catalog;
2826
+ const selected = (list.items.map(toPhoneSessionRow).find((item) => item.sessionId === sessionId)?.projections?.values?.modelSelection)?.next;
2827
+ return {
2828
+ current: {
2829
+ provider: String(selected?.provider ?? value.default?.provider ?? ""),
2830
+ model: String(selected?.model ?? value.default?.model ?? ""),
2831
+ ...typeof selected?.reasoningEffort === "string" ? { reasoningEffort: selected.reasoningEffort } : {}
2832
+ },
2833
+ routable: true,
2834
+ groups: Array.isArray(value.groups) ? value.groups : [],
2835
+ failures: Array.isArray(value.failures) ? value.failures : []
2836
+ };
2138
2837
  }
2139
2838
  //#endregion
2140
2839
  //#region src/report-service.ts
@@ -2145,26 +2844,31 @@ function summarizeResult(content) {
2145
2844
  */
2146
2845
  var DeepPilotReportService = class extends TypertRemoteService {
2147
2846
  snapshot;
2148
- pairingToken;
2149
- rotatePairingToken;
2847
+ pairingStarter;
2848
+ deviceRevoker;
2849
+ deviceScopeUpdater;
2150
2850
  relayTester;
2151
2851
  pushTester;
2152
- constructor(ctx, snapshot, pairingToken, rotatePairingToken, relayTester, pushTester) {
2852
+ constructor(ctx, snapshot, pairingStarter, deviceRevoker, deviceScopeUpdater, relayTester, pushTester) {
2153
2853
  super(ctx, "deeppilotReport", { namespace: "deeppilot" });
2154
2854
  this.snapshot = snapshot;
2155
- this.pairingToken = pairingToken;
2156
- this.rotatePairingToken = rotatePairingToken;
2855
+ this.pairingStarter = pairingStarter;
2856
+ this.deviceRevoker = deviceRevoker;
2857
+ this.deviceScopeUpdater = deviceScopeUpdater;
2157
2858
  this.relayTester = relayTester;
2158
2859
  this.pushTester = pushTester;
2159
2860
  }
2160
2861
  async report() {
2161
2862
  return this.snapshot();
2162
2863
  }
2163
- async revealToken() {
2164
- return this.pairingToken();
2864
+ async beginPairing() {
2865
+ return this.pairingStarter();
2165
2866
  }
2166
- async rotateToken() {
2167
- return this.rotatePairingToken();
2867
+ async revokeDevice(deviceId) {
2868
+ return this.deviceRevoker(deviceId);
2869
+ }
2870
+ async setDeviceScopes(deviceId, scopes) {
2871
+ return this.deviceScopeUpdater(deviceId, scopes);
2168
2872
  }
2169
2873
  async testRelay() {
2170
2874
  return this.relayTester();
@@ -2179,14 +2883,9 @@ var DeepPilotReportService = class extends TypertRemoteService {
2179
2883
  const REPORT_REMOTE_PACKAGE = "dsh-deeppilot";
2180
2884
  /** Canonical `<namespace>/<method>` endpoint of the report Remote. */
2181
2885
  const REPORT_ENDPOINT = "deeppilot/report";
2182
- /** Explicit, user-triggered endpoint for revealing the pairing secret. */
2183
- const REVEAL_TOKEN_ENDPOINT = "deeppilot/revealToken";
2184
- /**
2185
- * Explicit, user-triggered endpoint that replaces the pairing secret. The old
2186
- * token stops working immediately; the fresh one is returned so the page can
2187
- * show/QR it right away.
2188
- */
2189
- const ROTATE_TOKEN_ENDPOINT = "deeppilot/rotateToken";
2886
+ const BEGIN_PAIRING_ENDPOINT = "deeppilot/beginPairing";
2887
+ const REVOKE_DEVICE_ENDPOINT = "deeppilot/revokeDevice";
2888
+ const SET_DEVICE_SCOPES_ENDPOINT = "deeppilot/setDeviceScopes";
2190
2889
  function reject(field) {
2191
2890
  throw new TypeError(`deeppilot/report result: invalid ${field}`);
2192
2891
  }
@@ -2195,9 +2894,16 @@ function str(source, key, field) {
2195
2894
  if (typeof value !== "string") reject(field);
2196
2895
  return value;
2197
2896
  }
2198
- function num(source, key, field) {
2897
+ /**
2898
+ * Non-negative integer: counters and timestamps (activeConnections,
2899
+ * historyBufferMax, updatedAt, lastSeenTs, protocolVersion, etc.). A bare
2900
+ * `typeof number` check accepts 1.5, -1, and 1e20 — all of which then
2901
+ * surface verbatim on the settings page and break any sort or arithmetic
2902
+ * the UI does.
2903
+ */
2904
+ function int(source, key, field) {
2199
2905
  const value = source[key];
2200
- if (typeof value !== "number" || !Number.isFinite(value)) reject(field);
2906
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 0) reject(field);
2201
2907
  return value;
2202
2908
  }
2203
2909
  function bool(source, key, field) {
@@ -2218,15 +2924,18 @@ function parseDevice(value) {
2218
2924
  if (environment !== "development" && environment !== "production") reject("device.apns.environment");
2219
2925
  apns = {
2220
2926
  environment,
2221
- updatedAt: num(a, "updatedAt", "device.apns.updatedAt")
2927
+ updatedAt: int(a, "updatedAt", "device.apns.updatedAt")
2222
2928
  };
2223
2929
  }
2224
2930
  return {
2225
2931
  deviceId: str(s, "deviceId", "device.deviceId"),
2226
2932
  deviceName: str(s, "deviceName", "device.deviceName"),
2227
2933
  appVersion: str(s, "appVersion", "device.appVersion"),
2228
- firstSeenTs: num(s, "firstSeenTs", "device.firstSeenTs"),
2229
- lastSeenTs: num(s, "lastSeenTs", "device.lastSeenTs"),
2934
+ firstSeenTs: int(s, "firstSeenTs", "device.firstSeenTs"),
2935
+ lastSeenTs: int(s, "lastSeenTs", "device.lastSeenTs"),
2936
+ fingerprint: str(s, "fingerprint", "device.fingerprint"),
2937
+ scopes: normalizeDeviceScopes(s.scopes),
2938
+ ...s.revokedAt !== void 0 ? { revokedAt: int(s, "revokedAt", "device.revokedAt") } : {},
2230
2939
  ...apns ? { apns } : {}
2231
2940
  };
2232
2941
  }
@@ -2256,7 +2965,7 @@ function parseRemote(value) {
2256
2965
  ...typeof publicURL === "string" ? { publicURL } : {},
2257
2966
  ...typeof authURL === "string" ? { authURL } : {},
2258
2967
  ...typeof message === "string" ? { message } : {},
2259
- updatedAt: num(s, "updatedAt", "remote.updatedAt")
2968
+ updatedAt: int(s, "updatedAt", "remote.updatedAt")
2260
2969
  };
2261
2970
  }
2262
2971
  function parseRelayTestStep(value) {
@@ -2264,7 +2973,9 @@ function parseRelayTestStep(value) {
2264
2973
  const id = str(st, "id", "step.id");
2265
2974
  if (id !== "health" && id !== "enroll") reject("step.id");
2266
2975
  const latencyMs = st.latencyMs;
2267
- if (latencyMs !== void 0 && typeof latencyMs !== "number") reject("step.latencyMs");
2976
+ if (latencyMs !== void 0) {
2977
+ if (typeof latencyMs !== "number" || !Number.isFinite(latencyMs) || !Number.isInteger(latencyMs) || latencyMs < 0) reject("step.latencyMs");
2978
+ }
2268
2979
  return {
2269
2980
  id,
2270
2981
  ok: bool(st, "ok", "step.ok"),
@@ -2307,7 +3018,7 @@ function parsePushTestResult(value) {
2307
3018
  environment: str(r, "environment", "result.environment"),
2308
3019
  outcome: str(r, "outcome", "result.outcome"),
2309
3020
  ...typeof reason === "string" && reason.length > 0 ? { reason } : {},
2310
- ...typeof tokenFingerprint === "string" && /^[0-9a-f]{1,32}$/.test(tokenFingerprint) ? { tokenFingerprint } : {}
3021
+ ...typeof tokenFingerprint === "string" && /^[0-9a-f]{10}$/.test(tokenFingerprint) ? { tokenFingerprint } : {}
2311
3022
  };
2312
3023
  });
2313
3024
  const message = s.message;
@@ -2326,16 +3037,16 @@ function parseReport(value) {
2326
3037
  if (!Array.isArray(lanAddresses) || lanAddresses.some((value) => typeof value !== "string")) reject("lanAddresses");
2327
3038
  const releaseUrl = s.releaseUrl;
2328
3039
  return {
2329
- protocolVersion: num(s, "protocolVersion", "protocolVersion"),
3040
+ protocolVersion: int(s, "protocolVersion", "protocolVersion"),
2330
3041
  serverVersion: str(s, "serverVersion", "serverVersion"),
2331
3042
  pluginVersion: str(s, "pluginVersion", "pluginVersion"),
2332
3043
  ...s.updateAvailable === true ? { updateAvailable: true } : {},
2333
3044
  ...typeof releaseUrl === "string" && releaseUrl.length > 0 ? { releaseUrl } : {},
2334
3045
  enabled: bool(s, "enabled", "enabled"),
2335
- tokenPath: str(s, "tokenPath", "tokenPath"),
2336
- tokenReady: bool(s, "tokenReady", "tokenReady"),
2337
- activeConnections: num(s, "activeConnections", "activeConnections"),
2338
- historyBufferMax: num(s, "historyBufferMax", "historyBufferMax"),
3046
+ identityPath: str(s, "identityPath", "identityPath"),
3047
+ pairingReady: bool(s, "pairingReady", "pairingReady"),
3048
+ activeConnections: int(s, "activeConnections", "activeConnections"),
3049
+ historyBufferMax: int(s, "historyBufferMax", "historyBufferMax"),
2339
3050
  debug: bool(s, "debug", "debug"),
2340
3051
  lanAddresses,
2341
3052
  remote: parseRemote(s.remote),
@@ -2345,14 +3056,33 @@ function parseReport(value) {
2345
3056
  const reportSchema = { parse: parseReport };
2346
3057
  const relayTestSchema = { parse: parseRelayTestResult };
2347
3058
  const pushTestSchema = { parse: parsePushTestResult };
2348
- const pairingTokenSchema = { parse(value) {
2349
- if (typeof value !== "string" || value.length < 32) throw new TypeError("deeppilot/revealToken result: invalid token");
3059
+ const pairingGrantSchema = { parse(value) {
3060
+ const s = rec(value, "pairing grant");
3061
+ const code = str(s, "code", "pairingGrant.code");
3062
+ if (code.length < 32) reject("pairingGrant.code");
3063
+ return {
3064
+ code,
3065
+ expiresAt: int(s, "expiresAt", "pairingGrant.expiresAt"),
3066
+ audience: str(s, "audience", "pairingGrant.audience")
3067
+ };
3068
+ } };
3069
+ const deviceIdSchema = { parse(value) {
3070
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(value)) reject("deviceId");
2350
3071
  return value;
2351
3072
  } };
3073
+ const scopesSchema = { parse(value) {
3074
+ if (!Array.isArray(value) || value.some((scope) => typeof scope !== "string" || !DEVICE_SCOPES.includes(scope))) reject("scopes");
3075
+ return normalizeDeviceScopes(value);
3076
+ } };
2352
3077
  const REPORT_HOST_CONTRIBUTION = {
2353
3078
  package: REPORT_REMOTE_PACKAGE,
2354
3079
  face: "host",
2355
3080
  schemas: [],
3081
+ model: {
3082
+ services: [],
3083
+ events: [],
3084
+ objects: []
3085
+ },
2356
3086
  invocations: [
2357
3087
  {
2358
3088
  id: `${REPORT_REMOTE_PACKAGE}#${REPORT_ENDPOINT}`,
@@ -2368,29 +3098,72 @@ const REPORT_HOST_CONTRIBUTION = {
2368
3098
  }
2369
3099
  },
2370
3100
  {
2371
- id: `${REPORT_REMOTE_PACKAGE}#${REVEAL_TOKEN_ENDPOINT}`,
3101
+ id: `${REPORT_REMOTE_PACKAGE}#${BEGIN_PAIRING_ENDPOINT}`,
2372
3102
  service: "deeppilotReport",
2373
3103
  namespace: "deeppilot",
2374
- method: "revealToken",
3104
+ method: "beginPairing",
2375
3105
  invocation: { kind: "direct" },
2376
3106
  parameters: [],
2377
3107
  result: {
2378
3108
  mode: "strict",
2379
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#PairingToken`,
2380
- schema: pairingTokenSchema
3109
+ typeSymbol: `${REPORT_REMOTE_PACKAGE}#PairingGrantSnapshot`,
3110
+ schema: pairingGrantSchema
2381
3111
  }
2382
3112
  },
2383
3113
  {
2384
- id: `${REPORT_REMOTE_PACKAGE}#${ROTATE_TOKEN_ENDPOINT}`,
3114
+ id: `${REPORT_REMOTE_PACKAGE}#${REVOKE_DEVICE_ENDPOINT}`,
2385
3115
  service: "deeppilotReport",
2386
3116
  namespace: "deeppilot",
2387
- method: "rotateToken",
3117
+ method: "revokeDevice",
2388
3118
  invocation: { kind: "direct" },
2389
- parameters: [],
3119
+ parameters: [{
3120
+ name: "deviceId",
3121
+ wire: "deviceId",
3122
+ source: "json",
3123
+ codec: {
3124
+ mode: "strict",
3125
+ typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceId`,
3126
+ schema: deviceIdSchema
3127
+ }
3128
+ }],
3129
+ result: {
3130
+ mode: "strict",
3131
+ typeSymbol: `${REPORT_REMOTE_PACKAGE}#Boolean`,
3132
+ schema: { parse(value) {
3133
+ if (typeof value !== "boolean") reject("boolean");
3134
+ return value;
3135
+ } }
3136
+ }
3137
+ },
3138
+ {
3139
+ id: `${REPORT_REMOTE_PACKAGE}#${SET_DEVICE_SCOPES_ENDPOINT}`,
3140
+ service: "deeppilotReport",
3141
+ namespace: "deeppilot",
3142
+ method: "setDeviceScopes",
3143
+ invocation: { kind: "direct" },
3144
+ parameters: [{
3145
+ name: "deviceId",
3146
+ wire: "deviceId",
3147
+ source: "json",
3148
+ codec: {
3149
+ mode: "strict",
3150
+ typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceId`,
3151
+ schema: deviceIdSchema
3152
+ }
3153
+ }, {
3154
+ name: "scopes",
3155
+ wire: "scopes",
3156
+ source: "json",
3157
+ codec: {
3158
+ mode: "strict",
3159
+ typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceScopes`,
3160
+ schema: scopesSchema
3161
+ }
3162
+ }],
2390
3163
  result: {
2391
3164
  mode: "strict",
2392
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#PairingToken`,
2393
- schema: pairingTokenSchema
3165
+ typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceScopes`,
3166
+ schema: scopesSchema
2394
3167
  }
2395
3168
  },
2396
3169
  {
@@ -2427,9 +3200,9 @@ const REPORT_HOST_CONTRIBUTION = {
2427
3200
  * Provide the report service and register its Remote descriptor. Rides an
2428
3201
  * optional `typert` inject: profiles without the web stack never activate it.
2429
3202
  */
2430
- function applyReportRemote(ctx, snapshot, pairingToken, rotatePairingToken, relayTester, pushTester) {
3203
+ function applyReportRemote(ctx, snapshot, pairingStarter, deviceRevoker, deviceScopeUpdater, relayTester, pushTester) {
2431
3204
  ctx.inject(["typert"], (remoteCtx) => {
2432
- new DeepPilotReportService(remoteCtx, snapshot, pairingToken, rotatePairingToken, relayTester, pushTester);
3205
+ new DeepPilotReportService(remoteCtx, snapshot, pairingStarter, deviceRevoker, deviceScopeUpdater, relayTester, pushTester);
2433
3206
  const unregister = remoteCtx.typert.register(REPORT_HOST_CONTRIBUTION);
2434
3207
  remoteCtx.effect(() => () => void unregister(), "dsh-deeppilot: report remote");
2435
3208
  });
@@ -2541,10 +3314,9 @@ async function runRelayProbe(options) {
2541
3314
  });
2542
3315
  }
2543
3316
  }
2544
- const executed = steps.filter((step) => step.ok !== void 0);
2545
3317
  return {
2546
3318
  url: base,
2547
- overall: steps.length > 0 && steps.some((step) => step.id === "health" && step.ok) && executed.every((step) => step.ok) ? "ok" : "failed",
3319
+ overall: steps.length > 0 && steps.some((step) => step.id === "health" && step.ok) && steps.every((step) => step.ok) ? "ok" : "failed",
2548
3320
  tokenIssued,
2549
3321
  steps
2550
3322
  };
@@ -2566,6 +3338,10 @@ async function runRelayProbe(options) {
2566
3338
  * Privacy: logs carry outcomes and masked token prefixes only — never message
2567
3339
  * bodies or full tokens.
2568
3340
  */
3341
+ /** Classify Apple's reason without losing recoverable configuration errors. */
3342
+ function classifyApnsReason(reason) {
3343
+ return reason === "Unregistered" || reason === "ExpiredToken" ? "invalid-token" : "failed";
3344
+ }
2569
3345
  const PROVIDER_TOKEN_TTL_MS = 3e6;
2570
3346
  const REQUEST_TIMEOUT_MS = 1e4;
2571
3347
  /** base64url without padding. */
@@ -2606,7 +3382,10 @@ function apnsPayload(notification) {
2606
3382
  }
2607
3383
  /** collapse-id accepts ≤64 bytes of ASCII; keep it stable per session+event. */
2608
3384
  function collapseIdFor(notification) {
2609
- return `${notification.category}:${notification.sessionId}`.replace(/[^a-zA-Z0-9.:-]/g, "").slice(0, 64);
3385
+ const raw = `${notification.category}:${notification.sessionId}`;
3386
+ const readable = raw.replace(/[^a-zA-Z0-9.:-]/g, "");
3387
+ const digest = createHash("sha256").update(raw, "utf8").digest("hex").slice(0, 12);
3388
+ return `${readable.slice(0, 51)}:${digest}`;
2610
3389
  }
2611
3390
  function authorityFor(environment) {
2612
3391
  return environment === "production" ? "api.push.apple.com" : "api.sandbox.push.apple.com";
@@ -2721,7 +3500,8 @@ var ApnsClient = class {
2721
3500
  reason = String(JSON.parse(responseBody).reason ?? "");
2722
3501
  } catch {}
2723
3502
  if (status !== 200 && !reason) reason = "HTTP " + String(status);
2724
- if (reason === "Unregistered" || reason === "BadDeviceToken") return settle("invalid-token", reason);
3503
+ const outcome = classifyApnsReason(reason);
3504
+ if (outcome === "invalid-token") return settle(outcome, reason);
2725
3505
  if (this.debug) this.log(`apns rejected status=${status} reason=${reason}`);
2726
3506
  settle("failed", reason);
2727
3507
  });
@@ -2840,6 +3620,9 @@ var RelayClient = class {
2840
3620
  }
2841
3621
  }
2842
3622
  };
3623
+ function normalizeFunnelConnectionLimit(value) {
3624
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 16 ? value : 8;
3625
+ }
2843
3626
  //#endregion
2844
3627
  //#region src/remote-supervisor.ts
2845
3628
  const RESTART_DELAYS_MS = [
@@ -2850,6 +3633,14 @@ const RESTART_DELAYS_MS = [
2850
3633
  16e3,
2851
3634
  3e4
2852
3635
  ];
3636
+ /**
3637
+ * Throttle for configuration-level failures (helper binary missing, state dir
3638
+ * unwritable). The environment will not self-heal between attempts, so the
3639
+ * previous "1s..30s exponential" backoff was a CPU/for-loop on a misconfigured
3640
+ * Host. 60s matches the APNs sender-failure throttle on the host plugin
3641
+ * (index.ts SENDER_FAILURE_RETRY_MS) and the relay enrollment throttle.
3642
+ */
3643
+ const UNAVAILABLE_RETRY_MS = 6e4;
2853
3644
  const DEFAULT_REMOTE_HOSTNAME = "dsh-deeppilot";
2854
3645
  /** Preserve custom node names while migrating every pre-DeepPilot default. */
2855
3646
  function normalizeRemoteHostname(value) {
@@ -2861,6 +3652,20 @@ function normalizeRemoteHostname(value) {
2861
3652
  ].includes(hostname.toLowerCase())) return DEFAULT_REMOTE_HOSTNAME;
2862
3653
  return hostname;
2863
3654
  }
3655
+ function tunnelHelperArguments(originURL, statePath, options) {
3656
+ return [
3657
+ "--origin",
3658
+ originURL,
3659
+ "--hostname",
3660
+ normalizeRemoteHostname(options.hostname),
3661
+ "--state-dir",
3662
+ statePath,
3663
+ "--port",
3664
+ String(options.funnelPort ?? 443),
3665
+ "--max-connections-per-source",
3666
+ String(normalizeFunnelConnectionLimit(options.maxConnectionsPerSource))
3667
+ ];
3668
+ }
2864
3669
  /** Parse one helper IPC line without ever evaluating or interpolating it. */
2865
3670
  function parseHelperEvent(line) {
2866
3671
  try {
@@ -2882,6 +3687,15 @@ function parseHelperEvent(line) {
2882
3687
  return null;
2883
3688
  }
2884
3689
  }
3690
+ function isTailscaleAuthURL(value) {
3691
+ if (!value) return false;
3692
+ try {
3693
+ const url = new URL(value);
3694
+ return url.protocol === "https:" && (url.hostname === "login.tailscale.com" || url.hostname.endsWith(".login.tailscale.com"));
3695
+ } catch {
3696
+ return false;
3697
+ }
3698
+ }
2885
3699
  /** Translate Node's platform/architecture names to the GOOS/GOARCH directory
2886
3700
  * names used by the committed helper matrix. */
2887
3701
  function bundledHelperPlatformDir(platform = process.platform, arch = process.arch) {
@@ -2953,6 +3767,7 @@ var RemoteSupervisor = class {
2953
3767
  phase: "unavailable",
2954
3768
  message
2955
3769
  });
3770
+ this.scheduleRestart(originURL, "unavailable");
2956
3771
  return;
2957
3772
  }
2958
3773
  try {
@@ -2966,6 +3781,7 @@ var RemoteSupervisor = class {
2966
3781
  phase: "unavailable",
2967
3782
  message: `cannot create remote state dir: ${String(error)}`
2968
3783
  });
3784
+ this.scheduleRestart(originURL, "unavailable");
2969
3785
  return;
2970
3786
  }
2971
3787
  if (this.stopping) return;
@@ -2973,16 +3789,7 @@ var RemoteSupervisor = class {
2973
3789
  phase: "starting",
2974
3790
  message: void 0
2975
3791
  });
2976
- const child = spawn(helper, [
2977
- "--origin",
2978
- originURL,
2979
- "--hostname",
2980
- normalizeRemoteHostname(this.options.hostname),
2981
- "--state-dir",
2982
- statePath,
2983
- "--port",
2984
- String(this.options.funnelPort ?? 443)
2985
- ], {
3792
+ const child = spawn(helper, tunnelHelperArguments(originURL, statePath, this.options), {
2986
3793
  stdio: [
2987
3794
  "ignore",
2988
3795
  "pipe",
@@ -3035,7 +3842,7 @@ var RemoteSupervisor = class {
3035
3842
  phase: "error",
3036
3843
  message: detail || `helper exited (${signal ?? String(code)})`
3037
3844
  });
3038
- this.scheduleRestart(originURL);
3845
+ this.scheduleRestart(originURL, "crash");
3039
3846
  });
3040
3847
  }
3041
3848
  async dispose() {
@@ -3069,20 +3876,24 @@ var RemoteSupervisor = class {
3069
3876
  acceptLine(line) {
3070
3877
  const event = parseHelperEvent(line);
3071
3878
  if (event === null || event.phase === void 0) return;
3879
+ if (event.phase === "login_required") {
3880
+ if (this.statusValue.phase === "online" || !isTailscaleAuthURL(event.authURL)) return;
3881
+ }
3072
3882
  if (event.phase === "online") this.restartAttempt = 0;
3073
3883
  this.setStatus({
3074
3884
  ...event,
3075
3885
  phase: event.phase
3076
3886
  });
3077
3887
  }
3078
- scheduleRestart(originURL) {
3888
+ scheduleRestart(originURL, kind = "crash") {
3079
3889
  if (this.stopping || this.restartTimer !== void 0) return;
3080
- const delay = RESTART_DELAYS_MS[Math.min(this.restartAttempt, RESTART_DELAYS_MS.length - 1)];
3081
- this.restartAttempt += 1;
3890
+ const delay = kind === "unavailable" ? UNAVAILABLE_RETRY_MS : RESTART_DELAYS_MS[Math.min(this.restartAttempt, RESTART_DELAYS_MS.length - 1)];
3891
+ if (kind === "crash") this.restartAttempt += 1;
3082
3892
  this.restartTimer = setTimeout(() => {
3083
3893
  this.restartTimer = void 0;
3084
3894
  this.start(originURL);
3085
3895
  }, delay);
3896
+ this.restartTimer.unref?.();
3086
3897
  }
3087
3898
  setStatus(next) {
3088
3899
  const cleared = next.phase === "online" ? {
@@ -3324,48 +4135,41 @@ var UpdateChecker = class {
3324
4135
  dispose() {}
3325
4136
  };
3326
4137
  //#endregion
3327
- //#region src/index.ts
3328
- /**
3329
- * dsh-deeppilot — data bridge between the DSH host and DeepPilot
3330
- * clients. Registers exactly one WebSocket upgrade route (/phone) plus an
3331
- * optional health probe (/phone/health) on the existing web server. The web
3332
- * UI is never touched.
3333
- *
3334
- * Data plane: an in-process HostBridge consumes apiProxy.events.mux()/host()
3335
- * streams, mirrors session summaries, tracks pending approvals/questions,
3336
- * and fans projected protocol-v1 pushes out to every connected device.
3337
- *
3338
- * Protocol: src/protocol.ts, v1. The private app repository carries the
3339
- * matching normative document and Swift models.
3340
- */
3341
- const name = "deeppilot";
3342
- /** No eager service requirement: profiles without a web stack simply skip. */
3343
- const inject = [];
4138
+ //#region src/config.ts
3344
4139
  /** Operator-run relay used by distributed builds; overridable via config. */
3345
4140
  const DEFAULT_RELAY_URL = "https://pilot.hailab.dev";
3346
4141
  const Config = z.object({
3347
4142
  enabled: z.boolean().default(true),
3348
- authTokenPath: z.string().default(join(bridgeDataDir(), "auth-token")),
3349
- devicesPath: z.string().default(join(bridgeDataDir(), "devices.json")),
4143
+ devicesPath: z.string().default(join(bridgeDataDir(), "devices-v2.json")),
3350
4144
  historyBufferMax: z.natural().min(100).default(2e3),
3351
4145
  debug: z.boolean().default(false),
3352
4146
  remote: z.object({
3353
4147
  enabled: z.boolean().default(false),
3354
- provider: z.string().default("tailscale-funnel"),
4148
+ provider: z.union(["tailscale-funnel"]).default("tailscale-funnel"),
3355
4149
  hostname: z.string().default(DEFAULT_REMOTE_HOSTNAME),
3356
4150
  statePath: z.string().default(join(bridgeDataDir(), "tailscale")),
3357
4151
  helperPath: z.string().default(""),
3358
- funnelPort: z.natural().default(443)
4152
+ funnelPort: z.union([
4153
+ 443,
4154
+ 8443,
4155
+ 1e4
4156
+ ]).default(443),
4157
+ maxConnectionsPerSource: z.natural().min(1).max(16).default(8).description("Funnel 每个来源允许的并发连接数(1–16,修改后远程连接会短暂重连)")
3359
4158
  }).default({
3360
4159
  enabled: false,
3361
4160
  provider: "tailscale-funnel",
3362
4161
  hostname: DEFAULT_REMOTE_HOSTNAME,
3363
4162
  statePath: join(bridgeDataDir(), "tailscale"),
3364
4163
  helperPath: "",
3365
- funnelPort: 443
4164
+ funnelPort: 443,
4165
+ maxConnectionsPerSource: 8
3366
4166
  }),
3367
4167
  push: z.object({
3368
- provider: z.string().default("none"),
4168
+ provider: z.union([
4169
+ "none",
4170
+ "apns",
4171
+ "relay"
4172
+ ]).default("none"),
3369
4173
  teamId: z.string().default(""),
3370
4174
  keyId: z.string().default(""),
3371
4175
  keyPath: z.string().default(join(bridgeDataDir(), "apns", "AuthKey.p8")),
@@ -3382,6 +4186,203 @@ const Config = z.object({
3382
4186
  relayToken: ""
3383
4187
  })
3384
4188
  });
4189
+ /**
4190
+ * Cordis hands the second argument in different shapes depending on host
4191
+ * composition: a reactive options getter, the resolved config value, or
4192
+ * nothing when the patch row omits `config`. Normalize all of them.
4193
+ */
4194
+ function normalizeOptions(options) {
4195
+ if (typeof options === "function") return options();
4196
+ if (options && typeof options === "object") return options;
4197
+ return Config(void 0) ?? {};
4198
+ }
4199
+ //#endregion
4200
+ //#region src/phone-http.ts
4201
+ const CLIENT_IP_HEADER = "x-deeppilot-client-ip";
4202
+ function rejectUpgrade(socket, status, reason, retryAfterSeconds) {
4203
+ const body = JSON.stringify({ error: reason });
4204
+ const statusText = {
4205
+ 401: "Unauthorized",
4206
+ 429: "Too Many Requests",
4207
+ 500: "Internal Server Error",
4208
+ 503: "Service Unavailable"
4209
+ };
4210
+ const retryAfter = status === 429 && retryAfterSeconds !== void 0 ? `Retry-After: ${Math.max(1, Math.ceil(retryAfterSeconds))}\r\n` : "";
4211
+ socket.end("HTTP/1.1 " + status + " " + (statusText[status] ?? "Error") + "\r\n" + retryAfter + "Content-Type: application/json\r\nContent-Length: " + Buffer.byteLength(body) + "\r\nConnection: close\r\n\r\n" + body);
4212
+ }
4213
+ function normalizedAddress(value) {
4214
+ if (!value) return null;
4215
+ const normalized = value.startsWith("::ffff:") ? value.slice(7) : value;
4216
+ return isIP(normalized) === 0 ? null : normalized;
4217
+ }
4218
+ function isLoopback(value) {
4219
+ const address = normalizedAddress(value);
4220
+ return address === "127.0.0.1" || address === "::1";
4221
+ }
4222
+ /**
4223
+ * Resolve a stable rate-limit key. The helper-supplied address is trusted only
4224
+ * on the private loopback hop; direct clients cannot spoof it.
4225
+ */
4226
+ function requestClientIdentity(req) {
4227
+ if (isLoopback(req.socket.remoteAddress)) {
4228
+ const forwarded = req.headers[CLIENT_IP_HEADER];
4229
+ const address = normalizedAddress(Array.isArray(forwarded) ? forwarded[0] : forwarded);
4230
+ if (address !== null) return address;
4231
+ }
4232
+ return normalizedAddress(req.socket.remoteAddress) ?? "unknown";
4233
+ }
4234
+ //#endregion
4235
+ //#region src/auth-rate-limit.ts
4236
+ const DEFAULT_AUTH_RATE_POLICY = {
4237
+ windowMs: 6e4,
4238
+ attemptsPerSource: 12,
4239
+ globalAttempts: 120,
4240
+ maxUnauthenticatedPerSource: 2,
4241
+ failureWindowMs: 6e5,
4242
+ failuresBeforeBlock: 5,
4243
+ blockMs: 9e5,
4244
+ maxSources: 4096
4245
+ };
4246
+ const noop = () => {};
4247
+ /** Bounded in-memory protection for anonymous authentication attempts. */
4248
+ var AuthRateLimiter = class {
4249
+ policy;
4250
+ sources = /* @__PURE__ */ new Map();
4251
+ globalAttempts = [];
4252
+ constructor(policy = DEFAULT_AUTH_RATE_POLICY) {
4253
+ this.policy = policy;
4254
+ }
4255
+ admit(source, now = Date.now()) {
4256
+ const state = this.source(source, now);
4257
+ if (state === null) return {
4258
+ ok: false,
4259
+ retryAfterMs: this.policy.windowMs,
4260
+ release: noop
4261
+ };
4262
+ this.prune(state, now);
4263
+ state.lastSeen = now;
4264
+ if (state.blockedUntil > now) return {
4265
+ ok: false,
4266
+ retryAfterMs: state.blockedUntil - now,
4267
+ release: noop
4268
+ };
4269
+ if (state.active >= this.policy.maxUnauthenticatedPerSource) return {
4270
+ ok: false,
4271
+ retryAfterMs: this.policy.windowMs,
4272
+ release: noop
4273
+ };
4274
+ if (state.attempts.length >= this.policy.attemptsPerSource) return {
4275
+ ok: false,
4276
+ retryAfterMs: state.attempts[0] + this.policy.windowMs - now,
4277
+ release: noop
4278
+ };
4279
+ this.globalAttempts = this.globalAttempts.filter((ts) => ts > now - this.policy.windowMs);
4280
+ if (this.globalAttempts.length >= this.policy.globalAttempts) return {
4281
+ ok: false,
4282
+ retryAfterMs: this.globalAttempts[0] + this.policy.windowMs - now,
4283
+ release: noop
4284
+ };
4285
+ state.attempts.push(now);
4286
+ this.globalAttempts.push(now);
4287
+ state.active += 1;
4288
+ let released = false;
4289
+ return {
4290
+ ok: true,
4291
+ retryAfterMs: 0,
4292
+ release: () => {
4293
+ if (released) return;
4294
+ released = true;
4295
+ state.active = Math.max(0, state.active - 1);
4296
+ }
4297
+ };
4298
+ }
4299
+ recordFailure(source, now = Date.now()) {
4300
+ const state = this.source(source, now);
4301
+ if (state === null) return {
4302
+ blocked: true,
4303
+ newlyBlocked: false,
4304
+ retryAfterMs: this.policy.blockMs
4305
+ };
4306
+ this.prune(state, now);
4307
+ state.lastSeen = now;
4308
+ const wasBlocked = state.blockedUntil > now;
4309
+ state.failures.push(now);
4310
+ if (state.failures.length >= this.policy.failuresBeforeBlock) state.blockedUntil = Math.max(state.blockedUntil, now + this.policy.blockMs);
4311
+ return {
4312
+ blocked: state.blockedUntil > now,
4313
+ newlyBlocked: !wasBlocked && state.blockedUntil > now,
4314
+ retryAfterMs: Math.max(0, state.blockedUntil - now)
4315
+ };
4316
+ }
4317
+ recordSuccess(source, now = Date.now()) {
4318
+ const state = this.sources.get(source);
4319
+ if (state === void 0) return;
4320
+ state.failures = [];
4321
+ state.blockedUntil = 0;
4322
+ state.lastSeen = now;
4323
+ }
4324
+ source(source, now) {
4325
+ const existing = this.sources.get(source);
4326
+ if (existing !== void 0) return existing;
4327
+ if (this.sources.size >= this.policy.maxSources) this.pruneSources(now);
4328
+ if (this.sources.size >= this.policy.maxSources) return null;
4329
+ const state = {
4330
+ attempts: [],
4331
+ failures: [],
4332
+ blockedUntil: 0,
4333
+ active: 0,
4334
+ lastSeen: now
4335
+ };
4336
+ this.sources.set(source, state);
4337
+ return state;
4338
+ }
4339
+ prune(state, now) {
4340
+ state.attempts = state.attempts.filter((ts) => ts > now - this.policy.windowMs);
4341
+ state.failures = state.failures.filter((ts) => ts > now - this.policy.failureWindowMs);
4342
+ if (state.blockedUntil <= now) state.blockedUntil = 0;
4343
+ }
4344
+ pruneSources(now) {
4345
+ const staleBefore = now - Math.max(this.policy.failureWindowMs, this.policy.blockMs);
4346
+ for (const [source, state] of this.sources) {
4347
+ this.prune(state, now);
4348
+ if (state.active === 0 && state.blockedUntil === 0 && state.lastSeen < staleBefore) this.sources.delete(source);
4349
+ }
4350
+ }
4351
+ };
4352
+ //#endregion
4353
+ //#region src/push-policy.ts
4354
+ /** Prune only when the provider supplies an authoritative token-lifecycle verdict. */
4355
+ function shouldPrunePushToken(outcome, reason) {
4356
+ return outcome === "invalid-token" && (reason === "Unregistered" || reason === "ExpiredToken");
4357
+ }
4358
+ /**
4359
+ * Zero-touch relay self-heal: HTTP 401 means the relay no longer honors the
4360
+ * cached credential. Only auto-enrolled cells with a still-current token may
4361
+ * re-derive it; an explicitly configured relay token remains user-owned
4362
+ * configuration and is never silently rewritten.
4363
+ */
4364
+ function shouldReEnrollRelayToken(transport, outcome, reason, opts) {
4365
+ return transport === "relay" && outcome === "failed" && reason === "HTTP 401" && opts.hasEnrollKey && opts.usedCellToken && opts.tokenStillCurrent;
4366
+ }
4367
+ //#endregion
4368
+ //#region src/index.ts
4369
+ /**
4370
+ * dsh-deeppilot — data bridge between the DSH host and DeepPilot
4371
+ * clients. Registers exactly one WebSocket upgrade route (/phone) plus an
4372
+ * optional health probe (/phone/health) on the existing web server. The web
4373
+ * UI is never touched.
4374
+ *
4375
+ * Data plane: an in-process HostBridge consumes a local compatibility façade
4376
+ * over DSH 0.1.2 Session/Workspace controllers, mirrors session summaries,
4377
+ * tracks pending approvals/questions, and fans projected protocol-v2 pushes
4378
+ * out to every connected device.
4379
+ *
4380
+ * Protocol: PROTOCOL.md is normative; src/protocol.ts and the private app's
4381
+ * Swift models mirror that v2 contract.
4382
+ */
4383
+ const name = "deeppilot";
4384
+ /** No eager service requirement: profiles without a web stack simply skip. */
4385
+ const inject = [];
3385
4386
  const SERVER_VERSION = readOwnPackageVersion();
3386
4387
  const MAX_CLIENT_CONNECTIONS = 16;
3387
4388
  /**
@@ -3406,38 +4407,13 @@ function readOwnPackageVersion() {
3406
4407
  if (typeof envVersion === "string" && envVersion.length > 0) return envVersion;
3407
4408
  return "0.0.0+unknown";
3408
4409
  }
3409
- function rejectUpgrade(socket, status, reason) {
3410
- const body = JSON.stringify({ error: reason });
3411
- socket.end("HTTP/1.1 " + status + " Forbidden\r\nContent-Type: application/json\r\nContent-Length: " + Buffer.byteLength(body) + "\r\nConnection: close\r\n\r\n" + body);
3412
- }
3413
- /** Authorization is preferred; the query form remains for older app builds. */
3414
- function requestToken(req) {
3415
- const authorization = req.headers.authorization;
3416
- if (typeof authorization === "string") {
3417
- const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
3418
- if (match?.[1]) return match[1];
3419
- }
3420
- try {
3421
- return new URL(req.url ?? "/", "http://phone.local").searchParams.get("token");
3422
- } catch {
3423
- return null;
3424
- }
3425
- }
3426
- /**
3427
- * Cordis hands the second argument in different shapes depending on host
3428
- * composition: a reactive options getter, the resolved config value, or
3429
- * nothing when the patch row omits `config`. Normalize all of them.
3430
- */
3431
- function normalizeOptions(options) {
3432
- if (typeof options === "function") return options();
3433
- if (options && typeof options === "object") return options;
3434
- return Config(void 0) ?? {};
3435
- }
3436
4410
  function apply(ctx, options) {
3437
4411
  const cfg = normalizeOptions(options);
3438
4412
  const log = (message) => {
3439
4413
  console.log("[deeppilot] " + message);
3440
4414
  };
4415
+ const auditSalt = randomBytes(32);
4416
+ const auditLabel = (value) => createHash("sha256").update(auditSalt).update(value).digest("hex").slice(0, 12);
3441
4417
  /**
3442
4418
  * Settings-section source: while a settings service is attached this holds
3443
4419
  * the user-edited section value; otherwise the composition defaults. Read
@@ -3461,16 +4437,22 @@ function apply(ctx, options) {
3461
4437
  const dataDir = bridgeDataDir();
3462
4438
  const pushRelayPath = join(dataDir, "push-relay.json");
3463
4439
  const enrollmentCell = {};
4440
+ let enrollmentWriteTail = Promise.resolve();
3464
4441
  function persistEnrollment() {
3465
- (async () => {
4442
+ const snapshot = JSON.stringify({
4443
+ version: 1,
4444
+ ...enrollmentCell
4445
+ }, null, 2) + "\n";
4446
+ enrollmentWriteTail = enrollmentWriteTail.then(async () => {
4447
+ const tempPath = pushRelayPath + "." + randomBytes(6).toString("hex") + ".tmp";
3466
4448
  try {
3467
4449
  await mkdir(dataDir, { recursive: true });
3468
- await writeFile(pushRelayPath, JSON.stringify({
3469
- version: 1,
3470
- ...enrollmentCell
3471
- }, null, 2) + "\n", { mode: 384 });
3472
- } catch {}
3473
- })();
4450
+ await writeFile(tempPath, snapshot, { mode: 384 });
4451
+ await rename(tempPath, pushRelayPath);
4452
+ } catch {
4453
+ await unlink(tempPath).catch(() => {});
4454
+ }
4455
+ });
3474
4456
  }
3475
4457
  /** Fired from BridgeConnection when an app presents its built-in key. */
3476
4458
  const handlePushEnrollKey = async (enrollKey) => {
@@ -3483,12 +4465,12 @@ function apply(ctx, options) {
3483
4465
  }
3484
4466
  }
3485
4467
  persistEnrollment();
3486
- const url = (currentConfig().push?.relayUrl ?? "").trim() || DEFAULT_RELAY_URL;
4468
+ const url = (currentConfig().push?.relayUrl ?? "").trim() || "https://pilot.hailab.dev";
3487
4469
  await ensureRelayEnrolled(url);
3488
4470
  };
4471
+ const pairingCodes = new PairingCodeManager();
3489
4472
  const auth = {
3490
- token: null,
3491
- tokenPath: cfg.authTokenPath ?? join(dataDir, "auth-token"),
4473
+ audience: null,
3492
4474
  devices: null
3493
4475
  };
3494
4476
  const ready = (async () => {
@@ -3499,13 +4481,13 @@ function apply(ctx, options) {
3499
4481
  } catch (error) {
3500
4482
  log("legacy plugin-state migration skipped: " + String(error));
3501
4483
  }
3502
- auth.tokenPath = cfg.authTokenPath ?? join(dataDir, "auth-token");
3503
- auth.token = await loadOrCreateToken(auth.tokenPath);
3504
- auth.devices = await DeviceStore.load(cfg.devicesPath ?? join(dataDir, "devices.json"));
4484
+ await ensurePrivateBridgeDataDir();
4485
+ auth.audience = await loadOrCreateHostAudience(join(dataDir, "host-id"));
4486
+ auth.devices = await DeviceStore.load(cfg.devicesPath ?? join(dataDir, "devices-v2.json"));
3505
4487
  {
3506
4488
  const rows = auth.devices.list();
3507
4489
  const registered = rows.filter((row) => row.apns !== void 0).length;
3508
- log(`device registry loaded from ${expandHome(cfg.devicesPath ?? join(dataDir, "devices.json"))}: ${rows.length} device(s), ${registered} push registration(s)`);
4490
+ log(`device registry loaded from ${expandHome(cfg.devicesPath ?? join(dataDir, "devices-v2.json"))}: ${rows.length} device(s), ${registered} push registration(s)`);
3509
4491
  }
3510
4492
  try {
3511
4493
  const raw = JSON.parse(await readFile(pushRelayPath, "utf8"));
@@ -3517,44 +4499,22 @@ function apply(ctx, options) {
3517
4499
  } catch (error) {
3518
4500
  log("auth material unavailable, bridge degraded: " + String(error));
3519
4501
  return {
3520
- token: null,
4502
+ audience: null,
3521
4503
  devices: null
3522
4504
  };
3523
4505
  }
3524
4506
  return {
3525
- token: auth.token,
4507
+ audience: auth.audience,
3526
4508
  devices: auth.devices
3527
4509
  };
3528
4510
  })();
3529
- /**
3530
- * Replace the pairing secret: persist a fresh token, clear the paired-device
3531
- * registry, and drop every live phone socket. Handshake-time auth means an
3532
- * already-open socket would otherwise outlive its token; terminating forces
3533
- * each device to re-pair with the new secret. The old token is invalid the
3534
- * moment the file is rewritten.
3535
- *
3536
- * Serialized through rotateTail so two overlapping invocations can never
3537
- * return a token that a later write already invalidated.
3538
- */
3539
- const doRotate = async () => {
3540
- const { devices } = await ready;
3541
- if (auth.token === null || devices === null) throw new Error("pairing token unavailable");
3542
- auth.token = await writeNewToken(auth.tokenPath);
3543
- devices.clear();
3544
- let dropped = 0;
3545
- for (const connection of connections) {
3546
- connection.terminate();
3547
- dropped += 1;
3548
- }
3549
- connections.clear();
3550
- log(`pairing token rotated; ${dropped} live phone connection(s) dropped`);
3551
- return auth.token;
3552
- };
3553
- let rotateTail = Promise.resolve();
3554
- const rotatePairingToken = () => {
3555
- const next = rotateTail.then(doRotate);
3556
- rotateTail = next.catch(() => {});
3557
- return next;
4511
+ const beginPairing = async () => {
4512
+ await ready;
4513
+ if (auth.audience === null || auth.devices === null) throw new Error("device authentication unavailable");
4514
+ return {
4515
+ ...pairingCodes.issue(),
4516
+ audience: auth.audience
4517
+ };
3558
4518
  };
3559
4519
  /**
3560
4520
  * Settings-page push self-test: force one synthetic notification down the
@@ -3615,12 +4575,23 @@ function apply(ctx, options) {
3615
4575
  };
3616
4576
  };
3617
4577
  const connections = /* @__PURE__ */ new Set();
4578
+ const closeConnectionsForBridge = (bridge) => {
4579
+ for (const connection of connections) {
4580
+ if (!connection.isAttachedTo(bridge)) continue;
4581
+ connection.closeForServerStop();
4582
+ connections.delete(connection);
4583
+ }
4584
+ };
4585
+ const closeAllConnections = () => {
4586
+ for (const connection of connections) connection.closeForServerStop();
4587
+ connections.clear();
4588
+ };
3618
4589
  const resolvePushConfig = (config) => {
3619
4590
  const push = config.push ?? {};
3620
4591
  const configured = push.provider ?? "none";
3621
4592
  const effectiveProvider = configured === "none" && enrollmentCell.autoRelay === true ? "relay" : configured;
3622
4593
  if (effectiveProvider === "relay") {
3623
- const url = (push.relayUrl ?? "").trim() || DEFAULT_RELAY_URL;
4594
+ const url = (push.relayUrl ?? "").trim() || "https://pilot.hailab.dev";
3624
4595
  const token = (push.relayToken ?? "").trim() || enrollmentCell.token || "";
3625
4596
  if (!/^https:\/\//i.test(url)) return {
3626
4597
  ok: false,
@@ -3782,7 +4753,8 @@ function apply(ctx, options) {
3782
4753
  * - each device is delivered on ITS registered environment (the build
3783
4754
  * kind it self-reported), so sandbox and production devices coexist;
3784
4755
  * - the device's per-category switches suppress muted categories;
3785
- * - Unregistered/BadDeviceToken outcomes prune the stored token.
4756
+ * - only APNs' terminal Unregistered/ExpiredToken verdicts prune storage;
4757
+ * BadDeviceToken may be an environment mismatch and stays diagnosable.
3786
4758
  */
3787
4759
  const makePushOutlet = () => ({
3788
4760
  isAvailable: () => {
@@ -3795,7 +4767,7 @@ function apply(ctx, options) {
3795
4767
  (async () => {
3796
4768
  let resolved = resolvePushConfig(currentConfig());
3797
4769
  if (!resolved.ok && resolved.reason === "relay token not enrolled yet") {
3798
- const relayUrl = (currentConfig().push?.relayUrl ?? "").trim() || DEFAULT_RELAY_URL;
4770
+ const relayUrl = (currentConfig().push?.relayUrl ?? "").trim() || "https://pilot.hailab.dev";
3799
4771
  await ensureRelayEnrolled(relayUrl);
3800
4772
  resolved = resolvePushConfig(currentConfig());
3801
4773
  }
@@ -3825,6 +4797,10 @@ function apply(ctx, options) {
3825
4797
  log(`push(${transport}) ${notification.category}: no offline targets (connected=${connectedIds.size}, tokenized=${tokenized})`);
3826
4798
  return;
3827
4799
  }
4800
+ const relayUrl = resolved.value.kind === "relay" ? resolved.value.url : void 0;
4801
+ const relayTokenUsed = resolved.value.kind === "relay" ? resolved.value.token : void 0;
4802
+ const usedCellToken = relayTokenUsed !== void 0 && relayTokenUsed === enrollmentCell.token;
4803
+ const hasEnrollKey = Boolean(enrollmentCell.enrollKey);
3828
4804
  for (const device of candidates) {
3829
4805
  const registration = device.apns;
3830
4806
  send({
@@ -3833,9 +4809,20 @@ function apply(ctx, options) {
3833
4809
  notification
3834
4810
  }).then(({ outcome, reason }) => {
3835
4811
  log(`push(${transport}) ${notification.category} → "${device.deviceName}" [${registration.environment}] = ${outcome}${reason ? " (" + reason + ")" : ""}`);
3836
- if (outcome === "invalid-token") {
4812
+ if (shouldPrunePushToken(outcome, reason)) {
3837
4813
  devices.clearPushToken(device.deviceId);
3838
4814
  log(`push: pruned stale token of "${device.deviceName}" (${reason ?? "unknown"}) — app re-registers on next launch`);
4815
+ return;
4816
+ }
4817
+ if (relayUrl !== void 0 && shouldReEnrollRelayToken(transport, outcome, reason, {
4818
+ usedCellToken,
4819
+ hasEnrollKey,
4820
+ tokenStillCurrent: enrollmentCell.token === relayTokenUsed
4821
+ })) {
4822
+ enrollmentCell.token = void 0;
4823
+ persistEnrollment();
4824
+ log("push relay credential rejected (HTTP 401); re-enrolling");
4825
+ ensureRelayEnrolled(relayUrl);
3839
4826
  }
3840
4827
  }).catch(() => {});
3841
4828
  }
@@ -3859,17 +4846,20 @@ function apply(ctx, options) {
3859
4846
  updateChecker.scheduleInitial();
3860
4847
  const updateInfo = () => updateChecker.get();
3861
4848
  applyReportRemote(ctx, async () => {
3862
- let tokenReady = false;
4849
+ let pairingReady = false;
3863
4850
  let devices = [];
3864
4851
  try {
3865
4852
  await ready;
3866
- tokenReady = auth.token !== null;
3867
- devices = (auth.devices?.list() ?? []).map(({ deviceId, deviceName, appVersion, firstSeenTs, lastSeenTs, apns }) => ({
4853
+ pairingReady = auth.audience !== null && auth.devices !== null;
4854
+ devices = (auth.devices?.list() ?? []).filter((device) => device.publicKey !== void 0 && device.fingerprint !== void 0).map(({ deviceId, deviceName, appVersion, firstSeenTs, lastSeenTs, fingerprint, scopes, revokedAt, apns }) => ({
3868
4855
  deviceId,
3869
4856
  deviceName,
3870
4857
  appVersion,
3871
4858
  firstSeenTs,
3872
4859
  lastSeenTs,
4860
+ fingerprint,
4861
+ scopes: normalizeDeviceScopes(scopes),
4862
+ ...revokedAt !== void 0 ? { revokedAt } : {},
3873
4863
  ...apns ? { apns: {
3874
4864
  environment: apns.environment,
3875
4865
  updatedAt: apns.updatedAt
@@ -3878,14 +4868,14 @@ function apply(ctx, options) {
3878
4868
  } catch {}
3879
4869
  const update = updateInfo();
3880
4870
  return {
3881
- protocolVersion: 1,
4871
+ protocolVersion: 2,
3882
4872
  serverVersion: SERVER_VERSION,
3883
4873
  pluginVersion: update.currentVersion,
3884
4874
  ...update.available ? { updateAvailable: true } : {},
3885
4875
  ...update.releaseUrl !== null ? { releaseUrl: update.releaseUrl } : {},
3886
4876
  enabled: currentConfig().enabled === true,
3887
- tokenPath: expandHome(currentConfig().authTokenPath ?? join(bridgeDataDir(), "auth-token")),
3888
- tokenReady,
4877
+ identityPath: expandHome(currentConfig().devicesPath ?? join(bridgeDataDir(), "devices-v2.json")),
4878
+ pairingReady,
3889
4879
  activeConnections: connections.size,
3890
4880
  historyBufferMax: currentConfig().historyBufferMax ?? 2e3,
3891
4881
  debug: currentConfig().debug === true,
@@ -3893,11 +4883,32 @@ function apply(ctx, options) {
3893
4883
  remote: remoteStatus(),
3894
4884
  devices
3895
4885
  };
4886
+ }, beginPairing, async (deviceId) => {
4887
+ const { devices } = await ready;
4888
+ if (!devices) throw new Error("device registry unavailable");
4889
+ const revoked = devices.revoke(deviceId, Date.now());
4890
+ if (revoked) {
4891
+ for (const connection of [...connections]) {
4892
+ if (connection.connectedDeviceId !== deviceId) continue;
4893
+ connection.terminate();
4894
+ connections.delete(connection);
4895
+ }
4896
+ log(`device revoked id=${auditLabel(deviceId)}`);
4897
+ }
4898
+ return revoked;
4899
+ }, async (deviceId, scopes) => {
4900
+ const { devices } = await ready;
4901
+ if (!devices) throw new Error("device registry unavailable");
4902
+ const updated = devices.setScopes(deviceId, scopes);
4903
+ if (updated === null) throw new Error("active device not found");
4904
+ for (const connection of [...connections]) {
4905
+ if (connection.connectedDeviceId !== deviceId) continue;
4906
+ connection.terminate();
4907
+ connections.delete(connection);
4908
+ }
4909
+ log(`device scopes updated id=${auditLabel(deviceId)} scopes=${updated.join(",")}`);
4910
+ return updated;
3896
4911
  }, async () => {
3897
- await ready;
3898
- if (auth.token === null) throw new Error("pairing token unavailable");
3899
- return auth.token;
3900
- }, rotatePairingToken, async () => {
3901
4912
  const push = currentConfig().push ?? {};
3902
4913
  const configured = push.provider ?? "none";
3903
4914
  if ((configured === "none" && enrollmentCell.autoRelay === true ? "relay" : configured) !== "relay") return {
@@ -3910,7 +4921,7 @@ function apply(ctx, options) {
3910
4921
  message: `当前推送模式不是中继(provider=${configured})。启用方式二选一:① 零配置——在 ios/project.yml 填写 DSPushEnrollKey(与服务器 RELAY_ENROLL_KEY 一致)并重新安装 App,打开 App 即自动启用;② 手动——将 push.provider 设为 relay 并填入 relayToken`
3911
4922
  }]
3912
4923
  };
3913
- const url = (push.relayUrl ?? "").trim() || DEFAULT_RELAY_URL;
4924
+ const url = (push.relayUrl ?? "").trim() || "https://pilot.hailab.dev";
3914
4925
  if (!/^https:\/\//i.test(url)) return {
3915
4926
  url,
3916
4927
  overall: "failed",
@@ -3940,6 +4951,103 @@ function apply(ctx, options) {
3940
4951
  return await runPushSelfTest();
3941
4952
  });
3942
4953
  const state = {};
4954
+ let pendingUpgrades = 0;
4955
+ const authRateLimiter = new AuthRateLimiter();
4956
+ const readJSONBody = async (req, maxBytes = 16384) => {
4957
+ const chunks = [];
4958
+ let size = 0;
4959
+ for await (const chunk of req) {
4960
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
4961
+ size += buffer.length;
4962
+ if (size > maxBytes) throw new Error("request body too large");
4963
+ chunks.push(buffer);
4964
+ }
4965
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
4966
+ };
4967
+ const handlePair = async (req, res) => {
4968
+ res.setHeader("Content-Type", "application/json");
4969
+ if (!enabledNow()) {
4970
+ res.statusCode = 503;
4971
+ res.end(JSON.stringify({
4972
+ ok: false,
4973
+ error: "bridge disabled"
4974
+ }));
4975
+ return;
4976
+ }
4977
+ if (req.method !== "POST") {
4978
+ res.statusCode = 405;
4979
+ res.setHeader("Allow", "POST");
4980
+ res.end(JSON.stringify({
4981
+ ok: false,
4982
+ error: "POST required"
4983
+ }));
4984
+ return;
4985
+ }
4986
+ const source = requestClientIdentity(req);
4987
+ const admission = authRateLimiter.admit(source);
4988
+ if (!admission.ok) {
4989
+ res.statusCode = 429;
4990
+ res.setHeader("Retry-After", String(Math.max(1, Math.ceil(admission.retryAfterMs / 1e3))));
4991
+ res.end(JSON.stringify({
4992
+ ok: false,
4993
+ error: "pairing rate limited"
4994
+ }));
4995
+ return;
4996
+ }
4997
+ try {
4998
+ const { devices, audience } = await ready;
4999
+ if (!devices || !audience) throw new Error("device authentication unavailable");
5000
+ const raw = await readJSONBody(req);
5001
+ if (raw === null || typeof raw !== "object" || raw.v !== 2) throw new TypeError("protocol v2 required");
5002
+ const code = typeof raw.code === "string" ? raw.code : "";
5003
+ const publicKey = typeof raw.publicKey === "string" ? raw.publicKey : "";
5004
+ const deviceName = sanitizeDeviceField(raw.deviceName, 64) || "unknown";
5005
+ const appVersion = sanitizeDeviceField(raw.appVersion, 32) || "unknown";
5006
+ const deviceId = deviceIdForPublicKey(publicKey);
5007
+ if (devices.list().length >= 64 && devices.authorized(deviceId) === void 0) {
5008
+ res.statusCode = 409;
5009
+ res.end(JSON.stringify({
5010
+ ok: false,
5011
+ error: "device registry is full"
5012
+ }));
5013
+ return;
5014
+ }
5015
+ if (!pairingCodes.consume(code)) {
5016
+ const failure = authRateLimiter.recordFailure(source);
5017
+ res.statusCode = failure.blocked ? 429 : 401;
5018
+ if (failure.retryAfterMs > 0) res.setHeader("Retry-After", String(Math.max(1, Math.ceil(failure.retryAfterMs / 1e3))));
5019
+ res.end(JSON.stringify({
5020
+ ok: false,
5021
+ error: failure.blocked ? "pairing rate limited" : "pairing code invalid or expired"
5022
+ }));
5023
+ return;
5024
+ }
5025
+ const record = devices.register({
5026
+ publicKey,
5027
+ deviceName,
5028
+ appVersion,
5029
+ scopes: normalizeDeviceScopes(raw.scopes)
5030
+ }, Date.now());
5031
+ authRateLimiter.recordSuccess(source);
5032
+ log(`device paired id=${auditLabel(record.deviceId)} source=${auditLabel(source)}`);
5033
+ res.statusCode = 201;
5034
+ res.end(JSON.stringify({
5035
+ ok: true,
5036
+ v: 2,
5037
+ deviceId: record.deviceId,
5038
+ audience,
5039
+ scopes: record.scopes ?? []
5040
+ }));
5041
+ } catch (error) {
5042
+ res.statusCode = error instanceof SyntaxError || error instanceof TypeError ? 400 : 503;
5043
+ res.end(JSON.stringify({
5044
+ ok: false,
5045
+ error: error instanceof Error ? error.message : "pairing failed"
5046
+ }));
5047
+ } finally {
5048
+ admission.release();
5049
+ }
5050
+ };
3943
5051
  const handleUpgrade = (req, socket, head) => {
3944
5052
  (async () => {
3945
5053
  try {
@@ -3947,44 +5055,69 @@ function apply(ctx, options) {
3947
5055
  rejectUpgrade(socket, 503, "bridge disabled");
3948
5056
  return;
3949
5057
  }
3950
- if (connections.size >= MAX_CLIENT_CONNECTIONS) {
5058
+ if (connections.size + pendingUpgrades >= MAX_CLIENT_CONNECTIONS) {
3951
5059
  rejectUpgrade(socket, 429, "too many connections");
3952
5060
  return;
3953
5061
  }
3954
- const { devices } = await ready;
3955
- const token = auth.token;
3956
- if (!token || !devices) {
3957
- rejectUpgrade(socket, 503, "bridge degraded");
3958
- return;
3959
- }
3960
- const presentedToken = requestToken(req);
3961
- if (presentedToken !== null && !tokenMatches(presentedToken, token)) {
3962
- rejectUpgrade(socket, 401, "invalid token");
3963
- return;
3964
- }
3965
- const bridge = state.bridge;
3966
- if (!bridge) {
3967
- rejectUpgrade(socket, 503, "bridge not ready");
3968
- return;
3969
- }
3970
- if (auth.token !== token) {
3971
- rejectUpgrade(socket, 401, "invalid token");
3972
- return;
5062
+ pendingUpgrades += 1;
5063
+ try {
5064
+ const { devices, audience } = await ready;
5065
+ if (!audience || !devices) {
5066
+ rejectUpgrade(socket, 503, "bridge degraded");
5067
+ return;
5068
+ }
5069
+ const source = requestClientIdentity(req);
5070
+ const admission = authRateLimiter.admit(source);
5071
+ if (!admission.ok) {
5072
+ rejectUpgrade(socket, 429, "authentication rate limited", admission.retryAfterMs / 1e3);
5073
+ return;
5074
+ }
5075
+ const bridge = state.bridge;
5076
+ if (!bridge) {
5077
+ admission.release();
5078
+ rejectUpgrade(socket, 503, "bridge not ready");
5079
+ return;
5080
+ }
5081
+ try {
5082
+ wss.handleUpgrade(req, socket, head, (ws) => {
5083
+ if (auth.audience !== audience || state.bridge !== bridge) {
5084
+ admission.release();
5085
+ ws.close(1012, "bridge changed");
5086
+ return;
5087
+ }
5088
+ try {
5089
+ const connection = new BridgeConnection(ws, {
5090
+ bridge,
5091
+ devices,
5092
+ serverVersion: SERVER_VERSION,
5093
+ audience,
5094
+ log,
5095
+ debug: currentConfig().debug === true,
5096
+ onClosed: (closed) => connections.delete(closed),
5097
+ onAuthenticationSettled: (ok) => {
5098
+ admission.release();
5099
+ if (ok) authRateLimiter.recordSuccess(source);
5100
+ else if (authRateLimiter.recordFailure(source).newlyBlocked) log(`authentication source blocked source=${auditLabel(source)}`);
5101
+ },
5102
+ onDeviceAuthenticated: (deviceId) => {
5103
+ log(`device authenticated id=${auditLabel(deviceId)} source=${auditLabel(source)}`);
5104
+ },
5105
+ onPushEnrollKey: handlePushEnrollKey
5106
+ });
5107
+ connections.add(connection);
5108
+ } catch (error) {
5109
+ admission.release();
5110
+ ws.close(1011, "connection setup failed");
5111
+ throw error;
5112
+ }
5113
+ });
5114
+ } catch (error) {
5115
+ admission.release();
5116
+ throw error;
5117
+ }
5118
+ } finally {
5119
+ pendingUpgrades -= 1;
3973
5120
  }
3974
- wss.handleUpgrade(req, socket, head, (ws) => {
3975
- const connection = new BridgeConnection(ws, {
3976
- bridge,
3977
- devices,
3978
- serverVersion: SERVER_VERSION,
3979
- expectedToken: token,
3980
- transportAuthenticated: presentedToken !== null,
3981
- log,
3982
- debug: currentConfig().debug === true,
3983
- onClosed: (closed) => connections.delete(closed),
3984
- onPushEnrollKey: handlePushEnrollKey
3985
- });
3986
- connections.add(connection);
3987
- });
3988
5121
  } catch (error) {
3989
5122
  log("upgrade failed: " + String(error));
3990
5123
  rejectUpgrade(socket, 500, "internal error");
@@ -3994,9 +5127,8 @@ function apply(ctx, options) {
3994
5127
  const handleHealth = async (req, res) => {
3995
5128
  try {
3996
5129
  await ready;
3997
- const token = auth.token;
3998
5130
  res.setHeader("Content-Type", "application/json");
3999
- if (!token) {
5131
+ if (!auth.audience || !auth.devices) {
4000
5132
  res.statusCode = 503;
4001
5133
  res.end(JSON.stringify({
4002
5134
  ok: false,
@@ -4004,16 +5136,11 @@ function apply(ctx, options) {
4004
5136
  }));
4005
5137
  return;
4006
5138
  }
4007
- if (!tokenMatches(requestToken(req), token)) {
4008
- res.statusCode = 401;
4009
- res.end(JSON.stringify({ ok: false }));
4010
- return;
4011
- }
4012
5139
  res.statusCode = 200;
4013
5140
  res.end(JSON.stringify({
4014
5141
  ok: true,
4015
5142
  enabled: enabledNow(),
4016
- protocolVersion: 1,
5143
+ protocolVersion: 2,
4017
5144
  serverVersion: SERVER_VERSION,
4018
5145
  dataPlane: Boolean(state.bridge)
4019
5146
  }));
@@ -4022,15 +5149,17 @@ function apply(ctx, options) {
4022
5149
  res.end(JSON.stringify({ ok: false }));
4023
5150
  }
4024
5151
  };
4025
- ctx.inject(["apiProxy"], (sub) => {
5152
+ ctx.inject(["sessionController"], (sub) => {
4026
5153
  if (currentConfig().enabled !== true) {
4027
5154
  log("bridge disabled; data plane stays inactive");
4028
5155
  return;
4029
5156
  }
4030
5157
  const apiCtx = sub;
4031
- const proxy = apiCtx.apiProxy;
4032
- if (!proxy) {
4033
- log("apiProxy service absent; data plane stays inactive");
5158
+ let proxy;
5159
+ try {
5160
+ proxy = new Dsh012ApiProxy(apiCtx);
5161
+ } catch (error) {
5162
+ log("dsh 0.1.2 session bridge unavailable: " + String(error));
4034
5163
  return;
4035
5164
  }
4036
5165
  const bridge = new HostBridge(proxy, cfg.historyBufferMax);
@@ -4038,7 +5167,11 @@ function apply(ctx, options) {
4038
5167
  state.bridge = bridge;
4039
5168
  bridge.start();
4040
5169
  log("data plane active (mux + host streams)");
4041
- apiCtx.effect(() => () => bridge.dispose(), "deeppilot: host streams");
5170
+ apiCtx.effect(() => () => {
5171
+ closeConnectionsForBridge(bridge);
5172
+ if (state.bridge === bridge) state.bridge = void 0;
5173
+ bridge.dispose();
5174
+ }, "deeppilot: host streams");
4042
5175
  });
4043
5176
  ctx.inject(["webServer"], (sub) => {
4044
5177
  const webCtx = sub;
@@ -4056,11 +5189,16 @@ function apply(ctx, options) {
4056
5189
  path: "/phone/health",
4057
5190
  handler: handleHealth
4058
5191
  }), "deeppilot: /phone/health");
5192
+ webCtx.effect(() => web.register({
5193
+ kind: "exact",
5194
+ path: "/phone/pair",
5195
+ handler: handlePair
5196
+ }), "deeppilot: /phone/pair");
4059
5197
  const sweep = setInterval(() => {
4060
5198
  const now = Date.now();
4061
5199
  for (const connection of connections) if (connection.isStale(now, 6e4)) {
4062
5200
  log("dropping stale connection");
4063
- connection.terminate();
5201
+ connection.closeIdle();
4064
5202
  connections.delete(connection);
4065
5203
  }
4066
5204
  }, 3e4);
@@ -4071,6 +5209,7 @@ function apply(ctx, options) {
4071
5209
  path = new URL(req.url ?? "/", "http://phone.local").pathname;
4072
5210
  } catch {}
4073
5211
  if (path === "/phone/health") handleHealth(req, res);
5212
+ else if (path === "/phone/pair") handlePair(req, res);
4074
5213
  else {
4075
5214
  res.statusCode = 404;
4076
5215
  res.end("not found");
@@ -4102,7 +5241,8 @@ function apply(ctx, options) {
4102
5241
  hostname: normalizeRemoteHostname(remoteConfig.hostname),
4103
5242
  statePath: remoteConfig.statePath?.trim() || join(dataDir, "tailscale"),
4104
5243
  helperPath,
4105
- funnelPort: remotePort
5244
+ funnelPort: remotePort,
5245
+ maxConnectionsPerSource: normalizeFunnelConnectionLimit(remoteConfig.maxConnectionsPerSource)
4106
5246
  };
4107
5247
  const nextKey = JSON.stringify(next);
4108
5248
  if (nextKey === appliedRemoteKey) return;
@@ -4116,6 +5256,7 @@ function apply(ctx, options) {
4116
5256
  statePath: next.statePath,
4117
5257
  ...next.helperPath ? { helperPath: next.helperPath } : {},
4118
5258
  funnelPort: next.funnelPort,
5259
+ maxConnectionsPerSource: next.maxConnectionsPerSource,
4119
5260
  log
4120
5261
  });
4121
5262
  remoteSupervisor = supervisor;
@@ -4146,8 +5287,23 @@ function apply(ctx, options) {
4146
5287
  if (enabledNow()) log("/phone WebSocket registered");
4147
5288
  else log("bridge disabled; /phone refuses connections until re-enabled and restarted");
4148
5289
  });
5290
+ ctx.effect(() => async () => {
5291
+ closeAllConnections();
5292
+ const bridge = state.bridge;
5293
+ state.bridge = void 0;
5294
+ bridge?.dispose();
5295
+ const sender = cachedSender;
5296
+ cachedSender = void 0;
5297
+ updateChecker.dispose();
5298
+ const wssClosed = new Promise((resolve) => wss.close(() => resolve()));
5299
+ await Promise.allSettled([
5300
+ enrollmentWriteTail,
5301
+ sender?.dispose?.() ?? Promise.resolve(),
5302
+ wssClosed
5303
+ ]);
5304
+ }, "deeppilot: process resources");
4149
5305
  }
4150
5306
  //#endregion
4151
- export { Config, HostBridge, apply, inject, name, requestToken };
5307
+ export { Config, HostBridge, apply, inject, name, shouldPrunePushToken, shouldReEnrollRelayToken };
4152
5308
 
4153
5309
  //# sourceMappingURL=index.js.map