@cortexkit/opencode-magic-context 0.31.2 → 0.31.4

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 (108) hide show
  1. package/dist/config/index.d.ts.map +1 -1
  2. package/dist/config/project-security.d.ts +16 -12
  3. package/dist/config/project-security.d.ts.map +1 -1
  4. package/dist/config/schema/magic-context.d.ts +13 -0
  5. package/dist/config/schema/magic-context.d.ts.map +1 -1
  6. package/dist/features/magic-context/dreamer/classify-prompt.d.ts +2 -2
  7. package/dist/features/magic-context/dreamer/classify-prompt.d.ts.map +1 -1
  8. package/dist/features/magic-context/dreamer/classify.d.ts +8 -0
  9. package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
  10. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  11. package/dist/features/magic-context/dreamer/lease.d.ts +1 -0
  12. package/dist/features/magic-context/dreamer/lease.d.ts.map +1 -1
  13. package/dist/features/magic-context/dreamer/manifest-parser.d.ts +7 -0
  14. package/dist/features/magic-context/dreamer/manifest-parser.d.ts.map +1 -0
  15. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts +2 -3
  16. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts.map +1 -1
  17. package/dist/features/magic-context/dreamer/map-memories.d.ts +8 -0
  18. package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
  19. package/dist/features/magic-context/dreamer/promote-primers.d.ts.map +1 -1
  20. package/dist/features/magic-context/dreamer/retrospective-raw-provider.d.ts +5 -0
  21. package/dist/features/magic-context/dreamer/retrospective-raw-provider.d.ts.map +1 -1
  22. package/dist/features/magic-context/dreamer/verify-prompt.d.ts +2 -2
  23. package/dist/features/magic-context/dreamer/verify-prompt.d.ts.map +1 -1
  24. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
  25. package/dist/features/magic-context/memory/storage-memory-embeddings.d.ts +4 -0
  26. package/dist/features/magic-context/memory/storage-memory-embeddings.d.ts.map +1 -1
  27. package/dist/features/magic-context/memory/storage-memory.d.ts +12 -0
  28. package/dist/features/magic-context/memory/storage-memory.d.ts.map +1 -1
  29. package/dist/features/magic-context/memory/verification-paths.d.ts +16 -0
  30. package/dist/features/magic-context/memory/verification-paths.d.ts.map +1 -1
  31. package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
  32. package/dist/features/magic-context/message-index.d.ts +6 -0
  33. package/dist/features/magic-context/message-index.d.ts.map +1 -1
  34. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  35. package/dist/features/magic-context/search.d.ts.map +1 -1
  36. package/dist/features/magic-context/smart-notes/capabilities.d.ts +1 -0
  37. package/dist/features/magic-context/smart-notes/capabilities.d.ts.map +1 -1
  38. package/dist/features/magic-context/smart-notes/compiler.d.ts +3 -2
  39. package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
  40. package/dist/features/magic-context/smart-notes/runner.d.ts.map +1 -1
  41. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts +3 -2
  42. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
  43. package/dist/features/magic-context/smart-notes/schedule.d.ts.map +1 -1
  44. package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts +17 -6
  45. package/dist/features/magic-context/smart-notes/ssrf-guard.d.ts.map +1 -1
  46. package/dist/features/magic-context/smart-notes/types.d.ts +6 -1
  47. package/dist/features/magic-context/smart-notes/types.d.ts.map +1 -1
  48. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  49. package/dist/features/magic-context/storage-meta-persisted.d.ts +8 -0
  50. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  51. package/dist/features/magic-context/storage-meta.d.ts +1 -1
  52. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  53. package/dist/features/magic-context/storage.d.ts +1 -1
  54. package/dist/features/magic-context/storage.d.ts.map +1 -1
  55. package/dist/features/magic-context/workspaces.d.ts +8 -0
  56. package/dist/features/magic-context/workspaces.d.ts.map +1 -1
  57. package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
  58. package/dist/hooks/magic-context/compartment-runner.d.ts +2 -0
  59. package/dist/hooks/magic-context/compartment-runner.d.ts.map +1 -1
  60. package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
  61. package/dist/hooks/magic-context/read-session-db.d.ts.map +1 -1
  62. package/dist/hooks/magic-context/read-session-true-raw-tokens.d.ts.map +1 -1
  63. package/dist/hooks/magic-context/tag-messages.d.ts +1 -0
  64. package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
  65. package/dist/hooks/magic-context/todo-view.d.ts +27 -2
  66. package/dist/hooks/magic-context/todo-view.d.ts.map +1 -1
  67. package/dist/hooks/magic-context/transform-message-helpers.d.ts +6 -3
  68. package/dist/hooks/magic-context/transform-message-helpers.d.ts.map +1 -1
  69. package/dist/hooks/magic-context/wrapup-orchestrator.d.ts +1 -0
  70. package/dist/hooks/magic-context/wrapup-orchestrator.d.ts.map +1 -1
  71. package/dist/index.js +1046 -333
  72. package/dist/shared/assistant-message-extractor.d.ts +1 -0
  73. package/dist/shared/assistant-message-extractor.d.ts.map +1 -1
  74. package/dist/shared/redaction.d.ts.map +1 -1
  75. package/dist/shared/rpc-notifications.d.ts +21 -16
  76. package/dist/shared/rpc-notifications.d.ts.map +1 -1
  77. package/dist/shared/rpc-server.d.ts +1 -0
  78. package/dist/shared/rpc-server.d.ts.map +1 -1
  79. package/dist/shared/rpc-utils.d.ts +3 -1
  80. package/dist/shared/rpc-utils.d.ts.map +1 -1
  81. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  82. package/dist/tools/ctx-search/tools.d.ts.map +1 -1
  83. package/dist/tools/ctx-search/types.d.ts +1 -1
  84. package/dist/tools/ctx-search/types.d.ts.map +1 -1
  85. package/dist/tui/data/notification-socket.d.ts +1 -0
  86. package/dist/tui/data/notification-socket.d.ts.map +1 -1
  87. package/dist/tui-compiled/badge-contrast.d.ts +46 -0
  88. package/dist/tui-compiled/badge-contrast.d.ts.map +1 -0
  89. package/dist/tui-compiled/data/context-db.d.ts +45 -0
  90. package/dist/tui-compiled/data/context-db.d.ts.map +1 -0
  91. package/dist/tui-compiled/data/notification-socket.d.ts +40 -0
  92. package/dist/tui-compiled/data/notification-socket.d.ts.map +1 -0
  93. package/package.json +7 -4
  94. package/src/shared/assistant-message-extractor.ts +20 -0
  95. package/src/shared/redaction.ts +1 -0
  96. package/src/shared/rpc-client.test.ts +164 -5
  97. package/src/shared/rpc-notifications.ts +73 -21
  98. package/src/shared/rpc-server.ts +69 -18
  99. package/src/shared/rpc-utils.ts +12 -2
  100. package/src/tui/data/notification-socket.test.ts +131 -0
  101. package/src/tui/data/notification-socket.ts +81 -16
  102. package/src/tui/entry.mjs +36 -0
  103. package/src/tui-compiled/badge-contrast.ts +84 -0
  104. package/src/tui-compiled/data/context-db.ts +353 -0
  105. package/src/tui-compiled/data/notification-socket.ts +294 -0
  106. package/src/tui-compiled/index.tsx +1204 -0
  107. package/src/tui-compiled/slots/sidebar-content.tsx +1052 -0
  108. package/src/tui-compiled/types/opencode-plugin-tui.d.ts +232 -0
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Pick the text color for the sidebar header badge (a bold label drawn on a
3
+ * `theme.accent` background).
4
+ *
5
+ * Primary rule (matches AFT's sidebar by construction): paint the theme's own
6
+ * `background` color as the label, the inverse-of-panel look. Because it is a
7
+ * fixed theme token rather than an accent-derived computation, MC's badge and
8
+ * AFT's badge agree on EVERY accent automatically, so the same theme can never
9
+ * make one badge black and the other white (issue #198).
10
+ *
11
+ * Fallback rule (the reason a luminance pick exists at all): `theme.background`
12
+ * can be unusable as a label color in two degenerate cases, where it would
13
+ * render the label invisible on the accent:
14
+ * 1. Transparent background. Themes that respect terminal transparency set
15
+ * `background: "none"`, which resolves to `RGBA(0,0,0,0)`; drawing it as
16
+ * text renders fully transparent and the label disappears (issue #186).
17
+ * 2. Background ~= accent. If the theme's background and accent are nearly the
18
+ * same color, background-on-accent text has no contrast.
19
+ * In either case we fall back to a black/white pick that is guaranteed visible
20
+ * on the always-opaque accent.
21
+ *
22
+ * `RGBA` channels from @opentui/core are normalized 0..1 floats (alpha included).
23
+ * We accept the minimal `{ r, g, b, a? }` shape so this stays a pure, trivially
24
+ * testable function independent of the native color class, and we return the
25
+ * passed-in `background` object unchanged on the primary path so it stays the
26
+ * exact same theme token AFT uses.
27
+ */
28
+
29
+ type Color = { r: number; g: number; b: number; a?: number };
30
+
31
+ // Below this alpha the theme background is too transparent to read as a label on
32
+ // the accent (issue #186: background:"none" resolves to alpha 0).
33
+ const MIN_OPAQUE_ALPHA = 0.5;
34
+
35
+ // If the theme background and accent are within this per-channel distance they
36
+ // are effectively the same color, so background-on-accent text is unreadable.
37
+ const MIN_CHANNEL_DISTANCE = 0.06;
38
+
39
+ // Luminance midpoint for the fallback pick: accents below this keep white text,
40
+ // accents at/above it (light/pastel/near-white) get black. White-biased relative
41
+ // to the strict equal-contrast crossover (~0.179) so saturated mid-tone accents
42
+ // stay white. Only consulted on the degenerate fallback path.
43
+ const LIGHT_ACCENT_LUMINANCE = 0.5;
44
+
45
+ function srgbChannelToLinear(c: number): number {
46
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
47
+ }
48
+
49
+ function relativeLuminance(bg: Color): number {
50
+ return (
51
+ 0.2126 * srgbChannelToLinear(bg.r) +
52
+ 0.7152 * srgbChannelToLinear(bg.g) +
53
+ 0.0722 * srgbChannelToLinear(bg.b)
54
+ );
55
+ }
56
+
57
+ function nearlyEqual(a: Color, b: Color): boolean {
58
+ return (
59
+ Math.abs(a.r - b.r) < MIN_CHANNEL_DISTANCE &&
60
+ Math.abs(a.g - b.g) < MIN_CHANNEL_DISTANCE &&
61
+ Math.abs(a.b - b.b) < MIN_CHANNEL_DISTANCE
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Pure black/white pick by accent luminance. Used as the badge fallback and kept
67
+ * exported for callers that only have the accent.
68
+ */
69
+ export function readableTextColorOn(bg: Color): string {
70
+ return relativeLuminance(bg) < LIGHT_ACCENT_LUMINANCE ? "#ffffff" : "#000000";
71
+ }
72
+
73
+ /**
74
+ * Badge label color on the accent: the theme background (AFT parity) when it is
75
+ * usable, else a guaranteed-visible black/white fallback. Returns the passed-in
76
+ * `background` reference unchanged on the primary path.
77
+ */
78
+ export function badgeTextColor<T extends Color>(accent: T, background: T): T | string {
79
+ const alpha = background.a ?? 1;
80
+ if (alpha >= MIN_OPAQUE_ALPHA && !nearlyEqual(accent, background)) {
81
+ return background;
82
+ }
83
+ return readableTextColorOn(accent);
84
+ }
@@ -0,0 +1,353 @@
1
+ /**
2
+ * TUI data layer — pure RPC client, no direct SQLite access.
3
+ * All data is fetched from the server plugin via HTTP RPC.
4
+ */
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { MagicContextRpcClient } from "../../shared/rpc-client";
8
+ import type { EmbedDetail, SidebarSnapshot, StatusDetail } from "../../shared/rpc-types";
9
+
10
+ export type { EmbedDetail, SidebarSnapshot, StatusDetail };
11
+
12
+ let rpcClient: MagicContextRpcClient | null = null;
13
+ let rpcGeneration = 0;
14
+
15
+ function getStorageDir(): string {
16
+ // Plugin v0.16+ uses the shared cortexkit/magic-context path so OpenCode
17
+ // and Pi can share state. The TUI just needs to point its RPC client at
18
+ // the same storage directory the server plugin uses for the lock-file
19
+ // discovery convention.
20
+ const dataDir = process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share");
21
+ return path.join(dataDir, "cortexkit", "magic-context");
22
+ }
23
+
24
+ /** Initialize the RPC client. Call once on TUI startup. */
25
+ export function initRpcClient(directory: string): void {
26
+ const storageDir = getStorageDir();
27
+ // Bump the generation before replacing the client so late notification
28
+ // responses from a disposed client are ignored (the WS socket observes the
29
+ // new generation and abandons its in-flight connect).
30
+ rpcGeneration += 1;
31
+ rpcClient = new MagicContextRpcClient(storageDir, directory);
32
+ }
33
+
34
+ export function getRpcGeneration(): number {
35
+ return rpcGeneration;
36
+ }
37
+
38
+ /** The live RPC client (for the WS notification socket's endpoint discovery).
39
+ * Null before init / after close. */
40
+ export function getRpcClient(): MagicContextRpcClient | null {
41
+ return rpcClient;
42
+ }
43
+
44
+ /** Clean up the RPC client. */
45
+ export function closeRpc(): void {
46
+ // Closing invalidates any already-issued RPC calls; their callbacks must
47
+ // observe the new generation and abandon (the WS socket checks it too).
48
+ rpcGeneration += 1;
49
+ rpcClient?.reset();
50
+ rpcClient = null;
51
+ }
52
+
53
+ const EMPTY_SNAPSHOT: SidebarSnapshot = {
54
+ sessionId: "",
55
+ usagePercentage: 0,
56
+ inputTokens: 0,
57
+ contextLimit: 0,
58
+ systemPromptTokens: 0,
59
+ compartmentCount: 0,
60
+ memoryCount: 0,
61
+ memoryBlockCount: 0,
62
+ pendingOpsCount: 0,
63
+ historianRunning: false,
64
+ compartmentInProgress: false,
65
+ sessionNoteCount: 0,
66
+ readySmartNoteCount: 0,
67
+ cacheTtl: "5m",
68
+ lastDreamerRunAt: null,
69
+ projectIdentity: null,
70
+ compartmentTokens: 0,
71
+ factTokens: 0,
72
+ memoryTokens: 0,
73
+ docsTokens: 0,
74
+ profileTokens: 0,
75
+ conversationTokens: 0,
76
+ toolCallTokens: 0,
77
+ toolDefinitionTokens: 0,
78
+ executeThreshold: 65,
79
+ newWorkTokens: null,
80
+ totalInputTokens: null,
81
+ };
82
+
83
+ /**
84
+ * Per-session client-side sticky cache. Mirrors the server-side cache in
85
+ * `sidebar-snapshot-cache.ts` but covers the cases the server can't:
86
+ * - RPC call fails entirely (timeout, abort, parse error) → server is never reached
87
+ * - RPC server is not yet up (port file missing, retries exhausted)
88
+ * - Server returns an error envelope
89
+ *
90
+ * In all three cases the breakdown bar would otherwise disappear until the
91
+ * next successful refresh. With this cache, the client returns the most
92
+ * recent good snapshot for the same session so the UI stays stable through
93
+ * transient RPC blips. 5-minute staleness ceiling keeps it from showing
94
+ * obviously old data after long disconnects.
95
+ */
96
+ interface CachedSnapshot {
97
+ snapshot: SidebarSnapshot;
98
+ cachedAt: number;
99
+ }
100
+ const STICKY_TTL_MS = 5 * 60 * 1000;
101
+ const STICKY_MAX_ENTRIES = 100;
102
+ const stickySidebarCache = new Map<string, CachedSnapshot>();
103
+
104
+ function rememberSidebarSnapshot(snapshot: SidebarSnapshot): void {
105
+ if (!snapshot.sessionId || snapshot.inputTokens <= 0) return;
106
+ // LRU-style bound: drop the oldest entry once we hit the cap. With a
107
+ // 5-min TTL most stale entries time out naturally; this just prevents
108
+ // unbounded growth across many session switches in a long TUI session.
109
+ if (
110
+ stickySidebarCache.size >= STICKY_MAX_ENTRIES &&
111
+ !stickySidebarCache.has(snapshot.sessionId)
112
+ ) {
113
+ const firstKey = stickySidebarCache.keys().next().value;
114
+ if (firstKey) stickySidebarCache.delete(firstKey);
115
+ }
116
+ stickySidebarCache.set(snapshot.sessionId, {
117
+ snapshot,
118
+ cachedAt: Date.now(),
119
+ });
120
+ }
121
+
122
+ function recallSidebarSnapshot(sessionId: string, fallback: SidebarSnapshot): SidebarSnapshot {
123
+ const cached = stickySidebarCache.get(sessionId);
124
+ if (!cached) return fallback;
125
+ if (Date.now() - cached.cachedAt > STICKY_TTL_MS) {
126
+ stickySidebarCache.delete(sessionId);
127
+ return fallback;
128
+ }
129
+ if (!hasInFlightEvidence(fallback)) {
130
+ stickySidebarCache.delete(sessionId);
131
+ return fallback;
132
+ }
133
+ return cached.snapshot;
134
+ }
135
+
136
+ function hasInFlightEvidence(snapshot: SidebarSnapshot): boolean {
137
+ return (
138
+ snapshot.compartmentInProgress || snapshot.historianRunning || snapshot.pendingOpsCount > 0
139
+ );
140
+ }
141
+
142
+ /** Fetch sidebar snapshot from the server via RPC. */
143
+ export async function loadSidebarSnapshot(
144
+ sessionId: string,
145
+ directory: string,
146
+ ): Promise<SidebarSnapshot> {
147
+ const empty: SidebarSnapshot = { ...EMPTY_SNAPSHOT, sessionId };
148
+ if (!rpcClient) return recallSidebarSnapshot(sessionId, empty);
149
+ try {
150
+ const result = await rpcClient.call<SidebarSnapshot>("sidebar-snapshot", {
151
+ sessionId,
152
+ directory,
153
+ });
154
+ if ((result as unknown as Record<string, unknown>).error) {
155
+ return recallSidebarSnapshot(sessionId, empty);
156
+ }
157
+ // Trust successful server responses. The server has its own sticky
158
+ // sidebar cache (`sidebar-snapshot-cache.ts`) that handles transient
159
+ // zero-token windows by hybriding cached breakdown values into a
160
+ // fresh snapshot, AND clears that cache on `session.deleted`. If the
161
+ // server reaches us with `inputTokens === 0`, that's its considered
162
+ // answer — typically because the session was deleted, reverted, or
163
+ // is brand-new with no responses yet.
164
+ //
165
+ // Falling back to the client cache here would resurrect old token
166
+ // data for a deleted session (the client never sees `session.deleted`
167
+ // events, so its cache TTL is the only expiry). Sticky behavior is
168
+ // owned exclusively by the server side.
169
+ rememberSidebarSnapshot(result);
170
+ return result;
171
+ } catch {
172
+ return recallSidebarSnapshot(sessionId, empty);
173
+ }
174
+ }
175
+
176
+ /** Fetch full status detail from the server via RPC. */
177
+ export async function loadStatusDetail(
178
+ sessionId: string,
179
+ directory: string,
180
+ modelKey?: string,
181
+ ): Promise<StatusDetail> {
182
+ const emptyDetail: StatusDetail = {
183
+ ...EMPTY_SNAPSHOT,
184
+ sessionId,
185
+ tagCounter: 0,
186
+ activeTags: 0,
187
+ droppedTags: 0,
188
+ totalTags: 0,
189
+ activeBytes: 0,
190
+ lastResponseTime: 0,
191
+ lastNudgeTokens: 0,
192
+ lastTransformError: null,
193
+ isSubagent: false,
194
+ pendingOps: [],
195
+ contextLimit: 0,
196
+ cacheTtlMs: 0,
197
+ cacheRemainingMs: 0,
198
+ cacheExpired: false,
199
+ executeThreshold: 65,
200
+ executeThresholdMode: "percentage",
201
+ protectedTagCount: 20,
202
+ historyBudgetPercentage: 0.15,
203
+ historyBlockTokens: 0,
204
+ compressionBudget: null,
205
+ compressionUsage: null,
206
+ toastDurationMs: 5000,
207
+ };
208
+
209
+ if (!rpcClient) return emptyDetail;
210
+ try {
211
+ const result = await rpcClient.call<StatusDetail>("status-detail", {
212
+ sessionId,
213
+ directory,
214
+ modelKey,
215
+ });
216
+ if ((result as unknown as Record<string, unknown>).error) {
217
+ return emptyDetail;
218
+ }
219
+ return result;
220
+ } catch {
221
+ return emptyDetail;
222
+ }
223
+ }
224
+
225
+ const EMPTY_EMBED_DETAIL: EmbedDetail = {
226
+ enabled: false,
227
+ model: "off",
228
+ provider: "off",
229
+ session: { embedded: 0, total: 0 },
230
+ memories: { embedded: 0, total: 0 },
231
+ commits: { embedded: 0, total: 0, gitEnabled: false },
232
+ statusText: "Embedding is off (no provider configured).",
233
+ };
234
+
235
+ /** Fetch embedding coverage status for `/ctx-embed` via RPC. */
236
+ export async function loadEmbedDetail(sessionId: string, directory: string): Promise<EmbedDetail> {
237
+ if (!rpcClient) return EMPTY_EMBED_DETAIL;
238
+ try {
239
+ const result = await rpcClient.call<EmbedDetail>("embed-detail", {
240
+ sessionId,
241
+ directory,
242
+ });
243
+ if ((result as unknown as Record<string, unknown>).error) {
244
+ return EMPTY_EMBED_DETAIL;
245
+ }
246
+ return result;
247
+ } catch {
248
+ return EMPTY_EMBED_DETAIL;
249
+ }
250
+ }
251
+
252
+ /** Get compartment count via RPC. */
253
+ export async function getCompartmentCount(sessionId: string): Promise<number> {
254
+ if (!rpcClient) return 0;
255
+ try {
256
+ const result = await rpcClient.call<{ count: number }>("compartment-count", { sessionId });
257
+ return result.count ?? 0;
258
+ } catch {
259
+ return 0;
260
+ }
261
+ }
262
+
263
+ /** Send recomp request to server via RPC. */
264
+ export async function requestRecomp(sessionId: string): Promise<boolean> {
265
+ if (!rpcClient) return false;
266
+ try {
267
+ const result = await rpcClient.call<{ ok: boolean }>("recomp", { sessionId });
268
+ return result.ok ?? false;
269
+ } catch {
270
+ return false;
271
+ }
272
+ }
273
+
274
+ /** Run `/ctx-session-upgrade` for the session (full recomp + once-per-project
275
+ * memory migration). Fired from the upgrade dialog's "Run upgrade now" action. */
276
+ export async function requestUpgrade(sessionId: string): Promise<boolean> {
277
+ if (!rpcClient) return false;
278
+ try {
279
+ const result = await rpcClient.call<{ ok: boolean }>("upgrade", { sessionId });
280
+ return result.ok ?? false;
281
+ } catch {
282
+ return false;
283
+ }
284
+ }
285
+
286
+ /** Mark the upgrade reminder dismissed (the user made an explicit Confirm/Cancel
287
+ * choice), setting the durable stamp so the FRESH dialog won't re-show. Resume
288
+ * prompts are staging-driven and unaffected. */
289
+ export async function dismissUpgradeReminder(sessionId: string): Promise<boolean> {
290
+ if (!rpcClient) return false;
291
+ try {
292
+ const result = await rpcClient.call<{ ok: boolean }>("dismiss-upgrade-reminder", {
293
+ sessionId,
294
+ });
295
+ return result.ok ?? false;
296
+ } catch {
297
+ return false;
298
+ }
299
+ }
300
+
301
+ /** Resolve global toast duration from server config via RPC. */
302
+ export async function loadToastDurationMs(): Promise<number> {
303
+ if (!rpcClient) return 5000;
304
+ try {
305
+ const result = await rpcClient.call<{ toastDurationMs?: number }>("toast-duration", {});
306
+ return typeof result.toastDurationMs === "number" ? result.toastDurationMs : 5000;
307
+ } catch {
308
+ return 5000;
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Fetch the current startup announcement from the server, if any.
314
+ * Returns `{show: false}` when there's nothing to announce or when the
315
+ * configured ANNOUNCEMENT_VERSION has already been dismissed.
316
+ */
317
+ export interface AnnouncementResponse {
318
+ show: boolean;
319
+ version?: string;
320
+ features?: string[];
321
+ footer?: string;
322
+ }
323
+
324
+ export async function getAnnouncement(): Promise<AnnouncementResponse> {
325
+ if (!rpcClient) return { show: false };
326
+ try {
327
+ const result = await rpcClient.call<{
328
+ show?: boolean;
329
+ version?: string;
330
+ features?: string[];
331
+ footer?: string;
332
+ }>("get-announcement", {});
333
+ return {
334
+ show: result.show === true,
335
+ version: result.version,
336
+ features: Array.isArray(result.features) ? result.features : undefined,
337
+ footer: typeof result.footer === "string" ? result.footer : undefined,
338
+ };
339
+ } catch {
340
+ return { show: false };
341
+ }
342
+ }
343
+
344
+ /** Mark the current ANNOUNCEMENT_VERSION as dismissed on the server. */
345
+ export async function markAnnounced(): Promise<boolean> {
346
+ if (!rpcClient) return false;
347
+ try {
348
+ const result = await rpcClient.call<{ ok?: boolean }>("mark-announced", {});
349
+ return result.ok === true;
350
+ } catch {
351
+ return false;
352
+ }
353
+ }