dsh-deeppilot 0.2.0 → 0.2.1

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/README.md CHANGED
@@ -63,9 +63,9 @@ do not pass through the relay. Read [PRIVACY.md](./PRIVACY.md) and
63
63
 
64
64
  ## Screenshots
65
65
 
66
- Screenshots will be added after the current TestFlight review.
67
-
68
- <!-- Add future app screenshots under assets/screenshots/ and replace this note. -->
66
+ | Home | Sidebar | Chat | Settings |
67
+ | --- | --- | --- | --- |
68
+ | <img src="./assets/screenshots/home.png" alt="DeepPilot home screen" width="220"> | <img src="./assets/screenshots/sidebar.png" alt="DeepPilot sidebar" width="220"> | <img src="./assets/screenshots/chat.png" alt="DeepPilot chat screen" width="220"> | <img src="./assets/screenshots/settings.png" alt="DeepPilot settings screen" width="220"> |
69
69
 
70
70
  ## Compatibility
71
71
 
@@ -73,6 +73,12 @@ See [COMPATIBILITY.md](./COMPATIBILITY.md) for the tested baseline and current
73
73
  limitations. DSH is still evolving; include exact DSH and plugin versions when
74
74
  reporting an issue.
75
75
 
76
+ ## Protocol
77
+
78
+ [PROTOCOL.md](./PROTOCOL.md) is the normative DeepPilot bridge protocol. Any
79
+ wire change must update that document and `src/protocol.ts` together, preserve
80
+ protocol-v1 compatibility, and be coordinated with the private iOS client.
81
+
76
82
  ## Development
77
83
 
78
84
  ```sh
package/README.zh-CN.md CHANGED
@@ -59,15 +59,21 @@ dsh plugin --profile web remove dsh-deeppilot
59
59
 
60
60
  ## App 截图
61
61
 
62
- 当前 TestFlight 审核完成后再补充截图。
63
-
64
- <!-- 后续将 App 截图放到 assets/screenshots/,并替换本段说明。 -->
62
+ | 首页 | 侧边栏 | 聊天 | 设置 |
63
+ | --- | --- | --- | --- |
64
+ | <img src="./assets/screenshots/home.png" alt="DeepPilot 首页" width="220"> | <img src="./assets/screenshots/sidebar.png" alt="DeepPilot 侧边栏" width="220"> | <img src="./assets/screenshots/chat.png" alt="DeepPilot 聊天界面" width="220"> | <img src="./assets/screenshots/settings.png" alt="DeepPilot 设置界面" width="220"> |
65
65
 
66
66
  ## 兼容性
67
67
 
68
68
  已验证的基线与当前限制见 [COMPATIBILITY.md](./COMPATIBILITY.md)。DSH 仍在快速
69
69
  迭代;提交问题时请附上准确的 DSH 与插件版本。
70
70
 
71
+ ## 协议
72
+
73
+ [PROTOCOL.md](./PROTOCOL.md) 是 DeepPilot 桥接协议的规范性文档。任何 wire
74
+ 变更都必须同步更新该文档与 `src/protocol.ts`,保持协议 v1 向后兼容,并与
75
+ 私有 iOS 客户端协调。
76
+
71
77
  ## 开发
72
78
 
73
79
  ```sh
package/lib/index.d.ts CHANGED
@@ -39,6 +39,32 @@ interface PushNotification {
39
39
  title: string;
40
40
  body: string;
41
41
  }
42
+ interface PendingApprovalPayload {
43
+ requestId: string;
44
+ sessionId: string;
45
+ toolName: string;
46
+ summary: string;
47
+ riskLevel: 'read' | 'write' | 'destructive';
48
+ }
49
+ interface PendingQuestionOption {
50
+ label: string;
51
+ description?: string;
52
+ }
53
+ interface PendingQuestionItem {
54
+ id: string;
55
+ question: string;
56
+ multiSelect?: boolean;
57
+ options?: PendingQuestionOption[];
58
+ }
59
+ interface PendingQuestionPayload {
60
+ requestId: string;
61
+ sessionId: string;
62
+ questions: PendingQuestionItem[];
63
+ }
64
+ interface PendingSnapshotPayload {
65
+ approvals: PendingApprovalPayload[];
66
+ questions: PendingQuestionPayload[];
67
+ }
42
68
  //#endregion
