pi-magi-theme 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,22 +4,24 @@
4
4
  * One public-domain mythology, every symbol bound to a real function:
5
5
  * - the TREE OF LIFE shows where the agent is: the upper triad while it thinks, the light descending
6
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
7
+ * - the three MAGI (MELCHIOR / BALTHASAR / CASPAR) flicker while the agent works, showing what it is doing,
8
+ * and form the /magi council: three real models voting (on a question, or on the pending git diff)
9
+ * - the GOLEM acts: it is animated by EMET ("truth") while tools run, showing the file or command it works on;
10
+ * a failing tool erases the aleph and EMET becomes MET ("death"). SYNC is the tool success rate
11
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)
12
+ * - the SEVEN SEALS measure the context window; the sixth warns before compaction, the seventh opens when it runs
13
+ * - llama-swap telemetry: VRAM, GPU load/temp/power, energy used, RAM, server-side tok/s, prompt tok/s, cache hits
14
+ * - /magi config → assign a model to each MAGI; /magi-ui compact|status → smaller panel, llama-swap report
15
15
  *
16
16
  * Artwork is original; the symbolism is public domain.
17
17
  * Use with the theme ../../themes/magi.json
18
18
  */
19
19
 
20
+ import { execFile } from "node:child_process";
20
21
  import { readFileSync, writeFileSync } from "node:fs";
21
22
  import { homedir } from "node:os";
22
23
  import { join } from "node:path";
24
+ import { promisify } from "node:util";
23
25
  import type { AssistantMessage, Model } from "@earendil-works/pi-ai";
24
26
  import { completeSimple } from "@earendil-works/pi-ai";
