@openchambery/web 1.19.11 → 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 (123) 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-BQO1cBLs.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--takeMXN.js → MarkdownRendererImpl-Peu0GVSQ.js} +1 -1
  8. package/dist/assets/{MarkstreamRendererImpl-DGSqWcPN.js → MarkstreamRendererImpl-B-odd2Gz.js} +1 -1
  9. package/dist/assets/MobileDetailNavigation-CtYke_1C.js +1 -0
  10. package/dist/assets/{MobileShareBridge-D-BUjACe.js → MobileShareBridge-DIfOl8EZ.js} +3 -3
  11. package/dist/assets/MobileSurface-_KoX75Ff.js +2 -0
  12. package/dist/assets/{MultiRunWindow-BNR9ZWmD.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-DGerg-C2.js → SettingsView-BwQpbIZl.js} +11 -11
  16. package/dist/assets/{SettingsWindow-CSpd5JbJ.js → SettingsWindow-DfEGfyr8.js} +1 -1
  17. package/dist/assets/{TerminalView-DS-oUxJr.js → TerminalView-8402vpcO.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-Q1k9-IaY.js → ToolOutputDialog-B5RdkhsG.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-CG7D-3-q.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-Cr-joUEw.js → gitApi-_P0FsoXe.js} +1 -1
  32. package/dist/assets/{input-DFLpqjGR.js → input-BO3r20op.js} +1 -1
  33. package/dist/assets/{insertionBoundaries-X0xbikAA.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-D0L7fIg3.js → main-CLj_WS1o.js} +2 -2
  37. package/dist/assets/{main-BI7fNQoi.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-OcCu_0mG.js → mobile-DvbXUfNS.js} +2 -2
  43. package/dist/assets/{multirun-B0G_BEIN.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-BNOzn1QS.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-BREc3uhX.js → renderElectronMiniChatApp-CkJ51qCh.js} +2 -2
  50. package/dist/assets/renderMobileApp-DcjQPhWw.js +28 -0
  51. package/dist/assets/{runtimeConfig-P8nVMYzE.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-C_9-9ggp.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-CEmOsrr6.js → useAppFontEffects-BIFw36SA.js} +5 -5
  64. package/dist/assets/{useAssistantUIStore-me6s63b2.js → useAssistantUIStore-Cg9bsT4b.js} +1 -1
  65. package/dist/assets/{useDesktopWindowControlsLayout-CxaK-zXh.js → useDesktopWindowControlsLayout-DoygEcN4.js} +1 -1
  66. package/dist/assets/{useEffectiveDirectory-BIwv5r5l.js → useEffectiveDirectory-DJSYYkVp.js} +1 -1
  67. package/dist/assets/{useFontPreferences-BCOBlaAd.js → useFontPreferences-B5_apbBC.js} +1 -1
  68. package/dist/assets/{useMobileNavigationStore-DHm5kZPx.js → useMobileNavigationStore-DuTu0Mzh.js} +5 -5
  69. package/dist/assets/{useSessionAutoCleanup-CPLP9KWH.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-CZ4ZTWEH.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 +21 -6
  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 +216 -25
  87. package/server/lib/assistants/contact-tools.test.js +167 -0
  88. package/server/lib/assistants/contracts.js +2 -2
  89. package/server/lib/assistants/harness.js +20 -3
  90. package/server/lib/assistants/harness.test.js +120 -1
  91. package/server/lib/assistants/pi-tools.js +50 -3
  92. package/server/lib/assistants/pi-tools.test.js +25 -0
  93. package/server/lib/assistants/service.js +320 -27
  94. package/server/lib/assistants/service.test.js +581 -9
  95. package/server/lib/llm/DOCUMENTATION.md +31 -18
  96. package/server/lib/llm/catalog.js +91 -16
  97. package/server/lib/llm/catalog.test.js +152 -11
  98. package/server/lib/llm/generate.js +43 -7
  99. package/server/lib/llm/generate.test.js +98 -1
  100. package/server/lib/llm/temp-directory.js +34 -13
  101. package/server/lib/llm/temp-directory.test.js +57 -0
  102. package/server/lib/notifications/APNS.md +20 -4
  103. package/server/lib/notifications/DOCUMENTATION.md +5 -0
  104. package/server/lib/notifications/apns-runtime.js +136 -18
  105. package/server/lib/notifications/apns-runtime.test.js +128 -0
  106. package/server/lib/notifications/apns-titles.js +21 -1
  107. package/server/lib/notifications/live-activity-refresh-runtime.js +185 -0
  108. package/server/lib/notifications/live-activity-refresh-runtime.test.js +242 -0
  109. package/server/lib/notifications/runtime.js +93 -2
  110. package/server/lib/notifications/runtime.test.js +167 -4
  111. package/server/lib/opencode/settings-helpers.js +12 -0
  112. package/server/lib/opencode/settings-runtime.js +16 -0
  113. package/server/lib/opencode/settings-runtime.test.js +21 -0
  114. package/server/lib/scheduled-tasks/runtime.js +21 -0
  115. package/server/lib/scheduled-tasks/runtime.test.js +24 -0
  116. package/dist/assets/AssistantView-BwrcqJzu.js +0 -2
  117. package/dist/assets/MarkdownRenderer-C49bfcHx.js +0 -2
  118. package/dist/assets/MobileDetailNavigation-LZU2vFAs.js +0 -1
  119. package/dist/assets/MobileSurface-OAJjAQCZ.js +0 -2
  120. package/dist/assets/miniChat-BJ9AclZX.js +0 -2
  121. package/dist/assets/renderMobileApp-C4igZ42L.js +0 -28
  122. package/dist/assets/runtimeEndpointReset-6lVRspis.js +0 -147
  123. package/dist/assets/src-B-P5qIBc.css +0 -2
@@ -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
+ });
@@ -316,6 +316,34 @@ export const createNotificationTriggerRuntime = (deps) => {
316
316
  return isHiddenFromNavSession(meta);
317
317
  };