43
69
  //#region src/host-bridge.d.ts
44
70
  interface RpcOk<T> {
@@ -291,6 +317,17 @@ interface ApiProxyLike {
291
317
  host(req: RpcRequestLike<Record<string, never>>, signal: AbortSignal): AsyncIterable<MuxFrameLike | ApiStreamItemLike>;
292
318
  };
293
319
  }
320
+ /**
321
+ * Outcome of answering a pending approval/question. The host distinguishes
322
+ * "never/no longer pending" from "payload rejected", and collapsing both into
323
+ * a boolean made every rejection read as `question not pending` on the phone.
324
+ */
325
+ type PendingResponseOutcome = {
326
+ ok: true;
327
+ } | {
328
+ ok: false;
329
+ reason: 'not-pending' | 'bad-response' | 'transport';
330
+ };
294
331
  /** Downward sink every connected phone registers (one per WebSocket). */
295
332
  interface BridgeSink {
296
333
  push(type: string, payload: unknown, seq?: number): void;
@@ -343,6 +380,7 @@ declare class HostBridge {
343
380
  replay: boolean;
344
381
  approvals: boolean;
345
382
  questions: boolean;
383
+ pendingSnapshot: boolean;
346
384
  models: boolean;
347
385
  sessionManagement: boolean;
348
386
  projectSelection: boolean;
@@ -392,6 +430,12 @@ declare class HostBridge {
392
430
  private bumpPendingFlags;
393
431
  private pushSummary;
394
432
  listSessions(): SessionSummary[];
433
+ /**
434
+ * Complete transient interaction state. Unlike the replay ring, this remains
435
+ * authoritative after a long disconnect and is rehydrated by apiProxy's mux
436
+ * stream when the bridge itself restarts.
437
+ */
438
+ pendingSnapshot(): PendingSnapshotPayload;
395
439
  /** Tail history for an opened session; pushes s2c.session.tail to the sink. */
396
440
  openSession(sink: BridgeSink, sessionId: string, tailCount: number): Promise<boolean>;
397
441
  historyPage(sink: BridgeSink, sessionId: string, beforeSeq: number, limit: number): Promise<boolean>;
@@ -431,9 +475,9 @@ declare class HostBridge {
431
475
  mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';
432
476
  data: string;
433
477
  name?: string;
434
- }>): Promise<number | null>;
435
- respondApproval(requestId: string, decision: 'allow' | 'deny'): Promise<boolean>;
436
- respondQuestion(requestId: string, answers: unknown): Promise<boolean>;
478
+ }>): Promise<SessionManagementResult<number>>;
479
+ respondApproval(requestId: string, decision: 'allow' | 'deny', reason?: string): Promise<PendingResponseOutcome>;
480
+ respondQuestion(requestId: string, answers: unknown): Promise<PendingResponseOutcome>;
437
481
  }
438
482
  //#endregion
439
483
  //#region src/index.d.ts
package/lib/index.js CHANGED
@@ -362,6 +362,9 @@ var BridgeConnection = class {
362
362
  sessions: this.deps.bridge.listSessions()
363
363
  }, env.id);
364
364
  return;
