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

Declarative macOS system configuration using Nix flakes
[]() [
]()
Single host: alex-mbp (aarch64-darwin). Everything below is declared in this repo — system settings, CLI tools, GUI apps, fonts, shell, editor, and the Claude Code setup.
# 1. Prerequisites (git + clone access)
xcode-select --install
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew install gh
gh auth login # authenticate with GitHub
# 2. Clone and run bootstrap (handles everything else)
git clone https://github.com/AlxWrtl/NixConfig.git ~/.config/nix-darwin
cd ~/.config/nix-darwin
./bootstrap.sh
bootstrap.sh runs twelve checkpointed steps and skips whatever is already done, so it is safe to re-run after an interruption. Once a run completes, later runs skip the app configs restore unless you pass ./bootstrap.sh --restore:
| # | Step | Notes |
|---|---|---|
| 1 | Xcode Command Line Tools | |
| 2 | Nix package manager | Determinate Systems installer |
| 3 | Homebrew | |
| 4 | 1Password | pauses — you retrieve SSH keys + git-crypt key from the vault |
| 5 | SSH key permissions | chmod 700 ~/.ssh, 600 on the private key |
| 6 | GitHub CLI authentication | gh auth login |
| 7 | Decrypt secrets | git-crypt unlock |
| 8 | App Store login | pauses — sign in for masApps |
| 9 | darwin-rebuild switch | the actual build |
| 10 | Switch git remote to SSH | |
| 11 | VS Code extensions | vscode-install-extensions |
| 12 | Restore app configs | Plex, Logitech, Raycast, Ice, Finder sidebar, Wi-Fi/BT |
Steps 4 and 8 block on a manual action; the rest are unattended. App logins (Discord, Figma, Teams, …) stay manual — see the checklist below.
# 1. Xcode Command Line Tools
xcode-select --install
# 2. Install Homebrew + GitHub CLI
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew install gh
gh auth login
# 3. Install Nix
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh
# 4. Clone
git clone https://github.com/AlxWrtl/NixConfig.git ~/.config/nix-darwin
cd ~/.config/nix-darwin
# 5. Install 1Password and retrieve keys
brew install --cask 1password
# Open 1Password → login → save SSH keys to ~/.ssh/ and git-crypt key to ~/git-crypt-key
# 6. Decrypt secrets
nix-shell -p git-crypt --run "git-crypt unlock ~/git-crypt-key"
rm ~/git-crypt-key
# 7. Login to App Store (for masApps)
# 8. Build & apply
sudo darwin-rebuild switch --flake .#alex-mbp
# 9. Post-install
git remote set-url origin git@github.com:AlxWrtl/NixConfig.git
~/.local/bin/vscode-install-extensions
flake.nix # inputs, checks, darwinConfigurations, devShell
├── hosts/alex-mbp/ # Host identity
│ ├── default.nix
│ └── configuration.nix
├── modules/ # System modules
│ ├── system.nix # Nix settings, env, firewall, shell
│ ├── packages.nix # CLI tools + shell aliases
│ ├── services.nix # launchd agents & daemons
│ ├── ui.nix # Fonts, Dock, Finder, macOS defaults, wallpaper
│ └── brew.nix # taps, brews, casks, masApps
├── home/ # User config (home-manager)
│ ├── default.nix # User packages & imports
│ ├── git.nix # Git + SSH signing
│ ├── ssh.nix # SSH hosts
│ ├── zsh.nix # Shell (zsh + fzf + zoxide + autosuggest + highlighting)
│ ├── starship.nix # Prompt
│ ├── direnv.nix # Directory environments
│ ├── ghostty.nix # Terminal (Catppuccin, quick terminal)
│ ├── vscode.nix # VS Code settings, keybindings, extensions
│ ├── claude-code.nix # Claude Code entrypoint — imports claude-code/
│ ├── claude-code/ # settings, hooks, agents, skills, commands, rules…
│ │ # incl. skills-manifest.nix (Claude skills)
│ │ # and mods/ (task-board, apex-band, status-bar) via mods.nix
│ ├── codex.nix # Codex CLI entrypoint — imports codex/
│ └── codex/ # hooks.json generator, activation, hook & merge scripts
├── checks/ # Flake checks (see Quality Gates)
│ ├── agent-instructions.nix
│ ├── apex-consistency.nix
│ ├── apex-plan-provenance.nix
│ ├── apex-tier.nix
│ ├── audit-apex-needles.py # Advisory, not a flake check — needle shapes
│ ├── claude-config.nix
│ ├── claude-mods.nix
│ ├── codex-config.nix
│ ├── hook-wiring.nix
│ ├── js-lint.nix
│ ├── readme-consistency.nix
│ └── trello-cli.nix
├── backups/ # 🔒 Encrypted app config exports (backup-apps.sh)
├── wallpapers/ # Desktop wallpaper
├── secrets.nix # 🔒 Encrypted (git-crypt) — emails, IPs, usernames
├── bootstrap.sh # Fresh-machine install
├── backup-apps.sh # Export app configs into backups/
└── .gitattributes # git-crypt filter rules
flake.nix inputs: nixpkgs (unstable), nix-darwin, home-manager (master), and determinate for the Nix daemon.
Sensitive values live in secrets.nix, encrypted by git-crypt. The backups/ tree is encrypted by the same filter — app exports contain Wi-Fi networks and the Bluetooth device list.
git-crypt status # Check encryption status
git-crypt lock # Re-encrypt (rarely needed)
git-crypt unlock <key-file> # Decrypt after clone
Filter rules live in .gitattributes:
secrets.nix filter=git-crypt diff=git-crypt
backups/** filter=git-crypt diff=git-crypt
rebuild # alias: sudo darwin-rebuild switch --flake .#alex-mbp
nix flake check # Run all quality gates (see below)
nix flake update # Update inputs
nix develop # Dev shell: vulnix, nix-tree, nixfmt, nil
darwin-rebuild rollback # Rollback to previous generation
darwin-rebuild switch --flake .#alex-mbp --show-trace -v # Debug
nix-options (home/claude-code/scripts/nix-options.sh, packaged by home/claude-code/nix-options.nix) evaluates the options of the flake.lock-pinned nix-darwin, home-manager and determinate modules. "Declared in" maps to GitHub at the lock rev. Inside the Claude sandbox it falls back to a read-only store. Override with NIX_OPTIONS_FLAKE, --flake or --host.
nix-options show system.defaults.dock.autohide
nix-options show programs.git.settings
nix-options search dock
nix-options --json show system.defaults.dock.autohide
nix flake check runs every check below. They are the reason a broken module, a drifted Claude Code config or a stale README fails before it reaches the system.
| Check | What it enforces |
|---|---|
format-check | nixfmt --check over every tracked *.nix (find walk, no per-directory list) |
system-config | The whole alex-mbp darwin configuration actually builds |
agent-instructions | The shared instruction trunk actually reaches both rendered outputs: every shared section body present in CLAUDE.md and AGENTS.md, each heading exactly once, headings equal the declared trunk-plus-delta list in order, no mechanism Codex lacks named to Codex or smuggled through the trunk, the nix Docs Gate divergence pinned as Codex-inline only, each output under 100 lines, Project Map gone from both |
apex-consistency | The APEX skill keeps its critical clauses, flag casing, subagent isolation, and step-file references |
apex-plan-provenance | Every premise in an APEX plan carries [M] or [I] as its first token: the clause still stands in step-02-plan, and the line detector is run against two inline fixtures — one correctly tagged, one identical but for a stripped tag — so a detector that stopped detecting fails instead of passing. Presence is not truth: it proves the tag is THERE, never that it is earned; falsifying a tag is the examine reviewer's job and the Fable premises pass |
apex-tier | The built apex-tier classifier against throwaway git repos: a matcher added under hooks.Notification is direct, an entry added inside a deny = [ ... ] list is high, a permissionDecision branch in hooks/x.js is high, a 40-line README change is standard, a new .env.example is high. Canary M1 (the permission regex replaced by one that never matches) must turn the deny-list case away from high, proving that assertion rests on the permission class |
claude-config | Claude Code invariants: JSON parses, sandbox denies ~/.ssh and secrets, agents declare a model, haiku only on read-only agents, rules declare paths |
claude-mods | Claude Code mods (home/claude-code/mods.nix), offline structure only: names equal the folders under home/claude-code/mods/ both ways, each plugin.json parses and names its folder, each hooks.json names one existing module, no .js there, no sound / host process / network / file write / dynamic import / toast in any source and no deny in a hooks module, settings env.CLAUDE_CODE_PLUGIN_DIRS equal to the ~/.claude/mods/<name> folders, activation copying them with the DRY_RUN skip and the engine's types excluded. Canaries: the scan must flag $.audio.speak, the filter a .js name, the comparison an extra name. claude plugin validate --strict and claude plugin test on each mod folder are the code gate and run in the session (claude is not in the build sandbox) |
codex-config | Codex hook invariants: every command in the generated hooks.json names a script the module installs, both scripts pass node --check, hook order and matcher, registered timeouts above each script's own watchdog |
hook-wiring | Claude Code hook wiring, from the evaluated module rather than from text: every hook file home/claude-code.nix installs is named by a command in home/claude-code/settings.nix and every such command names a file that exists, both senses reported apart; additionalContext emitted only inside hookSpecificOutput, the one shape the reference documents; the hookEventName a hook writes equal to the event registering it. Each direction is guarded by a corpus-non-empty assertion first, because an extractor that stops matching would otherwise be green forever. The branch guards are also RUN against fixture repos, each under the timeout its registration gives the host: protect-main.js and block-main-bash.js must deny on malformed input and on a missing or hung git and stay silent off a protected branch, format-typescript.js must hand a $(…) file path to prettier unexpanded and must not call prettier --write when --find-config-path finds no prettier config, research-model.js must deny an Explore or codebase-navigator spawn whose model is not haiku (absent included) and stay silent on haiku, on other agent types and on malformed input, correction-budget.js must deny a correction brief whose model is not opus (absent included) before counting it, allow one more round per user grant, and deny a CronCreate or ScheduleWakeup prompt carrying an apex: +1 tour line while letting any other scheduler call through uncounted, correction-grant.js must grant one round only when the first non-empty prompt line is apex: +1 tour and the prompt carries no harness wrapper (task notification, agent hand-back), never on an agent_id of any value, malformed input or a token further down, never twice for one prompt nor over an existing grant file, and only to the project's live run (newest round, run dir under the cwd) once it is spent; the two hooks carry the same token line, asserted at eval. Canary mutants, which must turn their case red by a missed deny or a PWNED file and not by a crash, cover these branches only: malformed JSON, a missing git and the time budget in protect-main.js; malformed JSON, a broken git in the cwd or in a cd target, and the linear executor scan on a newline flood in block-main-bash.js; each of the two argv calls and the prettier-config gate in format-typescript.js; the absent-model branch and the agent-type set in research-model.js; the grant count, the opus check and the scheduler token check in correction-budget.js; the whole-line token, the first-line rule, the wrapper filter and the spent-budget test in correction-grant.js. The other cases are graded without a mutant |
js-lint | ESLint (pkgs.eslint, eslint:recommended rebuilt from builtinRules, node globals) over every tracked .js: the Claude hooks in home/claude-code/hooks/ and the Codex scripts; asserts the file count and that a canary with an unused variable turns it red |
readme-consistency | This file against the repo: the APEX flag table vs the skill, /apex examples typing only live flags, every .nix in modules/ home/ checks/ hosts/ present in the Structure tree, every check listed above, no dangling path, no alias documented that no attrset declares, no hard count |
trello-cli | The trello wrapper (home/claude-code/trello.nix). Text: skillTrello and cmdCard call only bare trello …, with no curl, no command substitution, no AUTH= and no secret cat; both keep the Tech & Pit test-write rule, the skill its contract/scope/handoffs sections, /card its stop/no-write branches and its comment-before-move order. Settings: Bash(trello ) allowed, no Bash(curl ), trello absent from excludedCommands, allowRead keeps both secret files, autoMode.environment has $defaults first plus the Trello line. Runtime: the built wrapper runs against a stub curl in 17 cases — key and token never in argv, URL or output (inherited SHELLOPTS=xtrace and secret-echoing success bodies included), the OAuth header arrives on stdin, ids validated before any call, secrets with embedded whitespace rejected, 401 and network errors exit 1. Canaries C1 (secret moved into the URL), C2 (id validation disabled) and C3 (set +o xtrace dropped) must each be killed by their target case |
Run them before every commit that touches .nix files — format-check in particular fails on formatting alone.
readme-consistency compares this document against live sources rather than against a copy of its own expectations: a check holding its own copy of the truth rots at the same rate as the thing it checks. It deliberately does not require the modules inside home/claude-code/ to be listed individually — that directory is documented as one unit.
Reclaim disk space by garbage-collecting old generations and deduplicating the Nix store, then prune Homebrew caches.
sudo nix-collect-garbage -d # Delete all old generations (system + user)
nix-store --optimise # Hard-link identical files in the store
brew autoremove # Remove unused brew dependencies
brew cleanup -s --prune=all # Purge all download caches and old versions
Note: if
sudo nix-collect-garbage -dwarns$HOME is not owned by youand falls back to root's profile (leaving user generations behind), run the user sweep without sudo as well:nix-collect-garbage -d.
Each row points at the module that owns it — that file is the authoritative list, deliberately not duplicated here.
| Layer | Tool | Contents |
|---|---|---|
| CLI tools | Nix (modules/packages.nix) | eza, bat, fd, ripgrep, fzf, atuin, zoxide, btop, jq/yq, git-crypt, gh, nodejs, pnpm, bun, python3, uv, ruff, sqlite, postgresql, redis, nixd/nil/nixfmt … |
| GUI apps | Homebrew casks (modules/brew.nix) | 1Password, Arc, Ghostty, VS Code, Zed, Docker, Raycast, Obsidian, Figma, Jellyfin, Tailscale … apps without an auto-updater are marked greedy |
| Formulae & taps | Homebrew (modules/brew.nix) | cloudflared, ffmpeg, displayplacer, mas, postgresql@16, trash, rtk-ai/tap/rtk |
| App Store | mas (modules/brew.nix) | DaisyDisk, Trello, iWork (Keynote/Numbers/Pages), Microsoft Office, Affinity Photo & Publisher |
| Fonts | Nix (modules/ui.nix) | Nerd Fonts (JetBrains Mono, Meslo LG, Hack, Fira Code, Sauce Code Pro), Cascadia Code, Inconsolata, Noto (+ CJK, emoji) |
| macOS defaults | Nix (modules/ui.nix) | Dock, Finder, trackpad, clock, screensaver, Window Manager, wallpaper |
| Services | Nix (modules/services.nix) | weekly flake update, throttled brew update, power tuning (pmset), network/TCP tuning |
| System | Nix (modules/system.nix) | Nix daemon settings, binary caches, env vars, application firewall |
| Terminal | home-manager (home/ghostty.nix) | Catppuccin Macchiato, quick terminal, keybindings |
| Editor | home-manager (home/vscode.nix) | Settings, keybindings, extension list |
| Shell | home-manager (home/zsh.nix) | zsh + fzf + zoxide + autosuggestions + syntax highlighting + aliases |
| Git | home-manager (home/git.nix) | SSH signing, rebase-on-pull, fsck |
| SSH | home-manager (home/ssh.nix) | Host configs (Tailscale) |
| Claude Code | home-manager (home/claude-code/) | settings, hooks, agents, skills, commands, rules, mods, shell aliases |
| Codex CLI | home-manager (home/codex/) | hooks.json + hook scripts (store symlinks), config.toml merge, hook-trust verification |
| Secrets | git-crypt (secrets.nix, backups/) | Git email, SSH hosts, app config exports |
Claude Code mods. home/claude-code/mods.nix lists the mods under home/claude-code/mods/ (TypeScript plugins of function hooks, no dependency, no sound). Activation copies them into ~/.claude/mods as real writable files — that folder is nix-owned, a hand-placed mod there is deleted on the next rebuild — and env.CLAUDE_CODE_PLUGIN_DIRS loads them. task-board: /task-board opens a "Tâches" pane listing background shells and subagents with their duration and state (en cours / fini / échoué); a subagent's still running shells turn échoué when it is killed, fails, or leaves the agent list (a teammate's: only when it leaves the list), since their notification would never reach the main loop; a shell also closes on its subagent's own notification row and on a TaskStop. apex-band: a band above the prompt shows the live APEX run of the working directory (title, mode, current step, branch, baseline) and nothing otherwise. status-bar: replaces the command status line. Drawn on the hint line under the prompt, it shows the model, folder, git branch, the last response's tokens in/out, the context bar with its percentage, and the 5h and 7d quota bars with their percentage and reset countdown: one row when it fits the width, else two, with the engine's own hint line still beneath. Read-only: the branch is read from .git/HEAD, no process is spawned. Run yourself after a rebuild, in a new session: /task-board, a background sleep 5 going from en cours to fini, a failing background command shown échoué, the band present during an APEX run and absent elsewhere, the status bar under the prompt on one row and on two in a narrow window.
Codex records hook trust per hook and by position, inside ~/.codex/config.toml, as a [hooks.state."<file>:<event>:<group>:<hook>"] table carrying a hash of the hook's content. A hook it has not been told to trust is skipped in silence — no error, no log, no protection, while the configuration still looks correct. That is why ~/.codex/config.toml is merged in place and never regenerated, and why home/codex/hooks.nix requires new hooks to be appended only at the END of an event's list.
Nothing outside Codex can grant that trust, and no Codex command reports it (codex doctor has no hook check). So the sequence after any change to the hooks is manual, and short:
sudo darwin-rebuild switch --flake .#alex-mbp — activation merges config.toml, links hooks.json, then runs codex-verify-hook-trust./hooks, review each hook and trust it.codex-verify-hook-trust -a. It writes the hash under ~/.local/state/, outside the agent's writable set, so nothing running inside a session can forge its own clean bill of health. Activation never passes -a: the review it records is a human act.After that, rebuilds are silent until the hooks' content changes — then the warning returns and step 3 has to happen again.
The verifier never claims a hook IS trusted at runtime; it can only report that one is un-approved or stale. Its silence is not proof of protection.
~/.ssh/id_ed25519*)~/.local/bin/vscode-install-extensions)nix flake check greenAPEX is the implementation workflow for Claude Code, declared in home/claude-code/skills.nix and guarded by checks/apex-consistency.nix. Every task that modifies files goes through it; a pure question does not.
Each phase runs as a fresh subagent and returns only a bounded summary, so the coordinator never accumulates raw context. Phase summaries are persisted to .claude/output/apex/{task-id}/.
The gate picks the depth, never whether to run. Each mode carries a default flag set, applied to every flag you did not type. The tier (Direct, Standard, High-stakes) is decided on the diff, not on the brief: apex-tier (home/claude-code/apex-tier.nix) reads its size, its paths and the indentation ancestry of each changed line.
| Mode | Default flags | Notes |
|---|---|---|
| Direct | -pr | ≤ 4 files, ≤ 30 changed lines, no sensitive surface |
| Diagnosis | -x -pr -o -n | bug/crash — reproduce first, debugger agent implements, ships as a PR |
| Standard | -t -pr -o -n | full orchestration |
| High-stakes | -t -x -pr -o -n -e | irreversible / security / architecture / prod — one examine reviewer, then Codex -e as read-only detector whose findings are triaged by evidence; Fable only as fallback when no usable external verdict (BLOCKED or -E), or under -p |
| Pure research | none | analyze only, no branch |
Lowercase forces ON, uppercase forces OFF (-PR cancels an automatic -pr). Typed flags beat mode defaults.
| Enable | Disable | Description |
|---|---|---|
-q | -Q | Clarify — ambiguities become up to 3 targeted questions before planning |
-x | -X | Examine — adversarial, checklist-driven review |
-t | -T | Test — create and run tests |
-f | -F | Test-first — a separate agent writes failing tests from the ACs; read-only for the implementer |
-2 | Divergence — second independent implementation of the core logic, behavioural diff | |
-p | -P | Premises — force/forbid the independent premises pass |
| -e | -E | External verify — one cross-vendor read-only pass (Codex/GPT) over th
hooks/index.tsx 379 lines1// 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}
379hooks/board.ts 276 lines1// 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}
276hooks/format.ts 120 lines1// 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}
120types/index.d.ts 32 lines1// 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