@openchambery/web 1.19.12 → 1.19.13-beta.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.
Files changed (115) hide show
  1. package/dist/assets/{AgentAvatar-CX0a1rKE.js → AgentAvatar-CiQIIM0d.js} +1 -1
  2. package/dist/assets/AssistantView-YmeyxehG.js +2 -0
  3. package/dist/assets/{DiagramView-BeUgp_Y0.js → DiagramView-BpZb-G9P.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-CTlNE4Hg.js → FadeInOnReveal-nf6-ZCrs.js} +1 -1
  5. package/dist/assets/{Icon-BYM02QyU.js → Icon-CeKHYyKZ.js} +1 -1
  6. package/dist/assets/MarkdownRenderer-wpeGdciL.js +2 -0
  7. package/dist/assets/{MarkdownRendererImpl-DB4Q3kGa.js → MarkdownRendererImpl-Peu0GVSQ.js} +1 -1
  8. package/dist/assets/{MarkstreamRendererImpl-BFdJzxia.js → MarkstreamRendererImpl-B-odd2Gz.js} +1 -1
  9. package/dist/assets/MobileDetailNavigation-CtYke_1C.js +1 -0
  10. package/dist/assets/{MobileShareBridge-UiOLx0-2.js → MobileShareBridge-DIfOl8EZ.js} +3 -3
  11. package/dist/assets/MobileSurface-_KoX75Ff.js +2 -0
  12. package/dist/assets/{MultiRunWindow-C3p3lj3U.js → MultiRunWindow-BT5Dwgzu.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-DS2nugLX.js → OnboardingScreen-BPusUgFG.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-KUpvYHdv.js → OverlayScrollbar-DUghqFGg.js} +1 -1
  15. package/dist/assets/{SettingsView-BXfKsz0J.js → SettingsView-BwQpbIZl.js} +11 -11
  16. package/dist/assets/{SettingsWindow-8IDuPZA5.js → SettingsWindow-DfEGfyr8.js} +1 -1
  17. package/dist/assets/{TerminalView-BiKCueth.js → TerminalView-8402vpcO.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-BC2F_Fdl.js → ToolOutputDialog-B5RdkhsG.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-B87OLIgc.js → appThemeRegistry-CIZ-shMD.js} +1 -1
  20. package/dist/assets/{button-C0cth9tl.js → button-DFPOdZ0g.js} +1 -1
  21. package/dist/assets/{clipboard-DJ4nQEuY.js → clipboard-C05anBwU.js} +1 -1
  22. package/dist/assets/{collapsible-DFT9cE80.js → collapsible-oGbEvbe1.js} +1 -1
  23. package/dist/assets/{context-menu-CAp74NfV.js → context-menu-DGz4_2hs.js} +1 -1
  24. package/dist/assets/{desktopHosts-Dc2PS66M.js → desktopHosts-BVJXQq1S.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-T9y0orqa.js → desktopRelayRestore-RDfuj1M7.js} +1 -1
  26. package/dist/assets/{diagram-BryzMuta.js → diagram-Csk5GpDC.js} +1 -1
  27. package/dist/assets/{dialog-CgbV5ljg.js → dialog-rrqB-H6q.js} +1 -1
  28. package/dist/assets/{es-BHqnSjoZ.js → es-FE0sJ9EC.js} +1 -1
  29. package/dist/assets/{fileQueries-BY-9OWzp.js → fileQueries-BcdWOPlD.js} +1 -1
  30. package/dist/assets/{fr-BfrlJY9i.js → fr-DNDCSfGt.js} +1 -1
  31. package/dist/assets/{gitApi-tTtHQVA3.js → gitApi-_P0FsoXe.js} +1 -1
  32. package/dist/assets/{input-DFLpqjGR.js → input-BO3r20op.js} +1 -1
  33. package/dist/assets/{insertionBoundaries-CCQpZ5zn.js → insertionBoundaries-CWoioTdE.js} +1 -1
  34. package/dist/assets/{ja-DP-7qt-x.js → ja-BDwrk0cM.js} +1 -1
  35. package/dist/assets/{ko-E2B5vGQP.js → ko-BeeU2o4I.js} +1 -1
  36. package/dist/assets/{main-Dkg31fRY.js → main-CLj_WS1o.js} +2 -2
  37. package/dist/assets/{main-C_PUoT3E.js → main-CnSZ89pD.js} +4 -4
  38. package/dist/assets/{markdown-worker-CTsrtpPh.js → markdown-worker-CZWE1F1M.js} +1 -1
  39. package/dist/assets/{markdownHeightCache-CYDlisxr.js → markdownHeightCache-Cdd3hkMl.js} +1 -1
  40. package/dist/assets/{markdownTheme-DyJ4Gfcj.js → markdownTheme-DIB8WVmz.js} +1 -1
  41. package/dist/assets/miniChat-B9c3y2pD.js +2 -0
  42. package/dist/assets/{mobile-Z_X7CbzB.js → mobile-DvbXUfNS.js} +2 -2
  43. package/dist/assets/{multirun-B0V3hdnD.js → multirun-CxhTsmRq.js} +1 -1
  44. package/dist/assets/{persistence-DdUac14I.js → persistence-CVTWFaqe.js} +2 -2
  45. package/dist/assets/{pl-r8X5gRM5.js → pl-BYuzpcI5.js} +1 -1
  46. package/dist/assets/{platform-B4KKLnZ5.js → platform-BY_Gtsqr.js} +1 -1
  47. package/dist/assets/{projectMeta-DRW9MQ4z.js → projectMeta-Dl5iPCB6.js} +1 -1
  48. package/dist/assets/{pt-BR-DcpdXCf5.js → pt-BR-Bn7cwEzv.js} +1 -1
  49. package/dist/assets/{renderElectronMiniChatApp-Dn1Su1dd.js → renderElectronMiniChatApp-CkJ51qCh.js} +2 -2
  50. package/dist/assets/renderMobileApp-DcjQPhWw.js +28 -0
  51. package/dist/assets/{runtimeConfig-C6matIEB.js → runtimeConfig-Bsk2kWsW.js} +1 -1
  52. package/dist/assets/runtimeEndpointReset-BBLFgl85.js +147 -0
  53. package/dist/assets/{sessionChangeSummary-hVby3sJk.js → sessionChangeSummary-DDVT7O5J.js} +1 -1
  54. package/dist/assets/{sessionLookup-B6P_aCun.js → sessionLookup-C8VZn0gJ.js} +1 -1
  55. package/dist/assets/{sortable-tabs-strip-C8_9kHqu.js → sortable-tabs-strip-s7UUxsa6.js} +1 -1
  56. package/dist/assets/src-BpHDMgud.css +2 -0
  57. package/dist/assets/{store-BDFdnk6l.js → store-n94dSb3M.js} +2 -2
  58. package/dist/assets/{terminalApi-947l7Ra3.js → terminalApi-C54_rKEK.js} +1 -1
  59. package/dist/assets/{textarea-Cgo_nWUU.js → textarea-BjX-KhMN.js} +1 -1
  60. package/dist/assets/{tooltip-BifLJ8o0.js → tooltip-CQGqGAG_.js} +1 -1
  61. package/dist/assets/{uk-Di0vl9V-.js → uk-CwpbHEa3.js} +1 -1
  62. package/dist/assets/{url-Pk6tQ1EZ.js → url-BMjqccCo.js} +1 -1
  63. package/dist/assets/{useAppFontEffects-Dmes1chr.js → useAppFontEffects-BIFw36SA.js} +5 -5
  64. package/dist/assets/{useAssistantUIStore-me6s63b2.js → useAssistantUIStore-Cg9bsT4b.js} +1 -1
  65. package/dist/assets/{useDesktopWindowControlsLayout-DuZ6URPR.js → useDesktopWindowControlsLayout-DoygEcN4.js} +1 -1
  66. package/dist/assets/{useEffectiveDirectory-CU2ChP0J.js → useEffectiveDirectory-DJSYYkVp.js} +1 -1
  67. package/dist/assets/{useFontPreferences-BCOBlaAd.js → useFontPreferences-B5_apbBC.js} +1 -1
  68. package/dist/assets/{useMobileNavigationStore-C8_J-qTp.js → useMobileNavigationStore-DuTu0Mzh.js} +5 -5
  69. package/dist/assets/{useSessionAutoCleanup-FOQeu1_T.js → useSessionAutoCleanup-C-L9teva.js} +1 -1
  70. package/dist/assets/{useUIStore-CuaA8-Hp.js → useUIStore-DwpQTjLO.js} +1 -1
  71. package/dist/assets/{useWorkerHighlightedLines-GBqKnki1.js → useWorkerHighlightedLines-DxTfgdEi.js} +1 -1
  72. package/dist/assets/{utils-BiK3tBGM.js → utils-81nUI_xp.js} +1 -1
  73. package/dist/assets/{zh-CN-coFCfeQE.js → zh-CN-BpzFZ-LU.js} +1 -1
  74. package/dist/assets/{zh-TW-oDRZ1-yb.js → zh-TW-Bopgc5Eo.js} +1 -1
  75. package/dist/index.html +9 -9
  76. package/dist/mini-chat.html +9 -9
  77. package/dist/mobile.html +9 -9
  78. package/package.json +1 -1
  79. package/server/index.js +19 -0
  80. package/server/lib/assistants/DOCUMENTATION.md +17 -4
  81. package/server/lib/assistants/assign.js +455 -30
  82. package/server/lib/assistants/assign.test.js +351 -0
  83. package/server/lib/assistants/contact-active-turn.http.test.js +134 -0
  84. package/server/lib/assistants/contact-store.js +64 -0
  85. package/server/lib/assistants/contact-store.test.js +12 -0
  86. package/server/lib/assistants/contact-tools.js +108 -9
  87. package/server/lib/assistants/contact-tools.test.js +111 -0
  88. package/server/lib/assistants/contracts.js +2 -2
  89. package/server/lib/assistants/harness.js +4 -1
  90. package/server/lib/assistants/service.js +320 -27
  91. package/server/lib/assistants/service.test.js +538 -9
  92. package/server/lib/llm/catalog.js +91 -16
  93. package/server/lib/llm/catalog.test.js +152 -11
  94. package/server/lib/notifications/APNS.md +20 -4
  95. package/server/lib/notifications/DOCUMENTATION.md +5 -0
  96. package/server/lib/notifications/apns-runtime.js +136 -18
  97. package/server/lib/notifications/apns-runtime.test.js +128 -0
  98. package/server/lib/notifications/apns-titles.js +21 -1
  99. package/server/lib/notifications/live-activity-refresh-runtime.js +185 -0
  100. package/server/lib/notifications/live-activity-refresh-runtime.test.js +242 -0
  101. package/server/lib/notifications/runtime.js +93 -2
  102. package/server/lib/notifications/runtime.test.js +167 -4
  103. package/server/lib/opencode/settings-helpers.js +12 -0
  104. package/server/lib/opencode/settings-runtime.js +16 -0
  105. package/server/lib/opencode/settings-runtime.test.js +21 -0
  106. package/server/lib/scheduled-tasks/runtime.js +21 -0
  107. package/server/lib/scheduled-tasks/runtime.test.js +24 -0
  108. package/dist/assets/AssistantView-DxIghtun.js +0 -2
  109. package/dist/assets/MarkdownRenderer-C4MGa_kQ.js +0 -2
  110. package/dist/assets/MobileDetailNavigation-B_RwWOwv.js +0 -1
  111. package/dist/assets/MobileSurface-CNChj_dX.js +0 -2
  112. package/dist/assets/miniChat-C9QMj8T8.js +0 -2
  113. package/dist/assets/renderMobileApp-BG1JJt1Q.js +0 -28
  114. package/dist/assets/runtimeEndpointReset-kgwmUJDb.js +0 -147
  115. package/dist/assets/src-B-P5qIBc.css +0 -2
