@openchambery/web 1.18.7-beta.2 → 1.18.7-beta.3

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 (133) hide show
  1. package/dist/assets/AgentAvatar-CVTgW_rx.js +1 -0
  2. package/dist/assets/AssistantView-CvQ91w2s.js +1 -0
  3. package/dist/assets/{ChatContainer-BHDNtLuy.js → ChatContainer-CERrtaB-.js} +41 -43
  4. package/dist/assets/ContextPanel-CLz4r1G-.js +285 -0
  5. package/dist/assets/DiagramView-DhV4Zthj.js +1 -0
  6. package/dist/assets/{FadeInOnReveal-BpIMrqet.js → FadeInOnReveal-Cy3sf2M5.js} +1 -1
  7. package/dist/assets/{Icon-C0G6afNV.js → Icon-BLWVqBee.js} +1 -1
  8. package/dist/assets/{MarkdownRendererImpl-B-SOb7Za.js → MarkdownRendererImpl-B4qwUu4q.js} +1 -1
  9. package/dist/assets/{MobileDetailNavigation-C7OjF65p.js → MobileDetailNavigation-CuWgEThi.js} +1 -1
  10. package/dist/assets/{MobileShareBridge-CRRrlX0l.js → MobileShareBridge-C3nay5Sq.js} +2 -2
  11. package/dist/assets/MobileSurface-D7v_ZkYO.js +2 -0
  12. package/dist/assets/{MultiRunWindow-Dx4tkVFN.js → MultiRunWindow-Cq8yyiqm.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-BpxD5W4Z.js → OnboardingScreen-CTNi2zuA.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-Bo7MF_jE.js → OverlayScrollbar-GQgJusKL.js} +1 -1
  15. package/dist/assets/SettingsView-CViKCsxJ.js +25 -0
  16. package/dist/assets/{SettingsWindow-BlKxv7c3.js → SettingsWindow-C6vwy4hH.js} +1 -1
  17. package/dist/assets/{TerminalView-xtUbWmdn.js → TerminalView-C1aX22ka.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-DUYcTYYW.js → ToolOutputDialog-BrOqj1sB.js} +4 -4
  19. package/dist/assets/appThemeRegistry-BbqImxSo.js +2 -0
  20. package/dist/assets/{button-DfF088a8.js → button-CeCkEpvn.js} +1 -1
  21. package/dist/assets/{clipboard-RoOQVnSl.js → clipboard-BXC1vZwE.js} +1 -1
  22. package/dist/assets/{collapsible-CVL-5iEB.js → collapsible-B-aagADh.js} +1 -1
  23. package/dist/assets/{desktopHosts-Dzx3McaD.js → desktopHosts-BrrYPqKq.js} +1 -1
  24. package/dist/assets/{desktopRelayRestore-B9S0Rp5s.js → desktopRelayRestore-DR60DATT.js} +1 -1
  25. package/dist/assets/diagram-Cxma_IKI.js +1 -0
  26. package/dist/assets/dialog-wraJHZQ2.js +1 -0
  27. package/dist/assets/es-DQqFAaWT.js +15 -0
  28. package/dist/assets/fileQueries-BJKLdYos.js +1 -0
  29. package/dist/assets/fr-CNfGRpc1.js +13 -0
  30. package/dist/assets/{gitApi-DsdwefuR.js → gitApi-B4z4eSZV.js} +1 -1
  31. package/dist/assets/{input-Ca2tUhgN.js → input-CyG05w-V.js} +1 -1
  32. package/dist/assets/insertionBoundaries-DCYAK5W1.js +4 -0
  33. package/dist/assets/ja-CQqehnVn.js +15 -0
  34. package/dist/assets/ko-kmwi8UJD.js +15 -0
  35. package/dist/assets/main-A8oe9r34.js +29 -0
  36. package/dist/assets/{main-CKb-YXE6.js → main-BJBKpmw3.js} +2 -2
  37. package/dist/assets/{markdown-worker-ZaHiIw5I.js → markdown-worker-DDCUZkz4.js} +1 -1
  38. package/dist/assets/{markdownTheme-DidS-jC5.js → markdownTheme-H7_7lbuh.js} +1 -1
  39. package/dist/assets/miniChat-BObOyFSV.js +2 -0
  40. package/dist/assets/mobile-Ba390Cmc.js +2 -0
  41. package/dist/assets/multirun-CMDYOrmD.js +4 -0
  42. package/dist/assets/{persistence-aCOlo6ew.js → persistence-DE9RnVcA.js} +2 -2
  43. package/dist/assets/pl-Blv5o2QA.js +15 -0
  44. package/dist/assets/platform-MjT8iqKB.js +2 -0
  45. package/dist/assets/projectMeta-uWBWFG5A.js +3 -0
  46. package/dist/assets/pt-BR-BK74t-Bd.js +15 -0
  47. package/dist/assets/radio-CEXUd4RC.js +1 -0
  48. package/dist/assets/renderElectronMiniChatApp-Bpgb2E78.js +2 -0
  49. package/dist/assets/renderMobileApp-BMwRozEC.js +23 -0
  50. package/dist/assets/runtimeConfig-hHuyauKZ.js +2 -0
  51. package/dist/assets/runtimeEndpointReset-CmMI7Xp1.js +4 -0
  52. package/dist/assets/{shikiHighlight-eMLXb99c.js → shikiHighlight-C-kTQvqd.js} +1 -1
  53. package/dist/assets/{sortable-tabs-strip-CDHNwlp9.js → sortable-tabs-strip-BdbKDDAP.js} +1 -1
  54. package/dist/assets/src-CgFGBE_A.css +2 -0
  55. package/dist/assets/store-UweBeN2y.js +15 -0
  56. package/dist/assets/{terminalApi-B36I-z14.js → terminalApi-Bnx0KvaV.js} +1 -1
  57. package/dist/assets/{tooltip-GC3dg3gO.js → tooltip-B__x-HDJ.js} +1 -1
  58. package/dist/assets/uk-6I-KXy3R.js +15 -0
  59. package/dist/assets/{url-BQHM4bC4.js → url-CTuw7Rjf.js} +1 -1
  60. package/dist/assets/{useAppFontEffects-B9Ozsfua.js → useAppFontEffects-8iAE0Yrj.js} +4 -4
  61. package/dist/assets/{useAssistantUIStore-DxZsjSPQ.js → useAssistantUIStore-D2K7ksGj.js} +1 -1
  62. package/dist/assets/{useDesktopWindowControlsLayout-QxNEOYzY.js → useDesktopWindowControlsLayout--oz1VAPt.js} +1 -1
  63. package/dist/assets/useEffectiveDirectory-DmoqXaa4.js +1 -0
  64. package/dist/assets/{useFontPreferences-D9h4M7x4.js → useFontPreferences-CWZnNRjz.js} +1 -1
  65. package/dist/assets/useMobileNavigationStore-BxbEX-es.js +412 -0
  66. package/dist/assets/{useSessionAutoCleanup-CXqhEUbp.js → useSessionAutoCleanup-BwlJlhY7.js} +1 -1
  67. package/dist/assets/{useUIStore-Wyu09VUF.js → useUIStore-DOxLVeGb.js} +1 -1
  68. package/dist/assets/{useWorkerHighlightedLines-2KOHSbaa.js → useWorkerHighlightedLines-B21D9MOD.js} +1 -1
  69. package/dist/assets/{utils-CJdWZupK.js → utils-C2R0YoC5.js} +1 -1
  70. package/dist/assets/vendor-legendapp-list-WE32lwNk.js +1 -0
  71. package/dist/assets/zh-CN-DwlzS4yK.js +15 -0
  72. package/dist/assets/zh-TW-p0VriyfT.js +15 -0
  73. package/dist/index.html +9 -9
  74. package/dist/mini-chat.html +9 -9
  75. package/dist/mobile.html +9 -9
  76. package/package.json +3 -2
  77. package/server/index.js +19 -6
  78. package/server/lib/notifications/APNS.md +88 -38
  79. package/server/lib/notifications/DOCUMENTATION.md +11 -4
  80. package/server/lib/notifications/apns-runtime.js +793 -78
  81. package/server/lib/notifications/apns-runtime.test.js +922 -0
  82. package/server/lib/notifications/apns-titles.js +212 -0
  83. package/server/lib/notifications/apns-titles.test.js +55 -0
  84. package/server/lib/notifications/routes.js +57 -1
  85. package/server/lib/notifications/runtime.js +20 -17
  86. package/server/lib/notifications/runtime.test.js +108 -0
  87. package/server/lib/opencode/DOCUMENTATION.md +1 -1
  88. package/server/lib/opencode/bootstrap-runtime.js +6 -0
  89. package/server/lib/opencode/bootstrap-runtime.test.js +21 -0
  90. package/server/lib/opencode/routes.js +24 -5
  91. package/server/lib/opencode/routes.test.js +78 -1
  92. package/server/lib/relay/DOCUMENTATION.md +2 -2
  93. package/server/lib/relay/service.js +45 -3
  94. package/server/lib/relay/service.test.js +117 -0
  95. package/server/lib/relay/signing-key.js +1 -1
  96. package/server/lib/scheduled-tasks/DOCUMENTATION.md +27 -2
  97. package/server/lib/scheduled-tasks/runtime.js +271 -3
  98. package/server/lib/scheduled-tasks/runtime.test.js +308 -0
  99. package/server/lib/session-goal/runtime.js +2 -2
  100. package/dist/assets/AgentAvatar-CWn_73qY.js +0 -1
  101. package/dist/assets/AssistantView-BiOO5An0.js +0 -1
  102. package/dist/assets/ContextPanel-BbNBZipF.js +0 -288
  103. package/dist/assets/DiagramView-AMPWVMV0.js +0 -1
  104. package/dist/assets/MobileSurface-DmMgH78x.js +0 -2
  105. package/dist/assets/SettingsView-BDaMrSki.js +0 -25
  106. package/dist/assets/appThemeRegistry-D6qZmouf.js +0 -2
  107. package/dist/assets/dialog-BbHDXPt6.js +0 -1
  108. package/dist/assets/es-BkIvCvwj.js +0 -15
  109. package/dist/assets/fileQueries-_VUJM8AE.js +0 -1
  110. package/dist/assets/fr-tCwQ3odX.js +0 -13
  111. package/dist/assets/insertionBoundaries-M7AXIWfg.js +0 -1
  112. package/dist/assets/ja-C0rD6dwp.js +0 -15
  113. package/dist/assets/ko-CAbtZ462.js +0 -15
  114. package/dist/assets/main-QjOL36Zh.js +0 -29
  115. package/dist/assets/miniChat-C45kGqqH.js +0 -2
  116. package/dist/assets/mobile-DH8nxp1K.js +0 -2
  117. package/dist/assets/multirun-BkKb0iZv.js +0 -4
  118. package/dist/assets/pl-Bb190ba6.js +0 -15
  119. package/dist/assets/platform-DxNH_0Fj.js +0 -2
  120. package/dist/assets/projectMeta-BHtBPaxp.js +0 -3
  121. package/dist/assets/pt-BR-B6p_t3IB.js +0 -15
  122. package/dist/assets/radio-BacnmuU0.js +0 -1
  123. package/dist/assets/renderElectronMiniChatApp-Cn7umSw9.js +0 -2
  124. package/dist/assets/renderMobileApp-DZBR2t3l.js +0 -23
  125. package/dist/assets/runtimeConfig-CSAVS8gd.js +0 -2
  126. package/dist/assets/runtimeEndpointReset-BZUX3z0A.js +0 -4
  127. package/dist/assets/src-DziW4HUU.css +0 -2
  128. package/dist/assets/store-1FOY4G3z.js +0 -15
  129. package/dist/assets/uk-Dy8-6ZEj.js +0 -15
  130. package/dist/assets/useEffectiveDirectory-D3u_oih8.js +0 -1
  131. package/dist/assets/useMobileNavigationStore-CCPTHVO9.js +0 -475
  132. package/dist/assets/zh-CN-gRTE0npc.js +0 -15
  133. package/dist/assets/zh-TW-ClD4BCfW.js +0 -15