365
+ case "c2s.pending.list":
366
+ this.send("s2c.pending.snapshot", this.deps.bridge.pendingSnapshot(), env.id);
367
+ return;
365
368
  case "c2s.workspaces.list": {
366
369
  if (!this.deps.bridge.capabilities.projectSelection) return this.fail(env.id, "E_UNSUPPORTED", "project selection unavailable on this host version");
367
370
  const result = await this.deps.bridge.listWorkspaces();
@@ -395,10 +398,15 @@ var BridgeConnection = class {
395
398
  }
396
399
  case "c2s.session.open": {
397
400
  const p = env.payload;
398
- if (!p?.sessionId) return this.fail(env.id, "E_PROTOCOL", "sessionId required");
399
- this.openSessions.add(p.sessionId);
400
- this.deps.bridge.markSinkOpen(this, p.sessionId);
401
- if (!await this.deps.bridge.openSession(this, p.sessionId, p.tailCount ?? 100)) this.fail(env.id, "E_NOT_FOUND", "session history unavailable");
401
+ if (!p?.sessionId || typeof p.sessionId !== "string") return this.fail(env.id, "E_PROTOCOL", "sessionId required");
402
+ const sessionId = p.sessionId;
403
+ this.openSessions.add(sessionId);
404
+ this.deps.bridge.markSinkOpen(this, sessionId);
405
+ if (!await this.deps.bridge.openSession(this, sessionId, p.tailCount ?? 100)) {
406
+ this.openSessions.delete(sessionId);
407
+ this.deps.bridge.markSinkClosed(this, sessionId);
408
+ return this.fail(env.id, "E_NOT_FOUND", "session history unavailable");
409
+ }
402
410
  return;
403
411
  }
404
412
  case "c2s.session.close": {
@@ -526,21 +534,23 @@ var BridgeConnection = class {
526
534
  });
527
535
  }
528
536
  const userSeq = await this.deps.bridge.sendPrompt(p.sessionId, text, images);
529
- if (userSeq === null) return this.fail(env.id, "E_BUSY", "session busy or unavailable");
530
- this.send("s2c.ack", { userSeq }, env.id);
537
+ if (!userSeq.ok) return this.fail(env.id, managementErrorCode(userSeq.kind), userSeq.message);
538
+ this.send("s2c.ack", { userSeq: userSeq.value }, env.id);
531
539
  return;
532
540
  }
533
541
  case "c2s.approval.respond": {
534
542
  const p = env.payload;
535
543
  if (!p?.requestId || p.decision !== "allow" && p.decision !== "deny") return this.fail(env.id, "E_PROTOCOL", "requestId and decision required");
536
- if (!await this.deps.bridge.respondApproval(p.requestId, p.decision)) return this.fail(env.id, "E_NOT_FOUND", "approval not pending");
544
+ const outcome = await this.deps.bridge.respondApproval(p.requestId, p.decision, typeof p.reason === "string" ? p.reason : void 0);
545
+ if (!outcome.ok) return this.fail(env.id, pendingResponseErrorCode(outcome.reason), pendingResponseMessage("approval", outcome.reason));
537
546
  this.send("s2c.ack", {}, env.id);
538
547
  return;
539
548
  }
540
549
  case "c2s.question.respond": {
541
550
  const p = env.payload;
542
551
  if (!p?.requestId || !Array.isArray(p.answers)) return this.fail(env.id, "E_PROTOCOL", "requestId and answers required");
543
- if (!await this.deps.bridge.respondQuestion(p.requestId, p.answers)) return this.fail(env.id, "E_NOT_FOUND", "question not pending");
552
+ const outcome = await this.deps.bridge.respondQuestion(p.requestId, p.answers);
553
+ if (!outcome.ok) return this.fail(env.id, pendingResponseErrorCode(outcome.reason), pendingResponseMessage("question", outcome.reason));
544
554
  this.send("s2c.ack", {}, env.id);
545
555
  return;
546
556
  }
@@ -612,6 +622,23 @@ var BridgeConnection = class {
612
622
  }
613
623
  }
614
624
  };
