@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,239 @@
1
+ // Tests for MagicUI op parsing, validation/sanitization and state reduction.
2
+ //
3
+ // The watcher model's output is untrusted: these tests pin down that
4
+ // (a) replies parse leniently (fenced/bare/prose-wrapped JSON) but fail
5
+ // closed on garbage,
6
+ // (b) HTML is sanitized to an inert allowlist (no scripts, handlers,
7
+ // inline styles or URLs — the iframe sandbox is the second layer),
8
+ // (c) budgets (ops/slots/log lengths) are enforced in the reducer, and
9
+ // (d) reduction is pure, version-bumping, and never mutates its input.
10
+ import { describe, expect, it } from "vitest";
11
+ import {
12
+ appendDecision,
13
+ applyOps,
14
+ makeDecisionEntry,
15
+ parseMagicUiReply,
16
+ sanitizeMagicHtml,
17
+ validateOps,
18
+ } from "./magic-ui-ops.js";
19
+ import {
20
+ MAGIC_UI_MAX_DECISIONS,
21
+ MAGIC_UI_MAX_OPS_PER_TURN,
22
+ MAGIC_UI_MAX_SLOTS,
23
+ emptyMagicUiState,
24
+ type MagicUiOp,
25
+ } from "./magic-ui-types.js";
26
+
27
+ const NOW = 1_750_000_000_000;
28
+
29
+ describe("parseMagicUiReply", () => {
30
+ it("parses a fenced json block", () => {
31
+ const reply = 'Here you go:\n```json\n[{"op":"remove_slot","slot":"a"}]\n```';
32
+ expect(parseMagicUiReply(reply)).toEqual([{ op: "remove_slot", slot: "a" }]);
33
+ });
34
+
35
+ it("parses a bare JSON array", () => {
36
+ expect(parseMagicUiReply('[{"op":"remove_slot","slot":"a"}]')).toEqual([
37
+ { op: "remove_slot", slot: "a" },
38
+ ]);
39
+ });
40
+
41
+ it("recovers an array embedded in prose", () => {
42
+ expect(parseMagicUiReply('sure! [{"op":"remove_slot","slot":"a"}] done')).toEqual([
43
+ { op: "remove_slot", slot: "a" },
44
+ ]);
45
+ });
46
+
47
+ it("returns [] for an empty reply (nothing to do)", () => {
48
+ expect(parseMagicUiReply(" ")).toEqual([]);
49
+ });
50
+
51
+ it("returns null for unparseable replies", () => {
52
+ expect(parseMagicUiReply("no ops here")).toBeNull();
53
+ expect(parseMagicUiReply('[{"op": broken]')).toBeNull();
54
+ expect(parseMagicUiReply('{"op":"set_slot"}')).toBeNull();
55
+ });
56
+ });
57
+
58
+ describe("sanitizeMagicHtml", () => {
59
+ it("strips script tags and event handlers", () => {
60
+ const dirty = '<div onclick="evil()"><script>alert(1)</script><p>ok</p></div>';
61
+ const clean = sanitizeMagicHtml(dirty);
62
+ expect(clean).not.toContain("script");
63
+ expect(clean).not.toContain("onclick");
64
+ expect(clean).toContain("<p>ok</p>");
65
+ });
66
+
67
+ it("strips inline styles, links and images", () => {
68
+ const dirty = '<p style="position:fixed">x</p><a href="javascript:evil()">y</a><img src="http://x/t.png">';
69
+ const clean = sanitizeMagicHtml(dirty);
70
+ expect(clean).not.toContain("style=");
71
+ expect(clean).not.toContain("href");
72
+ expect(clean).not.toContain("img");
73
+ });
74
+
75
+ it("keeps allowlisted structure with classes", () => {
76
+ const html = '<div class="stat-grid"><h3>Files</h3><ul><li>a.ts</li></ul></div>';
77
+ expect(sanitizeMagicHtml(html)).toBe(html);
78
+ });
79
+ });
80
+
81
+ describe("validateOps", () => {
82
+ it("drops unknown and malformed ops, keeps valid ones", () => {
83
+ const ops = validateOps([
84
+ { op: "hack_the_planet" },
85
+ { op: "set_slot" }, // missing fields
86
+ { op: "set_slot", slot: "progress", html: "<p>hi</p>" },
87
+ null,
88
+ 42,
89
+ ]);
90
+ expect(ops).toEqual([
91
+ { op: "set_slot", slot: "progress", html: "<p>hi</p>", title: undefined, area: undefined, span: undefined },
92
+ ]);
93
+ });
94
+
95
+ it("sanitizes set_slot html", () => {
96
+ const ops = validateOps([
97
+ { op: "set_slot", slot: "x", html: '<script>a</script><b onmouseover="p()">t</b>' },
98
+ ]);
99
+ expect(ops).toHaveLength(1);
100
+ const op = ops[0] as Extract<MagicUiOp, { op: "set_slot" }>;
101
+ expect(op.html).toBe("<b>t</b>");
102
+ });
103
+
104
+ it("drops a set_slot whose html sanitizes to nothing", () => {
105
+ expect(validateOps([{ op: "set_slot", slot: "x", html: "<script>a</script>" }])).toEqual([]);
106
+ });
107
+
108
+ it("caps the number of ops per turn", () => {
109
+ const raw = Array.from({ length: MAGIC_UI_MAX_OPS_PER_TURN + 10 }, (_, i) => ({
110
+ op: "remove_slot",
111
+ slot: `s${i}`,
112
+ }));
113
+ expect(validateOps(raw)).toHaveLength(MAGIC_UI_MAX_OPS_PER_TURN);
114
+ });
115
+
116
+ it("validates chart specs and drops non-numeric data", () => {
117
+ const ops = validateOps([
118
+ { op: "chart", slot: "c", spec: { kind: "bar", series: [{ label: "tests", data: [1, "x", 3] }] } },
119
+ { op: "chart", slot: "bad", spec: { kind: "pie3d", series: [{ label: "a", data: [1] }] } },
120
+ { op: "chart", slot: "bad2", spec: { kind: "bar", series: [] } },
121
+ ]);
122
+ expect(ops).toHaveLength(1);
123
+ const chart = ops[0] as Extract<MagicUiOp, { op: "chart" }>;
124
+ expect(chart.spec.series[0].data).toEqual([1, 3]);
125
+ });
126
+
127
+ it("normalizes open_item kinds and validates snippet/stat/decision ops", () => {
128
+ const ops = validateOps([
129
+ { op: "open_item", id: "run-tests", text: "Run the migration script", kind: "weird" },
130
+ { op: "resolve_item", id: "run-tests" },
131
+ { op: "snippet", slot: "s", title: "Migrate DB", code: "bun run migrate", language: "bash" },
132
+ { op: "stat", slot: "t", label: "Tests", value: "34 pass", trend: "up" },
133
+ { op: "decision_log", title: "Chose approach B", detail: "Simpler rollback path" },
134
+ { op: "session_summary", text: "We did things." },
135
+ ]);
136
+ expect(ops.map((o) => o.op)).toEqual([
137
+ "open_item", "resolve_item", "snippet", "stat", "decision_log", "session_summary",
138
+ ]);
139
+ expect((ops[0] as Extract<MagicUiOp, { op: "open_item" }>).kind).toBe("action");
140
+ });
141
+ });
142
+
143
+ describe("applyOps", () => {
144
+ it("sets slots, bumps version, and does not mutate the input state", () => {
145
+ const state = emptyMagicUiState(NOW);
146
+ const ops = validateOps([
147
+ { op: "set_slot", slot: "hero", html: "<p>Working on auth</p>", area: "hero" },
148
+ { op: "stat", slot: "tests", label: "Tests", value: "34", area: "side" },
149
+ ]);
150
+ const next = applyOps(state, ops, NOW + 1);
151
+ expect(next.version).toBe(1);
152
+ expect(next.slots.hero?.html).toBe("<p>Working on auth</p>");
153
+ expect(next.slots.tests?.stat?.value).toBe("34");
154
+ expect(next.layout).toEqual(
155
+ expect.arrayContaining([
156
+ expect.objectContaining({ slot: "hero", area: "hero" }),
157
+ expect.objectContaining({ slot: "tests", area: "side" }),
158
+ ]),
159
+ );
160
+ // Purity: original untouched
161
+ expect(state.version).toBe(0);
162
+ expect(Object.keys(state.slots)).toHaveLength(0);
163
+ });
164
+
165
+ it("removes slots together with their layout entries", () => {
166
+ let state = applyOps(
167
+ emptyMagicUiState(NOW),
168
+ validateOps([{ op: "set_slot", slot: "a", html: "<p>x</p>", area: "main" }]),
169
+ NOW,
170
+ );
171
+ state = applyOps(state, validateOps([{ op: "remove_slot", slot: "a" }]), NOW + 1);
172
+ expect(state.slots.a).toBeUndefined();
173
+ expect(state.layout.find((l) => l.slot === "a")).toBeUndefined();
174
+ expect(state.version).toBe(2);
175
+ });
176
+
177
+ it("adds, dedupes and resolves open items", () => {
178
+ let state = applyOps(
179
+ emptyMagicUiState(NOW),
180
+ validateOps([
181
+ { op: "open_item", id: "q1", text: "Which DB?", kind: "question" },
182
+ { op: "open_item", id: "a1", text: "Run bun run migrate", kind: "action" },
183
+ ]),
184
+ NOW,
185
+ );
186
+ expect(state.openItems.map((i) => i.id)).toEqual(["a1", "q1"]);
187
+ // Re-adding the same id replaces it instead of duplicating
188
+ state = applyOps(state, validateOps([{ op: "open_item", id: "q1", text: "Which DB? (updated)", kind: "question" }]), NOW + 1);
189
+ expect(state.openItems.filter((i) => i.id === "q1")).toHaveLength(1);
190
+ expect(state.openItems.find((i) => i.id === "q1")?.text).toContain("updated");
191
+ state = applyOps(state, validateOps([{ op: "resolve_item", id: "q1" }]), NOW + 2);
192
+ expect(state.openItems.map((i) => i.id)).toEqual(["a1"]);
193
+ });
194
+
195
+ it("caps the decision log", () => {
196
+ let state = emptyMagicUiState(NOW);
197
+ for (let i = 0; i < MAGIC_UI_MAX_DECISIONS + 5; i++) {
198
+ state = applyOps(state, validateOps([{ op: "decision_log", title: `d${i}`, detail: "x" }]), NOW + i);
199
+ }
200
+ expect(state.decisionLog).toHaveLength(MAGIC_UI_MAX_DECISIONS);
201
+ // Newest first
202
+ expect(state.decisionLog[0].title).toBe(`d${MAGIC_UI_MAX_DECISIONS + 4}`);
203
+ });
204
+
205
+ it("evicts the oldest slots beyond the slot budget", () => {
206
+ let state = emptyMagicUiState(NOW);
207
+ for (let i = 0; i < MAGIC_UI_MAX_SLOTS + 3; i++) {
208
+ state = applyOps(state, validateOps([{ op: "set_slot", slot: `s${i}`, html: `<p>${i}</p>` }]), NOW + i);
209
+ }
210
+ expect(Object.keys(state.slots)).toHaveLength(MAGIC_UI_MAX_SLOTS);
211
+ expect(state.slots.s0).toBeUndefined();
212
+ expect(state.slots[`s${MAGIC_UI_MAX_SLOTS + 2}`]).toBeDefined();
213
+ });
214
+
215
+ it("stores the session summary without rendering slots", () => {
216
+ const state = applyOps(
217
+ emptyMagicUiState(NOW),
218
+ validateOps([{ op: "session_summary", text: "Auth refactor session." }]),
219
+ NOW,
220
+ );
221
+ expect(state.sessionSummary).toBe("Auth refactor session.");
222
+ expect(Object.keys(state.slots)).toHaveLength(0);
223
+ });
224
+ });
225
+
226
+ describe("appendDecision", () => {
227
+ it("prepends server-generated entries and bumps the version", () => {
228
+ const state = emptyMagicUiState(NOW);
229
+ const entry = makeDecisionEntry("user", "Bash", "Allowed: rm -rf dist", NOW + 5, "allow");
230
+ const next = appendDecision(state, entry);
231
+ expect(next.decisionLog[0]).toMatchObject({
232
+ source: "user",
233
+ title: "Bash",
234
+ behavior: "allow",
235
+ });
236
+ expect(next.version).toBe(1);
237
+ expect(state.decisionLog).toHaveLength(0);
238
+ });
239
+ });
@@ -0,0 +1,365 @@
1
+ // ─── MagicUI op parsing / validation / reduction ─────────────────────────────
2
+ //
3
+ // The watcher model replies with a single JSON array of patch ops. This
4
+ // module turns that untrusted text into a validated, sanitized op list and
5
+ // folds it into the canonical MagicUiDashboardState.
6
+ //
7
+ // Defense in depth: the browser renders watcher HTML only inside a sandboxed
8
+ // opaque-origin iframe with a no-network CSP — the sanitizer here is the
9
+ // second belt, keeping scripts/handlers/URLs out of persisted state at the
10
+ // source so replays and future render surfaces stay safe too.
11
+
12
+ import sanitizeHtmlLib from "sanitize-html";
13
+ import {
14
+ MAGIC_UI_MAX_CHART_POINTS,
15
+ MAGIC_UI_MAX_CHART_SERIES,
16
+ MAGIC_UI_MAX_DECISIONS,
17
+ MAGIC_UI_MAX_HTML_CHARS,
18
+ MAGIC_UI_MAX_OPEN_ITEMS,
19
+ MAGIC_UI_MAX_OPS_PER_TURN,
20
+ MAGIC_UI_MAX_SLOTS,
21
+ MAGIC_UI_MAX_SNIPPET_CHARS,
22
+ type MagicChartSpec,
23
+ type MagicUiArea,
24
+ type MagicUiDashboardState,
25
+ type MagicUiDecisionEntry,
26
+ type MagicUiLayoutEntry,
27
+ type MagicUiOp,
28
+ } from "./magic-ui-types.js";
29
+
30
+ const AREAS: ReadonlySet<string> = new Set(["hero", "main", "side", "footer"]);
31
+ const CHART_KINDS: ReadonlySet<string> = new Set(["bar", "line", "donut", "sparkline"]);
32
+ const OPEN_ITEM_KINDS: ReadonlySet<string> = new Set(["action", "question", "blocker"]);
33
+
34
+ const SANITIZE_OPTIONS: sanitizeHtmlLib.IOptions = {
35
+ allowedTags: [
36
+ "div", "span", "p", "h1", "h2", "h3", "h4", "ul", "ol", "li",
37
+ "table", "thead", "tbody", "tr", "td", "th",
38
+ "strong", "em", "b", "i", "u", "s", "code", "pre", "small", "br", "hr",
39
+ "blockquote", "progress", "mark", "kbd", "sub", "sup",
40
+ ],
41
+ allowedAttributes: {
42
+ "*": ["class", "data-*"],
43
+ progress: ["value", "max", "class"],
44
+ },
45
+ // No style attribute at all: class-based styling only (the runtime ships
46
+ // utility classes). Inline style is the classic sanitizer escape hatch.
47
+ allowedStyles: {},
48
+ disallowedTagsMode: "discard",
49
+ allowedSchemes: [],
50
+ };
51
+
52
+ export function sanitizeMagicHtml(html: string): string {
53
+ return sanitizeHtmlLib(html, SANITIZE_OPTIONS);
54
+ }
55
+
56
+ function isNonEmptyString(v: unknown, max = 512): v is string {
57
+ return typeof v === "string" && v.trim().length > 0 && v.length <= max;
58
+ }
59
+
60
+ function normalizeSpan(v: unknown): 1 | 2 | 3 | undefined {
61
+ return v === 1 || v === 2 || v === 3 ? v : undefined;
62
+ }
63
+
64
+ function normalizeArea(v: unknown): MagicUiArea | undefined {
65
+ return typeof v === "string" && AREAS.has(v) ? (v as MagicUiArea) : undefined;
66
+ }
67
+
68
+ function validateChartSpec(raw: unknown): MagicChartSpec | null {
69
+ if (!raw || typeof raw !== "object") return null;
70
+ const spec = raw as Record<string, unknown>;
71
+ if (typeof spec.kind !== "string" || !CHART_KINDS.has(spec.kind)) return null;
72
+ if (!Array.isArray(spec.series) || spec.series.length === 0) return null;
73
+ const series: MagicChartSpec["series"] = [];
74
+ for (const s of spec.series.slice(0, MAGIC_UI_MAX_CHART_SERIES)) {
75
+ if (!s || typeof s !== "object") return null;
76
+ const entry = s as Record<string, unknown>;
77
+ if (!Array.isArray(entry.data)) return null;
78
+ const data = entry.data
79
+ .slice(0, MAGIC_UI_MAX_CHART_POINTS)
80
+ .filter((n): n is number => typeof n === "number" && Number.isFinite(n));
81
+ if (data.length === 0) return null;
82
+ series.push({
83
+ label: isNonEmptyString(entry.label, 120) ? entry.label : "",
84
+ data,
85
+ });
86
+ }
87
+ if (series.length === 0) return null;
88
+ const labels = Array.isArray(spec.labels)
89
+ ? spec.labels
90
+ .slice(0, MAGIC_UI_MAX_CHART_POINTS)
91
+ .filter((l): l is string => typeof l === "string")
92
+ .map((l) => l.slice(0, 80))
93
+ : undefined;
94
+ return {
95
+ kind: spec.kind as MagicChartSpec["kind"],
96
+ title: isNonEmptyString(spec.title, 160) ? spec.title : undefined,
97
+ labels,
98
+ series,
99
+ };
100
+ }
101
+
102
+ function validateLayoutEntries(raw: unknown): MagicUiLayoutEntry[] | null {
103
+ if (!Array.isArray(raw)) return null;
104
+ const out: MagicUiLayoutEntry[] = [];
105
+ for (const e of raw.slice(0, MAGIC_UI_MAX_SLOTS)) {
106
+ if (!e || typeof e !== "object") continue;
107
+ const entry = e as Record<string, unknown>;
108
+ const area = normalizeArea(entry.area);
109
+ if (!isNonEmptyString(entry.slot, 80) || !area) continue;
110
+ out.push({ slot: entry.slot, area, span: normalizeSpan(entry.span) });
111
+ }
112
+ return out.length > 0 ? out : null;
113
+ }
114
+
115
+ /**
116
+ * Extract the ops array from a raw watcher reply. Accepts either a fenced
117
+ * ```json block (preferred; the prompt asks for exactly one) or a bare JSON
118
+ * array. Returns [] for "nothing to do" replies and null for unparseable
119
+ * ones (so callers can count consecutive failures).
120
+ */
121
+ export function parseMagicUiReply(text: string): unknown[] | null {
122
+ const trimmed = text.trim();
123
+ if (!trimmed) return [];
124
+ const fence = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed);
125
+ const candidate = fence ? fence[1].trim() : trimmed;
126
+ const start = candidate.indexOf("[");
127
+ const end = candidate.lastIndexOf("]");
128
+ if (start === -1 || end === -1 || end < start) return null;
129
+ try {
130
+ const parsed = JSON.parse(candidate.slice(start, end + 1));
131
+ return Array.isArray(parsed) ? parsed : null;
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Validate and sanitize raw parsed ops. Unknown / malformed ops are dropped
139
+ * (never fatal — a partially-usable turn still updates the dashboard).
140
+ */
141
+ export function validateOps(raw: unknown[]): MagicUiOp[] {
142
+ const ops: MagicUiOp[] = [];
143
+ for (const item of raw.slice(0, MAGIC_UI_MAX_OPS_PER_TURN)) {
144
+ if (!item || typeof item !== "object") continue;
145
+ const o = item as Record<string, unknown>;
146
+ switch (o.op) {
147
+ case "set_slot": {
148
+ if (!isNonEmptyString(o.slot, 80) || typeof o.html !== "string") break;
149
+ const html = sanitizeMagicHtml(o.html.slice(0, MAGIC_UI_MAX_HTML_CHARS * 2)).slice(0, MAGIC_UI_MAX_HTML_CHARS);
150
+ if (!html.trim()) break;
151
+ ops.push({
152
+ op: "set_slot",
153
+ slot: o.slot,
154
+ html,
155
+ title: isNonEmptyString(o.title, 160) ? o.title : undefined,
156
+ area: normalizeArea(o.area),
157
+ span: normalizeSpan(o.span),
158
+ });
159
+ break;
160
+ }
161
+ case "remove_slot": {
162
+ if (isNonEmptyString(o.slot, 80)) ops.push({ op: "remove_slot", slot: o.slot });
163
+ break;
164
+ }
165
+ case "layout": {
166
+ const slots = validateLayoutEntries(o.slots);
167
+ if (slots) ops.push({ op: "layout", slots });
168
+ break;
169
+ }
170
+ case "chart": {
171
+ if (!isNonEmptyString(o.slot, 80)) break;
172
+ const spec = validateChartSpec(o.spec);
173
+ if (!spec) break;
174
+ ops.push({
175
+ op: "chart",
176
+ slot: o.slot,
177
+ spec,
178
+ title: isNonEmptyString(o.title, 160) ? o.title : undefined,
179
+ area: normalizeArea(o.area),
180
+ span: normalizeSpan(o.span),
181
+ });
182
+ break;
183
+ }
184
+ case "stat": {
185
+ if (!isNonEmptyString(o.slot, 80) || !isNonEmptyString(o.label, 120) || !isNonEmptyString(o.value, 80)) break;
186
+ ops.push({
187
+ op: "stat",
188
+ slot: o.slot,
189
+ label: o.label,
190
+ value: o.value,
191
+ trend: o.trend === "up" || o.trend === "down" || o.trend === "flat" ? o.trend : undefined,
192
+ area: normalizeArea(o.area),
193
+ span: normalizeSpan(o.span),
194
+ });
195
+ break;
196
+ }
197
+ case "snippet": {
198
+ if (!isNonEmptyString(o.slot, 80) || !isNonEmptyString(o.title, 160) || typeof o.code !== "string" || !o.code.trim()) break;
199
+ ops.push({
200
+ op: "snippet",
201
+ slot: o.slot,
202
+ title: o.title,
203
+ code: o.code.slice(0, MAGIC_UI_MAX_SNIPPET_CHARS),
204
+ language: isNonEmptyString(o.language, 40) ? o.language : undefined,
205
+ area: normalizeArea(o.area),
206
+ span: normalizeSpan(o.span),
207
+ });
208
+ break;
209
+ }
210
+ case "open_item": {
211
+ if (!isNonEmptyString(o.id, 80) || !isNonEmptyString(o.text, 500)) break;
212
+ ops.push({
213
+ op: "open_item",
214
+ id: o.id,
215
+ text: o.text,
216
+ kind: typeof o.kind === "string" && OPEN_ITEM_KINDS.has(o.kind) ? (o.kind as "action" | "question" | "blocker") : "action",
217
+ });
218
+ break;
219
+ }
220
+ case "resolve_item": {
221
+ if (isNonEmptyString(o.id, 80)) ops.push({ op: "resolve_item", id: o.id });
222
+ break;
223
+ }
224
+ case "decision_log": {
225
+ if (!isNonEmptyString(o.title, 160) || !isNonEmptyString(o.detail, 500)) break;
226
+ ops.push({ op: "decision_log", title: o.title, detail: o.detail });
227
+ break;
228
+ }
229
+ case "session_summary": {
230
+ if (typeof o.text === "string") {
231
+ ops.push({ op: "session_summary", text: o.text.slice(0, 2_000) });
232
+ }
233
+ break;
234
+ }
235
+ default:
236
+ break;
237
+ }
238
+ }
239
+ return ops;
240
+ }
241
+
242
+ let decisionCounter = 0;
243
+
244
+ /** Server-generated decision entry (user choices, AI auto-resolutions). */
245
+ export function makeDecisionEntry(
246
+ source: MagicUiDecisionEntry["source"],
247
+ title: string,
248
+ detail: string,
249
+ now: number,
250
+ behavior?: "allow" | "deny",
251
+ ): MagicUiDecisionEntry {
252
+ return {
253
+ id: `dec-${now}-${decisionCounter++}`,
254
+ ts: now,
255
+ source,
256
+ title: title.slice(0, 160),
257
+ detail: detail.slice(0, 500),
258
+ behavior,
259
+ };
260
+ }
261
+
262
+ function upsertLayout(layout: MagicUiLayoutEntry[], slot: string, area?: MagicUiArea, span?: 1 | 2 | 3): MagicUiLayoutEntry[] {
263
+ if (!area && !span) return layout;
264
+ const existing = layout.find((l) => l.slot === slot);
265
+ const next = layout.filter((l) => l.slot !== slot);
266
+ next.push({
267
+ slot,
268
+ area: area ?? existing?.area ?? "main",
269
+ span: span ?? existing?.span,
270
+ });
271
+ return next;
272
+ }
273
+
274
+ /**
275
+ * Fold validated ops into a new state (input state is not mutated).
276
+ * Enforces the slot budget by evicting the oldest-updated slots first.
277
+ */
278
+ export function applyOps(
279
+ state: MagicUiDashboardState,
280
+ ops: MagicUiOp[],
281
+ now: number,
282
+ ): MagicUiDashboardState {
283
+ const next: MagicUiDashboardState = {
284
+ ...state,
285
+ slots: { ...state.slots },
286
+ layout: [...state.layout],
287
+ decisionLog: [...state.decisionLog],
288
+ openItems: [...state.openItems],
289
+ };
290
+
291
+ for (const op of ops) {
292
+ switch (op.op) {
293
+ case "set_slot":
294
+ next.slots[op.slot] = { title: op.title, html: op.html, updatedAt: now };
295
+ next.layout = upsertLayout(next.layout, op.slot, op.area, op.span);
296
+ break;
297
+ case "remove_slot":
298
+ delete next.slots[op.slot];
299
+ next.layout = next.layout.filter((l) => l.slot !== op.slot);
300
+ break;
301
+ case "layout":
302
+ next.layout = op.slots.filter((l) => next.slots[l.slot] !== undefined || ops.some((other) => "slot" in other && other.slot === l.slot));
303
+ break;
304
+ case "chart":
305
+ next.slots[op.slot] = { title: op.title ?? op.spec.title, chart: op.spec, updatedAt: now };
306
+ next.layout = upsertLayout(next.layout, op.slot, op.area, op.span);
307
+ break;
308
+ case "stat":
309
+ next.slots[op.slot] = { stat: { label: op.label, value: op.value, trend: op.trend }, updatedAt: now };
310
+ next.layout = upsertLayout(next.layout, op.slot, op.area, op.span);
311
+ break;
312
+ case "snippet":
313
+ next.slots[op.slot] = {
314
+ title: op.title,
315
+ snippet: { title: op.title, code: op.code, language: op.language },
316
+ updatedAt: now,
317
+ };
318
+ next.layout = upsertLayout(next.layout, op.slot, op.area, op.span);
319
+ break;
320
+ case "open_item": {
321
+ next.openItems = next.openItems.filter((i) => i.id !== op.id);
322
+ next.openItems.unshift({ id: op.id, ts: now, text: op.text, kind: op.kind ?? "action" });
323
+ if (next.openItems.length > MAGIC_UI_MAX_OPEN_ITEMS) {
324
+ next.openItems = next.openItems.slice(0, MAGIC_UI_MAX_OPEN_ITEMS);
325
+ }
326
+ break;
327
+ }
328
+ case "resolve_item":
329
+ next.openItems = next.openItems.filter((i) => i.id !== op.id);
330
+ break;
331
+ case "decision_log":
332
+ next.decisionLog.unshift(makeDecisionEntry("agent", op.title, op.detail, now));
333
+ if (next.decisionLog.length > MAGIC_UI_MAX_DECISIONS) {
334
+ next.decisionLog = next.decisionLog.slice(0, MAGIC_UI_MAX_DECISIONS);
335
+ }
336
+ break;
337
+ case "session_summary":
338
+ next.sessionSummary = op.text;
339
+ break;
340
+ }
341
+ }
342
+
343
+ // Slot budget: evict oldest-updated slots beyond the cap.
344
+ const slotNames = Object.keys(next.slots);
345
+ if (slotNames.length > MAGIC_UI_MAX_SLOTS) {
346
+ const byAge = slotNames.sort((a, b) => next.slots[a].updatedAt - next.slots[b].updatedAt);
347
+ for (const name of byAge.slice(0, slotNames.length - MAGIC_UI_MAX_SLOTS)) {
348
+ delete next.slots[name];
349
+ next.layout = next.layout.filter((l) => l.slot !== name);
350
+ }
351
+ }
352
+
353
+ next.version = state.version + 1;
354
+ next.updatedAt = now;
355
+ return next;
356
+ }
357
+
358
+ /** Append a server-generated decision entry (bumps version so browsers update). */
359
+ export function appendDecision(
360
+ state: MagicUiDashboardState,
361
+ entry: MagicUiDecisionEntry,
362
+ ): MagicUiDashboardState {
363
+ const decisionLog = [entry, ...state.decisionLog].slice(0, MAGIC_UI_MAX_DECISIONS);
364
+ return { ...state, decisionLog, version: state.version + 1, updatedAt: entry.ts };
365
+ }
@@ -0,0 +1,56 @@
1
+ // Tests for the MagicUI effective-settings resolver.
2
+ //
3
+ // Semantics under test (deliberately different from AI validation's
4
+ // inherit-default): the global toggle only makes the feature AVAILABLE;
5
+ // a watcher runs only for sessions that explicitly opted in — a global
6
+ // default-on would silently start a Haiku watcher for every session.
7
+ // CLI availability is a hard gate (subscription login, no API key path).
8
+ import { describe, expect, it, vi, beforeEach } from "vitest";
9
+ import type { SessionState } from "./session-types.js";
10
+
11
+ const mocks = vi.hoisted(() => ({
12
+ magicUiEnabled: false,
13
+ cliAvailable: true,
14
+ }));
15
+
16
+ vi.mock("./settings-manager.js", () => ({
17
+ getSettings: () => ({ magicUiEnabled: mocks.magicUiEnabled, magicUiModel: "claude-haiku-4-5" }),
18
+ }));
19
+ vi.mock("./claude-cli-runner.js", () => ({
20
+ isClaudeCliAvailable: () => mocks.cliAvailable,
21
+ }));
22
+
23
+ import { getEffectiveMagicUi } from "./magic-ui-settings.js";
24
+
25
+ function session(magicUiActive: boolean | null | undefined): SessionState {
26
+ return { session_id: "s", magicUiActive } as SessionState;
27
+ }
28
+
29
+ beforeEach(() => {
30
+ mocks.magicUiEnabled = true;
31
+ mocks.cliAvailable = true;
32
+ });
33
+
34
+ describe("getEffectiveMagicUi", () => {
35
+ it("is enabled only with global availability AND explicit session opt-in", () => {
36
+ expect(getEffectiveMagicUi(session(true)).enabled).toBe(true);
37
+ expect(getEffectiveMagicUi(session(false)).enabled).toBe(false);
38
+ // null/undefined = no opt-in → off (never default-on per session)
39
+ expect(getEffectiveMagicUi(session(null)).enabled).toBe(false);
40
+ expect(getEffectiveMagicUi(session(undefined)).enabled).toBe(false);
41
+ });
42
+
43
+ it("is disabled when the feature is globally off, even with session opt-in", () => {
44
+ mocks.magicUiEnabled = false;
45
+ expect(getEffectiveMagicUi(session(true)).enabled).toBe(false);
46
+ });
47
+
48
+ it("is disabled when the Claude CLI is unavailable (subscription-login gate)", () => {
49
+ mocks.cliAvailable = false;
50
+ expect(getEffectiveMagicUi(session(true)).enabled).toBe(false);
51
+ });
52
+
53
+ it("reports the configured watcher model", () => {
54
+ expect(getEffectiveMagicUi(session(true)).model).toBe("claude-haiku-4-5");
55
+ });
56
+ });