25
27
  import type { ExtensionAPI, ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
@@ -92,20 +94,20 @@ function renderTreeOfLife(th: Theme): string[] {
92
94
  }
93
95
  // ring of radius r, cleared inside and with a small gap around it so paths stop short of the circle;
94
96
  // dashed rings keep every other 30° arc
95
- const ring = (cx: number, cy: number, r: number, v: number, dashed: boolean) => {
97
+ const ring = (c: { x: number; y: number }, r: number, v: number, dashed: boolean) => {
96
98
  for (let y = -r - 2; y <= r + 2; y++) {
97
99
  for (let x = -r - 2; x <= r + 2; x++) {
98
100
  const d = Math.hypot(x, y);
99
101
  if (d > r + 1.5) continue;
100
102
  const onRing = d > r - 0.7 && d <= r + 0.5;
101
103
  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);
104
+ set(c.x + x, c.y + y, onRing && (!dashed || arc) ? v : 0);
103
105
  }
104
106
  }
105
107
  };
106
- ring(DAAT.x, DAAT.y, 3, DAAT_PX, true);
108
+ ring(DAAT, 3, DAAT_PX, true);
107
109
  for (const s of SEPHIROT) {
108
- ring(s.x, s.y, 3.5, NODE, false);
110
+ ring(s, 3.5, NODE, false);
109
111
  set(s.x, s.y, NODE);
110
112
  }
111
113
 
@@ -178,7 +180,6 @@ const GOLEM_FALLEN = [
178
180
 
179
181
  const MAGI_UNITS = ["MELCHIOR", "BALTHASAR", "CASPAR"] as const;
180
182
  type MagiUnit = (typeof MAGI_UNITS)[number];
181
- const MAGI_TASKS = ["ANALYSIS", "SYNTHESIS", "VERIFY"] as const;
182
183
 
183
184
  function pulseFrames(theme: Theme): string[] {
184
185
  return [
@@ -191,27 +192,52 @@ function pulseFrames(theme: Theme): string[] {
191
192
  ];
192
193
  }
193
194
 
195
+ /** Pseudo-random on/off per unit, changing every `hold` frames: the MAGI flicker while they work. */
196
+ function flicker(frame: number, unit: number, hold = 2): boolean {
197
+ const x = Math.imul(Math.floor(frame / hold) + 1, 2654435761) ^ Math.imul(unit + 1, 40503);
198
+ return (x >>> 0) % 100 < 55;
199
+ }
200
+
194
201
  /* ─────────────────────────────────────────────────────── live state ── */
195
202
 
196
203
  type Phase = "idle" | "thinking" | "responding" | "tool";
204
+ type Vote = "APPROVE" | "CONDITIONAL" | "REJECT";
197
205
 
198
206
  const state = {
199
207
  phase: "idle" as Phase,
200
208
  phaseSince: Date.now(),
201
209
  toolName: "",
210
+ toolTarget: "", // file or command the current tool works on
202
211
  turns: 0,
203
212
  toolOk: 0, // CHESED
204
213
  toolFail: 0, // GEBURAH
205
214
  lastFailAt: 0,
206
215
  lastFailTool: "",
216
+ lastFailTarget: "",
207
217
  runStart: 0,
208
218
  lastRunMs: 0,
209
219
  compacting: false,
210
220
  compactSince: 0,
211
221
  compactBy: "", // who opens the seals: "smart-compact" (pi-smart-compact package) or "pi native"
212
222
  rebornAt: 0,
223
+ hasSmartCompact: false,
224
+ lastCouncil: undefined as { verdict: Vote | null; tally: number; question: string } | undefined,
213
225
  };
214
226
 
227
+ /** Panel preferences, persisted under "ui" in ~/.pi/agent/magi.json. */
228
+ type Currency = "EUR" | "USD";
229
+
230
+ const ui = {
231
+ compact: false,
232
+ kwhPrice: undefined as number | undefined, // price per kWh, for the COST row (/magi-ui config)
233
+ currency: "EUR" as Currency,
234
+ };
235
+
236
+ function fmtMoney(value: number): string {
237
+ const digits = value < 1 ? 3 : 2;
238
+ return ui.currency === "USD" ? `$${value.toFixed(digits)}` : `${value.toFixed(digits)} €`;
239
+ }
240
+
215
241
  function setPhase(p: Phase): void {
216
242
  if (state.phase === p) return;
217
243
  state.phase = p;
@@ -221,6 +247,8 @@ function setPhase(p: Phase): void {
221
247
  const ANIM_STEP_MS = 220;
222
248
  const FAIL_FLASH_MS = 2500;
223
249
  const REBIRTH_MS = 6000;
250
+ const DONE_TITLE_AFTER_MS = 30_000;
251
+ const SIXTH_SEAL_PERCENT = (6 / 7) * 100;
224
252
 
225
253
  /** Golem obedience: share of tool calls that succeeded (null before the first tool). */
226
254
  function syncPercent(): number | null {
@@ -240,6 +268,17 @@ function animating(now = Date.now()): boolean {
240
268
  );
241
269
  }
242
270
 
271
+ /** What a tool works on, from its arguments: a path, a command, a pattern, or the first short string. */
272
+ function toolTarget(args: unknown): string {
273
+ if (!args || typeof args !== "object") return "";
274
+ const a = args as Record<string, unknown>;
275
+ for (const key of ["path", "file_path", "filePath", "command", "cmd", "pattern", "query", "url"]) {
276
+ if (typeof a[key] === "string" && a[key]) return String(a[key]).replace(/\s+/g, " ").trim();
277
+ }
278
+ const first = Object.values(a).find((v) => typeof v === "string" && v.length > 0 && v.length < 200);
279
+ return typeof first === "string" ? first.replace(/\s+/g, " ").trim() : "";
280
+ }
281
+
243
282
  /** Performance of the current stream / last message. */
244
283
  const perf = {
245
284
  start: 0, // message_start
@@ -270,6 +309,8 @@ const sessionStart = Date.now();
270
309
 
271
310
  let liveCtx: ExtensionContext | undefined;
272
311
 
312
+ /* ── token totals: counted once per message instead of rescanning the whole session ── */
313
+
273
314
  interface TokenStats {
274
315
  input: number;
275
316
  output: number;
@@ -277,24 +318,26 @@ interface TokenStats {
277
318
  cost: number;
278
319
  }
279
320
 
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;
321
+ const tokens: TokenStats = { input: 0, output: 0, cacheRead: 0, cost: 0 };
322
+
323
+ function addUsage(m: AssistantMessage): void {
324
+ tokens.input += m.usage?.input ?? 0;
325
+ tokens.output += m.usage?.output ?? 0;
326
+ tokens.cacheRead += m.usage?.cacheRead ?? 0;
327
+ tokens.cost += m.usage?.cost?.total ?? 0;
328
+ }
329
+
330
+ /** Full recount: on session start and when switching branch. Also restores the last council verdict. */
331
+ function recountSession(ctx: ExtensionContext): void {
332
+ Object.assign(tokens, { input: 0, output: 0, cacheRead: 0, cost: 0 });
333
+ state.lastCouncil = undefined;
334
+ for (const entry of ctx.sessionManager.getBranch()) {
335
+ if (entry.type === "message" && entry.message.role === "assistant") addUsage(entry.message as AssistantMessage);
336
+ if (entry.type === "custom" && entry.customType === "magi-verdict") {
337
+ const d = entry.data as { verdict?: Vote | null; tally?: number; question?: string } | undefined;
338
+ if (d && Array.isArray((d as any).opinions)) state.lastCouncil = { verdict: d.verdict ?? null, tally: d.tally ?? 0, question: d.question ?? "" };
294
339
  }
295
340
  }
296
- statsCache = { at: now, value };
297
- return value;
298
341
  }
299
342
 
300
343
  function fmtTokens(n: number): string {
@@ -322,11 +365,17 @@ function bar(theme: Theme, filled: number, total: number, tone: ThemeColor): str
322
365
  return theme.fg(tone, "▓".repeat(f)) + theme.fg("dim", "░".repeat(total - f));
323
366
  }
324
367
 
325
- /** The seven seals of the context window: one breaks every 1/7 of it. */
326
- function renderSeals(theme: Theme, percent: number): string {
368
+ /** The seven seals of the context window: one breaks every 1/7 of it; the sixth blinks as a warning. */
369
+ function renderSeals(theme: Theme, percent: number, now = Date.now()): string {
327
370
  const broken = Math.min(7, Math.floor((percent / 100) * 7));
328
371
  const tone = usageTone(percent);
329
- return theme.fg(tone, "◉".repeat(broken)) + theme.fg("dim", "○".repeat(7 - broken));
372
+ let out = "";
373
+ for (let i = 0; i < 7; i++) {
374
+ if (i >= broken) out += theme.fg("dim", "○");
375
+ else if (broken === 6 && i === 5) out += theme.fg(Math.floor(now / 500) % 2 ? "warning" : "error", "◉");
376
+ else out += theme.fg(tone, "◉");
377
+ }
378
+ return out;
330
379
  }
331
380
 
332
381
  /* ──────────────────────────────────────────────────── MAGI triangle ── */
@@ -396,14 +445,15 @@ function buildHeader(theme: Theme) {
396
445
  return lines.map((l) => truncateToWidth(l, width));
397
446
  }
398
447
 
399
- // Wide layout: tree left, wordmark right.
448
+ // Wide layout: tree left, wordmark right (rows chosen to center the text block on the tree).
449
+ const [wordAt, triadAt] = [5, 12];
400
450
  const lines = [""];
401
451
  for (let i = 0; i < tree.length; i++) {
402
452
  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);
453
+ if (i >= wordAt && i < wordAt + MAGI_WORD.length) right = orange(MAGI_WORD[i - wordAt]!);
454
+ else if (i === triadAt) right = dim("├─ ") + triad + dim(" ─┤");
455
+ else if (i === triadAt + 1) right = muted(subtitle);
456
+ else if (i === triadAt + 2) right = dim(lore);
407
457
  lines.push(truncateToWidth(tree[i]! + " " + right, width));
408
458
  }
409
459
  lines.push("");
@@ -424,13 +474,14 @@ interface GpuStat {
424
474
  power: number;
425
475
  }
426
476
 
427
- type SwapState = "off" | "checking" | "loading" | "ready" | "error";
477
+ type SwapState = "off" | "checking" | "loading" | "ready" | "asleep" | "error";
428
478
 
429
479
  /** Live state of the llama-swap server behind the session model (only when the provider is "llama-swap"). */
430
480
  const swap = {
431
481
  base: "", // e.g. http://host:9292
432
482
  headers: {} as Record<string, string>,
433
- modelId: "",
483
+ modelId: "", // the id pi uses (may be an alias)
484
+ realId: "", // the model llama-swap actually runs for that id
434
485
  state: "off" as SwapState,
435
486
  since: 0, // when the current state started
436
487
  loadMs: 0, // how long the last real load took
@@ -442,6 +493,9 @@ const swap = {
442
493
  srvPps: 0, // server-measured prompt processing tok/s
443
494
  cacheTokens: 0,
444
495
  inputTokens: 0,
496
+ energyWh: 0, // GPU energy since the session started
497
+ lastSampleAt: 0,
498
+ lastWatts: 0,
445
499
  };
446
500
 
447
501
  function setSwapState(s: SwapState, error = ""): void {
@@ -478,20 +532,46 @@ function parseSwapMetrics(text: string): void {
478
532
  swap.gpus = [...gpus.entries()].sort(([a], [b]) => Number(a) - Number(b)).map(([, g]) => g);
479
533
  }
480
534
 
535
+ /**
536
+ * Integrates GPU power over time into Wh.
537
+ * ponytail: trapezoid over the 3–30s poll samples, GPUs only; gaps over 60s are clamped.
538
+ */
539
+ function sampleEnergy(now = Date.now()): void {
540
+ const watts = swap.gpus.reduce((sum, g) => sum + g.power, 0);
541
+ if (swap.lastSampleAt) {
542
+ const dtH = Math.min(60_000, now - swap.lastSampleAt) / 3_600_000;
543
+ swap.energyWh += ((swap.lastWatts + watts) / 2) * dtH;
544
+ }
545
+ swap.lastSampleAt = now;
546
+ swap.lastWatts = watts;
547
+ }
548
+
481
549
  async function refreshSwapMetrics(): Promise<void> {
482
550
  if (!swap.base) return;
483
551
  try {
484
552
  parseSwapMetrics(await (await swapGet("/metrics")).text());
553
+ sampleEnergy();
485
554
  } catch {
486
555
  // server unreachable: keep the last values
487
556
  }
488
557
  }
489
558
 
490
- /** Server-side token metrics of the most recent request (/api/metrics/activity, newest first). */
559
+ /** Notices when llama-swap unloaded the session model (e.g. its ttl expired) so the panel can say so. */
560
+ async function refreshSwapRunning(): Promise<void> {
561
+ if (!swap.base || swap.state !== "ready") return;
562
+ try {
563
+ const { running } = (await (await swapGet("/running")).json()) as { running?: { model: string }[] };
564
+ if (!(running ?? []).some((r) => r.model === swap.realId)) setSwapState("asleep");
565
+ } catch {
566
+ // optional
567
+ }
568
+ }
569
+
570
+ /** Server-side token metrics of the most recent request: a single row from /api/metrics/activity. */
491
571
  async function refreshSwapActivity(): Promise<void> {
492
572
  if (!swap.base) return;
493
573
  try {
494
- const { data } = (await (await swapGet("/api/metrics/activity")).json()) as { data?: any[] };
574
+ const { data } = (await (await swapGet("/api/metrics/activity?limit=1")).json()) as { data?: any[] };
495
575
  const t = data?.[0]?.tokens;
496
576
  if (!t) return;
497
577
  if (t.tokens_per_second > 0) swap.srvTps = t.tokens_per_second;
@@ -515,6 +595,7 @@ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined
515
595
  }
516
596
  const id = model.id;
517
597
  swap.modelId = id;
598
+ swap.realId = id;
518
599
  setSwapState("checking");
519
600
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
520
601
  swap.base = model.baseUrl.replace(/\/v1\/?$/, "");
@@ -524,6 +605,14 @@ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined
524
605
  };
525
606
  void refreshSwapMetrics();
526
607
  void refreshSwapActivity();
608
+ // an alias (e.g. "… - Instruct") runs another model: /running lists that one
609
+ try {
610
+ const { data } = (await (await swapGet("/v1/models")).json()) as { data?: any[] };
611
+ const entry = data?.find((m) => m.id === id);
612
+ if (entry?.meta?.llamaswap?.type === "alias" && entry.meta.llamaswap.modelID) swap.realId = entry.meta.llamaswap.modelID;
613
+ } catch {
614
+ // keep the id itself
615
+ }
527
616
 
528
617
  // no answer within 400ms → the model isn't in VRAM and is being loaded
529
618
  const slow = setTimeout(() => {
@@ -544,18 +633,87 @@ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined
544
633
  }
545
634
  }
546
635
 
636
+ /* ── /magi-ui status: a report built from the last requests llama-swap recorded ── */
637
+
638
+ interface ActivityRow {
639
+ timestamp: string;
640
+ model: string;
641
+ resp_status_code: number;
642
+ duration_ms: number;
643
+ tokens?: {
644
+ cache_tokens?: number;
645
+ input_tokens?: number;
646
+ output_tokens?: number;
647
+ prompt_per_second?: number;
648
+ tokens_per_second?: number;
649
+ draft_tokens?: number;
650
+ draft_acc_tokens?: number;
651
+ };
652
+ }
653
+
654
+ const ACTIVITY_REPORT_ROWS = 100;
655
+
656
+ function activityReport(th: Theme, rows: ActivityRow[], total: number, now = Date.now()): string[] {
657
+ const dim = (s: string) => th.fg("dim", s);
658
+ const mean = (xs: number[]) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0);
659
+ const ago = (iso: string) => {
660
+ const m = Math.max(0, Math.round((now - Date.parse(iso)) / 60000));
661
+ return m < 60 ? `${m}m ago` : `${Math.floor(m / 60)}h ${m % 60}m ago`;
662
+ };
663
+ const out = [th.fg("accent", th.bold("LLAMA-SWAP STATUS")) + dim(` · last ${rows.length} of ${total} requests`)];
664
+ const byModel = new Map<string, ActivityRow[]>();
665
+ for (const r of rows) byModel.set(r.model, [...(byModel.get(r.model) ?? []), r]);
666
+
667
+ for (const [model, rs] of byModel) {
668
+ const t = rs.map((r) => r.tokens ?? {});
669
+ const sum = (k: keyof NonNullable<ActivityRow["tokens"]>) => t.reduce((a, x) => a + Math.max(0, x[k] ?? 0), 0);
670
+ const errors = rs.filter((r) => r.resp_status_code >= 400).length;
671
+ const gen = mean(t.map((x) => x.tokens_per_second ?? 0).filter((v) => v > 0));
672
+ const prompt = mean(t.map((x) => x.prompt_per_second ?? 0).filter((v) => v > 0));
673
+ const cached = sum("cache_tokens");
674
+ const input = sum("input_tokens");
675
+ const draft = sum("draft_tokens");
676
+ const accepted = sum("draft_acc_tokens");
677
+ const durations = rs.map((r) => r.duration_ms).sort((a, b) => a - b);
678
+ const p95 = durations[Math.min(durations.length - 1, Math.floor(durations.length * 0.95))] ?? 0;
679
+ out.push("", th.fg("text", th.bold(model)));
680
+ out.push(dim(" requests ") + th.fg("text", String(rs.length)) + dim(" · errors ") + th.fg(errors ? "error" : "success", String(errors)) + dim(` · last ${ago(rs[0]!.timestamp)}`));
681
+ out.push(
682
+ dim(" speed ") +
683
+ th.fg("success", `gen ${gen.toFixed(1)} tok/s`) +
684
+ dim(" · ") +
685
+ th.fg("text", `prompt ${prompt.toFixed(1)} tok/s`) +
686
+ (draft ? dim(" · draft accepted ") + th.fg("text", `${Math.round((accepted / draft) * 100)}%`) : ""),
687
+ );
688
+ out.push(
689
+ dim(" tokens ") +
690
+ th.fg("text", `in ${fmtTokens(input)} · cached ${fmtTokens(cached)}`) +
691
+ dim(` (hit ${cached + input ? Math.round((cached / (cached + input)) * 100) : 0}%)`) +
692
+ th.fg("text", ` · out ${fmtTokens(sum("output_tokens"))}`),
693
+ );
694
+ out.push(dim(" duration ") + th.fg("text", `avg ${fmtMs(mean(durations))} · p95 ${fmtMs(p95)} · max ${fmtMs(durations.at(-1) ?? 0)}`));
695
+ }
696
+ const slowest = [...rows].sort((a, b) => b.duration_ms - a.duration_ms)[0];
697
+ if (slowest) {
698
+ const st = slowest.tokens ?? {};
699
+ out.push("", dim("slowest ") + th.fg("warning", fmtMs(slowest.duration_ms)) + dim(` · ${slowest.model} · ${ago(slowest.timestamp)} · in ${fmtTokens((st.input_tokens ?? 0) + (st.cache_tokens ?? 0))} out ${fmtTokens(st.output_tokens ?? 0)}`));
700
+ }
701
+ return out;
702
+ }
703
+
547
704
  /* ─────────────────────────────────────────────────────── side panel ── */
548
705
 
549
706
  const PANEL_WIDTH = MAGI_DIAGRAM_WIDTH + 2;
550
707
 
551
- /** Frames per deliberation cycle while thinking: units light up one by one, then consensus. */
552
- const CYCLE = 32;
553
-
554
708
  class MagiPanel implements Component {
555
709
  private frame = 0;
556
710
  private timer: ReturnType<typeof setInterval> | null = null;
711
+ /** The data rows change slowly: they are rebuilt at most twice a second, only the council animates every frame. */
712
+ private cache: { at: number; inner: number; compact: boolean; lines: string[] } | undefined;
557
713
  /** git branch, provided by the footer (the only place pi exposes it). */
558
714
  branch?: () => string | null | undefined;
715
+ /** When set (fullscreen column), the panel pads itself down to this many rows. */
716
+ fillHeight?: () => number;
559
717
 
560
718
  constructor(
561
719
  private readonly tui: TUI,
@@ -568,7 +726,9 @@ class MagiPanel implements Component {
568
726
  }, 125);
569
727
  }
570
728
 
571
- invalidate(): void {}
729
+ invalidate(): void {
730
+ this.cache = undefined;
731
+ }
572
732
 
573
733
  dispose(): void {
574
734
  if (this.timer) clearInterval(this.timer);
@@ -588,12 +748,19 @@ class MagiPanel implements Component {
588
748
  return b("│") + this.pad(content, inner) + b("│");
589
749
  }
590
750
 
591
- private sep(inner: number, label?: string): string {
751
+ private labelLine(inner: number, label: string, left: string, right: string, tone: ThemeColor): string {
592
752
  const b = (s: string) => this.theme.fg("border", s);
593
- if (!label) return b("├" + "─".repeat(inner) + "┤");
594
753
  const text = ` ${truncateToWidth(label, inner - 4)} `;
595
754
  const rest = Math.max(0, inner - visibleWidth(text) - 1);
596
- return b("├─") + this.theme.fg("muted", text) + b("─".repeat(rest) + "┤");
755
+ return b(left + "─") + this.theme.fg(tone, text) + b("─".repeat(rest) + right);
756
+ }
757
+
758
+ private sep(inner: number, label: string): string {
759
+ return this.labelLine(inner, label, "├", "┤", "muted");
760
+ }
761
+
762
+ private centered(content: string, inner: number): string {
763
+ return this.frameLine(" ".repeat(Math.max(0, Math.floor((inner - visibleWidth(content)) / 2))) + content, inner);
597
764
  }
598
765
 
599
766
  private field(label: string, value: string, inner: number, tone: ThemeColor = "text"): string {
@@ -617,151 +784,113 @@ class MagiPanel implements Component {
617
784
  });
618
785
  }
619
786
 
620
- /* ── MAGI triangle (thinking, verdicts, boot) or the golem (tools) ── */
787
+ /* ── top of the panel: the MAGI (flickering while working) or the golem (tools) ── */
621
788
 
622
789
  private councilSection(inner: number): string[] {
623
790
  const th = this.theme;
624
791
  const f = this.frame;
625
792
  const now = Date.now();
793
+ const secs = (t: number) => `${secsSince(t, now)}s`;
794
+ const pulse = animating(now) ? pulseFrames(th)[f % 6]! : th.fg("dim", "◇");
795
+
796
+ const idle = state.phase === "idle";
797
+ const fallen = now - state.lastFailAt < FAIL_FLASH_MS;
626
798
  let title: string;
799
+ let titleTone: ThemeColor = "accent";
627
800
  let body: string[];
801
+ let status: string;
628
802
 
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`;
803
+ if (state.phase === "tool" || (idle && fallen)) {
804
+ title = fallen ? `GOLEM FELL · MET · ${state.lastFailTool}` : `GOLEM · ${state.toolName || "tool"} · ${secs(state.phaseSince)}`;
805
+ titleTone = fallen ? "error" : "warning";
639
806
  body = this.golemArt(inner);
807
+ const target = (fallen ? state.lastFailTarget || state.lastFailTool : state.toolTarget || state.toolName) || "tool";
808
+ status = `${pulse} ${th.fg(fallen ? "error" : "text", truncateToWidth(target, inner - 4))}`;
640
809
  } 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
650
- ? "SEVENTH SEAL OPENED · REBORN"
651
- : `SEALS · ${state.compactBy || "compacting"} ${secsSince(state.compactSince, now)}s`;
652
- link = reborn ? "success" : f % 4 < 2 ? "warning" : "error";
653
- hub = reborn ? "═MAGI═" : ["─SEAL─", "━SEAL━"][f % 2]!;
654
- units = names.map((name, i) =>
655
- reborn
656
- ? { name, status: "REBORN", tone: "success", lit: true }
657
- : { name, status: "SEAL " + "◉".repeat(1 + ((Math.floor(f / 4) + i) % 3)), tone: "warning", lit: (f + i) % 3 !== 0 },
658
- );
659
- } else if (booting) {
660
- // MAGI boot while llama-swap loads the model into VRAM: units come online one at a time.
661
- const litCount = Math.floor(f / 6) % 4;
662
- title = swap.state === "loading" ? `BOOT · LOADING MODEL ${secsSince(swap.since, now)}s` : "BOOT · CHECKING MODEL";
663
- link = f % 4 < 2 ? "warning" : "dim";
664
- hub = ["─MAGI─", "━MAGI━"][f % 2]!;
665
- units = names.map((name, i) =>
666
- i < litCount
667
- ? { name, status: "ONLINE", tone: "warning", lit: true }
668
- : { name, status: i === litCount ? "BOOT" : "·····", tone: "warning", lit: i === litCount && f % 2 === 0 },
669
- );
670
- } else if (offline) {
810
+ let word: string;
811
+ let tone: ThemeColor;
812
+ let link: ThemeColor = "dim";
813
+ let hub = "─MAGI─";
814
+ let lit = (i: number) => flicker(f, i);
815
+ const breathing = ["─MAGI─", "━MAGI━"][f % 2]!;
816
+
817
+ if (state.compacting) {
818
+ title = `COMPACTING · ${state.compactBy || "context"} · ${secs(state.compactSince)}`;
819
+ [word, tone, link, hub, titleTone] = ["COMPACTING", "warning", "warning", ["─SEAL─", "━SEAL━"][f % 2]!, "warning"];
820
+ } else if (now - state.rebornAt < REBIRTH_MS) {
821
+ title = "SEVENTH SEAL OPENED";
822
+ [word, tone, link, hub, titleTone] = ["REBORN", "success", "success", "═MAGI═", "success"];
823
+ lit = () => true;
824
+ } else if (idle && (swap.state === "checking" || swap.state === "loading")) {
825
+ title = swap.state === "loading" ? `LOADING MODEL · ${secs(swap.since)}` : "CHECKING MODEL";
826
+ [word, tone, link, hub, titleTone] = ["LOADING", "warning", "warning", breathing, "warning"];
827
+ } else if (idle && swap.state === "error") {
671
828
  title = "MAGI OFFLINE";
672
- link = "error";
673
- hub = "─ ╳ ──";
674
- units = names.map((name) => ({ name, status: "OFFLINE", tone: "error", lit: f % 16 < 8 }));
829
+ [word, tone, link, hub, titleTone] = ["OFFLINE", "error", "error", "─ ╳ ──", "error"];
830
+ lit = () => f % 16 < 8;
831
+ } else if (idle && swap.state === "asleep") {
832
+ title = "MODEL ASLEEP · type to wake";
833
+ [word, tone, titleTone] = ["ASLEEP", "muted", "muted"];
834
+ lit = () => false;
675
835
  } else if (idle && swap.state === "ready" && now - swap.since < 3000) {
676
836
  title = swap.loadMs ? `MAGI ONLINE · ${fmtMs(swap.loadMs)}` : "MAGI ONLINE";
677
- link = "success";
678
- hub = "═MAGI═";
679
- units = names.map((name) => ({ name, status: "ONLINE", tone: "success", lit: true }));
837
+ [word, tone, link, hub, titleTone] = ["ONLINE", "success", "success", "═MAGI═", "success"];
838
+ lit = () => true;
680
839
  } else if (state.phase === "thinking") {
681
- // Deliberation: each unit lights up in turn until all three agree, then the cycle restarts.
682
- const litCount = Math.min(3, Math.floor((f % CYCLE) / 8));
683
- const consensus = litCount === 3;
684
- title = consensus ? "CONSENSUS" : `DELIBERATION ${secsSince(state.phaseSince, now)}s`;
685
- link = consensus ? (f % 4 < 2 ? "warning" : "accent") : "accent";
686
- const dots = ["· ", " · ", " · ", " · ", " ·"];
687
- units = names.map((name, i) => {
688
- if (i < litCount) return { name, status: consensus ? "AGREE" : MAGI_TASKS[i]!, tone: consensus ? "warning" : "accent", lit: true };
689
- // the unit being processed right now flickers
690
- return { name, status: dots[(f + i) % dots.length]!, tone: "accent", lit: i === litCount && f % 2 === 0 };
691
- });
692
- hub = consensus ? "◆MAGI◆" : ["─MAGI─", "━MAGI━"][f % 2]!;
840
+ title = `THINKING · ${secs(state.phaseSince)}`;
841
+ [word, tone, link, hub] = ["THINKING", "accent", "accent", breathing];
693
842
  } else if (state.phase === "responding") {
694
- title = "VERDICT: APPROVED";
695
- link = "success";
696
- hub = "═MAGI═";
697
- units = names.map((name) => ({ name, status: "APPROVE", tone: "success", lit: true }));
843
+ const tps = liveTps();
844
+ title = `RESPONDING${tps ? ` · ${tps.toFixed(1)} tok/s` : ""}`;
845
+ [word, tone, link, hub, titleTone] = ["RESPONDING", "success", "success", breathing, "success"];
698
846
  } else {
699
- title = "MAGI · AWAITING QUESTION";
700
- link = "dim";
701
- hub = "─MAGI─";
702
- units = names.map((name) => ({ name, status: "STANDBY", tone: "success", lit: false }));
847
+ title = "STANDBY";
848
+ [word, tone, titleTone] = ["STANDBY", "muted", "muted"];
849
+ lit = () => false;
703
850
  }
851
+
852
+ const units = MAGI_UNITS.map((name, i) => ({ name, status: word, tone, lit: lit(i) }));
704
853
  body = magiDiagram(th, units[1]!, units[2]!, units[0]!, link, hub);
854
+ const c = state.lastCouncil;
855
+ status = c
856
+ ? `${pulse} ${th.fg("dim", "COUNCIL ")}${th.fg(voteTone(c.verdict), `${c.verdict ?? "NO QUORUM"} ${c.tally}/3`)}`
857
+ : `${pulse} ${th.fg("dim", "COUNCIL · /magi")}`;
705
858
  }
706
859
 
707
- const out = [this.sep(inner, title)];
708
- for (const l of body) out.push(this.frameLine(l, inner));
709
-
710
- // status row: what the agent is doing
711
- const pulse = animating(now) ? pulseFrames(th)[f % 6]! : th.fg("dim", "◇");
712
- let activity: string;
713
- if (state.compacting) activity = th.fg("warning", "COMPACTING");
714
- else if (state.phase === "tool") activity = th.fg("warning", "TOOL CALL");
715
- else if (state.phase === "thinking") activity = th.fg("accent", "THINKING");
716
- else if (state.phase === "responding") activity = th.fg("success", "RESPONDING");
717
- else if (booting) activity = th.fg("warning", "LOADING MODEL");
718
- else if (offline) activity = th.fg("error", "SERVER OFFLINE");
719
- else activity = th.fg("dim", "STANDBY");
720
- const status = `${pulse} ${activity}`;
721
- out.push(this.frameLine(" ".repeat(Math.max(0, Math.floor((inner - visibleWidth(status)) / 2))) + status, inner));
722
- return out;
860
+ return [this.labelLine(inner, title, "┌", "┐", titleTone), ...body.map((l) => this.frameLine(l, inner)), this.centered(status, inner)];
723
861
  }
724
862
 
725
- render(width: number): string[] {
726
- const th = this.theme;
727
- const inner = Math.max(24, width - 2);
728
- const b = (s: string) => th.fg("border", s);
729
- const out: string[] = [];
730
-
731
- const title = " MAGI // SESSION MONITOR ";
732
- const fill = Math.max(0, inner - visibleWidth(title) - 1);
733
- out.push(b("┌─") + th.fg("accent", title) + b("─".repeat(fill) + "┐"));
734
-
735
- out.push(...this.councilSection(inner));
863
+ /* ── data rows ── */
736
864
 
737
- // session data
738
- out.push(this.sep(inner, "SESSION"));
865
+ private sessionRows(inner: number, compact: boolean): string[] {
866
+ const out = [this.sep(inner, "SESSION")];
739
867
  const model = liveCtx?.model;
740
868
  out.push(this.field("MODEL", truncateToWidth(model?.id ?? "—", inner - 11), inner, "text"));
741
- out.push(this.field("PROVIDER", truncateToWidth(String(model?.provider ?? "—"), inner - 11), inner, "muted"));
869
+ if (!compact) out.push(this.field("PROVIDER", truncateToWidth(String(model?.provider ?? "—"), inner - 11), inner, "muted"));
742
870
  out.push(this.field("THINKING", liveCtx?.thinkingLevel ?? "off", inner, "muted"));
743
- const cwd = (liveCtx?.cwd ?? "").split("/").filter(Boolean).pop() ?? "—";
744
- out.push(this.field("PROJECT", truncateToWidth(cwd, inner - 11), inner, "muted"));
745
- const branch = this.branch?.();
746
- if (branch) out.push(this.field("BRANCH", truncateToWidth(branch, inner - 11), inner, "muted"));
747
-
748
- // telemetry
749
- out.push(this.sep(inner, "TELEMETRY"));
750
- const stats = tokenStats();
751
- out.push(
752
- this.frameLine(
753
- ` ${th.fg("dim", "TURNS".padEnd(9))}${th.fg("text", String(state.turns))}`,
754
- inner,
755
- ),
756
- );
757
- out.push(
758
- this.frameLine(
759
- ` ${th.fg("dim", "TOKENS".padEnd(9))}${th.fg("success", "↑" + fmtTokens(stats.input))} ${th.fg("warning", "↓" + fmtTokens(stats.output))}`,
760
- inner,
761
- ),
762
- );
763
- if (stats.cacheRead) out.push(this.field("CACHE", fmtTokens(stats.cacheRead), inner, "muted"));
764
- if (stats.cost) out.push(this.field("COST", `$${stats.cost.toFixed(3)}`, inner, "muted"));
871
+ if (!compact) {
872
+ const cwd = (liveCtx?.cwd ?? "").split("/").filter(Boolean).pop() ?? "—";
873
+ out.push(this.field("PROJECT", truncateToWidth(cwd, inner - 11), inner, "muted"));
874
+ const branch = this.branch?.();
875
+ if (branch) out.push(this.field("BRANCH", truncateToWidth(branch, inner - 11), inner, "muted"));
876
+ }
877
+ return out;
878
+ }
879
+
880
+ private telemetryRows(inner: number, compact: boolean): string[] {
881
+ const th = this.theme;
882
+ const out = [this.sep(inner, "TELEMETRY")];
883
+ if (!compact) {
884
+ out.push(this.frameLine(` ${th.fg("dim", "TURNS".padEnd(9))}${th.fg("text", String(state.turns))}`, inner));
885
+ out.push(
886
+ this.frameLine(
887
+ ` ${th.fg("dim", "TOKENS".padEnd(9))}${th.fg("success", "↑" + fmtTokens(tokens.input))} ${th.fg("warning", "↓" + fmtTokens(tokens.output))}`,
888
+ inner,
889
+ ),
890
+ );
891
+ if (tokens.cacheRead) out.push(this.field("CACHE", fmtTokens(tokens.cacheRead), inner, "muted"));
892
+ if (tokens.cost) out.push(this.field("API COST", `$${tokens.cost.toFixed(3)}`, inner, "muted"));
893
+ }
765
894
 
766
895
  // SYNC: the golem's obedience = tool success rate (CHESED ✓ / GEBURAH ✗)
767
896
  const sync = syncPercent();
@@ -785,12 +914,15 @@ class MagiPanel implements Component {
785
914
  const usage = liveCtx?.getContextUsage?.();
786
915
  const percent = usage?.percent ?? 0;
787
916
  out.push(this.frameLine(` ${th.fg("dim", "SEALS".padEnd(9))}${renderSeals(th, percent)} ${th.fg(usageTone(percent), `${percent.toFixed(0)}%`)}`, inner));
788
- if (usage?.contextWindow) {
917
+ if (!compact && usage?.contextWindow) {
789
918
  out.push(this.field("CONTEXT", `${usage.tokens == null ? "?" : fmtTokens(usage.tokens)} / ${fmtTokens(usage.contextWindow)}`, inner, "muted"));
790
919
  }
920
+ return out;
921
+ }
791
922
 
792
- // performance
793
- out.push(this.sep(inner, "PERFORMANCE"));
923
+ private performanceRows(inner: number, compact: boolean): string[] {
924
+ const th = this.theme;
925
+ const out = [this.sep(inner, "PERFORMANCE")];
794
926
  const live = state.phase !== "idle" && perf.chars > 0;
795
927
  // idle: prefer the value measured by llama-swap itself
796
928
  const tps = live ? liveTps() : swap.srvTps || perf.tps;
@@ -800,6 +932,7 @@ class MagiPanel implements Component {
800
932
  inner,
801
933
  ),
802
934
  );
935
+ if (compact) return out;
803
936
  if (swap.srvPps) out.push(this.field("PROMPT/S", swap.srvPps.toFixed(1), inner, "muted"));
804
937
  // llama-swap's input_tokens are the prompt tokens processed outside the cache
805
938
  const promptTotal = swap.cacheTokens + swap.inputTokens;
@@ -812,86 +945,132 @@ class MagiPanel implements Component {
812
945
  out.push(this.field("DURATION", perf.lastMs ? fmtMs(perf.lastMs) : "—", inner, "muted"));
813
946
  const up = Math.floor((Date.now() - sessionStart) / 60000);
814
947
  out.push(this.field("UPTIME", `${Math.floor(up / 60)}h ${String(up % 60).padStart(2, "0")}m`, inner, "muted"));
948
+ return out;
949
+ }
950
+
951
+ /** COST: GPU energy used in the session × price per kWh set with /magi-ui config. */
952
+ private costRow(inner: number): string {
953
+ if (!swap.gpus.length) return this.field("COST", "—", inner, "muted");
954
+ if (ui.kwhPrice === undefined) return this.field("COST", "→ /magi-ui config", inner, "dim");
955
+ return this.field("COST", fmtMoney((swap.energyWh / 1000) * ui.kwhPrice), inner, "warning");
956
+ }
815
957
 
816
- // llama-swap server
817
- if (swap.base) {
818
- out.push(this.sep(inner, "LLAMA-SWAP"));
819
- const st =
820
- swap.state === "ready"
821
- ? th.fg("success", "IN VRAM")
958
+ private swapRows(inner: number, compact: boolean): string[] {
959
+ if (!swap.base) return [];
960
+ const th = this.theme;
961
+ const out = [this.sep(inner, "LLAMA-SWAP")];
962
+ const st =
963
+ swap.state === "ready"
964
+ ? th.fg("success", "IN VRAM")
965
+ : swap.state === "asleep"
966
+ ? th.fg("muted", "ASLEEP")
822
967
  : swap.state === "error"
823
968
  ? th.fg("error", "OFFLINE")
824
969
  : th.fg("warning", swap.state === "loading" ? `LOADING ${secsSince(swap.since)}s` : "CHECKING");
825
- const load = swap.state === "ready" && swap.loadMs ? th.fg("dim", ` load ${fmtMs(swap.loadMs)}`) : "";
826
- out.push(this.frameLine(` ${th.fg("dim", "STATUS".padEnd(9))}${st}${load}`, inner));
827
- if (swap.state === "error") out.push(this.frameLine(" " + th.fg("error", swap.error), inner));
828
- const gib = (n: number) => n / 2 ** 30;
829
- for (const [i, g] of swap.gpus.entries()) {
830
- const pct = g.memTotal ? (g.memUsed / g.memTotal) * 100 : 0;
831
- out.push(
832
- this.frameLine(
833
- ` ${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`)}`,
834
- inner,
835
- ),
836
- );
837
- out.push(
838
- this.frameLine(
839
- ` ${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`)}`,
840
- inner,
841
- ),
842
- );
843
- }
844
- if (swap.ramTotal) out.push(this.field("RAM", `${gib(swap.ramUsed).toFixed(1)} / ${Math.round(gib(swap.ramTotal))}G`, inner, "muted"));
970
+ const load = swap.state === "ready" && swap.loadMs ? th.fg("dim", ` load ${fmtMs(swap.loadMs)}`) : "";
971
+ out.push(this.frameLine(` ${th.fg("dim", "STATUS".padEnd(9))}${st}${load}`, inner));
972
+ if (compact) return [...out, this.costRow(inner)];
973
+ if (swap.state === "error") out.push(this.frameLine(" " + th.fg("error", swap.error), inner));
974
+ const gib = (n: number) => n / 2 ** 30;
975
+ for (const [i, g] of swap.gpus.entries()) {
976
+ const pct = g.memTotal ? (g.memUsed / g.memTotal) * 100 : 0;
977
+ out.push(
978
+ this.frameLine(
979
+ ` ${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`)}`,
980
+ inner,
981
+ ),
982
+ );
983
+ out.push(
984
+ this.frameLine(
985
+ ` ${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`)}`,
986
+ inner,
987
+ ),
988
+ );
845
989
  }
990
+ if (swap.ramTotal) out.push(this.field("RAM", `${gib(swap.ramUsed).toFixed(1)} / ${Math.round(gib(swap.ramTotal))}G`, inner, "muted"));
991
+ if (swap.gpus.length) out.push(this.field("ENERGY", `${(swap.energyWh / 1000).toFixed(3)} kWh`, inner, "muted"));
992
+ out.push(this.costRow(inner));
993
+ return out;
994
+ }
846
995
 
847
- out.push(b("└" + "─".repeat(inner) + "┘"));
848
- return out.map((l) => truncateToWidth(l, width));
996
+ render(width: number): string[] {
997
+ const inner = Math.max(24, width - 2);
998
+ const now = Date.now();
999
+ if (!this.cache || this.cache.inner !== inner || this.cache.compact !== ui.compact || now - this.cache.at > 500) {
1000
+ const c = ui.compact;
1001
+ this.cache = {
1002
+ at: now,
1003
+ inner,
1004
+ compact: c,
1005
+ lines: [...this.sessionRows(inner, c), ...this.telemetryRows(inner, c), ...this.performanceRows(inner, c), ...this.swapRows(inner, c)],
1006
+ };
1007
+ }
1008
+ const bottom = this.theme.fg("border", "└" + "─".repeat(inner) + "┘");
1009
+ const lines = [...this.councilSection(inner), ...this.cache.lines];
1010
+ // fullscreen: keep the frame going down to the bottom of the terminal
1011
+ const target = (this.fillHeight?.() ?? 0) - 1;
1012
+ while (lines.length < target) lines.push(this.frameLine("", inner));
1013
+ return [...lines, bottom].map((l) => truncateToWidth(l, width));
849
1014
  }
850
1015
  }
851
1016
 
852
1017
  /* ─────────────────────────────────────────────── MAGI deliberation ── */
853
1018
 
854
- type Vote = "APPROVE" | "CONDITIONAL" | "REJECT";
855
-
856
1019
  function voteTone(v: string | null | undefined): "success" | "warning" | "error" {
857
1020
  return v === "APPROVE" ? "success" : v === "CONDITIONAL" ? "warning" : "error";
858
1021
  }
859
1022
 
860
- /** Three minds, three useful engineering viewpoints. */
1023
+ /**
1024
+ * Three minds, three lenses. A lens works on any subject (a nature, not a specialty), so no MAGI
1025
+ * rejects a question just because it is not "its" topic; software is where each lens gets sharpest.
1026
+ */
861
1027
  const MAGI = [
862
1028
  {
863
1029
  unit: "MELCHIOR",
864
1030
  nature: "PRAGMATIST",
865
1031
  persona:
866
- "You are MELCHIOR, the pragmatist of the MAGI council: a senior engineer. Judge by: does it solve the actual problem, " +
867
- "the simplest solution that works, effort versus value, reuse of what already exists (stdlib, current stack, existing code), " +
868
- "time to ship. Call out over-engineering, speculative abstractions and unnecessary dependencies.",
1032
+ "You are MELCHIOR, the pragmatist of the MAGI council. Your lens works on any subject: what actually solves the problem " +
1033
+ "at hand, the simplest path that works, effort and cost versus value, what can be done now with what already exists, " +
1034
+ "and what is unnecessary. In software this means: reuse the current stack and existing code, avoid over-engineering, " +
1035
+ "speculative abstractions and extra dependencies, ship sooner.",
869
1036
  },
870
1037
  {
871
1038
  unit: "BALTHASAR",
872
1039
  nature: "GUARDIAN",
873
1040
  persona:
874
- "You are BALTHASAR, the guardian of the MAGI council: a protective reliability and security architect. Judge by: failure modes, " +
875
- "security, data safety, operability (monitoring, rollback, being paged at 3am), maintainability for the team, " +
876
- "backward compatibility and hidden long-term costs. Say what will break and how to prevent it.",
1041
+ "You are BALTHASAR, the guardian of the MAGI council. Your lens works on any subject: what can go wrong, how badly and " +
1042
+ "for whom, whether the choice can be undone, which safety nets are missing, the hidden and long-term costs, and what " +
1043
+ "must be protected. In software this means: failure modes, security, data safety, operability (monitoring, rollback, " +
1044
+ "being paged at 3am), maintainability and backward compatibility.",
877
1045
  },
878
1046
  {
879
1047
  unit: "CASPAR",
880
1048
  nature: "VISIONARY",
881
1049
  persona:
882
- "You are CASPAR, the visionary of the MAGI council: a creative, lateral-thinking architect. Judge by: is there a better framing of " +
883
- "the problem, more elegant or unconventional alternatives, developer and user experience, and how the design will evolve " +
884
- "over the next year. Always propose at least one alternative the other two would likely miss.",
1050
+ "You are CASPAR, the visionary of the MAGI council. Your lens works on any subject: whether the question is framed right, " +
1051
+ "better or unconventional alternatives, the experience of the people involved, and where the choice leads over time " +
1052
+ "and what it unlocks. In software this means: design alternatives, developer and user experience, how the system " +
1053
+ "evolves over the next year. Always name at least one concrete alternative the other two would likely miss.",
885
1054
  },
886
1055
  ] as const satisfies readonly { unit: MagiUnit; nature: string; persona: string }[];
887
1056
 
888
- const MAGI_RULES = `You are one of the three MAGI deliberating on a question from a software engineer (coding, software architecture, infrastructure).
889
- Answer strictly from your own nature: the other two MAGI cover the other viewpoints.
890
- Reply in the same language as the question.
1057
+ const MAGI_RULES = `You are one of the three MAGI. The council answers whatever the user asks: mostly software engineering, but not only.
1058
+
1059
+ Your nature is a lens, not a specialty, so competence is never a reason to reject. Whatever the subject, first work out the best answer to the question itself, then judge it through your lens. The other two MAGI cover the other lenses: stay in yours.
1060
+
1061
+ Be specific to this question. Every bullet must name something concrete from the question or the conversation: a tool, a number, a scenario, a step, a cost. Never write advice that would fit any question, such as "consider the trade-offs", "it depends", "ensure security" or "test properly".
1062
+
1063
+ How to vote:
1064
+ - APPROVE: you would go ahead as asked, or you have a clear recommendation.
1065
+ - CONDITIONAL: you would go ahead only if specific conditions hold, and you name them. When information is missing, vote CONDITIONAL and say exactly what you need to know and how each answer changes your recommendation.
1066
+ - REJECT: your lens finds a concrete problem that makes the proposal a bad idea, and you say what to do instead. Never reject because the topic is outside software or outside your nature, or because details are missing.
1067
+ For open questions (which one, how to), give your recommendation and vote on how confident you are in it.
1068
+
1069
+ Write in the language of the "Question for the MAGI", even though these instructions and the conversation may be in English.
891
1070
  Output format, no preamble:
892
1071
  VOTE: APPROVE | CONDITIONAL | REJECT
893
- - then at most 5 short bullet points (about 120 words total)
894
- 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.`;
1072
+ - first bullet: your direct answer or recommendation
1073
+ - then up to 4 bullets from your lens: 5 bullets at most in total, about 120 words`;
895
1074
 
896
1075
  interface MagiOpinion {
897
1076
  unit: string;
@@ -909,13 +1088,13 @@ interface Deliberation {
909
1088
  tally: number;
910
1089
  }
911
1090
 
912
- /* ── config: ~/.pi/agent/magi.json → { "MELCHIOR": { "model": "provider/id", "thinking": "low" }, … } ── */
1091
+ /* ── config: ~/.pi/agent/magi.json → { "MELCHIOR": { "model": "provider/id", "thinking": "low" }, …, "ui": { "compact": true, "kwhPrice": 0.3 } } ── */
913
1092
 
914
1093
  interface MagiUnitConfig {
915
1094
  model?: string;
916
1095
  thinking?: string;
917
1096
  }
918
- type MagiConfig = Partial<Record<MagiUnit, MagiUnitConfig>>;
1097
+ type MagiConfig = Partial<Record<MagiUnit, MagiUnitConfig>> & { ui?: { compact?: boolean; kwhPrice?: number; currency?: Currency } };
919
1098
 
920
1099
  const MAGI_CONFIG_PATH = join(homedir(), ".pi", "agent", "magi.json");
921
1100
 
@@ -927,6 +1106,10 @@ function loadMagiConfig(): MagiConfig {
927
1106
  }
928
1107
  }
929
1108
 
1109
+ function saveMagiConfig(cfg: MagiConfig): void {
1110
+ writeFileSync(MAGI_CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
1111
+ }
1112
+
930
1113
  function parseVote(text: string): Vote {
931
1114
  const m = /VOTE:\s*\**\s*(APPROVE|CONDITIONAL|REJECT)/i.exec(text);
932
1115
  return m ? (m[1]!.toUpperCase() as Vote) : "CONDITIONAL";
@@ -965,6 +1148,56 @@ function conversationExcerpt(ctx: ExtensionContext, maxChars = 6000): string {
965
1148
  return joined.length > maxChars ? "…" + joined.slice(-maxChars) : joined;
966
1149
  }
967
1150
 
1151
+ const REVIEW_MAX_CHARS = 24_000;
1152
+
1153
+ /** The pending changes for /magi review: tracked changes against HEAD plus the names of untracked files. */
1154
+ async function pendingChanges(cwd: string): Promise<{ diff: string; untracked: string[] }> {
1155
+ const git = (args: string[]) => promisify(execFile)("git", args, { cwd, maxBuffer: 32 * 1024 * 1024 }).then((r) => r.stdout);
1156
+ let diff: string;
1157
+ try {
1158
+ diff = await git(["diff", "HEAD"]);
1159
+ } catch {
1160
+ // no commits yet: staged + unstaged
1161
+ diff = (await git(["diff", "--cached"])) + (await git(["diff"]));
1162
+ }
1163
+ const untracked = (await git(["ls-files", "--others", "--exclude-standard"]).catch(() => "")).split("\n").filter(Boolean);
1164
+ return { diff, untracked };
1165
+ }
1166
+
1167
+ /** Common function words per language, used to name the reply language explicitly. */
1168
+ const LANGUAGE_HINTS: readonly [string, readonly string[]][] = [
1169
+ ["Italian", ["il", "lo", "la", "gli", "di", "che", "per", "non", "una", "con", "sono", "come", "perché", "è", "dovrei", "meglio", "mettiamo", "questo", "quale"]],
1170
+ ["Spanish", ["el", "los", "las", "que", "para", "por", "es", "cómo", "debería", "mejor", "este", "cuál"]],
1171
+ ["French", ["le", "les", "des", "est", "pour", "avec", "dois", "comment", "mieux", "ce", "quel"]],
1172
+ ["German", ["der", "die", "das", "und", "ist", "nicht", "für", "mit", "ich", "soll", "wie", "besser"]],
1173
+ ["English", ["the", "is", "should", "we", "for", "with", "and", "to", "of", "how", "which", "better"]],
1174
+ ];
1175
+
1176
+ /**
1177
+ * Guesses the question's language from function words; undefined when unsure.
1178
+ * ponytail: stopword heuristic for five languages, swap in a real detector if other languages matter.
1179
+ */
1180
+ function guessLanguage(text: string): string | undefined {
1181
+ const words = text.toLowerCase().match(/\p{L}+/gu) ?? [];
1182
+ const scores = LANGUAGE_HINTS.map(([lang, hints]) => [lang, words.filter((w) => hints.includes(w)).length] as const).sort(
1183
+ (a, b) => b[1] - a[1],
1184
+ );
1185
+ const [best, second] = scores;
1186
+ return best![1] >= 2 && best![1] > second![1] ? best![0] : undefined;
1187
+ }
1188
+
1189
+ /** The user message each MAGI receives. The language reminder sits after the question, where the model reads it last. */
1190
+ function councilPrompt(project: string, context: string, question: string, contextLabel = "Recent conversation (context only, may be empty)"): string {
1191
+ const lang = guessLanguage(question);
1192
+ const reminder = lang
1193
+ ? `(Write your whole answer in ${lang}, even if technical terms in the question are English.)`
1194
+ : "(Write your whole answer in the language of this question, even if technical terms in it are English.)";
1195
+ return (
1196
+ `Project: ${project}\n\n${contextLabel}:\n<context>\n${context}\n</context>\n\n` +
1197
+ `Question for the MAGI:\n${question}\n\n${reminder}`
1198
+ );
1199
+ }
1200
+
968
1201
  function resolveModel(ctx: ExtensionContext, ref?: string): Model<any> | undefined {
969
1202
  if (!ref) return ctx.model;
970
1203
  const slash = ref.indexOf("/");
@@ -1039,11 +1272,10 @@ function buildDeliberationView(
1039
1272
  out.push(row(" " + theme.fg("muted", question)));
1040
1273
  out.push(row(""));
1041
1274
 
1042
- // Pending units flicker until their model answers.
1043
- const spin = ["◐", "◓", "◑", "◒"][tick % 4]!;
1275
+ // Pending units flicker at random until their model answers.
1044
1276
  const units: UnitView[] = MAGI_UNITS.map((name, i) => {
1045
1277
  const o = opinions[i];
1046
- if (!o) return { name, status: `${spin} ···`, tone: "accent", lit: tick % 2 === 0 };
1278
+ if (!o) return { name, status: "···", tone: "accent", lit: flicker(tick, i) };
1047
1279
  if (!o.vote) return { name, status: "ERROR", tone: "error", lit: true };
1048
1280
  return { name, status: o.vote === "CONDITIONAL" ? "COND." : o.vote, tone: voteTone(o.vote), lit: true };
1049
1281
  });
@@ -1081,6 +1313,29 @@ function buildDeliberationView(
1081
1313
  };
1082
1314
  }
1083
1315
 
1316
+ /** A read-only boxed report (used by /magi-ui status); any key closes it. */
1317
+ function buildReportView(theme: Theme, lines: string[], close: () => void) {
1318
+ return {
1319
+ render(width: number): string[] {
1320
+ const inner = Math.max(40, Math.min(width - 2, 96));
1321
+ const row = (s: string) => {
1322
+ const t = truncateToWidth(" " + s, inner);
1323
+ return theme.fg("accent", "│") + t + " ".repeat(Math.max(0, inner - visibleWidth(t))) + theme.fg("accent", "│");
1324
+ };
1325
+ return [
1326
+ theme.fg("accent", "┌" + "─".repeat(inner) + "┐"),
1327
+ ...lines.map(row),
1328
+ theme.fg("accent", "└" + "─".repeat(inner) + "┘"),
1329
+ theme.fg("dim", " any key: close"),
1330
+ ].map((l) => truncateToWidth(l, width));
1331
+ },
1332
+ handleInput(_data: string) {
1333
+ close();
1334
+ },
1335
+ invalidate() {},
1336
+ };
1337
+ }
1338
+
1084
1339
  async function configureMagi(ctx: ExtensionContext): Promise<void> {
1085
1340
  const cfg = loadMagiConfig();
1086
1341
  const pool = ctx.scopedModels.length ? ctx.scopedModels.map((s) => s.model) : ctx.modelRegistry.getAvailable();
@@ -1095,7 +1350,7 @@ async function configureMagi(ctx: ExtensionContext): Promise<void> {
1095
1350
  }
1096
1351
  cfg[magi.unit] = { ...cfg[magi.unit], model: pick === SESSION ? undefined : pick };
1097
1352
  }
1098
- writeFileSync(MAGI_CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
1353
+ saveMagiConfig(cfg);
1099
1354
  ctx.ui.notify(`MAGI config saved to ${MAGI_CONFIG_PATH}`, "info");
1100
1355
  }
1101
1356
 
@@ -1104,11 +1359,12 @@ async function configureMagi(ctx: ExtensionContext): Promise<void> {
1104
1359
  /**
1105
1360
  * The footer's left side, one animation per real state of the agent:
1106
1361
  * - seals breaking / seventh seal opened → context compaction
1107
- * - golem EMET / MET → tool running / tool failed
1362
+ * - golem EMET / MET → tool running (with its file or command) / tool failed
1108
1363
  * - light pulsing in the upper triad → thinking
1109
1364
  * - light descending to Malkuth → streaming the answer
1110
1365
  * - light ascending from Malkuth → loading the model into VRAM
1111
- * - at rest in Malkuth → idle
1366
+ * - the sixth seal → context close to compaction
1367
+ * - asleep / at rest in Malkuth → model unloaded / idle
1112
1368
  */
1113
1369
  function footerLeft(th: Theme, now = Date.now()): string {
1114
1370
  const dim = (s: string) => th.fg("dim", s);
@@ -1140,14 +1396,17 @@ function footerLeft(th: Theme, now = Date.now()): string {
1140
1396
  const tally = th.fg("success", ` CHESED ✓${state.toolOk}`) + th.fg(state.toolFail ? "error" : "dim", ` GEBURAH ✗${state.toolFail}`);
1141
1397
  const syncText = sync === null ? "" : dim(" · sync ") + th.fg(syncTone(sync), `${sync.toFixed(0)}%`);
1142
1398
  if (now - state.lastFailAt < FAIL_FLASH_MS) {
1143
- return th.fg("error", "✗ GOLEM · MET") + dim(" · ") + th.fg("error", `${state.lastFailTool} failed`) + dim(" ·") + tally + syncText;
1399
+ const target = state.lastFailTarget ? dim(" ") + th.fg("error", truncateToWidth(state.lastFailTarget, 40)) : "";
1400
+ return th.fg("error", "✗ GOLEM · MET") + dim(" · ") + th.fg("error", `${state.lastFailTool} failed`) + target + dim(" ·") + tally + syncText;
1144
1401
  }
1145
1402
  const hammer = ["▚", "▞"][step % 2]!;
1403
+ const target = state.toolTarget ? dim(" ") + th.fg("muted", truncateToWidth(state.toolTarget, 40)) : "";
1146
1404
  return (
1147
1405
  th.fg("accent", `${hammer} GOLEM · `) +
1148
1406
  th.bold(th.fg("warning", "EMET")) +
1149
1407
  dim(" · ") +
1150
1408
  th.fg("text", state.toolName || "tool") +
1409
+ target +
1151
1410
  dim(` ${secsSince(state.phaseSince, now)}s ·`) +
1152
1411
  tally +
1153
1412
  syncText
@@ -1183,6 +1442,19 @@ function footerLeft(th: Theme, now = Date.now()): string {
1183
1442
  dim(` · loading model into VRAM ${secsSince(swap.since, now)}s`)
1184
1443
  );
1185
1444
  }
1445
+ const percent = liveCtx?.getContextUsage?.()?.percent ?? 0;
1446
+ if (percent >= SIXTH_SEAL_PERCENT) {
1447
+ const cmd = state.hasSmartCompact ? "/smart-compact" : "/compact";
1448
+ return (
1449
+ th.fg(step % 4 < 2 ? "error" : "warning", "⚠ SIXTH SEAL") +
1450
+ dim(` · context ${percent.toFixed(0)}% · compact now with `) +
1451
+ th.fg("text", cmd) +
1452
+ dim(" before it happens mid-task")
1453
+ );
1454
+ }
1455
+ if (swap.state === "asleep") {
1456
+ return th.fg("muted", "○ MALKUTH") + dim(" · model asleep in llama-swap · type to wake it");
1457
+ }
1186
1458
  const last = state.lastRunMs ? ` · last run ${fmtMs(state.lastRunMs)}` : "";
1187
1459
  return th.fg("success", "○ ") + th.fg("muted", "MALKUTH") + dim(` · the kingdom · at rest${last}`);
1188
1460
  }
@@ -1215,14 +1487,30 @@ function buildFooter(tui: TUI, theme: Theme, footerData: any) {
1215
1487
 
1216
1488
  /* ───────────────────────────────────────────────────────── extension ── */
1217
1489
 
1490
+ const BUSY_POLL_MS = 3_000;
1491
+ const IDLE_POLL_MS = 30_000;
1492
+ /** pi rewrites the window title on its own (right after startup, on session info changes), so ours is re-applied. */
1493
+ const TITLE_REFRESH_MS = 2_000;
1494
+
1495
+ /** Window title: "π - Magi - <working directory>", prefixed with ✓ after a long run until you are back. */
1496
+ function windowTitle(cwd: string, done = false): string {
1497
+ const home = homedir();
1498
+ const dir = cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd;
1499
+ return `${done ? "✓ " : ""}π - Magi - ${dir}`;
1500
+ }
1501
+
1218
1502
  export default function (pi: ExtensionAPI) {
1219
1503
  let chrome = true;
1220
1504
  let panelEnabled = true;
1221
1505
  let tuiRef: TUI | undefined;
1222
1506
  let panelHandle: OverlayHandle | undefined;
1223
1507
  let panel: MagiPanel | undefined;
1508
+ let titleDone = false;
1224
1509
 
1225
- const repaint = () => tuiRef?.requestRender();
1510
+ const repaint = () => {
1511
+ panel?.invalidate();
1512
+ tuiRef?.requestRender();
1513
+ };
1226
1514
 
1227
1515
  let footerDataRef: any;
1228
1516
  let wrappedRoot: any; // pi's original root, when the panel is a fullscreen column
@@ -1236,6 +1524,7 @@ export default function (pi: ExtensionAPI) {
1236
1524
  // ponytail: reads TuiAltScreen's private layoutRoot field; if pi renames it, falls back to the overlay.
1237
1525
  const t = tuiRef as any;
1238
1526
  if (t.mode === "fullscreen" && t.layoutRoot && typeof t.setLayoutRoot === "function") {
1527
+ panel.fillHeight = () => process.stdout.rows ?? 0;
1239
1528
  wrappedRoot = t.layoutRoot;
1240
1529
  t.setLayoutRoot(
1241
1530
  new HStack(
@@ -1297,7 +1586,7 @@ export default function (pi: ExtensionAPI) {
1297
1586
  return buildFooter(tui, theme, footerData);
1298
1587
  });
1299
1588
  ctx.ui.setWorkingIndicator({ frames: pulseFrames(ctx.ui.theme), intervalMs: 110 });
1300
- ctx.ui.setTitle("MAGI");
1589
+ ctx.ui.setTitle(windowTitle(ctx.cwd, titleDone));
1301
1590
  };
1302
1591
 
1303
1592
  pi.registerEntryRenderer("magi-verdict", (entry: any, _options: any, theme: Theme) => {
@@ -1321,15 +1610,54 @@ export default function (pi: ExtensionAPI) {
1321
1610
  });
1322
1611
 
1323
1612
  let metricsTimer: ReturnType<typeof setInterval> | undefined;
1613
+ let unsubscribeInput: (() => void) | undefined;
1614
+ let lastPoll = 0;
1615
+ let titleTimer: ReturnType<typeof setInterval> | undefined;
1616
+ /** Tools currently executing, by call id: the golem shows one of them, and leaves when none is left. */
1617
+ const runningTools = new Map<string, { name: string; target: string }>();
1324
1618
 
1325
1619
  pi.on("session_start", async (_event, ctx) => {
1326
1620
  liveCtx = ctx;
1621
+ recountSession(ctx);
1622
+ state.hasSmartCompact = pi.getCommands().some((c) => c.name.replace(/^\//, "") === "smart-compact");
1623
+ const cfg = loadMagiConfig();
1624
+ ui.compact = cfg.ui?.compact ?? false;
1625
+ ui.kwhPrice = cfg.ui?.kwhPrice;
1626
+ ui.currency = cfg.ui?.currency === "USD" ? "USD" : "EUR";
1327
1627
  if (ctx.mode !== "tui") return;
1328
1628
  applyChrome(ctx);
1329
- // load the model into VRAM now (animated in the panel and footer) and keep GPU stats fresh
1629
+ // load the model into VRAM now (animated in the panel and footer)
1330
1630
  void preloadModel(ctx);
1331
- metricsTimer ??= setInterval(() => void refreshSwapMetrics(), 3000);
1631
+ // GPU stats every 3s while something happens, every 30s when idle
1632
+ metricsTimer ??= setInterval(() => {
1633
+ const now = Date.now();
1634
+ if (!animating(now) && now - lastPoll < IDLE_POLL_MS) return;
1635
+ lastPoll = now;
1636
+ void refreshSwapMetrics()
1637
+ .then(refreshSwapRunning)
1638
+ .then(repaint);
1639
+ }, BUSY_POLL_MS);
1332
1640
  metricsTimer.unref?.();
1641
+ // back at the keyboard: clear the ✓ from the title, wake a model llama-swap unloaded
1642
+ unsubscribeInput ??= ctx.ui.onTerminalInput(() => {
1643
+ if (titleDone) {
1644
+ titleDone = false;
1645
+ ctx.ui.setTitle(windowTitle(ctx.cwd));
1646
+ }
1647
+ if (swap.state === "asleep" && liveCtx) void preloadModel(liveCtx);
1648
+ return undefined;
1649
+ });
1650
+ // ponytail: re-applies the title every 2s because pi overwrites it without telling extensions
1651
+ titleTimer ??= setInterval(() => {
1652
+ if (chrome) ctx.ui.setTitle(windowTitle(ctx.cwd, titleDone));
1653
+ }, TITLE_REFRESH_MS);
1654
+ titleTimer.unref?.();
1655
+ });
1656
+
1657
+ pi.on("session_tree", async (_event, ctx) => {
1658
+ liveCtx = ctx;
1659
+ recountSession(ctx);
1660
+ repaint();
1333
1661
  });
1334
1662
 
1335
1663
  pi.on("model_select", async (event, ctx) => {
@@ -1340,6 +1668,10 @@ export default function (pi: ExtensionAPI) {
1340
1668
  pi.on("session_shutdown", async () => {
1341
1669
  clearInterval(metricsTimer);
1342
1670
  metricsTimer = undefined;
1671
+ unsubscribeInput?.();
1672
+ unsubscribeInput = undefined;
1673
+ clearInterval(titleTimer);
1674
+ titleTimer = undefined;
1343
1675
  hidePanel();
1344
1676
  });
1345
1677
 
@@ -1369,7 +1701,15 @@ export default function (pi: ExtensionAPI) {
1369
1701
  const t = e?.type;
1370
1702
  if (t === "thinking_start" || t === "thinking_delta") setPhase("thinking");
1371
1703
  else if (t === "text_start" || t === "text_delta") setPhase("responding");
1372
- else if (t === "toolcall_start" || t === "toolcall_delta") setPhase("tool");
1704
+ else if (t === "toolcall_start" || t === "toolcall_delta") {
1705
+ // the model is writing a tool call: the golem shows which tool it prepares, not the previous one
1706
+ setPhase("tool");
1707
+ const call = e.partial?.content?.[e.contentIndex];
1708
+ if (call?.type === "toolCall") {
1709
+ state.toolName = call.name ?? "";
1710
+ state.toolTarget = toolTarget(call.arguments);
1711
+ }
1712
+ }
1373
1713
  if (typeof e?.delta === "string" && e.delta) {
1374
1714
  if (!perf.first) perf.first = Date.now();
1375
1715
  perf.chars += e.delta.length;
@@ -1377,20 +1717,23 @@ export default function (pi: ExtensionAPI) {
1377
1717
  });
1378
1718
 
1379
1719
  pi.on("message_end", async (event) => {
1380
- if (event.message.role !== "assistant" || !perf.start) return;
1720
+ if (event.message.role !== "assistant") return;
1381
1721
  const m = event.message as AssistantMessage;
1382
- const end = Date.now();
1383
- perf.lastMs = end - perf.start;
1384
- if (perf.first) {
1385
- perf.ttft = perf.first - perf.start;
1386
- const genS = (end - perf.first) / 1000;
1387
- const out = m.usage?.output || perf.chars / 4;
1388
- if (genS > 0.05 && out) {
1389
- perf.tps = out / genS;
1390
- perf.peakTps = Math.max(perf.peakTps, perf.tps);
1722
+ addUsage(m);
1723
+ if (perf.start) {
1724
+ const end = Date.now();
1725
+ perf.lastMs = end - perf.start;
1726
+ if (perf.first) {
1727
+ perf.ttft = perf.first - perf.start;
1728
+ const genS = (end - perf.first) / 1000;
1729
+ const out = m.usage?.output || perf.chars / 4;
1730
+ if (genS > 0.05 && out) {
1731
+ perf.tps = out / genS;
1732
+ perf.peakTps = Math.max(perf.peakTps, perf.tps);
1733
+ }
1391
1734
  }
1735
+ perf.start = 0;
1392
1736
  }
1393
- perf.start = 0;
1394
1737
  repaint();
1395
1738
  // llama-swap records the request once it completes
1396
1739
  setTimeout(() => void refreshSwapActivity().then(repaint), 300);
@@ -1398,20 +1741,37 @@ export default function (pi: ExtensionAPI) {
1398
1741
 
1399
1742
  pi.on("tool_execution_start", async (event, ctx) => {
1400
1743
  liveCtx = ctx;
1744
+ const tool = { name: event.toolName ?? "", target: toolTarget(event.args) };
1745
+ runningTools.set(event.toolCallId, tool);
1401
1746
  setPhase("tool");
1402
- state.toolName = event.toolName ?? "";
1747
+ state.toolName = tool.name;
1748
+ state.toolTarget = tool.target;
1403
1749
  repaint();
1404
1750
  });
1405
1751
 
1406
1752
  // CHESED (mercy) counts what worked, GEBURAH (severity) what failed; a failure erases the golem's aleph.
1407
1753
  pi.on("tool_execution_end", async (event) => {
1754
+ const tool = runningTools.get(event.toolCallId);
1755
+ runningTools.delete(event.toolCallId);
1408
1756
  if (event.isError) {
1409
1757
  state.toolFail++;
1410
1758
  state.lastFailAt = Date.now();
1411
- state.lastFailTool = event.toolName ?? "tool";
1759
+ state.lastFailTool = event.toolName ?? tool?.name ?? "tool";
1760
+ state.lastFailTarget = tool?.target ?? "";
1412
1761
  } else {
1413
1762
  state.toolOk++;
1414
1763
  }
1764
+ const next = [...runningTools.values()][0];
1765
+ if (next) {
1766
+ // parallel tools: the golem moves on to one still running
1767
+ state.toolName = next.name;
1768
+ state.toolTarget = next.target;
1769
+ } else {
1770
+ // all tools done: the model now reads their results and thinks about the next step
1771
+ setPhase("thinking");
1772
+ state.toolName = "";
1773
+ state.toolTarget = "";
1774
+ }
1415
1775
  repaint();
1416
1776
  });
1417
1777
 
@@ -1420,19 +1780,26 @@ export default function (pi: ExtensionAPI) {
1420
1780
  if (state.runStart) state.lastRunMs = Date.now() - state.runStart;
1421
1781
  state.runStart = 0;
1422
1782
  setPhase("idle");
1783
+ runningTools.clear();
1423
1784
  state.toolName = "";
1424
- if (ctx.mode === "tui" && chrome) ctx.ui.setWorkingMessage();
1785
+ state.toolTarget = "";
1786
+ if (ctx.mode === "tui" && chrome) {
1787
+ ctx.ui.setWorkingMessage();
1788
+ // a long run just finished: mark the window title until you touch the keyboard
1789
+ if (state.lastRunMs > DONE_TITLE_AFTER_MS) {
1790
+ titleDone = true;
1791
+ ctx.ui.setTitle(windowTitle(ctx.cwd, true));
1792
+ }
1793
+ }
1425
1794
  repaint();
1426
1795
  });
1427
1796
 
1428
1797
  // The seven seals: compaction breaks them, and the context is reborn.
1429
1798
  // When pi-smart-compact is installed it owns the compaction; this theme only watches and names it.
1430
- const hasSmartCompact = () => pi.getCommands().some((c) => c.name.replace(/^\//, "") === "smart-compact");
1431
-
1432
1799
  pi.on("session_before_compact", async () => {
1433
1800
  state.compacting = true;
1434
1801
  state.compactSince = Date.now();
1435
- state.compactBy = hasSmartCompact() ? "smart-compact" : "pi native";
1802
+ state.compactBy = state.hasSmartCompact ? "smart-compact" : "pi native";
1436
1803
  repaint();
1437
1804
  });
1438
1805
 
@@ -1440,7 +1807,7 @@ export default function (pi: ExtensionAPI) {
1440
1807
  state.compacting = false;
1441
1808
  state.rebornAt = Date.now();
1442
1809
  // fromExtension: an extension supplied the summary; otherwise pi's own compactor did (e.g. smart-compact fell back)
1443
- state.compactBy = event.fromExtension ? (hasSmartCompact() ? "smart-compact" : "extension") : "pi native";
1810
+ state.compactBy = event.fromExtension ? (state.hasSmartCompact ? "smart-compact" : "extension") : "pi native";
1444
1811
  repaint();
1445
1812
  });
1446
1813
 
@@ -1449,54 +1816,82 @@ export default function (pi: ExtensionAPI) {
1449
1816
  repaint();
1450
1817
  });
1451
1818
 
1819
+ /** Runs the council on a question with some context, shows the deliberation, stores the verdict. */
1820
+ async function runCouncil(ctx: ExtensionContext, question: string, context: string, contextLabel?: string): Promise<void> {
1821
+ const cfg = loadMagiConfig();
1822
+ const project = (ctx.cwd ?? "").split("/").filter(Boolean).pop() ?? "";
1823
+ const prompt = councilPrompt(project, context, question, contextLabel);
1824
+
1825
+ const controller = new AbortController();
1826
+ const opinions: (MagiOpinion | undefined)[] = MAGI.map(() => undefined);
1827
+ let finished = false;
1828
+ const all = Promise.all(
1829
+ MAGI.map((_, i) =>
1830
+ askMagi(ctx, i, prompt, cfg, controller.signal).then((o) => {
1831
+ opinions[i] = o;
1832
+ return o;
1833
+ }),
1834
+ ),
1835
+ ).finally(() => {
1836
+ finished = true;
1837
+ });
1838
+
1839
+ if (ctx.mode === "tui") {
1840
+ const cancelled = await ctx.ui.custom<boolean>((tui, theme, _keys, done) =>
1841
+ buildDeliberationView(tui, theme, question, opinions, () => finished, done),
1842
+ );
1843
+ if (cancelled) {
1844
+ controller.abort();
1845
+ ctx.ui.notify("MAGI deliberation aborted", "warning");
1846
+ return;
1847
+ }
1848
+ }
1849
+
1850
+ const final = await all;
1851
+ const { verdict, tally } = tallyVerdict(final.map((o) => o.vote));
1852
+ pi.appendEntry("magi-verdict", { question, opinions: final, verdict, tally } satisfies Deliberation);
1853
+ state.lastCouncil = { verdict, tally, question };
1854
+ repaint();
1855
+ }
1856
+
1452
1857
  pi.registerCommand("magi", {
1453
- description: "Ask the three MAGI (pragmatist, guardian, visionary); /magi config assigns a model to each",
1858
+ description: "Ask the three MAGI (pragmatist, guardian, visionary); /magi review [focus] judges the git diff; /magi config assigns models",
1454
1859
  handler: async (args, ctx) => {
1455
1860
  const arg = args.trim();
1456
1861
  if (arg === "config") return configureMagi(ctx);
1457
1862
 
1458
- const question = arg || (await ctx.ui.input("Question for the MAGI:", "should we …?"))?.trim() || "";
1459
- if (!question) return;
1460
-
1461
- const cfg = loadMagiConfig();
1462
- const project = (ctx.cwd ?? "").split("/").filter(Boolean).pop() ?? "";
1463
- const prompt =
1464
- `Project: ${project}\n\nRecent conversation (context only, may be empty):\n<conversation>\n${conversationExcerpt(ctx)}\n</conversation>\n\n` +
1465
- `Question for the MAGI:\n${question}`;
1466
-
1467
- const controller = new AbortController();
1468
- const opinions: (MagiOpinion | undefined)[] = MAGI.map(() => undefined);
1469
- let finished = false;
1470
- const all = Promise.all(
1471
- MAGI.map((_, i) =>
1472
- askMagi(ctx, i, prompt, cfg, controller.signal).then((o) => {
1473
- opinions[i] = o;
1474
- return o;
1475
- }),
1476
- ),
1477
- ).finally(() => {
1478
- finished = true;
1479
- });
1480
-
1481
- if (ctx.mode === "tui") {
1482
- const cancelled = await ctx.ui.custom<boolean>((tui, theme, _keys, done) =>
1483
- buildDeliberationView(tui, theme, question, opinions, () => finished, done),
1484
- );
1485
- if (cancelled) {
1486
- controller.abort();
1487
- ctx.ui.notify("MAGI deliberation aborted", "warning");
1863
+ if (arg === "review" || arg.startsWith("review ")) {
1864
+ const focus = arg.slice("review".length).trim();
1865
+ let changes: { diff: string; untracked: string[] };
1866
+ try {
1867
+ changes = await pendingChanges(ctx.cwd);
1868
+ } catch {
1869
+ ctx.ui.notify("MAGI review needs a git repository", "error");
1870
+ return;
1871
+ }
1872
+ if (!changes.diff.trim() && !changes.untracked.length) {
1873
+ ctx.ui.notify("Nothing to review: no changes against HEAD", "info");
1488
1874
  return;
1489
1875
  }
1876
+ const diff =
1877
+ changes.diff.length > REVIEW_MAX_CHARS
1878
+ ? changes.diff.slice(0, REVIEW_MAX_CHARS) + `\n… diff truncated (${changes.diff.length} chars in total)`
1879
+ : changes.diff;
1880
+ const untracked = changes.untracked.length ? `\n\nUntracked files (content not shown):\n${changes.untracked.join("\n")}` : "";
1881
+ const question = focus
1882
+ ? `Review these pending changes before committing, focusing on: ${focus}`
1883
+ : "Review these pending changes before committing: are they ready to commit, and what must change first?";
1884
+ return runCouncil(ctx, question, "```diff\n" + diff + "\n```" + untracked, "Pending changes (git diff HEAD)");
1490
1885
  }
1491
1886
 
1492
- const final = await all;
1493
- const { verdict, tally } = tallyVerdict(final.map((o) => o.vote));
1494
- pi.appendEntry("magi-verdict", { question, opinions: final, verdict, tally } satisfies Deliberation);
1887
+ const question = arg || (await ctx.ui.input("Question for the MAGI:", "should we …?"))?.trim() || "";
1888
+ if (!question) return;
1889
+ return runCouncil(ctx, question, conversationExcerpt(ctx));
1495
1890
  },
1496
1891
  });
1497
1892
 
1498
1893
  pi.registerCommand("magi-ui", {
1499
- description: "MAGI chrome: enable the theme, or manage chrome and side panel (on|off|panel)",
1894
+ description: "MAGI chrome: enable the theme, or manage it (on|off|panel|compact|status|config)",
1500
1895
  handler: async (args, ctx) => {
1501
1896
  liveCtx = ctx;
1502
1897
  const arg = args.trim().toLowerCase();
@@ -1508,6 +1903,59 @@ export default function (pi: ExtensionAPI) {
1508
1903
  ctx.ui.notify(`Side panel ${panelEnabled ? "enabled" : "disabled"}`, "info");
1509
1904
  return;
1510
1905
  }
1906
+ if (arg === "compact") {
1907
+ ui.compact = !ui.compact;
1908
+ const cfg = loadMagiConfig();
1909
+ saveMagiConfig({ ...cfg, ui: { ...cfg.ui, compact: ui.compact } });
1910
+ repaint();
1911
+ ctx.ui.notify(`Side panel ${ui.compact ? "compact" : "detailed"}`, "info");
1912
+ return;
1913
+ }
1914
+ if (arg === "config") {
1915
+ const currency = await ctx.ui.select(`Currency for COST (current: ${ui.currency})`, ["EUR", "USD"]);
1916
+ if (!currency) return;
1917
+ const current = ui.kwhPrice !== undefined ? ` (current: ${ui.kwhPrice})` : "";
1918
+ const raw = (await ctx.ui.input(`Electricity price per kWh in ${currency}${current}:`, "0.30"))?.trim();
1919
+ if (raw === undefined) return;
1920
+ const price = raw === "" && ui.kwhPrice !== undefined ? ui.kwhPrice : Number(raw.replace(",", "."));
1921
+ if (raw === "" && ui.kwhPrice === undefined) {
1922
+ ctx.ui.notify("No price entered: COST unchanged", "warning");
1923
+ return;
1924
+ }
1925
+ if (!Number.isFinite(price) || price < 0) {
1926
+ ctx.ui.notify(`Invalid price: "${raw}"`, "error");
1927
+ return;
1928
+ }
1929
+ ui.currency = currency === "USD" ? "USD" : "EUR";
1930
+ ui.kwhPrice = price;
1931
+ const cfg = loadMagiConfig();
1932
+ saveMagiConfig({ ...cfg, ui: { ...cfg.ui, currency: ui.currency, kwhPrice: price } });
1933
+ repaint();
1934
+ ctx.ui.notify(`COST: ${fmtMoney(price)} per kWh`, "info");
1935
+ return;
1936
+ }
1937
+ if (arg === "status") {
1938
+ if (!swap.base) {
1939
+ ctx.ui.notify("/magi-ui status needs a llama-swap session model", "warning");
1940
+ return;
1941
+ }
1942
+ let report: { data?: ActivityRow[]; total?: number };
1943
+ try {
1944
+ report = (await (await swapGet(`/api/metrics/activity?limit=${ACTIVITY_REPORT_ROWS}`, 15_000)).json()) as typeof report;
1945
+ } catch (err) {
1946
+ ctx.ui.notify(`llama-swap status failed: ${err instanceof Error ? err.message : String(err)}`, "error");
1947
+ return;
1948
+ }
1949
+ const rows = report.data ?? [];
1950
+ if (!rows.length) {
1951
+ ctx.ui.notify("llama-swap has no recorded requests yet", "info");
1952
+ return;
1953
+ }
1954
+ await ctx.ui.custom<void>((_tui, theme, _keys, done) =>
1955
+ buildReportView(theme, activityReport(theme, rows, report.total ?? rows.length), () => done(undefined)),
1956
+ );
1957
+ return;
1958
+ }
1511
1959
  if (arg === "off" || arg === "on") {
1512
1960
  chrome = arg === "on";
1513
1961
  applyChrome(ctx);
@@ -1522,7 +1970,7 @@ export default function (pi: ExtensionAPI) {
1522
1970
  }
1523
1971
  chrome = true;
1524
1972
  applyChrome(ctx);
1525
- ctx.ui.notify("MAGI online — /magi-ui panel, /magi-ui off", "info");
1973
+ ctx.ui.notify("MAGI online — /magi-ui panel|compact|status|off", "info");
1526
1974
  },
1527
1975
  });
1528
1976
  }