@openchambery/web 1.19.7-beta.6 → 1.19.7-beta.8

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.
Files changed (76) hide show
  1. package/dist/assets/{AgentAvatar-ziUs4sw3.js → AgentAvatar-Di8F4QJh.js} +1 -1
  2. package/dist/assets/{AssistantView-CCy18vfR.js → AssistantView-DYdXsGDh.js} +1 -1
  3. package/dist/assets/{ChatContainer-DJe_JLcz.js → ChatContainer-D3onaUkL.js} +35 -35
  4. package/dist/assets/{ContextPanel-Zu_D3BhW.js → ContextPanel-Cj6RVUAe.js} +3 -3
  5. package/dist/assets/{DiagramView-DXBSE1Qg.js → DiagramView-BoRTG67Z.js} +1 -1
  6. package/dist/assets/{FadeInOnReveal-BAlsRFJH.js → FadeInOnReveal-CV2s6YN8.js} +1 -1
  7. package/dist/assets/{MarkdownRendererImpl-CznjvgQG.js → MarkdownRendererImpl-DwUKY9k4.js} +1 -1
  8. package/dist/assets/{MarkstreamRendererImpl-CxeWpDCK.css → MarkstreamRendererImpl-Ca7RATbs.css} +1 -1
  9. package/dist/assets/{MobileDetailNavigation-Cd1aYfhC.js → MobileDetailNavigation-C0Z8beRL.js} +1 -1
  10. package/dist/assets/{MobileShareBridge-BSAjAbV2.js → MobileShareBridge-DTvmm9vz.js} +2 -2
  11. package/dist/assets/{MobileSurface-Dzgv8_-B.js → MobileSurface-CfrI-5uf.js} +1 -1
  12. package/dist/assets/{MultiRunWindow-DUNzqNmB.js → MultiRunWindow-D_jo8cqz.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-CJYyR54B.js → OnboardingScreen-D6rtE7rj.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-C-jZ8ITG.js → OverlayScrollbar-DlNZho3q.js} +1 -1
  15. package/dist/assets/{SettingsView-Ncm_fo9e.js → SettingsView-BOT158gD.js} +1 -1
  16. package/dist/assets/{SettingsWindow-h0RnxmRw.js → SettingsWindow-CzMoz2fi.js} +1 -1
  17. package/dist/assets/{TerminalView-CxNW-E1G.js → TerminalView-sf_LfP6Z.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-BxhqfMo9.js → ToolOutputDialog-BI5vwuaT.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-B-rV3gVd.js → appThemeRegistry-G_zwgqAH.js} +2 -2
  20. package/dist/assets/{clipboard-Bz0Zm9VJ.js → clipboard-D35XSwID.js} +1 -1
  21. package/dist/assets/{desktopRelayRestore-LBt0CBOj.js → desktopRelayRestore-8pvI_dSc.js} +1 -1
  22. package/dist/assets/{dialog-HCE4eLUW.js → dialog-x2hirWBd.js} +1 -1
  23. package/dist/assets/{es-D9KcV5TX.js → es-C8Sqjsia.js} +1 -1
  24. package/dist/assets/{fileQueries-Dnn15WsA.js → fileQueries-DGHZqZHc.js} +1 -1
  25. package/dist/assets/{fr-DPA9qUl3.js → fr-p-Zwm_j2.js} +1 -1
  26. package/dist/assets/{gitApi-BQk5cH8O.js → gitApi-BE0vA4zm.js} +1 -1
  27. package/dist/assets/{insertionBoundaries-7UaZ97RD.js → insertionBoundaries-CJksyugt.js} +1 -1
  28. package/dist/assets/{ja-Dsif3NoD.js → ja-B6REu1qP.js} +1 -1
  29. package/dist/assets/{ko-Dixx4Ctg.js → ko-CJBNH9IZ.js} +1 -1
  30. package/dist/assets/{main-D2whc6Yv.js → main-BTeHOAU_.js} +3 -3
  31. package/dist/assets/{main-D4zLYOaH.js → main-BUBx-y1E.js} +2 -2
  32. package/dist/assets/{markdownTheme-TdhQOZSN.js → markdownTheme-SPe4jOwX.js} +1 -1
  33. package/dist/assets/{miniChat-C6qw4n5u.js → miniChat-Btnf42TH.js} +2 -2
  34. package/dist/assets/{mobile-D2-A0hzp.js → mobile-CAjwV6ly.js} +2 -2
  35. package/dist/assets/{multirun-BLs0gcI6.js → multirun-Rpz1_-fz.js} +1 -1
  36. package/dist/assets/{persistence-CmjKImz2.js → persistence-w-AiX2i4.js} +1 -1
  37. package/dist/assets/{pl-Bj78ZGgf.js → pl-B2cBI_7K.js} +1 -1
  38. package/dist/assets/{platform-skqcMARt.js → platform-4eCmsdol.js} +1 -1
  39. package/dist/assets/{projectMeta-BAfqxsS4.js → projectMeta-CvUuy-j5.js} +1 -1
  40. package/dist/assets/{pt-BR-CFL3xSAf.js → pt-BR-CrCc9hmt.js} +1 -1
  41. package/dist/assets/{radio-Yoba9qwf.js → radio-D_a28OlM.js} +1 -1
  42. package/dist/assets/{renderElectronMiniChatApp-DI61MYvq.js → renderElectronMiniChatApp-DCzBQk2w.js} +2 -2
  43. package/dist/assets/{renderMobileApp-DsKjY4c_.js → renderMobileApp-U5sclQRp.js} +10 -7
  44. package/dist/assets/{runtimeConfig-_bkBy5Wd.js → runtimeConfig-6inIl99b.js} +1 -1
  45. package/dist/assets/{runtimeEndpointReset-CpAYissz.js → runtimeEndpointReset-B_rUFDT5.js} +1 -1
  46. package/dist/assets/{shikiHighlight-8q791YEM.js → shikiHighlight-CMpSAuTl.js} +1 -1
  47. package/dist/assets/{sortable-tabs-strip-Bj8N0VWf.js → sortable-tabs-strip-CNDgyX0B.js} +1 -1
  48. package/dist/assets/{store-DzpbXWX1.js → store-CL3WP48T.js} +2 -2
  49. package/dist/assets/{terminalApi-CNqqwjfG.js → terminalApi-CGtOtc8I.js} +1 -1
  50. package/dist/assets/{uk-BY60Ub3Z.js → uk-C8YKAz0x.js} +1 -1
  51. package/dist/assets/{useAppFontEffects-BkdcivZb.js → useAppFontEffects-3xHRVJcf.js} +2 -2
  52. package/dist/assets/{useAssistantUIStore-DaplPyb0.js → useAssistantUIStore-I0VWrB5V.js} +1 -1
  53. package/dist/assets/{useDesktopWindowControlsLayout-CzAHnjTa.js → useDesktopWindowControlsLayout-QJlddPRn.js} +1 -1
  54. package/dist/assets/{useEffectiveDirectory-NardZbAS.js → useEffectiveDirectory-Bcj1wP95.js} +1 -1
  55. package/dist/assets/{useFontPreferences-D8emhcNv.js → useFontPreferences-BLZ3y6mN.js} +1 -1
  56. package/dist/assets/{useMobileNavigationStore-lqmpSW1v.js → useMobileNavigationStore-CD0GRjaV.js} +4 -4
  57. package/dist/assets/{useSessionAutoCleanup-DUTte2bI.js → useSessionAutoCleanup-CN_6ABvx.js} +1 -1
  58. package/dist/assets/useUIStore-CNLmCoPU.js +1 -0
  59. package/dist/assets/{useWorkerHighlightedLines-cPHAAzjo.js → useWorkerHighlightedLines-DX9znDoW.js} +1 -1
  60. package/dist/assets/{zh-CN-Cox1LaOW.js → zh-CN-CCYwUnmB.js} +1 -1
  61. package/dist/assets/{zh-TW-B6RU0mxa.js → zh-TW-D8Y6py08.js} +1 -1
  62. package/dist/index.html +7 -7
  63. package/dist/mini-chat.html +7 -7
  64. package/dist/mobile.html +7 -7
  65. package/package.json +1 -1
  66. package/server/lib/notifications/APNS.md +4 -4
  67. package/server/lib/notifications/DOCUMENTATION.md +1 -1
  68. package/server/lib/notifications/apns-runtime.js +139 -20
  69. package/server/lib/notifications/apns-runtime.test.js +66 -7
  70. package/server/lib/notifications/apns-titles.js +62 -62
  71. package/server/lib/notifications/apns-titles.test.js +3 -1
  72. package/server/lib/notifications/routes.js +1 -1
  73. package/server/lib/notifications/runtime.js +25 -49
  74. package/server/lib/opencode/settings-runtime.js +16 -0
  75. package/dist/assets/useUIStore-B7KJS5G7.js +0 -1
  76. /package/dist/assets/{MarkstreamRendererImpl-DorvdTK9.js → MarkstreamRendererImpl-BRvprELo.js} +0 -0
