SLOPSHOPPER

apex-band

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…

newpanebandguardcommandprompt
★ 1v0.1.0no licenseupdated 2026-10-07AlxWrtl/NixConfig/home/claude-code/mods/apex-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · apex-band
│ ┃ APEX ✕ › fix the failing auth test and add an audit log call │ ┃ Aucun run APEX en cours. │ ┃ Lancez /apex ; le détail s’affiche ici. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /apex-pane │ ⎿ apex-band: Détail du run APEX ouvert. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · APEX
Aucun run APEX en cours. Lancez /apex ; le détail s’affiche ici.
README

Nix-Darwin Configuration

Declarative macOS system configuration using Nix flakes

[Nix]() [macOS]()

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.

Clean Install

# 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:

#StepNotes
1Xcode Command Line Tools
2Nix package managerDeterminate Systems installer
3Homebrew
41Passwordpauses — you retrieve SSH keys + git-crypt key from the vault
5SSH key permissionschmod 700 ~/.ssh, 600 on the private key
6GitHub CLI authenticationgh auth login
7Decrypt secretsgit-crypt unlock
8App Store loginpauses — sign in for masApps
9darwin-rebuild switchthe actual build
10Switch git remote to SSH
11VS Code extensionsvscode-install-extensions
12Restore app configsPlex, 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

Structure

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.

Secrets

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.

  • Locally: readable, transparent workflow
  • On GitHub: encrypted binary
  • Key backup: 1Password → "git-crypt nix-darwin key"
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

Commands

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

Option Docs

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

Quality Gates

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.

CheckWhat it enforces
format-checknixfmt --check over every tracked *.nix (find walk, no per-directory list)
system-configThe whole alex-mbp darwin configuration actually builds
agent-instructionsThe 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-consistencyThe APEX skill keeps its critical clauses, flag casing, subagent isolation, and step-file references
apex-plan-provenanceEvery 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-tierThe 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-configClaude Code invariants: JSON parses, sandbox denies ~/.ssh and secrets, agents declare a model, haiku only on read-only agents, rules declare paths
claude-modsClaude 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-configCodex 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-wiringClaude 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-lintESLint (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-consistencyThis 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-cliThe 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.

Maintenance / Cleanup

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 -d warns $HOME is not owned by you and falls back to root's profile (leaving user generations behind), run the user sweep without sudo as well: nix-collect-garbage -d.

What's Managed

Each row points at the module that owns it — that file is the authoritative list, deliberately not duplicated here.

LayerToolContents
CLI toolsNix (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 appsHomebrew 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 & tapsHomebrew (modules/brew.nix)cloudflared, ffmpeg, displayplacer, mas, postgresql@16, trash, rtk-ai/tap/rtk
App Storemas (modules/brew.nix)DaisyDisk, Trello, iWork (Keynote/Numbers/Pages), Microsoft Office, Affinity Photo & Publisher
FontsNix (modules/ui.nix)Nerd Fonts (JetBrains Mono, Meslo LG, Hack, Fira Code, Sauce Code Pro), Cascadia Code, Inconsolata, Noto (+ CJK, emoji)
macOS defaultsNix (modules/ui.nix)Dock, Finder, trackpad, clock, screensaver, Window Manager, wallpaper
ServicesNix (modules/services.nix)weekly flake update, throttled brew update, power tuning (pmset), network/TCP tuning
SystemNix (modules/system.nix)Nix daemon settings, binary caches, env vars, application firewall
Terminalhome-manager (home/ghostty.nix)Catppuccin Macchiato, quick terminal, keybindings
Editorhome-manager (home/vscode.nix)Settings, keybindings, extension list
Shellhome-manager (home/zsh.nix)zsh + fzf + zoxide + autosuggestions + syntax highlighting + aliases
Githome-manager (home/git.nix)SSH signing, rebase-on-pull, fsck
SSHhome-manager (home/ssh.nix)Host configs (Tailscale)
Claude Codehome-manager (home/claude-code/)settings, hooks, agents, skills, commands, rules, mods, shell aliases
Codex CLIhome-manager (home/codex/)hooks.json + hook scripts (store symlinks), config.toml merge, hook-trust verification
Secretsgit-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 Hooks — Trusting Them After a Rebuild

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:

  1. sudo darwin-rebuild switch --flake .#alex-mbp — activation merges config.toml, links hooks.json, then runs codex-verify-hook-trust.
  2. The verifier warns on both hooks. That is expected on a first rebuild and after any hook edit; a silent pass there would mean the check failed to notice, not that you are protected.
  3. Open Codex, run /hooks, review each hook and trust it.
  4. Record the reviewed baseline by hand: 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.

Post Clean Install Checklist

  • ☐ SSH keys restored (~/.ssh/id_ed25519*)
  • ☐ VS Code extensions installed (~/.local/bin/vscode-install-extensions)
  • ☐ Default browser set (Arc)
  • ☐ 1Password logged in + browser extension
  • ☐ iCloud signed in (Desktop & Documents sync)
  • ☐ Arc signed in (sync spaces)
  • ☐ App logins: Discord, WhatsApp, Spark, Teams, Figma
  • ☐ Raycast settings imported (if backed up)
  • ☐ nix flake check green

APEX

APEX 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}/.

Mode Gate

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.

ModeDefault flagsNotes
Direct-pr≤ 4 files, ≤ 30 changed lines, no sensitive surface
Diagnosis-x -pr -o -nbug/crash — reproduce first, debugger agent implements, ships as a PR
Standard-t -pr -o -nfull orchestration
High-stakes-t -x -pr -o -n -eirreversible / 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 researchnoneanalyze only, no branch

Flags

Lowercase forces ON, uppercase forces OFF (-PR cancels an automatic -pr). Typed flags beat mode defaults.

EnableDisableDescription
-q-QClarify — ambiguities become up to 3 targeted questions before planning
-x-XExamine — adversarial, checklist-driven review
-t-TTest — create and run tests
-f-FTest-first — a separate agent writes failing tests from the ACs; read-only for the implementer
-2Divergence — second independent implementation of the core logic, behavioural diff
-p-PPremises — force/forbid the independent premises pass

| -e | -E | External verify — one cross-vendor read-only pass (Codex/GPT) over th

Source 6 files
hooks/index.tsx 368 lines
1// 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}
368
hooks/band.ts 297 lines
1// 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}
297
hooks/context.ts 123 lines
1// 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}
123
hooks/pane.ts 202 lines
1// 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}
202
hooks/stats.ts 265 lines
1// 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}
265
types/index.d.ts 64 lines
1// 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