@@ -1175,3 +1175,131 @@ describe('apns live activity host-relay compatibility', () => {
1175
1175
  expect(apnsCalls).toHaveLength(0);
1176
1176
  });
1177
1177
  });
1178
+
1179
+ describe('apns live activity refresh', () => {
1180
+ it('sends nothing when no snapshot changed', async () => {
1181
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
1182
+ vi.stubGlobal('fetch', fetchMock);
1183
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
1184
+ const deps = makeDeps();
1185
+ const runtime = createApnsRuntime(deps);
1186
+ await runtime.addOrUpdateLiveActivityToken('s1', 'la-token', 'act-1', 'live', [
1187
+ { sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 },
1188
+ ]);
1189
+
1190
+ fetchMock.mockClear();
1191
+ await runtime.refreshLiveActivityTokens({ computeSnapshot: async () => null });
1192
+ expect(fetchMock.mock.calls.filter(isLiveActivitySend)).toHaveLength(0);
1193
+ const store = JSON.parse(await deps.fsPromises.readFile('/tmp/apns-tokens.json'));
1194
+ expect(store.liveActivityTokensBySession.s1).toHaveLength(1);
1195
+ });
1196
+
1197
+ it('pushes an update with the recomputed snapshot and bumps the event version monotonically', async () => {
1198
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
1199
+ vi.stubGlobal('fetch', fetchMock);
1200
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
1201
+ const deps = makeDeps();
1202
+ const runtime = createApnsRuntime(deps);
1203
+ await runtime.addOrUpdateLiveActivityToken('s1', 'la-token', 'act-1', 'live', [
1204
+ { sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 },
1205
+ ]);
1206
+
1207
+ fetchMock.mockClear();
1208
+ await runtime.refreshLiveActivityTokens({
1209
+ computeSnapshot: async () => ({
1210
+ items: [{ sessionId: 'ses_1', title: 'One', status: 'retry', startedAt: 1 }],
1211
+ }),
1212
+ });
1213
+ const sent = JSON.parse(fetchMock.mock.calls.find(isLiveActivitySend)[1].body);
1214
+ expect(sent.tokens).toEqual(['la-token']);
1215
+ expect(sent.event).toBe('update');
1216
+ expect(sent.contentState.status).toBe('retry');
1217
+ expect(sent.contentState.workingCount).toBe(1);
1218
+ expect(sent.contentState.items[0]).toMatchObject({ sessionID: 'ses_1', status: 'retry' });
1219
+ expect(sent.staleDate).toBeGreaterThan(0);
1220
+ expect(sent.dismissalDate).toBeUndefined();
1221
+
1222
+ const store = JSON.parse(await deps.fsPromises.readFile('/tmp/apns-tokens.json'));
1223
+ expect(store.liveActivityTokensBySession.s1[0].snapshot[0].status).toBe('retry');
1224
+ const firstVersion = store.liveActivityEventVersions.live;
1225
+
1226
+ fetchMock.mockClear();
1227
+ await runtime.refreshLiveActivityTokens({
1228
+ computeSnapshot: async () => ({
1229
+ items: [{ sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 }],
1230
+ }),
1231
+ });
1232
+ const second = JSON.parse(fetchMock.mock.calls.find(isLiveActivitySend)[1].body);
1233
+ expect(second.contentState.eventVersion).toBeGreaterThan(firstVersion);
1234
+ });
1235
+
1236
+ it('ends and clears the token when nothing is working anymore', async () => {
1237
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
1238
+ vi.stubGlobal('fetch', fetchMock);
1239
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
1240
+ const deps = makeDeps();
1241
+ const runtime = createApnsRuntime(deps);
1242
+ await runtime.addOrUpdateLiveActivityToken('s1', 'la-token', 'act-1', 'live', [
1243
+ { sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 },
1244
+ ]);
1245
+
1246
+ fetchMock.mockClear();
1247
+ await runtime.refreshLiveActivityTokens({
1248
+ computeSnapshot: async () => ({
1249
+ items: [{ sessionId: 'ses_1', title: 'One', status: 'complete', startedAt: 1, endedAt: 5 }],
1250
+ }),
1251
+ });
1252
+ const sent = JSON.parse(fetchMock.mock.calls.find(isLiveActivitySend)[1].body);
1253
+ expect(sent.event).toBe('end');
1254
+ expect(sent.contentState.status).toBe('complete');
1255
+ expect(sent.dismissalDate).toBeGreaterThan(0);
1256
+ expect(sent.staleDate).toBeUndefined();
1257
+
1258
+ const store = JSON.parse(await deps.fsPromises.readFile('/tmp/apns-tokens.json'));
1259
+ expect(store.liveActivityTokensBySession.s1).toBeUndefined();
1260
+ });
1261
+
1262
+ it('groups entries with identical snapshots into one send and keeps distinct snapshots separate', async () => {
1263
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
1264
+ vi.stubGlobal('fetch', fetchMock);
1265
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
1266
+ const runtime = createApnsRuntime(makeDeps());
1267
+ await runtime.addOrUpdateLiveActivityToken('s1', 'la-token-a', 'act-1', 'live', [
1268
+ { sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 },
1269
+ ]);
1270
+ await runtime.addOrUpdateLiveActivityToken('s2', 'la-token-b', 'act-2', 'live', [
1271
+ { sessionId: 'ses_2', title: 'Two', status: 'working', startedAt: 2 },
1272
+ ]);
1273
+
1274
+ fetchMock.mockClear();
1275
+ await runtime.refreshLiveActivityTokens({
1276
+ computeSnapshot: async (entry) => {
1277
+ if (entry.token === 'la-token-a') {
1278
+ return { items: [{ sessionId: 'ses_1', title: 'One', status: 'retry', startedAt: 1 }] };
1279
+ }
1280
+ return { items: [{ sessionId: 'ses_2', title: 'Two', status: 'retry', startedAt: 2 }] };
1281
+ },
1282
+ });
1283
+ const sends = fetchMock.mock.calls.filter(isLiveActivitySend).map((call) => JSON.parse(call[1].body));
1284
+ expect(sends).toHaveLength(2);
1285
+ });
1286
+
1287
+ it('treats a computeSnapshot rejection as skip, not failure', async () => {
1288
+ const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
1289
+ vi.stubGlobal('fetch', fetchMock);
1290
+ process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
1291
+ const deps = makeDeps();
1292
+ const runtime = createApnsRuntime(deps);
1293
+ await runtime.addOrUpdateLiveActivityToken('s1', 'la-token', 'act-1', 'live', [
1294
+ { sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 },
1295
+ ]);
1296
+
1297
+ fetchMock.mockClear();
1298
+ await expect(runtime.refreshLiveActivityTokens({
1299
+ computeSnapshot: async () => {
1300
+ throw new Error('boom');
1301
+ },
1302
+ })).resolves.toBeUndefined();
1303
+ expect(fetchMock.mock.calls.filter(isLiveActivitySend)).toHaveLength(0);
1304
+ });
1305
+ });
@@ -16,7 +16,7 @@ export const APNS_LOCALES = Object.freeze([
16
16
  'ja',
17
17
  ]);