package/dist/index.html CHANGED
@@ -552,26 +552,26 @@
552
552
  opacity: 0.72;
553
553
  }
554
554
  </style>
555
- <script type="module" crossorigin src="/assets/main-D4zLYOaH.js"></script>
555
+ <script type="module" crossorigin src="/assets/main-BUBx-y1E.js"></script>
556
556
  <link rel="modulepreload" crossorigin href="/assets/runtime-switch-BkpTTbEi.js">
557
557
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
558
558
  <link rel="modulepreload" crossorigin href="/assets/desktop-CfLlmafK.js">
559
559
  <link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
560
560
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
561
- <link rel="modulepreload" crossorigin href="/assets/useUIStore-B7KJS5G7.js">
561
+ <link rel="modulepreload" crossorigin href="/assets/useUIStore-CNLmCoPU.js">
562
562
  <link rel="modulepreload" crossorigin href="/assets/vendor-pathe-vBmvQE8f.js">
563
563
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-DLPElpoQ.js">
564
564
  <link rel="modulepreload" crossorigin href="/assets/vendor-tanstack-query-core-BWz0ose6.js">
565
- <link rel="modulepreload" crossorigin href="/assets/persistence-CmjKImz2.js">
565
+ <link rel="modulepreload" crossorigin href="/assets/persistence-w-AiX2i4.js">
566
566
  <link rel="modulepreload" crossorigin href="/assets/desktopHosts-C1Ns3DUu.js">
567
- <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-LBt0CBOj.js">
567
+ <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-8pvI_dSc.js">
568
568
  <link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-C07HQypa.js">
569
569
  <link rel="modulepreload" crossorigin href="/assets/vendor-aparajita-capacitor-secure-storage-CjQdwfjS.js">
