pi-magi-theme 0.1.0

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.
@@ -0,0 +1,1518 @@
1
+ /**
2
+ * MAGI — a three-mind council chrome for pi
3
+ *
4
+ * One public-domain mythology, every symbol bound to a real function:
5
+ * - the TREE OF LIFE shows where the agent is: the upper triad while it thinks, the light descending
6
+ * to Malkuth while it answers, ascending while the model is loaded into VRAM, at rest in Malkuth when idle
7
+ * - the three MAGI (MELCHIOR / BALTHASAR / CASPAR) deliberate: they light up while the model thinks,
8
+ * give the verdict when it answers, and form the /magi council (three real models voting)
9
+ * - the GOLEM acts: it is animated by EMET ("truth") while tools run; a failing tool erases the aleph
10
+ * and EMET becomes MET ("death"). SYNC is the golem's obedience: the tool success rate
11
+ * - CHESED (mercy) and GEBURAH (severity) count successful and failed tools
12
+ * - the SEVEN SEALS measure the context window; compaction breaks the seventh seal and the world is remade
13
+ * - llama-swap telemetry: VRAM, GPU load/temp/power, RAM, server-side tok/s, prompt tok/s, cache hits
14
+ * - /magi config → assign a model to each MAGI (~/.pi/agent/magi.json)
15
+ *
16
+ * Artwork is original; the symbolism is public domain.
17
+ * Use with the theme ../../themes/magi.json
18
+ */
19
+
20
+ import { readFileSync, writeFileSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { join } from "node:path";
23
+ import type { AssistantMessage, Model } from "@earendil-works/pi-ai";
24
+ import { completeSimple } from "@earendil-works/pi-ai";
25
+ import type { ExtensionAPI, ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
26
+ import type { Component, OverlayHandle, TUI } from "@earendil-works/pi-tui";
27
+ import { HStack, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
28
+
29
+ /* ────────────────────────────────────────────────────────────── art ── */
30
+
31
+ const MAGI_WORD = [
32
+ "███╗ ███╗ █████╗ ██████╗ ██╗",
33
+ "████╗ ████║██╔══██╗██╔════╝ ██║",
34
+ "██╔████╔██║███████║██║ ███╗██║",
35
+ "██║╚██╔╝██║██╔══██║██║ ██║██║",
36
+ "██║ ╚═╝ ██║██║ ██║╚██████╔╝██║",
37
+ "╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝",
38
+ ];
39
+
40
+ /** Tree of Life canvas, in terminal cells; drawn with braille dots (2×4 sub-pixels per cell). */
41
+ const TREE_W = 30;
42
+ const TREE_H = 20;
43
+
44
+ /** Sephirot in the order of the "lightning flash" (Keter → Malkuth), positioned in sub-pixels. */
45
+ const SEPHIROT = [
46
+ { name: "KETER", meaning: "the crown", x: 30, y: 5 },
47
+ { name: "CHOKMAH", meaning: "wisdom", x: 50, y: 19 },
48
+ { name: "BINAH", meaning: "understanding", x: 10, y: 19 },
49
+ { name: "CHESED", meaning: "mercy", x: 50, y: 37 },
50
+ { name: "GEBURAH", meaning: "severity", x: 10, y: 37 },
51
+ { name: "TIFERET", meaning: "beauty", x: 30, y: 46 },
52
+ { name: "NETZACH", meaning: "victory", x: 50, y: 55 },
53
+ { name: "HOD", meaning: "splendor", x: 10, y: 55 },
54
+ { name: "YESOD", meaning: "foundation", x: 30, y: 64 },
55
+ { name: "MALKUTH", meaning: "the kingdom", x: 30, y: 75 },
56
+ ] as const;
57
+
58
+ /** Da'at, the hidden sephirah: drawn dashed, not part of the flash. */
59
+ const DAAT = { x: 30, y: 28 };
60
+
61
+ /** The 22 paths, as pairs of SEPHIROT indices. */
62
+ const TREE_PATHS: readonly [number, number][] = [
63
+ [0, 1], [0, 2], [0, 5], [1, 2], [1, 5], [1, 3], [2, 5], [2, 4], [3, 4], [3, 5], [3, 6],
64
+ [4, 5], [4, 7], [5, 6], [5, 8], [5, 7], [6, 7], [6, 8], [6, 9], [7, 8], [7, 9], [8, 9],
65
+ ];
66
+
67
+ const BRAILLE_BITS = [
68
+ [0x01, 0x08],
69
+ [0x02, 0x10],
70
+ [0x04, 0x20],
71
+ [0x40, 0x80],
72
+ ]; // [row][col] → dot bit
73
+
74
+ /** Rasterizes the Tree of Life into braille: paths (muted), Da'at (dim, dashed), sephirot (accent rings). */
75
+ function renderTreeOfLife(th: Theme): string[] {
76
+ const W = TREE_W * 2;
77
+ const H = TREE_H * 4;
78
+ const PATH = 1;
79
+ const DAAT_PX = 2;
80
+ const NODE = 3;
81
+ const px = new Uint8Array(W * H);
82
+ const set = (x: number, y: number, v: number) => {
83
+ const rx = Math.round(x);
84
+ const ry = Math.round(y);
85
+ if (rx >= 0 && ry >= 0 && rx < W && ry < H) px[ry * W + rx] = v;
86
+ };
87
+ for (const [a, b] of TREE_PATHS) {
88
+ const p = SEPHIROT[a]!;
89
+ const q = SEPHIROT[b]!;
90
+ const n = Math.ceil(Math.max(Math.abs(q.x - p.x), Math.abs(q.y - p.y)));
91
+ for (let i = 0; i <= n; i++) set(p.x + ((q.x - p.x) * i) / n, p.y + ((q.y - p.y) * i) / n, PATH);
92
+ }
93
+ // ring of radius r, cleared inside and with a small gap around it so paths stop short of the circle;
94
+ // dashed rings keep every other 30° arc
95
+ const ring = (cx: number, cy: number, r: number, v: number, dashed: boolean) => {
96
+ for (let y = -r - 2; y <= r + 2; y++) {
97
+ for (let x = -r - 2; x <= r + 2; x++) {
98
+ const d = Math.hypot(x, y);
99
+ if (d > r + 1.5) continue;
100
+ const onRing = d > r - 0.7 && d <= r + 0.5;
101
+ const arc = Math.floor((Math.atan2(y, x) + Math.PI) / (Math.PI / 6)) % 2 === 0;
102
+ set(cx + x, cy + y, onRing && (!dashed || arc) ? v : 0);
103
+ }
104
+ }
105
+ };
106
+ ring(DAAT.x, DAAT.y, 3, DAAT_PX, true);
107
+ for (const s of SEPHIROT) {
108
+ ring(s.x, s.y, 3.5, NODE, false);
109
+ set(s.x, s.y, NODE);
110
+ }
111
+
112
+ const lines: string[] = [];
113
+ for (let row = 0; row < TREE_H; row++) {
114
+ let line = "";
115
+ for (let col = 0; col < TREE_W; col++) {
116
+ let bits = 0;
117
+ let top = 0;
118
+ for (let dy = 0; dy < 4; dy++) {
119
+ for (let dx = 0; dx < 2; dx++) {
120
+ const v = px[(row * 4 + dy) * W + col * 2 + dx]!;
121
+ if (!v) continue;
122
+ bits |= BRAILLE_BITS[dy]![dx]!;
123
+ top = Math.max(top, v);
124
+ }
125
+ }
126
+ const ch = bits ? String.fromCharCode(0x2800 + bits) : " ";
127
+ line += bits ? th.fg(top === NODE ? "accent" : top === DAAT_PX ? "dim" : "muted", ch) : ch;
128
+ }
129
+ lines.push(line);
130
+ }
131
+ return lines;
132
+ }
133
+
134
+ type NodeLight = "off" | "on" | "hot";
135
+
136
+ /** The ten sephirot as a tiny path: ●━●━◉─○ … (hot = where the light is now). */
137
+ function renderPath(th: Theme, lights: NodeLight[]): string {
138
+ const glyph = (l: NodeLight) => (l === "off" ? th.fg("dim", "○") : th.fg(l === "hot" ? "warning" : "accent", "●"));
139
+ let out = glyph(lights[0]!);
140
+ for (let i = 1; i < lights.length; i++) {
141
+ const joined = lights[i - 1] !== "off" && lights[i] !== "off";
142
+ out += (joined ? th.fg("accent", "━") : th.fg("dim", "─")) + glyph(lights[i]!);
143
+ }
144
+ return out;
145
+ }
146
+
147
+ /** The golem, 20×8 cells: at rest, striking (arms raised), and fallen (EMET → MET). */
148
+ const GOLEM_REST = [
149
+ " ▄██████▄ ",
150
+ " █ EMET █ ",
151
+ " ▀██████▀ ",
152
+ " ▄████████████▄ ",
153
+ " ██ ████████ ██ ",
154
+ " ▀▀ ████████ ▀▀ ",
155
+ " ███ ███ ",
156
+ " ▀▀▀▀ ▀▀▀▀ ",
157
+ ];
158
+ const GOLEM_STRIKE = [
159
+ " ▄▄ ▄██████▄ ▄▄ ",
160
+ " ██ █ EMET █ ██ ",
161
+ " ██ ▀██████▀ ██ ",
162
+ " ▀████████████████▀ ",
163
+ " ████████ ",
164
+ " ████████ ",
165
+ " ███ ███ ",
166
+ " ▀▀▀▀ ▀▀▀▀ ",
167
+ ];
168
+ const GOLEM_FALLEN = [
169
+ " ▄██████▄ ",
170
+ " █ MET █ ",
171
+ " ▀██████▀ ",
172
+ " ▄████████████▄ ",
173
+ " ██ ██░███░█ ██ ",
174
+ " ▀▀ █░████░█ ▀▀ ",
175
+ " ███ ███ ",
176
+ " ▀▀▀▀ ▀▀▀▀ ",
177
+ ];
178
+
179
+ const MAGI_UNITS = ["MELCHIOR", "BALTHASAR", "CASPAR"] as const;
180
+ type MagiUnit = (typeof MAGI_UNITS)[number];
181
+ const MAGI_TASKS = ["ANALYSIS", "SYNTHESIS", "VERIFY"] as const;
182
+
183
+ function pulseFrames(theme: Theme): string[] {
184
+ return [
185
+ theme.fg("dim", "◇"),
186
+ theme.fg("warning", "◈"),
187
+ theme.fg("accent", "◆"),
188
+ theme.fg("error", "◆"),
189
+ theme.fg("accent", "◆"),
190
+ theme.fg("warning", "◈"),
191
+ ];
192
+ }
193
+
194
+ /* ─────────────────────────────────────────────────────── live state ── */
195
+
196
+ type Phase = "idle" | "thinking" | "responding" | "tool";
197
+
198
+ const state = {
199
+ phase: "idle" as Phase,
200
+ phaseSince: Date.now(),
201
+ toolName: "",
202
+ turns: 0,
203
+ tools: 0,
204
+ toolOk: 0, // CHESED
205
+ toolFail: 0, // GEBURAH
206
+ lastFailAt: 0,
207
+ lastFailTool: "",
208
+ runStart: 0,
209
+ lastRunMs: 0,
210
+ compacting: false,
211
+ compactSince: 0,
212
+ rebornAt: 0,
213
+ };
214
+
215
+ function setPhase(p: Phase): void {
216
+ if (state.phase === p) return;
217
+ state.phase = p;
218
+ state.phaseSince = Date.now();
219
+ }
220
+
221
+ const ANIM_STEP_MS = 220;
222
+ const FAIL_FLASH_MS = 2500;
223
+ const REBIRTH_MS = 6000;
224
+
225
+ /** Golem obedience: share of tool calls that succeeded (null before the first tool). */
226
+ function syncPercent(): number | null {
227
+ const done = state.toolOk + state.toolFail;
228
+ return done ? (state.toolOk / done) * 100 : null;
229
+ }
230
+
231
+ /** Anything moving on screen: the agent works, the model loads, the seals break, or a tool just fell. */
232
+ function animating(now = Date.now()): boolean {
233
+ return (
234
+ state.phase !== "idle" ||
235
+ swap.state === "checking" ||
236
+ swap.state === "loading" ||
237
+ state.compacting ||
238
+ now - state.rebornAt < REBIRTH_MS ||
239
+ now - state.lastFailAt < FAIL_FLASH_MS
240
+ );
241
+ }
242
+
243
+ /** Performance of the current stream / last message. */
244
+ const perf = {
245
+ start: 0, // message_start
246
+ first: 0, // first delta
247
+ chars: 0, // streamed characters (token estimate)
248
+ tps: 0, // tokens/s of the last message (real, from usage)
249
+ ttft: 0, // ms to first token
250
+ lastMs: 0, // last message duration
251
+ peakTps: 0,
252
+ };
253
+
254
+ /** tok/s: live estimate (~4 chars/token, ponytail: real token count only arrives at stream end) or last real value. */
255
+ function liveTps(): number {
256
+ if (state.phase !== "idle" && perf.first && perf.chars) {
257
+ const s = (Date.now() - perf.first) / 1000;
258
+ if (s > 0.3) return perf.chars / 4 / s;
259
+ }
260
+ return perf.tps;
261
+ }
262
+
263
+ function fmtMs(ms: number): string {
264
+ return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
265
+ }
266
+
267
+ const secsSince = (t: number, now = Date.now()) => Math.max(0, Math.floor((now - t) / 1000));
268
+
269
+ const sessionStart = Date.now();
270
+
271
+ let liveCtx: ExtensionContext | undefined;
272
+
273
+ interface TokenStats {
274
+ input: number;
275
+ output: number;
276
+ cacheRead: number;
277
+ cost: number;
278
+ }
279
+
280
+ let statsCache: { at: number; value: TokenStats } = { at: 0, value: { input: 0, output: 0, cacheRead: 0, cost: 0 } };
281
+
282
+ function tokenStats(): TokenStats {
283
+ const now = Date.now();
284
+ if (now - statsCache.at < 500) return statsCache.value;
285
+ const value: TokenStats = { input: 0, output: 0, cacheRead: 0, cost: 0 };
286
+ const branch = liveCtx?.sessionManager?.getBranch?.() ?? [];
287
+ for (const entry of branch) {
288
+ if (entry.type === "message" && entry.message.role === "assistant") {
289
+ const m = entry.message as AssistantMessage;
290
+ value.input += m.usage.input;
291
+ value.output += m.usage.output;
292
+ value.cacheRead += m.usage.cacheRead ?? 0;
293
+ value.cost += m.usage.cost.total;
294
+ }
295
+ }
296
+ statsCache = { at: now, value };
297
+ return value;
298
+ }
299
+
300
+ function fmtTokens(n: number): string {
301
+ if (n < 1000) return `${n}`;
302
+ if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
303
+ return `${(n / 1_000_000).toFixed(2)}M`;
304
+ }
305
+
306
+ /** High usage is bad (context). */
307
+ function usageTone(percent: number): "success" | "warning" | "error" {
308
+ if (percent >= 80) return "error";
309
+ if (percent >= 50) return "warning";
310
+ return "success";
311
+ }
312
+
313
+ /** High sync is good (tool success). */
314
+ function syncTone(percent: number): "success" | "warning" | "error" {
315
+ if (percent >= 90) return "success";
316
+ if (percent >= 70) return "warning";
317
+ return "error";
318
+ }
319
+
320
+ function bar(theme: Theme, filled: number, total: number, tone: ThemeColor): string {
321
+ const f = Math.max(0, Math.min(total, filled));
322
+ return theme.fg(tone, "▓".repeat(f)) + theme.fg("dim", "░".repeat(total - f));
323
+ }
324
+
325
+ /** The seven seals of the context window: one breaks every 1/7 of it. */
326
+ function renderSeals(theme: Theme, percent: number): string {
327
+ const broken = Math.min(7, Math.floor((percent / 100) * 7));
328
+ const tone = usageTone(percent);
329
+ return theme.fg(tone, "◉".repeat(broken)) + theme.fg("dim", "○".repeat(7 - broken));
330
+ }
331
+
332
+ /* ──────────────────────────────────────────────────── MAGI triangle ── */
333
+
334
+ interface UnitView {
335
+ name: string;
336
+ status: string; // max 10 cells
337
+ tone: ThemeColor;
338
+ lit: boolean;
339
+ }
340
+
341
+ const MAGI_DIAGRAM_WIDTH = 32;
342
+
343
+ /**
344
+ * The three MAGI wired in a triangle: BALTHASAR on top, CASPAR bottom-left, MELCHIOR bottom-right,
345
+ * joined through the central hub. Always exactly 32 cells wide.
346
+ */
347
+ function magiDiagram(th: Theme, top: UnitView, left: UnitView, right: UnitView, link: ThemeColor, hub: string): string[] {
348
+ const center = (s: string) => {
349
+ const w = visibleWidth(s);
350
+ const l = Math.max(0, Math.floor((10 - w) / 2));
351
+ return " ".repeat(l) + s + " ".repeat(Math.max(0, 10 - w - l));
352
+ };
353
+ const edge = (u: UnitView, s: string) => th.fg(u.lit ? u.tone : "dim", s);
354
+ const name = (u: UnitView) => (u.lit ? th.bold(th.fg(u.tone, center(u.name))) : th.fg("muted", center(u.name)));
355
+ const stat = (u: UnitView) => th.fg(u.lit ? u.tone : "dim", center(u.status));
356
+ const L = (s: string) => th.fg(link, s);
357
+ const sp = (n: number) => " ".repeat(n);
358
+
359
+ return [
360
+ sp(10) + edge(top, "┌──────────┐") + sp(10),
361
+ sp(6) + L("╭───") + edge(top, "┤") + name(top) + edge(top, "├") + L("───╮") + sp(6),
362
+ sp(6) + L("│") + sp(3) + edge(top, "│") + stat(top) + edge(top, "│") + sp(3) + L("│") + sp(6),
363
+ sp(6) + L("│") + sp(3) + edge(top, "└──────────┘") + sp(3) + L("│") + sp(6),
364
+ " " + edge(left, "┌────") + L("┴") + edge(left, "─────┐") + sp(6) + edge(right, "┌─────") + L("┴") + edge(right, "────┐") + " ",
365
+ " " + edge(left, "│") + name(left) + edge(left, "│") + L(hub) + edge(right, "│") + name(right) + edge(right, "│") + " ",
366
+ " " + edge(left, "│") + stat(left) + edge(left, "│") + sp(6) + edge(right, "│") + stat(right) + edge(right, "│") + " ",
367
+ " " + edge(left, "└──────────┘") + sp(6) + edge(right, "└──────────┘") + " ",
368
+ ];
369
+ }
370
+
371
+ /* ─────────────────────────────────────────────────────────── header ── */
372
+
373
+ /** Static header: it scrolls away with the conversation, so nothing here animates. */
374
+ function buildHeader(theme: Theme) {
375
+ const tree = renderTreeOfLife(theme);
376
+
377
+ return {
378
+ render(width: number): string[] {
379
+ const orange = (s: string) => theme.fg("accent", s);
380
+ const dim = (s: string) => theme.fg("dim", s);
381
+ const muted = (s: string) => theme.fg("muted", s);
382
+ const triad = MAGI_UNITS.map((u) => theme.fg("success", u)).join(dim(" · "));
383
+ const subtitle = "KETER → MALKUTH · 10 SEPHIROT · 22 PATHS";
384
+ const lore = "THE MAGI JUDGE · THE GOLEM ACTS · THE SEALS KEEP TIME";
385
+
386
+ // Narrow layout: a single identification line.
387
+ if (width < 44) {
388
+ return ["", orange(theme.bold("◆ MAGI")) + dim(" // ") + triad, ""].map((l) => truncateToWidth(l, width));
389
+ }
390
+
391
+ // Medium layout: tree on top, wordmark below.
392
+ if (width < 70) {
393
+ const lines = ["", ...tree, ""];
394
+ for (const l of MAGI_WORD) lines.push(orange(l));
395
+ lines.push(dim("├─ ") + triad + dim(" ─┤"), muted(subtitle), dim(lore), "");
396
+ return lines.map((l) => truncateToWidth(l, width));
397
+ }
398
+
399
+ // Wide layout: tree left, wordmark right.
400
+ const lines = [""];
401
+ for (let i = 0; i < tree.length; i++) {
402
+ let right = "";
403
+ if (i >= 5 && i <= 10) right = orange(MAGI_WORD[i - 5]!);
404
+ else if (i === 12) right = dim("├─ ") + triad + dim(" ─┤");
405
+ else if (i === 13) right = muted(subtitle);
406
+ else if (i === 14) right = dim(lore);
407
+ lines.push(truncateToWidth(tree[i]! + " " + right, width));
408
+ }
409
+ lines.push("");
410
+ return lines;
411
+ },
412
+ invalidate() {},
413
+ };
414
+ }
415
+
416
+ /* ─────────────────────────────────────────────── llama-swap monitor ── */
417
+
418
+ interface GpuStat {
419
+ name: string;
420
+ util: number;
421
+ memUsed: number;
422
+ memTotal: number;
423
+ temp: number;
424
+ power: number;
425
+ }
426
+
427
+ type SwapState = "off" | "checking" | "loading" | "ready" | "error";
428
+
429
+ /** Live state of the llama-swap server behind the session model (only when the provider is "llama-swap"). */
430
+ const swap = {
431
+ base: "", // e.g. http://host:9292
432
+ headers: {} as Record<string, string>,
433
+ modelId: "",
434
+ state: "off" as SwapState,
435
+ since: 0, // when the current state started
436
+ loadMs: 0, // how long the last real load took
437
+ error: "",
438
+ gpus: [] as GpuStat[],
439
+ ramUsed: 0,
440
+ ramTotal: 0,
441
+ srvTps: 0, // server-measured generation tok/s of the last request
442
+ srvPps: 0, // server-measured prompt processing tok/s
443
+ cacheTokens: 0,
444
+ inputTokens: 0,
445
+ };
446
+
447
+ function setSwapState(s: SwapState, error = ""): void {
448
+ swap.state = s;
449
+ swap.since = Date.now();
450
+ swap.error = error;
451
+ }
452
+
453
+ function swapGet(path: string, timeoutMs = 5000): Promise<Response> {
454
+ return fetch(swap.base + path, { headers: swap.headers, signal: AbortSignal.timeout(timeoutMs) });
455
+ }
456
+
457
+ /** Parses llama-swap's Prometheus /metrics into GPU and RAM stats. */
458
+ function parseSwapMetrics(text: string): void {
459
+ const gpus = new Map<string, GpuStat>();
460
+ for (const line of text.split("\n")) {
461
+ const m = /^llamaswap_([a-z_]+)(?:\{([^}]*)\})?\s+(\S+)$/.exec(line);
462
+ if (!m) continue;
463
+ const key = m[1]!;
464
+ const labels = m[2] ?? "";
465
+ const value = Number(m[3]);
466
+ if (key === "memory_used_bytes") swap.ramUsed = value;
467
+ else if (key === "memory_total_bytes") swap.ramTotal = value;
468
+ if (!key.startsWith("gpu_")) continue;
469
+ const id = /id="([^"]*)"/.exec(labels)?.[1] ?? "0";
470
+ const gpu = gpus.get(id) ?? { name: /name="([^"]*)"/.exec(labels)?.[1] ?? "GPU", util: 0, memUsed: 0, memTotal: 0, temp: 0, power: 0 };
471
+ if (key === "gpu_util_percent") gpu.util = value;
472
+ else if (key === "gpu_memory_used_bytes") gpu.memUsed = value;
473
+ else if (key === "gpu_memory_total_bytes") gpu.memTotal = value;
474
+ else if (key === "gpu_temperature_celsius") gpu.temp = value;
475
+ else if (key === "gpu_power_draw_watts") gpu.power = value;
476
+ gpus.set(id, gpu);
477
+ }
478
+ swap.gpus = [...gpus.entries()].sort(([a], [b]) => Number(a) - Number(b)).map(([, g]) => g);
479
+ }
480
+
481
+ async function refreshSwapMetrics(): Promise<void> {
482
+ if (!swap.base) return;
483
+ try {
484
+ parseSwapMetrics(await (await swapGet("/metrics")).text());
485
+ } catch {
486
+ // server unreachable: keep the last values
487
+ }
488
+ }
489
+
490
+ /** Server-side token metrics of the most recent request (/api/metrics/activity, newest first). */
491
+ async function refreshSwapActivity(): Promise<void> {
492
+ if (!swap.base) return;
493
+ try {
494
+ const { data } = (await (await swapGet("/api/metrics/activity")).json()) as { data?: any[] };
495
+ const t = data?.[0]?.tokens;
496
+ if (!t) return;
497
+ if (t.tokens_per_second > 0) swap.srvTps = t.tokens_per_second;
498
+ if (t.prompt_per_second > 0) swap.srvPps = t.prompt_per_second;
499
+ swap.cacheTokens = Math.max(0, t.cache_tokens ?? 0);
500
+ swap.inputTokens = Math.max(0, t.input_tokens ?? 0);
501
+ } catch {
502
+ // metrics are optional
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Makes sure the session model is in VRAM. Any request under /upstream/<model>/ makes llama-swap
508
+ * load the model if needed, and it only answers once the model is ready.
509
+ */
510
+ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined = ctx.model): Promise<void> {
511
+ if (!model || model.provider !== "llama-swap" || !model.baseUrl) {
512
+ swap.base = "";
513
+ setSwapState("off");
514
+ return;
515
+ }
516
+ const id = model.id;
517
+ swap.modelId = id;
518
+ setSwapState("checking");
519
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
520
+ swap.base = model.baseUrl.replace(/\/v1\/?$/, "");
521
+ swap.headers = {
522
+ ...((auth.ok ? auth.headers : undefined) as Record<string, string> | undefined),
523
+ ...(auth.ok && auth.apiKey ? { Authorization: `Bearer ${auth.apiKey}` } : {}),
524
+ };
525
+ void refreshSwapMetrics();
526
+ void refreshSwapActivity();
527
+
528
+ // no answer within 400ms → the model isn't in VRAM and is being loaded
529
+ const slow = setTimeout(() => {
530
+ if (swap.modelId === id && swap.state === "checking") setSwapState("loading");
531
+ }, 400);
532
+ const started = Date.now();
533
+ try {
534
+ const res = await swapGet(`/upstream/${encodeURIComponent(id)}/health`, 15 * 60_000);
535
+ if (swap.modelId !== id) return; // the model changed meanwhile
536
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
537
+ if (swap.state === "loading") swap.loadMs = Date.now() - started;
538
+ setSwapState("ready");
539
+ } catch (err) {
540
+ if (swap.modelId === id) setSwapState("error", err instanceof Error ? err.message : String(err));
541
+ } finally {
542
+ clearTimeout(slow);
543
+ void refreshSwapMetrics();
544
+ }
545
+ }
546
+
547
+ /* ─────────────────────────────────────────────────────── side panel ── */
548
+
549
+ const PANEL_WIDTH = MAGI_DIAGRAM_WIDTH + 2;
550
+
551
+ /** Frames per deliberation cycle while thinking: units light up one by one, then consensus. */
552
+ const CYCLE = 32;
553
+
554
+ class MagiPanel implements Component {
555
+ private frame = 0;
556
+ private timer: ReturnType<typeof setInterval> | null = null;
557
+ /** git branch, provided by the footer (the only place pi exposes it). */
558
+ branch?: () => string | null | undefined;
559
+
560
+ constructor(
561
+ private readonly tui: TUI,
562
+ private readonly theme: Theme,
563
+ ) {
564
+ this.timer = setInterval(() => {
565
+ this.frame++;
566
+ // ~8 fps while something moves, rare refresh otherwise.
567
+ if (animating() || this.frame % 8 === 0) this.tui.requestRender();
568
+ }, 125);
569
+ }
570
+
571
+ invalidate(): void {}
572
+
573
+ dispose(): void {
574
+ if (this.timer) clearInterval(this.timer);
575
+ this.timer = null;
576
+ }
577
+
578
+ /* ── drawing helpers ── */
579
+
580
+ private pad(content: string, inner: number): string {
581
+ const w = visibleWidth(content);
582
+ if (w > inner) return truncateToWidth(content, inner);
583
+ return content + " ".repeat(inner - w);
584
+ }
585
+
586
+ private frameLine(content: string, inner: number): string {
587
+ const b = (s: string) => this.theme.fg("border", s);
588
+ return b("│") + this.pad(content, inner) + b("│");
589
+ }
590
+
591
+ private sep(inner: number, label?: string): string {
592
+ const b = (s: string) => this.theme.fg("border", s);
593
+ if (!label) return b("├" + "─".repeat(inner) + "┤");
594
+ const text = ` ${truncateToWidth(label, inner - 4)} `;
595
+ const rest = Math.max(0, inner - visibleWidth(text) - 1);
596
+ return b("├─") + this.theme.fg("muted", text) + b("─".repeat(rest) + "┤");
597
+ }
598
+
599
+ private field(label: string, value: string, inner: number, tone: ThemeColor = "text"): string {
600
+ const l = this.theme.fg("dim", label.padEnd(9));
601
+ return this.frameLine(" " + l + this.theme.fg(tone, value), inner);
602
+ }
603
+
604
+ /* ── the golem: tools ── */
605
+
606
+ private golemArt(inner: number): string[] {
607
+ const th = this.theme;
608
+ const fallen = Date.now() - state.lastFailAt < FAIL_FLASH_MS;
609
+ const art = fallen ? GOLEM_FALLEN : this.frame % 4 < 2 ? GOLEM_STRIKE : GOLEM_REST;
610
+ const tone: ThemeColor = fallen ? "error" : "accent";
611
+ const left = " ".repeat(Math.max(0, Math.floor((inner - 20) / 2)));
612
+ return art.map((line) => {
613
+ const word = fallen ? "MET" : "EMET";
614
+ const at = line.indexOf(word);
615
+ if (at < 0) return left + th.fg(tone, line);
616
+ return left + th.fg(tone, line.slice(0, at)) + th.bold(th.fg(fallen ? "error" : "warning", word)) + th.fg(tone, line.slice(at + word.length));
617
+ });
618
+ }
619
+
620
+ /* ── MAGI triangle (thinking, verdicts, boot) or the golem (tools) ── */
621
+
622
+ private councilSection(inner: number): string[] {
623
+ const th = this.theme;
624
+ const f = this.frame;
625
+ const now = Date.now();
626
+ let title: string;
627
+ let body: string[];
628
+
629
+ const idle = state.phase === "idle";
630
+ const booting = idle && (swap.state === "checking" || swap.state === "loading");
631
+ const offline = idle && swap.state === "error";
632
+ const golem = state.phase === "tool" || (idle && now - state.lastFailAt < FAIL_FLASH_MS);
633
+
634
+ if (golem) {
635
+ const fallen = now - state.lastFailAt < FAIL_FLASH_MS;
636
+ title = fallen
637
+ ? `GOLEM FELL · MET · ${state.lastFailTool}`
638
+ : `GOLEM · EMET · ${state.toolName || "tool"} ${secsSince(state.phaseSince, now)}s`;
639
+ body = this.golemArt(inner);
640
+ } else {
641
+ let link: ThemeColor;
642
+ let hub: string;
643
+ let units: UnitView[];
644
+ const names = [...MAGI_UNITS];
645
+
646
+ if (state.compacting || now - state.rebornAt < REBIRTH_MS) {
647
+ // the seals break while the context is compacted; then the world is remade
648
+ const reborn = !state.compacting;
649
+ title = reborn ? "SEVENTH SEAL OPENED · REBORN" : `BREAKING THE SEALS ${secsSince(state.compactSince, now)}s`;
650
+ link = reborn ? "success" : f % 4 < 2 ? "warning" : "error";
651
+ hub = reborn ? "═MAGI═" : ["─SEAL─", "━SEAL━"][f % 2]!;
652
+ units = names.map((name, i) =>
653
+ reborn
654
+ ? { name, status: "REBORN", tone: "success", lit: true }
655
+ : { name, status: "SEAL " + "◉".repeat(1 + ((Math.floor(f / 4) + i) % 3)), tone: "warning", lit: (f + i) % 3 !== 0 },
656
+ );
657
+ } else if (booting) {
658
+ // MAGI boot while llama-swap loads the model into VRAM: units come online one at a time.
659
+ const litCount = Math.floor(f / 6) % 4;
660
+ title = swap.state === "loading" ? `BOOT · LOADING MODEL ${secsSince(swap.since, now)}s` : "BOOT · CHECKING MODEL";
661
+ link = f % 4 < 2 ? "warning" : "dim";
662
+ hub = ["─MAGI─", "━MAGI━"][f % 2]!;
663
+ units = names.map((name, i) =>
664
+ i < litCount
665
+ ? { name, status: "ONLINE", tone: "warning", lit: true }
666
+ : { name, status: i === litCount ? "BOOT" : "·····", tone: "warning", lit: i === litCount && f % 2 === 0 },
667
+ );
668
+ } else if (offline) {
669
+ title = "MAGI OFFLINE";
670
+ link = "error";
671
+ hub = "─ ╳ ──";
672
+ units = names.map((name) => ({ name, status: "OFFLINE", tone: "error", lit: f % 16 < 8 }));
673
+ } else if (idle && swap.state === "ready" && now - swap.since < 3000) {
674
+ title = swap.loadMs ? `MAGI ONLINE · ${fmtMs(swap.loadMs)}` : "MAGI ONLINE";
675
+ link = "success";
676
+ hub = "═MAGI═";
677
+ units = names.map((name) => ({ name, status: "ONLINE", tone: "success", lit: true }));
678
+ } else if (state.phase === "thinking") {
679
+ // Deliberation: each unit lights up in turn until all three agree, then the cycle restarts.
680
+ const litCount = Math.min(3, Math.floor((f % CYCLE) / 8));
681
+ const consensus = litCount === 3;
682
+ title = consensus ? "CONSENSUS" : `DELIBERATION ${secsSince(state.phaseSince, now)}s`;
683
+ link = consensus ? (f % 4 < 2 ? "warning" : "accent") : "accent";
684
+ const dots = ["· ", " · ", " · ", " · ", " ·"];
685
+ units = names.map((name, i) => {
686
+ if (i < litCount) return { name, status: consensus ? "AGREE" : MAGI_TASKS[i]!, tone: consensus ? "warning" : "accent", lit: true };
687
+ // the unit being processed right now flickers
688
+ return { name, status: dots[(f + i) % dots.length]!, tone: "accent", lit: i === litCount && f % 2 === 0 };
689
+ });
690
+ hub = consensus ? "◆MAGI◆" : ["─MAGI─", "━MAGI━"][f % 2]!;
691
+ } else if (state.phase === "responding") {
692
+ title = "VERDICT: APPROVED";
693
+ link = "success";
694
+ hub = "═MAGI═";
695
+ units = names.map((name) => ({ name, status: "APPROVE", tone: "success", lit: true }));
696
+ } else {
697
+ title = "MAGI · AWAITING QUESTION";
698
+ link = "dim";
699
+ hub = "─MAGI─";
700
+ units = names.map((name) => ({ name, status: "STANDBY", tone: "success", lit: false }));
701
+ }
702
+ body = magiDiagram(th, units[1]!, units[2]!, units[0]!, link, hub);
703
+ }
704
+
705
+ const out = [this.sep(inner, title)];
706
+ for (const l of body) out.push(this.frameLine(l, inner));
707
+
708
+ // status row: what the agent is doing
709
+ const pulse = animating(now) ? pulseFrames(th)[f % 6]! : th.fg("dim", "◇");
710
+ let activity: string;
711
+ if (state.compacting) activity = th.fg("warning", "COMPACTING");
712
+ else if (state.phase === "tool") activity = th.fg("warning", "TOOL CALL");
713
+ else if (state.phase === "thinking") activity = th.fg("accent", "THINKING");
714
+ else if (state.phase === "responding") activity = th.fg("success", "RESPONDING");
715
+ else if (booting) activity = th.fg("warning", "LOADING MODEL");
716
+ else if (offline) activity = th.fg("error", "SERVER OFFLINE");
717
+ else activity = th.fg("dim", "STANDBY");
718
+ const status = `${pulse} ${activity}`;
719
+ out.push(this.frameLine(" ".repeat(Math.max(0, Math.floor((inner - visibleWidth(status)) / 2))) + status, inner));
720
+ return out;
721
+ }
722
+
723
+ render(width: number): string[] {
724
+ const th = this.theme;
725
+ const inner = Math.max(24, width - 2);
726
+ const b = (s: string) => th.fg("border", s);
727
+ const out: string[] = [];
728
+
729
+ const title = " MAGI // SESSION MONITOR ";
730
+ const fill = Math.max(0, inner - visibleWidth(title) - 1);
731
+ out.push(b("┌─") + th.fg("accent", title) + b("─".repeat(fill) + "┐"));
732
+
733
+ out.push(...this.councilSection(inner));
734
+
735
+ // session data
736
+ out.push(this.sep(inner, "SESSION"));
737
+ const model = liveCtx?.model;
738
+ out.push(this.field("MODEL", truncateToWidth(model?.id ?? "—", inner - 11), inner, "text"));
739
+ out.push(this.field("PROVIDER", truncateToWidth(String(model?.provider ?? "—"), inner - 11), inner, "muted"));
740
+ out.push(this.field("THINKING", liveCtx?.thinkingLevel ?? "off", inner, "muted"));
741
+ const cwd = (liveCtx?.cwd ?? "").split("/").filter(Boolean).pop() ?? "—";
742
+ out.push(this.field("PROJECT", truncateToWidth(cwd, inner - 11), inner, "muted"));
743
+ const branch = this.branch?.();
744
+ if (branch) out.push(this.field("BRANCH", truncateToWidth(branch, inner - 11), inner, "muted"));
745
+
746
+ // telemetry
747
+ out.push(this.sep(inner, "TELEMETRY"));
748
+ const stats = tokenStats();
749
+ out.push(
750
+ this.frameLine(
751
+ ` ${th.fg("dim", "TURNS".padEnd(9))}${th.fg("text", String(state.turns).padEnd(6))}${th.fg("dim", "TOOLS ")}${th.fg("text", String(state.tools))}`,
752
+ inner,
753
+ ),
754
+ );
755
+ out.push(
756
+ this.frameLine(
757
+ ` ${th.fg("dim", "TOKENS".padEnd(9))}${th.fg("success", "↑" + fmtTokens(stats.input))} ${th.fg("warning", "↓" + fmtTokens(stats.output))}`,
758
+ inner,
759
+ ),
760
+ );
761
+ if (stats.cacheRead) out.push(this.field("CACHE", fmtTokens(stats.cacheRead), inner, "muted"));
762
+ if (stats.cost) out.push(this.field("COST", `$${stats.cost.toFixed(3)}`, inner, "muted"));
763
+
764
+ // SYNC: the golem's obedience = tool success rate (CHESED ✓ / GEBURAH ✗)
765
+ const sync = syncPercent();
766
+ out.push(
767
+ this.frameLine(
768
+ sync === null
769
+ ? ` ${th.fg("dim", "SYNC".padEnd(9))}${th.fg("dim", "— no tools yet")}`
770
+ : ` ${th.fg("dim", "SYNC".padEnd(9))}${bar(th, Math.round(sync / 10), 10, syncTone(sync))} ${th.fg(syncTone(sync), `${sync.toFixed(0)}%`)}`,
771
+ inner,
772
+ ),
773
+ );
774
+ if (state.toolOk + state.toolFail) {
775
+ out.push(
776
+ this.frameLine(
777
+ ` ${th.fg("dim", "CHESED".padEnd(9))}${th.fg("success", `✓${state.toolOk}`.padEnd(6))}${th.fg("dim", "GEBURAH ")}${th.fg(state.toolFail ? "error" : "dim", `✗${state.toolFail}`)}`,
778
+ inner,
779
+ ),
780
+ );
781
+ }
782
+
783
+ // SEALS: the context window, one seal per seventh
784
+ const usage = liveCtx?.getContextUsage?.();
785
+ const percent = usage?.percent ?? 0;
786
+ out.push(this.frameLine(` ${th.fg("dim", "SEALS".padEnd(9))}${renderSeals(th, percent)} ${th.fg(usageTone(percent), `${percent.toFixed(0)}%`)}`, inner));
787
+ if (usage?.contextWindow) {
788
+ out.push(this.field("CONTEXT", `${usage.tokens == null ? "?" : fmtTokens(usage.tokens)} / ${fmtTokens(usage.contextWindow)}`, inner, "muted"));
789
+ }
790
+
791
+ // performance
792
+ out.push(this.sep(inner, "PERFORMANCE"));
793
+ const live = state.phase !== "idle" && perf.chars > 0;
794
+ // idle: prefer the value measured by llama-swap itself
795
+ const tps = live ? liveTps() : swap.srvTps || perf.tps;
796
+ out.push(
797
+ this.frameLine(
798
+ ` ${th.fg("dim", "TOK/S".padEnd(9))}${th.fg(live ? "accent" : "success", tps ? tps.toFixed(1) : "—")}${live ? th.fg("dim", " ~live") : swap.srvTps ? th.fg("dim", " server") : ""}`,
799
+ inner,
800
+ ),
801
+ );
802
+ if (swap.srvPps) out.push(this.field("PROMPT/S", swap.srvPps.toFixed(1), inner, "muted"));
803
+ if (swap.inputTokens) {
804
+ const hit = Math.round((swap.cacheTokens / swap.inputTokens) * 100);
805
+ out.push(this.field("KV CACHE", `${fmtTokens(swap.cacheTokens)}/${fmtTokens(swap.inputTokens)} hit ${hit}%`, inner, "muted"));
806
+ }
807
+ out.push(this.field("PEAK", perf.peakTps ? `${perf.peakTps.toFixed(1)} tok/s` : "—", inner, "muted"));
808
+ out.push(this.field("TTFT", perf.ttft ? fmtMs(perf.ttft) : "—", inner, "muted"));
809
+ out.push(this.field("DURATION", perf.lastMs ? fmtMs(perf.lastMs) : "—", inner, "muted"));
810
+ const up = Math.floor((Date.now() - sessionStart) / 60000);
811
+ out.push(this.field("UPTIME", `${Math.floor(up / 60)}h ${String(up % 60).padStart(2, "0")}m`, inner, "muted"));
812
+
813
+ // llama-swap server
814
+ if (swap.base) {
815
+ out.push(this.sep(inner, "LLAMA-SWAP"));
816
+ const st =
817
+ swap.state === "ready"
818
+ ? th.fg("success", "IN VRAM")
819
+ : swap.state === "error"
820
+ ? th.fg("error", "OFFLINE")
821
+ : th.fg("warning", swap.state === "loading" ? `LOADING ${secsSince(swap.since)}s` : "CHECKING");
822
+ const load = swap.state === "ready" && swap.loadMs ? th.fg("dim", ` load ${fmtMs(swap.loadMs)}`) : "";
823
+ out.push(this.frameLine(` ${th.fg("dim", "STATUS".padEnd(9))}${st}${load}`, inner));
824
+ if (swap.state === "error") out.push(this.frameLine(" " + th.fg("error", swap.error), inner));
825
+ const gib = (n: number) => n / 2 ** 30;
826
+ for (const [i, g] of swap.gpus.entries()) {
827
+ const pct = g.memTotal ? (g.memUsed / g.memTotal) * 100 : 0;
828
+ out.push(
829
+ this.frameLine(
830
+ ` ${th.fg("dim", `VRAM${i}`.padEnd(9))}${bar(th, Math.round(pct / 10), 10, "accent")} ${th.fg("muted", `${gib(g.memUsed).toFixed(1)}/${Math.round(gib(g.memTotal))}G`)}`,
831
+ inner,
832
+ ),
833
+ );
834
+ out.push(
835
+ this.frameLine(
836
+ ` ${th.fg("dim", `GPU${i}`.padEnd(9))}${th.fg(g.util > 0 ? "accent" : "muted", `${Math.round(g.util)}%`.padEnd(6))}${th.fg(g.temp >= 80 ? "error" : "muted", `${Math.round(g.temp)}°C`.padEnd(7))}${th.fg("muted", `${Math.round(g.power)}W`)}`,
837
+ inner,
838
+ ),
839
+ );
840
+ }
841
+ if (swap.ramTotal) out.push(this.field("RAM", `${gib(swap.ramUsed).toFixed(1)} / ${Math.round(gib(swap.ramTotal))}G`, inner, "muted"));
842
+ }
843
+
844
+ out.push(b("└" + "─".repeat(inner) + "┘"));
845
+ return out.map((l) => truncateToWidth(l, width));
846
+ }
847
+ }
848
+
849
+ /* ─────────────────────────────────────────────── MAGI deliberation ── */
850
+
851
+ type Vote = "APPROVE" | "CONDITIONAL" | "REJECT";
852
+
853
+ function voteTone(v: string | null | undefined): "success" | "warning" | "error" {
854
+ return v === "APPROVE" ? "success" : v === "CONDITIONAL" ? "warning" : "error";
855
+ }
856
+
857
+ /** Three minds, three useful engineering viewpoints. */
858
+ const MAGI = [
859
+ {
860
+ unit: "MELCHIOR",
861
+ nature: "PRAGMATIST",
862
+ persona:
863
+ "You are MELCHIOR, the pragmatist of the MAGI council: a senior engineer. Judge by: does it solve the actual problem, " +
864
+ "the simplest solution that works, effort versus value, reuse of what already exists (stdlib, current stack, existing code), " +
865
+ "time to ship. Call out over-engineering, speculative abstractions and unnecessary dependencies.",
866
+ },
867
+ {
868
+ unit: "BALTHASAR",
869
+ nature: "GUARDIAN",
870
+ persona:
871
+ "You are BALTHASAR, the guardian of the MAGI council: a protective reliability and security architect. Judge by: failure modes, " +
872
+ "security, data safety, operability (monitoring, rollback, being paged at 3am), maintainability for the team, " +
873
+ "backward compatibility and hidden long-term costs. Say what will break and how to prevent it.",
874
+ },
875
+ {
876
+ unit: "CASPAR",
877
+ nature: "VISIONARY",
878
+ persona:
879
+ "You are CASPAR, the visionary of the MAGI council: a creative, lateral-thinking architect. Judge by: is there a better framing of " +
880
+ "the problem, more elegant or unconventional alternatives, developer and user experience, and how the design will evolve " +
881
+ "over the next year. Always propose at least one alternative the other two would likely miss.",
882
+ },
883
+ ] as const satisfies readonly { unit: MagiUnit; nature: string; persona: string }[];
884
+
885
+ const MAGI_RULES = `You are one of the three MAGI deliberating on a question from a software engineer (coding, software architecture, infrastructure).
886
+ Answer strictly from your own nature: the other two MAGI cover the other viewpoints.
887
+ Reply in the same language as the question.
888
+ Output format, no preamble:
889
+ VOTE: APPROVE | CONDITIONAL | REJECT
890
+ - then at most 5 short bullet points (about 120 words total)
891
+ For CONDITIONAL, the bullets must state the conditions. If the question is open-ended rather than yes/no, give your recommendation and vote on the direction the question implies.`;
892
+
893
+ interface MagiOpinion {
894
+ unit: string;
895
+ nature: string;
896
+ model: string;
897
+ vote: Vote | null;
898
+ text: string;
899
+ error?: string;
900
+ }
901
+
902
+ interface Deliberation {
903
+ question: string;
904
+ opinions: MagiOpinion[];
905
+ verdict: Vote | null;
906
+ tally: number;
907
+ }
908
+
909
+ /* ── config: ~/.pi/agent/magi.json → { "MELCHIOR": { "model": "provider/id", "thinking": "low" }, … } ── */
910
+
911
+ interface MagiUnitConfig {
912
+ model?: string;
913
+ thinking?: string;
914
+ }
915
+ type MagiConfig = Partial<Record<MagiUnit, MagiUnitConfig>>;
916
+
917
+ const MAGI_CONFIG_PATH = join(homedir(), ".pi", "agent", "magi.json");
918
+
919
+ function loadMagiConfig(): MagiConfig {
920
+ try {
921
+ return JSON.parse(readFileSync(MAGI_CONFIG_PATH, "utf8"));
922
+ } catch {
923
+ return {};
924
+ }
925
+ }
926
+
927
+ function parseVote(text: string): Vote {
928
+ const m = /VOTE:\s*\**\s*(APPROVE|CONDITIONAL|REJECT)/i.exec(text);
929
+ return m ? (m[1]!.toUpperCase() as Vote) : "CONDITIONAL";
930
+ }
931
+
932
+ /** Majority vote; no majority (all different) → CONDITIONAL; no valid votes → null. */
933
+ function tallyVerdict(votes: (Vote | null)[]): { verdict: Vote | null; tally: number } {
934
+ const counts = new Map<Vote, number>();
935
+ for (const v of votes) if (v) counts.set(v, (counts.get(v) ?? 0) + 1);
936
+ let verdict: Vote | null = null;
937
+ let tally = 0;
938
+ for (const [v, c] of counts) {
939
+ if (c > tally) {
940
+ verdict = v;
941
+ tally = c;
942
+ }
943
+ }
944
+ if (tally === 1 && counts.size > 1) verdict = "CONDITIONAL";
945
+ return { verdict, tally };
946
+ }
947
+
948
+ /** Recent user/assistant text from the current branch, so the MAGI know what "this" refers to. */
949
+ function conversationExcerpt(ctx: ExtensionContext, maxChars = 6000): string {
950
+ const parts: string[] = [];
951
+ for (const e of ctx.sessionManager.getBranch()) {
952
+ if (e.type !== "message") continue;
953
+ const m = e.message as any;
954
+ if (m.role !== "user" && m.role !== "assistant") continue;
955
+ const text =
956
+ typeof m.content === "string"
957
+ ? m.content
958
+ : (m.content as any[]).filter((c) => c.type === "text").map((c) => c.text).join("\n");
959
+ if (text.trim()) parts.push(`${m.role.toUpperCase()}: ${text.trim()}`);
960
+ }
961
+ const joined = parts.join("\n\n");
962
+ return joined.length > maxChars ? "…" + joined.slice(-maxChars) : joined;
963
+ }
964
+
965
+ function resolveModel(ctx: ExtensionContext, ref?: string): Model<any> | undefined {
966
+ if (!ref) return ctx.model;
967
+ const slash = ref.indexOf("/");
968
+ return slash > 0 ? ctx.modelRegistry.find(ref.slice(0, slash), ref.slice(slash + 1)) : undefined;
969
+ }
970
+
971
+ async function askMagi(
972
+ ctx: ExtensionContext,
973
+ index: number,
974
+ prompt: string,
975
+ cfg: MagiConfig,
976
+ signal: AbortSignal,
977
+ ): Promise<MagiOpinion> {
978
+ const magi = MAGI[index]!;
979
+ const unitCfg = cfg[magi.unit] ?? {};
980
+ const model = resolveModel(ctx, unitCfg.model);
981
+ const base = { unit: magi.unit, nature: magi.nature, model: model ? `${model.provider}/${model.id}` : (unitCfg.model ?? "—") };
982
+ const fail = (error: string): MagiOpinion => ({ ...base, vote: null, text: "", error });
983
+ if (!model) return fail(unitCfg.model ? `model not found: ${unitCfg.model}` : "no session model");
984
+
985
+ try {
986
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
987
+ if (!auth.ok) return fail((auth as any).error ?? "no credentials for this model");
988
+ const target = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
989
+ const thinking = model.reasoning && unitCfg.thinking && unitCfg.thinking !== "off" ? unitCfg.thinking : undefined;
990
+ const res = await completeSimple(
991
+ target,
992
+ { systemPrompt: `${magi.persona}\n\n${MAGI_RULES}`, messages: [{ role: "user", content: prompt, timestamp: Date.now() }] },
993
+ { apiKey: auth.apiKey, headers: auth.headers, signal, reasoning: thinking as any },
994
+ );
995
+ if (res.stopReason === "error" || res.stopReason === "aborted") return fail(res.errorMessage ?? res.stopReason);
996
+ const text = res.content
997
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
998
+ .map((c) => c.text)
999
+ .join("")
1000
+ .trim();
1001
+ return { ...base, vote: parseVote(text), text: text.replace(/^[\s*]*VOTE:.*(\n|$)/i, "").trim() };
1002
+ } catch (err) {
1003
+ return fail(err instanceof Error ? err.message : String(err));
1004
+ }
1005
+ }
1006
+
1007
+ function buildDeliberationView(
1008
+ tui: TUI,
1009
+ theme: Theme,
1010
+ question: string,
1011
+ opinions: (MagiOpinion | undefined)[],
1012
+ isDone: () => boolean,
1013
+ close: (cancelled: boolean) => void,
1014
+ ) {
1015
+ let tick = 0;
1016
+ const timer = setInterval(() => {
1017
+ tick++;
1018
+ tui.requestRender();
1019
+ }, 100);
1020
+
1021
+ return {
1022
+ render(width: number): string[] {
1023
+ const dim = (s: string) => theme.fg("dim", s);
1024
+ const inner = Math.max(MAGI_DIAGRAM_WIDTH, Math.min(width - 2, 64));
1025
+ const pad = (s: string) => {
1026
+ const t = truncateToWidth(s, inner);
1027
+ return t + " ".repeat(Math.max(0, inner - visibleWidth(t)));
1028
+ };
1029
+ const row = (s: string) => theme.fg("accent", "║") + pad(s) + theme.fg("accent", "║");
1030
+ const done = isDone();
1031
+
1032
+ const out: string[] = [];
1033
+ out.push(theme.fg("accent", "╔" + "═".repeat(inner) + "╗"));
1034
+ out.push(row(theme.bold(theme.fg("accent", " MAGI COUNCIL")) + dim(" :: DELIBERATION")));
1035
+ out.push(theme.fg("accent", "╟" + "─".repeat(inner) + "╢"));
1036
+ out.push(row(" " + theme.fg("muted", question)));
1037
+ out.push(row(""));
1038
+
1039
+ // Pending units flicker until their model answers.
1040
+ const spin = ["◐", "◓", "◑", "◒"][tick % 4]!;
1041
+ const units: UnitView[] = MAGI_UNITS.map((name, i) => {
1042
+ const o = opinions[i];
1043
+ if (!o) return { name, status: `${spin} ···`, tone: "accent", lit: tick % 2 === 0 };
1044
+ if (!o.vote) return { name, status: "ERROR", tone: "error", lit: true };
1045
+ return { name, status: o.vote === "CONDITIONAL" ? "COND." : o.vote, tone: voteTone(o.vote), lit: true };
1046
+ });
1047
+ const { verdict, tally } = tallyVerdict(opinions.map((o) => o?.vote ?? null));
1048
+ const link: ThemeColor = done ? voteTone(verdict) : "accent";
1049
+ const hub = done ? "◆MAGI◆" : ["─MAGI─", "━MAGI━"][tick % 2]!;
1050
+ const left = " ".repeat(Math.max(0, Math.floor((inner - MAGI_DIAGRAM_WIDTH) / 2)));
1051
+ for (const l of magiDiagram(theme, units[1]!, units[2]!, units[0]!, link, hub)) out.push(row(left + l));
1052
+
1053
+ out.push(row(""));
1054
+ for (let i = 0; i < MAGI.length; i++) {
1055
+ const o = opinions[i];
1056
+ const status = !o ? dim("deliberating…") : o.error ? theme.fg("error", o.error) : theme.fg(voteTone(o.vote), o.vote!);
1057
+ out.push(row(` ${theme.fg("text", MAGI[i]!.unit.padEnd(12))}${dim(MAGI[i]!.nature.padEnd(11))}${status}`));
1058
+ }
1059
+ out.push(row(""));
1060
+ if (done) {
1061
+ const label = ` VERDICT: ${verdict ?? "NO QUORUM"} (${tally}/3)`;
1062
+ out.push(row(tick % 8 < 4 ? theme.bold(theme.fg(voteTone(verdict), label)) : theme.fg("dim", label)));
1063
+ } else {
1064
+ out.push(row(dim(" VERDICT: ─────")));
1065
+ }
1066
+ out.push(theme.fg("accent", "╚" + "═".repeat(inner) + "╝"));
1067
+ out.push(dim(done ? " enter/esc: close — full opinions are shown in the chat" : " esc: abort"));
1068
+ return out.map((l) => truncateToWidth(l, width));
1069
+ },
1070
+ handleInput(data: string) {
1071
+ if (isDone()) close(false);
1072
+ else if (matchesKey(data, "escape")) close(true);
1073
+ },
1074
+ invalidate() {},
1075
+ dispose() {
1076
+ clearInterval(timer);
1077
+ },
1078
+ };
1079
+ }
1080
+
1081
+ async function configureMagi(ctx: ExtensionContext): Promise<void> {
1082
+ const cfg = loadMagiConfig();
1083
+ const pool = ctx.scopedModels.length ? ctx.scopedModels.map((s) => s.model) : ctx.modelRegistry.getAvailable();
1084
+ const SESSION = "(session model)";
1085
+ const choices = [SESSION, ...pool.map((m) => `${m.provider}/${m.id}`)];
1086
+ for (const magi of MAGI) {
1087
+ const current = cfg[magi.unit]?.model ?? SESSION;
1088
+ const pick = await ctx.ui.select(`${magi.unit} · ${magi.nature} — model (current: ${current})`, choices);
1089
+ if (pick === undefined) {
1090
+ ctx.ui.notify("MAGI config unchanged", "info");
1091
+ return;
1092
+ }
1093
+ cfg[magi.unit] = { ...cfg[magi.unit], model: pick === SESSION ? undefined : pick };
1094
+ }
1095
+ writeFileSync(MAGI_CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
1096
+ ctx.ui.notify(`MAGI config saved to ${MAGI_CONFIG_PATH}`, "info");
1097
+ }
1098
+
1099
+ /* ──────────────────────────────────────────────────────────── footer ── */
1100
+
1101
+ /**
1102
+ * The footer's left side, one animation per real state of the agent:
1103
+ * - seals breaking / seventh seal opened → context compaction
1104
+ * - golem EMET / MET → tool running / tool failed
1105
+ * - light pulsing in the upper triad → thinking
1106
+ * - light descending to Malkuth → streaming the answer
1107
+ * - light ascending from Malkuth → loading the model into VRAM
1108
+ * - at rest in Malkuth → idle
1109
+ */
1110
+ function footerLeft(th: Theme, now = Date.now()): string {
1111
+ const dim = (s: string) => th.fg("dim", s);
1112
+ const step = Math.floor(now / ANIM_STEP_MS);
1113
+ const lights = (fn: (i: number) => NodeLight) => SEPHIROT.map((_, i) => fn(i));
1114
+
1115
+ if (state.compacting) {
1116
+ const broken = step % 8;
1117
+ return (
1118
+ th.fg("warning", "✶ ") +
1119
+ th.fg("warning", "◉".repeat(broken)) +
1120
+ dim("○".repeat(7 - broken)) +
1121
+ th.fg("warning", " BREAKING THE SEALS") +
1122
+ dim(` · compacting context ${secsSince(state.compactSince, now)}s`)
1123
+ );
1124
+ }
1125
+ if (now - state.rebornAt < REBIRTH_MS) {
1126
+ return (
1127
+ th.fg("success", "✶ ") +
1128
+ renderPath(th, lights(() => (step % 2 ? "on" : "hot"))) +
1129
+ th.fg("success", " SEVENTH SEAL OPENED") +
1130
+ dim(" · context compacted, the world is remade")
1131
+ );
1132
+ }
1133
+ if (state.phase === "tool" || now - state.lastFailAt < FAIL_FLASH_MS) {
1134
+ const sync = syncPercent();
1135
+ const tally = th.fg("success", ` CHESED ✓${state.toolOk}`) + th.fg(state.toolFail ? "error" : "dim", ` GEBURAH ✗${state.toolFail}`);
1136
+ const syncText = sync === null ? "" : dim(" · sync ") + th.fg(syncTone(sync), `${sync.toFixed(0)}%`);
1137
+ if (now - state.lastFailAt < FAIL_FLASH_MS) {
1138
+ return th.fg("error", "✗ GOLEM · MET") + dim(" · ") + th.fg("error", `${state.lastFailTool} failed`) + dim(" ·") + tally + syncText;
1139
+ }
1140
+ const hammer = ["▚", "▞"][step % 2]!;
1141
+ return (
1142
+ th.fg("accent", `${hammer} GOLEM · `) +
1143
+ th.bold(th.fg("warning", "EMET")) +
1144
+ dim(" · ") +
1145
+ th.fg("text", state.toolName || "tool") +
1146
+ dim(` ${secsSince(state.phaseSince, now)}s ·`) +
1147
+ tally +
1148
+ syncText
1149
+ );
1150
+ }
1151
+ if (state.phase === "thinking") {
1152
+ const cur = Math.floor(step / 2) % 3; // Keter, Chokmah, Binah
1153
+ const s = SEPHIROT[cur]!;
1154
+ return (
1155
+ th.fg("accent", "◆ ") +
1156
+ renderPath(th, lights((i) => (i === cur ? "hot" : i < 3 ? "on" : "off"))) +
1157
+ th.fg("warning", ` ${s.name}`) +
1158
+ dim(` · ${s.meaning} · thinking ${secsSince(state.phaseSince, now)}s`)
1159
+ );
1160
+ }
1161
+ if (state.phase === "responding") {
1162
+ const cur = 5 + (step % 5); // Tiferet → Malkuth
1163
+ const s = SEPHIROT[cur]!;
1164
+ const tps = liveTps();
1165
+ return (
1166
+ th.fg("success", "◆ ") +
1167
+ renderPath(th, lights((i) => (i === cur ? "hot" : i < cur ? "on" : "off"))) +
1168
+ th.fg("warning", ` ${s.name}`) +
1169
+ dim(` · ${s.meaning} · manifesting${tps ? ` ${tps.toFixed(1)} tok/s` : ""}`)
1170
+ );
1171
+ }
1172
+ if (swap.state === "checking" || swap.state === "loading") {
1173
+ const cur = 9 - (step % 10); // Malkuth → Keter
1174
+ return (
1175
+ th.fg("warning", "▲ ") +
1176
+ renderPath(th, lights((i) => (i === cur ? "hot" : i > cur ? "on" : "off"))) +
1177
+ th.fg("warning", ` ASCENT`) +
1178
+ dim(` · loading model into VRAM ${secsSince(swap.since, now)}s`)
1179
+ );
1180
+ }
1181
+ const last = state.lastRunMs ? ` · last run ${fmtMs(state.lastRunMs)}` : "";
1182
+ return th.fg("success", "○ ") + th.fg("muted", "MALKUTH") + dim(` · the kingdom · at rest${last}`);
1183
+ }
1184
+
1185
+ /** Footer: the animation on the left, other extensions' statuses on the right. */
1186
+ function buildFooter(tui: TUI, theme: Theme, footerData: any) {
1187
+ const timer = setInterval(() => {
1188
+ if (animating()) tui.requestRender();
1189
+ }, ANIM_STEP_MS);
1190
+
1191
+ const unsub = footerData?.onBranchChange?.(() => tui.requestRender());
1192
+
1193
+ return {
1194
+ render(width: number): string[] {
1195
+ const dim = (s: string) => theme.fg("dim", s);
1196
+ const statuses = footerData?.getExtensionStatuses?.();
1197
+ const right = statuses ? [...statuses.values()].filter(Boolean).join(dim(" │ ")) : "";
1198
+ const room = Math.max(12, width - visibleWidth(right) - 2);
1199
+ const l = truncateToWidth(footerLeft(theme), room);
1200
+ const gap = Math.max(1, width - visibleWidth(l) - visibleWidth(right));
1201
+ return [truncateToWidth(l + " ".repeat(gap) + right, width)];
1202
+ },
1203
+ invalidate() {},
1204
+ dispose() {
1205
+ clearInterval(timer);
1206
+ unsub?.();
1207
+ },
1208
+ };
1209
+ }
1210
+
1211
+ /* ───────────────────────────────────────────────────────── extension ── */
1212
+
1213
+ export default function (pi: ExtensionAPI) {
1214
+ let chrome = true;
1215
+ let panelEnabled = true;
1216
+ let tuiRef: TUI | undefined;
1217
+ let panelHandle: OverlayHandle | undefined;
1218
+ let panel: MagiPanel | undefined;
1219
+
1220
+ const repaint = () => tuiRef?.requestRender();
1221
+
1222
+ let footerDataRef: any;
1223
+ let wrappedRoot: any; // pi's original root, when the panel is a fullscreen column
1224
+
1225
+ const showPanel = (theme: Theme) => {
1226
+ if (!tuiRef || panel || !panelEnabled) return;
1227
+ panel = new MagiPanel(tuiRef, theme);
1228
+ panel.branch = () => footerDataRef?.getGitBranch?.();
1229
+
1230
+ // Fullscreen: a real column next to the conversation → stays put while the chat scrolls.
1231
+ // ponytail: reads TuiAltScreen's private layoutRoot field; if pi renames it, falls back to the overlay.
1232
+ const t = tuiRef as any;
1233
+ if (t.mode === "fullscreen" && t.layoutRoot && typeof t.setLayoutRoot === "function") {
1234
+ wrappedRoot = t.layoutRoot;
1235
+ t.setLayoutRoot(
1236
+ new HStack(
1237
+ [
1238
+ { component: wrappedRoot, basis: 0, grow: 1, shrink: 1, minSize: 40 },
1239
+ { component: panel, basis: PANEL_WIDTH, grow: 0, shrink: 0, visible: (vp) => vp.width >= PANEL_WIDTH + 56 },
1240
+ ],
1241
+ { gap: 1 },
1242
+ ),
1243
+ );
1244
+ tuiRef.requestRender();
1245
+ return;
1246
+ }
1247
+
1248
+ // Regular mode: the terminal owns scrollback, so the overlay scrolls with it.
1249
+ panelHandle = tuiRef.showOverlay(panel, {
1250
+ nonCapturing: true, // doesn't steal the keyboard
1251
+ anchor: "top-right",
1252
+ width: PANEL_WIDTH,
1253
+ maxHeight: "92%",
1254
+ margin: { top: 1, right: 1, bottom: 1 },
1255
+ // hides itself on narrow terminals
1256
+ visible: (termWidth) => termWidth >= PANEL_WIDTH + 56,
1257
+ });
1258
+ };
1259
+
1260
+ const hidePanel = () => {
1261
+ if (wrappedRoot) {
1262
+ (tuiRef as any)?.setLayoutRoot?.(wrappedRoot);
1263
+ wrappedRoot = undefined;
1264
+ }
1265
+ panelHandle?.hide();
1266
+ panel?.dispose();
1267
+ panelHandle = undefined;
1268
+ panel = undefined;
1269
+ };
1270
+
1271
+ const applyChrome = (ctx: ExtensionContext) => {
1272
+ if (ctx.mode !== "tui") return;
1273
+ if (!chrome) {
1274
+ hidePanel();
1275
+ ctx.ui.setHeader(undefined);
1276
+ ctx.ui.setFooter(undefined);
1277
+ ctx.ui.setWorkingIndicator();
1278
+ ctx.ui.setWorkingMessage();
1279
+ ctx.ui.setWidget("magi-hook", undefined);
1280
+ return;
1281
+ }
1282
+ // invisible widget: only used to grab the TUI reference
1283
+ ctx.ui.setWidget("magi-hook", (tui, theme) => {
1284
+ tuiRef = tui;
1285
+ queueMicrotask(() => showPanel(theme));
1286
+ return { render: () => [], invalidate: () => {} };
1287
+ });
1288
+ ctx.ui.setHeader((_tui, theme) => buildHeader(theme));
1289
+ ctx.ui.setFooter((tui, theme, footerData) => {
1290
+ tuiRef = tui;
1291
+ footerDataRef = footerData;
1292
+ return buildFooter(tui, theme, footerData);
1293
+ });
1294
+ ctx.ui.setWorkingIndicator({ frames: pulseFrames(ctx.ui.theme), intervalMs: 110 });
1295
+ ctx.ui.setTitle("MAGI");
1296
+ };
1297
+
1298
+ pi.registerEntryRenderer("magi-verdict", (entry: any, _options: any, theme: Theme) => {
1299
+ const d = entry.data as Deliberation;
1300
+ return {
1301
+ render(width: number): string[] {
1302
+ const out = [theme.fg("accent", "◆ MAGI COUNCIL") + theme.fg("dim", " :: ") + theme.fg("muted", d.question)];
1303
+ for (const o of d.opinions ?? []) {
1304
+ out.push("");
1305
+ const head = o.error ? theme.fg("error", "ERROR") : theme.bold(theme.fg(voteTone(o.vote), o.vote!));
1306
+ out.push(theme.bold(theme.fg("accent", o.unit)) + theme.fg("dim", ` · ${o.nature} · ${o.model} `) + head);
1307
+ for (const line of (o.error ?? o.text).split("\n")) {
1308
+ for (const w of wrapTextWithAnsi(theme.fg(o.error ? "error" : "text", line), Math.max(10, width - 2))) out.push(" " + w);
1309
+ }
1310
+ }
1311
+ out.push("", theme.bold(theme.fg(voteTone(d.verdict), `VERDICT: ${d.verdict ?? "NO QUORUM"} (${d.tally}/3)`)));
1312
+ return out.map((l) => truncateToWidth(l, width));
1313
+ },
1314
+ invalidate() {},
1315
+ };
1316
+ });
1317
+
1318
+ let metricsTimer: ReturnType<typeof setInterval> | undefined;
1319
+
1320
+ pi.on("session_start", async (_event, ctx) => {
1321
+ liveCtx = ctx;
1322
+ if (ctx.mode !== "tui") return;
1323
+ applyChrome(ctx);
1324
+ // load the model into VRAM now (animated in the panel and footer) and keep GPU stats fresh
1325
+ void preloadModel(ctx);
1326
+ metricsTimer ??= setInterval(() => void refreshSwapMetrics(), 3000);
1327
+ metricsTimer.unref?.();
1328
+ });
1329
+
1330
+ pi.on("model_select", async (event, ctx) => {
1331
+ liveCtx = ctx;
1332
+ if (ctx.mode === "tui") void preloadModel(ctx, event.model);
1333
+ });
1334
+
1335
+ pi.on("session_shutdown", async () => {
1336
+ clearInterval(metricsTimer);
1337
+ metricsTimer = undefined;
1338
+ hidePanel();
1339
+ });
1340
+
1341
+ pi.on("agent_start", async () => {
1342
+ state.runStart = Date.now();
1343
+ });
1344
+
1345
+ pi.on("turn_start", async (_event, ctx) => {
1346
+ liveCtx = ctx;
1347
+ state.turns++;
1348
+ setPhase("thinking");
1349
+ if (ctx.mode === "tui" && chrome) ctx.ui.setWorkingMessage("the MAGI deliberate…");
1350
+ repaint();
1351
+ });
1352
+
1353
+ pi.on("message_start", async (event) => {
1354
+ if (event.message.role !== "assistant") return;
1355
+ perf.start = Date.now();
1356
+ perf.first = 0;
1357
+ perf.chars = 0;
1358
+ });
1359
+
1360
+ // The panel tells "thinking" from "responding" by reading the stream.
1361
+ pi.on("message_update", async (event, ctx) => {
1362
+ liveCtx = ctx;
1363
+ const e = event.assistantMessageEvent as any;
1364
+ const t = e?.type;
1365
+ if (t === "thinking_start" || t === "thinking_delta") setPhase("thinking");
1366
+ else if (t === "text_start" || t === "text_delta") setPhase("responding");
1367
+ else if (t === "toolcall_start" || t === "toolcall_delta") setPhase("tool");
1368
+ if (typeof e?.delta === "string" && e.delta) {
1369
+ if (!perf.first) perf.first = Date.now();
1370
+ perf.chars += e.delta.length;
1371
+ }
1372
+ });
1373
+
1374
+ pi.on("message_end", async (event) => {
1375
+ if (event.message.role !== "assistant" || !perf.start) return;
1376
+ const m = event.message as AssistantMessage;
1377
+ const end = Date.now();
1378
+ perf.lastMs = end - perf.start;
1379
+ if (perf.first) {
1380
+ perf.ttft = perf.first - perf.start;
1381
+ const genS = (end - perf.first) / 1000;
1382
+ const out = m.usage?.output || perf.chars / 4;
1383
+ if (genS > 0.05 && out) {
1384
+ perf.tps = out / genS;
1385
+ perf.peakTps = Math.max(perf.peakTps, perf.tps);
1386
+ }
1387
+ }
1388
+ perf.start = 0;
1389
+ repaint();
1390
+ // llama-swap records the request once it completes
1391
+ setTimeout(() => void refreshSwapActivity().then(repaint), 300);
1392
+ });
1393
+
1394
+ pi.on("tool_execution_start", async (event, ctx) => {
1395
+ liveCtx = ctx;
1396
+ setPhase("tool");
1397
+ state.toolName = event.toolName ?? "";
1398
+ state.tools++;
1399
+ repaint();
1400
+ });
1401
+
1402
+ // CHESED (mercy) counts what worked, GEBURAH (severity) what failed; a failure erases the golem's aleph.
1403
+ pi.on("tool_execution_end", async (event) => {
1404
+ if (event.isError) {
1405
+ state.toolFail++;
1406
+ state.lastFailAt = Date.now();
1407
+ state.lastFailTool = event.toolName ?? "tool";
1408
+ } else {
1409
+ state.toolOk++;
1410
+ }
1411
+ repaint();
1412
+ });
1413
+
1414
+ pi.on("agent_settled", async (_event, ctx) => {
1415
+ liveCtx = ctx;
1416
+ if (state.runStart) state.lastRunMs = Date.now() - state.runStart;
1417
+ state.runStart = 0;
1418
+ setPhase("idle");
1419
+ state.toolName = "";
1420
+ if (ctx.mode === "tui" && chrome) ctx.ui.setWorkingMessage();
1421
+ repaint();
1422
+ });
1423
+
1424
+ // The seven seals: compaction breaks them, and the context is reborn.
1425
+ pi.on("session_before_compact", async () => {
1426
+ state.compacting = true;
1427
+ state.compactSince = Date.now();
1428
+ repaint();
1429
+ });
1430
+
1431
+ pi.on("session_compact", async () => {
1432
+ state.compacting = false;
1433
+ state.rebornAt = Date.now();
1434
+ repaint();
1435
+ });
1436
+
1437
+ pi.on("session_compact_failed", async () => {
1438
+ state.compacting = false;
1439
+ repaint();
1440
+ });
1441
+
1442
+ pi.registerCommand("magi", {
1443
+ description: "Ask the three MAGI (pragmatist, guardian, visionary); /magi config assigns a model to each",
1444
+ handler: async (args, ctx) => {
1445
+ const arg = args.trim();
1446
+ if (arg === "config") return configureMagi(ctx);
1447
+
1448
+ const question = arg || (await ctx.ui.input("Question for the MAGI:", "should we …?"))?.trim() || "";
1449
+ if (!question) return;
1450
+
1451
+ const cfg = loadMagiConfig();
1452
+ const project = (ctx.cwd ?? "").split("/").filter(Boolean).pop() ?? "";
1453
+ const prompt =
1454
+ `Project: ${project}\n\nRecent conversation (context only, may be empty):\n<conversation>\n${conversationExcerpt(ctx)}\n</conversation>\n\n` +
1455
+ `Question for the MAGI:\n${question}`;
1456
+
1457
+ const controller = new AbortController();
1458
+ const opinions: (MagiOpinion | undefined)[] = MAGI.map(() => undefined);
1459
+ let finished = false;
1460
+ const all = Promise.all(
1461
+ MAGI.map((_, i) =>
1462
+ askMagi(ctx, i, prompt, cfg, controller.signal).then((o) => {
1463
+ opinions[i] = o;
1464
+ return o;
1465
+ }),
1466
+ ),
1467
+ ).finally(() => {
1468
+ finished = true;
1469
+ });
1470
+
1471
+ if (ctx.mode === "tui") {
1472
+ const cancelled = await ctx.ui.custom<boolean>((tui, theme, _keys, done) =>
1473
+ buildDeliberationView(tui, theme, question, opinions, () => finished, done),
1474
+ );
1475
+ if (cancelled) {
1476
+ controller.abort();
1477
+ ctx.ui.notify("MAGI deliberation aborted", "warning");
1478
+ return;
1479
+ }
1480
+ }
1481
+
1482
+ const final = await all;
1483
+ const { verdict, tally } = tallyVerdict(final.map((o) => o.vote));
1484
+ pi.appendEntry("magi-verdict", { question, opinions: final, verdict, tally } satisfies Deliberation);
1485
+ },
1486
+ });
1487
+
1488
+ pi.registerCommand("magi-ui", {
1489
+ description: "MAGI chrome: enable the theme, or manage chrome and side panel (on|off|panel)",
1490
+ handler: async (args, ctx) => {
1491
+ liveCtx = ctx;
1492
+ const arg = args.trim().toLowerCase();
1493
+
1494
+ if (arg === "panel") {
1495
+ panelEnabled = !panelEnabled;
1496
+ if (panelEnabled) showPanel(ctx.ui.theme);
1497
+ else hidePanel();
1498
+ ctx.ui.notify(`Side panel ${panelEnabled ? "enabled" : "disabled"}`, "info");
1499
+ return;
1500
+ }
1501
+ if (arg === "off" || arg === "on") {
1502
+ chrome = arg === "on";
1503
+ applyChrome(ctx);
1504
+ ctx.ui.notify(`MAGI chrome ${chrome ? "enabled" : "disabled"}`, "info");
1505
+ return;
1506
+ }
1507
+
1508
+ const res = ctx.ui.setTheme("magi");
1509
+ if (!res.success) {
1510
+ ctx.ui.notify(`Theme magi not found: ${res.error}`, "error");
1511
+ return;
1512
+ }
1513
+ chrome = true;
1514
+ applyChrome(ctx);
1515
+ ctx.ui.notify("MAGI online — /magi-ui panel, /magi-ui off", "info");
1516
+ },
1517
+ });
1518
+ }