Bande au-dessus du prompt : run APEX en cours (titre, mode, étape, branche, baseline) ; /apex-pane ouvre le détail (phases ≈ tokens, sous-agents, totaux, vérif…

Declarative macOS system configuration using Nix flakes
[]() [
]()
Single host: alex-mbp (aarch64-darwin). Everything below is declared in this repo — system settings, CLI tools, GUI apps, fonts, shell, editor, and the Claude Code setup.
# 1. Prerequisites (git + clone access)
xcode-select --install
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew install gh
gh auth login # authenticate with GitHub
# 2. Clone and run bootstrap (handles everything else)
git clone https://github.com/AlxWrtl/NixConfig.git ~/.config/nix-darwin
cd ~/.config/nix-darwin
./bootstrap.sh
bootstrap.sh runs twelve checkpointed steps and skips whatever is already done, so it is safe to re-run after an interruption. Once a run completes, later runs skip the app configs restore unless you pass ./bootstrap.sh --restore:
| # | Step | Notes |
|---|---|---|
| 1 | Xcode Command Line Tools | |
| 2 | Nix package manager | Determinate Systems installer |
| 3 | Homebrew | |
| 4 | 1Password | pauses — you retrieve SSH keys + git-crypt key from the vault |
| 5 | SSH key permissions | chmod 700 ~/.ssh, 600 on the private key |
| 6 | GitHub CLI authentication | gh auth login |
| 7 | Decrypt secrets | git-crypt unlock |
| 8 | App Store login | pauses — sign in for masApps |
| 9 | darwin-rebuild switch | the actual build |
| 10 | Switch git remote to SSH | |
| 11 | VS Code extensions | vscode-install-extensions |
| 12 | Restore app configs | Plex, Logitech, Raycast, Ice, Finder sidebar, Wi-Fi/BT |
Steps 4 and 8 block on a manual action; the rest are unattended. App logins (Discord, Figma, Teams, …) stay manual — see the checklist below.
# 1. Xcode Command Line Tools
xcode-select --install
# 2. Install Homebrew + GitHub CLI
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew install gh
gh auth login
# 3. Install Nix
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh
# 4. Clone
git clone https://github.com/AlxWrtl/NixConfig.git ~/.config/nix-darwin
cd ~/.config/nix-darwin
# 5. Install 1Password and retrieve keys
brew install --cask 1password
# Open 1Password → login → save SSH keys to ~/.ssh/ and git-crypt key to ~/git-crypt-key
# 6. Decrypt secrets
nix-shell -p git-crypt --run "git-crypt unlock ~/git-crypt-key"
rm ~/git-crypt-key
# 7. Login to App Store (for masApps)
# 8. Build & apply
sudo darwin-rebuild switch --flake .#alex-mbp
# 9. Post-install
git remote set-url origin git@github.com:AlxWrtl/NixConfig.git
~/.local/bin/vscode-install-extensions
flake.nix # inputs, checks, darwinConfigurations, devShell
├── hosts/alex-mbp/ # Host identity
│ ├── default.nix
│ └── configuration.nix
├── modules/ # System modules
│ ├── system.nix # Nix settings, env, firewall, shell
│ ├── packages.nix # CLI tools + shell aliases
│ ├── services.nix # launchd agents & daemons
│ ├── ui.nix # Fonts, Dock, Finder, macOS defaults, wallpaper
│ └── brew.nix # taps, brews, casks, masApps
├── home/ # User config (home-manager)
│ ├── default.nix # User packages & imports
│ ├── git.nix # Git + SSH signing
│ ├── ssh.nix # SSH hosts
│ ├── zsh.nix # Shell (zsh + fzf + zoxide + autosuggest + highlighting)
│ ├── starship.nix # Prompt
│ ├── direnv.nix # Directory environments
│ ├── ghostty.nix # Terminal (Catppuccin, quick terminal)
│ ├── vscode.nix # VS Code settings, keybindings, extensions
│ ├── claude-code.nix # Claude Code entrypoint — imports claude-code/
│ ├── claude-code/ # settings, hooks, agents, skills, commands, rules…
│ │ # incl. skills-manifest.nix (Claude skills)
│ │ # and mods/ (task-board, apex-band, status-bar) via mods.nix
│ ├── codex.nix # Codex CLI entrypoint — imports codex/
│ └── codex/ # hooks.json generator, activation, hook & merge scripts
├── checks/ # Flake checks (see Quality Gates)
│ ├── agent-instructions.nix
│ ├── apex-consistency.nix
│ ├── apex-plan-provenance.nix
│ ├── apex-tier.nix
│ ├── audit-apex-needles.py # Advisory, not a flake check — needle shapes
│ ├── claude-config.nix
│ ├── claude-mods.nix
│ ├── codex-config.nix
│ ├── hook-wiring.nix
│ ├── js-lint.nix
│ ├── readme-consistency.nix
│ └── trello-cli.nix
├── backups/ # 🔒 Encrypted app config exports (backup-apps.sh)
├── wallpapers/ # Desktop wallpaper
├── secrets.nix # 🔒 Encrypted (git-crypt) — emails, IPs, usernames
├── bootstrap.sh # Fresh-machine install
├── backup-apps.sh # Export app configs into backups/
└── .gitattributes # git-crypt filter rules
flake.nix inputs: nixpkgs (unstable), nix-darwin, home-manager (master), and determinate for the Nix daemon.
Sensitive values live in secrets.nix, encrypted by git-crypt. The backups/ tree is encrypted by the same filter — app exports contain Wi-Fi networks and the Bluetooth device list.
git-crypt status # Check encryption status
git-crypt lock # Re-encrypt (rarely needed)
git-crypt unlock <key-file> # Decrypt after clone
Filter rules live in .gitattributes:
secrets.nix filter=git-crypt diff=git-crypt
backups/** filter=git-crypt diff=git-crypt
rebuild # alias: sudo darwin-rebuild switch --flake .#alex-mbp
nix flake check # Run all quality gates (see below)
nix flake update # Update inputs
nix develop # Dev shell: vulnix, nix-tree, nixfmt, nil
darwin-rebuild rollback # Rollback to previous generation
darwin-rebuild switch --flake .#alex-mbp --show-trace -v # Debug
nix-options (home/claude-code/scripts/nix-options.sh, packaged by home/claude-code/nix-options.nix) evaluates the options of the flake.lock-pinned nix-darwin, home-manager and determinate modules. "Declared in" maps to GitHub at the lock rev. Inside the Claude sandbox it falls back to a read-only store. Override with NIX_OPTIONS_FLAKE, --flake or --host.
nix-options show system.defaults.dock.autohide
nix-options show programs.git.settings
nix-options search dock
nix-options --json show system.defaults.dock.autohide
nix flake check runs every check below. They are the reason a broken module, a drifted Claude Code config or a stale README fails before it reaches the system.
| Check | What it enforces |
|---|---|
format-check | nixfmt --check over every tracked *.nix (find walk, no per-directory list) |
system-config | The whole alex-mbp darwin configuration actually builds |
agent-instructions | The shared instruction trunk actually reaches both rendered outputs: every shared section body present in CLAUDE.md and AGENTS.md, each heading exactly once, headings equal the declared trunk-plus-delta list in order, no mechanism Codex lacks named to Codex or smuggled through the trunk, the nix Docs Gate divergence pinned as Codex-inline only, each output under 100 lines, Project Map gone from both |
apex-consistency | The APEX skill keeps its critical clauses, flag casing, subagent isolation, and step-file references |
apex-plan-provenance | Every premise in an APEX plan carries [M] or [I] as its first token: the clause still stands in step-02-plan, and the line detector is run against two inline fixtures — one correctly tagged, one identical but for a stripped tag — so a detector that stopped detecting fails instead of passing. Presence is not truth: it proves the tag is THERE, never that it is earned; falsifying a tag is the examine reviewer's job and the Fable premises pass |
apex-tier | The built apex-tier classifier against throwaway git repos: a matcher added under hooks.Notification is direct, an entry added inside a deny = [ ... ] list is high, a permissionDecision branch in hooks/x.js is high, a 40-line README change is standard, a new .env.example is high. Canary M1 (the permission regex replaced by one that never matches) must turn the deny-list case away from high, proving that assertion rests on the permission class |
claude-config | Claude Code invariants: JSON parses, sandbox denies ~/.ssh and secrets, agents declare a model, haiku only on read-only agents, rules declare paths |
claude-mods | Claude Code mods (home/claude-code/mods.nix), offline structure only: names equal the folders under home/claude-code/mods/ both ways, each plugin.json parses and names its folder, each hooks.json names one existing module, no .js there, no sound / host process / network / file write / dynamic import / toast in any source and no deny in a hooks module, settings env.CLAUDE_CODE_PLUGIN_DIRS equal to the ~/.claude/mods/<name> folders, activation copying them with the DRY_RUN skip and the engine's types excluded. Canaries: the scan must flag $.audio.speak, the filter a .js name, the comparison an extra name. claude plugin validate --strict and claude plugin test on each mod folder are the code gate and run in the session (claude is not in the build sandbox) |
codex-config | Codex hook invariants: every command in the generated hooks.json names a script the module installs, both scripts pass node --check, hook order and matcher, registered timeouts above each script's own watchdog |
hook-wiring | Claude Code hook wiring, from the evaluated module rather than from text: every hook file home/claude-code.nix installs is named by a command in home/claude-code/settings.nix and every such command names a file that exists, both senses reported apart; additionalContext emitted only inside hookSpecificOutput, the one shape the reference documents; the hookEventName a hook writes equal to the event registering it. Each direction is guarded by a corpus-non-empty assertion first, because an extractor that stops matching would otherwise be green forever. The branch guards are also RUN against fixture repos, each under the timeout its registration gives the host: protect-main.js and block-main-bash.js must deny on malformed input and on a missing or hung git and stay silent off a protected branch, format-typescript.js must hand a $(…) file path to prettier unexpanded and must not call prettier --write when --find-config-path finds no prettier config, research-model.js must deny an Explore or codebase-navigator spawn whose model is not haiku (absent included) and stay silent on haiku, on other agent types and on malformed input, correction-budget.js must deny a correction brief whose model is not opus (absent included) before counting it, allow one more round per user grant, and deny a CronCreate or ScheduleWakeup prompt carrying an apex: +1 tour line while letting any other scheduler call through uncounted, correction-grant.js must grant one round only when the first non-empty prompt line is apex: +1 tour and the prompt carries no harness wrapper (task notification, agent hand-back), never on an agent_id of any value, malformed input or a token further down, never twice for one prompt nor over an existing grant file, and only to the project's live run (newest round, run dir under the cwd) once it is spent; the two hooks carry the same token line, asserted at eval. Canary mutants, which must turn their case red by a missed deny or a PWNED file and not by a crash, cover these branches only: malformed JSON, a missing git and the time budget in protect-main.js; malformed JSON, a broken git in the cwd or in a cd target, and the linear executor scan on a newline flood in block-main-bash.js; each of the two argv calls and the prettier-config gate in format-typescript.js; the absent-model branch and the agent-type set in research-model.js; the grant count, the opus check and the scheduler token check in correction-budget.js; the whole-line token, the first-line rule, the wrapper filter and the spent-budget test in correction-grant.js. The other cases are graded without a mutant |
js-lint | ESLint (pkgs.eslint, eslint:recommended rebuilt from builtinRules, node globals) over every tracked .js: the Claude hooks in home/claude-code/hooks/ and the Codex scripts; asserts the file count and that a canary with an unused variable turns it red |
readme-consistency | This file against the repo: the APEX flag table vs the skill, /apex examples typing only live flags, every .nix in modules/ home/ checks/ hosts/ present in the Structure tree, every check listed above, no dangling path, no alias documented that no attrset declares, no hard count |
trello-cli | The trello wrapper (home/claude-code/trello.nix). Text: skillTrello and cmdCard call only bare trello …, with no curl, no command substitution, no AUTH= and no secret cat; both keep the Tech & Pit test-write rule, the skill its contract/scope/handoffs sections, /card its stop/no-write branches and its comment-before-move order. Settings: Bash(trello ) allowed, no Bash(curl ), trello absent from excludedCommands, allowRead keeps both secret files, autoMode.environment has $defaults first plus the Trello line. Runtime: the built wrapper runs against a stub curl in 17 cases — key and token never in argv, URL or output (inherited SHELLOPTS=xtrace and secret-echoing success bodies included), the OAuth header arrives on stdin, ids validated before any call, secrets with embedded whitespace rejected, 401 and network errors exit 1. Canaries C1 (secret moved into the URL), C2 (id validation disabled) and C3 (set +o xtrace dropped) must each be killed by their target case |
Run them before every commit that touches .nix files — format-check in particular fails on formatting alone.
readme-consistency compares this document against live sources rather than against a copy of its own expectations: a check holding its own copy of the truth rots at the same rate as the thing it checks. It deliberately does not require the modules inside home/claude-code/ to be listed individually — that directory is documented as one unit.
Reclaim disk space by garbage-collecting old generations and deduplicating the Nix store, then prune Homebrew caches.
sudo nix-collect-garbage -d # Delete all old generations (system + user)
nix-store --optimise # Hard-link identical files in the store
brew autoremove # Remove unused brew dependencies
brew cleanup -s --prune=all # Purge all download caches and old versions
Note: if
sudo nix-collect-garbage -dwarns$HOME is not owned by youand falls back to root's profile (leaving user generations behind), run the user sweep without sudo as well:nix-collect-garbage -d.
Each row points at the module that owns it — that file is the authoritative list, deliberately not duplicated here.
| Layer | Tool | Contents |
|---|---|---|
| CLI tools | Nix (modules/packages.nix) | eza, bat, fd, ripgrep, fzf, atuin, zoxide, btop, jq/yq, git-crypt, gh, nodejs, pnpm, bun, python3, uv, ruff, sqlite, postgresql, redis, nixd/nil/nixfmt … |
| GUI apps | Homebrew casks (modules/brew.nix) | 1Password, Arc, Ghostty, VS Code, Zed, Docker, Raycast, Obsidian, Figma, Jellyfin, Tailscale … apps without an auto-updater are marked greedy |
| Formulae & taps | Homebrew (modules/brew.nix) | cloudflared, ffmpeg, displayplacer, mas, postgresql@16, trash, rtk-ai/tap/rtk |
| App Store | mas (modules/brew.nix) | DaisyDisk, Trello, iWork (Keynote/Numbers/Pages), Microsoft Office, Affinity Photo & Publisher |
| Fonts | Nix (modules/ui.nix) | Nerd Fonts (JetBrains Mono, Meslo LG, Hack, Fira Code, Sauce Code Pro), Cascadia Code, Inconsolata, Noto (+ CJK, emoji) |
| macOS defaults | Nix (modules/ui.nix) | Dock, Finder, trackpad, clock, screensaver, Window Manager, wallpaper |
| Services | Nix (modules/services.nix) | weekly flake update, throttled brew update, power tuning (pmset), network/TCP tuning |
| System | Nix (modules/system.nix) | Nix daemon settings, binary caches, env vars, application firewall |
| Terminal | home-manager (home/ghostty.nix) | Catppuccin Macchiato, quick terminal, keybindings |
| Editor | home-manager (home/vscode.nix) | Settings, keybindings, extension list |
| Shell | home-manager (home/zsh.nix) | zsh + fzf + zoxide + autosuggestions + syntax highlighting + aliases |
| Git | home-manager (home/git.nix) | SSH signing, rebase-on-pull, fsck |
| SSH | home-manager (home/ssh.nix) | Host configs (Tailscale) |
| Claude Code | home-manager (home/claude-code/) | settings, hooks, agents, skills, commands, rules, mods, shell aliases |
| Codex CLI | home-manager (home/codex/) | hooks.json + hook scripts (store symlinks), config.toml merge, hook-trust verification |
| Secrets | git-crypt (secrets.nix, backups/) | Git email, SSH hosts, app config exports |
Claude Code mods. home/claude-code/mods.nix lists the mods under home/claude-code/mods/ (TypeScript plugins of function hooks, no dependency, no sound). Activation copies them into ~/.claude/mods as real writable files — that folder is nix-owned, a hand-placed mod there is deleted on the next rebuild — and env.CLAUDE_CODE_PLUGIN_DIRS loads them. task-board: /task-board opens a "Tâches" pane listing background shells and subagents with their duration and state (en cours / fini / échoué); a subagent's still running shells turn échoué when it is killed, fails, or leaves the agent list (a teammate's: only when it leaves the list), since their notification would never reach the main loop; a shell also closes on its subagent's own notification row and on a TaskStop. apex-band: a band above the prompt shows the live APEX run of the working directory (title, mode, current step, branch, baseline) and nothing otherwise. status-bar: replaces the command status line. Drawn on the hint line under the prompt, it shows the model, folder, git branch, the last response's tokens in/out, the context bar with its percentage, and the 5h and 7d quota bars with their percentage and reset countdown: one row when it fits the width, else two, with the engine's own hint line still beneath. Read-only: the branch is read from .git/HEAD, no process is spawned. Run yourself after a rebuild, in a new session: /task-board, a background sleep 5 going from en cours to fini, a failing background command shown échoué, the band present during an APEX run and absent elsewhere, the status bar under the prompt on one row and on two in a narrow window.
Codex records hook trust per hook and by position, inside ~/.codex/config.toml, as a [hooks.state."<file>:<event>:<group>:<hook>"] table carrying a hash of the hook's content. A hook it has not been told to trust is skipped in silence — no error, no log, no protection, while the configuration still looks correct. That is why ~/.codex/config.toml is merged in place and never regenerated, and why home/codex/hooks.nix requires new hooks to be appended only at the END of an event's list.
Nothing outside Codex can grant that trust, and no Codex command reports it (codex doctor has no hook check). So the sequence after any change to the hooks is manual, and short:
sudo darwin-rebuild switch --flake .#alex-mbp — activation merges config.toml, links hooks.json, then runs codex-verify-hook-trust./hooks, review each hook and trust it.codex-verify-hook-trust -a. It writes the hash under ~/.local/state/, outside the agent's writable set, so nothing running inside a session can forge its own clean bill of health. Activation never passes -a: the review it records is a human act.After that, rebuilds are silent until the hooks' content changes — then the warning returns and step 3 has to happen again.
The verifier never claims a hook IS trusted at runtime; it can only report that one is un-approved or stale. Its silence is not proof of protection.
~/.ssh/id_ed25519*)~/.local/bin/vscode-install-extensions)nix flake check greenAPEX is the implementation workflow for Claude Code, declared in home/claude-code/skills.nix and guarded by checks/apex-consistency.nix. Every task that modifies files goes through it; a pure question does not.
Each phase runs as a fresh subagent and returns only a bounded summary, so the coordinator never accumulates raw context. Phase summaries are persisted to .claude/output/apex/{task-id}/.
The gate picks the depth, never whether to run. Each mode carries a default flag set, applied to every flag you did not type. The tier (Direct, Standard, High-stakes) is decided on the diff, not on the brief: apex-tier (home/claude-code/apex-tier.nix) reads its size, its paths and the indentation ancestry of each changed line.
| Mode | Default flags | Notes |
|---|---|---|
| Direct | -pr | ≤ 4 files, ≤ 30 changed lines, no sensitive surface |
| Diagnosis | -x -pr -o -n | bug/crash — reproduce first, debugger agent implements, ships as a PR |
| Standard | -t -pr -o -n | full orchestration |
| High-stakes | -t -x -pr -o -n -e | irreversible / security / architecture / prod — one examine reviewer, then Codex -e as read-only detector whose findings are triaged by evidence; Fable only as fallback when no usable external verdict (BLOCKED or -E), or under -p |
| Pure research | none | analyze only, no branch |
Lowercase forces ON, uppercase forces OFF (-PR cancels an automatic -pr). Typed flags beat mode defaults.
| Enable | Disable | Description |
|---|---|---|
-q | -Q | Clarify — ambiguities become up to 3 targeted questions before planning |
-x | -X | Examine — adversarial, checklist-driven review |
-t | -T | Test — create and run tests |
-f | -F | Test-first — a separate agent writes failing tests from the ACs; read-only for the implementer |
-2 | Divergence — second independent implementation of the core logic, behavioural diff | |
-p | -P | Premises — force/forbid the independent premises pass |
| -e | -E | External verify — one cross-vendor read-only pass (Codex/GPT) over th
hooks/index.tsx 368 lines1// apex-band: a band above the prompt showing the live APEX run of the
2// session's working directory (title, colored step bar, mode, branch,
3// baseline), read from <cwd>/.claude/output/apex/*/00-context.md. Draws
4// nothing of its own when no run is live, or when the run's branch is not
5// the one checked out (<cwd>/.git/HEAD); the bands of the mods beneath are
6// always kept. /apex-pane opens the detail pane: phases with approximate
7// token counts, one card per subagent, totals and cost, external verdict.
8// Observes only: turn.step, tool.call and turn.complete hooks return
9// next(e)'s result unchanged. Read-only: it never writes a file.
10
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, Register, TextProps, Timer } from 'claude-code'
13
14import type { ApexBandLoop, ApexBandPhases, ApexBandRun, ApexBandVerdict } from '../types'
15import { layoutBand } from './band.ts'
16import type { Seg } from './band.ts'
17import { headBranch, isLive, onBranch, parseContext } from './context.ts'
18import { layoutPane } from './pane.ts'
19import { addPhase, addStep, applySnapshot, endLoop, endTool, parseVerdict, startTool, syncPhases } from './stats.ts'
20import type { Usage } from './stats.ts'
21
22const POLL_MS = 5000
23const TICK_MS = 1000
24const PANE = 'apex'
25const TITLE = 'APEX'
26
27const run = atom({ plugin: 'apex-band', key: 'run' } as const, null)
28const loops = atom({ plugin: 'apex-band', key: 'loops' } as const, [])
29const phases = atom({ plugin: 'apex-band', key: 'phases' } as const, { dir: null, byStep: {} })
30const now = atom({ plugin: 'apex-band', key: 'now' } as const, 0)
31const cost = atom({ plugin: 'apex-band', key: 'cost' } as const, null)
32const verdict = atom({ plugin: 'apex-band', key: 'verdict' } as const, null)
33
34// Module-level: a hot reload drops the environment and its timers with it.
35let timer: Timer | undefined
36// The pane's per-second tick, alive only while the pane is open.
37let fastTimer: Timer | undefined
38
39// The newest run's context file and its mtime, or undefined.
40async function newestContext(
41 $: EngineInterface,
42 root: string,
43): Promise<{ path: string; dir: string; mtimeMs: number } | undefined> {
44 let entries
45 try {
46 entries = await $.fs.list(root)
47 } catch {
48 // No .claude/output/apex here (or unreadable): no run to show.
49 return undefined
50 }
51 let best: { path: string; dir: string; mtimeMs: number } | undefined
52 for (const entry of entries) {
53 if (entry.kind !== 'dir') continue
54 const path = `${root}/${entry.name}/00-context.md`
55 try {
56 const stat = await $.fs.stat(path)
57 if (best === undefined || stat.mtimeMs > best.mtimeMs) best = { path, dir: entry.name, mtimeMs: stat.mtimeMs }
58 } catch {
59 // A run directory without its context file is not a run: skipped.
60 }
61 }
62 return best
63}
64
65async function scan($: EngineInterface): Promise<ApexBandRun | null> {
66 const cwd = await $.session.cwd()
67 const newest = await newestContext($, `${cwd}/.claude/output/apex`)
68 if (newest === undefined) return null
69 let text: string
70 try {
71 text = await $.fs.read(newest.path)
72 } catch {
73 // Vanished between stat and read, or over 4 MiB: nothing shown this round.
74 return null
75 }
76 const parsed = parseContext(text, newest.dir)
77 if (!isLive(parsed, newest.mtimeMs, await $.clock.now())) return null
78 let head: string | undefined
79 try {
80 head = headBranch(await $.fs.read(`${cwd}/.git/HEAD`))
81 } catch {
82 // No repo here, or a worktree whose .git is a file: branch unknown, shown.
83 head = undefined
84 }
85 return onBranch(parsed, head) ? { ...parsed, dir: newest.dir } : null
86}
87
88// Applies a pure change to the loops, writing only when they changed (the
89// state library reads and writes named atoms only: one helper per atom).
90async function changeLoops($: EngineInterface, fn: (value: ApexBandLoop[]) => ApexBandLoop[]): Promise<void> {
91 const current = await read($, loops)
92 if (fn(current) === current) return
93 await update($, loops, fn)
94}
95
96async function changePhases($: EngineInterface, fn: (value: ApexBandPhases) => ApexBandPhases): Promise<void> {
97 const current = await read($, phases)
98 if (fn(current) === current) return
99 await update($, phases, fn)
100}
101
102// Writes the verdict unless it already holds an equal one.
103async function putVerdict($: EngineInterface, value: ApexBandVerdict | null): Promise<void> {
104 if (JSON.stringify(await read($, verdict)) === JSON.stringify(value)) return
105 await update($, verdict, () => value)
106}
107
108async function putCost($: EngineInterface, value: number | null): Promise<void> {
109 if ((await read($, cost)) === value) return
110 await update($, cost, () => value)
111}
112
113async function putNow($: EngineInterface, value: number): Promise<void> {
114 if ((await read($, now)) === value) return
115 await update($, now, () => value)
116}
117
118// The run's external-verify.json, re-read only when its mtime moved.
119async function refreshVerdict($: EngineInterface, found: ApexBandRun | null): Promise<void> {
120 const dir = found?.dir
121 if (dir === undefined) {
122 await putVerdict($, null)
123 return
124 }
125 const path = `${await $.session.cwd()}/.claude/output/apex/${dir}/external-verify.json`
126 let mtimeMs: number
127 try {
128 mtimeMs = (await $.fs.stat(path)).mtimeMs
129 } catch {
130 // No external verification yet for this run.
131 await putVerdict($, null)
132 return
133 }
134 const current = await read($, verdict)
135 if (current !== null && current.dir === dir && current.mtimeMs === mtimeMs) return
136 let next: ApexBandVerdict | null = null
137 try {
138 const parsed = parseVerdict(await $.fs.read(path))
139 if (parsed !== null) next = { dir, mtimeMs, ...parsed }
140 } catch {
141 // Vanished between stat and read, or over 4 MiB: shown as pending.
142 next = null
143 }
144 await putVerdict($, next)
145}
146
147// The agent list applied to the known loops: labels, types, ends.
148async function snapshot($: EngineInterface): Promise<void> {
149 const at = await $.clock.now()
150 const list = await $.agent.list()
151 const agents = list.map(a => ({ id: a.id, description: a.description, type: a.type, status: a.status }))
152 await changeLoops($, current => applySnapshot(current, agents, at))
153}
154
155// A segment's Text props: only the styles it sets, never an undefined prop.
156function segProps(seg: Seg): TextProps {
157 return {
158 ...(seg.tone === undefined ? {} : { color: seg.tone }),
159 ...(seg.dim === true ? { dimColor: true } : {}),
160 ...(seg.bold === true ? { bold: true } : {}),
161 }
162}
163
164async function refresh($: EngineInterface): Promise<void> {
165 const found = await scan($)
166 const current = await read($, run)
167 if (JSON.stringify(current) !== JSON.stringify(found)) await update($, run, () => found)
168 // Another run (or none): its buckets start over, never restored later.
169 const dir = found?.dir ?? null
170 await changePhases($, value => syncPhases(value, dir))
171 await refreshVerdict($, found)
172 await snapshot($)
173}
174
175// The pane's second: agents, cost, and the clock while a loop runs.
176async function fastTick($: EngineInterface): Promise<void> {
177 const at = await $.clock.now()
178 await snapshot($)
179 const usage = await $.session.usage()
180 await putCost($, usage.cost?.usd ?? null)
181 if ((await read($, loops)).some(l => l.status === 'running')) await putNow($, at)
182}
183
184function onFastTick($: EngineInterface): void {
185 fastTick($).catch(() => {
186 // Agent list or usage refused: the pane keeps its figures; the next
187 // tick (TICK_MS later) tries again.
188 })
189}
190
191// Runs `write`; a refused state write leaves the counters as they were (an
192// observer never fails the event it watches).
193async function record(write: () => Promise<void>): Promise<void> {
194 try {
195 await write()
196 } catch {
197 // Counters only: the next step, call or poll writes again.
198 }
199}
200
201function onTick($: EngineInterface): void {
202 refresh($).catch(() => {
203 // A failed scan or a refused write leaves the band as it was; the next
204 // tick (POLL_MS later) tries again.
205 })
206}
207
208// Started from session.start and lazily from prompt.submit: session.start
209// does not fire again after a hot reload.
210function ensurePolling($: EngineInterface): void {
211 if (timer !== undefined) return
212 timer = $.clock.every(POLL_MS, () => onTick($))
213 $.clock.after(0, () => onTick($))
214}
215
216export const register: Register = on => {
217 on('session.start', async ($, e, next) => {
218 ensurePolling($)
219 try {
220 await $.command.register({
221 name: 'apex-pane',
222 description: 'Ouvre le détail du run APEX (phases, sous-agents, tokens, vérif externe)',
223 immediate: true,
224 })
225 } catch {
226 // Refused registration: no /apex-pane this session, the band still runs.
227 }
228 return next(e)
229 })
230
231 on('command.run', { command: 'apex-pane' }, async $ => {
232 ensurePolling($)
233 const at = await $.clock.now()
234 await update($, now, () => at)
235 // Opened first: a refused open throws before any 1 s tick is armed. An
236 // unplaced pane is still open (seated once a surface places it): ticked.
237 await $.ui.open({ id: PANE, title: TITLE })
238 onFastTick($)
239 fastTimer ??= $.clock.every(TICK_MS, () => onFastTick($))
240 return { text: 'Détail du run APEX ouvert.' }
241 })
242
243 // The tick stops once the close went through: a hook beneath that keeps
244 // the pane open (answering, or throwing) keeps its figures live.
245 on('ui.close', async ($, e, next) => {
246 const closed = await next(e)
247 if (e.id === PANE) {
248 fastTimer?.cancel()
249 fastTimer = undefined
250 }
251 return closed
252 }).catch(($, e, next) => next(e))
253
254 // Each model response: a step and its usage for its loop, and the usage
255 // for the run's current step (approximate: the step as last polled).
256 on('turn.step', async function* ($, e, next) {
257 const result = yield* next(e)
258 const usage: Usage | null = result.usage
259 await record(async () => {
260 const at = await $.clock.now()
261 await changeLoops($, current => addStep(current, { agentId: e.agentId, model: e.model, usage }, at))
262 const live = await read($, run)
263 const dir = live?.dir
264 if (dir !== undefined) await changePhases($, current => addPhase(current, dir, live?.currentStep, usage))
265 })
266 return result
267 }).catch(async function* ($, e, next) {
268 return yield* next(e)
269 })
270
271 // Each tool call: counted and shown as its loop's current tool while it runs.
272 on('tool.call', async ($, e, next) => {
273 const { agentId, tool, tool_use_id: toolUseId } = e
274 await record(async () => {
275 const at = await $.clock.now()
276 await changeLoops($, current => startTool(current, { agentId, tool, toolUseId }, at))
277 })
278 const ran = await next(e)
279 await record(() => changeLoops($, current => endTool(current, agentId, toolUseId)))
280 return ran
281 }).catch(($, e, next) => next(e))
282
283 // A loop's turn ended: its status and the turn's duration.
284 on('turn.complete', async ($, e, next) => {
285 const done = await next(e)
286 await record(async () => {
287 const at = await $.clock.now()
288 await changeLoops($, current => endLoop(current, e.agentId, e.reason, e.durationMs, at))
289 })
290 return done
291 }).catch(($, e, next) => next(e))
292
293 on('prompt.submit', ($, e, next) => {
294 ensurePolling($)
295 return next(e)
296 }).catch(($, e, next) => next(e))
297
298 on('session.end', async ($, e, next) => {
299 if (e.reason === 'clear') {
300 // A /clear starts the counting over; the timers are kept for it.
301 await update($, loops, () => [])
302 await update($, phases, () => ({ dir: null, byStep: {} }))
303 await update($, verdict, () => null)
304 await update($, cost, () => null)
305 } else {
306 // No session.start follows a /clear: the timer is kept for it.
307 timer?.cancel()
308 timer = undefined
309 fastTimer?.cancel()
310 fastTimer = undefined
311 }
312 return next(e)
313 })
314
315 // The pane: reads state only, every line within the body's columns.
316 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
317 const { Box, Text } = $.ui.resolve(e)
318 const lines = layoutPane(
319 {
320 run: await read($, run),
321 loops: await read($, loops),
322 phases: await read($, phases),
323 now: await read($, now),
324 cost: await read($, cost),
325 verdict: await read($, verdict),
326 },
327 e.props.bodyColumns,
328 )
329 return (
330 <Box flexDirection="column">
331 {lines.map(line => (
332 <Box flexDirection="row">
333 {line.map(seg => (
334 <Text {...segProps(seg)} wrap="truncate-end">
335 {seg.text}
336 </Text>
337 ))}
338 </Box>
339 ))}
340 </Box>
341 )
342 })
343
344 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
345 const current = await read($, run)
346 if (current === null || e.props.hasSurvey) return next(e)
347
348 const { Box, Text } = $.ui.resolve(e)
349 const lines = layoutBand(current, e.props.bodyColumns, e.props.maxRows)
350 // The later mods' band, kept under ours (a tree replaces it otherwise).
351 const theirs = await next(e)
352 return (
353 <Box flexDirection="column">
354 {lines.map(line => (
355 <Box flexDirection="row">
356 {line.map(seg => (
357 <Text {...segProps(seg)} wrap="truncate-end">
358 {seg.text}
359 </Text>
360 ))}
361 </Box>
362 ))}
363 {theirs}
364 </Box>
365 )
366 })
367}
368hooks/band.ts 297 lines1// Pure layout of the band: at most two lines of styled segments, each line
2// fitting `cols` cells. Line 1 is the head (`APEX · <title>`) and the step
3// bar; mode, branch and baseline follow it when they fit, else go to line 2.
4// Widths are counted in terminal cells by an approximation (cellWidth): CJK,
5// pictographs and BMP default-emoji symbols (⌚ ✅ ⭐) count 2, joiners, VS16
6// and combining accents 0, the rest 1. Still an approximation: a text symbol
7// promoted to emoji by VS16 (☀️) counts 1, and a grapheme the table
8// misjudges (rare scripts, flag pairs) may be off.
9
10import type { ThemeKey } from 'claude-code'
11
12import type { ApexBandRun, ApexBandStep, ApexBandStepKind } from '../types'
13
14// One run of text drawn with one style; index.tsx maps it to a Text.
15export type Seg = { text: string; tone?: ThemeKey; dim?: boolean; bold?: boolean }
16
17const SEP = ' · '
18const HEAD = `APEX${SEP}`
19// A truncated title keeps at least this many cells before the bar shrinks.
20const MIN_TITLE = 8
21
22// Code points of `text`, so a surrogate pair is never split.
23const points = (text: string): string[] => Array.from(text)
24
25// Code point ranges drawn two cells wide: Hangul Jamo, CJK and Yi, Hangul
26// syllables, CJK compatibility, vertical and full-width forms, BMP symbols
27// with default emoji presentation (Emoji_Presentation=Yes), the emoji of
28// the 1F000–1F2FF blocks, emoji and pictographs, CJK extensions B and beyond.
29// The band's own glyphs (✓ ✗ ● ○ ■ – › · █ ░ …) stay outside, one cell.
30const WIDE: readonly (readonly [number, number])[] = [
31 [0x1100, 0x115f],
32 [0x231a, 0x231b],
33 [0x23e9, 0x23ec],
34 [0x23f0, 0x23f0],
35 [0x23f3, 0x23f3],
36 [0x25fd, 0x25fe],
37 [0x2614, 0x2615],
38 [0x2648, 0x2653],
39 [0x267f, 0x267f],
40 [0x2693, 0x2693],
41 [0x26a1, 0x26a1],
42 [0x26aa, 0x26ab],
43 [0x26bd, 0x26be],
44 [0x26c4, 0x26c5],
45 [0x26ce, 0x26ce],
46 [0x26d4, 0x26d4],
47 [0x26ea, 0x26ea],
48 [0x26f2, 0x26f3],
49 [0x26f5, 0x26f5],
50 [0x26fa, 0x26fa],
51 [0x26fd, 0x26fd],
52 [0x2705, 0x2705],
53 [0x270a, 0x270b],
54 [0x2728, 0x2728],
55 [0x274c, 0x274c],
56 [0x274e, 0x274e],
57 [0x2753, 0x2755],
58 [0x2757, 0x2757],
59 [0x2795, 0x2797],
60 [0x27b0, 0x27b0],
61 [0x27bf, 0x27bf],
62 [0x2b1b, 0x2b1c],
63 [0x2b50, 0x2b50],
64 [0x2b55, 0x2b55],
65 [0x2e80, 0xa4cf],
66 [0xac00, 0xd7a3],
67 [0xf900, 0xfaff],
68 [0xfe30, 0xfe4f],
69 [0xff00, 0xff60],
70 [0xffe0, 0xffe6],
71 [0x1f004, 0x1f004],
72 [0x1f0cf, 0x1f0cf],
73 [0x1f18e, 0x1f18e],
74 [0x1f191, 0x1f19a],
75 [0x1f200, 0x1f2ff],
76 [0x1f300, 0x1faff],
77 [0x20000, 0x3fffd],
78]
79
80// The cells a code point takes (approximation): 0 for the zero-width
81// joiner, VS16 and combining accents, 2 for the WIDE ranges, else 1.
82export function cellWidth(cp: number): number {
83 if (cp === 0x200d || cp === 0xfe0f || (cp >= 0x300 && cp <= 0x36f)) return 0
84 return WIDE.some(([lo, hi]) => cp >= lo && cp <= hi) ? 2 : 1
85}
86
87const cpCells = (cp: string): number => cellWidth(cp.codePointAt(0) ?? 0)
88
89// Cells of a text.
90export const textCells = (text: string): number => points(text).reduce((n, cp) => n + cpCells(cp), 0)
91
92// The longest head of `text` within `room` cells, whole code points only (a
93// wide glyph that does not fit is dropped, never halved), and its cells.
94function fit(text: string, room: number): { text: string; cells: number; whole: boolean } {
95 const cps = points(text)
96 let cells = 0
97 let n = 0
98 for (const cp of cps) {
99 const w = cpCells(cp)
100 if (cells + w > room) break
101 cells += w
102 n += 1
103 }
104 return { text: cps.slice(0, n).join(''), cells, whole: n === cps.length }
105}
106
107// Cuts `text` to `width` cells, the last one an ellipsis when cut.
108export function truncate(text: string, width: number): string {
109 if (width <= 0) return ''
110 if (textCells(text) <= width) return text
111 return width === 1 ? '…' : `${fit(text, width - 1).text}…`
112}
113
114// skipped → ignorée, a red word → rouge, a green word → vert, else the raw
115// text cut to 30 characters.
116export function baselineWord(baseline: string): string {
117 const s = baseline.toLowerCase()
118 if (/skip|ignor/.test(s)) return 'ignorée'
119 if (/\bred\b|rouge|fail|échec|✘|❌/.test(s)) return 'rouge'
120 if (/green|vert|✅|\bpass/.test(s)) return 'vert'
121 return truncate(baseline, 30)
122}
123
124// A step's short label: the `NN-` / `NNx-` prefix stripped, execute → exec,
125// validate → valid.
126export function stepLabel(step: string): string {
127 const bare = step.replace(/^\d+[a-z]?-/i, '')
128 if (bare === 'execute') return 'exec'
129 if (bare === 'validate') return 'valid'
130 return bare
131}
132
133// The glyph of a step kind and its style: a theme tone, or dim.
134export function stepMark(kind: ApexBandStepKind): Seg {
135 if (kind === 'done') return { text: '✓', tone: 'success' }
136 if (kind === 'running') return { text: '●', tone: 'warning' }
137 if (kind === 'failed') return { text: '✗', tone: 'error' }
138 if (kind === 'pending') return { text: '○', dim: true }
139 if (kind === 'skipped') return { text: '–', dim: true }
140 return { text: '·', dim: true }
141}
142
143// The style of a baseline word: vert green, rouge red, anything else dim.
144function baselineStyle(word: string): Omit<Seg, 'text'> {
145 if (word === 'vert') return { tone: 'success' }
146 if (word === 'rouge') return { tone: 'error' }
147 return { dim: true }
148}
149
150// Total cells of a line.
151export const width = (line: readonly Seg[]): number => line.reduce((n, seg) => n + textCells(seg.text), 0)
152
153// Non-empty groups joined by a dim ` · `.
154export function join(groups: readonly (readonly Seg[])[]): Seg[] {
155 const out: Seg[] = []
156 for (const group of groups) {
157 if (group.length === 0) continue
158 if (out.length > 0) out.push({ text: SEP, dim: true })
159 out.push(...group)
160 }
161 return out
162}
163
164// A line cut to `cols` cells, its last cell an ellipsis when cut.
165export function hardCut(line: readonly Seg[], cols: number): Seg[] {
166 if (width(line) <= cols) return [...line]
167 const out: Seg[] = []
168 let room = cols - 1
169 for (const seg of line) {
170 if (room <= 0) break
171 const head = fit(seg.text, room)
172 out.push({ ...seg, text: head.text })
173 room -= head.cells
174 // A cut segment ends the line: nothing narrower slips in after it.
175 if (!head.whole) break
176 }
177 const last = out[out.length - 1]
178 if (last === undefined) return [{ text: '…', dim: true }]
179 out[out.length - 1] = { ...last, text: `${last.text}…` }
180 return out
181}
182
183// One chip: the glyph, then the label (bold while running, plain when
184// failed, dim otherwise) unless `withLabel` is false.
185function chip(step: ApexBandStep, withLabel: boolean): Seg[] {
186 const mark = stepMark(step.kind)
187 if (!withLabel) return [mark]
188 const text = ` ${stepLabel(step.step)}`
189 if (step.kind === 'running') return [mark, { text, bold: true }]
190 if (step.kind === 'failed') return [mark, { text }]
191 return [mark, { text, dim: true }]
192}
193
194// The step bar at a degradation level: 0 every label, 1 done chips
195// glyph-only, 2 only running/failed chips keep a label, 3 as 2 with the
196// ` › ` separators turned to single spaces.
197function bar(steps: readonly ApexBandStep[], level: 0 | 1 | 2 | 3): Seg[] {
198 const out: Seg[] = []
199 steps.forEach((step, i) => {
200 if (i > 0) out.push({ text: level >= 3 ? ' ' : ' › ', dim: true })
201 const live = step.kind === 'running' || step.kind === 'failed'
202 const withLabel = level === 0 || live || (level === 1 && step.kind !== 'done')
203 out.push(...chip(step, withLabel))
204 })
205 return out
206}
207
208// The compact bar: one chip with its dim `k/n` position, the running step
209// (else the last failed one, else the next pending one); a ✓ and `n/n`
210// when none of these is left.
211function compactBar(steps: readonly ApexBandStep[]): Seg[] {
212 let at = steps.findIndex(s => s.kind === 'running')
213 if (at < 0) {
214 for (let i = 0; i < steps.length; i++) if (steps[i]?.kind === 'failed') at = i
215 }
216 if (at < 0) at = steps.findIndex(s => s.kind === 'pending')
217 const step = steps[at]
218 const n = steps.length
219 if (step === undefined) return [stepMark('done'), { text: ` ${n}/${n}`, dim: true }]
220 return [...chip(step, true), { text: ` ${at + 1}/${n}`, dim: true }]
221}
222
223// The head, one dim segment so it reads as a single Text.
224const head = (title: string): Seg[] => [{ text: `${HEAD}${title}`, dim: true }]
225
226// Head and bar within `cols`: labels dropped (done, then the rest), then
227// separators to spaces, then the title truncated (≥ MIN_TITLE cells), then
228// the compact bar when narrower than the spaced bar, then a hard cut of the
229// narrowest of the two.
230function fitBar(run: ApexBandRun, cols: number): Seg[] {
231 const { steps, title } = run
232 const bars = steps.length === 0 ? [[]] : [bar(steps, 0), bar(steps, 1), bar(steps, 2), bar(steps, 3)]
233 for (const b of bars) {
234 const line = join([head(title), b])
235 if (width(line) <= cols) return line
236 }
237 const minTitle = Math.min(MIN_TITLE, textCells(title))
238 const last: Seg[][] = steps.length === 0 ? [[]] : [bar(steps, 3)]
239 if (steps.length > 0) {
240 // With few steps the compact bar can be wider than the spaced one.
241 const compact = compactBar(steps)
242 if (width(compact) < width(bar(steps, 3))) last.push(compact)
243 }
244 for (const b of last) {
245 const room = cols - width(join([head(''), b]))
246 if (room >= minTitle) return join([head(truncate(title, room)), b])
247 }
248 const narrowest = last[last.length - 1] ?? []
249 return hardCut(join([head(truncate(title, minTitle)), narrowest]), cols)
250}
251
252type MetaKey = 'mode' | 'branch' | 'baseline'
253type Meta = { key: MetaKey; segs: Seg[] }
254
255// Mode, branch and `baseline <word>`, the fields the run has, in that order.
256export function meta(run: ApexBandRun): Meta[] {
257 const items: Meta[] = []
258 if (run.mode !== undefined && run.mode !== '') items.push({ key: 'mode', segs: [{ text: run.mode, dim: true }] })
259 if (run.branch !== undefined && run.branch !== '')
260 items.push({ key: 'branch', segs: [{ text: run.branch, dim: true }] })
261 if (run.baseline !== undefined) {
262 const word = baselineWord(run.baseline)
263 items.push({ key: 'baseline', segs: [{ text: 'baseline ', dim: true }, { text: word, ...baselineStyle(word) }] })
264 }
265 return items
266}
267
268// Line 2 within `cols`: branch dropped, then mode, then a hard cut.
269function fitMeta(items: readonly Meta[], cols: number): Seg[] {
270 let kept = [...items]
271 for (const drop of ['branch', 'mode'] as const) {
272 const line = join(kept.map(m => m.segs))
273 if (width(line) <= cols) return line
274 if (kept.length > 1) kept = kept.filter(m => m.key !== drop)
275 }
276 return hardCut(join(kept.map(m => m.segs)), cols)
277}
278
279// The band as at most min(2, maxRows) lines (at least one), each fitting
280// max(1, cols) cells. One line when all fits; else meta on line 2 when two
281// rows are free; else one line dropping branch, mode, baseline before the
282// bar degrades.
283export function layoutBand(run: ApexBandRun, cols: number, maxRows: number): Seg[][] {
284 const w = Math.max(1, cols)
285 const full = run.steps.length === 0 ? [] : bar(run.steps, 0)
286 let items = meta(run)
287 const whole = join([head(run.title), full, ...items.map(m => m.segs)])
288 if (width(whole) <= w) return [whole]
289 if (maxRows >= 2 && items.length > 0) return [fitBar(run, w), fitMeta(items, w)]
290 for (const drop of ['branch', 'mode', 'baseline'] as const) {
291 items = items.filter(m => m.key !== drop)
292 const line = join([head(run.title), full, ...items.map(m => m.segs)])
293 if (width(line) <= w) return [line]
294 }
295 return [fitBar(run, w)]
296}
297hooks/context.ts 123 lines1// Pure parsing of an APEX run's 00-context.md and its liveness rule.
2// The header format drifted over time (Branch/Branche, Flags/Flags résolus,
3// bold or wrapped Mode, no Mode, Baseline skipped, no Progress table, many
4// status spellings): every reader here tolerates all of them.
5
6import type { ApexBandRun, ApexBandStep, ApexBandStepKind } from '../types'
7
8export type Run = ApexBandRun
9export type Step = ApexBandStep
10export type StepKind = ApexBandStepKind
11
12// A run is live only while its context file moved in the last 6 hours.
13export const STALE_MS = 6 * 60 * 60 * 1000
14
15const clean = (value: string): string => value.replace(/\*\*/g, '').replace(/`/g, '').trim()
16
17export function statusKind(status: string): StepKind {
18 const s = clean(status).toLowerCase()
19 if (/^(complete|done|✅)/.test(s)) return 'done'
20 if (/^pending/.test(s)) return 'pending'
21 if (/^(in[ _]progress|en cours|running)/.test(s)) return 'running'
22 if (/^(skipped|supprimé|n\/a)/.test(s)) return 'skipped'
23 if (/^(red|failed|échoué)(?![a-z])/.test(s)) return 'failed'
24 return 'other'
25}
26
27// "Key: value", "**Key:** value", "**Key**: value", "- Key: value".
28const FIELD = /^\s*(?:[-*]\s+)?\**\s*([A-Za-zÀ-ÿ][A-Za-zÀ-ÿ ]*?)\s*\**\s*:\s*(.*)$/
29
30function cutMode(value: string): string {
31 const head = value.split(/\s+—\s+|\s+→\s+|\s*\(|;|\.\s/)[0] ?? value
32 return head.trim()
33}
34
35function readSteps(lines: readonly string[]): Step[] {
36 const start = lines.findIndex(l => /^##\s+progress\b/i.test(l))
37 if (start < 0) return []
38 const steps: Step[] = []
39 for (const line of lines.slice(start + 1)) {
40 if (/^##\s/.test(line)) break
41 if (!line.trim().startsWith('|')) continue
42 if (/^[\s|:-]+$/.test(line)) continue
43 const cells = line.split('|').slice(1, -1).map(clean)
44 const step = cells[0] ?? ''
45 const status = cells[1] ?? ''
46 if (step === '' || /^step$/i.test(step)) continue
47 steps.push({ step, status, kind: statusKind(status) })
48 }
49 return steps
50}
51
52export function parseContext(text: string, dirName: string): Run {
53 const lines = text.replace(/\r\n?/g, '\n').split('\n')
54 const headerEnd = lines.findIndex(l => /^##\s/.test(l))
55 const header = headerEnd < 0 ? lines : lines.slice(0, headerEnd)
56
57 const heading = header.find(l => /^#\s+\S/.test(l))
58 const headingText = heading === undefined ? undefined : clean(heading.replace(/^#\s+/, ''))
59 const apexTitle = headingText?.match(/^APEX\s*[:—–-]\s*(.+)$/i)?.[1]
60 const title = clean(apexTitle ?? headingText ?? '') || dirName
61
62 const fields = new Map<string, string>()
63 for (const line of header) {
64 const match = FIELD.exec(line)
65 const key = match?.[1]?.trim().toLowerCase()
66 const value = match?.[2]
67 if (key === undefined || value === undefined || fields.has(key)) continue
68 fields.set(key, clean(value))
69 }
70
71 const flags = fields.get('flags') ?? fields.get('flags résolus')
72 const modeField = fields.get('mode')
73 const modeFromFlags = flags?.match(/\bmode\s+([^\s,;)]+)/i)?.[1]
74 const mode = modeField !== undefined && modeField !== '' ? cutMode(modeField) : modeFromFlags
75
76 const branchField = fields.get('branch') ?? fields.get('branche')
77 const branch = branchField?.split(/\s+/)[0]
78
79 const baselineField = fields.get('baseline')
80 const skipped = fields.get('baseline skipped')
81 const baseline =
82 baselineField !== undefined && baselineField !== ''
83 ? baselineField
84 : skipped === undefined
85 ? undefined
86 : `skipped${skipped === '' ? '' : ` — ${skipped}`}`
87
88 const run: Run = { title, steps: readSteps(lines) }
89 if (mode !== undefined && mode !== '') run.mode = mode
90 if (branch !== undefined && branch !== '') run.branch = branch
91 if (baseline !== undefined) run.baseline = baseline
92 const current = currentStep(run.steps)
93 if (current !== undefined) run.currentStep = current
94 return run
95}
96
97export function currentStep(steps: readonly Step[]): string | undefined {
98 return (steps.find(s => s.kind === 'running') ?? steps.find(s => s.kind === 'pending'))?.step
99}
100
101// Live: modified in the last STALE_MS, its 09-finish row neither done nor
102// skipped, and, when it has a Progress table, a pending or running row left.
103export function isLive(run: Run, mtimeMs: number, now: number): boolean {
104 if (now - mtimeMs >= STALE_MS) return false
105 const finish = run.steps.find(s => /finish/i.test(s.step))
106 if (finish !== undefined && (finish.kind === 'done' || finish.kind === 'skipped')) return false
107 if (run.steps.length === 0) return true
108 return run.steps.some(s => s.kind === 'pending' || s.kind === 'running')
109}
110
111// The branch named by a .git/HEAD file ("ref: refs/heads/<name>"), or
112// undefined for a detached HEAD (a bare sha) or anything unrecognised.
113export function headBranch(text: string): string | undefined {
114 const name = /^ref:\s*refs\/heads\/(\S+)\s*$/.exec(text.trim())?.[1]
115 return name === undefined || name === '' ? undefined : name
116}
117
118// Off-branch only when both the run's branch and HEAD's are known and
119// differ: a run without Branch, or a detached/unreadable HEAD, stays shown.
120export function onBranch(run: Run, head: string | undefined): boolean {
121 return run.branch === undefined || head === undefined || run.branch === head
122}
123hooks/pane.ts 202 lines1// Pure layout of the /apex-pane pane: the run's header, its phases with
2// approximate token counts, one three-line card per subagent, the totals and
3// the external verdict once there is one. Every line fits `cols` cells (hard
4// cut otherwise); below 40 columns the share bar and the card's type and
5// model are dropped.
6
7import type { ThemeKey } from 'claude-code'
8
9import type { ApexBandLoop, ApexBandLoopStatus, ApexBandPhases, ApexBandRun, ApexBandVerdict } from '../types'
10import { hardCut, join, meta, stepLabel, stepMark, width } from './band.ts'
11import type { Seg } from './band.ts'
12import { MAIN, countedTotal, loopDuration, splitTotals } from './stats.ts'
13
14export type PaneInput = {
15 run: ApexBandRun | null
16 loops: readonly ApexBandLoop[]
17 phases: ApexBandPhases
18 now: number
19 cost: number | null
20 verdict: ApexBandVerdict | null
21}
22
23// Columns under which the bar and the card's type and model are dropped.
24export const NARROW = 40
25// Finished cards shown before the « +N terminés » line.
26export const MAX_DONE = 6
27const BAR_CELLS = 10
28const TOKEN_CELLS = 6
29const MAX_LABEL = 12
30
31const pad2 = (n: number): string => String(n).padStart(2, '0')
32
33// A duration as 45s, 3m 07s or 1h 05m.
34export function formatDuration(ms: number): string {
35 const seconds = Math.floor(Math.max(0, ms) / 1000)
36 if (seconds < 60) return `${seconds}s`
37 const minutes = Math.floor(seconds / 60)
38 if (minutes < 60) return `${minutes}m ${pad2(seconds % 60)}s`
39 return `${Math.floor(minutes / 60)}h ${pad2(minutes % 60)}m`
40}
41
42// A token count as 950, 1.2k, 45k or 1.3M.
43export function fmtTokens(n: number): string {
44 const v = Math.max(0, Math.round(n))
45 if (v < 1000) return String(v)
46 // The unit is chosen after rounding: 9 999 → 10k, 999 999 → 1.0M.
47 const tenths = (v / 1000).toFixed(1)
48 if (Number(tenths) < 10) return `${tenths}k`
49 const thousands = Math.round(v / 1000)
50 if (thousands < 1000) return `${thousands}k`
51 return `${(v / 1_000_000).toFixed(1)}M`
52}
53
54const plural = (n: number, word: string): string => `${n} ${word}${n > 1 ? 's' : ''}`
55
56const SEP: Seg = { text: ' · ', dim: true }
57const BLANK: Seg[] = [{ text: ' ' }]
58
59// The glyph of a loop status and its tone.
60export function loopMark(status: ApexBandLoopStatus): Seg {
61 if (status === 'running') return { text: '●', tone: 'warning' }
62 if (status === 'done') return { text: '✓', tone: 'success' }
63 if (status === 'failed') return { text: '✗', tone: 'error' }
64 return { text: '■', tone: 'inactive' }
65}
66
67// The glyph and tone of an external verdict word.
68function verdictMark(verdict: string): { glyph: string; tone: ThemeKey } {
69 if (verdict === 'PASS') return { glyph: '✓', tone: 'success' }
70 if (verdict === 'FAIL' || verdict === 'ERROR') return { glyph: '✗', tone: 'error' }
71 if (verdict === 'BLOCKED') return { glyph: '■', tone: 'warning' }
72 return { glyph: '·', tone: 'inactive' }
73}
74
75// A share of BAR_CELLS cells in the brand tone, at least one cell; the rest
76// of the row is left empty (no track drawn).
77function shareBar(share: number): Seg[] {
78 const filled = Math.max(1, Math.min(BAR_CELLS, Math.round(share * BAR_CELLS)))
79 return [{ text: '━'.repeat(filled), tone: 'claude' }]
80}
81
82// The « Phases ≈ » block: one row per step, its counted tokens (cache reads
83// excluded; dim — when none) and, when wide enough and the step has
84// tokens, its share of the run's counted tokens.
85function phaseRows(run: ApexBandRun, phases: ApexBandPhases, cols: number): Seg[][] {
86 if (run.steps.length === 0) return []
87 const byStep = phases.dir !== null && phases.dir === run.dir ? phases.byStep : {}
88 const totals = run.steps.map(s => {
89 const tally = byStep[s.step]
90 return tally === undefined ? 0 : countedTotal(tally)
91 })
92 const sum = totals.reduce((a, b) => a + b, 0)
93 const labelCells = Math.min(MAX_LABEL, Math.max(...run.steps.map(s => Array.from(stepLabel(s.step)).length)))
94 const rows: Seg[][] = [[{ text: 'Phases', bold: true }, { text: ' ≈ tokens', dim: true }]]
95 run.steps.forEach((step, i) => {
96 const n = totals[i] ?? 0
97 const label = Array.from(stepLabel(step.step)).slice(0, labelCells).join('').padEnd(labelCells)
98 const row: Seg[] = [stepMark(step.kind), { text: ` ${label} `, ...(step.kind === 'running' ? { bold: true } : {}) }]
99 row.push(n > 0 ? { text: fmtTokens(n).padStart(TOKEN_CELLS) } : { text: '—'.padStart(TOKEN_CELLS), dim: true })
100 if (cols >= NARROW && n > 0) row.push({ text: ' ' }, ...shareBar(n / sum))
101 rows.push(row)
102 })
103 return rows
104}
105
106// A card's detail lines. From NARROW: line A model · duration · in X · out Y
107// (in = input + cache writes), line B type · tool · N appels · cache X (when
108// any cache read); below NARROW one line tool · duration.
109function cardDetail(loop: ApexBandLoop, now: number, cols: number): string[] {
110 const dur = formatDuration(loopDuration(loop, now))
111 const parts = (list: readonly (string | undefined)[]): string =>
112 list.filter((p): p is string => p !== undefined && p !== '').join(' · ')
113 if (cols < NARROW) return [parts([loop.tool, dur])]
114 const { input, output, cacheRead, cacheWrite } = loop.tally
115 return [
116 parts([loop.model, dur, `in ${fmtTokens(input + cacheWrite)}`, `out ${fmtTokens(output)}`]),
117 parts([loop.type, loop.tool, plural(loop.calls, 'appel'), cacheRead > 0 ? `cache ${fmtTokens(cacheRead)}` : undefined]),
118 ]
119}
120
121// One subagent's card: glyph and label (bold while running), then its dim
122// detail lines; a finished card is dim throughout.
123function card(loop: ApexBandLoop, now: number, cols: number): Seg[][] {
124 const running = loop.status === 'running'
125 const label = loop.label ?? loop.type ?? loop.id
126 const head: Seg[] = [loopMark(loop.status), running ? { text: ` ${label}`, bold: true } : { text: ` ${label}`, dim: true }]
127 return [head, ...cardDetail(loop, now, cols).map(detail => [{ text: ` ${detail}`, dim: true }])]
128}
129
130// The « Sous-agents » block: running cards (oldest first), then finished
131// ones (most recent first), at most MAX_DONE of them, the rest counted.
132function cards(loops: readonly ApexBandLoop[], now: number, cols: number): Seg[][] {
133 const subs = loops.filter(l => l.id !== MAIN)
134 const rows: Seg[][] = [[{ text: 'Sous-agents', bold: true }]]
135 if (subs.length === 0) return [...rows, [{ text: 'aucun pour l’instant', dim: true }]]
136 const running = subs.filter(l => l.status === 'running').sort((a, b) => a.startedAt - b.startedAt)
137 const done = subs
138 .filter(l => l.status !== 'running')
139 .sort((a, b) => (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt))
140 for (const loop of running) rows.push(...card(loop, now, cols))
141 for (const loop of done.slice(0, MAX_DONE)) rows.push(...card(loop, now, cols))
142 if (done.length > MAX_DONE) rows.push([{ text: `+${done.length - MAX_DONE} terminés`, dim: true }])
143 return rows
144}
145
146// The « Totaux » block: main loop vs subagents (counted tokens, cache reads
147// excluded), the cache reads apart (dim, when any), then the session's cost;
148// groups that do not fit `cols` wrap onto a next line.
149function totals(loops: readonly ApexBandLoop[], cost: number | null, cols: number): Seg[][] {
150 const { main, sub } = splitTotals(loops)
151 const cache = main.cacheRead + sub.cacheRead
152 const groups: Seg[][] = [
153 [{ text: 'principal ', dim: true }, { text: fmtTokens(countedTotal(main)) }],
154 [{ text: 'sous-agents ', dim: true }, { text: fmtTokens(countedTotal(sub)) }],
155 ]
156 if (cache > 0) groups.push([{ text: `cache ${fmtTokens(cache)}`, dim: true }])
157 if (cost !== null) groups.push([{ text: `$${cost.toFixed(2)}` }])
158 const lines: Seg[][] = [[]]
159 for (const group of groups) {
160 const last = lines[lines.length - 1] ?? []
161 const joined = join([last, group])
162 if (last.length === 0 || width(joined) <= cols) lines[lines.length - 1] = joined
163 else lines.push([...group])
164 }
165 return [[{ text: 'Totaux', bold: true }], ...lines]
166}
167
168// The « Vérif externe » block: the verdict toned and its findings count;
169// no block at all until the run has one.
170function verdictRows(verdict: ApexBandVerdict | null, run: ApexBandRun): Seg[][] {
171 if (verdict === null || verdict.dir !== run.dir) return []
172 const { glyph, tone } = verdictMark(verdict.verdict)
173 return [
174 [{ text: 'Vérif externe', bold: true }],
175 [{ text: `${glyph} ${verdict.verdict}`, tone }, SEP, { text: plural(verdict.findings, 'constat'), dim: true }],
176 ]
177}
178
179// The pane as lines of segments, each within max(1, cols) cells.
180export function layoutPane(input: PaneInput, cols: number): Seg[][] {
181 const w = Math.max(1, cols)
182 const { run } = input
183 if (run === null) {
184 return [
185 [{ text: 'Aucun run APEX en cours.', dim: true }],
186 [{ text: 'Lancez /apex ; le détail s’affiche ici.', dim: true }],
187 ].map(line => hardCut(line, w))
188 }
189 const metaLine = join(meta(run).map(m => m.segs))
190 const lines: Seg[][] = [
191 [{ text: `APEX · ${run.title}`, bold: true }],
192 ...(metaLine.length > 0 ? [metaLine] : []),
193 ]
194 const phases = phaseRows(run, input.phases, w)
195 if (phases.length > 0) lines.push(BLANK, ...phases)
196 lines.push(BLANK, ...cards(input.loops, input.now, w))
197 lines.push(BLANK, ...totals(input.loops, input.cost, w))
198 const verdict = verdictRows(input.verdict, run)
199 if (verdict.length > 0) lines.push(BLANK, ...verdict)
200 return lines.map(line => hardCut(line, w))
201}
202hooks/stats.ts 265 lines1// Pure bookkeeping of the pane: per-loop counters (steps, tool calls, token
2// counts, running time), per-phase token buckets and the external verdict
3// read from external-verify.json. Every function returns
4// the same reference when nothing changed, so a caller can skip the write.
5
6import type { ApexBandLoop, ApexBandLoopStatus, ApexBandPhases, ApexBandTally } from '../types'
7
8// The loop id of the main conversation (events without an agentId).
9export const MAIN = 'main'
10
11// What a step's usage carries: ModelUsage's four counts and the model.
12export type Usage = {
13 input_tokens: number
14 output_tokens: number
15 cache_read_input_tokens: number
16 cache_creation_input_tokens: number
17 model?: string
18}
19
20// One agent of `$.agent.list()`, reduced to what the pane reads.
21export type AgentSnap = { id: string; description: string; type: string; status: string }
22
23export const ZERO: ApexBandTally = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
24
25// A model id made short: `claude-` and a date or `[1m]` suffix dropped, a
26// trailing `-5-5` version read as `-5.5` (claude-opus-5-5[1m] → opus-5.5).
27export function shortModel(model: string): string {
28 const bare = model
29 .replace(/\[[^\]]*\]$/, '')
30 .replace(/^claude-/, '')
31 .replace(/-\d{8}$/, '')
32 return bare.replace(/-(\d+)-(\d+)$/, '-$1.$2')
33}
34
35// A finite non-negative count, else 0 (usage comes from the API).
36const count = (n: number): number => (Number.isFinite(n) && n > 0 ? n : 0)
37
38// A tally plus one response's usage.
39export function addUsage(tally: ApexBandTally, usage: Usage): ApexBandTally {
40 return {
41 input: tally.input + count(usage.input_tokens),
42 output: tally.output + count(usage.output_tokens),
43 cacheRead: tally.cacheRead + count(usage.cache_read_input_tokens),
44 cacheWrite: tally.cacheWrite + count(usage.cache_creation_input_tokens),
45 }
46}
47
48// Two tallies summed.
49export const sumTally = (a: ApexBandTally, b: ApexBandTally): ApexBandTally => ({
50 input: a.input + b.input,
51 output: a.output + b.output,
52 cacheRead: a.cacheRead + b.cacheRead,
53 cacheWrite: a.cacheWrite + b.cacheWrite,
54})
55
56// All four counts of a tally summed.
57export const tallyTotal = (t: ApexBandTally): number => t.input + t.output + t.cacheRead + t.cacheWrite
58
59// The counts the pane shows as a loop's or phase's work: input, output and
60// cache writes; cache reads (the context re-read on every request) are left
61// out and shown apart.
62export const countedTotal = (t: ApexBandTally): number => t.input + t.output + t.cacheWrite
63
64// A fresh loop, running from `at`.
65function freshLoop(id: string, at: number): ApexBandLoop {
66 return { id, status: 'running', steps: 0, calls: 0, tally: ZERO, startedAt: at, durationMs: 0, since: at }
67}
68
69// The list with loop `id` replaced by `fn` of it (created when missing).
70function withLoop(
71 loops: readonly ApexBandLoop[],
72 id: string,
73 at: number,
74 fn: (loop: ApexBandLoop) => ApexBandLoop,
75): ApexBandLoop[] {
76 const i = loops.findIndex(l => l.id === id)
77 const loop = loops[i]
78 if (loop === undefined) return [...loops, fn(freshLoop(id, at))]
79 const out = [...loops]
80 out[i] = fn(loop)
81 return out
82}
83
84// A loop seen at work again: running from `at` unless it already was.
85function wake(loop: ApexBandLoop, at: number): ApexBandLoop {
86 if (loop.status === 'running') return loop
87 const { endedAt: _ended, endedBy: _by, ...rest } = loop
88 return { ...rest, status: 'running', since: at }
89}
90
91// One model response of loop `agentId` (main when absent): a step more,
92// its model and usage counted.
93export function addStep(
94 loops: readonly ApexBandLoop[],
95 step: { agentId?: string; model: string; usage: Usage | null },
96 at: number,
97): ApexBandLoop[] {
98 return withLoop(loops, step.agentId ?? MAIN, at, loop => {
99 const woken = wake(loop, at)
100 const model = shortModel(step.usage?.model ?? step.model)
101 return {
102 ...woken,
103 steps: woken.steps + 1,
104 ...(model === '' ? {} : { model }),
105 tally: step.usage === null ? woken.tally : addUsage(woken.tally, step.usage),
106 }
107 })
108}
109
110// A tool call starting in loop `agentId`: a call more, shown as current.
111export function startTool(
112 loops: readonly ApexBandLoop[],
113 call: { agentId?: string; tool: string; toolUseId?: string },
114 at: number,
115): ApexBandLoop[] {
116 return withLoop(loops, call.agentId ?? MAIN, at, loop => {
117 const { toolUseId: _old, ...woken } = wake(loop, at)
118 return {
119 ...woken,
120 calls: woken.calls + 1,
121 tool: call.tool,
122 ...(call.toolUseId === undefined ? {} : { toolUseId: call.toolUseId }),
123 }
124 })
125}
126
127// A tool call ended: the current tool cleared when it is still that call.
128export function endTool(loops: ApexBandLoop[], agentId: string | undefined, toolUseId: string | undefined): ApexBandLoop[] {
129 const id = agentId ?? MAIN
130 const i = loops.findIndex(l => l.id === id)
131 const loop = loops[i]
132 if (loop === undefined || loop.tool === undefined || loop.toolUseId !== toolUseId) return loops
133 const { tool: _tool, toolUseId: _use, ...rest } = loop
134 const out = [...loops]
135 out[i] = rest
136 return out
137}
138
139// A loop's turn ended (turn.complete reason, or an agent's last status):
140// its status set, the turn's time added, its current tool cleared.
141function finish(loop: ApexBandLoop, status: ApexBandLoopStatus, spentMs: number, at: number): ApexBandLoop {
142 const { tool: _tool, toolUseId: _use, since: _since, ...rest } = loop
143 return { ...rest, status, durationMs: loop.durationMs + Math.max(0, spentMs), endedAt: at }
144}
145
146// The status a turn.complete reason gives.
147export function reasonStatus(reason: string): ApexBandLoopStatus {
148 if (reason === 'answer') return 'done'
149 if (reason === 'aborted') return 'stopped'
150 return 'failed'
151}
152
153// Loop `agentId`'s turn completed after `durationMs`. A running loop ends;
154// a loop the agent snapshot ended first is reconciled once (the turn's
155// duration and reason replace the snapshot's estimate); an unknown loop or
156// a loop already ended by its turn is left alone.
157export function endLoop(
158 loops: ApexBandLoop[],
159 agentId: string | undefined,
160 reason: string,
161 durationMs: number,
162 at: number,
163): ApexBandLoop[] {
164 const id = agentId ?? MAIN
165 const i = loops.findIndex(l => l.id === id)
166 const loop = loops[i]
167 if (loop === undefined) return loops
168 const given = Number.isFinite(durationMs) && durationMs > 0
169 const out = [...loops]
170 if (loop.status === 'running') {
171 const spent = given ? durationMs : at - (loop.since ?? at)
172 out[i] = { ...finish(loop, reasonStatus(reason), spent, at), endedBy: 'turn' }
173 return out
174 }
175 if (loop.endedBy !== 'snapshot') return loops
176 // The snapshot added endedAt - since; the turn's own duration replaces it.
177 const ended = loop.endedAt ?? at
178 const estimate = Math.max(0, ended - (loop.since ?? ended))
179 const { since: _since, ...rest } = loop
180 const base = loop.durationMs - estimate
181 out[i] = { ...rest, status: reasonStatus(reason), durationMs: base + (given ? durationMs : estimate), endedBy: 'turn' }
182 return out
183}
184
185// The status an agent's terminal status gives; undefined while it works.
186function snapStatus(status: string): ApexBandLoopStatus | undefined {
187 if (status === 'completed') return 'done'
188 if (status === 'failed') return 'failed'
189 if (status === 'killed') return 'stopped'
190 return undefined
191}
192
193// The agent list applied to the known loops: description and type filled
194// in, a running loop whose agent ended is ended. Never reopens a loop.
195export function applySnapshot(loops: ApexBandLoop[], agents: readonly AgentSnap[], at: number): ApexBandLoop[] {
196 let out: ApexBandLoop[] | undefined
197 loops.forEach((loop, i) => {
198 const agent = agents.find(a => a.id === loop.id)
199 if (agent === undefined) return
200 let next = loop
201 if (next.label === undefined && agent.description !== '') next = { ...next, label: agent.description }
202 if (next.type === undefined && agent.type !== '') next = { ...next, type: agent.type }
203 const ended = snapStatus(agent.status)
204 if (ended !== undefined && next.status === 'running') {
205 // Kept: `since` lets the turn's own end replace this estimate (endLoop).
206 const since = next.since
207 next = { ...finish(next, ended, at - (since ?? at), at), ...(since === undefined ? {} : { since }), endedBy: 'snapshot' }
208 }
209 if (next === loop) return
210 out ??= [...loops]
211 out[i] = next
212 })
213 return out ?? loops
214}
215
216// The buckets kept for run `dir` (null: no live run), started over when the
217// polled run's directory is another one; the same reference when equal.
218export function syncPhases(phases: ApexBandPhases, dir: string | null): ApexBandPhases {
219 return phases.dir === dir ? phases : { dir, byStep: {} }
220}
221
222// One response's usage added to `step`'s bucket of run `dir`; the buckets
223// start over when the run's directory changed.
224export function addPhase(phases: ApexBandPhases, dir: string, step: string | undefined, usage: Usage | null): ApexBandPhases {
225 const base = phases.dir === dir ? phases : { dir, byStep: {} }
226 if (step === undefined || usage === null) return base
227 return { dir, byStep: { ...base.byStep, [step]: addUsage(base.byStep[step] ?? ZERO, usage) } }
228}
229
230// The main loop's tally and the subagents' summed.
231export function splitTotals(loops: readonly ApexBandLoop[]): { main: ApexBandTally; sub: ApexBandTally } {
232 let main = ZERO
233 let sub = ZERO
234 for (const loop of loops) {
235 if (loop.id === MAIN) main = sumTally(main, loop.tally)
236 else sub = sumTally(sub, loop.tally)
237 }
238 return { main, sub }
239}
240
241// A loop's running time at `now`: its finished turns plus the live one.
242export function loopDuration(loop: ApexBandLoop, now: number): number {
243 const live = loop.status === 'running' && loop.since !== undefined ? Math.max(0, now - loop.since) : 0
244 return loop.durationMs + live
245}
246
247const VERDICTS = new Set(['PASS', 'FAIL', 'BLOCKED', 'ERROR'])
248
249// The verdict word and findings count of an external-verify.json text, or
250// null when it is not one (bad JSON, unknown verdict, findings not a list).
251export function parseVerdict(text: string): { verdict: string; findings: number } | null {
252 let data: unknown
253 try {
254 data = JSON.parse(text)
255 } catch {
256 // Not JSON (half-written, or another file): no verdict to show.
257 return null
258 }
259 if (typeof data !== 'object' || data === null) return null
260 const word: unknown = Reflect.get(data, 'verdict')
261 const findings: unknown = Reflect.get(data, 'findings')
262 if (typeof word !== 'string' || !VERDICTS.has(word)) return null
263 return { verdict: word, findings: Array.isArray(findings) ? findings.length : 0 }
264}
265types/index.d.ts 64 lines1// apex-band state contract: the live APEX run the band draws, or null.
2// Self-contained (no import), as the plugin-authoring reference asks.
3
4export type ApexBandStepKind = 'done' | 'pending' | 'running' | 'skipped' | 'failed' | 'other'
5
6export type ApexBandStep = { step: string; status: string; kind: ApexBandStepKind }
7
8export type ApexBandRun = {
9 title: string
10 mode?: string
11 branch?: string
12 baseline?: string
13 steps: ApexBandStep[]
14 currentStep?: string
15 // The run's directory under .claude/output/apex, set by the poll.
16 dir?: string
17}
18
19// Token counts of one or more model responses, as the API reports them.
20export type ApexBandTally = { input: number; output: number; cacheRead: number; cacheWrite: number }
21
22export type ApexBandLoopStatus = 'running' | 'done' | 'failed' | 'stopped'
23
24// One model loop seen this session: a subagent (its agentId) or `main`.
25export type ApexBandLoop = {
26 id: string
27 label?: string
28 type?: string
29 model?: string
30 status: ApexBandLoopStatus
31 steps: number
32 calls: number
33 tool?: string
34 toolUseId?: string
35 tally: ApexBandTally
36 startedAt: number
37 // Running time of the finished turns; the live one counts from `since`.
38 durationMs: number
39 since?: number
40 endedAt?: number
41 // What ended it: the agent snapshot (an estimate, until its turn.complete
42 // reconciles it once) or its own turn.complete.
43 endedBy?: 'snapshot' | 'turn'
44}
45
46// The run's external-verify.json, as far as the pane shows it.
47export type ApexBandVerdict = { dir: string; verdict: string; findings: number; mtimeMs: number }
48
49// Tokens per APEX step of the run in `dir` (approximate: polled step).
50export type ApexBandPhases = { dir: string | null; byStep: Record<string, ApexBandTally> }
51
52declare module 'claude-code' {
53 interface PluginState {
54 'apex-band': {
55 run: ApexBandRun | null
56 loops: ApexBandLoop[]
57 phases: ApexBandPhases
58 now: number
59 cost: number | null
60 verdict: ApexBandVerdict | null
61 }
62 }
63}
64