570
- <link rel="modulepreload" crossorigin href="/assets/platform-skqcMARt.js">
571
- <link rel="modulepreload" crossorigin href="/assets/terminalApi-CNqqwjfG.js">
570
+ <link rel="modulepreload" crossorigin href="/assets/platform-4eCmsdol.js">
571
+ <link rel="modulepreload" crossorigin href="/assets/terminalApi-CGtOtc8I.js">
572
572
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
573
573
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
574
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-_bkBy5Wd.js">
574
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-6inIl99b.js">
575
575
  <link rel="stylesheet" crossorigin href="/assets/src-DZt1nWf5.css">
576
576
  </head>
577
577
  <body class="h-full bg-background text-foreground">
@@ -74,26 +74,26 @@
74
74
  }
75
75
  </style>
76
76
 
77
- <script type="module" crossorigin src="/assets/miniChat-C6qw4n5u.js"></script>
77
+ <script type="module" crossorigin src="/assets/miniChat-Btnf42TH.js"></script>
78
78
  <link rel="modulepreload" crossorigin href="/assets/runtime-switch-BkpTTbEi.js">
79
79
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
80
80
  <link rel="modulepreload" crossorigin href="/assets/desktop-CfLlmafK.js">
81
81
  <link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
82
82
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
83
- <link rel="modulepreload" crossorigin href="/assets/useUIStore-B7KJS5G7.js">
83
+ <link rel="modulepreload" crossorigin href="/assets/useUIStore-CNLmCoPU.js">
84
84
  <link rel="modulepreload" crossorigin href="/assets/vendor-pathe-vBmvQE8f.js">
85
85
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-DLPElpoQ.js">
86
86
  <link rel="modulepreload" crossorigin href="/assets/vendor-tanstack-query-core-BWz0ose6.js">
87
- <link rel="modulepreload" crossorigin href="/assets/persistence-CmjKImz2.js">
87
+ <link rel="modulepreload" crossorigin href="/assets/persistence-w-AiX2i4.js">
88
88
  <link rel="modulepreload" crossorigin href="/assets/desktopHosts-C1Ns3DUu.js">
89
- <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-LBt0CBOj.js">
89
+ <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-8pvI_dSc.js">
90
90
  <link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-C07HQypa.js">
91
91
  <link rel="modulepreload" crossorigin href="/assets/vendor-aparajita-capacitor-secure-storage-CjQdwfjS.js">
92
- <link rel="modulepreload" crossorigin href="/assets/platform-skqcMARt.js">
93
- <link rel="modulepreload" crossorigin href="/assets/terminalApi-CNqqwjfG.js">
92
+ <link rel="modulepreload" crossorigin href="/assets/platform-4eCmsdol.js">
93
+ <link rel="modulepreload" crossorigin href="/assets/terminalApi-CGtOtc8I.js">
94
94
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
95
95
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
96
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-_bkBy5Wd.js">
96
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-6inIl99b.js">
97
97
  <link rel="stylesheet" crossorigin href="/assets/src-DZt1nWf5.css">
98
98
  </head>
99
99
  <body class="h-full bg-background text-foreground">
package/dist/mobile.html CHANGED
@@ -63,26 +63,26 @@
63
63
  })();
64
64
  </script>
65
65
 
66
- <script type="module" crossorigin src="/assets/mobile-D2-A0hzp.js"></script>
66
+ <script type="module" crossorigin src="/assets/mobile-CAjwV6ly.js"></script>
67
67
  <link rel="modulepreload" crossorigin href="/assets/runtime-switch-BkpTTbEi.js">
68
68
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
69
69
  <link rel="modulepreload" crossorigin href="/assets/desktop-CfLlmafK.js">
70
70
  <link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
71
71
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
72
- <link rel="modulepreload" crossorigin href="/assets/useUIStore-B7KJS5G7.js">
72
+ <link rel="modulepreload" crossorigin href="/assets/useUIStore-CNLmCoPU.js">
73
73
  <link rel="modulepreload" crossorigin href="/assets/vendor-pathe-vBmvQE8f.js">
74
74
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-DLPElpoQ.js">
75
75
  <link rel="modulepreload" crossorigin href="/assets/vendor-tanstack-query-core-BWz0ose6.js">
76
- <link rel="modulepreload" crossorigin href="/assets/persistence-CmjKImz2.js">
76
+ <link rel="modulepreload" crossorigin href="/assets/persistence-w-AiX2i4.js">
77
77
  <link rel="modulepreload" crossorigin href="/assets/desktopHosts-C1Ns3DUu.js">
78
- <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-LBt0CBOj.js">
78
+ <link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-8pvI_dSc.js">
79
79
  <link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-C07HQypa.js">
80
80
  <link rel="modulepreload" crossorigin href="/assets/vendor-aparajita-capacitor-secure-storage-CjQdwfjS.js">
81
- <link rel="modulepreload" crossorigin href="/assets/platform-skqcMARt.js">
82
- <link rel="modulepreload" crossorigin href="/assets/terminalApi-CNqqwjfG.js">
81
+ <link rel="modulepreload" crossorigin href="/assets/platform-4eCmsdol.js">
82
+ <link rel="modulepreload" crossorigin href="/assets/terminalApi-CGtOtc8I.js">
83
83
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
84
84
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
85
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-_bkBy5Wd.js">
85
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-6inIl99b.js">
86
86
  <link rel="stylesheet" crossorigin href="/assets/src-DZt1nWf5.css">
87
87
  </head>
88
88
  <body class="h-full bg-background text-foreground">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchambery/web",
3
- "version": "1.19.7-beta.6",
3
+ "version": "1.19.7-beta.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
@@ -116,8 +116,8 @@ is server-global, so every device token of a server sees the same badge.
116
116
 
117
117
  Host (`apns-runtime.js`):