@@ -0,0 +1,212 @@
1
+ // Localized scenario titles for native push (APNs / FCM). Keys match UI locales.
2
+ // Titles stay content-free (scenario only); session name is the body separately.
3
+
4
+ export const APNS_DEFAULT_LOCALE = 'en';
5
+
6
+ export const APNS_LOCALES = Object.freeze([
7
+ 'en',
8
+ 'fr',
9
+ 'zh-CN',
10
+ 'zh-TW',
11
+ 'uk',
12
+ 'es',
13
+ 'pt-BR',
14
+ 'ko',
15
+ 'pl',
16
+ 'ja',
17
+ ]);
18
+
19
+ /** @typedef {'ready'|'error'|'question'|'permission'|'goal_complete'|'goal_blocked'|'goal_budget'|'update'} ApnsTitleType */
20
+
21
+ const TITLES = Object.freeze({
22
+ en: Object.freeze({
23
+ ready: 'Agent response is ready',
24
+ error: 'Agent hit an error',
25
+ question: 'Agent needs your input',
26
+ permission: 'Agent needs permission',
27
+ goal_complete: 'Goal complete',
28
+ goal_blocked: 'Goal blocked',
29
+ goal_budget: 'Goal reached its token budget',
30
+ update: 'Agent update',
31
+ session: 'Session',
32
+ }),
33
+ fr: Object.freeze({
34
+ ready: "La réponse de l'agent est prête",
35
+ error: "L'agent a rencontré une erreur",
36
+ question: "L'agent a besoin de votre saisie",
37
+ permission: "L'agent a besoin d'une autorisation",
38
+ goal_complete: 'Objectif terminé',
39
+ goal_blocked: 'Objectif bloqué',
40
+ goal_budget: "L'objectif a atteint son budget de tokens",
41
+ update: "Mise à jour de l'agent",
42
+ session: 'Session',
43
+ }),
44
+ 'zh-CN': Object.freeze({
45
+ ready: '智能体回复已就绪',
46
+ error: '智能体遇到错误',
47
+ question: '智能体需要你的输入',
48
+ permission: '智能体需要权限',
49
+ goal_complete: '目标已完成',
50
+ goal_blocked: '目标受阻',
51
+ goal_budget: '目标已达 token 预算',
52
+ update: '智能体更新',
53
+ session: '会话',
54
+ }),
55
+ 'zh-TW': Object.freeze({
56
+ ready: '智能體回覆已就緒',
57
+ error: '智能體遇到錯誤',
58
+ question: '智能體需要你的輸入',
59
+ permission: '智能體需要權限',
60
+ goal_complete: '目標已完成',
61
+ goal_blocked: '目標受阻',
62
+ goal_budget: '目標已達 token 預算',
63
+ update: '智能體更新',
64
+ session: '工作階段',
65
+ }),
66
+ uk: Object.freeze({
67
+ ready: 'Відповідь агента готова',
68
+ error: 'Агент зіткнувся з помилкою',
69
+ question: 'Агенту потрібне ваше введення',
70
+ permission: 'Агенту потрібен дозвіл',
71
+ goal_complete: 'Цілі досягнуто',
72
+ goal_blocked: 'Цілі заблоковано',
73
+ goal_budget: 'Ціль досягла ліміту токенів',
74
+ update: 'Оновлення агента',
75
+ session: 'Сесія',
76
+ }),
77
+ es: Object.freeze({
78
+ ready: 'La respuesta del agente está lista',
79
+ error: 'El agente encontró un error',
80
+ question: 'El agente necesita tu entrada',
81
+ permission: 'El agente necesita permiso',
82
+ goal_complete: 'Objetivo completado',
83
+ goal_blocked: 'Objetivo bloqueado',
84
+ goal_budget: 'El objetivo alcanzó su presupuesto de tokens',
85
+ update: 'Actualización del agente',
86
+ session: 'Sesión',
87
+ }),
88
+ 'pt-BR': Object.freeze({
89
+ ready: 'A resposta do agente está pronta',
90
+ error: 'O agente encontrou um erro',
91
+ question: 'O agente precisa da sua entrada',
92
+ permission: 'O agente precisa de permissão',
93
+ goal_complete: 'Objetivo concluído',
94
+ goal_blocked: 'Objetivo bloqueado',
95
+ goal_budget: 'O objetivo atingiu o orçamento de tokens',
96
+ update: 'Atualização do agente',
97
+ session: 'Sessão',
98
+ }),
99
+ ko: Object.freeze({
100
+ ready: '에이전트 응답이 준비되었습니다',
101
+ error: '에이전트에서 오류가 발생했습니다',
102
+ question: '에이전트가 입력을 요청합니다',
103
+ permission: '에이전트가 권한이 필요합니다',
104
+ goal_complete: '목표 완료',
105
+ goal_blocked: '목표 차단됨',
106
+ goal_budget: '목표가 토큰 예산에 도달했습니다',
107
+ update: '에이전트 업데이트',
108
+ session: '세션',
109
+ }),
110
+ pl: Object.freeze({
111
+ ready: 'Odpowiedź agenta jest gotowa',
112
+ error: 'Agent napotkał błąd',
113
+ question: 'Agent potrzebuje Twojego wejścia',
114
+ permission: 'Agent potrzebuje uprawnienia',
115
+ goal_complete: 'Cel ukończony',
116
+ goal_blocked: 'Cel zablokowany',
117
+ goal_budget: 'Cel osiągnął budżet tokenów',
118
+ update: 'Aktualizacja agenta',
119
+ session: 'Sesja',
120
+ }),
121
+ ja: Object.freeze({
122
+ ready: 'エージェントの応答が準備できました',
123
+ error: 'エージェントでエラーが発生しました',
124
+ question: 'エージェントが入力を求めています',
125
+ permission: 'エージェントが許可を求めています',
126
+ goal_complete: '目標完了',
127
+ goal_blocked: '目標がブロックされました',
128
+ goal_budget: '目標がトークン予算に達しました',
129
+ update: 'エージェントの更新',
130
+ session: 'セッション',
131
+ }),
132
+ });
133
+
134
+ const matchSupportedLocale = (value) => {
135
+ if (!value || typeof value !== 'string') return undefined;
136
+ const normalized = value.toLowerCase().replace(/_/g, '-');
137
+ if (
138
+ normalized === 'zh-tw'
139
+ || normalized.startsWith('zh-tw-')
140
+ || normalized === 'zh-hk'
141
+ || normalized.startsWith('zh-hk-')
142
+ || normalized === 'zh-mo'
143
+ || normalized.startsWith('zh-mo-')
144
+ || normalized === 'zh-hant'
145
+ || normalized.startsWith('zh-hant-')
146
+ ) {
147
+ return 'zh-TW';
148
+ }
149
+ if (normalized.startsWith('zh')) return 'zh-CN';
150
+ if (normalized.startsWith('en')) return 'en';
151
+ if (normalized === 'fr' || normalized.startsWith('fr-')) return 'fr';
152
+ if (normalized === 'uk' || normalized.startsWith('uk-') || normalized === 'ua' || normalized.startsWith('ua-')) {
153
+ return 'uk';
154
+ }
155
+ if (normalized === 'es' || normalized.startsWith('es-')) return 'es';
156
+ if (normalized === 'pt' || normalized.startsWith('pt-')) return 'pt-BR';
157
+ if (normalized === 'ko' || normalized.startsWith('ko-')) return 'ko';
158
+ if (normalized === 'ja' || normalized.startsWith('ja-')) return 'ja';
159
+ if (normalized === 'pl' || normalized.startsWith('pl-')) return 'pl';
160
+ return undefined;
161
+ };
162
+
163
+ export const normalizeApnsLocale = (value) => matchSupportedLocale(value) ?? APNS_DEFAULT_LOCALE;
164
+
165
+ const titlesFor = (locale) => TITLES[normalizeApnsLocale(locale)] || TITLES[APNS_DEFAULT_LOCALE];
166
+
167
+ /**
168
+ * @param {string|undefined|null} type
169
+ * @param {string|undefined|null} locale
170
+ */
171
+ export const resolveApnsTitle = (type, locale) => {
172
+ const dict = titlesFor(locale);
173
+ if (typeof type === 'string' && Object.prototype.hasOwnProperty.call(dict, type) && type !== 'session') {
174
+ return dict[type];
175
+ }
176
+ return dict.update;
177
+ };
178
+
179
+ /**
180
+ * @param {string|undefined|null} locale
181
+ */
182
+ export const resolveApnsSessionFallback = (locale) => titlesFor(locale).session;
183
+
184
+ /**
185
+ * Localize a generic native-push payload for one device locale.
186
+ * `payload.type` selects the scenario title; `payload.sessionName` (or non-empty body) is the body.
187
+ * Callers that already pass a fixed `title`/`body` (tests / legacy) keep that text when `type` is absent.
188
+ *
189
+ * @param {{ type?: string, title?: string, body?: string, sessionName?: string, badge?: number, tag?: string, data?: object }} payload
190
+ * @param {string|undefined|null} locale
191
+ */
192
+ export const localizeApnsPayload = (payload, locale) => {
193
+ const type = typeof payload?.type === 'string' ? payload.type : undefined;
194
+ const sessionName = typeof payload?.sessionName === 'string' && payload.sessionName.trim().length > 0
195
+ ? payload.sessionName.trim()
196
+ : '';
197
+ const legacyBody = typeof payload?.body === 'string' && payload.body.trim().length > 0
198
+ ? payload.body.trim()
199
+ : '';
200
+ const title = type
201
+ ? resolveApnsTitle(type, locale)
202
+ : (typeof payload?.title === 'string' && payload.title.length > 0
203
+ ? payload.title
204
+ : resolveApnsTitle('update', locale));
205
+ return {
206
+ title,
207
+ body: sessionName || legacyBody || resolveApnsSessionFallback(locale),
208
+ badge: payload?.badge,
209
+ tag: payload?.tag,
210
+ data: payload?.data,
211
+ };
212
+ };
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ APNS_DEFAULT_LOCALE,
5
+ APNS_LOCALES,
6
+ localizeApnsPayload,
7
+ normalizeApnsLocale,
8
+ resolveApnsSessionFallback,
9
+ resolveApnsTitle,
10
+ } from './apns-titles.js';
11
+
12
+ describe('apns-titles', () => {
13
+ it('normalizes BCP-47 tags onto supported app locales', () => {
14
+ expect(normalizeApnsLocale('zh-Hans-CN')).toBe('zh-CN');
15
+ expect(normalizeApnsLocale('zh_TW')).toBe('zh-TW');
16
+ expect(normalizeApnsLocale('pt-PT')).toBe('pt-BR');
17
+ expect(normalizeApnsLocale('ja-JP')).toBe('ja');
18
+ expect(normalizeApnsLocale('nope')).toBe(APNS_DEFAULT_LOCALE);
19
+ expect(normalizeApnsLocale(undefined)).toBe(APNS_DEFAULT_LOCALE);
20
+ });
21
+
22
+ it('resolves every scenario type for every supported locale', () => {
23
+ const types = ['ready', 'error', 'question', 'permission', 'goal_complete', 'goal_blocked', 'goal_budget', 'update'];
24
+ for (const locale of APNS_LOCALES) {
25
+ for (const type of types) {
26
+ const title = resolveApnsTitle(type, locale);
27
+ expect(typeof title).toBe('string');
28
+ expect(title.length).toBeGreaterThan(0);
29
+ }
30
+ expect(resolveApnsSessionFallback(locale).length).toBeGreaterThan(0);
31
+ }
32
+ });
33
+
34
+ it('localizes payload title by type and keeps session name as body', () => {
35
+ expect(localizeApnsPayload({ type: 'question', sessionName: 'Build fix' }, 'zh-CN')).toEqual({
36
+ title: '智能体需要你的输入',
37
+ body: 'Build fix',
38
+ badge: undefined,
39
+ tag: undefined,
40
+ data: undefined,
41
+ });
42
+ expect(localizeApnsPayload({ type: 'ready' }, 'fr').body).toBe('Session');
43
+ expect(localizeApnsPayload({ type: 'ready' }, 'zh-CN').body).toBe('会话');
44
+ });
45
+
46
+ it('keeps explicit legacy title/body when type is absent', () => {
47
+ expect(localizeApnsPayload({ title: 'Custom', body: 'Body' }, 'ja')).toEqual({
48
+ title: 'Custom',
49
+ body: 'Body',
50
+ badge: undefined,
51
+ tag: undefined,
52
+ data: undefined,
53
+ });
54
+ });
55
+ });
@@ -22,6 +22,17 @@ const parsePushUnsubscribeBody = (body) => {
22
22
  return { endpoint: endpoint.trim() };
23
23
  };
