SLOPSHOPPER

task-board

Tableau des tâches en arrière-plan (/task-board) : shells et sous-agents, durée, état.

newpanerowsguardcommandprompt
★ 1v0.1.0no licenseupdated 2026-10-07AlxWrtl/NixConfig/home/claude-code/mods/task-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-board
│ ┃ Tâches ✕ › fix the failing auth test and add an audit log call │ ┃ Aucune tâche en arrière-plan. │ ┃ Les shells en arrière-plan et les ⏺ Read(src/auth.ts) │ ┃ sous-agents s'affichent ici. ⎿ 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 │ │ › /task-board │ ⎿ task-board: Tableau des tâches ouvert. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tâches
Aucune tâche en arrière-plan. Les shells en arrière-plan et les sous-agents s'affichent 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 4 files
hooks/index.tsx 379 lines
1// task-board: a pane opened by /task-board listing the session's background
2// shells and subagents under a count header, each row a status glyph (en
3// cours / finie / échouée / arrêtée), its kind, label and duration; an agent
4// row adds its current tool and counted tokens. Finished tasks past five fold
5// into one line; two buttons show them all or clear them. Observes only:
6// every tool.call and turn.step hook returns next(e)'s result unchanged.
7// Silent: no sound, no pop-up notification.
8
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, Register, Timer } from 'claude-code'
11
12import {
13  addAgent,
14  addShell,
15  applyAgentSnapshot,
16  clear,
17  clearDone,
18  finishAgentTurn,
19  finishByNotification,
20  hasRecentDone,
21  noteStep,
22  noteTool,
23  parseTaskNotifications,
24  runningCount,
25  stopShell,
26  visible,
27} from './board.ts'
28import type { Notification, StepUsage, Task } from './board.ts'
29import { GLYPH_WIDTH, KIND_WIDTH, hasBothGroups, layoutRow, summary } from './format.ts'
30
31const PANE = 'task-board'
32const TITLE = 'Tâches'
33const TICK_MS = 1000
34
35const tasks = atom({ plugin: 'task-board', key: 'tasks' } as const, [])
36const now = atom({ plugin: 'task-board', key: 'now' } as const, 0)
37const isOpen = atom({ plugin: 'task-board', key: 'isOpen' } as const, false)
38const showAll = atom({ plugin: 'task-board', key: 'showAll' } as const, false)
39
40// Reads a field of an engine record whose shape varies per tool; anything
41// that is not a non-empty string reads as undefined.
42function stringField(record: unknown, key: string): string | undefined {
43  if (typeof record !== 'object' || record === null) return undefined
44  const value: unknown = Reflect.get(record, key)
45  return typeof value === 'string' && value !== '' ? value : undefined
46}
47
48function numberField(record: unknown, key: string): number | undefined {
49  if (typeof record !== 'object' || record === null) return undefined
50  const value: unknown = Reflect.get(record, key)
51  return typeof value === 'number' && Number.isFinite(value) ? value : undefined
52}
53
54// A row's text: a string as is, else its text blocks joined (any other
55// block skipped); anything else reads as empty.
56function rowText(content: unknown): string {
57  if (typeof content === 'string') return content
58  if (!Array.isArray(content)) return ''
59  const parts: string[] = []
60  for (const block of content) {
61    const text = stringField(block, 'type') === 'text' ? stringField(block, 'text') : undefined
62    if (text !== undefined) parts.push(text)
63  }
64  return parts.join('\n')
65}
66
67// Applies a pure change to the task list, writing only when it changed.
68async function change($: EngineInterface, fn: (list: Task[]) => Task[]): Promise<void> {
69  const current = await read($, tasks)
70  if (fn(current) === current) return
71  await update($, tasks, fn)
72}
73
74// Runs `write`; a refused state write leaves the board as it was (an
75// observer never fails, nor runs twice, the event it watches).
76async function record(write: () => Promise<void>): Promise<void> {
77  try {
78    await write()
79  } catch {
80    // Display only: the next step or call writes again.
81  }
82}
83
84// Module-level: a hot reload drops the environment and its timers with it.
85let timer: Timer | undefined
86
87async function tick($: EngineInterface): Promise<void> {
88  const at = await $.clock.now()
89  const list = await $.agent.list()
90  const snapshot = list.map(a => ({ id: a.id, description: a.description, type: a.type, status: a.status }))
91  await change($, current => applyAgentSnapshot(current, snapshot, at))
92  // Per-second refresh only while the pane is open and something runs, or a
93  // finished task has yet to leave the recent window (and fold).
94  const live = await read($, tasks)
95  if ((await read($, isOpen)) && (runningCount(live) > 0 || hasRecentDone(live, at))) {
96    await update($, now, () => at)
97  }
98}
99
100// Started from session.start and lazily from command.run / prompt.submit:
101// session.start does not fire again after a hot reload.
102function ensurePolling($: EngineInterface): void {
103  if (timer !== undefined) return
104  timer = $.clock.every(TICK_MS, () => {
105    tick($).catch(() => {
106      // A failed tick (agent list or state refused) is retried by the next
107      // one a second later; nothing else depends on it.
108    })
109  })
110}
111
112export const register: Register = on => {
113  on('session.start', async ($, e, next) => {
114    await $.command.register({
115      name: 'task-board',
116      description: 'Ouvre le tableau des tâches en arrière-plan (shells et sous-agents)',
117      immediate: true,
118    })
119    ensurePolling($)
120    return next(e)
121  })
122
123  on('prompt.submit', ($, e, next) => {
124    ensurePolling($)
125    return next(e)
126  }).catch(($, e, next) => next(e))
127
128  on('command.run', { command: 'task-board' }, async $ => {
129    ensurePolling($)
130    const at = await $.clock.now()
131    await update($, now, () => at)
132    await update($, isOpen, () => true)
133    await $.ui.open({ id: PANE, title: TITLE })
134    return { text: 'Tableau des tâches ouvert.' }
135  })
136
137  on('ui.close', async ($, e, next) => {
138    if (e.id === PANE) await update($, isOpen, () => false)
139    return next(e)
140  }).catch(($, e, next) => next(e))
141
142  // Background shell: run_in_background, or ctrl+B / auto-background, all
143  // answer a backgroundTaskId.
144  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
145    const ran = await next(e)
146    const id = stringField(ran.result, 'backgroundTaskId')
147    if (id !== undefined) {
148      const startedAt = await $.clock.now()
149      const label = stringField(e, 'description') ?? stringField(e, 'command') ?? 'shell'
150      const toolUseId = stringField(e, 'tool_use_id')
151      // Set only inside a subagent loop: its shells close with it (board.ts).
152      const ownerAgentId = stringField(e, 'agentId')
153      await change($, current =>
154        addShell(current, {
155          id,
156          label,
157          startedAt,
158          ...(toolUseId === undefined ? {} : { toolUseId }),
159          ...(ownerAgentId === undefined ? {} : { ownerAgentId }),
160        }),
161      )
162    }
163    return ran
164  }).catch(($, e, next) => next(e))
165
166  // Background subagent: the Agent call answers async_launched + agentId.
167  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
168    const ran = await next(e)
169    const id = stringField(ran.result, 'agentId')
170    if (stringField(ran.result, 'status') === 'async_launched' && id !== undefined) {
171      const startedAt = await $.clock.now()
172      const label = stringField(ran.result, 'description') ?? stringField(e, 'description') ?? 'agent'
173      const agentType = stringField(e, 'subagent_type')
174      const toolUseId = stringField(e, 'tool_use_id')
175      await change($, current =>
176        addAgent(current, {
177          id,
178          label,
179          startedAt,
180          ...(agentType === undefined ? {} : { agentType }),
181          ...(toolUseId === undefined ? {} : { toolUseId }),
182        }),
183      )
184    }
185    return ran
186  }).catch(($, e, next) => next(e))
187
188  // A background task's notification row: the one completion signal a shell
189  // has. A render hook never writes state, so the write is deferred to a
190  // timer; the row itself is drawn unchanged.
191  on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'task-notification' } } }, ($, e, next) => {
192    const task: unknown = e.props.task
193    const note: Notification = {}
194    const id = stringField(task, 'id')
195    const toolUseId = stringField(task, 'toolUseId')
196    const status = stringField(task, 'status')
197    const durationMs = numberField(task, 'durationMs')
198    if (id !== undefined) note.id = id
199    if (toolUseId !== undefined) note.toolUseId = toolUseId
200    if (status !== undefined) note.status = status
201    if (durationMs !== undefined) note.durationMs = durationMs
202    if (note.status !== undefined) {
203      $.clock.after(0, () => {
204        $.clock
205          .now()
206          .then(at => change($, current => finishByNotification(current, note, at)))
207          .catch(() => {
208            // State refused: the row stays "en cours" until the agent
209            // snapshot (subagents) or a later redraw of the row retries it.
210          })
211      })
212    }
213    return next(e)
214  })
215
216  // A subagent's shell notifies that subagent's loop only: its row never
217  // reaches the main transcript (nor the ui.render path above). Main-loop
218  // rows stay on that path. Read before next: the row is relayed unchanged.
219  on('session.append', async ($, e, next) => {
220    const agentId = e.agentId
221    if (agentId !== undefined && agentId !== '' && e.origin.kind === 'task-notification') {
222      const notes = parseTaskNotifications(rowText(e.message.content))
223      if (notes.length > 0) {
224        const at = await $.clock.now()
225        await change($, current => notes.reduce((list, note) => finishByNotification(list, note, at), current))
226      }
227    }
228    return next(e)
229  }).catch(($, e, next) => next(e))
230
231  // A shell stopped by TaskStop gets no notification row: close it as
232  // killed. TaskStop also stops agents; stopShell leaves agent rows to the
233  // agent snapshot.
234  on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
235    const ran = await next(e)
236    if (ran.isError !== true && ran.result !== undefined) {
237      const id = stringField(ran.result, 'task_id') ?? stringField(e, 'task_id') ?? stringField(e, 'shell_id')
238      if (id !== undefined) {
239        const at = await $.clock.now()
240        await change($, current => stopShell(current, id, at))
241      }
242    }
243    return ran
244  }).catch(($, e, next) => next(e))
245
246  // Each model response of a subagent: its counted tokens on the agent row.
247  on('turn.step', async function* ($, e, next) {
248    const result = yield* next(e)
249    const usage: StepUsage | null = result.usage
250    await record(() => change($, current => noteStep(current, e.agentId, usage)))
251    return result
252  }).catch(async function* ($, e, next) {
253    return yield* next(e)
254  })
255
256  // Any tool call in a subagent loop: shown as that agent's current tool,
257  // recorded before the call runs; its result is passed on unchanged.
258  on('tool.call', async ($, e, next) => {
259    const { agentId, tool } = e
260    await record(() => change($, current => noteTool(current, agentId, tool)))
261    return next(e)
262  }).catch(($, e, next) => next(e))
263
264  on('turn.complete', async ($, e, next) => {
265    const agentId = e.agentId
266    if (agentId !== undefined) {
267      const at = await $.clock.now()
268      await change($, current => finishAgentTurn(current, agentId, e.reason, at))
269    }
270    return next(e)
271  })
272
273  on('session.end', async ($, e, next) => {
274    if (e.reason === 'clear') {
275      // No session.start follows a /clear: keep the timer, empty the list.
276      await change($, clear)
277    } else {
278      timer?.cancel()
279      timer = undefined
280    }
281    return next(e)
282  })
283
284  // Header of non-zero counts, then running rows (oldest first) and finished
285  // rows (most recent first, board.ts order), the latter introduced by a dim
286  // label when both groups are shown; finished rows past the cap fold into a
287  // dim "+N terminées" line. Footer buttons: [t] show all / fold, [x] clear
288  // the finished. Reads state only; only the buttons' presses write.
289  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
290    const { Box, Button, Text } = $.ui.resolve(e)
291    const list = await read($, tasks)
292    const at = await read($, now)
293    const isAll = await read($, showAll)
294    const cols = Math.max(20, e.props.bodyColumns)
295
296    if (list.length === 0) {
297      return (
298        <Box flexDirection="column">
299          <Text dimColor>Aucune tâche en arrière-plan.</Text>
300          <Text dimColor>Les shells en arrière-plan et les sous-agents s'affichent ici.</Text>
301        </Box>
302      )
303    }
304
305    const header = summary(list).flatMap((part, i) => {
306      const count = (
307        <Text color={part.tone} bold={part.bold}>
308          {part.text}
309        </Text>
310      )
311      return i === 0 ? [count] : [<Text dimColor> · </Text>, count]
312    })
313    const { rows, hidden } = visible(list, at, isAll)
314    const firstDone = hasBothGroups(list) ? rows.findIndex(task => task.status !== 'running') : -1
315    const hasDone = list.some(task => task.status !== 'running')
316    const canFold = isAll || hidden > 0
317    const toggle = (): void => {
318      update($, showAll, value => !value).catch(() => {
319        // State refused: the pane stays as drawn; a later press retries.
320      })
321    }
322    const clearFinished = (): void => {
323      update($, tasks, clearDone).catch(() => {
324        // State refused: the finished rows stay; a later press retries.
325      })
326    }
327
328    return (
329      <Box flexDirection="column">
330        <Box flexDirection="row" flexWrap="wrap">
331          {header}
332        </Box>
333        {rows.flatMap((task, i) => {
334          const row = layoutRow(task, Math.max(at, task.startedAt), cols)
335          const line = (
336            <Box flexDirection="row">
337              <Box width={GLYPH_WIDTH} flexShrink={0}>
338                <Text color={row.tone}>{row.glyph}</Text>
339              </Box>
340              {row.kind === '' ? [] : [
341                <Box width={KIND_WIDTH} flexShrink={0}>
342                  <Text dimColor>{row.kind}</Text>
343                </Box>,
344              ]}
345              <Box flexDirection="row" flexGrow={1} flexShrink={1}>
346                <Text dimColor={row.isDone} wrap="truncate-end">
347                  {row.text}
348                </Text>
349                {row.detail === '' ? [] : [
350                  <Text dimColor wrap="truncate-end">
351                    {row.detail}
352                  </Text>,
353                ]}
354              </Box>
355              <Box flexShrink={0} marginLeft={1}>
356                <Text dimColor>{row.dur}</Text>
357              </Box>
358            </Box>
359          )
360          return i === firstDone ? [<Text dimColor>terminées</Text>, line] : [line]
361        })}
362        {hidden > 0 ? [<Text dimColor>{`+${hidden} terminée${hidden > 1 ? 's' : ''}`}</Text>] : []}
363        {canFold || hasDone ? [
364          <Box flexDirection="row" flexWrap="wrap">
365            {canFold ? [
366              <Button key="toggle" hotkey="t" label={isAll ? 'replier' : 'tout afficher'} plain dimColor onPress={toggle} />,
367            ] : []}
368            {hasDone ? [
369              <Box marginLeft={canFold ? 2 : 0}>
370                <Button key="clear-done" hotkey="x" label="vider les terminées" plain dimColor onPress={clearFinished} />
371              </Box>,
372            ] : []}
373          </Box>,
374        ] : []}
375      </Box>
376    )
377  })
378}
379
hooks/board.ts 276 lines
1// Pure reducer of the task list: no `$`, no clock, no I/O.
2// Every function returns the SAME array reference when nothing changed, so
3// a caller can skip the state write (and the redraw it causes).
4
5import type { TaskBoardStatus, TaskBoardTask } from '../types'
6
7export type Task = TaskBoardTask
8export type Status = TaskBoardStatus
9
10export const CAP = 50
11// A task finished less than RECENT_MS ago is always shown; past it, at most
12// MAX_DONE finished tasks are shown unless the pane shows them all.
13export const RECENT_MS = 30_000
14export const MAX_DONE = 5
15
16// A model step's usage, reduced to the fields counted here.
17export type StepUsage = {
18  input_tokens: number
19  output_tokens: number
20  cache_creation_input_tokens: number
21}
22
23// What a task-notification row carries (UserMessage `e.props.task`).
24export type Notification = {
25  id?: string
26  toolUseId?: string
27  status?: string
28  durationMs?: number
29}
30
31// What `$.agent.list()` answers, reduced to the fields read here.
32export type AgentSnapshot = {
33  id: string
34  description: string
35  type: string
36  status: string
37}
38
39export type TurnReason = 'answer' | 'aborted' | 'refusal' | 'error'
40
41const TEAMMATE = 'teammate'
42
43// An ended status word, or undefined for anything else (still running, or a
44// word this build does not name: the task keeps `running`).
45type EndedStatus = Exclude<Status, 'running'>
46
47function endedStatus(word: string | undefined): EndedStatus | undefined {
48  if (word === 'completed' || word === 'failed' || word === 'killed') return word
49  return undefined
50}
51
52// Running first (oldest first), then finished (most recent end first); past
53// CAP the oldest finished go, then the oldest running.
54function normalize(tasks: Task[]): Task[] {
55  const running = tasks.filter(t => t.status === 'running').sort((a, b) => a.startedAt - b.startedAt)
56  const done = tasks
57    .filter(t => t.status !== 'running')
58    .sort((a, b) => (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt))
59  return [...running.slice(-CAP), ...done].slice(0, CAP)
60}
61
62export function addShell(
63  tasks: Task[],
64  shell: { id: string; label: string; startedAt: number; toolUseId?: string; ownerAgentId?: string },
65): Task[] {
66  if (tasks.some(t => t.id === shell.id)) return tasks
67  const task: Task = {
68    id: shell.id,
69    kind: 'shell',
70    label: shell.label,
71    startedAt: shell.startedAt,
72    status: 'running',
73    ...(shell.toolUseId === undefined ? {} : { toolUseId: shell.toolUseId }),
74    ...(shell.ownerAgentId === undefined ? {} : { ownerAgentId: shell.ownerAgentId }),
75  }
76  return normalize([...tasks, task])
77}
78
79export function addAgent(
80  tasks: Task[],
81  agent: { id: string; label: string; startedAt: number; agentType?: string; toolUseId?: string },
82): Task[] {
83  if (tasks.some(t => t.id === agent.id)) return tasks
84  const task: Task = {
85    id: agent.id,
86    kind: 'agent',
87    label: agent.label,
88    startedAt: agent.startedAt,
89    status: 'running',
90    ...(agent.agentType === undefined ? {} : { agentType: agent.agentType }),
91    ...(agent.toolUseId === undefined ? {} : { toolUseId: agent.toolUseId }),
92  }
93  return normalize([...tasks, task])
94}
95
96function replaceAt(tasks: Task[], index: number, task: Task): Task[] {
97  return normalize(tasks.map((t, i) => (i === index ? task : t)))
98}
99
100// A background task's notification: matched by id, else by tool_use_id.
101// Unknown task, unknown status word, or a task already ended: no change
102// (a notification row is drawn again on every redraw and on a resume).
103export function finishByNotification(tasks: Task[], note: Notification, now: number): Task[] {
104  const status = endedStatus(note.status)
105  if (status === undefined) return tasks
106  let index = note.id === undefined ? -1 : tasks.findIndex(t => t.id === note.id)
107  if (index < 0 && note.toolUseId !== undefined) {
108    index = tasks.findIndex(t => t.toolUseId === note.toolUseId)
109  }
110  const task = index < 0 ? undefined : tasks[index]
111  if (task === undefined || task.status !== 'running') return tasks
112  const endedAt = note.durationMs === undefined ? now : task.startedAt + note.durationMs
113  return replaceAt(tasks, index, { ...task, status, endedAt })
114}
115
116// A task-notification row's text, as a subagent's transcript keeps it:
117// `<task-id>…</task-id>` and `<status>…</status>`. A missing id, or a status
118// word that is not an end, reads as undefined.
119export function parseTaskNotification(
120  text: string,
121): { id: string; status: EndedStatus } | undefined {
122  const id = /<task-id>([^<]*)<\/task-id>/.exec(text)?.[1]?.trim()
123  const status = endedStatus(/<status>([^<]*)<\/status>/.exec(text)?.[1]?.trim())
124  if (id === undefined || id === '' || status === undefined) return undefined
125  return { id, status }
126}
127
128// Every `<task-notification>…</task-notification>` block of a row: a row
129// delivered while the loop was busy may batch several. A malformed block is
130// skipped.
131export function parseTaskNotifications(text: string): Array<{ id: string; status: EndedStatus }> {
132  const notes: Array<{ id: string; status: EndedStatus }> = []
133  for (const block of text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)) {
134    const note = parseTaskNotification(block[1] ?? '')
135    if (note !== undefined) notes.push(note)
136  }
137  return notes
138}
139
140// A shell stopped by TaskStop: only a running shell row closes, as killed;
141// an agent id (TaskStop accepts both) or an unknown one is a no-op.
142export function stopShell(tasks: Task[], id: string, now: number): Task[] {
143  const index = tasks.findIndex(t => t.id === id && t.kind === 'shell')
144  const task = tasks[index]
145  if (task === undefined || task.status !== 'running') return tasks
146  return replaceAt(tasks, index, { ...task, status: 'killed', endedAt: now })
147}
148
149// A subagent's background shell notifies that subagent's loop, never the
150// main one: once the owner ends killed or failed, or leaves the agent list,
151// its still running shells are closed as `killed`. An owner that completed
152// keeps them (it may resume on the shell's notification).
153export function closeOrphanShells(tasks: Task[], ownerId: string, endedAt: number): Task[] {
154  const isOrphan = (t: Task): boolean => t.kind === 'shell' && t.status === 'running' && t.ownerAgentId === ownerId
155  if (!tasks.some(isOrphan)) return tasks
156  return normalize(tasks.map(t => (isOrphan(t) ? { ...t, status: 'killed', endedAt } : t)))
157}
158
159// A `$.agent.list()` snapshot: unknown agents are added; a known running
160// agent the list reports ended is finished, except a teammate (it goes
161// `idle` between turns and is never finished from here).
162export function applyAgentSnapshot(tasks: Task[], list: readonly AgentSnapshot[], now: number): Task[] {
163  let out = tasks
164  for (const agent of list) {
165    const ended = endedStatus(agent.status)
166    if ((ended === 'killed' || ended === 'failed') && agent.type !== TEAMMATE) out = closeOrphanShells(out, agent.id, now)
167  }
168  const listed = new Set(list.map(a => a.id))
169  for (const t of tasks) {
170    if (t.kind === 'agent' && !listed.has(t.id)) out = closeOrphanShells(out, t.id, now)
171  }
172  for (const agent of list) {
173    const index = out.findIndex(t => t.id === agent.id)
174    const ended = endedStatus(agent.status)
175    if (index < 0) {
176      const added = addAgent(out, { id: agent.id, label: agent.description, startedAt: now, agentType: agent.type })
177      const at = added.findIndex(t => t.id === agent.id)
178      const task = added[at]
179      out =
180        ended === undefined || task === undefined || agent.type === TEAMMATE
181          ? added
182          : replaceAt(added, at, { ...task, status: ended, endedAt: now })
183      continue
184    }
185    const task = out[index]
186    if (task === undefined || ended === undefined || task.status !== 'running') continue
187    if (task.agentType === TEAMMATE || agent.type === TEAMMATE) continue
188    out = replaceAt(out, index, { ...task, status: ended, endedAt: now })
189  }
190  return out
191}
192
193// A subagent's `turn.complete`: answer → completed, aborted → killed,
194// error | refusal → failed. A teammate is skipped (its turn ending is not
195// the teammate ending).
196export function finishAgentTurn(tasks: Task[], agentId: string, reason: TurnReason, now: number): Task[] {
197  const index = tasks.findIndex(t => t.id === agentId && t.kind === 'agent')
198  const task = tasks[index]
199  if (task === undefined || task.status !== 'running') return tasks
200  // A teammate's turn ending only clears its current tool.
201  if (task.agentType === TEAMMATE) return task.tool === undefined ? tasks : replaceAt(tasks, index, withoutTool(task))
202  const status: Status = reason === 'answer' ? 'completed' : reason === 'aborted' ? 'killed' : 'failed'
203  const out = replaceAt(tasks, index, { ...withoutTool(task), status, endedAt: now })
204  return status === 'completed' ? out : closeOrphanShells(out, agentId, now)
205}
206
207// The task without its current tool (the same object when it has none).
208function withoutTool(task: Task): Task {
209  if (task.tool === undefined) return task
210  const copy: Task = { ...task }
211  delete copy.tool
212  return copy
213}
214
215// A model step of subagent `agentId`: its input, output and cache-write
216// tokens are added to that agent's row (cache reads excluded: with a large
217// context they dwarf the rest). A main-loop step, an unknown agent, a null
218// usage or a zero count: no change.
219export function noteStep(tasks: Task[], agentId: string | undefined, usage: StepUsage | null): Task[] {
220  if (agentId === undefined || usage === null) return tasks
221  const count = usage.input_tokens + usage.output_tokens + usage.cache_creation_input_tokens
222  const index = tasks.findIndex(t => t.id === agentId && t.kind === 'agent')
223  const task = tasks[index]
224  if (task === undefined || !(count > 0)) return tasks
225  return tasks.map((t, i) => (i === index ? { ...task, tokens: (task.tokens ?? 0) + count } : t))
226}
227
228// A tool call in subagent `agentId`'s loop: shown as that running agent's
229// current tool. Main loop, unknown or finished agent, same tool: no change.
230export function noteTool(tasks: Task[], agentId: string | undefined, tool: string): Task[] {
231  if (agentId === undefined || tool === '') return tasks
232  const index = tasks.findIndex(t => t.id === agentId && t.kind === 'agent')
233  const task = tasks[index]
234  if (task === undefined || task.status !== 'running' || task.tool === tool) return tasks
235  return tasks.map((t, i) => (i === index ? { ...task, tool } : t))
236}
237
238// Every finished task removed; running ones kept.
239export function clearDone(tasks: Task[]): Task[] {
240  return tasks.some(t => t.status !== 'running') ? tasks.filter(t => t.status === 'running') : tasks
241}
242
243// True while a finished task is still inside the RECENT_MS window at `now`:
244// the pane's clock must keep ticking for it to fold once the window ends.
245export function hasRecentDone(tasks: readonly Task[], now: number): boolean {
246  return tasks.some(t => t.status !== 'running' && now - (t.endedAt ?? t.startedAt) < RECENT_MS)
247}
248
249// The rows the pane draws: every running task, then the finished ones (most
250// recent first) that are recent, among the first MAX_DONE, or all of them
251// when `showAll`; `hidden` counts the finished tasks left out.
252export function visible(tasks: readonly Task[], now: number, showAll: boolean): { rows: Task[]; hidden: number } {
253  const rows: Task[] = []
254  let done = 0
255  let hidden = 0
256  for (const t of tasks) {
257    if (t.status === 'running') {
258      rows.push(t)
259      continue
260    }
261    const isRecent = now - (t.endedAt ?? t.startedAt) < RECENT_MS
262    if (showAll || isRecent || done < MAX_DONE) rows.push(t)
263    else hidden += 1
264    done += 1
265  }
266  return { rows, hidden }
267}
268
269export function clear(tasks: Task[]): Task[] {
270  return tasks.length === 0 ? tasks : []
271}
272
273export function runningCount(tasks: readonly Task[]): number {
274  return tasks.filter(t => t.status === 'running').length
275}
276
hooks/format.ts 120 lines
1// Pure formatting of the board: duration, status glyph and tone, header
2// counts, width fit of one row.
3
4import type { ThemeKey } from 'claude-code'
5
6import type { TaskBoardStatus, TaskBoardTask } from '../types'
7
8const pad2 = (n: number): string => String(n).padStart(2, '0')
9
10// Cells of the glyph column (glyph + gap) and of the kind column.
11export const GLYPH_WIDTH = 2
12export const KIND_WIDTH = 6
13// Below this width the kind column is dropped to leave room for the label.
14export const KIND_MIN_COLS = 40
15
16// "Ns" under a minute, "Mm SSs" under an hour, "Hh MMm" past it.
17export function formatDuration(ms: number): string {
18  const seconds = Math.floor(Math.max(0, ms) / 1000)
19  if (seconds < 60) return `${seconds}s`
20  const minutes = Math.floor(seconds / 60)
21  if (minutes < 60) return `${minutes}m ${pad2(seconds % 60)}s`
22  return `${Math.floor(minutes / 60)}h ${pad2(minutes % 60)}m`
23}
24
25// One narrow glyph per status; killed has its own, never the failure cross.
26export function glyph(status: TaskBoardStatus): string {
27  if (status === 'running') return '●'
28  if (status === 'completed') return '✓'
29  if (status === 'failed') return '✗'
30  return '■'
31}
32
33// The theme color that goes with each status glyph.
34export function tone(status: TaskBoardStatus): ThemeKey {
35  if (status === 'running') return 'warning'
36  if (status === 'completed') return 'success'
37  if (status === 'failed') return 'error'
38  return 'inactive'
39}
40
41export function truncate(text: string, width: number): string {
42  if (width <= 0) return ''
43  if (text.length <= width) return text
44  return width === 1 ? '…' : `${text.slice(0, width - 1)}…`
45}
46
47export type SummaryPart = { text: string; tone: ThemeKey; bold: boolean }
48
49const plural = (n: number, word: string): string => `${n} ${word}${n > 1 ? 's' : ''}`
50
51// Header counts in a fixed order (en cours, finie(s), échouée(s),
52// arrêtée(s)), zero counts left out; only the running count is bold.
53export function summary(tasks: readonly TaskBoardTask[]): SummaryPart[] {
54  const count = (status: TaskBoardStatus): number => tasks.filter(t => t.status === status).length
55  const parts: SummaryPart[] = []
56  const running = count('running')
57  const completed = count('completed')
58  const failed = count('failed')
59  const killed = count('killed')
60  if (running > 0) parts.push({ text: `${running} en cours`, tone: tone('running'), bold: true })
61  if (completed > 0) parts.push({ text: plural(completed, 'finie'), tone: tone('completed'), bold: false })
62  if (failed > 0) parts.push({ text: plural(failed, 'échouée'), tone: tone('failed'), bold: false })
63  if (killed > 0) parts.push({ text: plural(killed, 'arrêtée'), tone: tone('killed'), bold: false })
64  return parts
65}
66
67// True when the list holds both running and finished tasks: only then is
68// the finished group introduced by its own label.
69export function hasBothGroups(tasks: readonly TaskBoardTask[]): boolean {
70  return tasks.some(t => t.status === 'running') && tasks.some(t => t.status !== 'running')
71}
72
73// "999", "1.2k", "10k", "1.0M": the unit is chosen after rounding
74// (9 999 → 10k, 999 999 → 1.0M), as apex-band's pane counts.
75export function fmtTokens(n: number): string {
76  const v = Math.max(0, Math.round(n))
77  if (v < 1000) return String(v)
78  const tenths = (v / 1000).toFixed(1)
79  if (Number(tenths) < 10) return `${tenths}k`
80  const thousands = Math.round(v / 1000)
81  if (thousands < 1000) return `${thousands}k`
82  return `${(v / 1_000_000).toFixed(1)}M`
83}
84
85// Cells the label keeps before a row's dim detail is cut.
86const MIN_NAME = 10
87
88// `text` is the label; `detail` the dim " · tool · tokens" suffix of an agent
89// (tool while running, tokens once counted), empty otherwise.
90export type Row = { glyph: string; tone: ThemeKey; kind: string; text: string; detail: string; dur: string; isDone: boolean }
91
92// One row laid out in `cols` cells: glyph column, kind column (empty below
93// KIND_MIN_COLS, and for an agent whose type already prefixes the label),
94// label then dim detail cut with "…" (the label keeps
95// MIN_NAME cells first), one gap, duration; all fit.
96export function layoutRow(task: TaskBoardTask, now: number, cols: number): Row {
97  const dur = formatDuration((task.endedAt ?? now) - task.startedAt)
98  const typedAgent = task.kind === 'agent' && task.agentType !== undefined
99  const kind = cols >= KIND_MIN_COLS && !typedAgent ? task.kind : ''
100  const base = task.label.replace(/\s+/g, ' ').trim()
101  const name = typedAgent ? `${task.agentType} · ${base}` : base
102  const room = cols - GLYPH_WIDTH - (kind === '' ? 0 : KIND_WIDTH) - 1 - dur.length
103  const parts = [
104    task.status === 'running' ? task.tool : undefined,
105    task.tokens !== undefined && task.tokens > 0 ? fmtTokens(task.tokens) : undefined,
106  ].filter((p): p is string => p !== undefined && p !== '')
107  const suffix = parts.map(p => ` · ${p}`).join('')
108  const nameRoom = Math.max(Math.min(name.length, room - suffix.length), Math.min(room, MIN_NAME))
109  const text = truncate(name, nameRoom)
110  return {
111    glyph: glyph(task.status),
112    tone: tone(task.status),
113    kind,
114    text,
115    detail: truncate(suffix, room - text.length),
116    dur,
117    isDone: task.status !== 'running',
118  }
119}
120
types/index.d.ts 32 lines
1// task-board state contract: what the hooks module keeps in $.state.
2// Self-contained (no import), as the plugin-authoring reference asks.
3
4export type TaskBoardKind = 'shell' | 'agent'
5
6// `killed` is kept as its own word in state and drawn as "arrêtée" (■), not as a failure.
7export type TaskBoardStatus = 'running' | 'completed' | 'failed' | 'killed'
8
9export type TaskBoardTask = {
10  // backgroundTaskId for a shell, agentId for a subagent.
11  id: string
12  kind: TaskBoardKind
13  label: string
14  startedAt: number
15  endedAt?: number
16  status: TaskBoardStatus
17  toolUseId?: string
18  agentType?: string
19  // Shell only: the subagent whose loop started it (absent on the main loop).
20  ownerAgentId?: string
21  // Agent only: the tool its loop is running now, cleared when its turn ends.
22  tool?: string
23  // Agent only: input + output + cache-write tokens of its steps (cache reads excluded).
24  tokens?: number
25}
26
27declare module 'claude-code' {
28  interface PluginState {
29    'task-board': { tasks: TaskBoardTask[]; now: number; isOpen: boolean; showAll: boolean }
30  }
31}
32