dsh-deeppilot 0.9.7 → 0.9.9

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,6 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID, sign, timingSafeEqual, verify } from "node:crypto";
3
- import { access, chmod, mkdir, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
3
+ import { access, appendFile, chmod, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { WebSocketServer } from "ws";
6
6
  import { homedir, networkInterfaces } from "node:os";
@@ -98,6 +98,24 @@ const DOMAIN_TABLES = {
98
98
  "frequency_too_high": "E_PROTOCOL",
99
99
  "schedule_ended": "E_PROTOCOL",
100
100
  "subagent_session": "E_PROTOCOL"
101
+ },
102
+ command: {
103
+ "command/not-found": "E_NOT_FOUND",
104
+ "command/definition-changed": "E_NOT_FOUND",
105
+ "command/attachments-unsupported": "E_PROTOCOL",
106
+ "command/export-unavailable": "E_INTERNAL",
107
+ "command/export-cancelled": "E_INTERNAL",
108
+ "command/export-unsupported": "E_UNSUPPORTED",
109
+ "command/invalid-response": "E_INTERNAL",
110
+ "command/permissions-unsupported": "E_UNSUPPORTED",
111
+ "gateway/bad-request": "E_PROTOCOL",
112
+ "session/not-found": "E_NOT_FOUND",
113
+ "session/agent-busy": "E_BUSY",
114
+ "session/writer-held": "E_BUSY",
115
+ "session/conflict": "E_BUSY",
116
+ "session-not-found": "E_NOT_FOUND",
117
+ "agent-busy": "E_BUSY",
118
+ "session-conflict": "E_BUSY"
101
119
  }
102
120
  };
103
121
  /**
@@ -127,6 +145,24 @@ function wireErrorOf(domain, error) {
127
145
  };
128
146
  }
129
147
  /**
148
+ * 把任意抛出物收敛成 `HostFailure`,供 `catch` 分支复用同一张域表。
149
+ *
150
+ * 指令链路里有两种失败来源:`DshApiProxy.call` 把插件的失败码放在
151
+ * `{result:{ok:false,error}}` 里正常返回,而导出的归档流是直接抛出的
152
+ * (`AbortError`、主机侧 `Error` 都算)。把「取形状」和「查表」分开,是为了让
153
+ * 两条路都只经一次翻译;没有 code 的抛出物用调用方给的 fallback,避免把
154
+ * `undefined` 当码送进 lookupCode 而落进无法解释的兜底。
155
+ */
156
+ function hostFailureOf(error, fallbackCode) {
157
+ const value = error;
158
+ const code = typeof value?.code === "string" && value.code.length > 0 ? value.code : fallbackCode;
159
+ const message = typeof value?.message === "string" && value.message.length > 0 ? value.message : void 0;
160
+ return message === void 0 ? { code } : {
161
+ code,
162
+ message
163
+ };
164
+ }
165
+ /**
130
166
  * 组装给手机看的那句话。
131
167
  *
132
168
  * DSH 把 prompt 准入的**任意**内部失败包成 `session/agent-busy`,真实原因只留在
@@ -213,6 +249,9 @@ function isEnvelope(value) {
213
249
  */
214
250
  const PUSH_SCOPE_BY_TYPE = {
215
251
  "s2c.session.event": "sessions.read",
252
+ "s2c.skills.changed": "sessions.read",
253
+ "s2c.commands.changed": "sessions.read",
254
+ "s2c.models.changed": "sessions.read",
216
255
  "s2c.sessions.delta": "sessions.read",
217
256
  "s2c.session.tail": "sessions.read",
218
257
  "s2c.history.page": "sessions.read",
@@ -355,6 +394,333 @@ var AuthRateLimiter = class {
355
394
  }
356
395
  };
357
396
  //#endregion
397
+ //#region src/diagnostics.ts
398
+ /**
399
+ * 连接诊断事件模型(Diagnostics)——插件唯一的「失败可观测」面。
400
+ *
401
+ * 为什么需要独立模块:本插件的日志出口原先是 `apply()` 里的一个闭包
402
+ * (`console.log('[deeppilot] ' + message)`),无时间戳、无级别、无来源;
403
+ * 连接被拒、认证失败、TLS 握手失败都各自静默结束,用户在设置页只看到
404
+ * 「连不上」三个字。把事件模型、节流、环形缓冲与落盘收在这里,目的有三:
405
+ *
406
+ * 1. 一次失败 = 一条结构化事件(时间戳 + kind + reason + 来源哈希 +
407
+ * 可选 attemptId / 关闭码 / 耗时),微信、tailscale、扫描器刷屏时仍
408
+ * 只留可读的十几行;
409
+ * 2. index.ts / connection-gate.ts / phone-server.ts / connection.ts 四个
410
+ * 入口共用同一个 `record(kind, reason, extra)`,失败路径不再各自发明
411
+ * 日志文案;
412
+ * 3. 环形 256 条 + 按天落盘(保留 7 天、目录上限 5MB)让「刚刚那次连不上」
413
+ * 在重启前后都能查;`recent()` / `lastFailure()` 同时供 `/phone/health`
414
+ * 与设置页诊断区块读取。
415
+ *
416
+ * 硬约束(与 AGENTS.md 一致):
417
+ * - 事件里只出现 auditLabel 产生的来源哈希,绝不出现原始 IP;
418
+ * - attemptId 只记录、不校验、不参与任何准入判断;长度 ≤16,字符集
419
+ * `[A-Za-z0-9_-]`,来源不可信(WS query `at` / HTTP header
420
+ * `X-DeepPilot-Attempt`);
421
+ * - 不打印配对码、公钥原文、APNs token、relay 凭据与消息正文。detail
422
+ * 字段只接受代码生成的短文本(如 socket 错误码),最长 160 字符。
423
+ *
424
+ * 与 `diagnostics.debug` 的关系:本模块的事件**无条件记录**。那个开关只
425
+ * 控制现有的 per-frame / 推送日志,失败事件若也绑到它上面,用户不开调试
426
+ * 开关就永远看不到为什么连不上——这正是本轮要修的 P0。
427
+ */
428
+ /**
429
+ * 统一日志行格式:`[deeppilot] <ISO 时间戳> <LEVEL> <message>`。
430
+ *
431
+ * 时间戳是 P0 的可观测性基线:没有它,用户贴回来的日志无法与手机端时间
432
+ * 对齐,也无法判断「刚才那次」是哪一次。ISO-8601(UTC)避免时区误读。
433
+ */
434
+ function formatLogLine(level, message, at = Date.now()) {
435
+ return `[deeppilot] ${new Date(at).toISOString()} ${level.toUpperCase()} ${message}`;
436
+ }
437
+ /**
438
+ * 插件日志函数。单参调用即 info;失败路径显式传 'warn'。
439
+ * 返回值的双参签名可以安全地传给只接受 `(message: string) => void` 的
440
+ * 既有依赖(PushGateway / HostBridge / 两个 transport 行)。
441
+ */
442
+ function createLogger() {
443
+ return (message, level = "info") => {
444
+ console.log(formatLogLine(level, message));
445
+ };
446
+ }
447
+ const MAX_DETAIL_CHARS = 160;
448
+ const MAX_REASON_CHARS = 64;
449
+ const DEFAULT_CAPACITY$1 = 256;
450
+ /** 同 kind + reason + source 在 60 秒内只输出一行。 */
451
+ const DEFAULT_THROTTLE_MS = 6e4;
452
+ const INFO_KINDS = /* @__PURE__ */ new Set(["connection-opened"]);
453
+ function defaultLevel(kind) {
454
+ return INFO_KINDS.has(kind) ? "info" : "warn";
455
+ }
456
+ /** 事件文本一律单行:换成空格并去掉控制字符,避免日志被注入换行。 */
457
+ function sanitizeText(value, maxChars) {
458
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]+/gu, " ").replace(/\s+/g, " ").trim().slice(0, maxChars);
459
+ }
460
+ function normalizeReason(reason) {
461
+ return sanitizeText(reason, MAX_REASON_CHARS).replace(/\s+/g, "-");
462
+ }
463
+ /**
464
+ * 来源只允许短标识(auditLabel 的 12 位十六进制)。原始 IP 含 `.` / `:`,
465
+ * 会在这一步被丢弃——即便某个调用方忘了哈希,也不会有 IP 落进日志。
466
+ */
467
+ function normalizeSource(value) {
468
+ if (typeof value !== "string") return void 0;
469
+ return /^[A-Za-z0-9_-]{1,64}$/.test(value) ? value : void 0;
470
+ }
471
+ const ATTEMPT_ID_HEADER = "x-deeppilot-attempt";
472
+ /** 只允许 URL 安全字符,最长 16:记录用,永不回显或参与判定。 */
473
+ const ATTEMPT_ID_PATTERN = /^[A-Za-z0-9_-]{1,16}$/;
474
+ /** 归一化一个客户端提供的 attemptId;不合法即丢弃(不报错、不拒绝请求)。 */
475
+ function normalizeAttemptId(value) {
476
+ if (typeof value !== "string") return void 0;
477
+ return ATTEMPT_ID_PATTERN.test(value) ? value : void 0;
478
+ }
479
+ /**
480
+ * 从一条 HTTP / WS 升级请求里取 attemptId。
481
+ *
482
+ * 优先 HTTP header(真实 HTTP 客户端可自由设置),其次 WS 升级 URL 的
483
+ * `?at=`(浏览器 / URLSession 的 WS 握手无法自定义 header)。两个来源都
484
+ * 经过同一份长度与字符集校验;任何异常(畸形 URL、数组头)都按「没有
485
+ * attemptId」处理——这是纯观测字段,绝不能因为它让握手失败。
486
+ */
487
+ function attemptIdFromRequest(request) {
488
+ const header = request.headers?.[ATTEMPT_ID_HEADER];
489
+ const fromHeader = normalizeAttemptId(Array.isArray(header) ? header[0] : header);
490
+ if (fromHeader !== void 0) return fromHeader;
491
+ if (typeof request.url !== "string" || request.url.length === 0) return void 0;
492
+ try {
493
+ return normalizeAttemptId(new URL(request.url, "http://phone.local").searchParams.get("at"));
494
+ } catch {
495
+ return;
496
+ }
497
+ }
498
+ /**
499
+ * 内存环形 + 节流日志 + 按天落盘。
500
+ *
501
+ * 职责边界:它不做任何策略判断(不决定是否拒绝连接),只负责把调用方
502
+ * 已经判定的失败事实记下来。因此它可以在四个入口之间安全共用。
503
+ */
504
+ var DiagnosticsStore = class {
505
+ events = [];
506
+ throttles = /* @__PURE__ */ new Map();
507
+ capacity;
508
+ throttleMs;
509
+ now;
510
+ write;
511
+ dir;
512
+ tail = Promise.resolve();
513
+ /** 已统计过的文件大小,避免每次 append 前都 stat。 */
514
+ fileSizes = /* @__PURE__ */ new Map();
515
+ directoryBytes = 0;
516
+ /** 最近一次执行过保留期清理的 UTC 日期(YYYY-MM-DD)。 */
517
+ prunedDay;
518
+ /** 落盘自身失败只报一次:否则磁盘满会把日志刷成新的刷屏源。 */
519
+ writeFailureReported = false;
520
+ constructor(options = {}) {
521
+ this.capacity = options.capacity ?? DEFAULT_CAPACITY$1;
522
+ this.throttleMs = options.throttleMs ?? DEFAULT_THROTTLE_MS;
523
+ this.now = options.now ?? Date.now;
524
+ this.write = options.write ?? ((line) => {
525
+ console.log(line);
526
+ });
527
+ this.dir = options.dir;
528
+ }
529
+ /**
530
+ * 记录一条事件。事件总是进入内存环形;终端日志与落盘按
531
+ * kind + reason + source 节流(60s 一行,恢复时标注 suppressed 次数)。
532
+ * 返回完整事件,方便调用方测试与断言。
533
+ */
534
+ record(kind, reason, extra = {}) {
535
+ const ts = this.now();
536
+ const attemptId = normalizeAttemptId(extra.attemptId);
537
+ const source = normalizeSource(extra.source);
538
+ const event = {
539
+ ts,
540
+ kind,
541
+ reason: normalizeReason(reason),
542
+ level: extra.level ?? defaultLevel(kind),
543
+ ...attemptId !== void 0 ? { attemptId } : {},
544
+ ...source !== void 0 ? { source } : {},
545
+ ...extra.closeCode !== void 0 ? { closeCode: extra.closeCode } : {},
546
+ ...extra.durationMs !== void 0 ? { durationMs: Math.max(0, Math.round(extra.durationMs)) } : {},
547
+ ...extra.authenticated !== void 0 ? { authenticated: extra.authenticated } : {},
548
+ ...extra.detail !== void 0 ? { detail: sanitizeText(extra.detail, MAX_DETAIL_CHARS) } : {}
549
+ };
550
+ this.events.push(event);
551
+ while (this.events.length > this.capacity) this.events.shift();
552
+ const throttleKey = `${kind}\u0000${event.reason}\u0000${event.source ?? "-"}`;
553
+ const previous = this.throttles.get(throttleKey);
554
+ let suppressed = 0;
555
+ if (previous !== void 0 && ts - previous.lastTs < this.throttleMs) {
556
+ previous.suppressed += 1;
557
+ return event;
558
+ }
559
+ if (previous !== void 0) suppressed = previous.suppressed;
560
+ this.throttles.set(throttleKey, {
561
+ lastTs: ts,
562
+ suppressed: 0
563
+ });
564
+ this.write(formatEventLine(event, suppressed));
565
+ this.persist(event, suppressed);
566
+ return event;
567
+ }
568
+ /** 最近 `limit` 条事件(时间升序),供 health / report / 测试读取。 */
569
+ recent(limit = 20) {
570
+ const size = Math.max(0, Math.trunc(limit));
571
+ return this.events.slice(-size);
572
+ }
573
+ /** 最近 `limit` 条失败事件(warn 级),新→旧排序前的升序切片。 */
574
+ recentFailures(limit = 10) {
575
+ const failures = this.events.filter((event) => event.level === "warn");
576
+ const size = Math.max(0, Math.trunc(limit));
577
+ return failures.slice(-size);
578
+ }
579
+ /** 最近一条失败事件;没有失败时 undefined。 */
580
+ lastFailure() {
581
+ for (let index = this.events.length - 1; index >= 0; index -= 1) {
582
+ const event = this.events[index];
583
+ if (event !== void 0 && event.level === "warn") return event;
584
+ }
585
+ }
586
+ /** 等价于「诊断面板复制」/health 需要的最近失败摘要。 */
587
+ lastFailureSummary() {
588
+ const failure = this.lastFailure();
589
+ return failure === void 0 ? void 0 : {
590
+ kind: failure.kind,
591
+ reason: failure.reason,
592
+ ts: failure.ts
593
+ };
594
+ }
595
+ /** 等待所有排队中的落盘完成(测试与进程收尾用)。 */
596
+ async flush() {
597
+ await this.tail;
598
+ }
599
+ /** 进程收尾:不再有新的写入队列,等待既有写入落盘。 */
600
+ async dispose() {
601
+ await this.flush();
602
+ }
603
+ /**
604
+ * 追加一行 JSONL。写入串行、失败静默(只报一次),绝不阻塞调用方——
605
+ * 诊断日志不能反过来拖慢或搞崩握手路径。
606
+ */
607
+ persist(event, suppressed) {
608
+ const dir = this.dir;
609
+ if (dir === void 0) return;
610
+ const day = utcDay(event.ts);
611
+ const path = join(dir, `diagnostics-${day}.jsonl`);
612
+ const payload = suppressed > 0 ? {
613
+ ...event,
614
+ suppressed
615
+ } : event;
616
+ const line = JSON.stringify(payload) + "\n";
617
+ const bytes = Buffer.byteLength(line);
618
+ this.tail = this.tail.then(async () => {
619
+ try {
620
+ await this.prepareDirectory(dir, day);
621
+ const currentSize = this.fileSizes.get(path) ?? await stat(path).then((info) => info.size).catch(() => 0);
622
+ this.fileSizes.set(path, currentSize);
623
+ if (this.directoryBytes + bytes > 5242880) return;
624
+ await mkdir(dir, {
625
+ recursive: true,
626
+ mode: 448
627
+ });
628
+ await appendFile(path, line, { mode: 384 });
629
+ this.fileSizes.set(path, currentSize + bytes);
630
+ this.directoryBytes += bytes;
631
+ } catch (error) {
632
+ this.reportWriteFailure(error);
633
+ }
634
+ });
635
+ this.tail = this.tail.catch(() => {});
636
+ }
637
+ /**
638
+ * 每天第一次落盘时清理保留期之外的文件并统计目录大小;同时限制目录
639
+ * 总量(5MB)。清理失败不影响当次写入。
640
+ */
641
+ async prepareDirectory(dir, day) {
642
+ if (this.prunedDay === day) return;
643
+ const names = await readdir(dir).catch(() => []);
644
+ const cutoffDay = utcDayMinus(day, 6);
645
+ let total = 0;
646
+ for (const name of names) {
647
+ const match = /^diagnostics-(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(name);
648
+ if (match === null) continue;
649
+ const full = join(dir, name);
650
+ if (match[1] !== void 0 && match[1] < cutoffDay) {
651
+ await unlink(full).catch(() => {});
652
+ this.fileSizes.delete(full);
653
+ continue;
654
+ }
655
+ const size = await stat(full).then((info) => info.size).catch(() => 0);
656
+ this.fileSizes.set(full, size);
657
+ total += size;
658
+ }
659
+ this.directoryBytes = total;
660
+ this.prunedDay = day;
661
+ }
662
+ reportWriteFailure(error) {
663
+ if (this.writeFailureReported) return;
664
+ this.writeFailureReported = true;
665
+ this.write(formatLogLine("warn", "diagnostics write failed: " + sanitizeText(String(error), MAX_DETAIL_CHARS)));
666
+ }
667
+ };
668
+ /** 一条事件的终端日志文本(不含前缀时间戳,由 formatLogLine 统一加)。 */
669
+ function formatEventLine(event, suppressed) {
670
+ const parts = [event.kind, event.reason];
671
+ if (event.source !== void 0) parts.push("src=" + event.source);
672
+ if (event.attemptId !== void 0) parts.push("attempt=" + event.attemptId);
673
+ if (event.closeCode !== void 0) parts.push("code=" + String(event.closeCode));
674
+ if (event.durationMs !== void 0) parts.push("durationMs=" + String(event.durationMs));
675
+ if (event.authenticated !== void 0) parts.push("auth=" + (event.authenticated ? "yes" : "no"));
676
+ if (suppressed > 0) parts.push("suppressed=" + String(suppressed));
677
+ if (event.detail !== void 0) parts.push("detail=" + event.detail);
678
+ return formatLogLine(event.level, parts.join(" "), event.ts);
679
+ }
680
+ function utcDay(ts) {
681
+ return new Date(ts).toISOString().slice(0, 10);
682
+ }
683
+ /** 日期字符串(YYYY-MM-DD)往前推 `days` 天;保留期计算用。 */
684
+ function utcDayMinus(day, days) {
685
+ const date = /* @__PURE__ */ new Date(day + "T00:00:00.000Z");
686
+ date.setUTCDate(date.getUTCDate() - days);
687
+ return date.toISOString().slice(0, 10);
688
+ }
689
+ /**
690
+ * `/phone/health` 的响应体:**只加不删**。
691
+ *
692
+ * 原有 `ok/enabled/protocolVersion/serverVersion/dataPlane` 保持原义;
693
+ * 新增字段只描述「这台电脑此刻能不能被连上」,不含 debug 开关状态,
694
+ * 也不返回局域网地址候选(那是配对页的职责,health 不该成为扫描器
695
+ * 的地址字典):
696
+ * - `local`:LAN 传输此刻的相位与端口;
697
+ * - `dataPlanePhase`:数据面 active / inactive;
698
+ * - `tlsRebuilt`:本次运行是否重建过 TLS 身份(手机据此判断是否需要重新配对);
699
+ * - `startedAt`:插件启动时刻;
700
+ * - `lastFailure`:最近一次失败的类型、分因与时间。
701
+ */
702
+ function buildHealthPayload(input) {
703
+ return {
704
+ ok: true,
705
+ enabled: input.enabled,
706
+ protocolVersion: input.protocolVersion,
707
+ serverVersion: input.serverVersion,
708
+ dataPlane: input.dataPlaneActive,
709
+ dataPlanePhase: input.dataPlaneActive ? "active" : "inactive",
710
+ local: {
711
+ phase: input.localPhase,
712
+ port: input.localPort
713
+ },
714
+ tlsRebuilt: input.tlsIdentityRegenerated,
715
+ startedAt: input.startedAt,
716
+ lastFailure: input.lastFailure === void 0 ? null : {
717
+ kind: input.lastFailure.kind,
718
+ reason: input.lastFailure.reason,
719
+ ts: input.lastFailure.ts
720
+ }
721
+ };
722
+ }
723
+ //#endregion
358
724
  //#region src/device-auth.ts
359
725
  function getPairingCodeTtlMs() {
360
726
  return Number(process.env.DEEPPILOT_PAIRING_TTL_MS) || 3e5;
@@ -380,6 +746,7 @@ async function loadOrCreateHostAudience(path) {
380
746
  const audience = "deeppilot:" + randomBytes(16).toString("base64url");
381
747
  await mkdir(dirname(path), { recursive: true });
382
748
  await writeFile(path, audience + "\n", { mode: 384 });
749
+ console.log(formatLogLine("info", "host identity created: " + path + " (previously paired devices must pair again)"));
383
750
  return audience;
384
751
  }
385
752
  function b64urlText(value) {
@@ -644,6 +1011,11 @@ var DeviceStore = class DeviceStore {
644
1011
  filePath;
645
1012
  devices = /* @__PURE__ */ new Map();
646
1013
  flushTail = Promise.resolve();
1014
+ /**
1015
+ * 注册表写失败的日志节流:磁盘只读 / 空间不足时每次 flush 都会失败,
1016
+ * 不节流会把「写不进去」变成新的刷屏源。同一条错误 60s 只报一次。
1017
+ */
1018
+ lastWriteFailureLogAt = 0;
647
1019
  constructor(filePath) {
648
1020
  this.filePath = filePath;
649
1021
  }
@@ -653,7 +1025,7 @@ var DeviceStore = class DeviceStore {
653
1025
  const raw = JSON.parse(await readFile(expandHome(filePath), "utf8"));
654
1026
  for (const rec of raw.devices ?? []) if (typeof rec.deviceId === "string") store.devices.set(rec.deviceId, rec);
655
1027
  } catch (error) {
656
- if (error?.code === "ENOENT") {} else console.log("[deeppilot] device registry unreadable, starting empty: " + String(error));
1028
+ if (error?.code === "ENOENT") {} else console.log(formatLogLine("warn", "device registry unreadable, starting empty: " + String(error)));
657
1029
  }
658
1030
  return store;
659
1031
  }
@@ -686,6 +1058,18 @@ var DeviceStore = class DeviceStore {
686
1058
  if (!record?.publicKey || record.revokedAt !== void 0) return void 0;
687
1059
  return record;
688
1060
  }
1061
+ /**
1062
+ * 认证分因用:把「为什么 authorized() 返回 undefined」拆成三种情形。
1063
+ * 只给日志使用,准入判定仍然只走 authorized()——wire 响应不会因为
1064
+ * 这个方法泄露设备是否注册过(4401 仍只有一个码)。
1065
+ */
1066
+ statusOf(deviceId) {
1067
+ const record = this.devices.get(deviceId);
1068
+ if (record === void 0) return "unknown";
1069
+ if (record.revokedAt !== void 0) return "revoked";
1070
+ if (record.publicKey === void 0) return "unknown";
1071
+ return "active";
1072
+ }
689
1073
  markAuthenticated(deviceId, deviceName, appVersion, now) {
690
1074
  const record = this.authorized(deviceId);
691
1075
  if (!record) return;
@@ -850,7 +1234,18 @@ var DeviceStore = class DeviceStore {
850
1234
  const temp = `${full}.${randomBytes(6).toString("hex")}.tmp`;
851
1235
  await writeFile(temp, body + "\n", { mode: 384 });
852
1236
  await rename(temp, full);
853
- } catch {}
1237
+ } catch (error) {
1238
+ this.logWriteFailure(error);
1239
+ }
1240
+ }
1241
+ /** 写失败日志按 60s 节流:磁盘满时 flush 会被反复触发,不能让它刷屏。 */
1242
+ logWriteFailure(error) {
1243
+ const now = Date.now();
1244
+ if (now - this.lastWriteFailureLogAt < 6e4) return;
1245
+ this.lastWriteFailureLogAt = now;
1246
+ const code = error?.code;
1247
+ const message = error instanceof Error ? error.message : String(error);
1248
+ console.log(formatLogLine("warn", "device registry write failed" + (typeof code === "string" ? " code=" + code : "") + ": " + message.slice(0, 160)));
854
1249
  }
855
1250
  };
856
1251
  //#endregion
@@ -2067,6 +2462,26 @@ const sessionRows = [
2067
2462
  ctx.send("s2c.ack", image);
2068
2463
  }
2069
2464
  },