24
24
 
25
+ const MAX_LIVE_ACTIVITY_TOKEN_LENGTH = 512;
26
+ const MAX_LIVE_ACTIVITY_ACTIVITY_ID_LENGTH = 128;
27
+ const MAX_LIVE_ACTIVITY_SESSION_ID_LENGTH = 128;
28
+
29
+ const parseLimitedPushId = (value, maxLength) => {
30
+ if (typeof value !== 'string') return '';
31
+ const trimmed = value.trim();
32
+ if (trimmed.length === 0 || trimmed.length > maxLength) return '';
33
+ return trimmed;
34
+ };
35
+
25
36
  export const NOTIFICATION_SSE_HEARTBEAT_INTERVAL_MS = 20_000;
26
37
 
27
38
  export const registerNotificationRoutes = (app, dependencies) => {
@@ -37,6 +48,8 @@ export const registerNotificationRoutes = (app, dependencies) => {
37
48
  removePushSubscription,
38
49
  addOrUpdateApnsToken,
39
50
  removeApnsToken,
51
+ addOrUpdateLiveActivityToken,
52
+ removeLiveActivityToken,
40
53
  updateUiVisibility,
41
54
  clearPendingPushBadge,
42
55
  isUiVisible,
@@ -162,8 +175,9 @@ export const registerNotificationRoutes = (app, dependencies) => {
162
175
  }
163
176
 
164
177
  const platform = req.body?.platform === 'android' ? 'android' : 'ios';
178
+ const locale = typeof req.body?.locale === 'string' ? req.body.locale.trim() : undefined;
165
179
  if (typeof addOrUpdateApnsToken === 'function') {
166
- await addOrUpdateApnsToken(uiToken, deviceToken, req.headers['user-agent'], platform);
180
+ await addOrUpdateApnsToken(uiToken, deviceToken, req.headers['user-agent'], platform, locale);
167
181
  }
168
182
  return res.json({ ok: true });
169
183
  });