118
118
  - `OPENCHAMBER_PUSH_RELAY_URL` (explicit send-URL override), `OPENCHAMBER_APNS_ENVIRONMENT`
119
- (`sandbox` default / `production` for TestFlight and App Store). The signing keypair is
120
- auto-generated — nothing to set.
119
+ (`production` default for TestFlight/App Store builds; set `sandbox` for Xcode development
120
+ builds). The signing keypair is auto-generated — nothing to set.
121
121
  - Direct fallback: `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8`
122
122
  (or `_P8_PATH`), `OPENCHAMBER_APNS_BUNDLE_ID`, `OPENCHAMBER_PUSH_RELAY_DISABLED=true`.
123
123
 
@@ -136,8 +136,8 @@ device tokens, or signatures.
136
136
  2. Give those values only to the Push process (environment or Docker secret). Do not put the
137
137
  `.p8` on Layer 1 or on each OpenChamber Host when using Relay mode.
138
138
  3. Xcode: confirm the Push Notifications capability; Clean Build Folder; run on device.
139
- Development builds use APNs `sandbox`. TestFlight and App Store Hosts set
140
- `OPENCHAMBER_APNS_ENVIRONMENT=production`.
139
+ Hosts default to APNs `production` (TestFlight/App Store tokens). Xcode development
140
+ builds (sandbox tokens) need `OPENCHAMBER_APNS_ENVIRONMENT=sandbox`.
141
141
 
142
142
  ## Security posture
143
143
 
@@ -79,7 +79,7 @@ This module provides notification message preparation utilities for the web serv
79
79
  - `sendApnsToAllUiSessions(payload)` — localizes scenario titles per stored token locale (`apns-titles.js`), groups tokens by locale, then signs + sends (no UI-visibility gate; iOS suppresses the foreground banner). Before a relay send, each token is checked against the in-memory registration success cache for the current register URL; cache hits skip `/v1/push/register-token`, misses are registered with bounded concurrency, and only successfully bound tokens are sent. Switching Relay/Push URL clears that cache so the next send re-binds persisted tokens to the new origin. No-ops with a single warning when APNs is unconfigured. Drops tokens on `410` / `BadDeviceToken` / `Unregistered`. Never sends Live Activity tokens.
80
80
  - `reRegisterAllTokens()` — binds every persisted device token to the current Push origin (same success cache and URL-switch invalidation as send).
81
81
  - `resolveApnsConfig()`
82
- - Configuration (env first, then `settings.apnsConfig`): `OPENCHAMBER_PUSH_RELAY_URL` (optional send-URL override), `OPENCHAMBER_PUSH_RELAY_DISABLED`, `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8` (PEM contents; literal `\n` accepted) or `OPENCHAMBER_APNS_P8_PATH`, `OPENCHAMBER_APNS_BUNDLE_ID` (default `com.yee94.openchamber`), `OPENCHAMBER_APNS_ENVIRONMENT` (`sandbox` default, or `production` for TestFlight and App Store).
82
+ - Configuration (env first, then `settings.apnsConfig`): `OPENCHAMBER_PUSH_RELAY_URL` (optional send-URL override), `OPENCHAMBER_PUSH_RELAY_DISABLED`, `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8` (PEM contents; literal `\n` accepted) or `OPENCHAMBER_APNS_P8_PATH`, `OPENCHAMBER_APNS_BUNDLE_ID` (default `com.yee94.openchamber`), `OPENCHAMBER_APNS_ENVIRONMENT` (`production` default for TestFlight/App Store builds; set `sandbox` explicitly for Xcode development builds).
83
83
 
84
84
  ### Emitter runtime API (emitter-runtime.js)
85
85
  - `createNotificationEmitterRuntime(dependencies)`: creates runtime for unified notification emission channels.
@@ -36,6 +36,72 @@ const LIVE_ACTIVITY_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
36
36
  const LIVE_ACTIVITY_COMPLETE_DISMISSAL_SECONDS = 15 * 60;
37
37
  const LIVE_ACTIVITY_ERROR_DISMISSAL_SECONDS = 60 * 60;
38
38
  const LIVE_ACTIVITY_STALE_SECONDS = 20 * 60;