2465
+ {
2466
+ type: "c2s.session.skills",
2467
+ stage: "authenticated",
2468
+ scopes: READ,
2469
+ capability: "skills",
2470
+ doc: "PROTOCOL.md c2s.session.skills",
2471
+ validate: (payload) => {
2472
+ const checked = payloadObject(payload);
2473
+ if (!checked.ok) return checked;
2474
+ return requireSessionId(checked.value) ?? checked;
2475
+ },
2476
+ handle: async (ctx, payload) => {
2477
+ const result = await ctx.bridge.sessionSkills(payload.sessionId);
2478
+ if (!result.ok) return ctx.fail(result.code, result.message);
2479
+ ctx.send("s2c.session.skills", {
2480
+ sessionId: payload.sessionId,
2481
+ ...result.value
2482
+ });
2483
+ }
2484
+ },
2070
2485
  {
2071
2486
  type: "c2s.session.models",
2072
2487
  stage: "authenticated",
@@ -2219,6 +2634,121 @@ const sessionRows = [
2219
2634
  }
2220
2635
  }
2221
2636
  ];
2637
+ //#endregion
2638
+ //#region src/wire-command.ts
2639
+ function sessionPayload(value) {
2640
+ const result = payloadObject(value);
2641
+ return result.ok && !isText(result.value.sessionId) ? reject$1("E_PROTOCOL", "会话标识无效") : result;
2642
+ }
2643
+ const categories = [
2644
+ "task-result",
2645
+ "instruction-following",
2646
+ "product-interaction",
2647
+ "service-stability",
2648
+ "resource-cost",
2649
+ "security-privacy-permission",
2650
+ "other"
2651
+ ];
2652
+ const commandRows = [...[
2653
+ "commands",
2654
+ "permissions",
2655
+ "feedback",
2656
+ "command"
2657
+ ].map((name) => ({
2658
+ type: `c2s.session.${name}`,
2659
+ stage: "authenticated",
2660
+ capability: "commands",
2661
+ scopes: name === "commands" || name === "permissions" ? ["sessions.read"] : ["prompt.send"],
2662
+ doc: `PROTOCOL.md c2s.session.${name}`,
2663
+ validate: (value) => {
2664
+ const result = sessionPayload(value);
2665
+ if (!result.ok) return result;
2666
+ const p = result.value;
2667
+ if (name === "command") {
2668
+ if (typeof p.line !== "string" || p.line.length > 65536 || !/^\/[a-z][a-z0-9_-]*(?:\s|$)/.test(p.line)) return reject$1("E_PROTOCOL", "指令格式无效");
2669
+ if (p.expectedDefinitionId !== void 0 && (typeof p.expectedDefinitionId !== "string" || !p.expectedDefinitionId || p.expectedDefinitionId.length > 512)) return reject$1("E_PROTOCOL", "指令定义标识无效");
2670
+ const attachments = p.attachments ?? [];
2671
+ if (!Array.isArray(attachments) || attachments.length > 4) return reject$1("E_PROTOCOL", "附件数量无效");
2672
+ for (const item of attachments) if (!item || !["image", "document"].includes(item.type) || typeof item.name !== "string" || item.name.length > 512 || typeof item.mediaType !== "string" || typeof item.data !== "string" || item.data.length > 16777216 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(item.data) || item.type === "image" && ![
2673
+ "image/png",
2674
+ "image/jpeg",
2675
+ "image/webp",
2676
+ "image/gif"
2677
+ ].includes(item.mediaType)) return reject$1("E_PROTOCOL", "附件格式无效");
2678
+ return {
2679
+ ok: true,
2680
+ value: {
2681
+ ...p,
2682
+ attachments
2683
+ }
2684
+ };
2685
+ }
2686
+ if (name === "feedback" && (typeof p.text !== "string" || p.text.length > 65536 || p.category !== void 0 && !categories.includes(String(p.category)))) return reject$1("E_PROTOCOL", "反馈内容无效");
2687
+ return result;
2688
+ },
2689
+ handle: async (ctx, payload) => {
2690
+ const operation = name === "commands" ? "list" : name === "command" ? "execute" : name;
2691
+ const result = await ctx.bridge.commandOperation(operation, payload);
2692
+ if (!result.ok) return ctx.fail(result.code, result.message);
2693
+ const type = name === "commands" ? "s2c.session.commands" : name === "permissions" ? "s2c.session.permissions" : name === "command" ? "s2c.session.commandResult" : "s2c.ack";
2694
+ ctx.send(type, {
2695
+ sessionId: payload.sessionId,
2696
+ ...result.value
2697
+ });
2698
+ }
2699
+ })), {
2700
+ type: "c2s.session.export",
2701
+ stage: "authenticated",
2702
+ scopes: ["sessions.read"],
2703
+ capability: "commands",
2704
+ doc: "PROTOCOL.md c2s.session.export",
2705
+ validate: sessionPayload,
2706
+ handle: async (ctx, payload) => {
2707
+ const signal = AbortSignal.any([ctx.signal ?? new AbortController().signal, AbortSignal.timeout(6e5)]);
2708
+ let reader;
2709
+ const cancel = () => {
2710
+ reader?.cancel().catch(() => {});
2711
+ };
2712
+ try {
2713
+ reader = (await ctx.bridge.exportSession(payload.sessionId, signal)).body.getReader();
2714
+ signal.addEventListener("abort", cancel, { once: true });
2715
+ let index = 0, bytes = 0;
2716
+ while (true) {
2717
+ signal.throwIfAborted();
2718
+ const next = await reader.read();
2719
+ if (next.done) break;
2720
+ for (let offset = 0; offset < next.value.length; offset += 49152) {
2721
+ const chunk = next.value.subarray(offset, offset + 49152);
2722
+ bytes += chunk.length;
2723
+ await ctx.waitForWritable?.();
2724
+ ctx.send("s2c.session.export.chunk", {
2725
+ index: index++,
2726
+ data: Buffer.from(chunk).toString("base64")
2727
+ });
2728
+ }
2729
+ }
2730
+ signal.throwIfAborted();
2731
+ ctx.send("s2c.ack", {
2732
+ sessionId: payload.sessionId,
2733
+ fileName: "dsh-session-log.zip",
2734
+ chunks: index,
2735
+ bytes
2736
+ });
2737
+ } catch (error) {
2738
+ const failure = hostFailureOf(error, "command/export-cancelled");
2739
+ const message = failure.code === "command/export-cancelled" ? "会话日志导出失败,请重试" : failure.message;
2740
+ const mapped = wireErrorOf("command", {
2741
+ ...failure,
2742
+ message
2743
+ });
2744
+ ctx.fail(mapped.code, mapped.message);
2745
+ } finally {
2746
+ signal.removeEventListener("abort", cancel);
2747
+ await reader?.cancel().catch(() => {});
2748
+ reader?.releaseLock();
2749
+ }
2750
+ }
2751
+ }];
2222
2752
  /** 第一个缺失的 scope;消息与迁移前一致(`scope <name> required`)。 */