625
+ /** Error code for a failed approval/question response outcome. */
626
+ function pendingResponseErrorCode(reason) {
627
+ switch (reason) {
628
+ case "not-pending": return "E_NOT_FOUND";
629
+ case "bad-response": return "E_PROTOCOL";
630
+ case "transport": return "E_INTERNAL";
631
+ }
632
+ }
633
+ /** Human-readable failure detail; `question not pending` must only ever mean
634
+ * "nothing pending", never "the host rejected the answer". */
635
+ function pendingResponseMessage(kind, reason) {
636
+ switch (reason) {
637
+ case "not-pending": return kind + " not pending";
638
+ case "bad-response": return kind + " answer rejected by host: answer does not match the asked questions";
639
+ case "transport": return "host connection failed while answering " + kind;
640
+ }
641
+ }
615
642
  function managementErrorCode(kind) {
616
643
  switch (kind) {
617
644
  case "unsupported": return "E_UNSUPPORTED";
@@ -677,6 +704,7 @@ var HostBridge = class {
677
704
  replay: true,
678
705
  approvals: true,
679
706
  questions: true,
707
+ pendingSnapshot: true,
680
708
  models: typeof this.apiProxy.sessions.models === "function" && typeof this.apiProxy.sessions.selectModel === "function",
681
709
  sessionManagement: typeof this.apiProxy.sessions.rename === "function" && typeof this.apiProxy.workspace?.archiveSession === "function",
682
710
  projectSelection: typeof this.apiProxy.workspace?.list === "function" && typeof this.apiProxy.workspace?.create === "function",
@@ -698,7 +726,7 @@ var HostBridge = class {
698
726
  /** Whether the ring still holds everything after the cursor. */
699
727
  canResumeFrom(cursor) {
700
728
  const oldest = this.ring.length > 0 ? this.ring[0].seq : this.cursor + 1;
701
- return cursor + 1 >= oldest;
729
+ return cursor <= this.cursor && cursor + 1 >= oldest;
702
730
  }
703
731
  sinkSessions = /* @__PURE__ */ new Map();
704
732
  lastAssistantText = /* @__PURE__ */ new Map();
@@ -1040,6 +1068,27 @@ var HostBridge = class {
1040
1068
  listSessions() {
1041
1069
  return [...this.summaries.values()].sort((a, b) => b.lastActivityTs - a.lastActivityTs);
1042
1070
  }
1071
+ /**
1072
+ * Complete transient interaction state. Unlike the replay ring, this remains
1073
+ * authoritative after a long disconnect and is rehydrated by apiProxy's mux
1074
+ * stream when the bridge itself restarts.
1075
+ */
1076
+ pendingSnapshot() {
1077
+ return {
1078
+ approvals: [...this.approvals.entries()].map(([requestId, pending]) => ({
1079
+ requestId,
1080
+ sessionId: pending.sessionId,
1081
+ toolName: pending.toolName,
1082
+ summary: pending.reason,
1083
+ riskLevel: riskOf(pending.toolName)
1084
+ })),
1085
+ questions: [...this.questions.entries()].map(([requestId, pending]) => ({
1086
+ requestId,
1087
+ sessionId: pending.sessionId,
1088
+ questions: Array.isArray(pending.questions) ? pending.questions : []
1089
+ }))
1090
+ };
1091
+ }
1043
1092
  /** Tail history for an opened session; pushes s2c.session.tail to the sink. */
1044
1093
  async openSession(sink, sessionId, tailCount) {
1045
1094
  try {
@@ -1445,22 +1494,38 @@ var HostBridge = class {
1445
1494
  clientTimeZone: localTimeZone()
1446
1495
  }
1447
1496
  });
1448
- if (!response.result || !response.result.ok) return null;
1497
+ if (!response.result) return {
1498
+ ok: false,
1499
+ kind: "internal",
1500
+ message: "prompt returned no result"
1501
+ };
1502
+ if (!response.result.ok) return hostSessionManagementError(response.result.error);
1449
1503
  const row = this.summaries.get(sessionId);
1450
1504
  if (row) {
1451
1505
  row.lastActivityTs = Date.now();
1452
1506
  this.pushSummary(row);
1453
1507
  }
1454
- return Date.now();
1455
- } catch {
1456
- return null;
1508
+ return {
1509
+ ok: true,
1510
+ value: Date.now()
1511
+ };
1512
+ } catch (error) {
1513
+ return {
1514
+ ok: false,
1515
+ kind: "internal",
1516
+ message: String(error)
1517
+ };
1457
1518
  }
1458
1519
  }
1459
- async respondApproval(requestId, decision) {
1520
+ async respondApproval(requestId, decision, reason) {
1460
1521
  const pending = this.approvals.get(requestId);
1461
- if (!pending) return false;
1522
+ if (!pending) return {
1523
+ ok: false,
1524
+ reason: "not-pending"
1525
+ };
1462
1526
  this.approvals.delete(requestId);
1463
1527
  const outcome = decision === "allow" ? "allowed-once" : "rejected";
1528
+ const denialReason = typeof reason === "string" ? reason.trim().slice(0, 500) : "";
1464
1529
  try {
1465
1530
  const receipt = await this.apiProxy.respond({
1466
1531
  type: "client-response",
@@ -1470,21 +1535,35 @@ var HostBridge = class {
1470
1535
  value: {
1471
1536
  sessionId: pending.sessionId,
1472
1537
  approvalId: requestId,
1473
- outcome
1538
+ outcome,
1539
+ ...denialReason.length > 0 ? { reason: denialReason } : {}
1474
1540
  }
1475
1541
  }
1476
1542
  });
1477
- const accepted = Boolean(receipt?.accepted);
1478
- if (!accepted && !this.approvals.has(requestId)) this.approvals.set(requestId, pending);
1479
- return accepted;
1543
+ if (!Boolean(receipt?.accepted)) {
1544
+ const failure = receiptFailureReason(receipt);
1545
+ if (failure !== "not-pending" && !this.approvals.has(requestId)) this.approvals.set(requestId, pending);
1546
+ return {
1547
+ ok: false,
1548
+ reason: failure
1549
+ };
1550
+ }
1551
+ this.bumpPendingFlags(pending.sessionId);
1552
+ return { ok: true };
1480
1553
  } catch {
1481
1554
  if (!this.approvals.has(requestId)) this.approvals.set(requestId, pending);
1482
- return false;
1555
+ return {
1556
+ ok: false,
1557
+ reason: "transport"
1558
+ };
1483
1559
  }
1484
1560
  }
1485
1561
  async respondQuestion(requestId, answers) {
1486
1562
  const pending = this.questions.get(requestId);
1487
- if (!pending) return false;
1563
+ if (!pending) return {
1564
+ ok: false,
1565
+ reason: "not-pending"
1566
+ };
1488
1567
  this.questions.delete(requestId);
1489
1568
  try {
1490
1569
  const receipt = await this.apiProxy.respond({
@@ -1494,19 +1573,65 @@ var HostBridge = class {
1494
1573
  ok: true,
1495
1574
  value: {
1496
1575
  sessionId: pending.sessionId,
1497
- answer: { answers }
1576
+ answer: { answers: normalizeAnswerItems(answers, pending.questions) }
1498
1577
  }
1499
1578
  }
1500
1579
  });
1501
- const accepted = Boolean(receipt?.accepted);
1502
- if (!accepted && !this.questions.has(requestId)) this.questions.set(requestId, pending);
1503
- return accepted;
1580
+ if (!Boolean(receipt?.accepted)) {
1581
+ const failure = receiptFailureReason(receipt);
1582
+ if (failure !== "not-pending" && !this.questions.has(requestId)) this.questions.set(requestId, pending);
1583
+ return {
1584
+ ok: false,
1585
+ reason: failure
1586
+ };
1587
+ }
1588
+ this.bumpPendingFlags(pending.sessionId);
1589
+ return { ok: true };
1504
1590
  } catch {
1505
1591
  if (!this.questions.has(requestId)) this.questions.set(requestId, pending);
1506
- return false;
1592
+ return {
1593
+ ok: false,
1594
+ reason: "transport"
1595
+ };
1507
1596
  }
1508
1597
  }
1509
1598
  };
1599
+ /**
1600
+ * The host validates question answers strictly (core dsh-user-questions via
1601
+ * apiProxy): a present-but-empty `custom` fails `matchesQuestions`, and a
1602
+ * single-select question rejects `custom` combined with a selection. Clients
1603
+ * may send lenient shapes (the phone historically always attached
1604
+ * `"custom": ""`, which made EVERY option-only answer fail), so normalize to
1605
+ * exactly what the host accepts before forwarding.
1606
+ */
1607
+ function normalizeAnswerItems(raw, questions) {
1608
+ if (!Array.isArray(raw)) return [];
1609
+ const askedById = /* @__PURE__ */ new Map();
1610
+ if (Array.isArray(questions)) {
1611
+ for (const q of questions) if (typeof q === "object" && q !== null && typeof q.id === "string") askedById.set(q.id, q);
1612
+ }
1613
+ const items = [];
1614
+ for (const entry of raw) {
1615
+ if (typeof entry !== "object" || entry === null) continue;
1616
+ const r = entry;
1617
+ if (typeof r.id !== "string") continue;
1618
+ const selected = [...new Set(Array.isArray(r.selected) ? r.selected.filter((s) => typeof s === "string") : [])];
1619
+ const customText = typeof r.custom === "string" ? r.custom : "";
1620
+ let custom;
1621
+ if (customText.trim().length > 0) custom = customText;
1622
+ if (custom !== void 0 && selected.length > 0 && askedById.get(r.id)?.multiSelect !== true) custom = void 0;
1623
+ items.push({
1624
+ id: r.id,
1625
+ selected,
1626
+ ...custom !== void 0 ? { custom } : {}
1627
+ });
1628
+ }
1629
+ return items;
1630
+ }
1631
+ /** Map an apiProxy respond receipt onto the failure vocabulary. */
1632
+ function receiptFailureReason(receipt) {
1633
+ return receipt?.reason === "not-pending" ? "not-pending" : "bad-response";
1634
+ }
1510
1635
  function clampTail(n) {
1511
1636
  if (!Number.isFinite(n)) return 100;
1512
1637
  return Math.max(10, Math.min(500, Math.floor(n)));
@@ -1598,9 +1723,10 @@ function projectEvent(sessionId, event) {
1598
1723
  kind: "message.final",
1599
1724
  data: {
1600
1725
  seq: event.seq,
1601
- role: "user",
1726
+ role: userRoleOf(event.data),
1602
1727
  text: messageText(event.data),
1603
1728
  ...attachmentProjection(event.data),
1729
+ ...contextProjectionOf(event.data),
1604
1730
  ts: tsOf(event)
1605
1731
  }
1606
1732
  };
@@ -1644,27 +1770,99 @@ function projectEvent(sessionId, event) {
1644
1770
  tool: {
1645
1771
  name: String(data?.name ?? "tool"),
1646
1772
  state: "running",
1647
- summary: summarizeArgs(data?.arguments)
1773
+ summary: summarizeArgs(data?.arguments),
1774
+ ...data?.callId ? { callId: String(data.callId) } : {}
1648
1775
  },
1649
1776
  ts: tsOf(event)
1650
1777
  }
1651
1778
  };
1652
1779
  }
1653
- case "tool/result": return {
1654
- kind: "tool.end",
1655
- data: {
1656
- seq: event.seq,
1657
- role: "tool",
1658
- ok: !event.data || event.data.error === void 0,
1659
- ts: tsOf(event)
1660
- }
1661
- };
1780
+ case "tool/result": {
1781
+ const data = event.data;
1782
+ return {
1783
+ kind: "tool.end",
1784
+ data: {
1785
+ seq: event.seq,
1786
+ role: "tool",
1787
+ ok: !event.data || data?.error === void 0,
1788
+ ...data?.callId ? { callId: String(data.callId) } : {},
1789
+ ts: tsOf(event)
1790
+ }
1791
+ };
1792
+ }
1662
1793
  default: return null;
1663
1794
  }
1664
1795
  }