39
+ const LIVE_ACTIVITY_AGGREGATE_SESSION_ID = 'live';
40
+ const MAX_LIVE_ACTIVITY_SNAPSHOT_ITEMS = 4;
41
+ const LIVE_ACTIVITY_ITEM_STATUSES = new Set([
42
+ 'working', 'tool', 'retry', 'input', 'permission', 'stale', 'complete', 'error',
43
+ ]);
44
+
45
+ const parseLiveActivitySnapshot = (value) => {
46
+ if (!Array.isArray(value)) return undefined;
47
+ const items = [];
48
+ for (const raw of value.slice(0, MAX_LIVE_ACTIVITY_SNAPSHOT_ITEMS)) {
49
+ if (!raw || typeof raw !== 'object') continue;
50
+ const sessionId = typeof raw.sessionId === 'string'
51
+ ? raw.sessionId.trim()
52
+ : (typeof raw.sessionID === 'string' ? raw.sessionID.trim() : '');
53
+ if (!sessionId || sessionId.length > MAX_LIVE_ACTIVITY_ID_CHARS) continue;
54
+ const status = typeof raw.status === 'string' && LIVE_ACTIVITY_ITEM_STATUSES.has(raw.status)
55
+ ? raw.status
56
+ : 'working';
57
+ const startedAt = Number(raw.startedAt);
58
+ if (!Number.isFinite(startedAt)) continue;
59
+ const item = {
60
+ sessionId,
61
+ title: typeof raw.title === 'string' ? raw.title.slice(0, 80) : '',
62
+ status,
63
+ startedAt,
64
+ };
65
+ const endedAt = Number(raw.endedAt);
66
+ if (Number.isFinite(endedAt)) item.endedAt = endedAt;
67
+ items.push(item);
68
+ }
69
+ return items;
70
+ };
71
+
72
+ const liveActivityItemIsWorking = (status) => status !== 'complete' && status !== 'error';
73
+
74
+ const liveActivityEntryCoversSession = (entry, sessionId) => {
75
+ if (entry.sessionId === sessionId) return true;
76
+ if (Array.isArray(entry.snapshot) && entry.snapshot.length > 0) {
77
+ return entry.snapshot.some((item) => item.sessionId === sessionId);
78
+ }
79
+ return entry.sessionId === LIVE_ACTIVITY_AGGREGATE_SESSION_ID;
80
+ };
81
+
82
+ const contentStateFromSnapshot = (snapshot, status, eventVersion, updatedAt, endedAt) => {
83
+ const working = snapshot.filter((item) => liveActivityItemIsWorking(item.status));
84
+ const primary = working[0] ?? snapshot[0];
85
+ const contentState = {
86
+ status,
87
+ eventVersion,
88
+ updatedAt,
89
+ workingCount: working.length,
90
+ items: snapshot.map((item) => {
91
+ const next = {
92
+ sessionID: item.sessionId,
93
+ title: item.title,
94
+ status: item.status,
95
+ startedAt: item.startedAt,
96
+ };
97
+ if (item.endedAt !== undefined) next.endedAt = item.endedAt;
98
+ return next;
99
+ }),
100
+ };
101
+ if (primary?.title) contentState.title = primary.title;
102
+ if (endedAt !== undefined) contentState.endedAt = endedAt;
103
+ return contentState;
104
+ };
39
105
  const MAX_RELAY_SEND_TOKENS = 100;
40
106
  const RELAY_REGISTER_CONCURRENCY = 16;
41
107
  const APNS_COLLAPSE_ID_MAX_BYTES = 64;
@@ -356,7 +422,8 @@ export const createApnsRuntime = (deps) => {
356
422
  const createdAt = typeof entry.createdAt === 'number' ? entry.createdAt : null;
357
423
  const lastSeenAt = typeof entry.lastSeenAt === 'number' ? entry.lastSeenAt : createdAt;
358
424
  if (typeof lastSeenAt === 'number' && now - lastSeenAt > LIVE_ACTIVITY_TOKEN_TTL_MS) return null;
359
- return { token, activityId, sessionId, createdAt, lastSeenAt };
425
+ const snapshot = parseLiveActivitySnapshot(entry.snapshot);
426
+ return { token, activityId, sessionId, snapshot, createdAt, lastSeenAt };
360
427
  })
361
428
  .filter(Boolean);
362
429
  };
@@ -464,7 +531,7 @@ export const createApnsRuntime = (deps) => {
464
531
  });
465
532
  };
466
533
 
