pi-legion 0.16.0 → 2.0.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.
Files changed (89) hide show
  1. package/CONTRIBUTING.md +58 -0
  2. package/README.md +313 -640
  3. package/agents/advisor.md +7 -5
  4. package/agents/centurion.md +53 -0
  5. package/agents/designer.md +33 -33
  6. package/agents/planner.md +1 -2
  7. package/agents/prototype.md +2 -2
  8. package/agents/reviewer.md +67 -31
  9. package/agents/scout.md +6 -2
  10. package/agents/worker.md +12 -3
  11. package/docs/contracts/esc-abort-v1.md +131 -0
  12. package/docs/contracts/esc-cascade-v2.md +100 -0
  13. package/docs/design.md +44 -25
  14. package/docs/legion-v2-contract.md +461 -0
  15. package/docs/legion-v2-delivery.md +62 -0
  16. package/docs/retire-hardening-contract.md +125 -0
  17. package/docs/subagents-teams.md +76 -0
  18. package/docs/tui-viewer-contract.md +276 -0
  19. package/docs/vexillatio-contract.md +162 -0
  20. package/index.ts +1462 -286
  21. package/package.json +12 -9
  22. package/skills/pi-legion/SKILL.md +74 -96
  23. package/src/agents/agents.ts +6 -11
  24. package/src/agents/overrides.ts +2 -1
  25. package/src/agents/paths.ts +1 -1
  26. package/src/agents/settings.ts +268 -20
  27. package/src/api.ts +47 -48
  28. package/src/{herdr → backends/legacy}/client.ts +14 -69
  29. package/src/{shared → backends/legacy}/cursor-chat.ts +2 -2
  30. package/src/{herdr → backends/legacy}/runner.ts +22 -9
  31. package/src/backends/legacy/types.ts +138 -0
  32. package/src/extension/auto-doctor.ts +144 -0
  33. package/src/extension/budget.ts +2 -2
  34. package/src/extension/child-guard.ts +214 -201
  35. package/src/extension/event-bus.ts +263 -0
  36. package/src/extension/legion-commands.ts +719 -0
  37. package/src/extension/legion-doctor.ts +888 -0
  38. package/src/extension/legion-stop.ts +150 -0
  39. package/src/extension/legion-tools.ts +816 -0
  40. package/src/extension/notify.ts +24 -19
  41. package/src/extension/playbook.ts +20 -89
  42. package/src/extension/recycle.ts +2 -7
  43. package/src/extension/runtime.ts +655 -105
  44. package/src/extension/scout-guard.ts +483 -0
  45. package/src/extension/slash.ts +89 -438
  46. package/src/extension/spawn-hints.ts +79 -0
  47. package/src/extension/summary.ts +17 -58
  48. package/src/legion/budget.ts +165 -0
  49. package/src/legion/db.ts +242 -0
  50. package/src/legion/events.ts +150 -0
  51. package/src/legion/mail.ts +412 -0
  52. package/src/legion/nodes.ts +461 -0
  53. package/src/legion/rebuild.ts +533 -0
  54. package/src/legion/state-machine.ts +313 -0
  55. package/src/legion/usage.ts +101 -0
  56. package/src/runs/acceptance.ts +82 -25
  57. package/src/runs/args.ts +51 -5
  58. package/src/runs/hygiene.ts +335 -0
  59. package/src/runs/orchestrator.ts +790 -1683
  60. package/src/runs/owner.ts +61 -0
  61. package/src/runs/provision.ts +135 -0
  62. package/src/runs/store.ts +44 -17
  63. package/src/runs/verify-queue.ts +195 -0
  64. package/src/shared/name.ts +29 -0
  65. package/src/shared/output-cap.ts +57 -0
  66. package/src/shared/progress.ts +57 -135
  67. package/src/shared/session.ts +243 -171
  68. package/src/shared/types.ts +107 -208
  69. package/src/supervisor/child-memory.ts +140 -0
  70. package/src/supervisor/proc-tree.ts +103 -0
  71. package/src/supervisor/rpc-supervisor.ts +1033 -0
  72. package/src/supervisor/types.ts +153 -0
  73. package/src/supervisor/ui-proxy.ts +226 -0
  74. package/src/tui/chat-items.ts +330 -0
  75. package/src/tui/live-overlay.ts +874 -0
  76. package/src/tui/status.ts +553 -27
  77. package/src/tui/tree-view.ts +808 -0
  78. package/src/tui/viz/canvas.ts +119 -0
  79. package/src/tui/viz/layout.ts +111 -0
  80. package/src/tui/viz/model.ts +123 -0
  81. package/src/tui/viz/palette.ts +188 -0
  82. package/src/tui/viz/panels.ts +530 -0
  83. package/src/tui/viz/railway.ts +313 -0
  84. package/src/tui/viz/rate.ts +211 -0
  85. package/src/tui/viz/view.ts +228 -0
  86. package/src/extension/parent-label.ts +0 -115
  87. package/src/profiles/classify.ts +0 -375
  88. package/src/profiles/profiles.ts +0 -730
  89. package/src/runs/layout.ts +0 -258