2223
2753
  function scopeRejection(row, scopes) {
2224
2754
  for (const scope of row.scopes) if (!scopes.has(scope)) return {
@@ -2297,6 +2827,7 @@ function accept(value) {
2297
2827
  /** 全部入站行。装配是静态的:新增特性 module 必须在这里显式出现。 */
2298
2828
  const WIRE_FRAME_ROWS = [
2299
2829
  ...sessionRows,
2830
+ ...commandRows,
2300
2831
  ...scheduleRows,
2301
2832
  ...interactionRows,
2302
2833
  ...pushRows,
@@ -2329,6 +2860,27 @@ function registryRowFor(type) {
2329
2860
  * (成功/证明无效/超时/关闭)都经 settleAuthentication 的 exactly-once
2330
2861
  * 守卫释放一次。
2331
2862
  */
2863
+ /**
2864
+ * 把一次认证失败归一到一个原因。判定顺序就是语义优先级:
2865
+ *
2866
+ * 1. 注册表状态先行——设备不存在 / 已撤销时,后面任何字段都不可信;
2867
+ * 2. nonce / audience 不匹配:挑战对象不是这一次(重放或串线);
2868
+ * 3. 服务器时钟已过挑战有效期:挑战过期;
2869
+ * 4. 客户端回显的 issuedAt / expiresAt 与挑战不同:客户端时钟或状态偏差;
2870
+ * 5. 其余(签名缺失 / 验签失败):bad-signature。
2871
+ *
2872
+ * 纯函数,连接门与单测共用同一份判定。
2873
+ */
2874
+ function classifyAuthFailure(input) {
2875
+ if (input.device === "revoked") return "revoked";
2876
+ if (input.device === "unknown") return "unknown-device";
2877
+ const { proof, challenge, now } = input;
2878
+ if (proof.nonce !== challenge.nonce) return "nonce-mismatch";
2879
+ if (proof.audience !== challenge.audience) return "audience-mismatch";
2880
+ if (now > challenge.expiresAt) return "challenge-expired";
2881
+ if (proof.issuedAt !== challenge.issuedAt || proof.expiresAt !== challenge.expiresAt) return "clock-skew";
2882
+ return "bad-signature";
2883
+ }
2332
2884
  /** upgrade 正常是毫秒级;5 秒已极宽,只为「回调根本不执行」兜底。 */
2333
2885
  const DEFAULT_ATTACH_TIMEOUT_MS = 5e3;
2334
2886
  var ConnectionGate = class {
@@ -2389,6 +2941,7 @@ var ConnectionGate = class {
2389
2941
  this.helloTimer = void 0;
2390
2942
  if (this.authenticated) return;
2391
2943
  this.settle(false, "timeout");
2944
+ this.reportAuthFailure("timeout", 4402);
2392
2945
  this.host?.close(4402, "auth timeout");
2393
2946
  }, AUTH_TIMEOUT_MS);
2394
2947
  this.helloTimer.unref();
@@ -2478,18 +3031,21 @@ var ConnectionGate = class {
2478
3031
  const p = env.payload ?? {};
2479
3032
  if (!p.deviceId) {
2480
3033
  host.fail(env.id, "E_PROTOCOL", "deviceId required");
3034
+ this.reportAuthFailure("device-id-missing", 4403);
2481
3035
  host.close(4403, "deviceId required");
2482
3036
  return;
2483
3037
  }
2484
3038
  const deviceId = sanitizeDeviceField(p.deviceId, 128);
2485
3039
  if (!deviceId) {
2486
3040
  host.fail(env.id, "E_PROTOCOL", "deviceId required");
3041
+ this.reportAuthFailure("device-id-missing", 4403);
2487
3042
  host.close(4403, "deviceId required");
2488
3043
  return;
2489
3044
  }
2490
3045
  const deviceName = sanitizeDeviceField(p.deviceName, 64) || "unknown";
2491
3046
  const appVersion = sanitizeDeviceField(p.appVersion, 32) || "unknown";
2492
- const record = this.options.devices.authorized(deviceId);
3047
+ const deviceState = this.options.devices.statusOf(deviceId);
3048
+ const record = deviceState === "active" ? this.options.devices.authorized(deviceId) : void 0;
2493
3049
  const resumeCursor = typeof p.resumeCursor === "number" && Number.isInteger(p.resumeCursor) && p.resumeCursor >= 0 ? p.resumeCursor : void 0;
2494
3050
  const challengeMatches = p.nonce === this.challenge.nonce && p.audience === this.challenge.audience && p.issuedAt === this.challenge.issuedAt && p.expiresAt === this.challenge.expiresAt && Date.now() <= this.challenge.expiresAt;
2495
3051
  if (!(record?.publicKey !== void 0 && typeof p.signature === "string" && challengeMatches && verifyAuthProof(record.publicKey, {
@@ -2499,7 +3055,14 @@ var ConnectionGate = class {
2499
3055
  resumeCursor,
2500
3056
  ...this.challenge
2501
3057
  }, p.signature)) || record === void 0) {
3058
+ const reason = classifyAuthFailure({
3059
+ device: deviceState,
3060
+ proof: p,
3061
+ challenge: this.challenge,
3062
+ now: Date.now()
3063
+ });
2502
3064
  host.fail(env.id, "E_AUTH", "device proof missing or invalid");
3065
+ this.reportAuthFailure(reason, 4401);
2503
3066
  this.settle(false, "invalid-proof");
2504
3067
  host.close(4401, "invalid device proof");
2505
3068
  return;
@@ -2521,6 +3084,19 @@ var ConnectionGate = class {
2521
3084
  appVersion
2522
3085
  });
2523
3086
  }
3087
+ /**
3088
+ * 认证失败事件。来源只写 auditLabel 哈希(没有哈希函数时宁可不写,
3089
+ * 也不把原始 IP 落盘);attemptId 原样带出,便于把同一次握手的
3090
+ * upgrade / auth 两条日志串起来。
3091
+ */
3092
+ reportAuthFailure(reason, closeCode) {
3093
+ const source = this.options.auditLabel?.(this.options.source);
3094
+ this.options.diagnostics?.record("auth-failed", reason, {
3095
+ ...source !== void 0 ? { source } : {},
3096
+ ...this.options.attemptId !== void 0 ? { attemptId: this.options.attemptId } : {},
3097
+ closeCode
3098
+ });
3099
+ }
2524
3100
  /** 限流记账与名额释放只此一处;exactly-once 由 settled 守卫保证。 */
2525
3101
  settle(ok, reason) {
2526
3102
  if (this.settled) return;
@@ -2545,6 +3121,8 @@ var ConnectionGate = class {
2545
3121
  const host = this.host;
2546
3122
  return {
2547
3123
  frame: env,
3124
+ signal: host.signal,
3125
+ waitForWritable: host.waitForWritable?.bind(host),
2548
3126
  deviceId: this.deviceId,
2549
3127
  widgetClient: this.widgetClient,
2550
3128
  bridge: host.bridge,
@@ -2604,6 +3182,11 @@ var BridgeConnection = class {
2604
3182
  liveActivityRegistrationGeneration = 0;
2605
3183
  pendingLiveActivityId;
2606
3184
  lastActivity = Date.now();
3185
+ streamAbort = new AbortController();
3186
+ /** 建连时刻:关闭事件用它算存活时长。 */
3187
+ openedAt = Date.now();
3188
+ /** 来源的审计哈希;没有 auditLabel 时留空,绝不记录原始 IP。 */
3189
+ sourceLabel;
2607
3190
  /** True when no inbound frame arrived within maxIdleMs. */
2608
3191
  isStale(now, maxIdleMs) {
2609
3192
  return now - this.lastActivity > maxIdleMs;
@@ -2618,12 +3201,24 @@ var BridgeConnection = class {
2618
3201
  audience: deps.audience,
2619
3202
  limiter: deps.rateLimiter ?? new AuthRateLimiter(),
2620
3203
  log: (message) => deps.log(message),
2621
- ...deps.auditLabel ? { auditLabel: deps.auditLabel } : {}
3204
+ ...deps.auditLabel ? { auditLabel: deps.auditLabel } : {},
3205
+ ...deps.diagnostics ? { diagnostics: deps.diagnostics } : {},
3206
+ ...deps.attemptId !== void 0 ? { attemptId: deps.attemptId } : {}
2622
3207
  })) {
2623
3208
  this.ws = ws;
2624
3209
  this.deps = deps;
2625
3210
  const connection = this;
3211
+ const sourceLabel = deps.auditLabel?.(deps.source ?? "local");
3212
+ this.sourceLabel = sourceLabel;
2626
3213
  const host = {
3214
+ signal: this.streamAbort.signal,
3215
+ waitForWritable: async () => {
3216
+ while (this.ws.bufferedAmount > 262144) {
3217
+ this.streamAbort.signal.throwIfAborted();
3218
+ await new Promise((resolve) => setTimeout(resolve, 10));
3219
+ }
3220
+ this.streamAbort.signal.throwIfAborted();
3221
+ },
2627
3222
  get bridge() {
2628
3223
  return connection.deps.bridge;
2629
3224
  },
@@ -2676,12 +3271,23 @@ var BridgeConnection = class {
2676
3271
  this.terminate();
2677
3272
  });
2678
3273
  });
2679
- ws.on("close", () => {
2680
- this.onClose();
3274
+ ws.on("close", (code) => {
3275
+ this.onClose(code);
2681
3276
  deps.onClosed?.(this);
2682
3277
  });
2683
- ws.on("error", () => {});
3278
+ ws.on("error", (error) => {
3279
+ this.deps.diagnostics?.record("connection-error", "socket-error", {
3280
+ ...this.sourceLabel !== void 0 ? { source: this.sourceLabel } : {},
3281
+ ...this.deps.attemptId !== void 0 ? { attemptId: this.deps.attemptId } : {},
3282
+ authenticated: this.identity !== void 0,
3283
+ detail: error instanceof Error ? error.message : String(error ?? "unknown")
3284
+ });
3285
+ });
2684
3286
  gate.attach(host);
3287
+ this.deps.diagnostics?.record("connection-opened", "socket-open", {
3288
+ ...sourceLabel !== void 0 ? { source: sourceLabel } : {},
3289
+ ...deps.attemptId !== void 0 ? { attemptId: deps.attemptId } : {}
3290
+ });
2685
3291
  }
2686
3292
  /** Hard-drop the socket (server-side stale sweep). */
2687
3293
  terminate() {
@@ -2766,9 +3372,18 @@ var BridgeConnection = class {
2766
3372
  else this.resync();
2767
3373
  }
2768
3374
  }
2769
- onClose() {
3375
+ onClose(code) {
2770
3376
  if (this.closed) return;
2771
3377
  this.closed = true;
3378
+ const closeCode = code ?? 1005;
3379
+ if (closeCode !== 1e3 && closeCode !== 1001) this.deps.diagnostics?.record("connection-closed", "abnormal-close", {
3380
+ ...this.sourceLabel !== void 0 ? { source: this.sourceLabel } : {},
3381
+ ...this.deps.attemptId !== void 0 ? { attemptId: this.deps.attemptId } : {},
3382
+ closeCode,
3383
+ durationMs: Date.now() - this.openedAt,
3384
+ authenticated: this.identity !== void 0
3385
+ });
3386
+ this.streamAbort.abort();
2772
3387
  this.gate.onClose();
2773
3388
  for (const id of this.openSessions) this.deps.bridge.markSinkClosed(this, id);
2774
3389
  this.openSessions.clear();
@@ -3422,7 +4037,7 @@ function apnsPayload(notification) {
3422
4037
  return alertPayload(notification);
3423
4038
  }
3424
4039
  function alertPayload(notification) {
3425
- const timeSensitive = notification.category === "approval.required" || notification.category === "question.asked";
4040
+ const timeSensitive = notification.category === "approval.required" || notification.category === "question.asked" || notification.category === "session.error";
3426
4041
  return {
3427
4042
  aps: {
3428
4043
  alert: {
@@ -3431,13 +4046,18 @@ function alertPayload(notification) {
3431
4046
  },
3432
4047
  sound: "default",
3433
4048
  category: notification.category,
4049
+ "mutable-content": 1,
3434
4050
  "thread-id": notification.hostAudience ? createHash("sha256").update(`${notification.hostAudience}:${notification.sessionId}`).digest("hex") : notification.sessionId.slice(0, 64),
3435
4051
  ...timeSensitive ? { "interruption-level": "time-sensitive" } : {}
3436
4052
  },
3437
4053
  ...notification.hostAudience ? { hostAudience: notification.hostAudience } : {},
3438
4054
  sessionId: notification.sessionId,
3439
4055
  notificationId: notification.notificationId,
3440
- kind: notification.category
4056
+ kind: notification.category,
4057
+ ...notification.category === "approval.required" && notification.toolName ? {
4058
+ toolName: notification.toolName.slice(0, 80),
4059
+ ...notification.detail ? { detail: notification.detail.slice(0, 600) } : {}
4060
+ } : {}
3441
4061
  };
3442
4062
  }
3443
4063
  function pushHeaders(bundleId, notification) {
@@ -3758,7 +4378,7 @@ function liveActivityState(session) {
3758
4378
  task: Array.from(session?.activity?.trim() || task?.content || "").slice(0, 160).join(""),
3759
4379
  done,
3760
4380
  total,
3761
- phase: !session || total === 0 ? "unavailable" : session.pendingApproval ? "approval" : session.pendingQuestion ? "question" : session.status === "running" ? "running" : session.status === "idle" ? "ended" : "unavailable"
4381
+ phase: !session || total === 0 ? "unavailable" : session.pendingApproval ? "approval" : session.pendingQuestion ? "question" : session.status === "running" ? "running" : session.status === "idle" ? "ended" : session.status === "error" ? "failed" : "unavailable"
3762
4382
  };
3763
4383
  }
3764
4384
  /** Persist terminal state before coalescing so a rapid next round cannot revive an activity. */
@@ -3779,7 +4399,7 @@ var LiveActivityPushManager = class {
3779
4399
  const r = device.liveActivity;
3780
4400
  if (!r) continue;
3781
4401
  const state = liveActivityState(this.latest.get(r.sessionId));
3782
- if (state.phase === "ended" || state.phase === "unavailable") this.devices()?.endLiveActivity(device.deviceId, r.token, state);
4402
+ if (state.phase === "ended" || state.phase === "failed" || state.phase === "unavailable") this.devices()?.endLiveActivity(device.deviceId, r.token, state);
3783
4403
  }
3784
4404
  this.scheduler.changed();
3785
4405
  }
@@ -3843,6 +4463,8 @@ function shouldReEnrollRelayToken(transport, outcome, reason, opts) {
3843
4463
  /** Apply before either transport receives the payload, including the Relay. */
3844
4464
  function pushContent(notification, mode) {
3845
4465
  if (mode !== "generic") return notification;
4466
+ const { toolName: _toolName, detail: _detail, ...rest } = notification;
4467
+ notification = rest;
3846
4468
  const bodies = {
3847
4469
  "turn.completed": "A task has completed.",
3848
4470
  "approval.required": "An approval needs your attention.",
@@ -4484,7 +5106,7 @@ var PushGateway = class {
4484
5106
  * it the listener is plain HTTP, which is reserved for the loopback Funnel
4485
5107
  * origin where tailscaled terminates TLS.
4486
5108
  */
4487
- function createPhoneServer(handlers, tls) {
5109
+ function createPhoneServer(handlers, tls, options) {
4488
5110
  const onRequest = (req, res) => {
4489
5111
  const path = requestPath(req);
4490
5112
  if (path === "/phone/health") handlers.health(req, res);
@@ -4506,6 +5128,25 @@ function createPhoneServer(handlers, tls) {
4506
5128
  }
4507
5129
  handlers.upgrade(req, socket, head);
4508
5130
  });
5131
+ const onTransportError = options?.onTransportError;
5132
+ if (onTransportError !== void 0) {
5133
+ if (tls !== void 0) server.on("tlsClientError", (error, socket) => {
5134
+ onTransportError({
5135
+ kind: "tls-client-error",
5136
+ error,
5137
+ remoteAddress: socket.remoteAddress
5138
+ });
5139
+ socket.destroy();
5140
+ });
5141
+ server.on("clientError", (error, socket) => {
5142
+ onTransportError({
5143
+ kind: "client-error",
5144
+ error,
5145
+ remoteAddress: socket.remoteAddress
5146
+ });
5147
+ socket.destroy();
5148
+ });
5149
+ }
4509
5150
  return server;
4510
5151
  }
4511
5152
  function requestPath(req) {
@@ -4606,7 +5247,7 @@ var TransportReconciler = class {
4606
5247
  this.instance = void 0;
4607
5248
  if (partial !== void 0) await this.spec.stop(partial).catch(() => {});
4608
5249
  this.currentStatus = this.spec.errorStatus(error, target);
4609
- this.log(this.spec.failureMessage?.(error) ?? "transport failed: " + String(error));
5250
+ this.log(this.spec.failureMessage?.(error, target) ?? "transport failed: " + String(error));
4610
5251
  }
4611
5252
  }
4612
5253
  /** 进程收尾:标记销毁、拆掉实例、等在途的那一次完成。 */
@@ -4647,7 +5288,7 @@ function createLocalTransport(deps) {
4647
5288
  const server = createPhoneServer(deps.handlers, {
4648
5289
  key: tls.key,
4649
5290
  cert: tls.cert
4650
- });
5291
+ }, deps.onTransportError !== void 0 ? { onTransportError: deps.onTransportError } : void 0);
4651
5292
  instance.server = server;
4652
5293
  instance.tlsFingerprint = tls.fingerprint;
4653
5294
  await listen(server, port, "0.0.0.0");
@@ -4685,7 +5326,7 @@ function createLocalTransport(deps) {
4685
5326
  updatedAt: Date.now()
4686
5327
  }),
4687
5328
  disabledMessage: "local transport disabled",
4688
- failureMessage: (error) => "local transport failed: " + localListenError(error, 0),
5329
+ failureMessage: (error, target) => "local transport failed: " + localListenError(error, normalizeLocalPort(target.port)),
4689
5330
  statusOf: (instance) => {
4690
5331
  if (instance === void 0 || instance.server === void 0) return void 0;
4691
5332
  return {
@@ -5225,6 +5866,8 @@ function unwrapStreamItem(item) {
5225
5866
  /** 每位一行:Host 上必须存在的依赖。缺失即该位为 false,相关帧回 E_UNSUPPORTED。 */
5226
5867
  const HOST_CAPABILITY_PROBES = {
5227
5868
  models: (proxy) => typeof proxy.sessions.models === "function",
5869
+ skills: (proxy) => typeof proxy.sessions.skills === "function",
5870
+ commands: (proxy) => typeof proxy.commands?.list === "function" && typeof proxy.commands?.execute === "function",
5228
5871
  sessionManagement: (proxy) => typeof proxy.sessions.rename === "function" && typeof proxy.workspace?.archiveSession === "function",
5229
5872
  sessionFork: (proxy) => typeof proxy.sessions.fork === "function",
5230
5873
  sessionRestore: (proxy) => typeof proxy.workspace?.unarchiveSession === "function",
@@ -5245,6 +5888,8 @@ function capabilityBits(proxy, pushOutlet) {
5245
5888
  promptDelivery: true,
5246
5889
  notifyAllCategories: true,
5247
5890
  models: HOST_CAPABILITY_PROBES.models(proxy),
5891
+ skills: HOST_CAPABILITY_PROBES.skills(proxy),
5892
+ commands: HOST_CAPABILITY_PROBES.commands(proxy),
5248
5893
  sessionManagement: HOST_CAPABILITY_PROBES.sessionManagement(proxy),
5249
5894
  sessionFork: HOST_CAPABILITY_PROBES.sessionFork(proxy),
5250
5895
  sessionRestore: HOST_CAPABILITY_PROBES.sessionRestore(proxy),
@@ -5333,7 +5978,7 @@ var HostBridge = class {
5333
5978
  return capabilityBits(this.apiProxy, this.pushOutlet);
5334
5979
  }
5335
5980
  diagnostic(message) {
5336
- console.log("[deeppilot] " + message);
5981
+ console.log(formatLogLine("info", message));
5337
5982
  }
5338
5983
  currentCursor() {
5339
5984
  return this.cursor;
@@ -5377,12 +6022,17 @@ var HostBridge = class {
5377
6022
  emitNotify(args) {
5378
6023
  if ((args.category === "turn.completed" || args.category === "session.error") && this.subagentSessionIds.has(args.sessionId)) return;
5379
6024
  const body = args.body.length > 120 ? args.body.slice(0, 119) + "…" : args.body;
6025
+ const approvalExtras = args.category === "approval.required" && args.toolName ? {
6026
+ toolName: args.toolName.slice(0, 80),
6027
+ ...args.detail ? { detail: args.detail.slice(0, 600) } : {}
6028
+ } : {};
5380
6029
  this.record("s2c.notify", {
5381
6030
  notificationId: args.notificationId,
5382
6031
  category: args.category,
5383
6032
  sessionId: args.sessionId,
5384
6033
  title: args.title,
5385
6034
  body,
6035
+ ...approvalExtras,
5386
6036
  ts: Date.now()
5387
6037
  }, (sink) => this.isViewedBy(sink, args.sessionId));
5388
6038
  this.fanOutPush({
@@ -5390,7 +6040,8 @@ var HostBridge = class {
5390
6040
  category: args.category,
5391
6041
  sessionId: args.sessionId,
5392
6042
  title: args.title,
5393
- body
6043
+ body,
6044
+ ...approvalExtras
5394
6045
  });
5395
6046
  }
5396
6047
  /** F-9: when a turn completes, notify every device not viewing the session. */
@@ -5550,12 +6201,19 @@ var HostBridge = class {
5550
6201
  }
5551
6202
  onMuxFrame(frame) {
5552
6203
  switch (frame.type) {
6204
+ case "skills/changed":
6205
+ this.record("s2c.skills.changed", {});
6206
+ break;
6207
+ case "commands/changed":
6208
+ this.record("s2c.commands.changed", {});
6209
+ break;
5553
6210
  case "session/event": {
5554
6211
  const event = frame.event;
5555
6212
  const sessionId = String(frame.sessionId ?? "");
5556
6213
  if (!event || !sessionId) break;
5557
6214
  if (frame.isSubagent === true) this.subagentSessionIds.add(sessionId);
5558
6215
  if (this.subagentSessionIds.has(sessionId)) break;
6216
+ if (event.type === "agent-preset/selected") this.record("s2c.skills.changed", { sessionId });
5559
6217
  this.noteActivity(sessionId, event);
5560
6218
  this.captureAssistantText(sessionId, event);
5561
6219
  const projection = projectEvent(sessionId, event);
@@ -5572,6 +6230,19 @@ var HostBridge = class {
5572
6230
  case "session/projection": {
5573
6231
  const p = frame;
5574
6232
  if (!p.sessionId) break;
6233
+ if (p.key === "workingDirectory" || p.key === "agentPreset") this.record("s2c.skills.changed", { sessionId: p.sessionId });
6234
+ if (p.key === "modelSelection") {
6235
+ const next = p.value?.next;
6236
+ const current = next && typeof next.provider === "string" && typeof next.model === "string" ? {
6237
+ provider: next.provider,
6238
+ model: next.model,
6239
+ ...typeof next.reasoningEffort === "string" ? { reasoningEffort: next.reasoningEffort } : {}
6240
+ } : void 0;
6241
+ this.record("s2c.models.changed", {
6242
+ sessionId: p.sessionId,
6243
+ ...current ? { current } : {}
6244
+ });
6245
+ }
5575
6246
  this.applyProjection(p.sessionId, String(p.key ?? ""), p.value);
5576
6247
  break;
5577
6248
  }
@@ -5600,7 +6271,9 @@ var HostBridge = class {
5600
6271
  category: "approval.required",
5601
6272
  title: "需要批准",
5602
6273
  body: toolName + ": " + summary,
5603
- notificationId: "apr-" + p.approvalId
6274
+ notificationId: "apr-" + p.approvalId,
6275
+ toolName,
6276
+ detail: summary
5604
6277
  });
5605
6278
  this.bumpPendingFlags(sessionId);
5606
6279
  break;
@@ -5948,6 +6621,75 @@ var HostBridge = class {
5948
6621
  return null;
5949
6622
  }
5950
6623
  }
6624
+ async commandOperation(operation, payload) {
6625
+ const api = this.apiProxy.commands;
6626
+ if (!api) return {
6627
+ ok: false,
6628
+ code: "E_UNSUPPORTED",
6629
+ message: "当前主机不支持手机指令"
6630
+ };
6631
+ try {
6632
+ const response = await api[operation].call(api, {
6633
+ rpcId: randomUUID(),
6634
+ payload
6635
+ });
6636
+ if (response.result?.ok) return {
6637
+ ok: true,
6638
+ value: response.result.value ?? {}
6639
+ };
6640
+ return wireErrorOf("command", response.result?.error ?? { code: "command/invalid-response" });
6641
+ } catch (error) {
6642
+ return wireErrorOf("command", hostFailureOf(error, "command/invalid-response"));
6643
+ }
6644
+ }
6645
+ async exportSession(sessionId, signal) {
6646
+ const api = this.apiProxy.commands;
6647
+ if (!api) throw Object.assign(/* @__PURE__ */ new Error("当前主机不支持日志导出"), { code: "command/export-unsupported" });
6648
+ return api.export({
6649
+ rpcId: randomUUID(),
6650
+ payload: { sessionId }
6651
+ }, signal);
6652
+ }
6653
+ /** 由会话目录决定技能可见性;不读取正文,也不把本机路径发给移动端。 */
6654
+ async sessionSkills(sessionId) {
6655
+ const read = this.apiProxy.sessions.skills;
6656
+ if (!read) return {
6657
+ ok: false,
6658
+ code: "E_UNSUPPORTED",
6659
+ message: "当前主机不支持技能目录"
6660
+ };
6661
+ try {
6662
+ const response = await read.call(this.apiProxy.sessions, {
6663
+ rpcId: randomUUID(),
6664
+ payload: { sessionId }
6665
+ });
6666
+ if (!response.result) return {
6667
+ ok: false,
6668
+ code: "E_INTERNAL",
6669
+ message: "技能目录响应无效"
6670
+ };
6671
+ if (!response.result.ok) return wireErrorOf("session", response.result.error);
6672
+ const seen = /* @__PURE__ */ new Set();
6673
+ return {
6674
+ ok: true,
6675
+ value: { skills: response.result.value.skills.map((skill) => {
6676
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(skill.name) || seen.has(skill.name) || typeof skill.description !== "string" || typeof skill.modelInvocable !== "boolean") throw new Error("技能目录条目无效");
6677
+ seen.add(skill.name);
6678
+ return {
6679
+ name: skill.name,
6680
+ description: skill.description,
6681
+ modelInvocable: skill.modelInvocable
6682
+ };
6683
+ }) }
6684
+ };
6685
+ } catch {
6686
+ return {
6687
+ ok: false,
6688
+ code: "E_INTERNAL",
6689
+ message: "无法读取当前会话的技能目录"
6690
+ };
6691
+ }
6692
+ }
5951
6693
  async sessionModels(sessionId) {
5952
6694
  const models = this.apiProxy.sessions.models;
5953
6695
  if (typeof models !== "function") return {
@@ -7083,6 +7825,145 @@ async function startDshRemoteInteractions(hostCtx, handlers) {
7083
7825
  }
7084
7826
  }
7085
7827
  //#endregion
7828
+ //#region src/command-api.ts
7829
+ /**
7830
+ * 给本域自造的失败带上 Host 风格的码。
7831
+ *
7832
+ * 这些失败不是上游返回的,而是插件在这里判定出来的,所以必须自己声明语义:
7833
+ * 没有码的抛出物在 wire-errors 的域表里查不到,一律落进 `E_INTERNAL`,手机端
7834
+ * 便无法区分「指令不存在」「定义已变化」「这个指令不收附件」——它们对用户的
7835
+ * 下一步动作要求完全不同。码的取值见 `src/wire-errors.ts` 的 `command` 域表。
7836
+ */
7837
+ function fail(code, message) {
7838
+ return Object.assign(new Error(message), { code });
7839
+ }
7840
+ /** 只经官方 Gateway 调用命令,保留其 Agent 解析、作用域覆盖和生命周期记录。 */
7841
+ var DshCommandApi = class {
7842
+ ctx;
7843
+ peer;
7844
+ constructor(ctx) {
7845
+ this.ctx = ctx;
7846
+ }
7847
+ get available() {
7848
+ return this.ctx.get("commands") !== void 0 && typeof this.ctx.get("typertGateway")?.invoke === "function";
7849
+ }
7850
+ connection() {
7851
+ return this.ctx.get("connection");
7852
+ }
7853
+ owner() {
7854
+ const connection = this.connection();
7855
+ return this.peer ??= connection?.createPeer?.({
7856
+ principal: { kind: "owner" },
7857
+ interactions: "none"
7858
+ }) ?? connection?.operator;
7859
+ }
7860
+ invoke(namespace, method, args) {
7861
+ return this.ctx.get("typertGateway").invoke({
7862
+ namespace,
7863
+ method,
7864
+ args,
7865
+ peer: this.owner(),
7866
+ signal: AbortSignal.timeout(6e5)
7867
+ });
7868
+ }
7869
+ async list(sessionId) {
7870
+ const rows = await this.invoke("commands", "list", { agentId: sessionId });
7871
+ const names = /* @__PURE__ */ new Set();
7872
+ return {
7873
+ commands: rows.map((row) => {
7874
+ if (!/^[a-z][a-z0-9_-]{0,127}$/.test(row.name) || names.has(row.name) || typeof row.description !== "string") throw fail("command/invalid-response", "命令目录无效");
7875
+ names.add(row.name);
7876
+ return {
7877
+ name: row.name,
7878
+ description: row.description,
7879
+ ...typeof row.definitionId === "string" ? { definitionId: row.definitionId } : {},
7880
+ ...row.input && typeof row.input.hint === "string" ? { input: {
7881
+ hint: row.input.hint,
7882
+ attachments: row.input.attachments === true
7883
+ } } : {}
7884
+ };
7885
+ }),
7886
+ permissions: this.ctx.get("permissionPresets") !== void 0,
7887
+ export: typeof this.connection()?.createSharedFetchHandler === "function"
7888
+ };
7889
+ }
7890
+ async execute(sessionId, line, attachments, expectedDefinitionId) {
7891
+ const catalog = await this.list(sessionId);
7892
+ const name = /^\/([a-z][a-z0-9_-]*)(?=\s|$)/.exec(line)?.[1];
7893
+ const command = catalog.commands.find((row) => row.name === name);
7894
+ if (!command) throw fail("command/not-found", "当前会话没有这个指令");
7895
+ if (expectedDefinitionId !== void 0 && command.definitionId !== expectedDefinitionId) throw fail("command/definition-changed", "指令定义已变化,请重新选择");
7896
+ if (attachments.length && command.input?.attachments !== true) throw fail("command/attachments-unsupported", "这个指令不接受附件");
7897
+ const submittedAttachments = [];
7898
+ for (const item of attachments) if (item.type === "image") submittedAttachments.push({
7899
+ type: "image",
7900
+ mediaType: item.mediaType,
7901
+ data: item.data
7902
+ });
7903
+ else {
7904
+ const uploaded = await this.invoke("fileUploads", "upload", {
7905
+ agentId: sessionId,
7906
+ request: {
7907
+ name: item.name + ".txt",
7908
+ data: item.data
7909
+ }
7910
+ });
7911
+ submittedAttachments.push({
7912
+ type: "file",
7913
+ receiptId: uploaded.receiptId
7914
+ });
7915
+ }
7916
+ const result = await this.invoke("commands", "execute", {
7917
+ agentId: sessionId,
7918
+ line,
7919
+ submittedAttachments
7920
+ });
7921
+ if (!result || typeof result.commandId !== "string" || !["success", "error"].includes(result.result?.kind)) throw fail("command/invalid-response", "指令执行响应无效");
7922
+ return {
7923
+ commandId: result.commandId,
7924
+ result: {
7925
+ kind: result.result.kind,
7926
+ ...typeof result.result.text === "string" ? { text: result.result.text } : {},
7927
+ ...Number.isSafeInteger(result.result.sourceEventSeq) && result.result.sourceEventSeq >= 0 ? { sourceEventSeq: result.result.sourceEventSeq } : {}
7928
+ }
7929
+ };
7930
+ }
7931
+ async permissions(sessionId) {
7932
+ const service = this.ctx.get("permissionPresets");
7933
+ if (!service) throw fail("command/permissions-unsupported", "当前主机不支持权限预设");
7934
+ const catalog = await this.invoke("permissionPresets", "catalog", {});
7935
+ const currentValue = (await this.ctx.get("sessionController").projections({ sessionId }, new AbortController().signal))?.values?.permissions?.currentValue;
7936
+ const seen = /* @__PURE__ */ new Set();
7937
+ const options = catalog.options.map((option) => {
7938
+ if (typeof option.value !== "string" || !option.value.trim() || option.value.length > 512 || /[\r\n]/.test(option.value) || seen.has(option.value) || typeof option.name !== "string") throw fail("command/invalid-response", "权限目录无效");
7939
+ seen.add(option.value);
7940
+ return {
7941
+ value: option.value,
7942
+ name: option.name,
7943
+ ...typeof option.description === "string" ? { description: option.description } : {},
7944
+ confirmation: option.value === "auto" || service.resolve(option.value).sandbox === "danger-full-access"
7945
+ };
7946
+ });
7947
+ return {
7948
+ ...typeof currentValue === "string" ? { currentValue } : {},
7949
+ options
7950
+ };
7951
+ }
7952
+ async feedback(sessionId, text, category) {
7953
+ const result = await this.invoke("sessionFeedback", "record", { request: {
7954
+ sessionId,
7955
+ ...text.trim() ? { text: text.trim() } : {},
7956
+ ...category ? { category } : {}
7957
+ } });
7958
+ if (result?.ok !== true) throw fail(typeof result?.error?.code === "string" ? result.error.code : "command/invalid-response", typeof result?.error?.message === "string" ? result.error.message : "反馈未能保存");
7959
+ }
7960
+ async export(sessionId, signal) {
7961
+ const response = await this.connection().createSharedFetchHandler("/api").fetch(new Request("http://localhost/api/session.export?includeDescendants=true&sessionId=" + encodeURIComponent(sessionId), { signal }), this.owner());
7962
+ if (!response.ok || !response.body || !response.headers.get("content-type")?.includes("application/zip")) throw fail("command/export-unavailable", "主机未能生成会话日志归档");
7963
+ return response;
7964
+ }
7965
+ };
7966
+ //#endregion
7086
7967
  //#region src/dsh-api-proxy.ts
7087
7968
  /**
7088
7969
  * Adapter for the DSH rc.2 controller API.
@@ -7095,6 +7976,17 @@ async function startDshRemoteInteractions(hostCtx, handlers) {
7095
7976
  /** Direct-controller adapter with the shape HostBridge consumes. */
7096
7977
  var DshApiProxy = class {
7097
7978
  ctx;
7979
+ commandApi;
7980
+ get commands() {
7981
+ if (!this.commandApi.available) return void 0;
7982
+ return {
7983
+ list: (request) => this.call(() => this.commandApi.list(request.payload.sessionId)),
7984
+ execute: (request) => this.call(() => this.commandApi.execute(request.payload.sessionId, request.payload.line, request.payload.attachments, request.payload.expectedDefinitionId)),
7985
+ permissions: (request) => this.call(() => this.commandApi.permissions(request.payload.sessionId)),
7986
+ feedback: (request) => this.call(() => this.commandApi.feedback(request.payload.sessionId, request.payload.text, request.payload.category)),
7987
+ export: (request, signal) => this.commandApi.export(request.payload.sessionId, signal)
7988
+ };
7989
+ }
7098
7990
  session;
7099
7991
  workspaceController;
7100
7992
  scheduleController;
@@ -7104,6 +7996,7 @@ var DshApiProxy = class {
7104
7996
  shouldSurfaceInteraction;
7105
7997
  constructor(ctx, options = {}) {
7106
7998
  this.ctx = ctx;
7999
+ this.commandApi = new DshCommandApi(ctx);
7107
8000
  const session = ctx.get("sessionController");
7108
8001
  if (session === void 0) throw new Error("DSH sessionController is unavailable");
7109
8002
  this.session = session;
@@ -7112,6 +8005,12 @@ var DshApiProxy = class {
7112
8005
  this.directoryPicker = ctx.get("directoryPickerController");
7113
8006
  this.shouldSurfaceInteraction = options.shouldSurfaceInteraction ?? (() => true);
7114
8007
  this.resolveSchedule();
8008
+ Object.defineProperty(this.sessions, "skills", { get: () => {
8009
+ const catalog = ctx.get("sessionSkillCatalog");
8010
+ if (typeof catalog?.list !== "function") return void 0;
8011
+ const read = async (request) => this.call(() => catalog.list({ sessionId: request.payload.sessionId }, new AbortController().signal));
8012
+ return read;
8013
+ } });
7115
8014
  }
7116
8015
  /**
7117
8016
  * Resolve the optional Schedule service lazily, on every read.
@@ -7307,6 +8206,10 @@ var DshApiProxy = class {
7307
8206
  value
7308
8207
  });
7309
8208
  });
8209
+ const offSkills = this.ctx.on("skills/change", (() => {
8210
+ queue.push({ type: "skills/changed" });
8211
+ }), { global: true });
8212
+ const offCommands = ["commands/change", "permission-presets/catalog-changed"].map((event) => this.ctx.on(event, (() => queue.push({ type: "commands/changed" })), { global: true }));
7310
8213
  let disposeInteractions;
7311
8214
  try {
7312
8215
  disposeInteractions = await startDshRemoteInteractions(this.ctx, {
@@ -7321,6 +8224,8 @@ var DshApiProxy = class {
7321
8224
  } finally {
7322
8225
  offEvent();
7323
8226
  offProjection?.();
8227
+ offSkills();
8228
+ offCommands.forEach((dispose) => dispose());
7324
8229
  await disposeInteractions?.();
7325
8230
  queue.close();
7326
8231
  }
@@ -7823,6 +8728,40 @@ function parsePushTestResult(value) {
7823
8728
  results
7824
8729
  };
7825
8730
  }
8731
+ function parseDiagnosticFailure(value) {
8732
+ const s = rec(value, "diagnostics.recentFailures[]");
8733
+ const kind = str(s, "kind", "diagnostics.kind");
8734
+ if (!/^[a-z][a-z0-9-]{0,47}$/.test(kind)) reject("diagnostics.kind");
8735
+ const reason = str(s, "reason", "diagnostics.reason");
8736
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(reason)) reject("diagnostics.reason");
8737
+ const attemptId = s.attemptId;
8738
+ if (attemptId !== void 0 && (typeof attemptId !== "string" || !/^[A-Za-z0-9_-]{1,16}$/.test(attemptId))) reject("diagnostics.attemptId");
8739
+ const source = s.source;
8740
+ if (source !== void 0 && (typeof source !== "string" || !/^[0-9a-f]{8,64}$/.test(source))) reject("diagnostics.source");
8741
+ const closeCode = s.closeCode;
8742
+ if (closeCode !== void 0 && (typeof closeCode !== "number" || !Number.isInteger(closeCode))) reject("diagnostics.closeCode");
8743
+ const durationMs = s.durationMs;
8744
+ if (durationMs !== void 0 && (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs < 0)) reject("diagnostics.durationMs");
8745
+ return {
8746
+ ts: int(s, "ts", "diagnostics.ts"),
8747
+ kind,
8748
+ reason,
8749
+ ...typeof attemptId === "string" ? { attemptId } : {},
8750
+ ...typeof source === "string" ? { source } : {},
8751
+ ...typeof closeCode === "number" ? { closeCode } : {},
8752
+ ...typeof durationMs === "number" ? { durationMs: Math.round(durationMs) } : {}
8753
+ };
8754
+ }
8755
+ function parseDiagnostics(value) {
8756
+ const s = rec(value, "diagnostics");
8757
+ const failures = s.recentFailures;
8758
+ if (!Array.isArray(failures)) reject("diagnostics.recentFailures");
8759
+ if (failures.length > 20) reject("diagnostics.recentFailures");
8760
+ return {
8761
+ startedAt: int(s, "startedAt", "diagnostics.startedAt"),
8762
+ recentFailures: failures.map(parseDiagnosticFailure)
8763
+ };
8764
+ }
7826
8765
  function parseReport(value) {
7827
8766
  const s = rec(value, "report");
7828
8767
  const devices = s.devices;
@@ -7844,7 +8783,8 @@ function parseReport(value) {
7844
8783
  lanAddresses,
7845
8784
  local: parseLocal(s.local),
7846
8785
  remote: parseRemote(s.remote),
7847
- devices: devices.map(parseDevice)
8786
+ devices: devices.map(parseDevice),
8787
+ ...s.diagnostics !== void 0 ? { diagnostics: parseDiagnostics(s.diagnostics) } : {}
7848
8788
  };
7849
8789
  }
7850
8790
  const reportSchema = { parse: parseReport };
@@ -8313,13 +9253,17 @@ async function loadOrCreateLanTlsIdentity(dir, now = Date.now()) {
8313
9253
  });
8314
9254
  const keyPath = join(dir, LAN_TLS_KEY_FILE);
8315
9255
  const certPath = join(dir, LAN_TLS_CERT_FILE);
8316
- let key = await readPem(keyPath);
9256
+ let key = await readPem(keyPath, (error) => {
9257
+ console.log(formatLogLine("warn", "LAN TLS private key unreadable, regenerating identity: " + describeFileError(error)));
9258
+ });
8317
9259
  let regenerated = false;
8318
9260
  if (key === void 0 || !validPrivateKey(key)) {
8319
9261
  regenerated = true;
8320
9262
  key = void 0;
8321
9263
  }
8322
- let cert = regenerated ? void 0 : await readPem(certPath);
9264
+ let cert = regenerated ? void 0 : await readPem(certPath, (error) => {
9265
+ console.log(formatLogLine("warn", "LAN TLS certificate unreadable, re-signing with the existing key: " + describeFileError(error)));
9266
+ });
8323
9267
  let resigned = false;
8324
9268
  const existing = key !== void 0 && cert !== void 0 ? inspectCertificate(cert, key, now) : void 0;
8325
9269
  if (existing === void 0) {
@@ -8341,15 +9285,22 @@ async function loadOrCreateLanTlsIdentity(dir, now = Date.now()) {
8341
9285
  resigned
8342
9286
  };
8343
9287
  }
8344
- async function readPem(path) {
9288
+ async function readPem(path, onError) {
8345
9289
  try {
8346
9290
  const text = await readFile(path, "utf8");
8347
9291
  return text.trim().length > 0 ? text : void 0;
8348
9292
  } catch (error) {
8349
9293
  if (error.code === "ENOENT") return void 0;
9294
+ onError?.(error);
8350
9295
  return;
8351
9296
  }
8352
9297
  }
9298
+ /** 读失败的安全描述:文件名 + errno,不带文件内容。 */
9299
+ function describeFileError(error) {
9300
+ const code = error?.code;
9301
+ const message = error instanceof Error ? error.message : String(error);
9302
+ return (typeof code === "string" ? "code=" + code + " " : "") + message.slice(0, 140);
9303
+ }
8353
9304
  function validPrivateKey(pem) {
8354
9305
  try {
8355
9306
  return createPrivateKey(pem).asymmetricKeyType === "ec";
@@ -8492,9 +9443,7 @@ function readOwnPackageVersion() {
8492
9443
  }
8493
9444
  function apply(ctx, options) {
8494
9445
  const cfg = normalizeOptions(options);
8495
- const log = (message) => {
8496
- console.log("[deeppilot] " + message);
8497
- };
9446
+ const log = createLogger();
8498
9447
  const auditSalt = randomBytes(32);
8499
9448
  const auditLabel = (value) => createHash("sha256").update(auditSalt).update(value).digest("hex").slice(0, 12);
8500
9449
  let scheduleRemoteReconcile;
@@ -8507,6 +9456,30 @@ function apply(ctx, options) {
8507
9456
  }));
8508
9457
  if (currentConfig().enabled !== true) log("disabled via settings; bridge stays inactive (rumors of /phone below are skipped)");
8509
9458
  const dataDir = bridgeDataDir();
9459
+ /** 插件启动时刻:`/phone/health` 与设置页报告都读它。 */
9460
+ const startedAt = Date.now();
9461
+ /**
9462
+ * 连接诊断事件:内存环形 256 条 + 按天落盘(插件数据目录 diagnostics/,
9463
+ * 保留 7 天、目录上限 5MB)。与 `diagnostics.debug` 完全解耦——失败事件
9464
+ * 无条件记录,否则用户不开调试开关就永远看不到「为什么连不上」。
9465
+ */
9466
+ const diagnostics = new DiagnosticsStore({
9467
+ dir: join(dataDir, "diagnostics"),
9468
+ write: (line) => {
9469
+ console.log(line);
9470
+ }
9471
+ });
9472
+ /**
9473
+ * 传输层错误(TLS 握手失败 / HTTP 报文解析失败)的统一入口。来源先经
9474
+ * auditLabel 哈希再记录;原始 IP 绝不进入事件或日志。
9475
+ */
9476
+ const recordTransportError = (event) => {
9477
+ const code = event.error?.code;
9478
+ diagnostics.record("connection-error", event.kind, {
9479
+ ...event.remoteAddress !== void 0 ? { source: auditLabel(event.remoteAddress) } : {},
9480
+ detail: typeof code === "string" ? code : event.error instanceof Error ? event.error.message : String(event.error)
9481
+ });
9482
+ };
8510
9483
  const pushGateway = new PushGateway({
8511
9484
  config: currentConfig,
8512
9485
  dataDir,
@@ -8527,7 +9500,7 @@ function apply(ctx, options) {
8527
9500
  const migratedFrom = await migrateLegacyBridgeDataDir();
8528
9501
  if (migratedFrom !== null) log(`migrated legacy plugin state from ${migratedFrom} to ${dataDir}`);
8529
9502
  } catch (error) {
8530
- log("legacy plugin-state migration skipped: " + String(error));
9503
+ log("legacy plugin-state migration skipped: " + String(error), "warn");
8531
9504
  }
8532
9505
  await ensurePrivateBridgeDataDir();
8533
9506
  auth.audience = await loadOrCreateHostAudience(join(dataDir, "host-id"));
@@ -8539,7 +9512,7 @@ function apply(ctx, options) {
8539
9512
  }
8540
9513
  await pushGateway.restore();
8541
9514
  } catch (error) {
8542
- log("auth material unavailable, bridge degraded: " + String(error));
9515
+ log("auth material unavailable, bridge degraded: " + String(error), "warn");
8543
9516
  return {
8544
9517
  audience: null,
8545
9518
  devices: null
@@ -8666,7 +9639,19 @@ function apply(ctx, options) {
8666
9639
  lanAddresses,
8667
9640
  local: localStatus(lanAddresses),
8668
9641
  remote: remoteStatus(),
8669
- devices
9642
+ devices,
9643
+ diagnostics: {
9644
+ startedAt,
9645
+ recentFailures: diagnostics.recentFailures(20).map((event) => ({
9646
+ ts: event.ts,
9647
+ kind: event.kind,
9648
+ reason: event.reason,
9649
+ ...event.attemptId !== void 0 ? { attemptId: event.attemptId } : {},
9650
+ ...event.source !== void 0 ? { source: event.source } : {},
9651
+ ...event.closeCode !== void 0 ? { closeCode: event.closeCode } : {},
9652
+ ...event.durationMs !== void 0 ? { durationMs: event.durationMs } : {}
9653
+ }))
9654
+ }
8670
9655
  };
8671
9656
  }, beginPairing, async (deviceId) => {
8672
9657
  return await revokeDevice(deviceId);
@@ -8706,7 +9691,16 @@ function apply(ctx, options) {
8706
9691
  };
8707
9692
  const handlePair = async (req, res) => {
8708
9693
  res.setHeader("Content-Type", "application/json");
9694
+ const source = requestClientIdentity(req);
9695
+ const attemptId = attemptIdFromRequest(req);
9696
+ const reportPairRejected = (reason) => {
9697
+ diagnostics.record("pair-rejected", reason, {
9698
+ source: auditLabel(source),
9699
+ ...attemptId !== void 0 ? { attemptId } : {}
9700
+ });
9701
+ };
8709
9702
  if (!enabledNow()) {
9703
+ reportPairRejected("bridge-disabled");
8710
9704
  res.statusCode = 503;
8711
9705
  res.end(JSON.stringify({
8712
9706
  ok: false,
@@ -8715,6 +9709,7 @@ function apply(ctx, options) {
8715
9709
  return;
8716
9710
  }
8717
9711
  if (req.method !== "POST") {
9712
+ reportPairRejected("method-not-allowed");
8718
9713
  res.statusCode = 405;
8719
9714
  res.setHeader("Allow", "POST");
8720
9715
  res.end(JSON.stringify({
@@ -8723,9 +9718,9 @@ function apply(ctx, options) {
8723
9718
  }));
8724
9719
  return;
8725
9720
  }
8726
- const source = requestClientIdentity(req);
8727
9721
  const admission = authRateLimiter.admit(source);
8728
9722
  if (!admission.ok) {
9723
+ reportPairRejected("rate-limited");
8729
9724
  res.statusCode = 429;
8730
9725
  res.setHeader("Retry-After", String(Math.max(1, Math.ceil(admission.retryAfterMs / 1e3))));
8731
9726
  res.end(JSON.stringify({
@@ -8745,6 +9740,7 @@ function apply(ctx, options) {
8745
9740
  const appVersion = sanitizeDeviceField(raw.appVersion, 32) || "unknown";
8746
9741
  const deviceId = deviceIdForPublicKey(publicKey);
8747
9742
  if (devices.list().length >= 64 && devices.authorized(deviceId) === void 0) {
9743
+ reportPairRejected("registry-full");
8748
9744
  res.statusCode = 409;
8749
9745
  res.end(JSON.stringify({
8750
9746
  ok: false,
@@ -8754,6 +9750,7 @@ function apply(ctx, options) {
8754
9750
  }
8755
9751
  if (!pairingCodes.consume(code)) {
8756
9752
  const failure = authRateLimiter.recordFailure(source);
9753
+ reportPairRejected("invalid-code");
8757
9754
  res.statusCode = failure.blocked ? 429 : 401;
8758
9755
  if (failure.retryAfterMs > 0) res.setHeader("Retry-After", String(Math.max(1, Math.ceil(failure.retryAfterMs / 1e3))));
8759
9756
  res.end(JSON.stringify({
@@ -8781,7 +9778,9 @@ function apply(ctx, options) {
8781
9778
  ...tlsFingerprint ? { tlsFingerprint } : {}
8782
9779
  }));
8783
9780
  } catch (error) {
8784
- res.statusCode = error instanceof SyntaxError || error instanceof TypeError ? 400 : 503;
9781
+ const badRequest = error instanceof SyntaxError || error instanceof TypeError;
9782
+ reportPairRejected(badRequest ? "bad-request" : "internal-error");
9783
+ res.statusCode = badRequest ? 400 : 503;
8785
9784
  res.end(JSON.stringify({
8786
9785
  ok: false,
8787
9786
  error: error instanceof Error ? error.message : "pairing failed"
@@ -8791,14 +9790,24 @@ function apply(ctx, options) {
8791
9790
  }
8792
9791
  };
8793
9792
  const handleUpgrade = (req, socket, head) => {
9793
+ const source = requestClientIdentity(req);
9794
+ const sourceLabel = auditLabel(source);
9795
+ const attemptId = attemptIdFromRequest(req);
9796
+ const reject = (status, reason) => {
9797
+ diagnostics.record("upgrade-rejected", reason, {
9798
+ source: sourceLabel,
9799
+ ...attemptId !== void 0 ? { attemptId } : {}
9800
+ });
9801
+ rejectUpgrade(socket, status, reason);
9802
+ };
8794
9803
  (async () => {
8795
9804
  try {
8796
9805
  if (!enabledNow()) {
8797
- rejectUpgrade(socket, 503, "bridge disabled");
9806
+ reject(503, "bridge disabled");
8798
9807
  return;
8799
9808
  }
8800
9809
  if (connections.size + pendingUpgrades >= MAX_CLIENT_CONNECTIONS) {
8801
- rejectUpgrade(socket, 429, "too many connections");
9810
+ reject(429, "too many connections");
8802
9811
  return;
8803
9812
  }
8804
9813
  pendingUpgrades += 1;
@@ -8806,30 +9815,36 @@ function apply(ctx, options) {
8806
9815
  try {
8807
9816
  const { devices, audience } = await ready;
8808
9817
  if (!audience || !devices) {
8809
- rejectUpgrade(socket, 503, "bridge degraded");
9818
+ reject(503, "bridge degraded");
8810
9819
  return;
8811
9820
  }
8812
9821
  const bridge = state.bridge;
8813
9822
  if (!bridge) {
8814
- rejectUpgrade(socket, 503, "bridge not ready");
9823
+ reject(503, "bridge not ready");
8815
9824
  return;
8816
9825
  }
8817
9826
  gate = new ConnectionGate({
8818
- source: requestClientIdentity(req),
9827
+ source,
8819
9828
  devices,
8820
9829
  audience,
8821
9830
  limiter: authRateLimiter,
8822
9831
  log,
8823
- auditLabel
9832
+ auditLabel,
9833
+ diagnostics,
9834
+ ...attemptId !== void 0 ? { attemptId } : {}
8824
9835
  });
8825
9836
  if (!gate.admitted) {
8826
- rejectUpgrade(socket, 429, "authentication rate limited");
9837
+ reject(429, "authentication rate limited");
8827
9838
  return;
8828
9839
  }
8829
9840
  const live = gate;
8830
9841
  wss.handleUpgrade(req, socket, head, (ws) => {
8831
9842
  if (auth.audience !== audience || state.bridge !== bridge) {
8832
9843
  live.markDead();
9844
+ diagnostics.record("upgrade-rejected", "bridge-changed", {
9845
+ source: sourceLabel,
9846
+ ...attemptId !== void 0 ? { attemptId } : {}
9847
+ });
8833
9848
  ws.close(1012, "bridge changed");
8834
9849
  return;
8835
9850
  }
@@ -8844,6 +9859,8 @@ function apply(ctx, options) {
8844
9859
  source: live.source,
8845
9860
  rateLimiter: authRateLimiter,
8846
9861
  auditLabel,
9862
+ diagnostics,
9863
+ ...attemptId !== void 0 ? { attemptId } : {},
8847
9864
  onClosed: (closed) => connections.delete(closed),
8848
9865
  onDeviceAuthenticated: (deviceId) => {
8849
9866
  log(`device authenticated id=${auditLabel(deviceId)} source=${auditLabel(live.source)}`);
@@ -8863,7 +9880,12 @@ function apply(ctx, options) {
8863
9880
  pendingUpgrades -= 1;
8864
9881
  }
8865
9882
  } catch (error) {
8866
- log("upgrade failed: " + String(error));
9883
+ log("upgrade failed: " + String(error), "warn");
9884
+ diagnostics.record("upgrade-rejected", "internal-error", {
9885
+ source: sourceLabel,
9886
+ ...attemptId !== void 0 ? { attemptId } : {},
9887
+ detail: error instanceof Error ? error.message : String(error)
9888
+ });
8867
9889
  rejectUpgrade(socket, 500, "internal error");
8868
9890
  }
8869
9891
  })();
@@ -8880,14 +9902,19 @@ function apply(ctx, options) {
8880
9902
  }));
8881
9903
  return;
8882
9904
  }
9905
+ const local = localTransport.status();
8883
9906
  res.statusCode = 200;
8884
- res.end(JSON.stringify({
8885
- ok: true,
9907
+ res.end(JSON.stringify(buildHealthPayload({
9908
+ startedAt,
8886
9909
  enabled: enabledNow(),
8887
9910
  protocolVersion: 2,
8888
9911
  serverVersion: SERVER_VERSION,
8889
- dataPlane: Boolean(state.bridge)
8890
- }));
9912
+ dataPlaneActive: Boolean(state.bridge),
9913
+ localPhase: local.phase,
9914
+ localPort: local.port,
9915
+ tlsIdentityRegenerated,
9916
+ lastFailure: diagnostics.lastFailureSummary()
9917
+ })));
8891
9918
  } catch {
8892
9919
  res.statusCode = 500;
8893
9920
  res.end(JSON.stringify({ ok: false }));
@@ -8902,7 +9929,8 @@ function apply(ctx, options) {
8902
9929
  handlers: phoneHandlers,
8903
9930
  config: currentConfig,
8904
9931
  tls: loadLanTls,
8905
- log
9932
+ log,
9933
+ onTransportError: recordTransportError
8906
9934
  });
8907
9935
  const remoteTransport = createRemoteTransport({
8908
9936
  handlers: phoneHandlers,
@@ -8920,7 +9948,7 @@ function apply(ctx, options) {
8920
9948
  };
8921
9949
  };
8922
9950
  const remoteStatus = () => remoteTransport.status();
8923
- const originServer = createPhoneServer(phoneHandlers);
9951
+ const originServer = createPhoneServer(phoneHandlers, void 0, { onTransportError: recordTransportError });
8924
9952
  let originURL;
8925
9953
  scheduleLocalReconcile = () => localTransport.scheduleReconcile();
8926
9954
  scheduleRemoteReconcile = () => remoteTransport.scheduleReconcile();
@@ -8932,7 +9960,7 @@ function apply(ctx, options) {
8932
9960
  originURL = `http://127.0.0.1:${address.port}`;
8933
9961
  scheduleRemoteReconcile?.();
8934
9962
  }
8935
- }, (error) => log("remote origin failed: " + String(error)));
9963
+ }, (error) => log("remote origin failed: " + String(error), "warn"));
8936
9964
  return async () => {
8937
9965
  scheduleLocalReconcile = void 0;
8938
9966
  scheduleRemoteReconcile = void 0;
@@ -8976,7 +10004,7 @@ function apply(ctx, options) {
8976
10004
  try {
8977
10005
  proxy = new DshApiProxy(apiCtx, { shouldSurfaceInteraction: hasPairedPhoneSurface });
8978
10006
  } catch (error) {
8979
- log("DSH session bridge unavailable: " + String(error));
10007
+ log("DSH session bridge unavailable: " + String(error), "warn");
8980
10008
  return;
8981
10009
  }
8982
10010
  const bridge = new HostBridge(proxy, cfg.historyBufferMax, join(dataDir, "prompt-deliveries-v1.json"), join(dataDir, "schedule-mutations-v1.json"), join(dataDir, "fork-mutations-v1.json"), log);
@@ -8999,6 +10027,7 @@ function apply(ctx, options) {
8999
10027
  await pushGateway.dispose();
9000
10028
  const wssClosed = new Promise((resolve) => wss.close(() => resolve()));
9001
10029
  await Promise.allSettled([wssClosed]);
10030
+ await diagnostics.dispose();
9002
10031
  }, "deeppilot: process resources");
9003
10032
  }
9004
10033
  //#endregion