@hellcoder/companion 0.116.0 → 0.116.1-preview.20260814041931.d191f06

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 (49) hide show
  1. package/dist/assets/{AgentsPage-BBi92OuO.js → AgentsPage-BAalV1v6.js} +5 -5
  2. package/dist/assets/CronManager-B2Vo1YmB.js +1 -0
  3. package/dist/assets/{DashboardPage-BvuDoRlp.js → DashboardPage-BOvDiP1W.js} +1 -1
  4. package/dist/assets/{IntegrationsPage-CK_ThzWg.js → IntegrationsPage-kOXLH5KU.js} +1 -1
  5. package/dist/assets/{LinearOAuthSettingsPage-InTf-E7u.js → LinearOAuthSettingsPage-H-uUJ-ub.js} +1 -1
  6. package/dist/assets/{LinearSettingsPage-BwjC9Vd-.js → LinearSettingsPage-CD_x4AGr.js} +1 -1
  7. package/dist/assets/MagicUIDashboard-9Jgiv4rb.js +1119 -0
  8. package/dist/assets/MagicUIView-BLEcS9XO.js +1 -0
  9. package/dist/assets/{Playground-DlzomEOu.js → Playground-QLrd9q16.js} +10 -9
  10. package/dist/assets/{PromptsPage-hRZhLUYR.js → PromptsPage-11eJ6ak7.js} +1 -1
  11. package/dist/assets/{RunsPage-zsVmbFoi.js → RunsPage-Bz3udIL2.js} +1 -1
  12. package/dist/assets/{SandboxManager-Bq_aXA1z.js → SandboxManager-Dv47pbC6.js} +1 -1
  13. package/dist/assets/SettingsPage-C_2ftG-C.js +1 -0
  14. package/dist/assets/{TailscalePage-fE7Z9uiF.js → TailscalePage-DzMX_7XA.js} +1 -1
  15. package/dist/assets/index-DSCRqqpK.css +1 -0
  16. package/dist/assets/index-DspbwgSL.js +140 -0
  17. package/dist/assets/{sw-register-Cjt2BQTW.js → sw-register-CarYQEeI.js} +1 -1
  18. package/dist/index.html +2 -2
  19. package/dist/sw.js +1 -1
  20. package/package.json +4 -1
  21. package/server/async-message-queue.ts +38 -0
  22. package/server/auto-namer.test.ts +8 -0
  23. package/server/claude-sdk-adapter.ts +1 -36
  24. package/server/event-bus-types.ts +32 -1
  25. package/server/index.ts +7 -0
  26. package/server/linear-agent-bridge.ts +6 -72
  27. package/server/linear-connections.test.ts +8 -0
  28. package/server/magic-ui-ops.test.ts +239 -0
  29. package/server/magic-ui-ops.ts +365 -0
  30. package/server/magic-ui-settings.test.ts +56 -0
  31. package/server/magic-ui-settings.ts +38 -0
  32. package/server/magic-ui-store.ts +94 -0
  33. package/server/magic-ui-types.ts +138 -0
  34. package/server/magic-ui-watcher.test.ts +288 -0
  35. package/server/magic-ui-watcher.ts +623 -0
  36. package/server/message-extract.ts +79 -0
  37. package/server/routes/settings-routes.ts +22 -0
  38. package/server/routes.test.ts +59 -0
  39. package/server/session-types.ts +15 -1
  40. package/server/settings-manager.test.ts +6 -0
  41. package/server/settings-manager.ts +24 -1
  42. package/server/ws-bridge-browser-ingest.ts +1 -0
  43. package/server/ws-bridge-codex.test.ts +12 -0
  44. package/server/ws-bridge-controls.ts +9 -0
  45. package/server/ws-bridge.ts +65 -0
  46. package/dist/assets/CronManager-DxLaNh9T.js +0 -1
  47. package/dist/assets/SettingsPage-BvrrlEKj.js +0 -1
  48. package/dist/assets/index--SnXduR0.js +0 -139
  49. package/dist/assets/index-DcQxHfE7.css +0 -1