@@ -0,0 +1,119 @@
1
+ /** Tiny cell grid: every cell is one terminal column wide, so widths are exact. */
2
+
3
+ import { ROLE_COLOR_PREFIX, resolveRolePalette, type VizTheme } from "./palette.ts";
4
+
5
+ export interface Cell {
6
+ ch: string;
7
+ color?: string;
8
+ bold?: boolean;
9
+ inverse?: boolean;
10
+ }
11
+
12
+ export class Canvas {
13
+ readonly width: number;
14
+ readonly height: number;
15
+ readonly cells: Cell[][];
16
+
17
+ constructor(width: number, height: number) {
18
+ this.width = Math.max(0, Math.floor(width));
19
+ this.height = Math.max(0, Math.floor(height));
20
+ this.cells = Array.from({ length: this.height }, () =>
21
+ Array.from({ length: this.width }, () => ({ ch: " " }) as Cell),
22
+ );
23
+ }
24
+
25
+ get(x: number, y: number): Cell | undefined {
26
+ return this.cells[y]?.[x];
27
+ }
28
+
29
+ set(x: number, y: number, ch: string, color?: string, style: { bold?: boolean; inverse?: boolean } = {}): void {
30
+ const row = this.cells[y];
31
+ if (!row || x < 0 || x >= this.width) return;
32
+ row[x] = { ch: sanitizeChar(ch), color, ...style };
33
+ }
34
+
35
+ /** Write text clipped to [x, maxX). Returns the column after the last written cell. */
36
+ text(x: number, y: number, value: string, color?: string, style: { bold?: boolean; inverse?: boolean; maxX?: number } = {}): number {
37
+ const limit = Math.min(this.width, style.maxX ?? this.width);
38
+ let col = x;
39
+ for (const ch of Array.from(value)) {
40
+ if (col >= limit) break;
41
+ if (col >= 0) this.set(col, y, ch, color, style);
42
+ col++;
43
+ }
44
+ return col;
45
+ }
46
+
47
+ /** Agent types referenced by deferred `role:` colors in this canvas. */
48
+ roles(): Set<string> {
49
+ const roles = new Set<string>();
50
+ for (const row of this.cells) for (const cell of row) {
51
+ if (cell.color?.startsWith(ROLE_COLOR_PREFIX)) roles.add(cell.color.slice(ROLE_COLOR_PREFIX.length));
52
+ }
53
+ return roles;
54
+ }
55
+
56
+ toLines(theme: VizTheme, palette = resolveRolePalette(theme, this.roles())): string[] {
57
+ const resolve = (color: string | undefined): string | undefined =>
58
+ color?.startsWith(ROLE_COLOR_PREFIX) ? palette.get(color.slice(ROLE_COLOR_PREFIX.length)) ?? "text" : color;
59
+ return this.cells.map((row) => renderRow(row.map((cell) => cell.color?.startsWith(ROLE_COLOR_PREFIX) ? { ...cell, color: resolve(cell.color) } : cell), theme));
60
+ }
61
+
62
+ toPlain(): string[] {
63
+ return this.cells.map((row) => row.map((cell) => cell.ch).join(""));
64
+ }
65
+ }
66
+
67
+ /** Keep cells single-width: wide/control characters become a placeholder. */
68
+ export function sanitizeChar(ch: string): string {
69
+ if (ch.length === 0) return " ";
70
+ const code = ch.codePointAt(0)!;
71
+ if (code < 0x20 || (code >= 0x7f && code < 0xa0)) return " ";
72
+ if (isWide(code)) return "?";
73
+ return String.fromCodePoint(code);
74
+ }
75
+
76
+ function isWide(code: number): boolean {
77
+ return (
78
+ (code >= 0x1100 && code <= 0x115f) ||
79
+ (code >= 0x2e80 && code <= 0xa4cf) ||
80
+ (code >= 0xac00 && code <= 0xd7a3) ||
81
+ (code >= 0xf900 && code <= 0xfaff) ||
82
+ (code >= 0xfe30 && code <= 0xfe4f) ||
83
+ (code >= 0xff00 && code <= 0xff60) ||
84
+ (code >= 0xffe0 && code <= 0xffe6) ||
85
+ (code >= 0x1f300 && code <= 0x1faff) ||
86
+ (code >= 0x20000 && code <= 0x3fffd)
87
+ );
88
+ }
89
+
90
+ /** Truncate plain text to `width` single-width cells with an ellipsis. */
91
+ export function ellipsize(value: string, width: number): string {
92
+ const chars = Array.from(value).map(sanitizeChar);
93
+ if (width <= 0) return "";
94
+ if (chars.length <= width) return chars.join("");
95
+ return `${chars.slice(0, Math.max(0, width - 1)).join("")}…`;
96
+ }
97
+
98
+ function renderRow(row: Cell[], theme: VizTheme): string {
99
+ let out = "";
100
+ let i = 0;
101
+ while (i < row.length) {
102
+ const start = row[i]!;
103
+ let text = "";
104
+ while (
105
+ i < row.length &&
106
+ row[i]!.color === start.color &&
107
+ !!row[i]!.bold === !!start.bold &&
108
+ !!row[i]!.inverse === !!start.inverse
109
+ ) {
110
+ text += row[i]!.ch;
111
+ i++;
112
+ }
113
+ let styled = start.color && text.trim().length > 0 ? theme.fg(start.color, text) : text;
114
+ if (start.bold && theme.bold) styled = theme.bold(styled);
115
+ if (start.inverse && theme.inverse) styled = theme.inverse(styled);
116
+ out += styled;
117
+ }
118
+ return out;
119
+ }
@@ -0,0 +1,111 @@
1
+ /** Layout selection and panel rectangles for the /legion full view. */
2
+
3
+ export type LayoutKind = "wide" | "stacked" | "portrait";
4
+
5
+ export interface Rect {
6
+ x: number;
7
+ y: number;
8
+ w: number;
9
+ h: number;
10
+ }
11
+
12
+ export interface LegionLayout {
13
+ kind: LayoutKind;
14
+ header: Rect;
15
+ galaxy: Rect;
16
+ chart: Rect;
17
+ log: Rect;
18
+ footer: Rect;
19
+ }
20
+
21
+ export const WIDE_MIN_COLS = 140;
22
+ export const PORTRAIT_MAX_COLS = 80;
23
+ /** Kept for compatibility; tallness is now decided by aspect ratio alone. */
24
+ export const TALL_PORTRAIT_MAX_COLS = 100;
25
+ /** Chart rows in portrait: enough to read, never a tall skinny bar. */
26
+ export const PORTRAIT_CHART_MIN = 10;
27
+ export const PORTRAIT_CHART_MAX = 18;
28
+ /** Side-by-side layouts never stretch panels beyond this many rows. */
29
+ export const STACKED_TOP_MAX = 24;
30
+ export const WIDE_CHART_MAX = 24;
31
+
32
+ /** Terminal cells are ~2:1 (tall), so rows*2 >= cols means the window looks taller than wide. */
33
+ export function isTall(cols: number, rows: number): boolean {
34
+ return rows * 2 >= cols;
35
+ }
36
+
37
+ export function selectLayout(cols: number, rows: number): LayoutKind {
38
+ if (cols < PORTRAIT_MAX_COLS) return "portrait";
39
+ if (isTall(cols, rows)) return "portrait";
40
+ if (cols >= WIDE_MIN_COLS) return "wide";
41
+ return "stacked";
42
+ }
43
+
44
+ export interface LayoutHints {
45
+ /** Number of log events, so a short history does not get a huge empty box. */
46
+ logEvents?: number;
47
+ }
48
+
49
+ export function computeLayout(cols: number, rows: number, hints: LayoutHints = {}): LegionLayout {
50
+ const W = Math.max(1, Math.floor(cols));
51
+ const H = Math.max(6, Math.floor(rows));
52
+ const kind = selectLayout(W, H);
53
+ const headerH = kind === "portrait" ? 2 : 1;
54
+ const header = { x: 0, y: 0, w: W, h: headerH };
55
+ const footer = { x: 0, y: H - 1, w: W, h: 1 };
56
+ const bodyY = headerH;
57
+ const bodyH = H - headerH - 1;
58
+ if (kind === "wide") {
59
+ const leftW = Math.floor(W * 0.6);
60
+ // Chart is capped; on very tall wide terminals the galaxy absorbs the rest.
61
+ const chartH = Math.min(WIDE_CHART_MAX, Math.max(3, Math.round(bodyH * 0.45)));
62
+ const galaxyH = bodyH - chartH;
63
+ return {
64
+ kind,
65
+ header,
66
+ footer,
67
+ galaxy: { x: 0, y: bodyY, w: leftW, h: galaxyH },
68
+ chart: { x: 0, y: bodyY + galaxyH, w: leftW, h: chartH },
69
+ log: { x: leftW, y: bodyY, w: W - leftW, h: bodyH },
70
+ };
71
+ }
72
+ if (kind === "stacked") {
73
+ const topH = Math.min(STACKED_TOP_MAX, Math.max(3, Math.round(bodyH * 0.58)));
74
+ const galaxyW = Math.floor(W * 0.5);
75
+ return {
76
+ kind,
77
+ header,
78
+ footer,
79
+ galaxy: { x: 0, y: bodyY, w: galaxyW, h: topH },
80
+ chart: { x: galaxyW, y: bodyY, w: W - galaxyW, h: topH },
81
+ log: { x: 0, y: bodyY + topH, w: W, h: bodyH - topH },
82
+ };
83
+ }
84
+ // Portrait: galaxy (aspect-limited), chart (fixed band), log gets the rest.
85
+ const chartH = Math.min(PORTRAIT_CHART_MAX, Math.max(PORTRAIT_CHART_MIN, Math.round(bodyH * 0.3)), Math.max(3, bodyH - 9));
86
+ // A visually round galaxy is ~W/2 rows tall (cells are 2:1); allow it up to ~W*0.6.
87
+ const galaxyCap = Math.max(8, Math.round(W * 0.5));
88
+ const galaxyGrowCap = Math.max(galaxyCap, Math.round(W * 0.65));
89
+ let galaxyH = Math.max(3, Math.min(galaxyCap, Math.round(bodyH * 0.4), bodyH - chartH - 6));
90
+ let logH = bodyH - galaxyH - chartH;
91
+ // Few events: give some of the empty log area back to the galaxy.
92
+ if (hints.logEvents !== undefined) {
93
+ const logNeeded = Math.max(8, hints.logEvents + 4);
94
+ const spare = logH - logNeeded;
95
+ if (spare > 0) {
96
+ const grow = Math.min(spare, galaxyGrowCap - galaxyH);
97
+ if (grow > 0) {
98
+ galaxyH += grow;
99
+ logH -= grow;
100
+ }
101
+ }
102
+ }
103
+ return {
104
+ kind,
105
+ header,
106
+ footer,
107
+ galaxy: { x: 0, y: bodyY, w: W, h: galaxyH },
108
+ chart: { x: 0, y: bodyY + galaxyH, w: W, h: chartH },
109
+ log: { x: 0, y: bodyY + galaxyH + chartH, w: W, h: Math.max(0, logH) },
110
+ };
111
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Shared data model for the Legion visualization (railway strip + /legion view).
3
+ *
4
+ * Subagents spawn dynamically and nest, so nothing here assumes a fixed role
5
+ * list. Node statuses collapse to four display states: `blocked` is a
6
+ * transient in-memory approval flag and renders as running.
7
+ */
8
+
9
+ import type { StatusEntry } from "../status.ts";
10
+
11
+ export type VizStatus = "running" | "settled" | "failed" | "retired";
12
+
13
+ export interface VizNode {
14
+ id: string;
15
+ name: string;
16
+ role: string;
17
+ parentId: string | null;
18
+ depth: number;
19
+ status: VizStatus;
20
+ startedAt: number;
21
+ /** Set once the node reaches a terminal display state. */
22
+ endedAt?: number;
23
+ /** Cumulative tokens reported for the node (input+cache+output; display totals). */
24
+ tokens: number;
25
+ /** Cumulative output tokens only; sampled for tok/s. */
26
+ tokensOut: number;
27
+ /**
28
+ * Cumulative model-generation wall ms (assistant − prior user/toolResult).
29
+ * Denominator for tok/s when > 0.
30
+ */
31
+ generationMs: number;
32
+ /** Unix ms start of the current turn, when the status entry recorded one. */
33
+ currentTurnStartedAt?: number;
34
+ /** Unix ms the current turn settled. Absent while it is still in flight. */
35
+ currentTurnEndedAt?: number;
36
+ }
37
+
38
+ /** Same set the footer/busy label treat as active, so live counts agree everywhere. */
39
+ const RUNNING = new Set(["starting", "working", "running", "blocked"]);
40
+ const SETTLED = new Set(["settled", "success", "completed", "done"]);
41
+ const FAILED = new Set(["failed", "aborted", "error", "truncated"]);
42
+
43
+ export function displayStatus(state: string | undefined): VizStatus {
44
+ const value = (state ?? "").toLowerCase();
45
+ if (SETTLED.has(value)) return "settled";
46
+ if (FAILED.has(value)) return "failed";
47
+ if (RUNNING.has(value)) return "running";
48
+ return "retired";
49
+ }
50
+
51
+ /** Build viz nodes from status entries (root row included when present). */
52
+ export function nodesFromEntries(entries: readonly StatusEntry[]): VizNode[] {
53
+ const out: VizNode[] = [];
54
+ const seen = new Set<string>();
55
+ for (const entry of entries) {
56
+ const id = entry.nodeId ?? entry.name;
57
+ if (seen.has(id)) continue;
58
+ seen.add(id);
59
+ const status = id === "root" ? "running" : displayStatus(entry.herdrStatus ?? entry.state);
60
+ out.push({
61
+ id,
62
+ name: entry.name,
63
+ role: entry.agent || "agent",
64
+ parentId: entry.parentId ?? (id === "root" ? null : entry.nodeId ? "root" : null),
65
+ depth: entry.depth ?? (id === "root" ? 0 : 1),
66
+ status,
67
+ startedAt: entry.startedAt,
68
+ ...(status !== "running" ? { endedAt: entry.finishedAt ?? entry.startedAt } : {}),
69
+ tokens: Math.max(0, entry.tokensTotal ?? 0),
70
+ tokensOut: Math.max(0, entry.tokensOut ?? 0),
71
+ generationMs: Math.max(0, entry.generationMs ?? 0),
72
+ ...(entry.currentTurnStartedAt !== undefined ? { currentTurnStartedAt: entry.currentTurnStartedAt } : {}),
73
+ ...(entry.currentTurnEndedAt !== undefined ? { currentTurnEndedAt: entry.currentTurnEndedAt } : {}),
74
+ });
75
+ }
76
+ // Parents that are missing from the snapshot re-attach to the nearest known ancestor.
77
+ const ids = new Set(out.map((node) => node.id));
78
+ for (const node of out) {
79
+ if (node.parentId !== null && !ids.has(node.parentId)) node.parentId = ids.has("root") && node.id !== "root" ? "root" : null;
80
+ }
81
+ disambiguateNames(out);
82
+ return out;
83
+ }
84
+
85
+ /**
86
+ * Child supervisors number their own children, so a nested "scout-0" can share
87
+ * a name with a root-level "scout-0". Prefix duplicates with their parent name
88
+ * (the shallowest keeps the bare name) so strip/log/galaxy labels stay unique.
89
+ */
90
+ export function disambiguateNames(nodes: VizNode[]): void {
91
+ const byName = new Map<string, VizNode[]>();
92
+ for (const node of nodes) byName.set(node.name, [...(byName.get(node.name) ?? []), node]);
93
+ const byId = new Map(nodes.map((node) => [node.id, node]));
94
+ for (const group of byName.values()) {
95
+ if (group.length < 2) continue;
96
+ const sorted = [...group].sort((a, b) => a.depth - b.depth || a.startedAt - b.startedAt);
97
+ for (const node of sorted.slice(1)) {
98
+ const parent = node.parentId ? byId.get(node.parentId) : undefined;
99
+ if (parent && parent.id !== "root") node.name = `${parent.name}/${node.name}`;
100
+ }
101
+ }
102
+ }
103
+
104
+ export function isAgent(node: VizNode): boolean {
105
+ return node.id !== "root" && node.parentId !== null;
106
+ }
107
+
108
+ export function formatCount(value: number): string {
109
+ if (!Number.isFinite(value) || value <= 0) return "0";
110
+ if (value < 1000) return String(Math.round(value));
111
+ if (value < 1_000_000) return `${(value / 1000).toFixed(1).replace(/\.0$/, "")}k`;
112
+ return `${(value / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
113
+ }
114
+
115
+ export function formatDuration(ms: number): string {
116
+ const total = Math.max(0, Math.floor(ms / 1000));
117
+ const h = Math.floor(total / 3600);
118
+ const m = Math.floor((total % 3600) / 60);
119
+ const s = total % 60;
120
+ if (h > 0) return `${h}h${String(m).padStart(2, "0")}m`;
121
+ if (m > 0) return `${m}m${String(s).padStart(2, "0")}s`;
122
+ return `${s}s`;
123
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Role → pi theme token mapping. Colors are always theme tokens (never hex),
3
+ * so the visualization follows `/theme` changes automatically.
4
+ */
5
+
6
+ import type { VizNode, VizStatus } from "./model.ts";
7
+
8
+ export interface VizTheme {
9
+ fg(color: string, text: string): string;
10
+ bg?(color: string, text: string): string;
11
+ bold?(text: string): string;
12
+ inverse?(text: string): string;
13
+ getFgAnsi?(color: string): string;
14
+ }
15
+
16
+ export const ROLE_TOKENS: Readonly<Record<string, string>> = {
17
+ centurion: "accent",
18
+ planner: "syntaxKeyword",
19
+ worker: "syntaxVariable",
20
+ scout: "syntaxNumber",
21
+ reviewer: "syntaxFunction",
22
+ advisor: "syntaxString",
23
+ };
24
+
25
+ /** Fallback pool for roles that are not in the table (deterministic hash pick). */
26
+ export const FALLBACK_TOKENS = [
27
+ "syntaxType",
28
+ "syntaxString",
29
+ "syntaxFunction",
30
+ "syntaxNumber",
31
+ "syntaxKeyword",
32
+ "syntaxVariable",
33
+ "syntaxOperator",
34
+ ] as const;
35
+
36
+ function hash(value: string): number {
37
+ let h = 2166136261;
38
+ for (let i = 0; i < value.length; i++) {
39
+ h ^= value.charCodeAt(i);
40
+ h = Math.imul(h, 16777619);
41
+ }
42
+ return h >>> 0;
43
+ }
44
+
45
+ export function roleToken(role: string): string {
46
+ const key = role.toLowerCase();
47
+ return ROLE_TOKENS[key] ?? FALLBACK_TOKENS[hash(key) % FALLBACK_TOKENS.length]!;
48
+ }
49
+
50
+ /** Prefix marking a cell color as "the color of this agent type", resolved per theme at paint time. */
51
+ export const ROLE_COLOR_PREFIX = "role:";
52
+
53
+ /** Deferred per-type color; resolved by `resolveRolePalette` when a canvas is painted. */
54
+ export function roleColor(role: string): string {
55
+ // The ledger root is the user's own pi session, not an agent type: keep it
56
+ // neutral so it never takes a type's color (centurion owns accent).
57
+ if (role.toLowerCase() === "root") return "text";
58
+ return `${ROLE_COLOR_PREFIX}${role.toLowerCase()}`;
59
+ }
60
+
61
+ /**
62
+ * Extra theme tokens tried, in order, when a type's preferred token renders
63
+ * with the same ANSI color as a type already assigned. pi's `system` theme in
64
+ * its palette-index tier (terminal reported no palette) maps accent AND
65
+ * syntaxNumber to ANSI slot 5, so centurion and scout were both magenta.
66
+ */
67
+ export const DISTINCT_FALLBACK_TOKENS = [
68
+ "syntaxKeyword",
69
+ "syntaxVariable",
70
+ "syntaxNumber",
71
+ "syntaxFunction",
72
+ "syntaxString",
73
+ "syntaxType",
74
+ "success",
75
+ "warning",
76
+ "accent",
77
+ "mdLink",
78
+ "mdHeading",
79
+ "mdCode",
80
+ "thinkingHigh",
81
+ "thinkingMedium",
82
+ "toolDiffAdded",
83
+ "bashMode",
84
+ ] as const;
85
+
86
+ /** ANSI prefix a token renders with. */
87
+ export function tokenAnsi(theme: VizTheme, token: string): string {
88
+ try {
89
+ if (typeof theme.getFgAnsi === "function") return theme.getFgAnsi(token);
90
+ return theme.fg(token, "\u0000").split("\u0000")[0] ?? token;
91
+ } catch {
92
+ return `?${token}`;
93
+ }
94
+ }
95
+
96
+ type Swatch = { kind: "rgb"; l: number; a: number; b: number } | { kind: "key"; key: string };
97
+
98
+ function xterm256(n: number): [number, number, number] {
99
+ if (n >= 232) {
100
+ const v = 8 + 10 * (n - 232);
101
+ return [v, v, v];
102
+ }
103
+ const i = n - 16;
104
+ const step = [0, 95, 135, 175, 215, 255];
105
+ return [step[Math.floor(i / 36)]!, step[Math.floor(i / 6) % 6]!, step[i % 6]!];
106
+ }
107
+
108
+ function oklab(r: number, g: number, b: number): Swatch {
109
+ const lin = (c: number) => {
110
+ const v = c / 255;
111
+ return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
112
+ };
113
+ const [R, G, B] = [lin(r), lin(g), lin(b)];
114
+ const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B);
115
+ const m = Math.cbrt(0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B);
116
+ const s = Math.cbrt(0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B);
117
+ return {
118
+ kind: "rgb",
119
+ l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
120
+ a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
121
+ b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * What a token actually looks like: an OKLab color for truecolor / 256-color
127
+ * output, or an opaque key for the 16 terminal-defined palette slots (whose
128
+ * real color is unknown, so only the same slot counts as equal).
129
+ */
130
+ export function tokenSwatch(theme: VizTheme, token: string): Swatch {
131
+ const ansi = tokenAnsi(theme, token);
132
+ const rgb = /38;2;(\d+);(\d+);(\d+)/.exec(ansi);
133
+ if (rgb) return oklab(Number(rgb[1]), Number(rgb[2]), Number(rgb[3]));
134
+ const idx = /38;5;(\d+)/.exec(ansi);
135
+ if (idx && Number(idx[1]) >= 16) return oklab(...xterm256(Number(idx[1])));
136
+ const basic = /(?:^|\[|;)(3[0-7]|9[0-7])m/.exec(ansi);
137
+ return { kind: "key", key: idx ? `p${idx[1]}` : basic ? `b${basic[1]}` : ansi };
138
+ }
139
+
140
+ /** OKLab distance below which two colors read as the same (~JND); pi's own dark/light role tokens are >= 0.04 apart, the system-theme accent/syntaxNumber clash is ~0.002. */
141
+ export const MIN_TYPE_COLOR_DISTANCE = 0.025;
142
+
143
+ export function swatchesClash(x: Swatch, y: Swatch): boolean {
144
+ if (x.kind === "key" || y.kind === "key") return x.kind === y.kind && (x as { key: string }).key === (y as { key: string }).key;
145
+ return Math.hypot(x.l - y.l, x.a - y.a, x.b - y.b) < MIN_TYPE_COLOR_DISTANCE;
146
+ }
147
+
148
+ const ROLE_ORDER = Object.keys(ROLE_TOKENS);
149
+
150
+ /**
151
+ * Map each agent type present to a theme token so that, where the theme can
152
+ * tell them apart, no two types share an on-screen color (nor the failed /
153
+ * retired colors). Known types are assigned first in table order so their
154
+ * preferred tokens win; the rest follow alphabetically (deterministic).
155
+ */
156
+ export function resolveRolePalette(theme: VizTheme, roles: Iterable<string>): Map<string, string> {
157
+ const unique = [...new Set([...roles].map((role) => role.toLowerCase()))].sort((a, b) => {
158
+ const ia = ROLE_ORDER.indexOf(a);
159
+ const ib = ROLE_ORDER.indexOf(b);
160
+ return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b);
161
+ });
162
+ // Compare what the colors LOOK like, not their escape bytes: pi's system
163
+ // theme derives accent and syntaxNumber from the same palette slot, giving
164
+ // e.g. #b38eb9 vs #b38fb8 in truecolor — different bytes, same pink.
165
+ const used: Swatch[] = [tokenSwatch(theme, "error"), tokenSwatch(theme, "dim")];
166
+ const out = new Map<string, string>();
167
+ for (const role of unique) {
168
+ const preferred = roleToken(role);
169
+ const candidates = [preferred, ...FALLBACK_TOKENS.slice(hash(role) % FALLBACK_TOKENS.length), ...DISTINCT_FALLBACK_TOKENS];
170
+ const pick = candidates.find((token) => {
171
+ const swatch = tokenSwatch(theme, token);
172
+ return !used.some((other) => swatchesClash(swatch, other));
173
+ }) ?? preferred;
174
+ used.push(tokenSwatch(theme, pick));
175
+ out.set(role, pick);
176
+ }
177
+ return out;
178
+ }
179
+
180
+ export function statusToken(role: string, status: VizStatus): string {
181
+ if (status === "failed") return "error";
182
+ if (status === "retired") return "dim";
183
+ return roleColor(role);
184
+ }
185
+
186
+ export function nodeToken(node: Pick<VizNode, "role" | "status">): string {
187
+ return statusToken(node.role, node.status);
188
+ }