@@ -187,6 +201,48 @@ export const registerNotificationRoutes = (app, dependencies) => {
187
201
  return res.json({ ok: true });
188
202
  });
189
203
 
204
+ app.post('/api/push/live-activity-token', async (req, res) => {
205
+ await ensureSessionWatcher();
206
+
207
+ const uiToken = uiAuthController?.ensureSessionToken
208
+ ? await uiAuthController.ensureSessionToken(req, res)
209
+ : getUiSessionTokenFromRequest(req);
210
+ if (!uiToken) {
211
+ return res.status(401).json({ error: 'UI session missing' });
212
+ }
213
+
214
+ const token = parseLimitedPushId(req.body?.token, MAX_LIVE_ACTIVITY_TOKEN_LENGTH);
215
+ const activityId = parseLimitedPushId(req.body?.activityId, MAX_LIVE_ACTIVITY_ACTIVITY_ID_LENGTH);
216
+ const sessionId = parseLimitedPushId(req.body?.sessionId, MAX_LIVE_ACTIVITY_SESSION_ID_LENGTH);
217
+ if (!token || !activityId || !sessionId) {
218
+ return res.status(400).json({ error: 'Invalid body' });
219
+ }
220
+
221
+ if (typeof addOrUpdateLiveActivityToken === 'function') {
222
+ await addOrUpdateLiveActivityToken(uiToken, token, activityId, sessionId);
223
+ }
224
+ return res.json({ ok: true });
225
+ });
226
+
227
+ app.delete('/api/push/live-activity-token', async (req, res) => {
228
+ const uiToken = uiAuthController?.ensureSessionToken
229
+ ? await uiAuthController.ensureSessionToken(req, res)
230
+ : getUiSessionTokenFromRequest(req);
231
+ if (!uiToken) {
232
+ return res.status(401).json({ error: 'UI session missing' });
233
+ }
234
+
235
+ const token = parseLimitedPushId(req.body?.token, MAX_LIVE_ACTIVITY_TOKEN_LENGTH);
236
+ if (!token) {
237
+ return res.status(400).json({ error: 'Invalid body' });
238
+ }
239
+
240
+ if (typeof removeLiveActivityToken === 'function') {
241
+ await removeLiveActivityToken(uiToken, token);
242
+ }
243
+ return res.json({ ok: true });
244
+ });
245
+
190
246
  app.post('/api/push/visibility', async (req, res) => {
191
247
  const uiToken = uiAuthController?.ensureSessionToken
192
248
  ? await uiAuthController.ensureSessionToken(req, res)
@@ -11,6 +11,7 @@ export const createNotificationTriggerRuntime = (deps) => {
11
11
  broadcastUiNotification,
12
12
  sendPushToAllUiSessions,
13
13
  sendApnsToAllUiSessions,
14
+ sendLiveActivityEnd,
14
15
  isAnyInteractiveClientVisible,
15
16
  buildOpenCodeUrl,
16
17
  getOpenCodeAuthHeaders,
@@ -49,24 +50,16 @@ export const createNotificationTriggerRuntime = (deps) => {
49
50
 
50
51
  // Generic notification for native push (per the mobile design): a fixed, scenario-based
51
52
  // title + the session name as the body. No model/project/message content crosses the relay.
52
- const APNS_TITLE_BY_TYPE = {
53
- ready: 'Agent response is ready',
54
- error: 'Agent hit an error',
55
- question: 'Agent needs your input',
56
- permission: 'Agent needs permission',
57
- goal_complete: 'Goal complete',
58
- goal_blocked: 'Goal blocked',
59
- goal_budget: 'Goal reached its token budget',
60
- };
61
-
53
+ // Title strings are localized per device token locale inside sendApnsToAllUiSessions.
62
54
  const toApnsGenericPayload = (payload) => {
63
55
  const data = payload?.data && typeof payload.data === 'object' ? payload.data : {};
64
56
  const sessionName = typeof data.sessionName === 'string' && data.sessionName.trim().length > 0
65
57
  ? data.sessionName.trim()
66
- : 'Session';
58
+ : '';
59
+ const type = typeof data.type === 'string' ? data.type : undefined;
67
60
  return {
68
- title: APNS_TITLE_BY_TYPE[data.type] || 'Agent update',
69
- body: sessionName,
61
+ type,
62
+ sessionName,
70
63
  badge: trackPushAndCountBadge(typeof payload?.tag === 'string' ? payload.tag : undefined),
71
64
  tag: payload?.tag,
72
65
  // sessionId is forwarded so a tapped push can deep-link; it is an opaque id, not content.
@@ -377,14 +370,24 @@ export const createNotificationTriggerRuntime = (deps) => {
377
370
 
378
371
  if (payload.type === 'message.updated') {
379
372
  const info = payload.properties?.info;
380
- if (
373
+ const isAssistantTerminal = Boolean(
381
374
  sessionId
382
375
  && info?.role === 'assistant'
383
- && (info?.finish === 'stop' || info?.finish === 'error')
384
- && await shouldSkipSystemSessionNotification(sessionId, notificationDirectory)
385
- ) {
376
+ && (info?.finish === 'stop' || info?.finish === 'error'),
377
+ );
378
+ if (isAssistantTerminal && await shouldSkipSystemSessionNotification(sessionId, notificationDirectory)) {
386
379
  return;
387
380
  }
381
+ if (isAssistantTerminal) {
382
+ try {
383
+ await sendLiveActivityEnd?.({
384
+ sessionId,
385
+ status: info.finish === 'error' ? 'error' : 'complete',
386
+ });
387
+ } catch (error) {
388
+ console.warn('[Live Activity] end failed:', error?.message ?? error);
389
+ }
390
+ }
388
391
  if (info?.role === 'assistant' && info?.finish === 'stop' && sessionId) {
389
392
  const settings = await readSettingsFromDisk();
390
393
 
@@ -14,6 +14,7 @@ const createRuntime = (overrides = {}) => {
14
14
  const broadcastUiNotification = vi.fn();
15
15
  const sendPushToAllUiSessions = vi.fn(async () => {});
16
16
  const sendApnsToAllUiSessions = vi.fn(async () => {});
17
+ const sendLiveActivityEnd = vi.fn(async () => {});
17
18
  const runtime = createNotificationTriggerRuntime({
18
19
  readSettingsFromDisk: vi.fn(async () => defaultSettings),
19
20
  prepareNotificationLastMessage: vi.fn(async ({ message }) => message || ''),
@@ -26,6 +27,7 @@ const createRuntime = (overrides = {}) => {
26
27
  broadcastUiNotification,
27
28
  sendPushToAllUiSessions,
28
29
  sendApnsToAllUiSessions,
30
+ sendLiveActivityEnd,
29
31
  isAnyInteractiveClientVisible: () => false,
30
32
  buildOpenCodeUrl: (path) => `http://opencode.test${path}`,
31
33
  getOpenCodeAuthHeaders: () => ({}),
@@ -38,6 +40,7 @@ const createRuntime = (overrides = {}) => {
38
40
  broadcastUiNotification,
39
41
  sendPushToAllUiSessions,
40
42
  sendApnsToAllUiSessions,
43
+ sendLiveActivityEnd,
41
44
  };
42
45
  };
43
46
 
@@ -174,3 +177,108 @@ describe('notification trigger runtime smallModel suppression', () => {
174
177
  expect(sendPushToAllUiSessions).toHaveBeenCalledTimes(1);
175
178
  });
176
179
  });
180
+
181
+ const rootSessionResponse = () => jsonResponse({
182
+ id: 'ses_root',
183
+ parentID: null,
184
+ title: 'Ordinary',
185
+ metadata: {},
186
+ });
187
+
188
+ const completionPayload = (sessionId = 'ses_root', finish = 'stop') => ({
189
+ type: 'message.updated',
190
+ properties: {
191
+ directory: '/repo',
192
+ info: {
193
+ id: 'msg_2',
194
+ sessionID: sessionId,
195
+ role: 'assistant',
196
+ finish,
197
+ mode: 'build',
198
+ modelID: 'gpt',
199
+ },
200
+ },
201
+ });
202
+
203
+ describe('notification trigger live activity end', () => {
204
+ it('ends the live activity on top-level completion and error', async () => {
205
+ vi.stubGlobal('fetch', vi.fn(async () => rootSessionResponse()));
206
+ const { runtime, sendLiveActivityEnd } = createRuntime();
207
+ await runtime.maybeSendPushForTrigger(completionPayload('ses_root', 'stop'));
208
+ await runtime.maybeSendPushForTrigger({
209
+ type: 'session.error',
210
+ properties: { directory: '/repo', sessionID: 'ses_err', error: 'boom' },
211
+ });
212
+ expect(sendLiveActivityEnd).toHaveBeenNthCalledWith(1, { sessionId: 'ses_root', status: 'complete' });
213
+ expect(sendLiveActivityEnd).toHaveBeenNthCalledWith(2, { sessionId: 'ses_err', status: 'error' });
214
+ });
215
+
216
+ it('still ends the live activity when ordinary notifications are disabled', async () => {
217
+ vi.stubGlobal('fetch', vi.fn(async () => rootSessionResponse()));
218
+ const { runtime, sendLiveActivityEnd, sendPushToAllUiSessions, sendApnsToAllUiSessions } = createRuntime({
219
+ readSettingsFromDisk: vi.fn(async () => ({ ...defaultSettings, notifyOnCompletion: false })),
220
+ });
221
+ await runtime.maybeSendPushForTrigger(completionPayload());
222
+ expect(sendLiveActivityEnd).toHaveBeenCalledWith({ sessionId: 'ses_root', status: 'complete' });
223
+ expect(sendPushToAllUiSessions).not.toHaveBeenCalled();
224
+ expect(sendApnsToAllUiSessions).not.toHaveBeenCalled();
225
+ });
226
+
227
+ it('ends the live activity even when an interactive client is visible', async () => {
228
+ vi.stubGlobal('fetch', vi.fn(async () => rootSessionResponse()));
229
+ const { runtime, sendLiveActivityEnd, sendApnsToAllUiSessions } = createRuntime({
230
+ isAnyInteractiveClientVisible: () => true,
231
+ });
232
+ await runtime.maybeSendPushForTrigger(completionPayload());
233
+ expect(sendLiveActivityEnd).toHaveBeenCalledWith({ sessionId: 'ses_root', status: 'complete' });
234
+ expect(sendApnsToAllUiSessions).not.toHaveBeenCalled();
235
+ });
236
+
237
+ it('suppresses live activity end for child and small-model sessions', async () => {
238
+ const fetchMock = vi.fn(async (url) => {
239
+ if (String(url).includes('ses_child')) {
240
+ return jsonResponse({ id: 'ses_child', parentID: 'ses_parent', metadata: {} });
241
+ }
242
+ return jsonResponse({
243
+ id: 'ses_small',
244
+ parentID: null,
245
+ metadata: { openchamber: { smallModel: { purpose: 'session-title' } } },
246
+ });
247
+ });
248
+ vi.stubGlobal('fetch', fetchMock);
249
+ const { runtime, sendLiveActivityEnd, sendPushToAllUiSessions } = createRuntime();
250
+ await runtime.maybeSendPushForTrigger(completionPayload('ses_child'));
251
+ await runtime.maybeSendPushForTrigger(completionPayload('ses_small', 'error'));
252
+ expect(sendLiveActivityEnd).not.toHaveBeenCalled();
253
+ expect(sendPushToAllUiSessions).not.toHaveBeenCalled();
254
+ });
255
+
256
+ it('isolates live activity end failure from ordinary push', async () => {
257
+ vi.stubGlobal('fetch', vi.fn(async () => rootSessionResponse()));
258
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
259
+ const sendLiveActivityEnd = vi.fn(async () => {
260
+ throw new Error('live-activity failed');
261
+ });
262
+ const { runtime, sendPushToAllUiSessions, sendApnsToAllUiSessions } = createRuntime({
263
+ sendLiveActivityEnd,
264
+ });
265
+ try {
266
+ await runtime.maybeSendPushForTrigger(completionPayload());
267
+ expect(sendLiveActivityEnd).toHaveBeenCalledTimes(1);
268
+ expect(sendPushToAllUiSessions).toHaveBeenCalledTimes(1);
269
+ expect(sendApnsToAllUiSessions).toHaveBeenCalledTimes(1);
270
+ } finally {
271
+ warn.mockRestore();
272
+ }
273
+ });
274
+
275
+ it('repeats terminal delivery on duplicate completion events', async () => {
276
+ vi.stubGlobal('fetch', vi.fn(async () => rootSessionResponse()));
277
+ const { runtime, sendLiveActivityEnd } = createRuntime();
278
+ await runtime.maybeSendPushForTrigger(completionPayload());
279
+ await runtime.maybeSendPushForTrigger(completionPayload());
280
+ expect(sendLiveActivityEnd).toHaveBeenCalledTimes(2);
281
+ expect(sendLiveActivityEnd).toHaveBeenNthCalledWith(1, { sessionId: 'ses_root', status: 'complete' });
282
+ expect(sendLiveActivityEnd).toHaveBeenNthCalledWith(2, { sessionId: 'ses_root', status: 'complete' });
283
+ });
284
+ });
@@ -83,7 +83,7 @@ This module provides OpenCode server integration utilities for the web server ru
83
83
  - `GET /api/config/opencode-resolution`
84
84
  - `GET /api/behavior/agents-md`: returns an authoritative empty document only when the file is absent (`ENOENT`); permission and I/O failures return HTTP 500.
85
85
  - `PUT /api/behavior/agents-md`
86
- - `POST /api/opencode/upgrade` (proxies OpenCode upgrade, then restarts managed OpenCode so the new binary is active)
86
+ - `POST /api/opencode/upgrade` (proxies OpenCode `POST /global/upgrade` with a required semantic version `target`, then restarts managed OpenCode so the new binary is active)
87
87
  - `GET /api/opencode/upgrade-status`
88
88
  - `POST /api/opencode/directory`
89
89
  - `GET /api/provider/:providerId/source`
@@ -43,6 +43,9 @@ export const createBootstrapRuntime = (dependencies) => {
43
43
  removePushSubscription,
44
44
  addOrUpdateApnsToken,
45
45
  removeApnsToken,
46
+ addOrUpdateLiveActivityToken,
47
+ removeLiveActivityToken,
48
+ sendLiveActivityEnd,
46
49
  updateUiVisibility,
47
50
  clearPendingPushBadge,
48
51
  isUiVisible,
@@ -119,6 +122,9 @@ export const createBootstrapRuntime = (dependencies) => {
119
122
  removePushSubscription,
120
123
  addOrUpdateApnsToken,
121
124
  removeApnsToken,
125
+ addOrUpdateLiveActivityToken,
126
+ removeLiveActivityToken,
127
+ sendLiveActivityEnd,
122
128
  updateUiVisibility,
123
129
  clearPendingPushBadge,
124
130
  isUiVisible,
@@ -16,4 +16,25 @@ describe('bootstrap runtime', () => {
16
16
  });
17
17
  expect(registerAuthAndAccessRoutes).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ authorizeManagedOpenCodeBridgeRequest }));
18
18
  });
19
+
20
+ it('passes live activity register, unregister, and end into notification route registration', () => {
21
+ const registerNotificationRoutes = vi.fn();
22
+ const runtime = createBootstrapRuntime({
23
+ createUiAuth: () => ({ enabled: false }), registerServerStatusRoutes: vi.fn(), registerCommonRequestMiddleware: vi.fn(), registerAuthAndAccessRoutes: vi.fn(),
24
+ registerTtsRoutes: vi.fn(), registerNotificationRoutes, registerOpenChamberRoutes: vi.fn(), express: {},
25
+ });
26
+ const addOrUpdateLiveActivityToken = vi.fn();
27
+ const removeLiveActivityToken = vi.fn();
28
+ const sendLiveActivityEnd = vi.fn();
29
+ runtime.setupBaseRoutes({}, {
30
+ process: {}, openchamberVersion: 'test', runtimeName: 'test', serverStartedAt: 0, gracefulShutdown: vi.fn(), getHealthSnapshot: vi.fn(), verboseRequestLogs: false, uiPassword: null,
31
+ tunnelAuthController: {}, remoteClientAuthRuntime: {}, clientPairingRuntime: {}, getRelayPairingCandidate: vi.fn(), reconcileRelay: vi.fn(), getPairingTransports: vi.fn(), getDirectCandidateUrls: vi.fn(), getServerId: vi.fn(), getServerLabel: vi.fn(), readSettingsFromDiskMigrated: vi.fn(), normalizeTunnelSessionTtlMs: vi.fn(), authorizeManagedOpenCodeBridgeRequest: vi.fn(),
32
+ sayTTSCapability: vi.fn(), ensurePushInitialized: vi.fn(), ensureGlobalWatcherStarted: vi.fn(), getOrCreateVapidKeys: vi.fn(), getUiSessionTokenFromRequest: vi.fn(), writeSettingsToDisk: vi.fn(), addOrUpdatePushSubscription: vi.fn(), removePushSubscription: vi.fn(), addOrUpdateApnsToken: vi.fn(), removeApnsToken: vi.fn(), addOrUpdateLiveActivityToken, removeLiveActivityToken, sendLiveActivityEnd, updateUiVisibility: vi.fn(), clearPendingPushBadge: vi.fn(), isUiVisible: vi.fn(), getUiNotificationClients: vi.fn(), writeSseEvent: vi.fn(), sessionRuntime: { getSessionActivitySnapshot: vi.fn(), getSessionStateSnapshot: vi.fn(), getSessionAttentionSnapshot: vi.fn(), getSessionState: vi.fn(), getSessionAttentionState: vi.fn(), markSessionViewed: vi.fn(), markSessionUnviewed: vi.fn(), markUserMessageSent: vi.fn() }, setPushInitialized: vi.fn(), fs: {}, os: {}, path: {}, server: {}, __dirname: '', openchamberDataDir: '', fetchFreeZenModels: vi.fn(), getCachedZenModels: vi.fn(), setAutoAcceptSession: vi.fn(), sessionIndexService: {}, sessionIndexSyncRuntime: {},
33
+ });
34
+ expect(registerNotificationRoutes).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
35
+ addOrUpdateLiveActivityToken,
36
+ removeLiveActivityToken,
37
+ sendLiveActivityEnd,
38
+ }));
39
+ });
19
40
  });
@@ -133,6 +133,20 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
133
133
  }
134
134
  };
135
135
 
136
+ const normalizeOpenCodeUpgradeTarget = (value) => {
137
+ if (typeof value !== 'string') return '';
138
+ return value.trim().replace(/^v/i, '');
139
+ };
140
+
141
+ const readOpenCodeUpgradeError = (payload, fallback) => {
142
+ if (typeof payload?.error === 'string' && payload.error.trim()) return payload.error.trim();
143
+ if (typeof payload?.data?.message === 'string' && payload.data.message.trim()) {
144
+ return payload.data.message.trim();
145
+ }
146
+ if (typeof payload?.message === 'string' && payload.message.trim()) return payload.message.trim();
147
+ return fallback;
148
+ };
149
+
136
150
  app.get('/api/config/settings', async (req, res) => {
137
151
  try {
138
152
  const settings = await readSettingsFromDiskMigrated();
@@ -175,9 +189,14 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
175
189
  });
176
190
  }
177
191
 
178
- const target = typeof req.body?.target === 'string' && req.body.target.trim().length > 0
179
- ? req.body.target.trim()
180
- : undefined;
192
+ const target = normalizeOpenCodeUpgradeTarget(req.body?.target);
193
+ if (!target) {
194
+ return res.status(400).json({
195
+ success: false,
196
+ error: 'OpenCode upgrade requires a semantic version target',
197
+ });
198
+ }
199
+
181
200
  const response = await fetch(buildOpenCodeUrl('/global/upgrade', ''), {
182
201
  method: 'POST',
183
202
  headers: {
@@ -185,13 +204,13 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
185
204
  Accept: 'application/json',
186
205
  ...getOpenCodeAuthHeaders(),
187
206
  },
188
- body: JSON.stringify(target ? { target } : {}),
207
+ body: JSON.stringify({ target }),
189
208
  });
190
209
  const payload = await response.json().catch(() => null);
191
210
  if (!response.ok) {
192
211
  return res.status(response.status).json({
193
212
  success: false,
194
- error: payload?.error || response.statusText || 'Failed to upgrade OpenCode',
213
+ error: readOpenCodeUpgradeError(payload, response.statusText || 'Failed to upgrade OpenCode'),
195
214
  });
196
215
  }
197
216