1665
1796
  function tsOf(event) {
1666
1797
  return typeof event.time === "number" ? event.time : Date.now();
1667
1798
  }
1799
+ /** Read the durable message source off one user/message payload. Handles both
1800
+ * bare-message payloads and older `{message: {...}}` wrappers; undefined when
1801
+ * the shape carries no readable source (legacy hosts). */
1802
+ function userMessageSource(data) {
1803
+ if (!data || typeof data !== "object") return void 0;
1804
+ const obj = data;
1805
+ if (obj.source && typeof obj.source === "object") return obj.source;
1806
+ if (obj.message && typeof obj.message === "object" && obj.message.source && typeof obj.message.source === "object") return obj.message.source;
1807
+ }
1808
+ /** Wire role for one user/message payload. A payload without any readable
1809
+ * source degrades to 'user' so history written by older hosts stays visible;
1810
+ * a present source follows the host's own trajectory rule — anything whose
1811
+ * `kind` is not 'user' is injected context and projects as 'system'. */
1812
+ function userRoleOf(data) {
1813
+ const source = userMessageSource(data);
1814
+ if (!source) return "user";
1815
+ return source.kind === "user" ? "user" : "system";
1816
+ }
1817
+ /** Producer name of one injected-context source, mirroring how the DSH client
1818
+ * runtime derives its trajectory label: plugin name, skill name, instruction
1819
+ * paths, session-reference labels, or the raw kind as fallback. */
1820
+ function contextLabelOf(source) {
1821
+ const kind = typeof source.kind === "string" ? source.kind : "";
1822
+ const joined = (member) => {
1823
+ const list = source[member];
1824
+ if (!Array.isArray(list)) return void 0;
1825
+ const names = list.flatMap((entry) => {
1826
+ if (!entry || typeof entry !== "object") return [];
1827
+ const record = entry;
1828
+ return [typeof record.label === "string" ? record.label : typeof record.path === "string" ? record.path : ""];
1829
+ }).filter((name) => name.length > 0);
1830
+ return names.length > 0 ? names.join(", ") : void 0;
1831
+ };
1832
+ switch (kind) {
1833
+ case "session-reference": return joined("references") ?? (kind || void 0);
1834
+ case "agent-instructions": return joined("changes") ?? (kind || void 0);
1835
+ case "plugin": return typeof source.plugin === "string" && source.plugin.length > 0 ? source.plugin : kind || void 0;
1836
+ case "skill-invocation": return typeof source.name === "string" && source.name.length > 0 ? source.name : kind || void 0;
1837
+ default: return kind || void 0;
1838
+ }
1839
+ }
1840
+ /** Semantic ContextForm declared by the producer ('snapshot', 'notice', …);
1841
+ * anything unrecognized stays undefined so clients render it opaque. */
1842
+ function contextFormOf(source) {
1843
+ if (typeof source.form !== "string" || source.form.length === 0) return void 0;
1844
+ return [
1845
+ "instructions",
1846
+ "catalog",
1847
+ "snapshot",
1848
+ "notice",
1849
+ "relay",
1850
+ "recall"
1851
+ ].includes(source.form) ? source.form : void 0;
1852
+ }
1853
+ /** Optional `context` metadata for one system row; {} on user rows. */
1854
+ function contextProjectionOf(data) {
1855
+ if (userRoleOf(data) !== "system") return {};
1856
+ const source = userMessageSource(data);
1857
+ if (!source) return {};
1858
+ const label = contextLabelOf(source);
1859
+ const form = contextFormOf(source);
1860
+ if (!label && !form) return {};
1861
+ return { context: {
1862
+ ...label ? { label } : {},
1863
+ ...form ? { form } : {}
1864
+ } };
1865
+ }
1668
1866
  /** Extract plain text from user/assistant message payloads across shapes. */
