dsh-deeppilot 0.6.1 → 0.7.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,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import { createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID, sign, timingSafeEqual, verify } from "node:crypto";
2
+ import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID, sign, timingSafeEqual, verify } from "node:crypto";
3
3
  import { access, chmod, mkdir, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { WebSocketServer } from "ws";
@@ -11,10 +11,11 @@ import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
11
11
  import { connect } from "node:http2";
12
12
  import { spawn } from "node:child_process";
13
13
  import { fileURLToPath } from "node:url";
14
- import { request } from "node:https";
14
+ import { createServer, request } from "node:https";
15
15
  import z from "@deepseek-ai/schemastery";
16
16
  import { isIP } from "node:net";
17
- import { createServer } from "node:http";
17
+ import { createServer as createServer$1 } from "node:http";
18
+ import { generate } from "selfsigned";
18
19
  function validSendId(id) {
19
20
  return typeof id === "string" && /^\d{13}-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
20
21
  }
@@ -243,6 +244,10 @@ function validateRequest(type, value) {
243
244
  ids.add(answer.id);
244
245
  }
245
246
  }
247
+ if (type === "c2s.liveActivity.register" || type === "c2s.liveActivity.unregister") {
248
+ if (!text(p.activityId, 128)) return "invalid activityId";
249
+ if (type === "c2s.liveActivity.register" && (!text(p.sessionId) || typeof p.deviceToken !== "string" || !/^[0-9a-fA-F]{32,512}$/.test(p.deviceToken) || !["development", "production"].includes(p.environment) || !optional("enrollKey", (v) => text(v, 128)))) return "invalid live activity registration";
250
+ }
246
251
  if (type === "c2s.push.register" || type === "c2s.widget.push.register") {
247
252
  if (p.environment !== void 0 && p.environment !== "production" && p.environment !== "development") return "invalid APNs environment";
248
253
  if (!optional("enrollKey", (v) => text(v, 128))) return "invalid enrollKey";
@@ -462,7 +467,8 @@ var DeviceStore = class DeviceStore {
462
467
  firstSeenTs: existing?.firstSeenTs ?? now,
463
468
  lastSeenTs: now,
464
469
  ...existing?.apns ? { apns: existing.apns } : {},
465
- ...existing?.widgetApns ? { widgetApns: existing.widgetApns } : {}
470
+ ...existing?.widgetApns ? { widgetApns: existing.widgetApns } : {},
471
+ ...existing?.liveActivity ? { liveActivity: existing.liveActivity } : {}
466
472
  };
467
473
  this.devices.set(deviceId, next);
468
474
  this.flush();
@@ -488,6 +494,7 @@ var DeviceStore = class DeviceStore {
488
494
  record.revokedAt = now;
489
495
  delete record.apns;
490
496
  delete record.widgetApns;
497
+ delete record.liveActivity;
491
498
  this.flush();
492
499
  return true;
493
500
  }
@@ -533,6 +540,29 @@ var DeviceStore = class DeviceStore {
533
540
  delete record.apns;
534
541
  this.flush();
535
542
  }
543
+ setLiveActivity(deviceId, registration) {
544
+ const record = this.authorized(deviceId);
545
+ if (!record || !isValidApnsToken(registration.token)) return;
546
+ const old = record.liveActivity;
547
+ record.liveActivity = old?.activityId === registration.activityId ? {
548
+ ...registration,
549
+ expiresAt: old.expiresAt,
550
+ endedState: old.endedState
551
+ } : registration;
552
+ this.flush();
553
+ }
554
+ endLiveActivity(deviceId, token, state) {
555
+ const registration = this.devices.get(deviceId)?.liveActivity;
556
+ if (!registration || registration.token !== token || registration.endedState) return;
557
+ registration.endedState = state;
558
+ this.flush();
559
+ }
560
+ clearLiveActivity(deviceId, activityId, token) {
561
+ const record = this.devices.get(deviceId);
562
+ if (record?.liveActivity?.activityId !== activityId || token !== void 0 && record.liveActivity.token !== token) return;
563
+ delete record.liveActivity;
564
+ this.flush();
565
+ }
536
566
  setWidgetPushToken(deviceId, token, environment, now) {
537
567
  const record = this.authorized(deviceId);
538
568
  if (!record || !isValidApnsToken(token)) return;
@@ -613,8 +643,9 @@ function requiredScope(type) {
613
643
  if (type === "c2s.session.sendPrompt" || type === "c2s.session.delivery") return "prompt.send";
614
644
  if (type === "c2s.pending.list") return "interactions.respond";
615
645
  if (type === "c2s.approval.respond" || type === "c2s.question.respond") return "interactions.respond";
646
+ if (type === "c2s.liveActivity.register" || type === "c2s.liveActivity.unregister") return "notifications.register";
616
647
  if (type === "c2s.push.register" || type === "c2s.widget.push.register") return "notifications.register";
617
- 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";
648
+ if (type === "c2s.workspace.create" || type === "c2s.session.create" || type === "c2s.session.rename" || type === "c2s.session.archive" || type === "c2s.session.unarchive" || type === "c2s.session.cancel" || type === "c2s.session.selectModel") return "sessions.manage";
618
649
  if (type.startsWith("c2s.")) return "sessions.read";
619
650
  }
620
651
  /** Broadcast and replay authorization. Unknown frame types fail closed. */
@@ -693,6 +724,8 @@ var BridgeConnection = class {
693
724
  deviceId;
694
725
  scopes = /* @__PURE__ */ new Set();
695
726
  widgetClient = false;
727
+ liveActivityRegistrationGeneration = 0;
728
+ pendingLiveActivityId;
696
729
  authChallenge;
697
730
  constructor(ws, deps) {
698
731
  this.ws = ws;
@@ -992,6 +1025,18 @@ var BridgeConnection = class {
992
1025
  this.send("s2c.session.archived", { sessionId: p.sessionId }, env.id);
993
1026
  return;
994
1027
  }
1028
+ case "c2s.sessions.archived":
1029
+ this.send("s2c.sessions.archived.snapshot", { sessions: this.deps.bridge.listArchivedSessions() }, env.id);
1030
+ return;
1031
+ case "c2s.session.unarchive": {
1032
+ const p = env.payload;
1033
+ if (!p?.sessionId) return this.fail(env.id, "E_PROTOCOL", "sessionId required");
1034
+ if (!this.deps.bridge.capabilities.sessionRestore) return this.fail(env.id, "E_UNSUPPORTED", "session restore unavailable on this host version");
1035
+ const result = await this.deps.bridge.unarchiveSession(p.sessionId);
1036
+ if (!result.ok) return this.fail(env.id, managementErrorCode(result.kind), result.message);
1037
+ this.send("s2c.session.unarchived", { sessionId: p.sessionId }, env.id);
1038
+ return;
1039
+ }
995
1040
  case "c2s.session.cancel": {
996
1041
  const p = env.payload;
997
1042
  if (!p?.sessionId) return this.fail(env.id, "E_PROTOCOL", "sessionId required");
@@ -1119,6 +1164,49 @@ var BridgeConnection = class {
1119
1164
  this.send("s2c.ack", {}, env.id);
1120
1165
  return;
1121
1166
  }
1167
+ case "c2s.liveActivity.unregister": {
1168
+ const p = env.payload;
1169
+ if (this.pendingLiveActivityId === p.activityId) {
1170
+ this.liveActivityRegistrationGeneration += 1;
1171
+ this.pendingLiveActivityId = void 0;
1172
+ }
1173
+ this.deps.devices.clearLiveActivity(this.deviceId, p.activityId);
1174
+ this.send("s2c.ack", {}, env.id);
1175
+ return;
1176
+ }
1177
+ case "c2s.liveActivity.register": {
1178
+ const p = env.payload;
1179
+ const initial = this.deviceId && this.deps.devices.authorized(this.deviceId);
1180
+ if (!initial || ![
1181
+ "notifications.register",
1182
+ "sessions.read",
1183
+ "interactions.respond"
1184
+ ].every((scope) => initial.scopes?.includes(scope))) return this.fail(env.id, "E_FORBIDDEN", "live activity permissions required");
1185
+ const generation = ++this.liveActivityRegistrationGeneration;
1186
+ this.pendingLiveActivityId = p.activityId;
1187
+ if (p.enrollKey) await this.deps.onPushEnrollKey?.(p.enrollKey);
1188
+ if (generation !== this.liveActivityRegistrationGeneration) return this.fail(env.id, "E_BUSY", "live activity registration superseded");
1189
+ const record = this.deviceId && this.deps.devices.authorized(this.deviceId);
1190
+ if (this.closed || !record || ![
1191
+ "notifications.register",
1192
+ "sessions.read",
1193
+ "interactions.respond"
1194
+ ].every((scope) => record.scopes?.includes(scope))) return this.fail(env.id, "E_FORBIDDEN", "live activity permissions required");
1195
+ if (!this.deps.bridge.listSessions().some((row) => row.id === p.sessionId)) return this.fail(env.id, "E_NOT_FOUND", "session not found");
1196
+ const previous = record.liveActivity;
1197
+ if (previous?.activityId === p.activityId && previous.sessionId !== p.sessionId) return this.fail(env.id, "E_PROTOCOL", "activity is bound to another session");
1198
+ this.deps.devices.setLiveActivity(record.deviceId, {
1199
+ activityId: p.activityId,
1200
+ sessionId: p.sessionId,
1201
+ token: p.deviceToken.toLowerCase(),
1202
+ environment: p.environment,
1203
+ updatedAt: Date.now(),
1204
+ expiresAt: Date.now() + 288e5
1205
+ });
1206
+ this.deps.bridge.refreshLiveActivities();
1207
+ this.send("s2c.ack", { enabled: this.deps.bridge.capabilities.push }, env.id);
1208
+ return;
1209
+ }
1122
1210
  case "c2s.widget.push.register":
1123
1211
  case "c2s.push.register": {
1124
1212
  const widget = env.type === "c2s.widget.push.register";
@@ -1298,6 +1386,7 @@ function projectEvent(sessionId, event) {
1298
1386
  kind: "turn.end",
1299
1387
  data: { ok: event.data?.reason?.kind === "completed" }
1300
1388
  };
1389
+ case "system/message": return null;
1301
1390
  case "user/message": return {
1302
1391
  kind: "message.final",
1303
1392
  data: { ...limitMessageProjection({
@@ -1358,14 +1447,28 @@ function projectEvent(sessionId, event) {
1358
1447
  }
1359
1448
  case "tool/result": {
1360
1449
  const data = event.data;
1450
+ const ok = data?.error === void 0;
1451
+ const result = limitMessageProjection({
1452
+ seq: event.seq,
1453
+ role: "tool",
1454
+ ts: tsOf(event),
1455
+ tool: {
1456
+ name: "result",
1457
+ state: ok ? "ok" : "error",
1458
+ summary: ok ? summarizeResult(data?.message?.content) : "失败"
1459
+ },
1460
+ ...ok ? attachmentProjection(data?.message) : {}
1461
+ });
1361
1462
  return {
1362
1463
  kind: "tool.end",
1363
1464
  data: {
1364
- seq: event.seq,
1365
- role: "tool",
1366
- ok: !event.data || data?.error === void 0,
1367
- ...data?.callId ? { callId: String(data.callId) } : {},
1368
- ts: tsOf(event)
1465
+ seq: result.seq,
1466
+ role: result.role,
1467
+ ts: result.ts,
1468
+ ok,
1469
+ summary: result.tool?.summary,
1470
+ ...result.attachments ? { attachments: result.attachments } : {},
1471
+ ...data?.callId ? { callId: String(data.callId) } : {}
1369
1472
  }
1370
1473
  };
1371
1474
  }
@@ -1560,6 +1663,7 @@ function projectHistory(events) {
1560
1663
  ts: tsOf(event)
1561
1664
  };
1562
1665
  switch (event.type) {
1666
+ case "system/message": break;
1563
1667
  case "user/message":
1564
1668
  messages.push({
1565
1669
  ...base,
@@ -1602,19 +1706,23 @@ function projectHistory(events) {
1602
1706
  const target = callId ? toolByCall.get(callId) : void 0;
1603
1707
  const failed = data?.error !== void 0;
1604
1708
  const summary = failed ? "失败" : summarizeResult(data?.message?.content);
1605
- if (target?.tool) target.tool = {
1606
- ...target.tool,
1607
- state: failed ? "error" : "ok",
1608
- summary
1609
- };
1610
- else messages.push({
1709
+ const attachments = failed ? {} : attachmentProjection(data?.message);
1710
+ if (target?.tool) {
1711
+ target.tool = {
1712
+ ...target.tool,
1713
+ state: failed ? "error" : "ok",
1714
+ summary
1715
+ };
1716
+ if (attachments.attachments) Object.assign(target, attachments);
1717
+ } else messages.push({
1611
1718
  ...base,
1612
1719
  role: "tool",
1613
1720
  tool: {
1614
1721
  name: "result",
1615
1722
  state: failed ? "error" : "ok",
1616
1723
  summary
1617
- }
1724
+ },
1725
+ ...attachments
1618
1726
  });
1619
1727
  break;
1620
1728
  }
@@ -1738,9 +1846,13 @@ var HostBridge = class {
1738
1846
  historyBufferMax;
1739
1847
  id = ++BRIDGE_SEQ;
1740
1848
  summaries = /* @__PURE__ */ new Map();
1849
+ activeTools = /* @__PURE__ */ new Map();
1741
1850
  approvals = /* @__PURE__ */ new Map();
1742
1851
  questions = /* @__PURE__ */ new Map();
1743
1852
  archivedSessionIds = /* @__PURE__ */ new Set();
1853
+ /** Mirrors archived rows from the last sessions.list so the phone can browse
1854
+ * and restore them. Excluded from `summaries` and from the live broadcast. */
1855
+ archivedSummaries = /* @__PURE__ */ new Map();
1744
1856
  subagentSessionIds = /* @__PURE__ */ new Set();
1745
1857
  sinks = /* @__PURE__ */ new Set();
1746
1858
  ring = [];
@@ -1775,9 +1887,11 @@ var HostBridge = class {
1775
1887
  notifyAllCategories: true,
1776
1888
  models: typeof this.apiProxy.sessions.models === "function" && typeof this.apiProxy.sessions.selectModel === "function",
1777
1889
  sessionManagement: typeof this.apiProxy.sessions.rename === "function" && typeof this.apiProxy.workspace?.archiveSession === "function",
1890
+ sessionRestore: typeof this.apiProxy.workspace?.unarchiveSession === "function",
1778
1891
  projectSelection: typeof this.apiProxy.workspace?.list === "function" && typeof this.apiProxy.workspace?.create === "function",
1779
1892
  push: this.pushOutlet?.isAvailable() === true,
1780
- widgetPush: true
1893
+ widgetPush: true,
1894
+ liveActivityPush: true
1781
1895
  };
1782
1896
  }
1783
1897
  diagnostic(message) {
@@ -1823,7 +1937,7 @@ var HostBridge = class {
1823
1937
  * PROTOCOL §6 + §7), then fan the same payload out to offline devices
1824
1938
  * holding an APNs token. */
1825
1939
  emitNotify(args) {
1826
- if (this.subagentSessionIds.has(args.sessionId)) return;
1940
+ if ((args.category === "turn.completed" || args.category === "session.error") && this.subagentSessionIds.has(args.sessionId)) return;
1827
1941
  const body = args.body.length > 120 ? args.body.slice(0, 119) + "…" : args.body;
1828
1942
  this.record("s2c.notify", {
1829
1943
  notificationId: args.notificationId,
@@ -1890,16 +2004,23 @@ var HostBridge = class {
1890
2004
  for (const sink of receivers) sink.replayDone();
1891
2005
  return true;
1892
2006
  }
2007
+ refreshLiveActivities() {
2008
+ try {
2009
+ this.pushOutlet?.liveActivityChanged?.(this.listSessions());
2010
+ } catch {}
2011
+ }
1893
2012
  record(type, payload, except) {
1894
2013
  if (this.disposed) return;
2014
+ if (type === "s2c.sessions.delta") this.refreshLiveActivities();
1895
2015
  if (type === "s2c.sessions.delta" || type.startsWith("s2c.pending.")) {
1896
2016
  const fingerprint = JSON.stringify([
1897
- this.listSessions().map(({ id, title, status, lastActivityTs, todos, pendingApproval, pendingQuestion }) => ({
2017
+ this.listSessions().map(({ id, title, status, lastActivityTs, todos, todoItems, pendingApproval, pendingQuestion }) => ({
1898
2018
  id,
1899
2019
  title,
1900
2020
  status,
1901
2021
  lastActivityTs,
1902
2022
  todos,
2023
+ todoItems,
1903
2024
  pendingApproval,
1904
2025
  pendingQuestion
1905
2026
  })),
@@ -1992,9 +2113,12 @@ var HostBridge = class {
1992
2113
  const event = frame.event;
1993
2114
  const sessionId = String(frame.sessionId ?? "");
1994
2115
  if (!event || !sessionId) break;
2116
+ if (frame.isSubagent === true) this.subagentSessionIds.add(sessionId);
2117
+ if (this.subagentSessionIds.has(sessionId)) break;
1995
2118
  this.noteActivity(sessionId, event);
1996
2119
  this.captureAssistantText(sessionId, event);
1997
2120
  const projection = projectEvent(sessionId, event);
2121
+ this.captureActivity(sessionId, event, projection?.data.tool);
1998
2122
  if (projection) this.record("s2c.session.event", {
1999
2123
  sessionId,
2000
2124
  kind: projection.kind,
@@ -2029,6 +2153,7 @@ var HostBridge = class {
2029
2153
  summary,
2030
2154
  riskLevel: riskOf(toolName)
2031
2155
  });
2156
+ if (p.callId) this.loadApprovalArguments(p.approvalId, p.callId);
2032
2157
  this.emitNotify({
2033
2158
  sessionId,
2034
2159
  category: "approval.required",
@@ -2111,17 +2236,27 @@ var HostBridge = class {
2111
2236
  const workspaceBySession = /* @__PURE__ */ new Map();
2112
2237
  for (const workspace of workspaces) for (const sessionId of workspace.sessionIds ?? []) workspaceBySession.set(String(sessionId), workspace);
2113
2238
  const next = /* @__PURE__ */ new Map();
2114
- const subagentIds = /* @__PURE__ */ new Set();
2239
+ const nextArchived = /* @__PURE__ */ new Map();
2115
2240
  for (const row of response.result.value.items ?? []) {
2116
- if (this.archivedSessionIds.has(row.sessionId)) continue;
2117
- if (isSubagentRow(row)) {
2118
- subagentIds.add(row.sessionId);
2241
+ if (isSubagentRow(row)) this.subagentSessionIds.add(row.sessionId);
2242
+ if (this.subagentSessionIds.has(row.sessionId)) continue;
2243
+ const summary = toSummary(row, this.approvals, this.questions, workspaceBySession.get(row.sessionId));
2244
+ if (this.archivedSessionIds.has(row.sessionId)) {
2245
+ nextArchived.set(row.sessionId, {
2246
+ ...summary,
2247
+ archived: true
2248
+ });
2119
2249
  continue;
2120
2250
  }
2121
- next.set(row.sessionId, toSummary(row, this.approvals, this.questions, workspaceBySession.get(row.sessionId)));
2251
+ next.set(row.sessionId, summary);
2252
+ }
2253
+ for (const [id, calls] of this.activeTools) {
2254
+ const row = next.get(id);
2255
+ if (!row || row.status !== "running" && !row.pendingApproval && !row.pendingQuestion) this.activeTools.delete(id);
2256
+ else row.activity = [...calls.values()].at(-1) ?? null;
2122
2257
  }
2123
- this.subagentSessionIds = subagentIds;
2124
2258
  this.summaries = next;
2259
+ this.archivedSummaries = nextArchived;
2125
2260
  const removedIds = [...previousIds].filter((id) => !next.has(id));
2126
2261
  for (const id of this.lastAssistantText.keys()) if (!next.has(id)) this.lastAssistantText.delete(id);
2127
2262
  this.record("s2c.sessions.delta", {
@@ -2139,6 +2274,24 @@ var HostBridge = class {
2139
2274
  row.title = firstUser.text.replace(/\s+/g, " ").trim().slice(0, 60);
2140
2275
  this.pushSummary(row);
2141
2276
  }
2277
+ captureActivity(sessionId, event, tool) {
2278
+ const row = this.summaries.get(sessionId);
2279
+ if (!row) return;
2280
+ const data = event.data;
2281
+ if (event.type === "tool/call" && data?.callId) {
2282
+ const calls = this.activeTools.get(sessionId) ?? /* @__PURE__ */ new Map();
2283
+ const detail = [tool?.name, tool?.summary].filter(Boolean).join(": ");
2284
+ calls.set(data.callId, Array.from(detail).slice(0, 160).join(""));
2285
+ if (calls.size > 64) calls.delete(calls.keys().next().value);
2286
+ this.activeTools.set(sessionId, calls);
2287
+ } else if (event.type === "tool/result" && data?.callId) this.activeTools.get(sessionId)?.delete(data.callId);
2288
+ else if (event.type === "turn/start" || event.type === "turn/end") this.activeTools.delete(sessionId);
2289
+ else return;
2290
+ const activity = [...this.activeTools.get(sessionId)?.values() ?? []].at(-1) ?? null;
2291
+ if (row.activity === activity) return;
2292
+ row.activity = activity;
2293
+ this.pushSummary(row);
2294
+ }
2142
2295
  noteActivity(sessionId, event) {
2143
2296
  const row = this.summaries.get(sessionId);
2144
2297
  if (!row) return;
@@ -2197,6 +2350,14 @@ var HostBridge = class {
2197
2350
  return [...this.summaries.values()].sort((a, b) => b.lastActivityTs - a.lastActivityTs);
2198
2351
  }
2199
2352
  /**
2353
+ * Archived rows from the last sessions.list, newest first. Served only on
2354
+ * demand so the live session list and its broadcast keep their shape for
2355
+ * clients that predate `c2s.sessions.archived`.
2356
+ */
2357
+ listArchivedSessions() {
2358
+ return [...this.archivedSummaries.values()].sort((a, b) => b.lastActivityTs - a.lastActivityTs);
2359
+ }
2360
+ /**
2200
2361
  * Complete transient interaction state. Unlike the replay ring, this remains
2201
2362
  * authoritative after a long disconnect and is rehydrated by apiProxy's mux
2202
2363
  * stream when the bridge itself restarts.
@@ -2208,6 +2369,7 @@ var HostBridge = class {
2208
2369
  sessionId: pending.sessionId,
2209
2370
  toolName: pending.toolName,
2210
2371
  summary: pending.reason,
2372
+ ...pending.toolArguments !== void 0 ? { toolArguments: pending.toolArguments } : {},
2211
2373
  riskLevel: riskOf(pending.toolName)
2212
2374
  })),
2213
2375
  questions: [...this.questions.entries()].map(([requestId, pending]) => ({
@@ -2217,6 +2379,35 @@ var HostBridge = class {
2217
2379
  }))
2218
2380
  };
2219
2381
  }
2382
+ /** Resolve only the exact invocation in this session; never guess from the latest tool. */
2383
+ async loadApprovalArguments(requestId, callId) {
2384
+ const pending = this.approvals.get(requestId);
2385
+ if (!pending) return;
2386
+ try {
2387
+ const response = await this.apiProxy.sessions.history({
2388
+ rpcId: randomUUID(),
2389
+ payload: {
2390
+ sessionId: pending.sessionId,
2391
+ maxMessages: 200
2392
+ }
2393
+ });
2394
+ if (this.disposed || this.approvals.get(requestId) !== pending || !response.result?.ok) return;
2395
+ const args = ((response.result.value.events ?? []).map((row) => row.event).find((event) => {
2396
+ const data = event.data;
2397
+ return event.type === "tool/call" && data?.callId === callId && data?.name === pending.toolName;
2398
+ })?.data)?.arguments;
2399
+ if (typeof args !== "string" || !args.trim()) return;
2400
+ pending.toolArguments = args;
2401
+ this.record("s2c.pending.approval", {
2402
+ requestId,
2403
+ sessionId: pending.sessionId,
2404
+ toolName: pending.toolName,
2405
+ summary: pending.reason,
2406
+ riskLevel: riskOf(pending.toolName),
2407
+ toolArguments: args
2408
+ });
2409
+ } catch {}
2410
+ }
2220
2411
  /** Tail history for an opened session; pushes s2c.session.tail to the sink. */
2221
2412
  async openSession(sink, sessionId, tailCount) {
2222
2413
  try {
@@ -2430,6 +2621,38 @@ var HostBridge = class {
2430
2621
  };
2431
2622
  }
2432
2623
  }
2624
+ async unarchiveSession(sessionId) {
2625
+ const unarchive = this.apiProxy.workspace?.unarchiveSession;
2626
+ if (typeof unarchive !== "function") return {
2627
+ ok: false,
2628
+ kind: "unsupported",
2629
+ message: "session restore unavailable on this host version"
2630
+ };
2631
+ try {
2632
+ const response = await unarchive.call(this.apiProxy.workspace, {
2633
+ rpcId: randomUUID(),
2634
+ payload: { sessionId }
2635
+ });
2636
+ if (!response.result) return {
2637
+ ok: false,
2638
+ kind: "internal",
2639
+ message: "session restore returned no result"
2640
+ };
2641
+ if (!response.result.ok) return hostSessionManagementError(response.result.error);
2642
+ this.archivedSessionIds = new Set((response.result.value.archivedSessionIds ?? []).map(String));
2643
+ await this.refreshSummaries();
2644
+ return {
2645
+ ok: true,
2646
+ value: true
2647
+ };
2648
+ } catch (error) {
2649
+ return {
2650
+ ok: false,
2651
+ kind: "internal",
2652
+ message: String(error)
2653
+ };
2654
+ }
2655
+ }
2433
2656
  async cancelSession(sessionId) {
2434
2657
  const cancel = this.apiProxy.sessions.cancel;
2435
2658
  if (typeof cancel !== "function") return {
@@ -3163,6 +3386,7 @@ var Dsh012ApiProxy = class {
3163
3386
  if (session === void 0) throw new Error("dsh 0.1.2 sessionController is unavailable");
3164
3387
  this.session = session;
3165
3388
  this.workspaceController = ctx.get("workspaceController");
3389
+ if (typeof this.workspaceController?.unarchiveSession !== "function") delete this.workspace?.unarchiveSession;
3166
3390
  this.directoryPicker = ctx.get("directoryPickerController");
3167
3391
  this.shouldSurfaceInteraction = options.shouldSurfaceInteraction ?? (() => true);
3168
3392
  }
@@ -3180,7 +3404,7 @@ var Dsh012ApiProxy = class {
3180
3404
  }
3181
3405
  sessions = {
3182
3406
  list: async () => this.call(async () => {
3183
- return { items: (await this.session.list({}, new AbortController().signal)).items.map(toPhoneSessionRow).filter((row) => !isSubagentRow(row)) };
3407
+ return { items: (await this.session.list({}, new AbortController().signal)).items.map(toPhoneSessionRow) };
3184
3408
  }),
3185
3409
  history: async (request) => this.call(async () => {
3186
3410
  const sessionId = request.payload.sessionId;
@@ -3242,6 +3466,12 @@ var Dsh012ApiProxy = class {
3242
3466
  archiveSession: async (request) => this.call(async () => {
3243
3467
  if (this.workspaceController === void 0) throw unavailable("workspace controller unavailable");
3244
3468
  return { archivedSessionIds: [...(await this.workspaceController.archiveSession(request.payload)).archivedSessionIds] };
3469
+ }),
3470
+ unarchiveSession: async (request) => this.call(async () => {
3471
+ if (this.workspaceController === void 0) throw unavailable("workspace controller unavailable");
3472
+ const unarchive = this.workspaceController.unarchiveSession;
3473
+ if (typeof unarchive !== "function") throw unavailable("session restore unavailable on this host version");
3474
+ return { archivedSessionIds: [...(await unarchive.call(this.workspaceController, request.payload)).archivedSessionIds] };
3245
3475
  })
3246
3476
  };
3247
3477
  host = {
@@ -3278,6 +3508,7 @@ var Dsh012ApiProxy = class {
3278
3508
  queue.push({
3279
3509
  type: "session/event",
3280
3510
  sessionId: String(session.id),
3511
+ isSubagent: session.header?.origin === "subagent",
3281
3512
  event
3282
3513
  });
3283
3514
  }), { global: true });
@@ -3326,7 +3557,8 @@ var Dsh012ApiProxy = class {
3326
3557
  sessionId,
3327
3558
  approvalId: rpcId,
3328
3559
  toolName: String(request.toolName ?? "tool"),
3329
- reason: String(request.reason ?? "")
3560
+ reason: String(request.reason ?? ""),
3561
+ ...request.callId ? { callId: request.callId } : {}
3330
3562
  });
3331
3563
  return response.promise.finally(() => {
3332
3564
  request.signal?.removeEventListener("abort", abort);
@@ -3665,10 +3897,16 @@ function parseLocal(value) {
3665
3897
  if (!Array.isArray(endpoints) || endpoints.some((entry) => typeof entry !== "string")) reject("local.endpoints");
3666
3898
  const message = s.message;
3667
3899
  if (message !== void 0 && typeof message !== "string") reject("local.message");
3900
+ const tlsFingerprint = s.tlsFingerprint;
3901
+ if (tlsFingerprint !== void 0 && typeof tlsFingerprint !== "string") reject("local.tlsFingerprint");
3902
+ const tlsIdentityRegenerated = s.tlsIdentityRegenerated;
3903
+ if (tlsIdentityRegenerated !== void 0 && typeof tlsIdentityRegenerated !== "boolean") reject("local.tlsIdentityRegenerated");
3668
3904
  return {
3669
3905
  phase,
3670
3906
  port: int(s, "port", "local.port"),
3671
3907
  endpoints,
3908
+ ...typeof tlsFingerprint === "string" ? { tlsFingerprint } : {},
3909
+ ...tlsIdentityRegenerated === true ? { tlsIdentityRegenerated: true } : {},
3672
3910
  ...typeof message === "string" ? { message } : {},
3673
3911
  updatedAt: int(s, "updatedAt", "local.updatedAt")
3674
3912
  };
@@ -4094,6 +4332,12 @@ function p8ToDer(pem) {
4094
4332
  }
4095
4333
  /** Pure payload builder so tests can assert the wire format without sockets. */
4096
4334
  function apnsPayload(notification) {
4335
+ if ("kind" in notification && notification.kind === "liveactivity") return { aps: {
4336
+ timestamp: notification.timestamp,
4337
+ event: notification.event,
4338
+ "content-state": notification.contentState,
4339
+ ...notification.event === "end" ? { "dismissal-date": notification.timestamp + 300 } : { "stale-date": notification.timestamp + 180 }
4340
+ } };
4097
4341
  if ("kind" in notification && notification.kind === "widget") return { aps: { "content-changed": true } };
4098
4342
  return alertPayload(notification);
4099
4343
  }
@@ -4117,6 +4361,12 @@ function alertPayload(notification) {
4117
4361
  };
4118
4362
  }
4119
4363
  function pushHeaders(bundleId, notification) {
4364
+ if ("kind" in notification && notification.kind === "liveactivity") return {
4365
+ "apns-topic": bundleId + ".push-type.liveactivity",
4366
+ "apns-push-type": "liveactivity",
4367
+ "apns-priority": notification.event === "end" ? "10" : "5",
4368
+ "apns-expiration": String(notification.timestamp + 180)
4369
+ };
4120
4370
  if ("kind" in notification && notification.kind === "widget") return {
4121
4371
  "apns-topic": bundleId + ".push-type.widgets",
4122
4372
  "apns-push-type": "widgets",
@@ -5023,9 +5273,10 @@ const MAX_LOCAL_PORT = 65535;
5023
5273
  function normalizeLocalPort(value) {
5024
5274
  return typeof value === "number" && Number.isInteger(value) && value >= 1024 && value <= 65535 ? value : DEFAULT_LOCAL_PORT;
5025
5275
  }
5276
+ /** The LAN listener is TLS-only, so every advertised endpoint is `https://`. */
5026
5277
  function localEndpointURLs(addresses, port) {
5027
5278
  const normalizedPort = normalizeLocalPort(port);
5028
- return [...new Set(addresses)].map((address) => `http://${address}:${normalizedPort}`);
5279
+ return [...new Set(addresses)].map((address) => `https://${address}:${normalizedPort}`);
5029
5280
  }
5030
5281
  function localListenError(error, port) {
5031
5282
  const code = error?.code;
@@ -5044,7 +5295,7 @@ const Config = z.object({
5044
5295
  debug: z.boolean().default(false),
5045
5296
  local: z.object({
5046
5297
  enabled: z.boolean().default(true),
5047
- port: z.natural().min(MIN_LOCAL_PORT).max(MAX_LOCAL_PORT).default(DEFAULT_LOCAL_PORT).description("DeepPilot 局域网独立端口(默认 3098,修改后本地连接会短暂重连)")
5298
+ port: z.natural().min(MIN_LOCAL_PORT).max(MAX_LOCAL_PORT).default(DEFAULT_LOCAL_PORT).description("DeepPilot 局域网独立端口(仅 TLS;默认 3098,修改后本地连接会短暂重连)")
5048
5299
  }).default({
5049
5300
  enabled: true,
5050
5301
  port: DEFAULT_LOCAL_PORT
@@ -5331,14 +5582,91 @@ var WidgetPushScheduler = class {
5331
5582
  }
5332
5583
  };
5333
5584
  //#endregion
5585
+ //#region src/live-activity.ts
5586
+ function liveActivityState(session) {
5587
+ const total = Math.max(0, session?.todos?.total ?? session?.todoItems?.length ?? 0);
5588
+ const done = Math.min(total, Math.max(0, session?.todos?.done ?? 0));
5589
+ const task = session?.todoItems?.find((item) => item.status === "in_progress") ?? session?.todoItems?.find((item) => item.status === "pending");
5590
+ return {
5591
+ title: Array.from(session?.title ?? "").slice(0, 100).join(""),
5592
+ task: Array.from(session?.activity?.trim() || task?.content || "").slice(0, 160).join(""),
5593
+ done,
5594
+ total,
5595
+ phase: !session || total === 0 ? "unavailable" : session.pendingApproval ? "approval" : session.pendingQuestion ? "question" : session.status === "running" ? "running" : session.status === "idle" ? "ended" : "unavailable"
5596
+ };
5597
+ }
5598
+ /** Persist terminal state before coalescing so a rapid next round cannot revive an activity. */
5599
+ var LiveActivityPushManager = class {
5600
+ devices;
5601
+ send;
5602
+ latest = /* @__PURE__ */ new Map();
5603
+ sent = /* @__PURE__ */ new Map();
5604
+ scheduler;
5605
+ constructor(devices, send, intervalMs = 15e3) {
5606
+ this.devices = devices;
5607
+ this.send = send;
5608
+ this.scheduler = new WidgetPushScheduler(() => this.flush(), intervalMs);
5609
+ }
5610
+ changed(sessions) {
5611
+ this.latest = new Map(sessions.map((session) => [session.id, session]));
5612
+ for (const device of this.devices()?.list() ?? []) {
5613
+ const r = device.liveActivity;
5614
+ if (!r) continue;
5615
+ const state = liveActivityState(this.latest.get(r.sessionId));
5616
+ if (state.phase === "ended" || state.phase === "unavailable") this.devices()?.endLiveActivity(device.deviceId, r.token, state);
5617
+ }
5618
+ this.scheduler.changed();
5619
+ }
5620
+ async flush() {
5621
+ const devices = this.devices();
5622
+ if (!devices) return;
5623
+ const valid = /* @__PURE__ */ new Set();
5624
+ for (const device of devices.list()) {
5625
+ const r = device.liveActivity;
5626
+ if (!r) continue;
5627
+ const key = device.deviceId + ":" + r.token;
5628
+ valid.add(key);
5629
+ if (device.revokedAt !== void 0 || ![
5630
+ "notifications.register",
5631
+ "sessions.read",
5632
+ "interactions.respond"
5633
+ ].every((scope) => device.scopes?.some((value) => value === scope)) || r.expiresAt <= Date.now()) {
5634
+ devices.clearLiveActivity(device.deviceId, r.activityId, r.token);
5635
+ continue;
5636
+ }
5637
+ const state = r.endedState ?? liveActivityState(this.latest.get(r.sessionId));
5638
+ const fingerprint = JSON.stringify(state);
5639
+ if (this.sent.get(key) === fingerprint) continue;
5640
+ if (devices.authorized(device.deviceId)?.liveActivity?.token !== r.token) continue;
5641
+ const result = await this.send(r.token, r.environment, {
5642
+ kind: "liveactivity",
5643
+ event: r.endedState ? "end" : "update",
5644
+ timestamp: Math.floor(Date.now() / 1e3),
5645
+ contentState: state
5646
+ }).catch(() => ({ outcome: "failed" }));
5647
+ if (result.outcome === "sent") this.sent.set(key, fingerprint);
5648
+ else if (result.outcome === "invalid-token") devices.clearLiveActivity(device.deviceId, r.activityId, r.token);
5649
+ else this.scheduler.changed();
5650
+ }
5651
+ for (const key of this.sent.keys()) if (!valid.has(key)) this.sent.delete(key);
5652
+ }
5653
+ dispose() {
5654
+ this.scheduler.dispose();
5655
+ }
5656
+ };
5657
+ //#endregion
5334
5658
  //#region src/phone-server.ts
5335
5659
  /**
5336
5660
  * A deliberately narrow transport listener. Both the LAN endpoint and the
5337
5661
  * loopback-only Funnel origin use this factory, so neither can accidentally
5338
5662
  * inherit DSH's wider web/API route surface.
5663
+ *
5664
+ * With `tls` the listener speaks HTTPS/WSS only (the LAN posture); without
5665
+ * it the listener is plain HTTP, which is reserved for the loopback Funnel
5666
+ * origin where tailscaled terminates TLS.
5339
5667
  */
5340
- function createPhoneServer(handlers) {
5341
- const server = createServer((req, res) => {
5668
+ function createPhoneServer(handlers, tls) {
5669
+ const onRequest = (req, res) => {
5342
5670
  const path = requestPath(req);
5343
5671
  if (path === "/phone/health") handlers.health(req, res);
5344
5672
  else if (path === "/phone/pair") handlers.pair(req, res);
@@ -5346,7 +5674,12 @@ function createPhoneServer(handlers) {
5346
5674
  res.statusCode = 404;
5347
5675
  res.end("not found");
5348
5676
  }
5349
- });
5677
+ };
5678
+ const server = tls === void 0 ? createServer$1(onRequest) : createServer({
5679
+ key: tls.key,
5680
+ cert: tls.cert,
5681
+ minVersion: "TLSv1.2"
5682
+ }, onRequest);
5350
5683
  server.on("upgrade", (req, socket, head) => {
5351
5684
  if (requestPath(req) !== "/phone") {
5352
5685
  socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
@@ -5383,12 +5716,175 @@ function closeServer(server) {
5383
5716
  return new Promise((resolve) => server.close(() => resolve()));
5384
5717
  }
5385
5718
  //#endregion
5719
+ //#region src/lan-tls.ts
5720
+ const LAN_TLS_COMMON_NAME = "dsh-deeppilot";
5721
+ const LAN_TLS_KEY_FILE = "key.pem";
5722
+ const LAN_TLS_CERT_FILE = "cert.pem";
5723
+ const CERT_VALIDITY_MS = 31536e7;
5724
+ /** Re-sign this far ahead of expiry so a long-running host never serves a stale leaf. */
5725
+ const CERT_RENEW_LEAD_MS = 2592e6;
5726
+ /** Compute the pinned public-key fingerprint of a PEM certificate. */
5727
+ function spkiFingerprint(certPem) {
5728
+ const der = createPublicKey(certPem).export({
5729
+ type: "spki",
5730
+ format: "der"
5731
+ });
5732
+ return "sha256:" + createHash("sha256").update(der).digest("base64url");
5733
+ }
5734
+ /**
5735
+ * Load the LAN listener's TLS identity from `dir`, creating or repairing it
5736
+ * as needed. The private key is the durable identity: it is created once and
5737
+ * reused for every subsequent certificate, so pinned devices survive
5738
+ * certificate renewals. Only a missing or unreadable key forces a new one.
5739
+ */
5740
+ async function loadOrCreateLanTlsIdentity(dir, now = Date.now()) {
5741
+ await mkdir(dir, {
5742
+ recursive: true,
5743
+ mode: 448
5744
+ });
5745
+ const keyPath = join(dir, LAN_TLS_KEY_FILE);
5746
+ const certPath = join(dir, LAN_TLS_CERT_FILE);
5747
+ let key = await readPem(keyPath);
5748
+ let regenerated = false;
5749
+ if (key === void 0 || !validPrivateKey(key)) {
5750
+ regenerated = true;
5751
+ key = void 0;
5752
+ }
5753
+ let cert = regenerated ? void 0 : await readPem(certPath);
5754
+ let resigned = false;
5755
+ const existing = key !== void 0 && cert !== void 0 ? inspectCertificate(cert, key, now) : void 0;
5756
+ if (existing === void 0) {
5757
+ const issued = await issueCertificate(key, now);
5758
+ if (key === void 0) {
5759
+ key = pemFile(issued.key);
5760
+ await writePrivate(keyPath, key);
5761
+ } else resigned = true;
5762
+ cert = pemFile(issued.cert);
5763
+ await writePrivate(certPath, cert);
5764
+ }
5765
+ if (key === void 0 || cert === void 0) throw new Error("LAN TLS identity unavailable");
5766
+ return {
5767
+ key,
5768
+ cert,
5769
+ fingerprint: spkiFingerprint(cert),
5770
+ notAfter: existing ?? new X509Certificate(cert).validToDate.getTime(),
5771
+ regenerated,
5772
+ resigned
5773
+ };
5774
+ }
5775
+ async function readPem(path) {
5776
+ try {
5777
+ const text = await readFile(path, "utf8");
5778
+ return text.trim().length > 0 ? text : void 0;
5779
+ } catch (error) {
5780
+ if (error.code === "ENOENT") return void 0;
5781
+ return;
5782
+ }
5783
+ }
5784
+ function validPrivateKey(pem) {
5785
+ try {
5786
+ return createPrivateKey(pem).asymmetricKeyType === "ec";
5787
+ } catch {
5788
+ return false;
5789
+ }
5790
+ }
5791
+ /**
5792
+ * Return the certificate's expiry when it is usable with `keyPem`, or
5793
+ * `undefined` when it must be re-issued (malformed, wrong key, or expiring).
5794
+ */
5795
+ function inspectCertificate(certPem, keyPem, now) {
5796
+ try {
5797
+ const cert = new X509Certificate(certPem);
5798
+ const certSpki = cert.publicKey.export({
5799
+ type: "spki",
5800
+ format: "der"
5801
+ });
5802
+ const keySpki = createPublicKey(createPrivateKey(keyPem)).export({
5803
+ type: "spki",
5804
+ format: "der"
5805
+ });
5806
+ if (!certSpki.equals(keySpki)) return void 0;
5807
+ const notAfter = cert.validToDate.getTime();
5808
+ if (!(cert.validFromDate.getTime() <= now && notAfter - CERT_RENEW_LEAD_MS > now)) return void 0;
5809
+ return notAfter;
5810
+ } catch {
5811
+ return;
5812
+ }
5813
+ }
5814
+ async function issueCertificate(existingKey, now) {
5815
+ const notBeforeDate = /* @__PURE__ */ new Date(now - 3e5);
5816
+ const notAfterDate = new Date(now + CERT_VALIDITY_MS);
5817
+ const keyPair = existingKey === void 0 ? void 0 : {
5818
+ privateKey: createPrivateKey(existingKey).export({
5819
+ type: "pkcs8",
5820
+ format: "pem"
5821
+ }),
5822
+ publicKey: createPublicKey(existingKey).export({
5823
+ type: "spki",
5824
+ format: "pem"
5825
+ })
5826
+ };
5827
+ const result = await generate([{
5828
+ name: "commonName",
5829
+ value: LAN_TLS_COMMON_NAME
5830
+ }], {
5831
+ keyType: "ec",
5832
+ curve: "P-256",
5833
+ algorithm: "sha256",
5834
+ notBeforeDate,
5835
+ notAfterDate,
5836
+ ...keyPair ? { keyPair } : {},
5837
+ extensions: [
5838
+ {
5839
+ name: "basicConstraints",
5840
+ cA: false,
5841
+ critical: true
5842
+ },
5843
+ {
5844
+ name: "keyUsage",
5845
+ digitalSignature: true,
5846
+ keyAgreement: true,
5847
+ critical: true
5848
+ },
5849
+ {
5850
+ name: "extKeyUsage",
5851
+ serverAuth: true
5852
+ },
5853
+ {
5854
+ name: "subjectAltName",
5855
+ altNames: [{
5856
+ type: 2,
5857
+ value: LAN_TLS_COMMON_NAME
5858
+ }]
5859
+ }
5860
+ ]
5861
+ });
5862
+ return {
5863
+ key: existingKey ?? result.private,
5864
+ cert: result.cert
5865
+ };
5866
+ }
5867
+ /** Exact on-disk form, so a freshly issued value equals its later reload. */
5868
+ function pemFile(pem) {
5869
+ return pem.replace(/\r\n/g, "\n").trimEnd() + "\n";
5870
+ }
5871
+ async function writePrivate(path, contents) {
5872
+ const tmp = `${path}.${process.pid}.tmp`;
5873
+ try {
5874
+ await writeFile(tmp, contents, { mode: 384 });
5875
+ await rename(tmp, path);
5876
+ } catch (error) {
5877
+ await unlink(tmp).catch(() => {});
5878
+ throw error;
5879
+ }
5880
+ }
5881
+ //#endregion
5386
5882
  //#region src/index.ts
5387
5883
  /**
5388
5884
  * dsh-deeppilot — data bridge between the DSH host and DeepPilot
5389
- * clients. Owns independent, narrowly routed LAN and Funnel-origin listeners;
5390
- * temporary compatibility routes remain on the existing DSH web server. The
5391
- * web UI and the rest of DSH's API are never exposed by these listeners.
5885
+ * clients. Owns independent, narrowly routed LAN (TLS-only, pinned by paired
5886
+ * devices) and loopback Funnel-origin listeners. The web UI and the rest of
5887
+ * DSH's API are never exposed by these listeners.
5392
5888
  *
5393
5889
  * Data plane: an in-process HostBridge consumes a local compatibility façade
5394
5890
  * over DSH 0.1.2 Session/Workspace controllers, mirrors session summaries,
@@ -5399,7 +5895,7 @@ function closeServer(server) {
5399
5895
  * Swift models mirror that v2 contract.
5400
5896
  */
5401
5897
  const name = "deeppilot";
5402
- /** No eager web-service requirement: independent transports start on their own. */
5898
+ /** No web-service requirement: the plugin owns its own transport listeners. */
5403
5899
  const inject = [];
5404
5900
  const SERVER_VERSION = readOwnPackageVersion();
5405
5901
  const MAX_CLIENT_CONNECTIONS = 16;
@@ -5818,8 +6314,27 @@ function apply(ctx, options) {
5818
6314
  }
5819
6315
  }
5820
6316
  });
6317
+ const liveActivityPush = new LiveActivityPushManager(() => auth.devices ?? void 0, async (deviceToken, environment, notification) => {
6318
+ if (!enabledNow()) return { outcome: "failed" };
6319
+ const resolved = resolvePushConfig(currentConfig());
6320
+ if (!resolved.ok) return { outcome: "failed" };
6321
+ const send = await senderFor(resolved.value);
6322
+ if (!send) return { outcome: "failed" };
6323
+ const result = await send({
6324
+ deviceToken,
6325
+ environment,
6326
+ notification
6327
+ });
6328
+ if (resolved.value.kind === "relay" && result.reason === "HTTP 401" && enrollmentCell.token === resolved.value.token && enrollmentCell.enrollKey) {
6329
+ enrollmentCell.token = void 0;
6330
+ persistEnrollment();
6331
+ await ensureRelayEnrolled(resolved.value.url);
6332
+ }
6333
+ return result;
6334
+ });
5821
6335
  const makePushOutlet = () => ({
5822
6336
  widgetChanged: () => widgetPush.changed(),
6337
+ liveActivityChanged: (sessions) => liveActivityPush.changed(sessions),
5823
6338
  isAvailable: () => {
5824
6339
  const resolved = resolvePushConfig(currentConfig());
5825
6340
  if (!resolved.ok) return false;
@@ -5911,9 +6426,27 @@ function apply(ctx, options) {
5911
6426
  endpoints: [],
5912
6427
  updatedAt: Date.now()
5913
6428
  };
6429
+ let lanTlsIdentity;
6430
+ let tlsIdentityRegenerated = false;
6431
+ const loadLanTls = () => {
6432
+ lanTlsIdentity ??= (async () => {
6433
+ await ready;
6434
+ const identity = await loadOrCreateLanTlsIdentity(join(dataDir, "lan-tls"));
6435
+ if (identity.regenerated) {
6436
+ tlsIdentityRegenerated = true;
6437
+ log(`LAN TLS identity created: fingerprint=${identity.fingerprint}; previously paired LAN devices must pair again`);
6438
+ } else if (identity.resigned) log(`LAN TLS certificate renewed (fingerprint unchanged: ${identity.fingerprint})`);
6439
+ return identity;
6440
+ })();
6441
+ lanTlsIdentity.catch(() => {
6442
+ lanTlsIdentity = void 0;
6443
+ });
6444
+ return lanTlsIdentity;
6445
+ };
5914
6446
  const localStatus = (addresses) => ({
5915
6447
  ...localState,
5916
- endpoints: localState.phase === "online" ? localEndpointURLs(addresses, localState.port) : []
6448
+ endpoints: localState.phase === "online" ? localEndpointURLs(addresses, localState.port) : [],
6449
+ ...tlsIdentityRegenerated ? { tlsIdentityRegenerated: true } : {}
5917
6450
  });
5918
6451
  const remoteStatus = () => remoteSupervisor?.status() ?? {
5919
6452
  provider: "tailscale-funnel",
@@ -6113,13 +6646,15 @@ function apply(ctx, options) {
6113
6646
  }, Date.now());
6114
6647
  authRateLimiter.recordSuccess(source);
6115
6648
  log(`device paired id=${auditLabel(record.deviceId)} source=${auditLabel(source)}`);
6649
+ const tlsFingerprint = req.socket.encrypted === true ? (await lanTlsIdentity)?.fingerprint : void 0;
6116
6650
  res.statusCode = 201;
6117
6651
  res.end(JSON.stringify({
6118
6652
  ok: true,
6119
6653
  v: 2,
6120
6654
  deviceId: record.deviceId,
6121
6655
  audience,
6122
- scopes: record.scopes ?? []
6656
+ scopes: record.scopes ?? [],
6657
+ ...tlsFingerprint ? { tlsFingerprint } : {}
6123
6658
  }));
6124
6659
  } catch (error) {
6125
6660
  res.statusCode = error instanceof SyntaxError || error instanceof TypeError ? 400 : 503;
@@ -6272,9 +6807,15 @@ function apply(ctx, options) {
6272
6807
  endpoints: [],
6273
6808
  updatedAt: Date.now()
6274
6809
  };
6275
- const server = createPhoneServer(phoneHandlers);
6276
- localServer = server;
6810
+ let server;
6277
6811
  try {
6812
+ const tls = await loadLanTls();
6813
+ if (localDisposed || appliedLocalKey !== nextKey) return;
6814
+ server = createPhoneServer(phoneHandlers, {
6815
+ key: tls.key,
6816
+ cert: tls.cert
6817
+ });
6818
+ localServer = server;
6278
6819
  await listen(server, next.port, "0.0.0.0");
6279
6820
  if (localDisposed || localServer !== server) {
6280
6821
  await closeServer(server);
@@ -6284,12 +6825,15 @@ function apply(ctx, options) {
6284
6825
  phase: "online",
6285
6826
  port: next.port,
6286
6827
  endpoints: [],
6828
+ tlsFingerprint: tls.fingerprint,
6287
6829
  updatedAt: Date.now()
6288
6830
  };
6289
- log(`local transport listening on 0.0.0.0:${next.port}`);
6831
+ log(`local transport listening on https://0.0.0.0:${next.port} (tls fingerprint ${tls.fingerprint})`);
6290
6832
  } catch (error) {
6291
- if (localServer === server) localServer = void 0;
6292
- await closeServer(server);
6833
+ if (server !== void 0) {
6834
+ if (localServer === server) localServer = void 0;
6835
+ await closeServer(server);
6836
+ }
6293
6837
  const message = localListenError(error, next.port);
6294
6838
  localState = {
6295
6839
  phase: "error",
@@ -6422,30 +6966,6 @@ function apply(ctx, options) {
6422
6966
  bridge.dispose();
6423
6967
  }, "deeppilot: host streams");
6424
6968
  });
6425
- ctx.inject(["webServer"], (sub) => {
6426
- const webCtx = sub;
6427
- const web = webCtx.webServer;
6428
- if (!web) {
6429
- log("webServer service absent; 3080 compatibility routes unavailable (independent transports remain active)");
6430
- return;
6431
- }
6432
- webCtx.effect(() => web.registerUpgrade({
6433
- path: "/phone",
6434
- handler: handleUpgrade
6435
- }), "deeppilot: /phone WebSocket");
6436
- webCtx.effect(() => web.register({
6437
- kind: "exact",
6438
- path: "/phone/health",
6439
- handler: handleHealth
6440
- }), "deeppilot: /phone/health");
6441
- webCtx.effect(() => web.register({
6442
- kind: "exact",
6443
- path: "/phone/pair",
6444
- handler: handlePair
6445
- }), "deeppilot: /phone/pair");
6446
- if (enabledNow()) log("/phone compatibility routes registered on DSH web server");
6447
- else log("bridge disabled; /phone refuses connections until re-enabled");
6448
- });
6449
6969
  ctx.effect(() => async () => {
6450
6970
  closeAllConnections();
6451
6971
  const bridge = state.bridge;
@@ -6455,6 +6975,7 @@ function apply(ctx, options) {
6455
6975
  cachedSender = void 0;
6456
6976
  updateChecker.dispose();
6457
6977
  widgetPush.dispose();
6978
+ liveActivityPush.dispose();
6458
6979
  const wssClosed = new Promise((resolve) => wss.close(() => resolve()));
6459
6980
  await Promise.allSettled([
6460
6981
  enrollmentWriteTail,