467
- const addOrUpdateLiveActivityToken = async (uiSessionToken, token, activityId, sessionId) => {
534
+ const addOrUpdateLiveActivityToken = async (uiSessionToken, token, activityId, sessionId, snapshot) => {
468
535
  if (!uiSessionToken) return;
469
536
  const trimmedToken = typeof token === 'string' ? token.trim() : '';
470
537
  const trimmedActivityId = typeof activityId === 'string' ? activityId.trim() : '';
@@ -486,10 +553,12 @@ export const createApnsRuntime = (deps) => {
486
553
  const filtered = existing.filter(
487
554
  (entry) => entry.activityId !== trimmedActivityId && entry.token !== trimmedToken,
488
555
  );
556
+ const nextSnapshot = parseLiveActivitySnapshot(snapshot) ?? replaced?.snapshot;
489
557
  filtered.unshift({
490
558
  token: trimmedToken,
491
559
  activityId: trimmedActivityId,
492
560
  sessionId: trimmedSessionId,
561
+ snapshot: nextSnapshot,
493
562
  createdAt: typeof replaced?.createdAt === 'number' ? replaced.createdAt : now,
494
563
  lastSeenAt: now,
495
564
  });
@@ -590,7 +659,7 @@ export const createApnsRuntime = (deps) => {
590
659
  teamId,
591
660
  p8,
592
661
  bundleId: bundleId || DEFAULT_BUNDLE_ID,
593
- environment: environment === 'production' ? 'production' : 'sandbox',
662
+ environment: (environment || 'production') === 'production' ? 'production' : 'sandbox',
594
663
  };
595
664
  };
596
665
 
@@ -710,7 +779,7 @@ export const createApnsRuntime = (deps) => {
710
779
  liveActivityUnregisterUrl: url.replace(/\/send$/, '/unregister-live-activity-token'),
711
780
  liveActivitySendUrl: url.replace(/\/send$/, '/live-activity'),
712
781
  environment:
713
- (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || 'sandbox').toLowerCase() === 'production'
782
+ (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || 'production').toLowerCase() === 'production'
714
783
  ? 'production'
715
784
  : 'sandbox',
716
785
  });
@@ -857,9 +926,13 @@ export const createApnsRuntime = (deps) => {
857
926
  return { dismissalDate: liveActivityDismissalDate(status, nowMs) };
858
927
  };
859
928
 
860
- const liveActivityRelaySignMessage = ({ ts, tokens, event, contentState, dismissalDate, staleDate }) => (
861
- `${ts}.${[...tokens].sort().join(',')}.${event}.${contentState.status}.${contentState.eventVersion}.${contentState.updatedAt}.${contentState.endedAt ?? ''}.${dismissalDate ?? ''}.${staleDate ?? ''}`
862
- );
929
+ const liveActivityRelaySignMessage = ({ ts, tokens, event, contentState, dismissalDate, staleDate }) => {
930
+ let message = `${ts}.${[...tokens].sort().join(',')}.${event}.${contentState.status}.${contentState.eventVersion}.${contentState.updatedAt}.${contentState.endedAt ?? ''}.${dismissalDate ?? ''}.${staleDate ?? ''}`;
931
+ if (contentState.title !== undefined || contentState.workingCount !== undefined || contentState.items !== undefined) {
932
+ message += `.${contentState.title ?? ''}.${contentState.workingCount ?? ''}.${JSON.stringify(contentState.items ?? [])}`;
933
+ }
934
+ return message;
935
+ };
863
936
 
864
937
  const sendLiveActivityViaRelay = async (deviceTokens, payload, relay) => {
865
938
  if (!Array.isArray(deviceTokens) || deviceTokens.length === 0) return [];
@@ -1039,7 +1112,7 @@ export const createApnsRuntime = (deps) => {
1039
1112
  const seen = new Set();
1040
1113
  for (const record of Object.values(pruneLiveActivityTokensBySession(store.liveActivityTokensBySession, now))) {
1041
1114
  for (const entry of record) {
1042
- if (entry.sessionId !== sessionId || seen.has(entry.token)) continue;
1115
+ if (!liveActivityEntryCoversSession(entry, sessionId) || seen.has(entry.token)) continue;
1043
1116
  seen.add(entry.token);
1044
1117
  entries.push(entry);
1045
1118
  }
@@ -1047,14 +1120,18 @@ export const createApnsRuntime = (deps) => {
1047
1120
  return entries;
1048
1121
  };
1049
1122
 
1050
- const sendLiveActivityEnd = async ({ sessionId, status, eventVersion, endedAt } = {}) => {
1123
+ const sendLiveActivityEnd = async ({ sessionId, status, eventVersion, endedAt, title } = {}) => {
1051
1124
  const trimmedSessionId = typeof sessionId === 'string' ? sessionId.trim() : '';
1052
1125
  if (!isLimitedId(trimmedSessionId, MAX_LIVE_ACTIVITY_ID_CHARS)) return;
1053
- if (!LIVE_ACTIVITY_TERMINAL_STATUSES.has(status)) return;
1126
+ const nextStatus = LIVE_ACTIVITY_TERMINAL_STATUSES.has(status) ? status : undefined;
1127
+ const nextTitle = typeof title === 'string' ? title.trim().slice(0, 80) : '';
1128
+ if (!nextStatus && !nextTitle) return;
1054
1129
 
1055
1130
  const now = Date.now();
1056
1131
  let pendingEntries = [];
1057
1132
  let nextVersion = 0;
1133
+ let event = 'end';
1134
+ let snapshotForSend;
1058
1135
  await persistTokenUpdate((current) => {
1059
1136
  const liveActivityTokensBySession = pruneLiveActivityTokensBySession(
1060
1137
  current.liveActivityTokensBySession,
@@ -1068,31 +1145,73 @@ export const createApnsRuntime = (deps) => {
1068
1145
  if (pendingEntries.length === 0) {
1069
1146
  return { ...current, liveActivityTokensBySession };
1070
1147
  }
1071
- const previous = current.liveActivityEventVersions?.[trimmedSessionId];
1148
+ const previous = current.liveActivityEventVersions?.[trimmedSessionId]
1149
+ ?? current.liveActivityEventVersions?.[LIVE_ACTIVITY_AGGREGATE_SESSION_ID];
1072
1150
  nextVersion = nextLiveActivityEventVersion(now, previous, eventVersion);
1151
+ const endedAtSeconds = toLiveActivityUnixSeconds(endedAt, now);
1152
+ for (const [uiSession, record] of Object.entries(liveActivityTokensBySession)) {
1153
+ liveActivityTokensBySession[uiSession] = record.map((entry) => {
1154
+ if (!liveActivityEntryCoversSession(entry, trimmedSessionId)) return entry;
1155
+ if (!Array.isArray(entry.snapshot) || entry.snapshot.length === 0) return entry;
1156
+ const snapshot = entry.snapshot.map((item) => {
1157
+ if (item.sessionId !== trimmedSessionId) return item;
1158
+ const patched = { ...item };
1159
+ if (nextTitle) patched.title = nextTitle;
1160
+ if (nextStatus) {
1161
+ patched.status = nextStatus;
1162
+ patched.endedAt = endedAtSeconds;
1163
+ }
1164
+ return patched;
1165
+ });
1166
+ snapshotForSend = snapshot;
1167
+ return { ...entry, snapshot };
1168
+ });
1169
+ }
1170
+ const working = Array.isArray(snapshotForSend)
1171
+ ? snapshotForSend.filter((item) => liveActivityItemIsWorking(item.status))
1172
+ : [];
1173
+ event = nextStatus && working.length === 0 ? 'end' : 'update';
1174
+ if (!nextStatus) event = 'update';
1073
1175
  return {
1074
1176
  ...current,
1075
1177
  liveActivityTokensBySession,
1076
1178
  liveActivityEventVersions: {
1077
1179
  ...(current.liveActivityEventVersions || {}),
1078
1180
  [trimmedSessionId]: nextVersion,
1181
+ [LIVE_ACTIVITY_AGGREGATE_SESSION_ID]: nextVersion,
1079
1182
  },
1080
1183
  };
1081
1184
  });
1082
1185
  if (pendingEntries.length === 0) return;
1186
+ if (!nextStatus && !(Array.isArray(snapshotForSend) && snapshotForSend.length > 0)) return;
1083
1187
 
1084
1188
  const updatedAt = now / 1000;
1085
1189
  const endedAtSeconds = toLiveActivityUnixSeconds(endedAt, now);
1086
- const contentState = {
1087
- status,
1088
- eventVersion: nextVersion,
1089
- updatedAt,
1090
- endedAt: endedAtSeconds,
1091
- };
1190
+ const aggregateStatus = event === 'end'
1191
+ ? (nextStatus || 'complete')
1192
+ : (Array.isArray(snapshotForSend)
1193
+ ? (snapshotForSend.some((item) => liveActivityItemIsWorking(item.status))
1194
+ ? (snapshotForSend.find((item) => liveActivityItemIsWorking(item.status))?.status || 'working')
1195
+ : (nextStatus || 'working'))
1196
+ : (nextStatus || 'working'));
1197
+ const contentState = Array.isArray(snapshotForSend) && snapshotForSend.length > 0
1198
+ ? contentStateFromSnapshot(
1199
+ snapshotForSend,
1200
+ aggregateStatus,
1201
+ nextVersion,
1202
+ updatedAt,
1203
+ event === 'end' ? endedAtSeconds : undefined,
1204
+ )
1205
+ : {
1206
+ status: nextStatus || 'complete',
1207
+ eventVersion: nextVersion,
1208
+ updatedAt,
1209
+ ...(event === 'end' ? { endedAt: endedAtSeconds } : {}),
1210
+ };
1092
1211
  const payload = {
1093
- event: 'end',
1212
+ event,
1094
1213
  contentState,
1095
- ...liveActivityPushDates('end', status, updatedAt, now),
1214
+ ...liveActivityPushDates(event, contentState.status, updatedAt, now),
1096
1215
  };
1097
1216
 
1098
1217
  const tokens = pendingEntries.map((entry) => entry.token);
@@ -1121,7 +1240,7 @@ export const createApnsRuntime = (deps) => {
1121
1240
  return;
1122
1241
  }
1123
1242
 
1124
- if (accepted.length > 0) {
1243
+ if (event === 'end' && accepted.length > 0) {
1125
1244
  await removeLiveActivityTokens(accepted);
1126
1245
  }
1127
1246
  };
@@ -115,6 +115,25 @@ describe('apns runtime bundle ID', () => {
115
115
  });
116
116
  });
117
117
 
118
+ describe('apns runtime APNs environment', () => {
119
+ it('defaults resolveApnsConfig environment to production when env and settings omit it', async () => {
120
+ process.env.OPENCHAMBER_APNS_KEY_ID = 'KEY123';
121
+ process.env.OPENCHAMBER_APNS_TEAM_ID = 'TEAM123';
122
+ process.env.OPENCHAMBER_APNS_P8 = P8;
123
+ const runtime = createApnsRuntime(makeDeps());
124
+ await expect(runtime.resolveApnsConfig()).resolves.toMatchObject({ environment: 'production' });
125
+ });
126
+
127
+ it('uses sandbox when OPENCHAMBER_APNS_ENVIRONMENT=sandbox', async () => {
128
+ process.env.OPENCHAMBER_APNS_KEY_ID = 'KEY123';
129
+ process.env.OPENCHAMBER_APNS_TEAM_ID = 'TEAM123';
130
+ process.env.OPENCHAMBER_APNS_P8 = P8;
131
+ process.env.OPENCHAMBER_APNS_ENVIRONMENT = 'sandbox';
132
+ const runtime = createApnsRuntime(makeDeps());
133
+ await expect(runtime.resolveApnsConfig()).resolves.toMatchObject({ environment: 'sandbox' });
134
+ });
135
+ });
136
+
118
137
  describe('apns runtime relay mode (default)', () => {
119
138
  it('registers tokens (signed) and posts signed generic text, dropping dead tokens', async () => {
120
139
  const fetchMock = vi.fn(async (url) =>
@@ -618,9 +637,9 @@ describe('apns runtime push relay derivation and re-register', () => {
618
637
  expect(sendBodies).toHaveLength(3);
619
638
 
620
639
  const byTitle = Object.fromEntries(sendBodies.map((body) => [body.title, body]));
621
- expect(byTitle['Agent response is ready']?.tokens).toEqual(['tokenEn']);
622
- expect(byTitle['智能体回复已就绪']?.tokens).toEqual(['tokenZh']);
623
- expect(byTitle['エージェントの応答が準備できました']?.tokens).toEqual(['tokenJa']);
640
+ expect(byTitle['Task completed']?.tokens).toEqual(['tokenEn']);
641
+ expect(byTitle['任务已完成']?.tokens).toEqual(['tokenZh']);
642
+ expect(byTitle['タスク完了']?.tokens).toEqual(['tokenJa']);
624
643
  for (const body of sendBodies) {
625
644
  expect(body.body).toBe('My session');
626
645
  expect(body.badge).toBe(2);
@@ -641,7 +660,7 @@ describe('apns runtime push relay derivation and re-register', () => {
641
660
  await runtime.sendApnsToAllUiSessions({ type: 'permission', sessionName: 'S' });
642
661
 
643
662
  const sent = JSON.parse(fetchMock.mock.calls.find(isSend)[1].body);
644
- expect(sent.title).toBe('Agent needs permission');
663
+ expect(sent.title).toBe('Needs permission');
645
664
  expect(sent.body).toBe('S');
646
665
  });
647
666
  });
@@ -840,7 +859,7 @@ describe('apns live activity routes', () => {
840
859
  body: { token: ' la-token ', activityId: ' act-1 ', sessionId: ' ses_1 ' },
841
860
  }, postRes);
842
861
  expect(postRes.body).toEqual({ ok: true });
843
- expect(addOrUpdateLiveActivityToken).toHaveBeenCalledWith('ui-token', 'la-token', 'act-1', 'ses_1');
862
+ expect(addOrUpdateLiveActivityToken).toHaveBeenCalledWith('ui-token', 'la-token', 'act-1', 'ses_1', undefined);
844
863
 
845
864
  const delRes = createMockRes();
846
865
  await getRoute('DELETE', '/api/push/live-activity-token')({ body: { token: 'la-token' } }, delRes);
@@ -906,6 +925,34 @@ describe('apns live activity relay and direct delivery', () => {
906
925
  }
907
926
  });
908
927
 
928
+ it('updates the aggregate live activity when one covered session completes', async () => {
929
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
930
+ vi.stubGlobal('fetch', fetchMock);
931
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
932
+ const runtime = createApnsRuntime(makeDeps());
933
+ const token = 'a'.repeat(64);
934
+ await runtime.addOrUpdateLiveActivityToken('s1', token, 'act-1', 'live', [
935
+ { sessionId: 'ses_a', title: 'Alpha', status: 'working', startedAt: 100 },
936
+ { sessionId: 'ses_b', title: 'Beta', status: 'working', startedAt: 100 },
937
+ ]);
938
+ fetchMock.mockClear();
939
+ await runtime.sendLiveActivityEnd({ sessionId: 'ses_a', status: 'complete' });
940
+ const sent = JSON.parse(fetchMock.mock.calls.find(isLiveActivitySend)[1].body);
941
+ expect(sent.event).toBe('update');
942
+ expect(sent.contentState.workingCount).toBe(1);
943
+ expect(sent.contentState.items).toEqual([
944
+ expect.objectContaining({ sessionID: 'ses_a', status: 'complete', title: 'Alpha' }),
945
+ expect.objectContaining({ sessionID: 'ses_b', status: 'working', title: 'Beta' }),
946
+ ]);
947
+ expect(sent.staleDate).toBeTypeOf('number');
948
+ expect(sent.dismissalDate).toBeUndefined();
949
+ await verifyRelaySignature(
950
+ sent.publicKeyJwk,
951
+ `${sent.ts}.${[...sent.tokens].sort().join(',')}.${sent.event}.${sent.contentState.status}.${sent.contentState.eventVersion}.${sent.contentState.updatedAt}.${sent.contentState.endedAt ?? ''}.${sent.dismissalDate ?? ''}.${sent.staleDate ?? ''}.${sent.contentState.title ?? ''}.${sent.contentState.workingCount ?? ''}.${JSON.stringify(sent.contentState.items ?? [])}`,
952
+ sent.sig,
953
+ ).then((ok) => expect(ok).toBe(true));
954
+ });
955
+
909
956
  it('uses a 60-minute dismissal for error ends', async () => {
910
957
  const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
911
958
  vi.stubGlobal('fetch', fetchMock);
@@ -921,11 +968,10 @@ describe('apns live activity relay and direct delivery', () => {
921
968
  expect(sent.dismissalDate).toBeLessThanOrEqual(Math.floor(after / 1000) + 60 * 60);
922
969
  });
923
970
 
924
- it('forwards OPENCHAMBER_APNS_ENVIRONMENT=production as env on live-activity relay send', async () => {
971
+ it('defaults live-activity relay env to production when OPENCHAMBER_APNS_ENVIRONMENT is unset', async () => {
925
972
  const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
926
973
  vi.stubGlobal('fetch', fetchMock);
927
974
  process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
928
- process.env.OPENCHAMBER_APNS_ENVIRONMENT = 'production';
929
975
  const runtime = createApnsRuntime(makeDeps());
930
976
  await runtime.addOrUpdateLiveActivityToken('s1', 'la-token', 'act-1', 'ses_1');
931
977
  fetchMock.mockClear();
@@ -934,6 +980,19 @@ describe('apns live activity relay and direct delivery', () => {
934
980
  expect(sent.env).toBe('production');
935
981
  });
936
982
 
983
+ it('forwards OPENCHAMBER_APNS_ENVIRONMENT=sandbox as env on live-activity relay send', async () => {
984
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
985
+ vi.stubGlobal('fetch', fetchMock);
986
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
987
+ process.env.OPENCHAMBER_APNS_ENVIRONMENT = 'sandbox';
988
+ const runtime = createApnsRuntime(makeDeps());
989
+ await runtime.addOrUpdateLiveActivityToken('s1', 'la-token', 'act-1', 'ses_1');
990
+ fetchMock.mockClear();
991
+ await runtime.sendLiveActivityEnd({ sessionId: 'ses_1', status: 'complete' });
992
+ const sent = JSON.parse(fetchMock.mock.calls.find(isLiveActivitySend)[1].body);
993
+ expect(sent.env).toBe('sandbox');
994
+ });
995
+
937
996
  it('keeps tokens after a failed relay end so a later attempt can retry', async () => {
938
997
  const fetchMock = vi.fn(async (url) => {
939
998
  if (String(url).endsWith('/live-activity')) return jsonResponse({ error: true }, 500);