SLOPSHOPPER

mission-control

A graphical pane of the harness at work: every agent of the turn as a swimlane coloured by phase, bar height its effort, with advisor calls and failed tool…

newpanecommandtoast
v0.5.0no licenseupdated 2026-10-15cdeust/claude-mods/mods/mission-control
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mission-control
│ ┃ Mission ✕ › fix the failing auth test and add an audit log call │ ┃ MISSION CONTROL turn - - idle, last 10 min │ ┃ ⏺ Read(src/auth.ts) │ ┃ idle: no agent in the last 10 min ⎿ Read 6 lines │ ┃ ▣ 0 lanes; the terminal draws no pixels here ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ EFFORT ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ FAILED CALLS PER STEP, BY PHASE │ ┃ no phase has two points yet: the ledger ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ fills as turns complete │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ STEPS BY MODEL (this window, then the last │ ┃ sessions) › /mission │ ┃ ⎿ mission-control: Mission control opened; drawn in pixels (Ghostt │ ┃ window no step yet │ ┃ │ ┃ DELEGATIONS (judged against their DESIGN │ ┃ block) │ ┃ no delegation judged yet │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mission
MISSION CONTROL turn - - idle, last 10 min idle: no agent in the last 10 min ▣ 0 lanes; the terminal draws no pixels here, set renderer t EFFORT FAILED CALLS PER STEP, BY PHASE no phase has two points yet: the ledger fills as turns complete STEPS BY MODEL (this window, then the last sessions) window no step yet DELEGATIONS (judged against their DESIGN block) no delegation judged yet
README

claude-mods

Claude Code mods and a separate Codex adapter for the ai-architect.tools harness. The Claude mods keep one concern per mod, state shared through dependencies:

ModOwnsDepends on
cortex-guardrefusals: wiki pages only through wiki_write, worktrees inside <repo>/.claude/worktrees/nothing
cortex-wiki/wiki <wiki/**.md> to PDFnothing
zetetic-geniusstate: the request grade (task class → effort), the genius patterns and skills it matchesnothing
zetetic-autopilotcontext, policy, phases: effort ladder, model routing (a subagent's own definition first), pressure, offloaded tool results; the phase each loop is in and the advisor callszetetic-genius
zetetic-insightsinsights: the turn ledger across sessions ($.store), joined with /insights session outcomes and .zetetic/goals; per-phase effort proposals, `/calibrate apply\reset`, the calibration file the autopilot readszetetic-autopilot, zetetic-genius
cortex-cockpitstats, ledger, tally, stages, hygiene; the /cortex pane draws the rest (PHASES, INSIGHTS, POLICY...)the four above
mission-controlrenderer; the /mission pane: the turn's lanes by phase (bar height = effort, advisor calls, failed calls), effort per phase, learning curves per phase. Pixels in kitty or Ghostty outside tmux, coloured cells elsewherezetetic-autopilot, zetetic-insights
harness-fleetfleet: the owner's plugins, installed vs offered version, open PRs with CI, open issues (a defect is taken and fixed, a feature request goes to the owner); /fleet. Reads only (gh pr list, gh issue list, git remote): every outward action is a button the owner presses, which puts a prompt in front of the modelguard, genius, autopilot
superpowersthird-party skills library (obra/superpowers, MIT), pinned to v7.0.0 bb92a77: listed here so it installs with the mods; which skills we keep is decided after measurementnothing

Developing

Each mod is validated, tested and type-checked on its own:

cd mods/<mod> && claude plugin validate . && claude plugin test && npx -y -p typescript tsc -p . --noEmit

tsc needs the engine to have loaded the mod once (it lays .claude-plugin/types/); keep --noEmit, since a plain tsc -p . writes .js files beside the sources. For hot reload in a session, link the mod into that session's ~/.claude/dev-mods/<session>/ folder; the engine watches the folder a link names.

A mod cannot import a file from another mod (the engine refuses it: "outside the plugin's folder"), so the few small pure files several mods need are copied into each of them: hooks/paths.ts (where ~/ lands: HOME, else USERPROFILE, CLAUDE_CONFIG_DIR for ~/.claude) in five mods, and hooks/rules.ts (the worktree roots, the python names) in cortex-guard and cortex-cockpit, each with its test. After editing one, copy it over the others; this fails when a copy differs:

sh scripts/check-shared.sh

Installing

.claude-plugin/marketplace.json lists each mod as "source": "./mods/<mod>", so the repository is the marketplace. From another machine:

/plugin install <mod> --marketplace cdeust/claude-mods

y adds the marketplace, then the user scope. On this machine a folder marketplace reads the mods from the checkout, with no copy: claude plugin marketplace add <this folder>, then /plugin install <mod> and /reload-plugins after an edit.

Without write access to settings.json

claude plugin marketplace add and claude plugin install both write enabledPlugins and extraKnownMarketplaces into settings.json. Where that file cannot be changed, load the mods by directory instead: nothing is written, and each mod is registered for the session as <name>@inline.

MODS=/path/to/claude-mods/mods

# repeat --plugin-dir once per mod
claude \
  --plugin-dir $MODS/cortex-guard \
  --plugin-dir $MODS/cortex-wiki \
  --plugin-dir $MODS/zetetic-genius \
  --plugin-dir $MODS/zetetic-autopilot \
  --plugin-dir $MODS/zetetic-insights \
  --plugin-dir $MODS/cortex-cockpit \
  --plugin-dir $MODS/mission-control \
  --plugin-dir $MODS/harness-fleet

# or one variable, colon separated
CLAUDE_CODE_PLUGIN_DIRS=$MODS/cortex-guard:$MODS/cortex-wiki:$MODS/zetetic-genius:$MODS/zetetic-autopilot:$MODS/zetetic-insights:$MODS/cortex-cockpit:$MODS/mission-control:$MODS/harness-fleet claude

# an alias in the shell profile: it sets MODS itself, because the alias body is single-quoted and
# is expanded each time it runs, not when the profile is read
alias claude-mods='MODS=/path/to/claude-mods/mods; CLAUDE_CODE_PLUGIN_DIRS=$MODS/cortex-guard:$MODS/cortex-wiki:$MODS/zetetic-genius:$MODS/zetetic-autopilot:$MODS/zetetic-insights:$MODS/cortex-cockpit:$MODS/mission-control:$MODS/harness-fleet claude'

Which form to prefer: an interactive session may ask for a login when CLAUDE_CONFIG_DIR points at an empty directory (seen once in an isolated directory, where headless claude -p did not ask). On the machine's own configuration directory, which is already logged in, that does not apply; use the repeated --plugin-dir or the alias there, and keep an isolated CLAUDE_CONFIG_DIR for tests only.

claude plugin list shows them under "Session-only plugins" with Status: loaded.

What then must hold:

  • The dependencies of a mod are resolved among the mods loaded in the same command, so load a mod together with what it names: zetetic-autopilot needs zetetic-genius; cortex-cockpit needs cortex-guard, zetetic-autopilot and zetetic-genius; harness-fleet needs the same three. A mod whose dependency is absent is listed disabled, with the error Dependency "<name>" is not installed.
  • Options cannot be set (they live in settings.json under pluginConfigs), so every default in a plugin.json has to be right on any machine:
ModOptionDefaultNotes
cortex-guardhygiene_script~/Developments/disk-hygiene/disk_hygiene.py~/ is the home of the user running the session, read at runtime. When the file is not there, one toast per session says hygiene script not found at <path>; worktrees are not registered; an empty value disables the registration without a toast. A python3 that fails to start gets one toast naming the cause, and no further worktree is registered for the session (no retry)
zetetic-geniusmode, classifier_modelobserve, haikuthe grade is one low-effort completion on the haiku alias
zetetic-autopilotpolicy_mode, quota_pressure_percent, offload_threshold_chars, offload_preview_charsenforce, 80, 30000, 2000a result past the threshold is written whole to a file in the session's tool-results directory (the engine's cleanupPeriodDays sweeps it, 30 days by default; the mod deletes nothing); the model reads the preview, the path and the size
cortex-cockpitsurface, cortex_serverink, plugin_hypermnesia-mcp_cortexthe server name as /mcp lists it
mission-controlrendererautoauto draws pixels in kitty or Ghostty outside tmux (TMUX, TERM, TERM_PROGRAM, KITTY_WINDOW_ID), cells elsewhere; cells or pixels forces one
harness-fleetgithub_owner, refresh_on_startcdeust, truereads gh pr list, gh issue list and git remote
  • The machine must let the mods do what they ask: python3 (guard), the Cortex MCP server by name (cockpit), gh and a route to api.github.com (fleet), the files under ~/.claude/plugins/ (fleet), pandoc and xelatex (wiki), and the model aliases (genius, autopilot).

/cortex check tests each of these on the machine where it runs and prints one line per check, ok, FAIL or n/a. cortex-wiki and harness-fleet count as loaded only when the command they register (/wiki, /fleet) was registered by that plugin; a user's own wiki.md, an MCP prompt or another plugin's command of the same name prints FAIL, and a plugin command whose plugin the engine does not name prints n/a. A refusal from the sandbox reaches the line as the first 160 characters of its own text, so it can be pasted back. It only reads: python3 --version, gh --version, gh api rate_limit, git remote, the two fleet files, memory_stats on the Cortex server.

Two limits of the check are stated in its output. A model alias is only proven by a paid completion, which the check does not make, so it prints n/a for the models. The options of cortex-guard and harness-fleet are not readable from another mod (they publish none in their state, and the engine did not list their rows in the run below); the check then says so, and checks the hygiene script at its default location.

Measured, on one Mac (Claude Code 2.1.293, no company sandbox): with a pristine settings.json holding {} and an isolated CLAUDE_CONFIG_DIR, loading all six by CLAUDE_CODE_PLUGIN_DIRS lists all six as loaded and leaves settings.json at {}; cortex-cockpit or zetetic-autopilot loaded alone is disabled with the dependency error above; /cortex check run headless (claude -p "/cortex check") answers one line per check, and reports harness-fleet and cortex-wiki as not loaded when only the other four are, and with user commands wiki.md and fleet.md present it still reports them not loaded (the engine reports a directory-loaded mod's command with source: plugin and plugin set to the bare manifest name).

Not measured: the managed-settings lock itself (it could not be reproduced), whether a company policy accepts --plugin-dir, any refusal of a real sandbox (blocked network, denied exec, unreadable ~/.claude), the Cortex server answering through /cortex check (the headless run had no MCP server connected, and the check printed the engine's refusal for it), the model aliases, and whether /config lists the options of directory-loaded mods in an interactive session.

Source 10 files
hooks/register.tsx 92 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { type Choice, chooseRenderer } from './renderer'
5import type { PastTurn } from '../types'
6import { pastTurn, remembered, sceneOf } from './scene'
7import { CellsView, PixelsView, TextView } from './view'
8
9const PANE = 'mission-control'
10const TITLE = 'Mission'
11const renderer = atom({ plugin: 'mission-control', key: 'renderer' } as const, { renderer: 'cells', reason: 'not chosen yet' } as Choice)
12
13const history = atom({ plugin: 'mission-control', key: 'history' } as const, [] as PastTurn[])
14
15// The dependencies' state, as their contracts declare it.
16const AUTOPILOT_PHASES = { plugin: 'zetetic-autopilot', key: 'phases' } as const
17const INSIGHTS = { plugin: 'zetetic-insights', key: 'insights' } as const
18
19const cut = (error: unknown): string => (error instanceof Error ? error.message : String(error)).slice(0, 120)
20
21// A failure leaves the atom's default (cells, which draw everywhere) and says why, never a session
22// start that throws.
23async function choose($: EngineInterface, setting: unknown): Promise<void> {
24  try {
25    await chosen($, setting)
26  } catch (error) {
27    $.ui.toast(`mission-control: renderer not chosen, cells: ${cut(error).slice(0, 80)}`)
28  }
29}
30
31async function chosen($: EngineInterface, setting: unknown): Promise<void> {
32  const choice = chooseRenderer(setting, {
33    tmux: await $.env.get('TMUX'),
34    term: await $.env.get('TERM'),
35    termProgram: await $.env.get('TERM_PROGRAM'),
36    kittyWindow: await $.env.get('KITTY_WINDOW_ID'),
37  })
38  await update($, renderer, () => choice)
39}
40
41export const register: Register = (on, options) => {
42  on('session.start', async ($, e, next) => {
43    await choose($, options.renderer)
44    try {
45      await $.command.register({ name: 'mission', description: 'Open the mission-control pane: lanes by phase, effort, advisor calls, learning curves', immediate: true })
46    } catch (error) {
47      $.ui.toast(`/mission is taken by another plugin: ${cut(error).slice(0, 80)}`)
48    }
49
50    return next(e)
51  })
52
53  // /clear, /resume and /branch reset $.state and never fire session.start again. Classic hooks do
54  // not run where an organization manages Claude Code: there the renderer stays cells after them.
55  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
56    await choose($, options.renderer)
57
58    return next(e)
59  }).catch(($, e, next) => next(e))
60
61  on('command.run', { command: 'mission' }, async ($) => {
62    const { isPlaced } = await $.ui.open({ id: PANE, title: TITLE })
63    const r = await read($, renderer)
64    return { text: `Mission control ${isPlaced ? 'opened' : 'waits for room (144 columns, or 110 in fullscreen)'}; drawn in ${r.renderer} (${r.reason}).` }
65  })
66
67  // Main's turn as it ended, before the autopilot starts its row afresh.
68  on('turn.complete', async ($, e, next) => {
69    if (e.agentId === undefined) {
70      const phases = (await $.state.get(AUTOPILOT_PHASES)).value
71      const main = phases?.loops.find((l) => l.id === 'main')
72      if (phases?.turnId != null && main !== undefined && main.runs.length > 0) {
73        const turn = pastTurn(main, phases.turnId, await $.clock.now())
74        await update($, history, (h) => remembered(h, turn))
75      }
76    }
77
78    return next(e)
79  })
80
81  // Redrawn whenever the autopilot's phases or the insights change: the reads subscribe it.
82  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
83    const phases = (await $.state.get(AUTOPILOT_PHASES)).value ?? null
84    const insights = (await $.state.get(INSIGHTS)).value ?? null
85    const scene = sceneOf(phases, insights, await $.clock.now(), await read($, history))
86    if (e.surface !== 'terminal') return TextView($.ui.resolve(e) as never, scene)
87    const ui = $.ui.resolve(e)
88    const r = await read($, renderer)
89    return r.renderer === 'pixels' ? PixelsView(ui, scene, e.props.bodyColumns) : CellsView(ui, scene, e.props.bodyColumns)
90  })
91}
92
hooks/renderer.ts 26 lines
1// Which painter the terminal can show. Pixels need the kitty graphics protocol, which the engine
2// speaks to kitty and Ghostty only; cells draw everywhere.
3//
4// source: claude-code types, ImageProps ("pixels ... where the terminal can (kitty, Ghostty), the
5// `alt` elsewhere") and reference.md (same). Inside tmux the terminal the engine sees is tmux's own
6// (measured 2026-10-14: TERM=tmux-256color, TERM_PROGRAM=tmux in a pane), neither kitty nor
7// Ghostty, so cells are the default there; a tmux set up to pass kitty graphics through is the
8// `renderer: pixels` option's case. The variables: tmux sets TMUX in every pane (tmux(1),
9// ENVIRONMENT); kitty sets TERM=xterm-kitty and KITTY_WINDOW_ID (kitty docs); Ghostty sets
10// TERM=xterm-ghostty and TERM_PROGRAM=ghostty (Ghostty docs). The option overrides when they mislead.
11
12import type { Renderer } from '../types'
13
14export type Choice = { renderer: Renderer; reason: string }
15export type TermVars = { tmux: string | undefined; term: string | undefined; termProgram: string | undefined; kittyWindow: string | undefined }
16
17const isSet = (v: string | undefined): v is string => v !== undefined && v !== ''
18
19export const chooseRenderer = (setting: unknown, vars: TermVars): Choice => {
20  if (setting === 'cells' || setting === 'pixels') return { renderer: setting, reason: 'set in the renderer option' }
21  if (isSet(vars.tmux)) return { renderer: 'cells', reason: 'inside tmux, which is neither kitty nor Ghostty' }
22  if (vars.term === 'xterm-kitty' || isSet(vars.kittyWindow)) return { renderer: 'pixels', reason: 'kitty' }
23  if (vars.term === 'xterm-ghostty' || vars.termProgram === 'ghostty') return { renderer: 'pixels', reason: 'Ghostty' }
24  return { renderer: 'cells', reason: `${vars.termProgram ?? vars.term ?? 'this terminal'} has no kitty graphics the engine uses` }
25}
26
hooks/scene.ts 259 lines
1// What the pane shows, as data both painters draw: the turn's lanes (main and its agents) over
2// time, the effort each phase runs at, and each phase's learning curve. Pure: the same state at
3// the same instant is the same scene.
4
5import type { PluginState } from 'claude-code'
6
7import type { PastTurn } from '../types'
8
9export type PhasesState = PluginState['zetetic-autopilot']['phases']
10export type InsightsState = PluginState['zetetic-insights']['insights']
11type LoopPhases = PhasesState['loops'][number]
12// Structural copies of the insights contract's types (a mod cannot import another mod's file).
13export type ModelFamily = 'haiku' | 'sonnet' | 'opus' | 'fable' | 'other' | 'unknown'
14export type ModelSteps = { family: ModelFamily; steps: number }
15export type SessionModels = InsightsState['models'][number]
16export type Phase = NonNullable<LoopPhases['phase']>
17
18export const MAX_LANES = 8 // source: own choice, main and the seven agents seen last
19export const WINDOW_MS = 10 * 60_000 // source: own choice, the recent past the lanes span
20export const HISTORY_TURNS = 20 // source: own choice, main's past turns kept for the window
21export const MODEL_ROWS = 8 // source: own choice, the last sessions drawn beside the window
22export const PROBE_STEPS = 1 // source: audit 2026-10-15 (insights-audit/report.md section 1): 20 of 24 ledger sessions were `claude -p` probe runs of one step. The insights state carries no cwd per session, so a session of at most this many steps in total stands for a probe
23export const IDLE_MS = 20_000 // source: own choice, a pause this long is cut from the time axis
24const CUT_SHARE = 0.04 // source: own choice, the room one cut takes, as a share of the active time
25export const FAMILIES: readonly ModelFamily[] = ['haiku', 'sonnet', 'opus', 'fable', 'other', 'unknown'] // cheap to dear, then the rest
26const LADDER = ['low', 'medium', 'high', 'xhigh', 'max'] as const
27
28// An effort as a bar height, 1 (low) to 4 (xhigh or max); 0 when the engine sent none.
29export const rung = (effort: string | null): number => {
30  const i = LADDER.indexOf(effort as (typeof LADDER)[number])
31  return i < 0 ? 0 : Math.min(i + 1, 4)
32}
33
34export type Segment = { phase: Phase; effort: string | null; t0: number; t1: number; steps: number; errors: number }
35export type Lane = { id: string; label: string; model: string; isRunning: boolean; segments: Segment[]; advisor: number[] }
36export type Gauge = { phase: Phase; calibrated: string | null; ran: string | null }
37export type Curve = { phase: Phase; rates: number[]; moveAt: number | null; move: string | null }
38// The stretches of time something ran in; a pause of IDLE_MS or more between them is cut from the
39// axis, so a turn of seconds after minutes of reading does not shrink to a sliver.
40export type Span = { t0: number; t1: number }
41export type Delegations = InsightsState['delegations']
42export type Mechanical = InsightsState['mechanical']
43export type Frictions = InsightsState['frictions']
44// `probes`: how many of the insights' sessions were left out of `sessions` as probes.
45export type Models = { window: ModelSteps[]; sessions: SessionModels[]; probes: number }
46export type Scene = { t0: number; t1: number; spans: Span[]; turnId: string | null; lanes: Lane[]; gauges: Gauge[]; curves: Curve[]; models: Models; delegations: Delegations | null; mechanical: Mechanical | null; frictions: Frictions | null }
47
48// A model id as its family: the first of fable, opus, sonnet, haiku found in it, else other.
49export const familyOf = (model: unknown): ModelFamily => {
50  if (typeof model !== 'string' || model === '') return 'unknown'
51  const m = model.toLowerCase()
52  return (['fable', 'opus', 'sonnet', 'haiku'] as const).find((f) => m.includes(f)) ?? 'other'
53}
54
55// Steps added per family, in FAMILIES order, a family with no step left out.
56const tally = (parts: readonly { model: string; steps: number }[]): ModelSteps[] => {
57  const by = new Map<ModelFamily, number>()
58  for (const p of parts) by.set(familyOf(p.model), (by.get(familyOf(p.model)) ?? 0) + p.steps)
59  return FAMILIES.filter((f) => (by.get(f) ?? 0) > 0).map((family) => ({ family, steps: by.get(family)! }))
60}
61
62// A loop's runs as segments: each run lasts until the next one starts, the last until the loop's
63// last step (or now, while it runs).
64const segmentsOf = (loop: LoopPhases, now: number): Segment[] =>
65  loop.runs.map((r, i) => ({
66    phase: r.phase,
67    effort: r.effort,
68    t0: r.since,
69    t1: loop.runs[i + 1]?.since ?? (loop.status === 'running' ? now : Math.max(loop.updatedAt, r.since)),
70    steps: r.steps,
71    errors: r.errors,
72  }))
73
74const shortType = (type: string): string => type.split(':').pop() ?? type
75
76// Main's turn as it completed, kept because the autopilot starts main's row afresh each turn.
77export const pastTurn = (main: LoopPhases, turnId: string, now: number): PastTurn => ({
78  turnId,
79  model: main.model,
80  runs: segmentsOf(main, now).map((s) => ({ phase: s.phase, effort: s.effort, steps: s.steps, errors: s.errors, since: s.t0, until: s.t1 })),
81})
82
83// The history with this turn in it (replacing an earlier record of the same turn), newest last.
84export const remembered = (history: readonly PastTurn[], turn: PastTurn): PastTurn[] =>
85  [...history.filter((t) => t.turnId !== turn.turnId), turn].slice(-HISTORY_TURNS)
86
87// The one rule for every flow (main's past turns, main's live turn, each agent): a run is in the
88// window when it ends at or after the window's start. A run's end is its segment's t1: now while
89// its loop runs, else the loop's last step. A main turn that ended before the window is left out,
90// lane and count alike.
91const inWindow = (from: number) => (s: Segment): boolean => s.t1 >= from
92
93// Main over the window (its past turns, then the current one) and the agents that ran in it, the
94// newest kept; the advisor calls of the window on main's lane.
95const lanesOf = (phases: PhasesState, history: readonly PastTurn[], now: number): { lanes: Lane[]; window: ModelSteps[] } => {
96  const from = now - WINDOW_MS
97  const counted = inWindow(from)
98  const main = phases.loops.find((l) => l.id === 'main')
99  const pastRuns = history
100    .filter((t) => t.turnId !== phases.turnId)
101    .flatMap((t) => t.runs.map((r) => ({ model: t.model, seg: { phase: r.phase as Phase, effort: r.effort, t0: r.since, t1: r.until, steps: r.steps, errors: r.errors } as Segment })))
102    .filter(({ seg }) => counted(seg))
103  const mainLive = (main === undefined ? [] : segmentsOf(main, now)).filter(counted)
104  const mainSegments = [...pastRuns.map(({ seg }) => seg), ...mainLive]
105  const agents = phases.loops
106    .filter((l) => l.id !== 'main' && l.runs.length > 0 && l.updatedAt >= from)
107    .sort((a, b) => b.updatedAt - a.updatedAt)
108    .slice(0, mainSegments.length === 0 ? MAX_LANES : MAX_LANES - 1)
109    .reverse()
110    .map((l) => ({ loop: l, segments: segmentsOf(l, now).filter(counted) }))
111  const agentLanes: Lane[] = agents.map(({ loop: l, segments }) => ({
112    id: l.id,
113    label: shortType(l.type),
114    model: l.model,
115    isRunning: l.status === 'running',
116    segments,
117    advisor: [],
118  }))
119  // The window's steps by model, counted by the one rule: main's past runs under their turn's model,
120  // its current runs under main's, each shown agent under its own.
121  const window = tally([
122    ...pastRuns.map(({ model, seg }) => ({ model, steps: seg.steps })),
123    ...mainLive.map((s) => ({ model: main?.model ?? '', steps: s.steps })),
124    ...agents.flatMap(({ loop: l, segments }) => segments.map((s) => ({ model: l.model, steps: s.steps }))),
125  ])
126  if (mainSegments.length === 0) return { lanes: agentLanes, window }
127  const mainLane: Lane = {
128    id: 'main',
129    label: 'main',
130    model: main?.model ?? '',
131    isRunning: main?.status === 'running',
132    segments: mainSegments,
133    advisor: phases.advisor.uses.filter((u) => u.at >= from).map((u) => u.at),
134  }
135  return { lanes: [mainLane, ...agentLanes], window }
136}
137
138// A session is a probe when all its steps together are at most PROBE_STEPS.
139const isProbe = (s: SessionModels): boolean => s.steps.reduce((n, f) => n + f.steps, 0) <= PROBE_STEPS
140
141// The probes are counted apart and the last MODEL_ROWS real sessions kept: filtering first, so probes never take a row.
142const modelsOf = (window: ModelSteps[], insights: InsightsState | null): Models => {
143  const all = insights?.models ?? []
144  return { window, sessions: all.filter((s) => !isProbe(s)).slice(-MODEL_ROWS), probes: all.filter(isProbe).length }
145}
146
147// The one scale every learning curve is drawn on: the highest rate of any curve, so a curve's height
148// compares with the others'. 10 % when no curve ever failed (nothing to scale, and no division by zero).
149export const curveScale = (curves: readonly Curve[]): number => {
150  const top = curves.reduce((m, c) => Math.max(m, ...c.rates), 0)
151  return top > 0 ? top : 0.1
152}
153
154// What a curve's label says: the latest rate, the highest, and over how many points. Never first against last.
155export const curveSummary = (c: Curve): { last: number; peak: number; n: number } => ({ last: c.rates[c.rates.length - 1]!, peak: Math.max(...c.rates), n: c.rates.length })
156
157const gaugesOf = (main: LoopPhases | undefined, insights: InsightsState | null): Gauge[] => {
158  const calibrated = insights?.calibration.phases ?? {}
159  const seen = new Set<Phase>([...(Object.keys(calibrated) as Phase[]), ...(main?.runs.map((r) => r.phase) ?? [])])
160  return [...seen].sort().map((phase) => ({
161    phase,
162    calibrated: calibrated[phase] ?? null,
163    ran: [...(main?.runs ?? [])].reverse().find((r) => r.phase === phase)?.effort ?? null,
164  }))
165}
166
167const curvesOf = (insights: InsightsState | null): Curve[] =>
168  (insights?.trends ?? [])
169    .filter((t) => t.points.length >= 2)
170    .map((t) => ({
171      phase: t.phase,
172      rates: t.points.map((p) => p.rate),
173      // The first point recorded after the move; a move newer than every point sits on the last.
174      moveAt: t.move === null ? null : ((i) => (i < 0 ? t.points.length - 1 : i))(t.points.findIndex((p) => p.at > t.move!.at)),
175      move: t.move === null ? null : `${t.move.from} → ${t.move.to}`,
176    }))
177
178// State survives a plugin reload, so a turn written by an earlier version may have no model: read as
179// '' (unknown), the same as the insights side's `mainModel ?? ''`.
180const settled = (history: readonly PastTurn[]): PastTurn[] =>
181  history.map((t) => ({ ...t, model: typeof t.model === 'string' ? t.model : '' }))
182
183export const sceneOf = (phases: PhasesState | null, insights: InsightsState | null, now: number, history: readonly PastTurn[] = []): Scene => {
184  const { lanes, window } = phases === null ? { lanes: [], window: [] } : lanesOf(phases, settled(history), now)
185  const starts = lanes.flatMap((l) => l.segments.map((s) => s.t0))
186  const ends = lanes.flatMap((l) => l.segments.map((s) => s.t1))
187  return {
188    t0: starts.length === 0 ? now : Math.min(...starts),
189    t1: ends.length === 0 ? now : Math.max(now, ...ends),
190    spans: spansOf(lanes),
191    turnId: phases?.turnId ?? null,
192    lanes,
193    gauges: gaugesOf(phases?.loops.find((l) => l.id === 'main'), insights),
194    curves: curvesOf(insights),
195    models: modelsOf(window, insights),
196    // An older insights (0.3.0 has no delegations, 0.4.0 no mechanical) sends neither: null, never a crash.
197    delegations: insights?.delegations ?? null,
198    mechanical: insights?.mechanical ?? null,
199    // An older insights (before 0.7.0) has no frictions: null draws no section, never a crash.
200    frictions: insights?.frictions ?? null,
201  }
202}
203
204// Every segment's stretch, merged where the pause between two is under IDLE_MS. O(n log n).
205const spansOf = (lanes: readonly Lane[]): Span[] => {
206  const all = lanes.flatMap((l) => l.segments.map((s) => ({ t0: s.t0, t1: Math.max(s.t0, s.t1) }))).sort((a, b) => a.t0 - b.t0)
207  const out: Span[] = []
208  for (const s of all) {
209    const last = out[out.length - 1]
210    if (last !== undefined && s.t0 - last.t1 < IDLE_MS) last.t1 = Math.max(last.t1, s.t1)
211    else out.push({ ...s })
212  }
213  return out
214}
215
216// The axis as laid out: active time in full, each cut pause (between spans, and from the last span
217// to now) the same small room. `at` maps an instant; a moment inside a pause sits in its cut.
218const axes = new WeakMap<Scene, ReturnType<typeof layAxis>>()
219const axisOf = (scene: Scene) => {
220  const known = axes.get(scene)
221  if (known !== undefined) return known
222  const axis = layAxis(scene)
223  axes.set(scene, axis)
224  return axis
225}
226
227const layAxis = (scene: Scene) => {
228  const active = scene.spans.reduce((n, s) => n + (s.t1 - s.t0), 0)
229  const cut = Math.max(1, active * CUT_SHARE)
230  const last = scene.spans[scene.spans.length - 1]
231  const trailing = last !== undefined && scene.t1 - last.t1 >= IDLE_MS
232  const at = (t: number): number => {
233    let offset = 0
234    for (const [i, s] of scene.spans.entries()) {
235      if (i > 0) offset += cut
236      if (t < s.t0) return Math.max(0, offset - cut / 2)
237      if (t <= s.t1) return offset + (t - s.t0)
238      offset += s.t1 - s.t0
239    }
240    return offset + (trailing ? Math.min(cut, t - (last?.t1 ?? t)) : 0)
241  }
242  return { at, total: at(scene.t1), cut, trailing }
243}
244
245// A time as a column of a track `width` cells (or pixels) wide.
246export const columnOf = (scene: Scene, t: number, width: number): number => {
247  const axis = axisOf(scene)
248  return axis.total <= 0 ? 0 : Math.min(width - 1, Math.max(0, Math.floor((axis.at(t) / axis.total) * width)))
249}
250
251// The columns where a pause was cut, for a dashed mark; and the active time the axis shows.
252export const cutsOf = (scene: Scene, width: number): { columns: number[]; activeMs: number } => {
253  const axis = axisOf(scene)
254  const edges = scene.spans.slice(1).map((s) => s.t0)
255  const columns = edges.map((t) => columnOf(scene, t - 1, width))
256  if (axis.trailing) columns.push(columnOf(scene, scene.spans[scene.spans.length - 1]!.t1 + 1, width))
257  return { columns, activeMs: scene.spans.reduce((n, s) => n + (s.t1 - s.t0), 0) }
258}
259
hooks/view.tsx 177 lines
1// The pane's tree, from the scene: one Raster of cells, or text around pixel pictures. Both lay the
2// same rows, so the two read alike.
3
4import type { Elements } from 'claude-code'
5
6import { IDLE, LABEL, delegationLines, familiesPresent, frictionLines, modelRows, paintCells, probeNote, segmentWidths, shareText } from './cells'
7import { GRID, INK, INK_2, MUTED, familyColor, phaseColor } from './palette'
8import { MAX_IMAGE_COLUMNS } from './bitmap'
9import { curvePicture, lanesPicture } from './pixels'
10import { type Scene, WINDOW_MS, curveScale, curveSummary, rung } from './scene'
11
12export type Ui = Elements['terminal']
13
14const hex = (c: number): string => `#${c.toString(16).padStart(6, '0')}`
15const RIGHT = 10
16const CURVE_ROWS = 3
17
18export const CellsView = (ui: Ui, scene: Scene, columns: number) => {
19  const grid = paintCells(scene, columns)
20  return <ui.Raster key="mission" columns={grid.columns} rows={grid.rows} cells={grid.encode()} />
21}
22
23const Gauges = (ui: Ui, scene: Scene) => {
24  const { Box, Text } = ui
25  return (
26    <Box flexDirection="column">
27      <Text color={hex(INK)}>EFFORT</Text>
28      {scene.gauges.map((g) => {
29        const level = rung(g.calibrated ?? 'medium')
30        return (
31          <Text>
32            <Text color={hex(INK_2)}>{g.phase.padEnd(LABEL)}</Text>
33            <Text color={hex(phaseColor(g.phase))}>{'████'.repeat(level)}</Text>
34            <Text color={hex(GRID)}>{'░░░░'.repeat(4 - level)}</Text>
35            <Text color={hex(g.calibrated === null ? MUTED : INK_2)}>{`  ${g.calibrated ?? 'medium (default)'} / ${g.ran ?? '-'}`}</Text>
36          </Text>
37        )
38      })}
39    </Box>
40  )
41}
42
43// STEPS BY MODEL as text rows: coloured blocks, the same widths as the cells' section.
44const Models = (ui: Ui, scene: Scene, columns: number) => {
45  const { Box, Text } = ui
46  const track = Math.max(1, columns - LABEL - 24)
47  return (
48    <Box flexDirection="column">
49      <Text color={hex(INK)}>STEPS BY MODEL (this window, then the last sessions)</Text>
50      <Text>
51        <Text>{' '.repeat(LABEL)}</Text>
52        {familiesPresent(scene).map((f) => (
53          <Text>
54            <Text color={hex(familyColor(f))}>█</Text>
55            <Text color={hex(INK_2)}>{` ${f}  `}</Text>
56          </Text>
57        ))}
58      </Text>
59      {modelRows(scene).map((row) => {
60        const total = row.steps.reduce((n, s) => n + s.steps, 0)
61        const widths = segmentWidths(row.steps, track)
62        return (
63          <Text>
64            <Text color={hex(INK_2)}>{row.label.padEnd(LABEL)}</Text>
65            {total === 0 && <Text color={hex(MUTED)}>no step yet</Text>}
66            {row.steps.map((s, k) => (
67              <Text color={hex(familyColor(s.family))}>{'█'.repeat(widths[k]!)}</Text>
68            ))}
69            {total > 0 && <Text color={hex(INK_2)}>{`  ${shareText(row.steps)}`}</Text>}
70          </Text>
71        )
72      })}
73      {scene.models.probes > 0 && <Text color={hex(MUTED)}>{probeNote(scene)}</Text>}
74    </Box>
75  )
76}
77
78// DELEGATIONS as text rows: the same pieces and colours as the cells' section.
79const Delegations = (ui: Ui, scene: Scene) => {
80  const { Box, Text } = ui
81  return (
82    <Box flexDirection="column">
83      {delegationLines(scene).map((line) => (
84        <Text>
85          {line.map((p) => (
86            <Text color={hex(p.color)}>{p.text}</Text>
87          ))}
88        </Text>
89      ))}
90    </Box>
91  )
92}
93
94// FRICTIONS as text rows: the same rows as the cells' section; nothing when the insights sends none.
95const Frictions = (ui: Ui, scene: Scene, columns: number) => {
96  const { Box, Text } = ui
97  const lines = frictionLines(scene, columns)
98  if (lines.length === 0) return null
99  return (
100    <Box flexDirection="column">
101      <Text> </Text>
102      {lines.map((line) => (
103        <Text>
104          {line.map((p) => (
105            <Text color={hex(p.color)}>{p.text}</Text>
106          ))}
107        </Text>
108      ))}
109    </Box>
110  )
111}
112
113export const PixelsView = (ui: Ui, scene: Scene, columns: number) => {
114  const { Box, Text, Image } = ui
115  // Image boxes are 1 to 255 columns (ImageProps): a wider pane leaves the rest of the row empty.
116  const track = Math.min(MAX_IMAGE_COLUMNS, Math.max(1, columns - LABEL))
117  const curveColumns = Math.max(1, track - RIGHT)
118  const lanes = lanesPicture(scene, track)
119  const pct = (r: number) => `${Math.round(r * 100)}%`
120  const scale = curveScale(scene.curves)
121  return (
122    <Box flexDirection="column">
123      <Text color={hex(INK)}>
124        MISSION CONTROL <Text color={hex(MUTED)}>{`turn ${(scene.turnId ?? '-').slice(0, 8)} - ${scene.lanes.length === 0 ? 'idle' : `${scene.lanes.length} lane${scene.lanes.length === 1 ? '' : 's'}`}, last ${WINDOW_MS / 60_000} min`}</Text>
125      </Text>
126      <Text> </Text>
127      {scene.lanes.length === 0 && <Text color={hex(MUTED)}>{IDLE}</Text>}
128      <Box flexDirection="row">
129        <Box flexDirection="column" width={LABEL}>
130          {scene.lanes.flatMap((l) => [
131            <Text color={hex(l.isRunning ? INK : INK_2)}>{l.label.slice(0, LABEL - 2)}</Text>,
132            <Text color={hex(MUTED)}>{l.segments.map((s) => s.phase.slice(0, 4)).filter((p, i, a) => a.indexOf(p) === i).join(' ')}</Text>,
133          ])}
134        </Box>
135        <Image key="lanes" source={lanes.source()} columns={track} rows={Math.max(1, scene.lanes.length) * 2} alt={`${scene.lanes.length} lanes; the terminal draws no pixels here, set renderer to cells`} />
136      </Box>
137      <Text> </Text>
138      {Gauges(ui, scene)}
139      <Text> </Text>
140      <Text color={hex(INK)}>{`FAILED CALLS PER STEP, BY PHASE${scene.curves.length === 0 ? '' : ` - one scale, 0-${pct(scale)}`}`}</Text>
141      {scene.curves.length === 0 && <Text color={hex(MUTED)}>no phase has two points yet: the ledger fills as turns complete</Text>}
142      {scene.curves.map((c) => (
143        <Box flexDirection="row">
144          <Box flexDirection="column" width={LABEL}>
145            <Text color={hex(phaseColor(c.phase))}>{c.phase}</Text>
146            <Text color={hex(MUTED)}>{`peak ${pct(curveSummary(c).peak)}`}</Text>
147            {c.move !== null && <Text color={hex(INK_2)}>{c.move}</Text>}
148          </Box>
149          <Image key={`curve-${c.phase}`} source={curvePicture(c, scale, curveColumns, CURVE_ROWS).source()} columns={curveColumns} rows={CURVE_ROWS} alt={`${c.phase} last ${pct(curveSummary(c).last)}, peak ${pct(curveSummary(c).peak)}`} />
150          <Box flexDirection="column" width={RIGHT}>
151            <Text color={hex(INK)}>{` last ${pct(curveSummary(c).last)}`}</Text>
152            <Text color={hex(MUTED)}>{` n=${curveSummary(c).n}`}</Text>
153          </Box>
154        </Box>
155      ))}
156      <Text> </Text>
157      {Models(ui, scene, columns)}
158      <Text> </Text>
159      {Delegations(ui, scene)}
160      {Frictions(ui, scene, columns)}
161    </Box>
162  )
163}
164
165// Where neither cells nor pixels are drawn (desktop, VS Code, mobile): the scene in words.
166export const TextView = (ui: Elements['desktop'], scene: Scene) => {
167  const { Box, Text } = ui
168  return (
169    <Box flexDirection="column">
170      <Text>Mission control draws in the terminal. This turn:</Text>
171      {scene.lanes.map((l) => (
172        <Text>{`${l.label}: ${l.segments.map((s) => `${s.phase} ${s.effort ?? '-'} ${s.steps} steps${s.errors > 0 ? ` (${s.errors} failed)` : ''}`).join(', ')}`}</Text>
173      ))}
174    </Box>
175  )
176}
177
hooks/cells.ts 263 lines
1// The scene painted into one grid of cells: lanes, the effort per phase, the learning curves.
2// Pure; the register file hands the grid to a `Raster`.
3
4import { Grid, MAX_COLUMNS } from './grid'
5import { CRITICAL, GRID, INK, INK_2, MUTED, SURFACE, familyColor, phaseColor } from './palette'
6import { FAMILIES, curveScale, curveSummary, type Curve, type Gauge, type Lane, type ModelFamily, type ModelSteps, type Scene, PROBE_STEPS, WINDOW_MS, columnOf, cutsOf, familyOf, rung } from './scene'
7
8export const LABEL = 15 // source: own choice, the longest phase name ("implementation") is 14, and a space
9const RIGHT = 10 // the curves' value column
10const CURVE_ROWS = 3 // 12 braille dots tall
11const MIN_COLUMNS = 40 // source: own choice, below this the track is too short to read
12
13// Bar height per effort rung: none, low, medium, high, xhigh/max (U+2581..U+2588 lower blocks).
14const BARS = [0x2581, 0x2582, 0x2584, 0x2586, 0x2588]
15const FULL = 0x2588
16const SHADE = 0x2591
17const LEVELS = ['low', 'medium', 'high', 'xhigh'] as const
18
19const DASH = 0x250a // ┊, where a pause was cut from the axis
20
21const duration = (ms: number): string => {
22  const s = Math.round(ms / 1000)
23  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
24}
25
26export const probeNote = (scene: Scene): string => `+${scene.models.probes} probe session${scene.models.probes === 1 ? '' : 's'} hidden (${PROBE_STEPS} step or fewer)`
27
28export const IDLE = `idle: no agent in the last ${WINDOW_MS / 60_000} min`
29
30const pct = (r: number): string => `${Math.round(r * 100)}%`
31
32const heightOf = (scene: Scene): number =>
33  2 + Math.max(1, scene.lanes.length) * 2 + 2 + 2 + Math.max(1, scene.gauges.length) + 1 + 1 + Math.max(1, scene.curves.length * (CURVE_ROWS + 1)) + 3 + scene.models.sessions.length + (scene.models.probes > 0 ? 1 : 0) + 1 + delegationLines(scene).length + frictionLines(scene, MAX_COLUMNS).length + (scene.frictions == null ? 0 : 1)
34
35// The room the printed shares take to the right of a model bar (two spaces, three families).
36const SHARES = 24 // source: own choice, "  haiku 12% sonnet 30% opus 58%" is 31 and is cut at the edge
37
38// The bar's columns per family, in the order given, summing to exactly `track`: floor of each
39// share, every family with steps at least one column, the difference (the rounding, or the
40// columns the minimums took) added to or taken from the widest. A track shorter than the number
41// of families gives its columns to the largest ones.
42export const segmentWidths = (steps: readonly ModelSteps[], track: number): number[] => {
43  const total = steps.reduce((n, s) => n + s.steps, 0)
44  const widths = steps.map(() => 0)
45  if (total <= 0 || track <= 0) return widths
46  const order = steps.map((_, i) => i).filter((i) => steps[i]!.steps > 0).sort((a, b) => steps[b]!.steps - steps[a]!.steps || a - b)
47  if (track < order.length) {
48    for (const i of order.slice(0, track)) widths[i] = 1
49    return widths
50  }
51  for (const i of order) widths[i] = Math.max(1, Math.floor((steps[i]!.steps / total) * track))
52  let diff = track - widths.reduce((n, w) => n + w, 0)
53  widths[order[0]!]! += Math.max(0, diff)
54  for (; diff < 0; diff++) widths[widths.reduce((best, w, i) => (w > widths[best]! ? i : best), 0)]!--
55  return widths
56}
57
58export const shareText = (steps: readonly ModelSteps[]): string => {
59  const total = steps.reduce((n, s) => n + s.steps, 0)
60  return steps.map((s) => `${s.family} ${Math.round((s.steps / total) * 100)}%`).join(' ')
61}
62
63// The window's row, then one per session; each with the label it carries, its families in the
64// fixed order and only those with a step.
65export const modelRows = (scene: Scene): { label: string; steps: ModelSteps[] }[] =>
66  [
67    { label: 'window', steps: scene.models.window },
68    ...scene.models.sessions.map((s) => ({ label: s.session.slice(0, 8), steps: s.steps })),
69  ].map((r) => ({ label: r.label, steps: r.steps.filter((s) => s.steps > 0).sort((a, b) => FAMILIES.indexOf(a.family) - FAMILIES.indexOf(b.family)) }))
70
71// The families with a step in any row, in the fixed order.
72export const familiesPresent = (scene: Scene): ModelFamily[] => {
73  const seen = new Set(modelRows(scene).flatMap((r) => r.steps.map((s) => s.family)))
74  return FAMILIES.filter((f) => seen.has(f))
75}
76
77// The DELEGATIONS section as lines of coloured pieces, shared by both painters. Every number is the
78// insights field it names; the colour never carries the meaning alone (the count is printed).
79export type Piece = { text: string; color: number }
80const FLAGS = ['overkill', 'underpowered', 'undesigned', 'rerouted'] as const
81const RECENT = 4 // source: own choice, the newest flagged delegations that fit under the counts
82
83export const delegationLines = (scene: Scene): Piece[][] => {
84  const d = scene.delegations
85  const m = scene.mechanical
86  const lines: Piece[][] = [[{ text: 'DELEGATIONS (judged against their DESIGN block)', color: INK }]]
87  if (d == null) lines.push([{ text: 'no delegation judged yet', color: MUTED }])
88  else {
89    lines.push([{ text: `${d.total} delegations, ${d.designed} designed`, color: INK_2 }])
90    const counts: Piece[] = []
91    FLAGS.forEach((f, i) => {
92      const n = d.flags?.[f] ?? 0
93      counts.push({ text: `${i === 0 ? '' : '  '}${f} ${n}`, color: n > 0 ? CRITICAL : MUTED })
94    })
95    lines.push(counts)
96    for (const r of (d.recent ?? []).slice(-RECENT).reverse())
97      lines.push([{ text: `${r.type.slice(0, 14).padEnd(14)} ${familyOf(r.model)} ${r.effort ?? '-'} ${r.steps}st ${r.errors}x  ${r.flags.join(',')}`, color: INK_2 }])
98  }
99  if (m != null)
100    lines.push([{ text: m.share == null ? 'main opus/fable: no measured step yet' : `main opus/fable: ${pct(m.share)} steps only Read/Grep/Glob, ${m.sessions} sessions`, color: m.share == null ? MUTED : INK_2 }])
101  return lines
102}
103
104// The FRICTIONS section: the insights' recurring frictions, the first FRICTION_ROWS, one row each:
105// the kind in KIND columns, in how many sessions (Ns), how many times (Mx), the signature. Nothing
106// at all when the insights sends none (a version before 0.7.0). Printable ASCII only (a Raster
107// refuses the rest), and every row cut to the pane's width.
108const FRICTION_ROWS = 5 // source: own choice, the rows that fit under DELEGATIONS; the brief says only the top one
109const KIND = 15 // source: the longest kind, "harness-refusal", is 15
110const ascii = (s: string): string => s.replace(/[^\x20-\x7e]/g, '?')
111
112export const frictionLines = (scene: Scene, columns: number): Piece[][] => {
113  const f = scene.frictions
114  if (f == null) return []
115  const row = (text: string, color: number): Piece[] => [{ text: ascii(text).slice(0, columns), color }]
116  const lines: Piece[][] = [row('FRICTIONS (recurring over the last sessions)', INK)]
117  if (f.length === 0) lines.push(row('no friction recurs over 2 sessions yet', MUTED))
118  for (const r of f.slice(0, FRICTION_ROWS)) lines.push(row(`${String(r.kind).slice(0, KIND).padEnd(KIND)} ${r.sessions}s ${r.count}x ${r.signature}`, INK_2))
119  return lines
120}
121
122function frictions(g: Grid, scene: Scene, top: number): void {
123  frictionLines(scene, g.columns).forEach((line, i) => {
124    let x = 0
125    for (const p of line) x = g.text(x, top + i, p.text, p.color)
126  })
127}
128
129function delegations(g: Grid, scene: Scene, top: number): void {
130  delegationLines(scene).forEach((line, i) => {
131    let x = 0
132    for (const p of line) x = g.text(x, top + i, p.text, p.color)
133  })
134}
135
136function models(g: Grid, scene: Scene, top: number): void {
137  g.text(0, top, 'STEPS BY MODEL (this window, then the last sessions)', INK)
138  let x = LABEL
139  for (const f of familiesPresent(scene)) {
140    g.put(x, top + 1, FULL, familyColor(f))
141    x = g.text(x + 1, top + 1, ` ${f}  `, INK_2)
142  }
143  const track = Math.max(1, g.columns - LABEL - SHARES)
144  modelRows(scene).forEach((row, i) => {
145    const y = top + 2 + i
146    g.text(0, y, row.label, INK_2)
147    if (row.steps.length === 0) {
148      g.text(LABEL, y, 'no step yet', MUTED)
149      return
150    }
151    const widths = segmentWidths(row.steps, track)
152    let c = LABEL
153    row.steps.forEach((s, k) => {
154      for (let w = 0; w < widths[k]!; w++) g.put(c++, y, FULL, familyColor(s.family))
155    })
156    g.text(LABEL + track, y, `  ${shareText(row.steps)}`, INK_2)
157  })
158  if (scene.models.probes > 0) g.text(0, top + 2 + modelRows(scene).length, probeNote(scene), MUTED)
159}
160
161function lanes(g: Grid, scene: Scene, top: number): number {
162  const width = g.columns - LABEL
163  if (scene.lanes.length === 0) {
164    g.text(LABEL, top, IDLE, MUTED)
165    return top + 2
166  }
167  scene.lanes.forEach((lane: Lane, i) => {
168    const y = top + i * 2
169    g.text(0, y, lane.label.slice(0, LABEL - 2), lane.isRunning ? INK : INK_2)
170    // Failed calls per column, summed: runs closer than a mark's width share one count.
171    const fails = new Map<number, number>()
172    for (const s of lane.segments) {
173      const c0 = columnOf(scene, s.t0, width)
174      const c1 = Math.max(c0, columnOf(scene, s.t1, width))
175      for (let c = c0; c <= c1; c++) g.put(LABEL + c, y, BARS[rung(s.effort)]!, phaseColor(s.phase))
176      if (c1 - c0 > s.phase.length) g.text(LABEL + c0, y + 1, s.phase, INK_2)
177      if (s.errors > 0) fails.set(c1, (fails.get(c1) ?? 0) + s.errors)
178    }
179    for (const at of lane.advisor) g.put(LABEL + columnOf(scene, at, width), y + 1, 0x2a, INK)
180    // Each count at its run's end, pulled inside the track first; one that would start within a
181    // mark's width (3) and a space of the previous is added to it, so marks never overlap.
182    let at = Number.NEGATIVE_INFINITY
183    let count = 0
184    const flush = () => {
185      if (count > 0) g.text(LABEL + at, y + 1, count > 99 ? 'x99' : `x${count}`, CRITICAL)
186    }
187    for (const c of [...fails.keys()].sort((a, b) => a - b)) {
188      const p = Math.min(c, width - 3)
189      if (p - at < 4) count += fails.get(c)!
190      else {
191        flush()
192        at = p
193        count = fails.get(c)!
194      }
195    }
196    flush()
197  })
198  // A dashed mark down every lane where a pause was cut, on cells nothing else drew.
199  for (const c of cutsOf(scene, width).columns)
200    for (let y = top; y < top + scene.lanes.length * 2; y++)
201      if (g.words[(y * g.columns + LABEL + c) * 3] === 0x20) g.put(LABEL + c, y, DASH, GRID)
202  return top + scene.lanes.length * 2
203}
204
205function gauges(g: Grid, list: readonly Gauge[], top: number): number {
206  g.text(0, top, 'EFFORT', INK)
207  g.text(LABEL, top, 'low med high max  calibrated / last run', MUTED)
208  if (list.length === 0) g.text(0, top + 1, 'no phase seen yet', MUTED)
209  list.forEach((gauge, i) => {
210    const y = top + 1 + i
211    const level = rung(gauge.calibrated ?? 'medium')
212    g.text(0, y, gauge.phase, INK_2)
213    LEVELS.forEach((_, k) => {
214      for (let c = 0; c < 4; c++) g.put(LABEL + k * 4 + c, y, k < level ? FULL : SHADE, k < level ? phaseColor(gauge.phase) : GRID)
215    })
216    g.text(LABEL + 18, y, `${gauge.calibrated ?? 'medium (default)'} / ${gauge.ran ?? '-'}`, gauge.calibrated === null ? MUTED : INK_2)
217  })
218  return top + 1 + Math.max(1, list.length)
219}
220
221function curve(g: Grid, c: Curve, scale: number, top: number): void {
222  const width = g.columns - LABEL - RIGHT
223  const dotsX = width * 2
224  const dotsY = CURVE_ROWS * 4
225  g.text(0, top, c.phase, INK_2)
226  const at = (i: number): [number, number] => [
227    LABEL * 2 + Math.round((i * (dotsX - 1)) / (c.rates.length - 1)),
228    top * 4 + dotsY - 1 - Math.round((c.rates[i]! / scale) * (dotsY - 1)),
229  ]
230  for (let i = 1; i < c.rates.length; i++) g.line(...at(i - 1), ...at(i), phaseColor(c.phase))
231  const s = curveSummary(c)
232  g.text(LABEL + width + 1, top, `last ${pct(s.last)}`, INK)
233  g.text(0, top + 1, `peak ${pct(s.peak)}`, MUTED) // under the name, so a 5% phase beside a 100% one still says how high it went
234  g.text(LABEL + width + 1, top + 1, `n=${s.n}`, MUTED)
235  if (c.moveAt !== null) {
236    const x = Math.floor(at(c.moveAt)[0] / 2)
237    g.put(x, top + CURVE_ROWS, 0x5e, INK)
238    g.text(x + 2, top + CURVE_ROWS, (c.move ?? '').replace('→', '>'), INK_2)
239  }
240}
241
242export const paintCells = (scene: Scene, columns: number): Grid => {
243  const g = new Grid(Math.min(MAX_COLUMNS, Math.max(MIN_COLUMNS, columns)), heightOf(scene), SURFACE)
244  g.text(0, 0, 'MISSION CONTROL', INK)
245  g.text(16, 0, `turn ${(scene.turnId ?? '-').slice(0, 8)} - ${scene.lanes.length === 0 ? 'idle' : `${scene.lanes.length} lane${scene.lanes.length === 1 ? '' : 's'}`}, last ${WINDOW_MS / 60_000} min`, MUTED)
246  let y = lanes(g, scene, 2)
247  const cuts = cutsOf(scene, g.columns - LABEL)
248  g.text(LABEL, y, `${duration(cuts.activeMs)} of work${cuts.columns.length === 0 ? '' : `, ${cuts.columns.length} pause${cuts.columns.length === 1 ? '' : 's'} cut`}`, MUTED)
249  g.text(g.columns - 3, y, 'now', MUTED)
250  y += 2
251  y = gauges(g, scene.gauges, y) + 1
252  g.text(0, y, `FAILED CALLS PER STEP, BY PHASE${scene.curves.length === 0 ? '' : ` - one scale, 0-${pct(curveScale(scene.curves))}`}`, INK)
253  y += 1
254  if (scene.curves.length === 0) g.text(0, y, 'no phase has two points yet: the ledger fills as turns complete', MUTED)
255  scene.curves.forEach((c, i) => curve(g, c, curveScale(scene.curves), y + i * (CURVE_ROWS + 1)))
256  const m = y + Math.max(1, scene.curves.length * (CURVE_ROWS + 1)) + 1
257  models(g, scene, m)
258  const d = m + 2 + modelRows(scene).length + (scene.models.probes > 0 ? 1 : 0) + 1
259  delegations(g, scene, d)
260  frictions(g, scene, d + delegationLines(scene).length + 1)
261  return g
262}
263
hooks/palette.ts 51 lines
1// The pane's colours, as 0x00RRGGBB. The pane paints its own surface, so the contrast it was
2// validated against holds whatever the terminal's background is.
3//
4// Every colour is an exact entry of the xterm 256-colour palette: inside tmux Claude Code draws in
5// 256 colours (measured 2026-10-14: `tmux capture-pane -e` of a session with COLORTERM=truecolor
6// holds only `38;5;n` codes), so a 24-bit colour is snapped to its nearest entry there and the
7// validated palette would drift; an exact entry draws the same in both modes.
8//
9// source: the dataviz skill's reference palette (references/palette.md), its eight dark
10// categorical slots snapped to the nearest 256-colour cube entries; the yellow slot's nearest
11// (#d78700) fails the lightness band, so it is the next darker ochre #af8700 (136).
12// `validate_palette.js --mode dark` on these eight: all checks pass, the worst adjacent CVD ΔE 6.8
13// (the 6-8 band, legal only with a secondary encoding: every colour is drawn beside its phase
14// name). One slot per phase in a fixed order, never cycled.
15
16import type { ModelFamily, Phase } from './scene'
17
18export const SURFACE = 0x1c1c1c // 234, the reference dark surface #1a1a19 snapped
19export const INK = 0xeeeeee // 255, text primary; 14.7:1 on the surface
20export const INK_2 = 0xbcbcbc // 250, text secondary; 9.0:1
21export const MUTED = 0x8a8a8a // 245, quiet text; 5.0:1 (own choice: the reference grey 2.7:1 is too low for text)
22export const GRID = 0x444444 // 238, empty gauge cells and rules, never text
23// source: same file, status palette: a status colour is reserved and ships with a glyph. A failed
24// tool call is an `x` and its count in it, in the marks row beside the lane, never on a bar. The
25// critical step #d03b3b snaps to #d75f5f, the report slot's own entry, so it is #ff0000 (196),
26// 4.3:1 on the surface and apart from every slot.
27export const CRITICAL = 0xff0000
28
29export const PHASE_ORDER: readonly Phase[] = ['planning', 'analysis', 'design', 'implementation', 'verification', 'test', 'review', 'report']
30
31const SLOTS = [0x5f87d7, 0xd75f00, 0x00af5f, 0xaf8700, 0xd75f87, 0x008700, 0x8787d7, 0xd75f5f]
32
33export const phaseColor = (phase: Phase): number => SLOTS[PHASE_ORDER.indexOf(phase)] ?? INK_2
34
35export const rgb = (color: number): [number, number, number] => [(color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff]
36
37// A second categorical dimension, the model family, drawn only in the STEPS BY MODEL section and
38// always beside its printed family name (the secondary encoding the 6-8 band needs). Exact xterm
39// entries again.
40// source: same palette's slots (blue, purple, green, orange) and its neutrals, one per family.
41const FAMILY_SLOTS: Record<ModelFamily, number> = {
42  haiku: 0x00af5f,
43  sonnet: 0x5f87d7,
44  opus: 0x8787d7,
45  fable: 0xd75f00,
46  other: 0xbcbcbc,
47  unknown: 0x444444,
48}
49
50export const familyColor = (family: ModelFamily): number => FAMILY_SLOTS[family]
51
hooks/bitmap.ts 57 lines
1// An RGBA picture the mod paints itself, handed to an `Image` as `{ rgba, width, height }`: no PNG
2// encoder, no library.
3//
4// source: claude-code types, ImageSource: `{ rgba, width, height }` base64 bytes the plugin holds,
5// at most 2 MiB decoded, width and height each 1 to 2048 pixels; ImageProps: 1 to 255 columns. An
6// Image draws pixels over the kitty graphics protocol (kitty, Ghostty) and its `alt` elsewhere.
7
8import { base64 } from './grid'
9import { rgb } from './palette'
10
11export const MAX_BYTES = 2 * 1024 * 1024
12export const MAX_SIDE = 2048
13export const MAX_IMAGE_COLUMNS = 255
14// source: own choice, the pixels one cell stands for when a picture is painted; the terminal
15// scales the picture to its box, so this sets the detail, not the size on screen.
16export const CELL_W = 8
17export const CELL_H = 16
18
19export class Bitmap {
20  readonly bytes: Uint8Array
21  private readonly pixels: Uint32Array
22
23  constructor(
24    readonly width: number,
25    readonly height: number,
26    background: number,
27  ) {
28    if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1 || width > MAX_SIDE || height > MAX_SIDE)
29      throw new Error(`picture ${width}×${height} is outside the 1..2048 pixels an Image takes per side`)
30    if (width * height * 4 > MAX_BYTES) throw new Error(`picture ${width}×${height} is over the 2 MiB an Image takes`)
31    this.bytes = new Uint8Array(width * height * 4)
32    this.pixels = new Uint32Array(this.bytes.buffer)
33    this.rect(0, 0, width, height, background)
34  }
35
36  // A row at a time through a 32-bit view: one pixel is the bytes R, G, B, A in memory order, read
37  // as a little-endian word (the order every engine this runs on uses: x86-64 and arm64).
38  rect(x: number, y: number, w: number, h: number, color: number): void {
39    const [r, g, b] = rgb(color)
40    const word = ((255 << 24) | (b << 16) | (g << 8) | r) >>> 0
41    const i0 = Math.max(0, Math.floor(x))
42    const i1 = Math.min(this.width, Math.ceil(x + w))
43    if (i1 <= i0) return
44    for (let j = Math.max(0, Math.floor(y)); j < Math.min(this.height, Math.ceil(y + h)); j++) this.pixels.fill(word, j * this.width + i0, j * this.width + i1)
45  }
46
47  // A line `thick` pixels wide, stamped square by square along its length.
48  line(x0: number, y0: number, x1: number, y1: number, color: number, thick = 2): void {
49    const n = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0)))
50    for (let k = 0; k <= n; k++) this.rect(x0 + ((x1 - x0) * k) / n - thick / 2, y0 + ((y1 - y0) * k) / n - thick / 2, thick, thick, color)
51  }
52
53  source(): { rgba: string; width: number; height: number } {
54    return { rgba: base64(this.bytes), width: this.width, height: this.height }
55  }
56}
57
hooks/pixels.ts 69 lines
1// The scene's lanes and curves painted as pictures, for terminals that draw pixels. The words
2// around them (lane names, phase names, values) stay text, so they are sharp and read by a screen
3// reader; the pictures carry what text cannot: bars, marks, lines.
4
5import { Bitmap, CELL_H, CELL_W, MAX_SIDE } from './bitmap'
6import { CRITICAL, GRID, INK, SURFACE, phaseColor } from './palette'
7import { type Curve, type Scene, columnOf, cutsOf, rung } from './scene'
8
9const GAP = 2 // source: dataviz skill marks spec, a 2px surface gap between adjacent fills
10
11// One lane per two cell rows: its bar in the upper row, its marks in the lower one, as the cell
12// painter lays them, so the lane names beside the picture line up the same way.
13// The terminal scales a picture to its box, so a wide box gets fewer pixels per cell, never more
14// than an Image takes.
15const sideOf = (cells: number, perCell: number): number => Math.min(MAX_SIDE, Math.max(1, cells * perCell))
16
17export const lanesPicture = (scene: Scene, columns: number): Bitmap => {
18  const w = sideOf(columns, CELL_W)
19  const bmp = new Bitmap(w, sideOf(Math.max(1, scene.lanes.length) * 2, CELL_H), SURFACE)
20  scene.lanes.forEach((lane, i) => {
21    const base = (i * 2 + 1) * CELL_H // the bar row's bottom edge
22    bmp.rect(0, base, w, 1, GRID)
23    for (const s of lane.segments) {
24      const x0 = columnOf(scene, s.t0, w)
25      const x1 = Math.max(x0 + 2, columnOf(scene, s.t1, w))
26      const h = Math.max(2, (rung(s.effort) / 4) * (CELL_H - 2))
27      bmp.rect(x0, base - h, x1 - x0 - GAP, h, phaseColor(s.phase))
28      if (s.errors > 0) {
29        // An x in the critical colour at the end of the run that failed, in the marks row.
30        const cx = x1 - GAP - 4
31        const cy = base + CELL_H / 2
32        bmp.line(cx - 3, cy - 3, cx + 3, cy + 3, CRITICAL)
33        bmp.line(cx - 3, cy + 3, cx + 3, cy - 3, CRITICAL)
34      }
35    }
36    for (const at of lane.advisor) {
37      // A diamond in the text ink where the advisor was called.
38      const cx = columnOf(scene, at, w)
39      const cy = base + CELL_H / 2
40      for (let d = 0; d <= 4; d++) {
41        bmp.rect(cx - (4 - d), cy - d, 2 * (4 - d) + 1, 1, INK)
42        bmp.rect(cx - (4 - d), cy + d, 2 * (4 - d) + 1, 1, INK)
43      }
44    }
45  })
46  // A dashed rule down the lanes where a pause was cut from the axis.
47  for (const x of cutsOf(scene, w).columns) for (let y = 0; y < bmp.height; y += 6) bmp.rect(x, y, 1, 3, GRID)
48  return bmp
49}
50
51// One curve over `rows` cell rows: the rate per point, scaled to `scale`, the one every curve shares, a dashed rule where the calibration moved the phase.
52export const curvePicture = (c: Curve, scale: number, columns: number, rows: number): Bitmap => {
53  const w = sideOf(columns, CELL_W)
54  const h = sideOf(rows, CELL_H)
55  const bmp = new Bitmap(w, h, SURFACE)
56  const pad = 3
57  const at = (i: number): [number, number] => [
58    pad + (i * (w - 2 * pad)) / (c.rates.length - 1),
59    h - pad - (c.rates[i]! / scale) * (h - 2 * pad),
60  ]
61  bmp.rect(0, h - 1, w, 1, GRID)
62  if (c.moveAt !== null) {
63    const [x] = at(c.moveAt)
64    for (let y = 0; y < h; y += 6) bmp.rect(x, y, 1, 3, INK)
65  }
66  for (let i = 1; i < c.rates.length; i++) bmp.line(...at(i - 1), ...at(i), phaseColor(c.phase))
67  return bmp
68}
69
hooks/grid.ts 113 lines
1// A grid of terminal cells, each a glyph, a foreground and a background, encoded as a `Raster`'s
2// `cells`. Braille cells carry a 2 × 4 dot mask, so a line is drawn at twice the columns and four
3// times the rows.
4//
5// source: claude-code types, RasterProps: `cells` is standard padded base64 of `columns * rows`
6// little-endian u32 triplets `[codePoint, foreground, background]`; a code point is one printable
7// width-1 BMP character (blocks, box drawing, braille too) or the tree is refused; 1 to 512
8// columns, 1 to 256 rows.
9
10export const MAX_COLUMNS = 512
11export const MAX_ROWS = 256
12const BRAILLE = 0x2800
13// source: Unicode braille patterns: dot (column, row) to bit, rows 0-2 then row 3.
14const DOT = [
15  [0x01, 0x02, 0x04, 0x40],
16  [0x08, 0x10, 0x20, 0x80],
17]
18
19export class Grid {
20  readonly words: Uint32Array
21  private readonly dots: Uint8Array
22
23  constructor(
24    readonly columns: number,
25    readonly rows: number,
26    readonly background: number,
27  ) {
28    if (columns < 1 || columns > MAX_COLUMNS || rows < 1 || rows > MAX_ROWS) throw new Error(`grid ${columns}×${rows} is outside 1..512 × 1..256`)
29    this.words = new Uint32Array(columns * rows * 3)
30    this.dots = new Uint8Array(columns * rows)
31    for (let i = 0; i < columns * rows; i++) this.words.set([0x20, background, background], i * 3)
32  }
33
34  put(x: number, y: number, glyph: number, color: number): void {
35    if (x < 0 || y < 0 || x >= this.columns || y >= this.rows) return
36    this.words.set([glyph, color, this.background], (y * this.columns + x) * 3)
37  }
38
39  // Printable ASCII only, cut at the right edge; anything else is a `?`.
40  text(x: number, y: number, s: string, color: number): number {
41    for (let i = 0; i < s.length; i++) {
42      const c = s.charCodeAt(i)
43      this.put(x + i, y, c >= 0x20 && c < 0x7f ? c : 0x3f, color)
44    }
45    return x + s.length
46  }
47
48  // One braille dot at dot coordinates; the cell takes the colour of its last dot.
49  dot(px: number, py: number, color: number): void {
50    const x = Math.floor(px / 2)
51    const y = Math.floor(py / 4)
52    if (px < 0 || py < 0 || x >= this.columns || y >= this.rows) return
53    const i = y * this.columns + x
54    this.dots[i]! |= DOT[px % 2]![py % 4]!
55    this.words.set([BRAILLE | this.dots[i]!, color, this.background], i * 3)
56  }
57
58  // A straight run of dots from one point to the other (Bresenham).
59  line(x0: number, y0: number, x1: number, y1: number, color: number): void {
60    const dx = Math.abs(x1 - x0)
61    const dy = -Math.abs(y1 - y0)
62    const sx = x0 < x1 ? 1 : -1
63    const sy = y0 < y1 ? 1 : -1
64    let err = dx + dy
65    for (;;) {
66      this.dot(x0, y0, color)
67      if (x0 === x1 && y0 === y1) return
68      const e2 = 2 * err
69      if (e2 >= dy) {
70        err += dy
71        x0 += sx
72      }
73      if (e2 <= dx) {
74        err += dx
75        y0 += sy
76      }
77    }
78  }
79
80  // The `cells` prop: the words' bytes, little-endian, in padded base64.
81  encode(): string {
82    const bytes = new Uint8Array(this.words.length * 4)
83    const view = new DataView(bytes.buffer)
84    this.words.forEach((w, i) => view.setUint32(i * 4, w, true))
85    return base64(bytes)
86  }
87
88  // The glyphs as text rows, for tests and for the `alt` of nothing.
89  glyphRows(): string[] {
90    const out: string[] = []
91    for (let y = 0; y < this.rows; y++) {
92      let row = ''
93      for (let x = 0; x < this.columns; x++) row += String.fromCharCode(this.words[(y * this.columns + x) * 3]!)
94      out.push(row)
95    }
96    return out
97  }
98}
99
100const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
101
102// Standard padded base64 (RFC 4648 §4).
103export const base64 = (bytes: Uint8Array): string => {
104  let out = ''
105  for (let i = 0; i < bytes.length; i += 3) {
106    const n = (bytes[i]! << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
107    out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]!
108    out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63]! : '='
109    out += i + 2 < bytes.length ? ALPHABET[n & 63]! : '='
110  }
111  return out
112}
113
types/index.d.ts 18 lines
1// mission-control keeps two values: the renderer chosen at session start, so a redraw does not read
2// the environment again; and main's runs of the last turns, which the autopilot starts afresh each
3// turn, so the lanes show the recent past and not the current turn alone.
4
5export type Renderer = 'cells' | 'pixels'
6
7export type PastRun = { phase: string; effort: string | null; steps: number; errors: number; since: number; until: number }
8export type PastTurn = { turnId: string; model: string; runs: PastRun[] }
9
10declare module 'claude-code' {
11  interface PluginState {
12    'mission-control': {
13      renderer: { renderer: Renderer; reason: string }
14      history: PastTurn[]
15    }
16  }
17}
18