@@ -0,0 +1,623 @@
1
+ // ─── MagicUI watcher ─────────────────────────────────────────────────────────
2
+ //
3
+ // One cheap long-lived Haiku session per opted-in companion session. It taps
4
+ // the companionBus message stream (works for BOTH Claude and Codex main
5
+ // sessions), builds small incremental digests — never re-reading the
6
+ // transcript — and feeds them to the Agent SDK's `query()` over a streaming
7
+ // input queue. The model replies with JSON patch ops that are validated,
8
+ // folded into the canonical dashboard state, persisted, and broadcast to
9
+ // browsers as full snapshots.
10
+ //
11
+ // Auth: the SDK spawns the logged-in `claude` binary, so the watcher rides
12
+ // the Claude Code subscription exactly like normal sessions. NEVER inject
13
+ // ANTHROPIC_API_KEY here (metered billing; the reference instance has none).
14
+ //
15
+ // Context growth: the watcher never re-reads history. When its own session
16
+ // gets long it restarts seeded with the serialized dashboard state + the
17
+ // rolling session_summary the model itself maintains — the dashboard IS the
18
+ // compressed memory.
19
+
20
+ import { query as sdkQuery } from "@anthropic-ai/claude-agent-sdk";
21
+ import type { Options as SdkOptions, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
22
+ import { AsyncMessageQueue } from "./async-message-queue.js";
23
+ import { companionBus } from "./event-bus.js";
24
+ import { log } from "./logger.js";
25
+ import { resolveBinary } from "./path-resolver.js";
26
+ import { getEffectiveMagicUi } from "./magic-ui-settings.js";
27
+ import { MagicUiStore } from "./magic-ui-store.js";
28
+ import {
29
+ appendDecision,
30
+ applyOps,
31
+ makeDecisionEntry,
32
+ parseMagicUiReply,
33
+ validateOps,
34
+ } from "./magic-ui-ops.js";
35
+ import {
36
+ emptyMagicUiState,
37
+ type MagicUiDashboardState,
38
+ type MagicUiStatus,
39
+ } from "./magic-ui-types.js";
40
+ import {
41
+ extractTextFromAssistant,
42
+ extractToolUses,
43
+ } from "./message-extract.js";
44
+ import type {
45
+ BrowserIncomingMessage,
46
+ PermissionRequest,
47
+ SessionState,
48
+ } from "./session-types.js";
49
+ import { tmpdir } from "node:os";
50
+
51
+ // ── Tuning ──────────────────────────────────────────────────────────────────
52
+
53
+ /** Flush mid-turn if the accumulator has been non-empty this long. */
54
+ const MAX_LATENCY_MS = 20_000;
55
+ /** Total digest budget per watcher turn. */
56
+ const DIGEST_MAX_CHARS = 4_000;
57
+ /** Per-assistant-text budget inside a digest. */
58
+ const TEXT_SNIPPET_CHARS = 400;
59
+ /** Stop an idle watcher after this long without any digestible activity. */
60
+ const IDLE_STOP_MS = 10 * 60_000;
61
+ /** Restart the Haiku session (with seed) after this many completed turns. */
62
+ const RESTART_AFTER_TURNS = 40;
63
+ /** Give up (degraded) after this many consecutive failed query starts. */
64
+ const MAX_CRASH_RETRIES = 3;
65
+
66
+ export const MAGIC_UI_SYSTEM_PROMPT = `You are a live dashboard artist for a coding session. You receive incremental digests of what a coding agent (and its user) did, and you maintain a single fixed-viewport dashboard by emitting JSON patch operations. You never write prose to the user.
67
+
68
+ OUTPUT FORMAT — HARD RULE: reply with exactly one \`\`\`json code block containing an ARRAY of ops. Nothing else. If nothing meaningful changed, reply with [].
69
+
70
+ OPS:
71
+ - {"op":"set_slot","slot":"<name>","html":"<fragment>","title":"...","area":"hero|main","span":1|2|3}
72
+ Allowed tags: div span p h1-h4 ul ol li table thead tbody tr td th strong em b i u s code pre small br hr blockquote progress mark kbd sub sup. class and data-* attributes only. NO style/script/links/images.
73
+ - {"op":"remove_slot","slot":"<name>"}
74
+ - {"op":"layout","slots":[{"slot":"...","area":"hero|main","span":1|2|3}]}
75
+ - {"op":"chart","slot":"...","spec":{"kind":"bar|line|donut|sparkline","labels":[...],"series":[{"label":"...","data":[...]}]},"title":"...","area":"...","span":...}
76
+ - {"op":"stat","slot":"...","label":"...","value":"...","trend":"up|down|flat","area":"...","span":...}
77
+ - {"op":"snippet","slot":"...","title":"...","code":"...","language":"bash"} — for any script/command the USER must run themselves; rendered copyable.
78
+ - {"op":"open_item","id":"<stable-id>","text":"...","kind":"action|question|blocker"} — OPEN points where the agent waits on the user (questions asked, manual steps requested, blockers). Keep them current; emit {"op":"resolve_item","id":"..."} the moment a point is addressed.
79
+ - {"op":"decision_log","title":"...","detail":"..."} — ONLY for notable choices the agent itself made (approach picked, tradeoff taken). User choices are logged automatically — never duplicate them.
80
+ - {"op":"session_summary","text":"..."} — maintain a rolling ~5 sentence summary of the WHOLE session. Refresh it every few turns. It is not rendered; it is your own memory across restarts.
81
+
82
+ DASHBOARD RULES:
83
+ - Fixed viewport, NO scrolling: keep at most 8 visible slots. When new content arrives, CONDENSE — merge older work into a compact summary slot or remove it — never just add.
84
+ - Newest/most important activity belongs in area "hero". Important points of the discussion must stay visible in a condensed form.
85
+ - Charts and stats ONLY for genuinely numeric data (tests passed, files changed, durations, counts). Never invent numbers.
86
+ - Decisions and open points are first-class: reference pending decisions in content, but NEVER build buttons or forms — the runtime renders the real controls.
87
+ - Emit MINIMAL ops: only slots that actually changed. Keep every html fragment small and dense. Prefer 1 chart + stats + short lists over walls of text.`;
88
+
89
+ // ── Digest accumulation ─────────────────────────────────────────────────────
90
+
91
+ interface TurnAccumulator {
92
+ texts: string[];
93
+ tools: Array<{ name: string; summary: string }>;
94
+ files: Set<string>;
95
+ permissionLines: string[];
96
+ resultLine: string | null;
97
+ firstEventAt: number | null;
98
+ }
99
+
100
+ function newAccumulator(): TurnAccumulator {
101
+ return { texts: [], tools: [], files: new Set(), permissionLines: [], resultLine: null, firstEventAt: null };
102
+ }
103
+
104
+ function accumulatorEmpty(acc: TurnAccumulator): boolean {
105
+ return acc.texts.length === 0 && acc.tools.length === 0 && acc.permissionLines.length === 0 && acc.resultLine === null;
106
+ }
107
+
108
+ function toolSummary(tool: { name: string; rawInput?: Record<string, unknown> }): string {
109
+ const input = tool.rawInput ?? {};
110
+ const detail =
111
+ typeof input.file_path === "string" ? input.file_path
112
+ : typeof input.command === "string" ? String(input.command).slice(0, 120)
113
+ : typeof input.pattern === "string" ? String(input.pattern).slice(0, 60)
114
+ : typeof input.description === "string" ? String(input.description).slice(0, 80)
115
+ : "";
116
+ return detail ? `${tool.name}(${detail})` : tool.name;
117
+ }
118
+
119
+ export function buildDigest(acc: TurnAccumulator, seq: number): string {
120
+ const lines: string[] = [`TURN DIGEST #${seq}`];
121
+ if (acc.texts.length) {
122
+ const joined = acc.texts
123
+ .map((t) => t.length > TEXT_SNIPPET_CHARS ? `${t.slice(0, TEXT_SNIPPET_CHARS)}…` : t)
124
+ .join("\n");
125
+ lines.push(`assistant:\n${joined}`);
126
+ }
127
+ if (acc.tools.length) {
128
+ const maxTools = 15;
129
+ const shown = acc.tools.slice(0, maxTools).map((t) => t.summary).join(", ");
130
+ const extra = acc.tools.length > maxTools ? ` [+${acc.tools.length - maxTools} more tool calls]` : "";
131
+ lines.push(`tools: ${shown}${extra}`);
132
+ }
133
+ if (acc.files.size) {
134
+ lines.push(`files touched: ${[...acc.files].slice(0, 20).join(", ")}`);
135
+ }
136
+ if (acc.permissionLines.length) {
137
+ lines.push(`decisions: ${acc.permissionLines.join("; ")}`);
138
+ }
139
+ if (acc.resultLine) lines.push(`result: ${acc.resultLine}`);
140
+ let digest = lines.join("\n");
141
+ if (digest.length > DIGEST_MAX_CHARS) {
142
+ digest = `${digest.slice(0, DIGEST_MAX_CHARS)}\n…[digest truncated]`;
143
+ }
144
+ return digest;
145
+ }
146
+
147
+ // ── Watcher (one per session) ───────────────────────────────────────────────
148
+
149
+ type QueryFn = typeof sdkQuery;
150
+
151
+ interface WatcherDeps {
152
+ broadcast: (sessionId: string, msg: BrowserIncomingMessage) => void;
153
+ store: MagicUiStore;
154
+ model: string;
155
+ queryFn?: QueryFn;
156
+ /** Injectable clock for tests. */
157
+ now?: () => number;
158
+ }
159
+
160
+ export class MagicUiWatcher {
161
+ private acc = newAccumulator();
162
+ private queue: AsyncMessageQueue<SDKUserMessage> | null = null;
163
+ private abortController: AbortController | null = null;
164
+ private inFlight = false;
165
+ private pendingDigest: string | null = null;
166
+ private digestSeq = 0;
167
+ private completedTurns = 0;
168
+ private crashRetries = 0;
169
+ private latencyTimer: ReturnType<typeof setTimeout> | null = null;
170
+ private idleTimer: ReturnType<typeof setTimeout> | null = null;
171
+ private stopped = false;
172
+ state: MagicUiDashboardState;
173
+
174
+ constructor(
175
+ private readonly sessionId: string,
176
+ private readonly deps: WatcherDeps,
177
+ ) {
178
+ this.state = deps.store.load(sessionId, this.now());
179
+ }
180
+
181
+ private now(): number {
182
+ return this.deps.now ? this.deps.now() : Date.now();
183
+ }
184
+
185
+ start(): void {
186
+ this.stopped = false;
187
+ this.setStatus("live");
188
+ this.broadcastState();
189
+ this.startQuery(this.seedMessage());
190
+ this.armIdleTimer();
191
+ }
192
+
193
+ /** First message of a (re)started watcher session: current dashboard + summary. */
194
+ private seedMessage(): string | null {
195
+ if (this.state.version === 0) return null;
196
+ const seedState = { slots: this.state.slots, layout: this.state.layout, openItems: this.state.openItems };
197
+ return [
198
+ "You are resuming a dashboard you maintain. CURRENT DASHBOARD STATE:",
199
+ JSON.stringify(seedState).slice(0, 12_000),
200
+ this.state.sessionSummary ? `SESSION SUMMARY SO FAR: ${this.state.sessionSummary}` : "",
201
+ "Continue maintaining this dashboard. Reply [] to acknowledge.",
202
+ ].filter(Boolean).join("\n");
203
+ }
204
+
205
+ private startQuery(seed: string | null): void {
206
+ const binary = resolveBinary("claude");
207
+ if (!binary) {
208
+ this.setStatus("degraded");
209
+ this.broadcastState();
210
+ return;
211
+ }
212
+ const queue = new AsyncMessageQueue<SDKUserMessage>();
213
+ const abort = new AbortController();
214
+ this.queue = queue;
215
+ this.abortController = abort;
216
+ this.inFlight = false;
217
+ this.completedTurns = 0;
218
+
219
+ const options: SdkOptions = {
220
+ abortController: abort,
221
+ model: this.deps.model,
222
+ systemPrompt: MAGIC_UI_SYSTEM_PROMPT,
223
+ // The watcher is a pure text transformer: no tools, ever. Deny at
224
+ // every layer in case the model tries anyway.
225
+ allowedTools: [],
226
+ maxTurns: RESTART_AFTER_TURNS + 5,
227
+ cwd: tmpdir(), // neutral cwd — no project CLAUDE.md leaks into the watcher
228
+ pathToClaudeCodeExecutable: binary,
229
+ includePartialMessages: false,
230
+ // Full server env: the CLI resolves subscription OAuth itself.
231
+ // Do NOT inject ANTHROPIC_API_KEY (see claude-sdk-adapter.ts).
232
+ env: { ...(process.env as Record<string, string>) },
233
+ canUseTool: async () => ({ behavior: "deny", message: "The dashboard watcher may not use tools." }),
234
+ };
235
+
236
+ const q = (this.deps.queryFn ?? sdkQuery)({ prompt: queue, options });
237
+ void this.pump(q, abort);
238
+
239
+ if (seed) {
240
+ this.inFlight = true;
241
+ queue.push(this.userMessage(seed));
242
+ }
243
+ }
244
+
245
+ private userMessage(text: string): SDKUserMessage {
246
+ return {
247
+ type: "user",
248
+ message: { role: "user", content: text },
249
+ parent_tool_use_id: null,
250
+ } as SDKUserMessage;
251
+ }
252
+
253
+ private async pump(q: AsyncIterable<unknown>, abort: AbortController): Promise<void> {
254
+ let turnTexts: string[] = [];
255
+ try {
256
+ for await (const raw of q) {
257
+ const msg = raw as { type?: string; subtype?: string; message?: { content?: unknown } };
258
+ if (msg.type === "assistant") {
259
+ const text = extractTextFromAssistant(msg as unknown as BrowserIncomingMessage);
260
+ if (text) turnTexts.push(text);
261
+ } else if (msg.type === "result") {
262
+ this.crashRetries = 0;
263
+ this.completedTurns += 1;
264
+ this.handleReply(turnTexts.join("\n"));
265
+ turnTexts = [];
266
+ this.finishTurn();
267
+ }
268
+ }
269
+ } catch (err) {
270
+ if (!abort.signal.aborted) {
271
+ log.warn("magic-ui", "Watcher query crashed", {
272
+ sessionId: this.sessionId,
273
+ error: err instanceof Error ? err.message : String(err),
274
+ });
275
+ this.handleCrash();
276
+ return;
277
+ }
278
+ }
279
+ // Clean end (abort/close): nothing to do — stop()/restart() own the state.
280
+ }
281
+
282
+ private handleCrash(): void {
283
+ if (this.stopped) return;
284
+ this.crashRetries += 1;
285
+ if (this.crashRetries > MAX_CRASH_RETRIES) {
286
+ this.setStatus("degraded");
287
+ this.broadcastState();
288
+ return;
289
+ }
290
+ const backoff = Math.min(30_000, 2_000 * 2 ** (this.crashRetries - 1));
291
+ setTimeout(() => {
292
+ if (!this.stopped) this.startQuery(this.seedMessage());
293
+ }, backoff);
294
+ }
295
+
296
+ private handleReply(text: string): void {
297
+ const parsed = parseMagicUiReply(text);
298
+ if (parsed === null) {
299
+ log.warn("magic-ui", "Unparseable watcher reply", { sessionId: this.sessionId, sample: text.slice(0, 200) });
300
+ return;
301
+ }
302
+ if (parsed.length === 0) return;
303
+ const ops = validateOps(parsed);
304
+ if (ops.length === 0) return;
305
+ this.state = applyOps(this.state, ops, this.now());
306
+ this.persistAndBroadcast();
307
+ }
308
+
309
+ /** A watcher turn finished: send the queued digest, or restart on budget. */
310
+ private finishTurn(): void {
311
+ this.inFlight = false;
312
+ if (this.completedTurns >= RESTART_AFTER_TURNS) {
313
+ this.restart();
314
+ return;
315
+ }
316
+ if (this.pendingDigest) {
317
+ const digest = this.pendingDigest;
318
+ this.pendingDigest = null;
319
+ this.sendDigest(digest);
320
+ }
321
+ }
322
+
323
+ private restart(): void {
324
+ this.abortController?.abort();
325
+ this.queue?.close();
326
+ this.startQuery(this.seedMessage());
327
+ if (this.pendingDigest) {
328
+ const digest = this.pendingDigest;
329
+ this.pendingDigest = null;
330
+ this.sendDigest(digest);
331
+ }
332
+ }
333
+
334
+ private sendDigest(digest: string): void {
335
+ if (!this.queue) return;
336
+ if (this.inFlight) {
337
+ // One turn in flight at a time; merge follow-ups into one pending digest.
338
+ this.pendingDigest = this.pendingDigest
339
+ ? `${this.pendingDigest}\n\n${digest}`.slice(-DIGEST_MAX_CHARS * 2)
340
+ : digest;
341
+ return;
342
+ }
343
+ this.inFlight = true;
344
+ this.queue.push(this.userMessage(digest));
345
+ }
346
+
347
+ // ── Event intake ──────────────────────────────────────────────────────
348
+
349
+ onAssistantMessage(msg: BrowserIncomingMessage): void {
350
+ const text = extractTextFromAssistant(msg);
351
+ if (text.trim()) this.acc.texts.push(text.trim());
352
+ for (const tool of extractToolUses(msg)) {
353
+ this.acc.tools.push({ name: tool.name, summary: toolSummary(tool) });
354
+ const input = tool.rawInput ?? {};
355
+ if ((tool.name === "Edit" || tool.name === "Write" || tool.name === "NotebookEdit") && typeof input.file_path === "string") {
356
+ this.acc.files.add(input.file_path);
357
+ }
358
+ }
359
+ this.touch();
360
+ this.armLatencyTimer();
361
+ }
362
+
363
+ onResult(msg: BrowserIncomingMessage): void {
364
+ if (msg.type === "result") {
365
+ const data = (msg as { data?: { duration_ms?: number; num_turns?: number; total_cost_usd?: number } }).data;
366
+ const parts: string[] = [];
367
+ if (typeof data?.duration_ms === "number") parts.push(`${Math.round(data.duration_ms / 1000)}s`);
368
+ if (typeof data?.num_turns === "number") parts.push(`${data.num_turns} turns`);
369
+ this.acc.resultLine = parts.join(", ") || "turn completed";
370
+ }
371
+ this.touch();
372
+ this.flush("turn-end");
373
+ }
374
+
375
+ onPermissionRequested(request: PermissionRequest): void {
376
+ const kind = request.tool_name === "AskUserQuestion" ? "QUESTION for user" : `PERMISSION wanted: ${request.tool_name}`;
377
+ const detail = request.tool_name === "AskUserQuestion"
378
+ ? (Array.isArray(request.input.questions)
379
+ ? request.input.questions.map((q) => (q as { question?: string })?.question || "").filter(Boolean).join(" / ").slice(0, 200)
380
+ : "")
381
+ : typeof request.input.command === "string" ? String(request.input.command).slice(0, 120) : (request.description ?? "");
382
+ this.acc.permissionLines.push(`PENDING ${kind}${detail ? ` — ${detail}` : ""}`);
383
+ this.touch();
384
+ // Decisions must reach the dashboard fast.
385
+ this.flush("permission");
386
+ }
387
+
388
+ onPermissionResolved(payload: {
389
+ behavior: "allow" | "deny";
390
+ resolvedBy: "user" | "ai";
391
+ toolName?: string;
392
+ answers?: Record<string, string>;
393
+ reason?: string;
394
+ }): void {
395
+ // Server-generated decision-log entry — correctness never depends on Haiku.
396
+ const source = payload.resolvedBy === "ai" ? "ai_auto" : "user";
397
+ const title = payload.toolName === "AskUserQuestion"
398
+ ? "Answered the agent's question"
399
+ : payload.toolName === "ExitPlanMode"
400
+ ? "Plan approval"
401
+ : payload.toolName || "Permission";
402
+ const detail = payload.answers
403
+ ? Object.entries(payload.answers).map(([q, a]) => (q ? `${q} → ${a}` : a)).join("; ").slice(0, 400)
404
+ : payload.reason
405
+ ? `${payload.behavior === "allow" ? "Allowed" : "Denied"} (${payload.reason})`
406
+ : payload.behavior === "allow" ? "Allowed" : "Denied";
407
+ this.state = appendDecision(
408
+ this.state,
409
+ makeDecisionEntry(source, title, detail, this.now(), payload.behavior),
410
+ );
411
+ this.persistAndBroadcast();
412
+
413
+ // And give the watcher context.
414
+ this.acc.permissionLines.push(
415
+ `RESOLVED ${title}: ${detail}${payload.resolvedBy === "ai" ? " [auto-resolved by AI validation]" : ""}`,
416
+ );
417
+ this.touch();
418
+ this.armLatencyTimer();
419
+ }
420
+
421
+ onPermissionCancelled(): void {
422
+ this.acc.permissionLines.push("A pending request was cancelled (likely interrupt)");
423
+ this.touch();
424
+ }
425
+
426
+ // ── Flush machinery ───────────────────────────────────────────────────
427
+
428
+ private touch(): void {
429
+ if (this.acc.firstEventAt === null) this.acc.firstEventAt = this.now();
430
+ this.armIdleTimer();
431
+ }
432
+
433
+ private armLatencyTimer(): void {
434
+ if (this.latencyTimer) return;
435
+ this.latencyTimer = setTimeout(() => {
436
+ this.latencyTimer = null;
437
+ this.flush("max-latency");
438
+ }, MAX_LATENCY_MS);
439
+ }
440
+
441
+ private armIdleTimer(): void {
442
+ if (this.idleTimer) clearTimeout(this.idleTimer);
443
+ this.idleTimer = setTimeout(() => this.suspendIdle(), IDLE_STOP_MS);
444
+ }
445
+
446
+ flush(reason: string): void {
447
+ if (this.latencyTimer) {
448
+ clearTimeout(this.latencyTimer);
449
+ this.latencyTimer = null;
450
+ }
451
+ if (accumulatorEmpty(this.acc)) return;
452
+ if (this.stopped) {
453
+ // Idle-suspended watcher: activity arrived — lazily restart.
454
+ this.start();
455
+ }
456
+ this.digestSeq += 1;
457
+ const digest = buildDigest(this.acc, this.digestSeq);
458
+ this.acc = newAccumulator();
459
+ log.info("magic-ui", `Flushing digest (${reason})`, { sessionId: this.sessionId, seq: this.digestSeq });
460
+ this.sendDigest(digest);
461
+ }
462
+
463
+ /** Idle for a while → stop burning a subprocess; state stays broadcastable. */
464
+ private suspendIdle(): void {
465
+ if (this.stopped) return;
466
+ log.info("magic-ui", "Suspending idle watcher", { sessionId: this.sessionId });
467
+ this.teardownQuery();
468
+ this.stopped = true;
469
+ this.setStatus("stopped");
470
+ this.persistAndBroadcast();
471
+ }
472
+
473
+ stop(): void {
474
+ this.stopped = true;
475
+ if (this.latencyTimer) clearTimeout(this.latencyTimer);
476
+ if (this.idleTimer) clearTimeout(this.idleTimer);
477
+ this.latencyTimer = null;
478
+ this.idleTimer = null;
479
+ this.teardownQuery();
480
+ this.setStatus("stopped");
481
+ this.persistAndBroadcast();
482
+ this.deps.store.flush(this.sessionId);
483
+ }
484
+
485
+ private teardownQuery(): void {
486
+ this.abortController?.abort();
487
+ this.queue?.close();
488
+ this.abortController = null;
489
+ this.queue = null;
490
+ this.inFlight = false;
491
+ }
492
+
493
+ // ── State plumbing ────────────────────────────────────────────────────
494
+
495
+ private setStatus(status: MagicUiStatus): void {
496
+ if (this.state.status === status) return;
497
+ this.state = { ...this.state, status, version: this.state.version + 1, updatedAt: this.now() };
498
+ }
499
+
500
+ private persistAndBroadcast(): void {
501
+ this.deps.store.save(this.sessionId, this.state);
502
+ this.broadcastState();
503
+ }
504
+
505
+ broadcastState(): void {
506
+ this.deps.broadcast(this.sessionId, { type: "magic_ui_state", state: this.state });
507
+ }
508
+ }
509
+
510
+ // ── Manager ─────────────────────────────────────────────────────────────────
511
+
512
+ export interface MagicUiManagerDeps {
513
+ broadcast: (sessionId: string, msg: BrowserIncomingMessage) => void;
514
+ getSessionState: (sessionId: string) => SessionState | undefined;
515
+ store?: MagicUiStore;
516
+ queryFn?: QueryFn;
517
+ now?: () => number;
518
+ }
519
+
520
+ export class MagicUiWatcherManager {
521
+ private watchers = new Map<string, MagicUiWatcher>();
522
+ private store: MagicUiStore;
523
+ private unsubscribes: Array<() => void> = [];
524
+
525
+ constructor(private readonly deps: MagicUiManagerDeps) {
526
+ this.store = deps.store ?? new MagicUiStore();
527
+ this.unsubscribes.push(
528
+ companionBus.on("magic-ui:setting-changed", ({ sessionId }) => this.reconcile(sessionId)),
529
+ companionBus.on("magic-ui:sync-requested", ({ sessionId }) => this.handleSyncRequest(sessionId)),
530
+ companionBus.on("message:assistant", ({ sessionId, message }) => {
531
+ this.watcherFor(sessionId)?.onAssistantMessage(message);
532
+ }),
533
+ companionBus.on("message:result", ({ sessionId, message }) => {
534
+ this.watcherFor(sessionId)?.onResult(message);
535
+ }),
536
+ companionBus.on("permission:requested", ({ sessionId, request }) => {
537
+ this.watcherFor(sessionId)?.onPermissionRequested(request);
538
+ }),
539
+ companionBus.on("permission:resolved", ({ sessionId, ...payload }) => {
540
+ this.watcherFor(sessionId)?.onPermissionResolved(payload);
541
+ }),
542
+ companionBus.on("permission:cancelled", ({ sessionId }) => {
543
+ this.watcherFor(sessionId)?.onPermissionCancelled();
544
+ }),
545
+ companionBus.on("session:exited", ({ sessionId }) => {
546
+ // The CLI exited, but the session may relaunch; keep the watcher
547
+ // unless the user opted out — just note quietness via idle timer.
548
+ void sessionId;
549
+ }),
550
+ );
551
+ }
552
+
553
+ /** Effective-enabled watcher for a session, lazily created (e.g. after a
554
+ * server restart where the persisted session state says active). */
555
+ private watcherFor(sessionId: string): MagicUiWatcher | undefined {
556
+ const existing = this.watchers.get(sessionId);
557
+ if (existing) return existing;
558
+ const sessionState = this.deps.getSessionState(sessionId);
559
+ if (!sessionState) return undefined;
560
+ const effective = getEffectiveMagicUi(sessionState);
561
+ if (!effective.enabled) return undefined;
562
+ return this.startWatcher(sessionId, effective.model);
563
+ }
564
+
565
+ private startWatcher(sessionId: string, model: string): MagicUiWatcher {
566
+ const watcher = new MagicUiWatcher(sessionId, {
567
+ broadcast: this.deps.broadcast,
568
+ store: this.store,
569
+ model,
570
+ queryFn: this.deps.queryFn,
571
+ now: this.deps.now,
572
+ });
573
+ this.watchers.set(sessionId, watcher);
574
+ watcher.start();
575
+ log.info("magic-ui", "Watcher started", { sessionId, model });
576
+ return watcher;
577
+ }
578
+
579
+ /** Re-evaluate opt-in for a session after set_magic_ui. */
580
+ reconcile(sessionId: string): void {
581
+ const sessionState = this.deps.getSessionState(sessionId);
582
+ const effective = sessionState ? getEffectiveMagicUi(sessionState) : { enabled: false, model: "" };
583
+ const existing = this.watchers.get(sessionId);
584
+ if (effective.enabled && !existing) {
585
+ this.startWatcher(sessionId, effective.model);
586
+ } else if (!effective.enabled && existing) {
587
+ existing.stop();
588
+ this.watchers.delete(sessionId);
589
+ log.info("magic-ui", "Watcher stopped (opt-out)", { sessionId });
590
+ }
591
+ }
592
+
593
+ private handleSyncRequest(sessionId: string): void {
594
+ const watcher = this.watcherFor(sessionId);
595
+ if (watcher) {
596
+ watcher.broadcastState();
597
+ return;
598
+ }
599
+ // Not active: still serve the persisted snapshot so reloads of an
600
+ // opted-out-but-previously-magic session show the last dashboard.
601
+ const state = this.store.load(sessionId, this.deps.now ? this.deps.now() : Date.now());
602
+ if (state.version > 0) {
603
+ this.deps.broadcast(sessionId, { type: "magic_ui_state", state });
604
+ }
605
+ }
606
+
607
+ removeSession(sessionId: string): void {
608
+ const watcher = this.watchers.get(sessionId);
609
+ if (watcher) {
610
+ watcher.stop();
611
+ this.watchers.delete(sessionId);
612
+ }
613
+ this.store.remove(sessionId);
614
+ }
615
+
616
+ shutdown(): void {
617
+ for (const unsub of this.unsubscribes.splice(0)) unsub();
618
+ for (const [id, watcher] of this.watchers) {
619
+ watcher.stop();
620
+ this.watchers.delete(id);
621
+ }
622
+ }
623
+ }