18
18
 
19
- /** @typedef {'ready'|'error'|'question'|'permission'|'goal_complete'|'goal_blocked'|'goal_budget'|'update'} ApnsTitleType */
19
+ /** @typedef {'ready'|'error'|'question'|'permission'|'goal_complete'|'goal_blocked'|'goal_budget'|'task_complete'|'task_error'|'update'} ApnsTitleType */
20
20
 
21
21
  const TITLES = Object.freeze({
22
22
  en: Object.freeze({
@@ -27,6 +27,8 @@ const TITLES = Object.freeze({
27
27
  goal_complete: 'Goal completed',
28
28
  goal_blocked: 'Goal blocked',
29
29
  goal_budget: 'Token budget reached',
30
+ task_complete: 'Scheduled task completed',
31
+ task_error: 'Scheduled task failed',
30
32
  update: 'Update',
31
33
  session: 'Session',
32
34
  }),
@@ -38,6 +40,8 @@ const TITLES = Object.freeze({
38
40
  goal_complete: 'Objectif atteint',
39
41
  goal_blocked: 'Objectif bloqué',
40
42
  goal_budget: 'Budget de tokens atteint',
43
+ task_complete: 'Tâche planifiée terminée',
44
+ task_error: 'Échec de la tâche planifiée',
41
45
  update: 'Mise à jour',
42
46
  session: 'Session',
43
47
  }),
@@ -49,6 +53,8 @@ const TITLES = Object.freeze({
49
53
  goal_complete: '目标已完成',
50
54
  goal_blocked: '目标受阻',
51
55
  goal_budget: '已达用量上限',
56
+ task_complete: '定时任务已完成',
57
+ task_error: '定时任务失败',
52
58
  update: '有新进展',
53
59
  session: '会话',
54
60
  }),
@@ -60,6 +66,8 @@ const TITLES = Object.freeze({
60
66
  goal_complete: '目標已完成',
61
67
  goal_blocked: '目標受阻',
62
68
  goal_budget: '已達用量上限',
69
+ task_complete: '排程任務已完成',
70
+ task_error: '排程任務失敗',
63
71
  update: '有新進展',
64
72
  session: '工作階段',
65
73
  }),
@@ -71,6 +79,8 @@ const TITLES = Object.freeze({
71
79
  goal_complete: 'Цілі досягнуто',
72
80
  goal_blocked: 'Цілі заблоковано',
73
81
  goal_budget: 'Досягнуто ліміт токенів',
82
+ task_complete: 'Заплановане завдання виконано',
83
+ task_error: 'Заплановане завдання не вдалося',
74
84
  update: 'Оновлення',
75
85
  session: 'Сесія',
76
86
  }),
@@ -82,6 +92,8 @@ const TITLES = Object.freeze({
82
92
  goal_complete: 'Objetivo completado',
83
93
  goal_blocked: 'Objetivo bloqueado',
84
94
  goal_budget: 'Presupuesto de tokens alcanzado',
95
+ task_complete: 'Tarea programada completada',
96
+ task_error: 'La tarea programada falló',
85
97
  update: 'Actualización',
86
98
  session: 'Sesión',
87
99
  }),
@@ -93,6 +105,8 @@ const TITLES = Object.freeze({
93
105
  goal_complete: 'Objetivo concluído',
94
106
  goal_blocked: 'Objetivo bloqueado',
95
107
  goal_budget: 'Orçamento de tokens atingido',
108
+ task_complete: 'Tarefa agendada concluída',
109
+ task_error: 'A tarefa agendada falhou',
96
110
  update: 'Atualização',
97
111
  session: 'Sessão',
98
112
  }),
@@ -104,6 +118,8 @@ const TITLES = Object.freeze({
104
118
  goal_complete: '목표 완료',
105
119
  goal_blocked: '목표 차단됨',
106
120
  goal_budget: '토큰 한도 도달',
121
+ task_complete: '예약 작업 완료',
122
+ task_error: '예약 작업 실패',
107
123
  update: '업데이트',
108
124
  session: '세션',
109
125
  }),
@@ -115,6 +131,8 @@ const TITLES = Object.freeze({
115
131
  goal_complete: 'Cel ukończony',
116
132
  goal_blocked: 'Cel zablokowany',
117
133
  goal_budget: 'Osiągnięto limit tokenów',
134
+ task_complete: 'Zadanie zaplanowane ukończone',
135
+ task_error: 'Zadanie zaplanowane nie powiodło się',
118
136
  update: 'Aktualizacja',
119
137
  session: 'Sesja',
120
138
  }),
@@ -126,6 +144,8 @@ const TITLES = Object.freeze({
126
144
  goal_complete: '目標完了',
127
145
  goal_blocked: '目標がブロックされました',
128
146
  goal_budget: 'トークン上限に達しました',
147
+ task_complete: 'スケジュールタスクが完了しました',
148
+ task_error: 'スケジュールタスクに失敗しました',
129
149
  update: '更新',
130
150
  session: 'セッション',
131
151
  }),
@@ -0,0 +1,185 @@
1
+ // Periodic Live Activity snapshot refresh for iOS devices that registered a
2
+ // Live Activity push token. While the app is foreground/connected the UI keeps
3
+ // its own ActivityKit state fresh; once iOS suspends or kills the app, only
4
+ // APNs can move the on-screen activity. Completion/error/title already push
5
+ // event-driven (runtime.js → sendLiveActivityEnd); this runtime covers the
6
+ // busy phase: every interval it recomputes each persisted token's snapshot
7
+ // from the authoritative session-status snapshot and pushes an update only
8
+ // when something actually changed (APNs budgets Live Activity updates per
9
+ // hour, so unchanged snapshots never send).
10
+ //
11
+ // Interval: default 30s, override with OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS
12
+ // (milliseconds; `0` disables the timer). Snapshots use unix seconds, matching
13
+ // the persisted token store (apns-runtime.js parseLiveActivitySnapshot).
14
+
15
+ export const LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS = 30_000;
16
+ export const LIVE_ACTIVITY_REFRESH_ITEM_LIMIT = 4; // mirrors the UI catalog cap
17
+ export const LIVE_ACTIVITY_REFRESH_TITLE_MAX = 80;
18
+
19
+ export const resolveLiveActivityRefreshIntervalMs = (env = process.env) => {
20
+ const raw = env?.OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS;
21
+ if (raw === undefined || raw === null || String(raw).trim() === '') {
22
+ return LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS;
23
+ }
24
+ const parsed = Number(raw);
25
+ if (!Number.isFinite(parsed) || parsed < 0) return LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS;
26
+ return Math.floor(parsed);
27
+ };
28
+
29
+ const liveActivityItemIsWorking = (status) => status !== 'complete' && status !== 'error';
30
+
31
+ // Session-side sub-states the server has no authoritative source for. The UI
32
+ // derives them from live permission/question/message-delta channels; once it is
33
+ // gone we keep the last row state instead of flickering back to `working`.
34
+ const LIVE_ACTIVITY_PRESERVED_ROW_STATUSES = new Set(['permission', 'input', 'tool']);
35
+
36
+ const mapSessionStatusToRowStatus = (sessionStatus) => {
37
+ if (sessionStatus === 'retry') return 'retry';
38
+ if (sessionStatus === 'busy') return 'working';
39
+ return null; // idle / unknown → terminal
40
+ };
41
+
42
+ const nextRowStatus = (currentRowStatus, sessionStatus) => {
43
+ if (sessionStatus === 'busy') {
44
+ if (LIVE_ACTIVITY_PRESERVED_ROW_STATUSES.has(currentRowStatus)) return currentRowStatus;
45
+ return 'working'; // including stale → working (server connection is authoritative here)
46
+ }
47
+ if (sessionStatus === 'retry') return 'retry';
48
+ return 'complete';
49
+ };
50
+
51
+ const capLiveActivityItems = (items) => {
52
+ if (items.length <= LIVE_ACTIVITY_REFRESH_ITEM_LIMIT) return items;
53
+ const working = items.filter((item) => liveActivityItemIsWorking(item.status));
54
+ const settled = items
55
+ .filter((item) => !liveActivityItemIsWorking(item.status))
56
+ .sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0));
57
+ const next = [...working];
58
+ for (const item of settled) {
59
+ if (next.length >= LIVE_ACTIVITY_REFRESH_ITEM_LIMIT) break;
60
+ next.push(item);
61
+ }
62
+ return next.slice(0, LIVE_ACTIVITY_REFRESH_ITEM_LIMIT);
63
+ };
64
+
65
+ const liveActivityItemsSignature = (items) => items
66
+ .map((item) => `${item.sessionId}\0${item.title}\0${item.status}\0${item.endedAt ?? ''}`)
67
+ .join('\n');
68
+
69
+ /**
70
+ * Recompute one entry's snapshot from the authoritative session-state snapshot.
71
+ * Returns `{ items }` (unix seconds) when something changed, `null` to skip.
72
+ * Rows for sessions with no live server state are preserved as-is (never guess
73
+ * terminal from missing data); new busy/retry sessions join only when the
74
+ * injected resolver confirms sidebar visibility.
75
+ */
76
+ export const computeLiveActivityRefreshSnapshot = async (input) => {
77
+ const { entry, sessionStates, resolveSession, nowMs } = input;
78
+ const nowSeconds = Math.floor(nowMs / 1000);
79
+ const previous = Array.isArray(entry.snapshot) ? entry.snapshot : [];
80
+ const items = [];
81
+ const seen = new Set();
82
+
83
+ for (const item of previous) {
84
+ if (!item || typeof item.sessionId !== 'string') continue;
85
+ seen.add(item.sessionId);
86
+ const state = sessionStates[item.sessionId];
87
+ if (!state) {
88
+ items.push(item); // no authoritative state → keep the last known row
89
+ continue;
90
+ }
91
+ const rowStatus = nextRowStatus(item.status, state.status);
92
+ if (rowStatus === item.status) {
93
+ items.push(item);
94
+ continue;
95
+ }
96
+ if (rowStatus === 'complete') {
97
+ items.push({
98
+ ...item,
99
+ status: 'complete',
100
+ endedAt: item.endedAt ?? nowSeconds,
101
+ });
102
+ continue;
103
+ }
104
+ items.push({ ...item, status: rowStatus });
105
+ }
106
+
107
+ const additions = [];
108
+ for (const [sessionId, state] of Object.entries(sessionStates || {})) {
109
+ if (seen.has(sessionId)) continue;
110
+ const mapped = mapSessionStatusToRowStatus(state?.status);
111
+ if (mapped === null || mapped === 'complete') continue;
112
+ let candidate = null;
113
+ try {
114
+ candidate = await resolveSession(sessionId);
115
+ } catch {
116
+ candidate = null;
117
+ }
118
+ if (!candidate || candidate.visible !== true) continue;
119
+ additions.push({
120
+ sessionId,
121
+ title: typeof candidate.title === 'string' ? candidate.title.slice(0, LIVE_ACTIVITY_REFRESH_TITLE_MAX) : '',
122
+ status: mapped,
123
+ startedAt: nowSeconds,
124
+ });
125
+ }
126
+
127
+ const merged = capLiveActivityItems([...items, ...additions]);
128
+ if (liveActivityItemsSignature(merged) === liveActivityItemsSignature(previous)) return null;
129
+ return { items: merged };
130
+ };
131
+
132
+ /**
133
+ * Owns the refresh interval. One tick snapshots the authoritative session
134
+ * states once, then hands every persisted Live Activity entry to
135
+ * `refreshLiveActivityTokens` (apns-runtime.js), which skips entries whose
136
+ * computed snapshot is unchanged. Overlapping ticks are dropped, not queued.
137
+ */
138
+ export const createLiveActivityRefreshRuntime = (deps) => {
139
+ const {
140
+ intervalMs,
141
+ refreshLiveActivityTokens,
142
+ getSessionStateSnapshot,
143
+ resolveSession,
144
+ } = deps;
145
+ let timer = null;
146
+ let ticking = false;
147
+
148
+ const tick = async () => {
149
+ if (ticking) return;
150
+ ticking = true;
151
+ try {
152
+ const sessionStates = getSessionStateSnapshot();
153
+ const nowMs = Date.now();
154
+ await refreshLiveActivityTokens({
155
+ computeSnapshot: (entry) => computeLiveActivityRefreshSnapshot({
156
+ entry,
157
+ sessionStates,
158
+ resolveSession,
159
+ nowMs,
160
+ }),
161
+ });
162
+ } catch (error) {
163
+ console.warn('[Live Activity refresh] tick failed:', error?.message ?? error);
164
+ } finally {
165
+ ticking = false;
166
+ }
167
+ };
168
+
169
+ const start = () => {
170
+ if (timer || !(intervalMs > 0)) return;
171
+ timer = setInterval(() => {
172
+ void tick();
173
+ }, intervalMs);
174
+ timer.unref?.();
175
+ };
176
+
177
+ const dispose = () => {
178
+ if (timer) {
179
+ clearInterval(timer);
180
+ timer = null;
181
+ }
182
+ };
183
+
184
+ return { start, dispose, tick };
185
+ };
@@ -0,0 +1,242 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import {
4
+ LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS,
5
+ computeLiveActivityRefreshSnapshot,
6
+ createLiveActivityRefreshRuntime,
7
+ resolveLiveActivityRefreshIntervalMs,
8
+ } from './live-activity-refresh-runtime.js';
9
+
10
+ afterEach(() => {
11
+ vi.useRealTimers();
12
+ vi.restoreAllMocks();
13
+ });
14
+
15
+ describe('resolveLiveActivityRefreshIntervalMs', () => {
16
+ it('defaults to 30s', () => {
17
+ expect(resolveLiveActivityRefreshIntervalMs({})).toBe(LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS);
18
+ expect(resolveLiveActivityRefreshIntervalMs({
19
+ OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS: ' ',
20
+ })).toBe(LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS);
21
+ });
22
+
23
+ it('honours explicit overrides including disable', () => {
24
+ expect(resolveLiveActivityRefreshIntervalMs({
25
+ OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS: '45000',
26
+ })).toBe(45_000);
27
+ expect(resolveLiveActivityRefreshIntervalMs({
28
+ OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS: '0',
29
+ })).toBe(0);
30
+ });
31
+
32
+ it('falls back to the default for invalid values', () => {
33
+ expect(resolveLiveActivityRefreshIntervalMs({
34
+ OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS: 'abc',
35
+ })).toBe(LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS);
36
+ expect(resolveLiveActivityRefreshIntervalMs({
37
+ OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS: '-5',
38
+ })).toBe(LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS);
39
+ });
40
+ });
41
+
42
+ describe('computeLiveActivityRefreshSnapshot', () => {
43
+ const baseInput = (overrides = {}) => ({
44
+ entry: { token: 't', sessionId: 'live', snapshot: [] },
45
+ sessionStates: {},
46
+ resolveSession: async () => null,
47
+ nowMs: 1_700_000_000_000,
48
+ ...overrides,
49
+ });
50
+
51
+ it('maps idle to complete with endedAt and marks the snapshot changed', async () => {
52
+ const result = await computeLiveActivityRefreshSnapshot(baseInput({
53
+ entry: {
54
+ token: 't',
55
+ sessionId: 'live',
56
+ snapshot: [{ sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 }],
57
+ },
58
+ sessionStates: { ses_1: { status: 'idle' } },
59
+ }));
60
+ expect(result).toEqual({
61
+ items: [{
62
+ sessionId: 'ses_1',
63
+ title: 'One',
64
+ status: 'complete',
65
+ startedAt: 1,
66
+ endedAt: Math.floor(1_700_000_000_000 / 1000),
67
+ }],
68
+ });
69
+ });
70
+
71
+ it('keeps server-side-only sub-states while the session stays busy', async () => {
72
+ const snapshot = [{ sessionId: 'ses_1', title: 'One', status: 'permission', startedAt: 1 }];
73
+ const result = await computeLiveActivityRefreshSnapshot(baseInput({
74
+ entry: { token: 't', sessionId: 'live', snapshot },
75
+ sessionStates: { ses_1: { status: 'busy' } },
76
+ }));
77
+ expect(result).toBeNull();
78
+ });
79
+
80
+ it('recovers stale rows to working while busy', async () => {
81
+ const result = await computeLiveActivityRefreshSnapshot(baseInput({
82
+ entry: {
83
+ token: 't',
84
+ sessionId: 'live',
85
+ snapshot: [{ sessionId: 'ses_1', title: 'One', status: 'stale', startedAt: 1 }],
86
+ },
87
+ sessionStates: { ses_1: { status: 'busy' } },
88
+ }));
89
+ expect(result?.items[0].status).toBe('working');
90
+ });
91
+
92
+ it('maps retry sessions to the retry row status', async () => {
93
+ const result = await computeLiveActivityRefreshSnapshot(baseInput({
94
+ entry: {
95
+ token: 't',
96
+ sessionId: 'live',
97
+ snapshot: [{ sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 }],
98
+ },
99
+ sessionStates: { ses_1: { status: 'retry' } },
100
+ }));
101
+ expect(result?.items[0].status).toBe('retry');
102
+ });
103
+
104
+ it('preserves rows whose session has no authoritative server state', async () => {
105
+ const snapshot = [{ sessionId: 'ses_gone', title: 'Gone', status: 'working', startedAt: 1 }];
106
+ const result = await computeLiveActivityRefreshSnapshot(baseInput({
107
+ entry: { token: 't', sessionId: 'live', snapshot },
108
+ sessionStates: {},
109
+ }));
110
+ expect(result).toBeNull();
111
+ });
112
+
113
+ it('adds new busy sessions only when the resolver marks them visible', async () => {
114
+ const resolveSession = vi.fn(async (sessionId) => (
115
+ sessionId === 'ses_child'
116
+ ? { title: 'Child', visible: false }
117
+ : { title: 'New Task', visible: true }
118
+ ));
119
+ const result = await computeLiveActivityRefreshSnapshot(baseInput({
120
+ entry: { token: 't', sessionId: 'live', snapshot: [] },
121
+ sessionStates: {
122
+ ses_new: { status: 'busy' },
123
+ ses_child: { status: 'busy' },
124
+ ses_idle: { status: 'idle' },
125
+ },
126
+ resolveSession,
127
+ }));
128
+ expect(result?.items).toHaveLength(1);
129
+ expect(result?.items[0]).toMatchObject({ sessionId: 'ses_new', title: 'New Task', status: 'working' });
130
+ expect(resolveSession).not.toHaveBeenCalledWith('ses_idle');
131
+ });
132
+
133
+ it('skips new sessions when the resolver throws or resolves empty', async () => {
134
+ const result = await computeLiveActivityRefreshSnapshot(baseInput({
135
+ entry: { token: 't', sessionId: 'live', snapshot: [] },
136
+ sessionStates: { ses_new: { status: 'busy' } },
137
+ resolveSession: async () => {
138
+ throw new Error('boom');
139
+ },
140
+ }));
141
+ expect(result).toBeNull();
142
+ });
143
+
144
+ it('caps merged rows at the catalog limit, working rows first', async () => {
145
+ const snapshot = [1, 2, 3, 4, 5].map((n) => ({
146
+ sessionId: `ses_${n}`,
147
+ title: `S${n}`,
148
+ status: 'complete',
149
+ startedAt: n,
150
+ endedAt: n,
151
+ }));
152
+ const result = await computeLiveActivityRefreshSnapshot(baseInput({
153
+ entry: { token: 't', sessionId: 'live', snapshot },
154
+ sessionStates: { ses_new: { status: 'busy' } },
155
+ resolveSession: async () => ({ title: 'New', visible: true }),
156
+ }));
157
+ expect(result?.items).toHaveLength(4);
158
+ expect(result?.items.some((item) => item.sessionId === 'ses_new')).toBe(true);
159
+ // Most recently settled rows survive the cap.
160
+ expect(result?.items.some((item) => item.sessionId === 'ses_5')).toBe(true);
161
+ expect(result?.items.some((item) => item.sessionId === 'ses_1')).toBe(false);
162
+ });
163
+
164
+ it('returns null when nothing changed', async () => {
165
+ const snapshot = [
166
+ { sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 },
167
+ { sessionId: 'ses_2', title: 'Two', status: 'complete', startedAt: 2, endedAt: 3 },
168
+ ];
169
+ const result = await computeLiveActivityRefreshSnapshot(baseInput({
170
+ entry: { token: 't', sessionId: 'live', snapshot },
171
+ sessionStates: { ses_1: { status: 'busy' } },
172
+ }));
173
+ expect(result).toBeNull();
174
+ });
175
+ });
176
+
177
+ describe('createLiveActivityRefreshRuntime', () => {
178
+ it('ticks the refresh with one session-state snapshot for all entries', async () => {
179
+ const refreshLiveActivityTokens = vi.fn(async () => {});
180
+ const runtime = createLiveActivityRefreshRuntime({
181
+ intervalMs: LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS,
182
+ refreshLiveActivityTokens,
183
+ getSessionStateSnapshot: () => ({ ses_1: { status: 'busy' } }),
184
+ resolveSession: async () => null,
185
+ });
186
+ await runtime.tick();
187
+
188
+ expect(refreshLiveActivityTokens).toHaveBeenCalledTimes(1);
189
+ const arg = refreshLiveActivityTokens.mock.calls[0][0];
190
+ expect(typeof arg.computeSnapshot).toBe('function');
191
+ const result = await arg.computeSnapshot({
192
+ token: 't',
193
+ sessionId: 'live',
194
+ snapshot: [{ sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 }],
195
+ });
196
+ expect(result).toBeNull();
197
+ });
198
+
199
+ it('drops overlapping ticks instead of queueing them', async () => {
200
+ let release;
201
+ const gate = new Promise((resolve) => { release = resolve; });
202
+ const refreshLiveActivityTokens = vi.fn(async () => { await gate; });
203
+ const runtime = createLiveActivityRefreshRuntime({
204
+ intervalMs: 1000,
205
+ refreshLiveActivityTokens,
206
+ getSessionStateSnapshot: () => ({}),
207
+ resolveSession: async () => null,
208
+ });
209
+ const first = runtime.tick();
210
+ await runtime.tick(); // overlapping → dropped
211
+ release();
212
+ await first;
213
+ expect(refreshLiveActivityTokens).toHaveBeenCalledTimes(1);
214
+ });
215
+
216
+ it('never schedules a timer when disabled (interval 0) and disposes cleanly', async () => {
217
+ vi.useFakeTimers();
218
+ const refreshLiveActivityTokens = vi.fn(async () => {});
219
+ const runtime = createLiveActivityRefreshRuntime({
220
+ intervalMs: 0,
221
+ refreshLiveActivityTokens,
222
+ getSessionStateSnapshot: () => ({}),
223
+ resolveSession: async () => null,
224
+ });
225
+ runtime.start();
226
+ await vi.advanceTimersByTimeAsync(120_000);
227
+ expect(refreshLiveActivityTokens).not.toHaveBeenCalled();
228
+
229
+ const enabled = createLiveActivityRefreshRuntime({
230
+ intervalMs: 30_000,
231
+ refreshLiveActivityTokens,
232
+ getSessionStateSnapshot: () => ({}),
233
+ resolveSession: async () => null,
234
+ });
235
+ enabled.start();
236
+ await vi.advanceTimersByTimeAsync(30_000);
237
+ expect(refreshLiveActivityTokens).toHaveBeenCalledTimes(1);
238
+ enabled.dispose();
239
+ await vi.advanceTimersByTimeAsync(120_000);
240
+ expect(refreshLiveActivityTokens).toHaveBeenCalledTimes(1);
241
+ });
242
+ });