1669
1867
  function messageText(data) {
1670
1868
  if (typeof data === "string") return data;
@@ -1874,9 +2072,10 @@ function projectHistory(events) {
1874
2072
  case "user/message":
1875
2073
  messages.push({
1876
2074
  ...base,
1877
- role: "user",
2075
+ role: userRoleOf(event.data),
1878
2076
  text: messageText(event.data),
1879
- ...attachmentProjection(event.data)
2077
+ ...attachmentProjection(event.data),
2078
+ ...contextProjectionOf(event.data)
1880
2079
  });
1881
2080
  break;
1882
2081
  case "assistant/message": {
@@ -2711,12 +2910,14 @@ var RemoteSupervisor = class {
2711
2910
  mode: 448
2712
2911
  });
2713
2912
  } catch (error) {
2913
+ if (this.stopping) return;
2714
2914
  this.setStatus({
2715
2915
  phase: "unavailable",
2716
2916
  message: `embedded tunnel helper unavailable: ${String(error)}`
2717
2917
  });
2718
2918
  return;
2719
2919
  }
2920
+ if (this.stopping) return;
2720
2921
  this.setStatus({
2721
2922
  phase: "starting",
2722
2923
  message: void 0
@@ -3207,6 +3408,10 @@ function apply(ctx, options) {
3207
3408
  let enrollAttemptFor;
3208
3409
  let enrollLastAttemptAt = 0;
3209
3410
  const ensureRelayEnrolled = async (url) => {
3411
+ if (!/^https:\/\//i.test(url.trim())) {
3412
+ log("push relay enrollment refused: relayUrl must be an https URL");
3413
+ return;
3414
+ }
3210
3415
  if (enrollmentCell.token) return enrollmentCell.token;
3211
3416
  const fingerprint = url + ":" + String(enrollmentCell.enrollKey ?? "");
3212
3417
  if (fingerprint !== enrollAttemptFor) {
@@ -3239,7 +3444,14 @@ function apply(ctx, options) {
3239
3444
  }
3240
3445
  };
3241
3446
  let cachedSender;
3447
+ /**
3448
+ * Last failed APNs-sender build. The config fingerprint cannot see the
3449
+ * filesystem, so remembering a failure forever meant "copy the .p8 into
3450
+ * place later" never recovered without an edit or restart; throttle the
3451
+ * retry by time instead — same pattern as relay enrollment below.
3452
+ */
3242
3453
  let senderFailedFor;
3454
+ const SENDER_FAILURE_RETRY_MS = 6e4;
3243
3455
  /**
3244
3456
  * Lazily build the push sender for the current config. A broken config
3245
3457
  * (unreadable .p8) disables push for that fingerprint with exactly one log
@@ -3248,7 +3460,7 @@ function apply(ctx, options) {
3248
3460
  const senderFor = async (resolved) => {
3249
3461
  const fingerprint = JSON.stringify(resolved);
3250
3462
  if (cachedSender?.fingerprint === fingerprint) return cachedSender.send;
3251
- if (senderFailedFor === fingerprint) return void 0;
3463
+ if (senderFailedFor?.fingerprint === fingerprint && Date.now() - senderFailedFor.at < SENDER_FAILURE_RETRY_MS) return;
3252
3464
  if (cachedSender) {
3253
3465
  await cachedSender.dispose?.().catch(() => {});
3254
3466
  cachedSender = void 0;
@@ -3269,7 +3481,10 @@ function apply(ctx, options) {
3269
3481
  try {
3270
3482
  await readFile(expandHome(resolved.keyPath), "utf8");
3271
3483
  } catch (error) {
3272
- senderFailedFor = fingerprint;
3484
+ senderFailedFor = {
3485
+ fingerprint,
3486
+ at: Date.now()
3487
+ };
3273
3488
  log("apns push unavailable (key unreadable at " + resolved.keyPath + "): " + String(error));
3274
3489
  return;
3275
3490
  }
@@ -3422,6 +3637,16 @@ function apply(ctx, options) {
3422
3637
  }]
3423
3638
  };
3424
3639
  const url = (push.relayUrl ?? "").trim() || DEFAULT_RELAY_URL;
3640
+ if (!/^https:\/\//i.test(url)) return {
3641
+ url,
3642
+ overall: "failed",
3643
+ tokenIssued: false,
3644
+ steps: [{
3645
+ id: "health",
3646
+ ok: false,
3647
+ message: "relayUrl 必须是 https 地址:注册请求携带共享密钥,明文 HTTP 会把它暴露给链路上的任何节点"
3648
+ }]
3649
+ };
3425
3650
  if (!enrollmentCell.clientId && enrollmentCell.enrollKey) {
3426
3651
  enrollmentCell.clientId = "u_" + randomBytes(16).toString("base64url");
3427
3652
  persistEnrollment();