318
318
 
319
+ // Live Activity refresh ticks carry no directory, so the cache lookup must
320
+ // match the session under ANY cached directory key before falling back to the
321
+ // directory-less OpenCode session endpoint.
322
+ const lookupSessionMetaAnyDirectory = (sessionId) => {
323
+ const direct = getCachedSessionMeta(sessionId, undefined);
324
+ if (direct) return direct;
325
+ const suffix = `\0${sessionId}`;
326
+ const now = Date.now();
327
+ for (const [key, entry] of sessionMetaCache) {
328
+ if (!key.endsWith(suffix)) continue;
329
+ if (now - entry.at <= SESSION_META_CACHE_TTL_MS) return entry;
330
+ }
331
+ return undefined;
332
+ };
333
+
334
+ // Sidebar candidate for a new Live Activity snapshot row: title + visibility
335
+ // under the same suppression rules as ordinary push. Cache-first; a missing
336
+ // meta resolves as not-visible (hidden sessions must never leak into a push).
337
+ const resolveLiveActivitySessionCandidate = async (sessionId) => {
338
+ const cached = lookupSessionMetaAnyDirectory(sessionId);
339
+ if (cached?.complete) {
340
+ return { title: cached.title ?? '', visible: !isHiddenFromNavSession(cached) };
341
+ }
342
+ const meta = await fetchSessionMeta(sessionId, undefined);
343
+ if (!meta) return null;
344
+ return { title: meta.title ?? '', visible: !isHiddenFromNavSession(meta) };
345
+ };
346
+
319
347
  const extractSessionIdFromPayload = (payload) => {
320
348
  if (!payload || typeof payload !== 'object') return null;
321
349
  const props = payload.properties;
@@ -650,7 +678,7 @@ export const createNotificationTriggerRuntime = (deps) => {
650
678
 
651
679
  const settings = await readSettingsFromDisk();
652
680
 
653
- if (settings.notifyOnQuestion === false) {
681
+ if (settings.notifyOnPermission === false) {
654
682
  return;
655
683
  }
656
684
 
@@ -726,6 +754,8 @@ export const createNotificationTriggerRuntime = (deps) => {
726
754
  // full text; APNs with the generic per-type title and the session name as
727
755
  // body, so the relay never sees content).
728
756
  const sendGoalSettlePush = async ({ sessionId, directory, status, title, body }) => {
757
+ const settings = await readSettingsFromDisk();
758
+ if (settings.notifyOnGoals === false) return;
729
759
  let sessionName = '';
730
760
  try {
731
761
  const base = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, '');
@@ -759,6 +789,65 @@ export const createNotificationTriggerRuntime = (deps) => {
759
789
  );
760
790
  };
761
791
 
792
+ // Scheduled task run notification: fired by the scheduled-tasks runtime when
793
+ // a scheduled (non-manual) run settles. Desktop/UI SSE carry the task name
794
+ // and, on failure, the error text; push channels follow the same generic
795
+ // contract as other triggers (APNs title is the localized scenario, body is
796
+ // the task name). Manual runs skip — the user just clicked "run now".
797
+ const sendScheduledTaskRunNotification = async ({
798
+ projectID,
799
+ taskID,
800
+ taskName,
801
+ status,
802
+ sessionId,
803
+ reason,
804
+ errorMessage,
805
+ } = {}) => {
806
+ if (typeof projectID !== 'string' || !projectID) return;
807
+ if (typeof taskID !== 'string' || !taskID) return;
808
+ if (reason === 'manual') return;
809
+ if (status !== 'success' && status !== 'error') return;
810
+ const settings = await readSettingsFromDisk();
811
+ if (settings.notifyOnScheduledTasks === false) return;
812
+
813
+ const name = typeof taskName === 'string' && taskName.trim() ? taskName.trim() : 'Schedule';
814
+ const isError = status === 'error';
815
+ const title = isError ? 'Scheduled task failed' : 'Scheduled task completed';
816
+ const trimmedError = typeof errorMessage === 'string' && errorMessage.trim() ? errorMessage.trim() : '';
817
+ const body = isError && trimmedError ? `${name}: ${trimmedError}` : name;
818
+ const tag = `scheduled-${projectID}-${taskID}`;
819
+
820
+ if (settings.nativeNotificationsEnabled) {
821
+ const notificationPayload = {
822
+ kind: isError ? 'task-error' : 'task-complete',
823
+ title,
824
+ body,
825
+ tag,
826
+ ...(sessionId ? { sessionId } : {}),
827
+ requireHidden: false,
828
+ };
829
+ const desktopNotificationDelivered = emitDesktopNotification(notificationPayload);
830
+ broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
831
+ }
832
+
833
+ await fanoutPush(
834
+ {
835
+ title,
836
+ body,
837
+ tag,
838
+ data: {
839
+ url: typeof sessionId === 'string' && sessionId
840
+ ? buildSessionDeepLinkUrl(sessionId)
841
+ : '/',
842
+ ...(typeof sessionId === 'string' && sessionId ? { sessionId } : {}),
843
+ sessionName: name,
844
+ type: isError ? 'task_error' : 'task_complete',
845
+ },
846
+ },
847
+ { requireNoSse: true },
848
+ );
849
+ };
850
+
762
851
  const contactNotificationTitle = (name) => {
763
852
  if (typeof name !== 'string' || !name.trim()) return 'Assistant';
764
853
  const stripped = name.replace(/^(?:\p{Extended_Pictographic}|\p{Regional_Indicator}|[\uFE0F\u200D])+\s*/u, '').trim();
@@ -768,7 +857,7 @@ export const createNotificationTriggerRuntime = (deps) => {
768
857
  const sendContactTurnNotification = async ({ assistantID, name, body, status } = {}) => {
769
858
  if (typeof assistantID !== 'string' || !assistantID.trim()) return;
770
859
  const settings = await readSettingsFromDisk();
771
- if (settings.notifyOnCompletion === false) return;
860
+ if (settings.notifyOnAssistants === false) return;
772
861
 
773
862
  const title = contactNotificationTitle(name);
774
863
  const rawBody = typeof body === 'string' ? body : '';
@@ -818,5 +907,7 @@ export const createNotificationTriggerRuntime = (deps) => {
818
907
  clearPendingPushBadge,
819
908
  sendGoalSettlePush,
820
909
  sendContactTurnNotification,
910
+ sendScheduledTaskRunNotification,
911
+ resolveLiveActivitySessionCandidate,
821
912
  };
822
913
  };