Maintenant dans Cockpit (onglet Machine, /cockpit machine : charge du PC par ce même échantillonneur, charge du runner par lib/core/runner.mjs) ; ce mod reste…

Mods for Claude Code: small panes that show what your agents do. They never block or rewrite a tool call; at most they warn.
Needs Claude Code 2.1.287 or later (function-hook mods, panes, Svg element).
The band's Mods bar has 6 entries (Julian, 16 Oct). Each older mod is now shown inside one of them; its own command still works (while it is loaded it answers itself, once removed the hub registers the same name as an alias).
| Entry | Command | Hub mod | Shows inside it |
|---|---|---|---|
| Jauge | /jauge | band | the band above the prompt and its pane |
| Cockpit | /cockpit | cockpit | zoomable méga › projet › conversation › agent with one summary card per topic (Qui consomme, T’attend, À toi, CI, Quota, Problèmes du jour, En cours) and the Usine and Machine tabs; older mods: factory, machine, usage |
| Veille | /veille | veille | tabs Alertes, Fil, À archiver, Export eta; older mods: export-eta |
| À toi | /afaire | a-faire | what only you can do |
| Registres | /registres | registres | tabs Problèmes, Tests réels, Règles, Ménage; older mods: problemes, tests-reels, metrics, menage |
| Référence | /reference | reference | tabs Config, Guide, Scénarios, Santé, CI; older mods: control, guide, scenarios, sante, ci |
filetree (external) stays apart: its own Fichiers tab next to Cockpit.
| Mod | Command | What it shows | |
|---|---|---|---|
band | `/jauge [refresh\ | close]` | Jauge, above the prompt (French labels). Export conv and, when the app links sub-conversations to this one, Export + enfants +N on the Jauge line (26 Oct): one masked Markdown brief for another AI (what the conversation is, where it stands and what is next, live ETA, git, PRs/MRs, every message and tool call), saved under ~/.claude/export-eta/briefs and copied to the clipboard, by export-eta's exporter/brief.mjs; labels in export_brief. Clarity layout (default, 25 Oct): folded to ONE line « ▶ ● 3 agents en cours · ctx ▰▰▱▱▱ 30 % · 5 h · sem » coloured by state; ▶/▼ unfolds sections (En cours · cette conversation as a ├ └ tree with bars, %, tokens, ETA; Jauges; Ailleurs sur ce PC; Mods), state kept in $.store; jauge.layout: "line" keeps the older line. One line always visible: a slim context bar (only the next threshold named) then « ctx 8 % · compact ~16 min · 434 tok/s · 1 compactage(s) · 5 h 24 % · sem 14 % », then « À toi : N » and « attend Julian : N » once each, red when N > 0 (counted by lib/core/veille.mjs in the scanner, the same count Cockpit and Veille show), and a ▸ that unfolds « Cette conversation » (model · effort, agents running/seen, Workflow, PR). The Usage and Veille sections left the band: 5 h / week details are in Cockpit's Quota card, alerts in Veille. Under it, the Mods bar with the 6 entries (launcher.preset: "hubs", default): Jauge, Cockpit, Veille, À toi, Registres, Référence; a hub not loaded is greyed and reads « non chargé : ajouter mods/<x> à CLAUDE_CODE_PLUGIN_DIRS »; launcher.preset: "all" brings back the 15 older buttons. /jauge adds the per-scope table (tok/s, /min, /h, list-price $/h, over 60 s and 10 min), the 5 hottest sessions with their compactions, projects, 30-minute sparklines with compaction markers, each conversation's compactions, and the hand-off helper (RESUME). Optional status-line entry. |
| Mod | Command | What it shows | ||||||
|---|---|---|---|---|---|---|---|---|
cockpit | `/cockpit [mega\ | macro\ | medium\ | micro] [projet ou conversation], /cockpit close` | Cockpit, one side pane ordered by scope (French labels), each section foldable to one summary line: Cette conversation (open and selected at start: model · effort, ctx, state, the lead tree holding it through the app's spawnedFrom link → sub-conversations → Workflows → agents, finished ones folded behind « + N terminé(s) », step, PR/CI, an agent card with its log when an agent is picked), Ce projet (its conversations as a tree, even lanes in another repo, factory's Focus + Pipeline), Tous les projets (two lines per project), Claude global (5-hour, week, Fable week, credits, this session's cost, model share, cache hit, Veille alerts, a /machine button). A small breadcrumb « Méga › Projet › Conversation › Agent » zooms out; a click drills down. The context gauge lives in band, above the prompt. Opens by itself at session start, as a tab next to filetree (the desktop shows one pane at a time). Sections, order, default level and auto-open in cockpit.config.json. It reuses factory, band and veille through byte copies in lib/ (a mod may import only its own folder; scripts/sync-libs.mjs, drift-tested). | |||
factory | `/factory [mission\ | focus\ | arbre\ | pipeline\ | quotas\ | systeme\ | journal], /factory <title or id>` | Side panel of all your agents (v2, French labels), tabs on top and every screen stacked top to bottom at the pane's width (no fixed-size drawing, no zoom). Mission: counters (working, idle, blocked with the reason, waiting on you, done today, cost today), one card per project (lead, sub-conversations, running agents, context %, model and effort), model share vs the V6 targets, and the conversation list with the All / Live / Problems / Waiting filters. Focus: the Ça t'attend rows (green PR/MR not merged, question or action waiting on you, lane idle on a red, conflicting or skipped PR, sub-conversations per lead; one native notification per new row), fleet pulse, live timeline (compactions, context over the 250K hand-off mark, blocked, quiet, merged, handed off) and progress today per project. Arbre (this conversation or one picked from the list): lead with model, effort bars, price per 1M and context, its sub-conversations, Workflows and agents with model, effort, state, tokens and cost, the architect on call (before a plan, error repeats, before done, calls and tokens read), the bill (every turn vs on call), approvals and guards, warnings, the session log and a status line. Pipeline: work in flight per stage (plan, build, review, test/CI, merge) per project. Quotas: Claude 5-hour and weekly bars with the forecast from the usage mod, Codex when available, cost today per model and per project. Système: CPU, RAM, GPU, disks and GitLab runner load from the machine mod. Journal: every session's events, filterable by kind and project. Without the usage or machine mod those boxes say « module non chargé ». Labels, thresholds and which screens show are in factory.config.json → screens and waiting. Screen ideas (counters, Focus, timeline, account pool) from rig-console in korallis/agent-stack (Apache-2.0); no code copied. |
machine | `/machine [refresh\ | ci]` | Machine, a live view of this PC (French labels): CPU total and per core, RAM, free space per fixed drive, GPU (nvidia-smi, else a Windows performance counter, else « non disponible »), top 5 processes by CPU and by memory with claude, node, python and git coloured and totalled, and the local GitLab runner's load (runners online, jobs running / pending) through the ci mod's own scanner and config. From these, « agents en parallèle conseillés maintenant : N » with the rules that fired (CPU, RAM, disk, pending jobs) and the caps (4 CI pipelines minus running jobs, 3 npm ci / tsc). Samples only while its pane is open (every 5 s) or its optional band entry is on (every 15 s, off by default). | |||||
usage | `/conso [aujourdhui\ | semaine\ | classement\ | prevision\ | scenarios\ | plein\ | codex] [refresh]` | Consommation, in French: Classement ranks who burned the most (1 h, 4 h, aujourd'hui, 2 j, 7 j, 30 j; by conversation, project, model, agent type, feature or Claude/Codex; tokens by kind, cost equivalent, share, trend vs the window before, subagents counted under their parent); tokens and list-price cost equivalent today and over the week, per hour (heatmap), project, model, kind (main thread, Agent subagent, Workflow agent, sub-conversation), app surface, effort, skill and MCP server, top 10 sessions. Forecast of the 5-hour and weekly limits from the official % ($.session.usage()) and the measured pace (last 2 h, today, 7 days) with calme / normal / intense projections; which scenario (S1-S5) fits the quota left before the reset; suggests S5 "Plein régime" when much of the week would be left. Optional status-line entry. Codex tab: Codex tokens today and over 7 days, per model, and the plan % Codex writes in its local session files (~/.codex/sessions/**/rollout-*.jsonl, the files the codex-hud plugin reads), next to Claude's on one line; « non disponible » when nothing is written (option codex: config/on/off). Its parser and price table are a shared module (mods/usage/lib). |
| Mod | Command | What it shows | |||||
|---|---|---|---|---|---|---|---|
veille | `/veille [fil\ | mode <x>\ | ack <clé\ | all>\ | coordinateur\ | tour]` | Veille, French labels: Fil (/veille fil, the pane's « Fil » tab): every conversation's last message in one place, zero model tokens (files only, nothing sent to a model or added to a prompt). Grouped by project (foldable, « 5 conv · 2 en cours · 1 attend Julian · dernière activité 20:41 »), then lead → sub-conversations (the app's spawnedFrom link, a lane in another repo stays under its lead), by last activity. Folded: title, state (en cours / au repos / attend Julian / bloquée / terminée), model · effort, time, the first two lines of the last message (the end-of-turn wrap_up recap when the turn has one, else the last assistant text; tool calls left out). Unfolded: the full message (Markdown), the ask and its buttons, PR/MR links, « Ouvrir la conversation » and « Copier l'id ». « Nouveau » since your last read ($.store), filters Tout / Attend Julian / Bloquées / Terminées aujourd'hui / Projet, a red « attend Julian : N ». Token-like strings are masked. The watcher writes ~/.claude/veille/fil.json at every fire; the pane reads it and refreshes only on « Rafraîchir ». a zero-token watcher script (Windows task ClaudeVeille, every 5 min, runs with Claude closed) tours conversations, PRs/MRs and CI with the same sources and rules as factory's « Ça t'attend » (its scanner and waiting.mjs imported, not copied), and writes alerts this pane shows: severity, since when, conversation or PR, link, Ack button, last tour (red when two fires were missed), every action with its reason. Modes (config, pane buttons, /veille mode): rien, légère (30 min, notify only), active (5 min, + wake), nuit-active (10 min, the script merges a green up-to-date PR/MR itself: every check success, never skipped, cancelled, manual or 0 jobs; + wake; notifications only for what needs Julian), nuit-test-légère (30 min, notify, small token budget), each with what it may do, a token budget per hour and quiet hours. A conversation blocked on Julian: Windows notification and an e-mail digest (SMTP, your ~/.claude/veille/smtp.json). Waking a conversation (claude -p --resume <id>, off by default) only for what the script cannot do. One line above the prompt with the mode. See veille. |
export-eta | `/export-eta [en-cours\ | lot\ | tout\ | brief [enfants]\ | close] (also /export` when the built-in leaves it free) | « Export eta », zero model tokens: exports conversations to the eta repo as Markdown through a PR. Brief (/export-eta brief [enfants], the band's Export conv / Export + enfants buttons): one masked Markdown file about a conversation and its sub-conversations for another AI, local + clipboard, no PR. En cours (snapshot of every ongoing conversation), Sélection (checkboxes per project, foldable), Lot terminé: each Veille tour counts the conversations finished since the last export; at batch_min (3) or when a lead and all its sub-conversations are finished, « 4 conversations terminées — Exporter vers eta » shows with a button in Veille (Alertes and Fil) and above the prompt (auto: true exports without a click). Each file: header (project, folder, branch, model · effort, start, last activity, state, parent, sub-conversations, PRs), then the messages in order, one line per tool call, tool outputs and file dumps left out, agent results in one line; split into parts past max_kb (300 KB). A confidentiality pass masks tokens, keys, passwords, cookies, connection strings, JWTs, private keys and e-mail addresses (not yours), then re-checks: a file with an unmaskable hit is held back and listed. Layout sources/conversations/<project>/<date>-<title>-<id>.md + INDEX.md per project and global; a re-export rewrites only its own file. Git: fresh worktree of the eta repo, branch, PR, squash merge (green checks, or none and a clean pass), branch deleted, worktree removed. The pane lists the last exports (date, count, PR, masked counts, held-back files). The eta path lives in ~/.claude/export-eta/config.json, never in this repo. |
| Mod | Command | What it shows |
|---|---|---|
a-faire | /afaire [refresh] | « À faire par Julian », in French: every action only you can do, so none gets lost, by collapsible category with a one-line summary each: Configuration (veille's smtp.json missing or incomplete, mods installed but not in CLAUDE_CODE_PLUGIN_DIRS, the ClaudeVeille task missing or not firing), Décisions (open rows and « Your action » lines of your pending-decision files, your open PRs labelled or saying in one sentence that they wait for Julian to approve or decide, questions.md lines marked for you), Connexions / 2FA, Secrets à ranger (tests-reels registry, counts only), Tests manuels (manual-checks.md), Questions des conversations (veille's « attend Julian » alerts, with the conversation link). Each item: why it blocks, the exact steps with a Copier button, since when, its source, a priority (bloque une conversation > bloque une fonction > confort). Fait re-checks what can be measured (fill smtp.json and the item closes by itself; until then it says why not) and marks the rest done; Plus tard hides it one day. Read-only except its own ~/.claude/a-faire. The band shows a red « À toi : N » on its first line and the « À toi » entry in its Mods bar; the veille watcher pushes each new high-priority item once (Windows notification, and its e-mail when smtp.json works). Sources and patterns in afaire.config.json. |
| Mod | Command | What it shows | ||||
|---|---|---|---|---|---|---|
registres | `/registres [problemes\ | tests\ | regles\ | menage\ | close]` | « Registres », one pane with tabs Problèmes, Tests réels, Règles, Ménage, each drawn by a byte copy of its owner's pure view on the owner's files (see its README). /problemes, /tests, /regles, /menage become aliases when their old mod is not loaded. Santé and Veille errors reach the Problèmes tab. |
problemes | `/problemes [refresh\ | P-NNN], /problemes close` | « Problèmes », in French: a persistent register of every error and problem with Claude work, so the same issue is understood once and never re-discovered. A zero-token collector (scanner/problemes_scan.mjs collect, run when the pane opens and the register is older than 30 min, on ↻, and by the Veille watcher each tour) reads only the bytes appended to the transcripts since its last run (sessions, sub-agents, Workflow agents) and the desktop app's session files, and spots AskUserQuestion calls, turns that end on a question, the app's needs_action, permission refusals, folders and rules granted, app and worktree guards, protected rm, BLOCKED: finals, Workflows with agents_error, failing or skipped gh / glab checks, API errors. Configurable rules classify each hit; hits with the same signature (rule + app or key) are one problem whose count and last-seen go up; a fixed problem that comes back is reopened. Seeds import your existing registers once per row (Markdown tables, incidents/findings JSON, from a file or git show). The pane: « N ouverts · M corrigés · cette semaine +K », then collapsible categories with one-line summaries (Blocages manuels, Workflow, Règles, App Desktop / Claude Code, CI, Par application, Mods), each problem expandable to symptom, cause, fix, evidence, timeline and notes; filters by status, period, category and app; « Ajouter une note » appends to the local ledger. Data only in ~/.claude/problemes/. | |||
tests-reels | /tests [refresh], /tests close | « Tests réels », French labels, read-only: Julian's registry of real (non-CI) tests per application (test tenant runs, Cloudflare Access / Workers checks, Power BI Desktop / SSAS / Docker local runs, UI automation, manual checks). Top to bottom, each block folded to one summary line: totals, À ranger (accounts whose secret is not stored, missing ids, secrets seen in clear in the history: kind, session and date only), then one block per app with environments (tenant / cloud / local / docker, URLs, short ids), test accounts masked until « voir » with only where the secret lives (Windows Credential Manager target), procedures with their last run (green / red / never), last runs, manual checks and « Trouvé dans l'historique ». Data lives only in ~/.claude/tests-reels (never in this repo); cli.mjs add / run-log record environments, accounts, procedures and runs and refuse any secret value; scanner/recover.mjs rebuilds what the transcripts, manual-checks.md, handoff/ and reports/ say. See its README. | ||||
metrics | /regles [V5.8] [refresh] | « Les règles marchent ? », in French: whether the rules help, per day over 30 days and per rules version. PRs/MRs merged per day across your repos (gh + glab), tokens and list-price cost per merged PR (M8, sessions attributed to a repo by their folder), share of Opus / Sonnet / Haiku turns, Agent and Workflow agent() calls that name a model (M1), main turns at ≥ 250 K (M2), manual stops, permission refusals (M10), app guard hits, CI gaps (« creux », approximate), BLOCKED / red-twice stops, wake-up calls (M6), compactions. Sparklines with the rules versions from the rules file's git log as │ markers, an « avant / après » table for any version and a summary row per version. Thresholds, targets, labels and detection patterns in metrics.config.json; caches in ~/.claude/metrics-mod, incremental. | ||||
menage | /menage [refresh] | « Ménage », in French: what Claude work left lying on this PC, read-only. For every git repo under your roots: each worktree (branch, merged into main or not, clean or modified, last commit age, disk size), worktree folders git no longer lists, local and remote branches already merged (fast-forward, or squash: same content or same patch-id in main, or a merged PR in the desktop app's session files); plus unloaded copies in ~/.claude/mods, old deploy backups, transcript disk use per project (and projects whose folder is gone), old session temp folders, and desktop sessions that can be archived (idle, every PR merged). Totals of reclaimable space. Each row shows the exact safe command (git worktree remove without --force, git branch -d, …) with a Copy button; the mod never runs it. Modified or unmerged items are red « ne pas supprimer ». Roots, thresholds, command templates and labels in menage.config.json. |
| Mod | Command | What it shows | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
reference | `/reference [config\ | guide\ | scenarios\ | sante\ | ci [diagramme]\ | close]` | « Référence », one pane with tabs Config, Guide, Scénarios, Santé, CI, each drawn by a byte copy of its owner's view on the owner's caches (see its README). Appliquer ce scénario writes the chosen per-role model and effort to ~/.claude/scenarios/applied.json and shows the diff against settings and agent files; it never edits them. /config-claude, /control, /mods-guide, /scenarios, /sante, /ci become aliases when their old mod is not loaded. | |||||
control | `/control [setup\ | usage\ | practices\ | history\ | config] [scan], /config-claude [scan]` | CONFIG (/config-claude, also a tab of /control): everything in Claude Code on this machine, read-only, French labels, top to bottom in the side panel, as a tree category → sub-category → item, each item with its path, modified time, size and full content (Markdown rendered or raw), a search box across names, fields and full text, and a « Changements » list (config files modified in the last 7 days). Ten categories: rules and instructions (global and project CLAUDE.md / CLAUDE.local.md / AGENTS.md, .claude/rules, output styles, memory), agents (user, project, plugin, built-in from the docs), skills / commands / workflows, settings merged by precedence (managed > CLI > local > project > user, docs: value, file that set it, files it overrides; lists merged), documented defaults with their doc URL (« non vérifié » when the docs don't say), hooks and permissions, mods and plugins (loaded or not, version, commands; old copies in ~/.claude/mods flagged), MCP servers (names, command or URL, scope; env and header names only), desktop app preferences (an allow-list of keys; the rest « non lisible depuis un mod »), Claude Control (rules source, deploy.py, last deploy backup, RULES-INVENTORY.md, and a badge when ~/.claude differs from the repo). Secrets are masked before anything is written: any settings key matching `token\ | secret\ | passw\ | key\ | auth\ | credential\ | cookie, every ANTHROPIC_* value, token-looking strings; process env vars by name only. SETUP, USAGE, PRACTICES, HISTORY: read live at each scan, so it never goes stale with a rules version. SETUP: « Règles actives » (the title line and version of ~/.claude/CLAUDE.md, e.g. V5.8), blockers (hook, deny rule, mod deny path), « Mods chargés » (each mod folder: loaded by CLAUDE_CODE_PLUGIN_DIRS, installed not loaded, or a copy of a mod loaded elsewhere), hidden rules, then settings (env names only, a value only for an allow-list), settings hooks, permissions, repo CLAUDE.md, agents, skills, workflows, hook scripts, scheduled tasks, MCP servers, plugins, memory. USAGE: 1/7/30-day model, effort and delegation mix, each metric against the live rule line it measures. PRACTICES: one section per heading of the live CLAUDE.md, each line with the evidence it cites (dates, PRs, audits, PRACTICES ids) and its measured state, plus a « V6 en attente » panel (kept / changed / dropped) when the rules repo has docs/V6-CHANGES.md` on main. HISTORY: the rules' version history. |
guide | /mods-guide [onglet ou section] | Mode d'emploi des mods, in French: tabs (Sommaire, Bases, Événements, Interface, Configuration, Installer et activer, Tester, Écrire un mod pas à pas, Référence API, Nos mods, FAQ), foldable categories, and one card per mod of this repo built live from its plugin.json and hooks module. Same content in MODE-EMPLOI.md. |
| scenarios | /scenarios [S1..] [prompt\|agent\|workflow\|frontmatter\|env] | Scénarios d'agents: presets of roles -> model, effort, unit (session, sub-conversation, Workflow agent(), Agent subagent) and max parallel (S1 Standard V6, S2 Économe, S3 Qualité max, S4 Nuit, S5 Plein régime, plus yours from a JSON file). For each role, the exact settings (Agent call, agent() opts, agent-file frontmatter, env vars) and the documented
hooks/register.tsx 422 lines1// machine mod ("Machine"): live view of this PC, read-only.
2// /machine opens the pane: CPU (total, per core), RAM, disks, GPU, top processes (claude, node, python,
3// git highlighted), GitLab runner load (through the ci mod's scanner) and
4// « agents en parallèle conseillés maintenant : N » from the rule in machine.config.json
5// /machine refresh the same, sampled now; /machine ci checks the runner now (ci.py --refresh)
6// Sampling (scanner/machine.py) runs only while the pane is open, every interval_s (default 5 s), or every
7// band.interval_s when the optional band entry is on (userConfig band, off by default). Nothing here blocks,
8// rewrites or delays a tool call, and nothing is added to any prompt.
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, Register } from 'claude-code'
11
12import type { GpuInfo, RunnerInfo, Sample } from '../types'
13import {
14 advise,
15 bandParts,
16 bar,
17 ciText,
18 coreBar,
19 deepMerge,
20 expandHome,
21 fmtBytes,
22 fmtPct,
23 gpuText,
24 hms,
25 intervalMs,
26 level,
27 metrics,
28 parseArgs,
29 summary,
30} from './logic'
31import type { Config, Part } from './logic'
32
33const PANE = { id: 'machine' }
34const sampleAtom = atom({ plugin: 'machine', key: 'sample' } as const, null)
35const gpuAtom = atom({ plugin: 'machine', key: 'gpu' } as const, null)
36const runnerAtom = atom({ plugin: 'machine', key: 'runner' } as const, null)
37const errorAtom = atom({ plugin: 'machine', key: 'error' } as const, '')
38
39const DOCS = {
40 mods: 'https://code.claude.com/docs/en/plugins/mods/overview',
41}
42
43type Options = { config?: string; band?: string }
44
45let options: Options = {}
46let cfg: Config | undefined
47let cfgProblem = ''
48let timer: { cancel: () => void } | undefined
49let timerMs = 0
50let paneOpen = false
51let sampling = false
52let runnerBusy = false
53let prevRaw: Sample['raw'] | undefined
54let lastGpuAt = 0
55let lastRunnerAt = 0
56
57const home = async ($: EngineInterface) => {
58 let v: string | undefined
59 try {
60 v = await $.env.get('USERPROFILE')
61 } catch {}
62 if (!v) {
63 try {
64 v = await $.env.get('HOME')
65 } catch {}
66 }
67 return v ?? ''
68}
69
70const loadConfig = async ($: EngineInterface): Promise<Config> => {
71 if (cfg) return cfg
72 const root = $.plugin.root.replace(/\\/g, '/')
73 let base = {} as Config
74 try {
75 base = JSON.parse((await $.fs.read(`${root}/machine.config.json`, { as: 'text' })) as string)
76 } catch (err) {
77 cfgProblem = `machine.config.json: ${String(err).slice(0, 120)}`
78 }
79 if (options.config?.trim()) {
80 const p = expandHome(options.config.trim(), await home($))
81 try {
82 base = deepMerge(base, JSON.parse((await $.fs.read(p, { as: 'text' })) as string))
83 } catch (err) {
84 cfgProblem = `${p}: ${String(err).slice(0, 120)}`
85 }
86 }
87 cfg = base
88 return base
89}
90
91const scanner = ($: EngineInterface) => `${$.plugin.root.replace(/\\/g, '/')}/scanner/machine.py`
92
93/** One sample; the previous raw counters go back in so the % cover the time since the last sample. */
94const sampleNow = async ($: EngineInterface) => {
95 if (sampling) return
96 sampling = true
97 try {
98 const c = await loadConfig($)
99 const now = await $.clock.now()
100 const fresh = prevRaw && now - prevRaw.t * 1000 < 3 * Math.max(timerMs, intervalMs(c))
101 const wantGpu = c.gpu.source !== 'off' && now - lastGpuAt >= c.gpu.refresh_s * 1000
102 const req = {
103 prev: fresh ? prevRaw : null,
104 first_delta_ms: c.first_delta_ms,
105 top_n: c.top_n,
106 families: c.families,
107 runner_process: c.ci.runner_process,
108 disks_exclude: c.disks_exclude,
109 gpu: wantGpu,
110 gpu_source: c.gpu.source,
111 gpu_perf_counter: c.gpu.perf_counter,
112 }
113 const r = await $.process.run([c.python, '-I', scanner($), 'sample'], { stdin: JSON.stringify(req), timeoutMs: 30000 })
114 let s: Sample | undefined
115 try {
116 s = JSON.parse(r.stdout)
117 } catch {}
118 if (!s || r.exitCode !== 0) {
119 await update($, errorAtom, () => `machine.py : ${(r.stderr || r.stdout || `exit ${r.exitCode}`).trim().split('\n').pop()?.slice(0, 200)}`)
120 return
121 }
122 prevRaw = s.raw
123 const { raw: _raw, ...clean } = s
124 if (wantGpu) lastGpuAt = now
125 await update($, sampleAtom, () => clean as Sample)
126 if (s.gpu) await update($, gpuAtom, () => s!.gpu as GpuInfo)
127 await update($, errorAtom, () => '')
128 } catch (err) {
129 await update($, errorAtom, () => `machine.py : ${String(err).slice(0, 200)}`)
130 } finally {
131 sampling = false
132 }
133}
134
135/** GitLab runner load through the ci mod's scanner (its config, its cache); in the background, never overlapping. */
136const runnerSoon = ($: EngineInterface, force = false) => {
137 if (runnerBusy) return
138 runnerBusy = true
139 $.clock.after(0, async () => {
140 try {
141 const c = await loadConfig($)
142 const now = await $.clock.now()
143 if (!c.ci.enabled || (!force && now - lastRunnerAt < c.ci.refresh_s * 1000)) return
144 lastRunnerAt = now
145 const h = await home($)
146 const args = [c.python, '-I', scanner($), 'runner']
147 if (c.ci.scanner) args.push('--ci', expandHome(c.ci.scanner, h))
148 if (c.ci.config) args.push('--ci-config', expandHome(c.ci.config, h))
149 for (const b of c.ci.backends) args.push('--backend', b)
150 if (force) args.push('--refresh')
151 const r = await $.process.run(args, { timeoutMs: 120000 })
152 let info: RunnerInfo | undefined
153 try {
154 info = JSON.parse(r.stdout)
155 } catch {}
156 await update($, runnerAtom, () => info ?? { generated: null, backends: [], error: `exit ${r.exitCode}` })
157 } catch (err) {
158 await update($, runnerAtom, () => ({ generated: null, backends: [], error: String(err).slice(0, 160) }))
159 } finally {
160 runnerBusy = false
161 }
162 })
163}
164
165const tick = async ($: EngineInterface) => {
166 await sampleNow($)
167 runnerSoon($)
168}
169
170/** Pane open: every interval_s. Pane closed and band on: every band.interval_s. Otherwise no timer at all. */
171const schedule = async ($: EngineInterface) => {
172 const c = await loadConfig($)
173 const want = paneOpen ? intervalMs(c) : options.band === 'on' ? intervalMs(c, true) : 0
174 if (want === timerMs && (timer || !want)) return
175 timer?.cancel()
176 timer = undefined
177 timerMs = want
178 if (want) timer = $.clock.every(want, () => void tick($))
179}
180
181export const register: Register = (on, raw) => {
182 options = (raw ?? {}) as Options
183 cfg = undefined
184 timer?.cancel()
185 timer = undefined
186 timerMs = 0
187
188 on('session.start', async ($, e, next) => {
189 const started = await next(e)
190 await $.command.register({
191 name: 'machine',
192 description: 'Machine: CPU, RAM, disques, GPU, top processus, charge du runner GitLab et agents en parallèle conseillés (args: [refresh|ci])',
193 argumentHint: '[refresh|ci]',
194 })
195 if (options.band === 'on') {
196 await schedule($)
197 void tick($)
198 }
199 return started
200 })
201
202 on('command.run', { command: 'machine' }, async ($, e) => {
203 const p = parseArgs(e.args ?? '')
204 const c = await loadConfig($)
205 await $.ui.open({ id: PANE.id, title: c.labels.pane_title ?? 'Machine' })
206 paneOpen = true
207 await schedule($)
208 if (p.refresh) prevRaw = undefined
209 await sampleNow($)
210 runnerSoon($, p.ci)
211 const s = await read($, sampleAtom)
212 const err = await read($, errorAtom)
213 return { text: `${summary(s, await read($, gpuAtom), await read($, runnerAtom), c)}${err ? `\nerreur : ${err}` : ''}${cfgProblem ? `\n${cfgProblem}` : ''}` }
214 })
215
216 // The pane closed: stop sampling (or fall back to the band's slower pace).
217 on('ui.close', async ($, e, next) => {
218 const r = await next(e)
219 if (e.id === PANE.id) {
220 paneOpen = false
221 await schedule($)
222 }
223 return r
224 }).catch(($, e, next) => next(e))
225
226 // Optional compact entry in the band, below what the mods after this one draw there (theirs kept).
227 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
228 if (options.band !== 'on' || e.props.hasSurvey) return next(e)
229 const c = await loadConfig($)
230 const parts = bandParts(await read($, sampleAtom), await read($, gpuAtom), await read($, runnerAtom), c)
231 const theirs = await next(e)
232 if (!parts.length) return theirs
233 const { Box, Text } = $.ui.resolve(e)
234 const color = (p: Part) => (p.tone && c.colors[p.tone] ? { color: c.colors[p.tone] } : { dimColor: true })
235 return (
236 <Box flexDirection="column">
237 {theirs}
238 <Box flexDirection="row" flexWrap="wrap">
239 {parts.map((p, i) => (
240 <Text key={`m${i}`} {...color(p)}>
241 {`${i ? ' · ' : ''}${p.text}`}
242 </Text>
243 ))}
244 </Box>
245 </Box>
246 )
247 }).catch(($, e, next) => next(e))
248
249 on('ui.render', { component: 'Pane', requestId: 'machine' }, async ($, e) => {
250 const { Box, Text, Button, Link } = $.ui.resolve(e)
251 const c = await loadConfig($)
252 if (!paneOpen) {
253 // the module reloaded while the pane stayed open: resume sampling
254 paneOpen = true
255 void schedule($)
256 }
257 const s = await read($, sampleAtom)
258 const gpu = await read($, gpuAtom)
259 const runner = await read($, runnerAtom)
260 const err = await read($, errorAtom)
261 const L = c.labels
262 const tone = (t?: string) => (t && c.colors[t] ? { color: c.colors[t] } : {})
263 const pad = (x: string, n: number) => (x.length >= n ? x.slice(0, n - 1) + ' ' : x + ' '.repeat(n - x.length))
264 const lpad = (x: string, n: number) => (x.length >= n ? x : ' '.repeat(n - x.length) + x)
265 const nv = <Text color={c.colors.warn}>non vérifié</Text>
266
267 const head = (
268 <Box flexDirection="row" gap={1} flexWrap="wrap">
269 <Button key="refresh" hotkey="r" onPress={() => void sampleNow($)}>
270 {L.refresh ?? 'Actualiser'}
271 </Button>
272 <Button key="ci" hotkey="c" onPress={() => runnerSoon($, true)}>
273 {L.refresh_ci ?? 'Vérifier le runner'}
274 </Button>
275 <Text dimColor>
276 {s
277 ? `mesuré à ${hms(s.at)} · toutes les ${Math.round(timerMs / 1000) || c.interval_s} s tant que ce panneau est ouvert · mesure ${s.ms} ms · ${s.procs.count} processus`
278 : L.measuring ?? 'mesure en cours'}
279 </Text>
280 </Box>
281 )
282 if (!s) {
283 return (
284 <Box flexDirection="column" gap={1}>
285 {head}
286 {err ? <Text color={c.colors.bad}>{`erreur : ${err}`}</Text> : null}
287 {cfgProblem ? <Text color={c.colors.bad}>{cfgProblem}</Text> : null}
288 </Box>
289 )
290 }
291
292 const m = metrics(s, gpu, runner, c)
293 const a = advise(m, c.advice)
294 const advice = (
295 <Box flexDirection="column">
296 <Text bold {...tone(a.n < a.base ? 'warn' : 'ok')}>{`${L.advice} : ${a.n}`}</Text>
297 <Text dimColor wrap="wrap">
298 {`base ${a.base}` +
299 (a.reasons.length ? ` · ${a.reasons.map(r => `${r.label} (${r.effect})`).join(' · ')}` : ' · aucune règle déclenchée') +
300 ` · bornes ${c.advice.floor}–${c.advice.ceiling}` +
301 (a.unknown.length ? ` · non mesuré : ${a.unknown.join(', ')}` : '')}
302 </Text>
303 </Box>
304 )
305
306 const cpu = (
307 <Box flexDirection="column">
308 <Text>
309 <Text bold>{pad(L.cpu, 8)}</Text>
310 <Text {...tone(level(m.cpu, c.levels))}>{`${bar(m.cpu)} ${lpad(fmtPct(m.cpu), 5)}`}</Text>
311 <Text dimColor>{` · ${s.ncpu} cœurs logiques · sur ${(s.interval_ms / 1000).toFixed(1).replace('.', ',')} s`}</Text>
312 </Text>
313 {s.cpu.cores.length ? (
314 <Text>
315 <Text dimColor>{pad(L.cores, 8)}</Text>
316 <Text {...tone(level(Math.max(...s.cpu.cores), c.levels))}>{coreBar(s.cpu.cores)}</Text>
317 <Text dimColor>{` ${s.cpu.cores.map(x => Math.round(x)).join(' ')}`}</Text>
318 </Text>
319 ) : null}
320 <Text>
321 <Text bold>{pad(L.ram, 8)}</Text>
322 <Text {...tone(level(m.ram, c.levels))}>{`${bar(m.ram)} ${lpad(fmtPct(m.ram), 5)}`}</Text>
323 <Text dimColor>{s.ram ? ` · ${fmtBytes(s.ram.used)} / ${fmtBytes(s.ram.total)}` : ''}</Text>
324 </Text>
325 <Text>
326 <Text bold>{pad(L.gpu, 8)}</Text>
327 {m.gpu !== undefined ? <Text {...tone(level(m.gpu, c.levels))}>{`${bar(m.gpu)} ${lpad(fmtPct(m.gpu), 5)} · `}</Text> : null}
328 <Text dimColor>{gpu ? `${gpuText(gpu, L)} (${gpu.source})` : L.measuring ?? 'mesure en cours'}</Text>
329 </Text>
330 <Text>
331 <Text bold>{pad(L.disks, 8)}</Text>
332 {s.disks.map((d, i) => (
333 <Text key={`d${i}`} {...(d.pct_used >= c.disk_warn_pct ? tone('warn') : {})}>
334 {`${i ? ' · ' : ''}${d.drive} ${fmtBytes(d.free)} ${L.free ?? 'libres'} / ${fmtBytes(d.total)}`}
335 </Text>
336 ))}
337 </Text>
338 </Box>
339 )
340
341 const procRow = (p: Sample['procs']['top_cpu'][number], key: string) => (
342 <Text key={key} {...(p.family ? tone(p.family) : { dimColor: true })}>
343 {pad(p.name, 30) + lpad(p.cpu == null ? '-' : `${p.cpu.toFixed(1).replace('.', ',')} %`, 9) + lpad(fmtBytes(p.ram), 10) + lpad(String(p.pid), 8)}
344 </Text>
345 )
346 const header = <Text dimColor>{pad('processus', 30) + lpad('CPU', 9) + lpad('mémoire', 10) + lpad('pid', 8)}</Text>
347 const procs = (
348 <Box flexDirection="column">
349 <Text bold>{L.top_cpu}</Text>
350 {header}
351 {s.procs.top_cpu.length ? s.procs.top_cpu.map((p, i) => procRow(p, `c${i}`)) : <Text dimColor>{L.measuring}</Text>}
352 <Text bold>{L.top_ram}</Text>
353 {header}
354 {s.procs.top_ram.map((p, i) => procRow(p, `r${i}`))}
355 <Text>
356 <Text bold>{`${L.families} : `}</Text>
357 {Object.keys(c.families).map((f, i) => {
358 const x = s.procs.families[f]
359 return (
360 <Text key={`f${f}`} {...tone(f)}>
361 {`${i ? ' · ' : ''}${f} ×${x?.count ?? 0}${x ? ` ${fmtPct(x.cpu)} ${fmtBytes(x.ram)}` : ''}`}
362 </Text>
363 )
364 })}
365 </Text>
366 <Text dimColor wrap="wrap">
367 {'CPU = part de toute la machine (100 % = tous les cœurs) ; mémoire = working set.' +
368 (s.procs.unreadable
369 ? ` ${s.procs.unreadable} processus système illisibles sans droits admin` +
370 (m.cpu !== undefined && s.procs.cpu_attributed !== undefined
371 ? ` : ${fmtPct(Math.max(0, m.cpu - s.procs.cpu_attributed))} du CPU leur revient (non attribué).`
372 : '.')
373 : '')}
374 </Text>
375 </Box>
376 )
377
378 const ci = (
379 <Box flexDirection="column">
380 <Text>
381 <Text bold>{`${L.ci} : `}</Text>
382 <Text>{ciText(runner, m, s, c)}</Text>
383 </Text>
384 {(runner?.backends ?? []).map(b => (
385 <Text key={`b${b.id}`} dimColor wrap="wrap">
386 {` ${b.label} (${b.id}${b.tag ? `, tag ${b.tag}` : ''}) · ${b.usable === 'yes' ? 'utilisable' : b.usable === 'no' ? 'non utilisable' : 'inconnu'} · ${b.why}` +
387 (b.runners ? ` · runners ${b.runners.online}/${b.runners.total} en ligne` : '') +
388 (b.jobs ? ` · jobs ${b.jobs.running ?? '?'} ${L.running}, ${b.jobs.pending ?? 'non suivi'} ${L.pending}` : '') +
389 (b.concurrent ? ` · concurrent ${b.concurrent}` : '')}
390 </Text>
391 ))}
392 <Box flexDirection="row" gap={1}>
393 <Text dimColor>
394 {`via le scanner du mod ci (cache ${runner?.generated ?? 'jamais'}) ; jobs en attente seulement pour les pending_projects de sa config ; enfants du processus runner = jobs locaux :`}
395 </Text>
396 {nv}
397 </Box>
398 </Box>
399 )
400
401 return (
402 <Box flexDirection="column" gap={1}>
403 {head}
404 {err ? <Text color={c.colors.bad}>{`erreur : ${err}`}</Text> : null}
405 {cfgProblem ? <Text color={c.colors.bad}>{cfgProblem}</Text> : null}
406 {advice}
407 {cpu}
408 {procs}
409 {ci}
410 <Box flexDirection="column">
411 <Text dimColor wrap="wrap">
412 Sources : compteurs du système (Windows : ntdll/kernel32, Linux : /proc), nvidia-smi ou compteur de performance Windows pour le GPU, scanner du mod ci pour le runner. Lecture seule ; règles et seuils dans machine.config.json.
413 </Text>
414 <Box flexDirection="row" gap={1}>
415 <Link href={DOCS.mods} label={DOCS.mods} />
416 </Box>
417 </Box>
418 </Box>
419 )
420 })
421}
422hooks/logic.ts 205 lines1// machine mod: the pure part. Metrics from a sample, the "agents en parallèle conseillés" rule, French formatting,
2// the band text and the /machine summary. No I/O: register.tsx and node --test both import it.
3import type { GpuInfo, RunnerInfo, Sample } from '../types'
4
5export type { GpuInfo, RunnerInfo, Sample }
6
7export type Rule = { metric: string; op: '>' | '>=' | '<' | '<='; value: number; delta: number; label: string }
8export type Cap = { id: string; value: number; minus?: string; label: string }
9export type Config = {
10 python: string
11 interval_s: number
12 min_interval_s: number
13 first_delta_ms: number
14 top_n: number
15 families: Record<string, string[]>
16 disks_exclude: string[]
17 disk_warn_pct: number
18 gpu: { source: string; refresh_s: number; perf_counter: string }
19 ci: { enabled: boolean; scanner: string; config: string; backends: string[]; refresh_s: number; runner_process: string[] }
20 advice: { base: number; floor: number; ceiling: number; rules: Rule[]; caps: Cap[] }
21 band: { interval_s: number; items: string[] }
22 colors: Record<string, string>
23 levels: { warn: number; bad: number }
24 labels: Record<string, string>
25}
26
27export const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
28
29/** Deep merge; arrays and scalars of `over` replace; keys starting with _ in `over` are ignored. */
30export const deepMerge = <T,>(base: T, over: unknown): T => {
31 if (!isObj(base) || !isObj(over)) return base
32 const out: Record<string, unknown> = { ...base }
33 for (const [k, v] of Object.entries(over)) {
34 if (k.startsWith('_')) continue
35 out[k] = isObj(v) && isObj(out[k]) ? deepMerge(out[k], v) : v
36 }
37 return out as T
38}
39
40export const expandHome = (p: string, home: string) => (p.startsWith('~') && home ? home.replace(/\\/g, '/') + p.slice(1) : p).replace(/\\/g, '/')
41
42/** Seconds between two samples, never below the configured floor. */
43export const intervalMs = (c: Config, band = false) => Math.max(c.min_interval_s, band ? c.band.interval_s : c.interval_s) * 1000
44
45// ------------------------------------------------------------------ metrics and advice
46
47export type Metrics = Partial<Record<'cpu' | 'ram' | 'gpu' | 'disk_free_gb' | 'ci_running' | 'ci_pending' | 'runner_children', number>>
48
49export const metrics = (s: Sample | null, gpu: GpuInfo | null, r: RunnerInfo | null, c: Config): Metrics => {
50 const m: Metrics = {}
51 if (s?.cpu.total != null) m.cpu = s.cpu.total
52 if (s?.ram) m.ram = s.ram.pct
53 const utils = (gpu?.gpus ?? []).map(g => g.util).filter((u): u is number => typeof u === 'number')
54 if (utils.length) m.gpu = Math.max(...utils)
55 if (s?.disks.length) m.disk_free_gb = Math.min(...s.disks.map(d => d.free / 1e9))
56 if (s?.procs.runner.present) m.runner_children = s.procs.runner.children
57 const bs = (r?.backends ?? []).filter(b => !c.ci.backends.length || c.ci.backends.includes(b.id))
58 const sum = (k: 'running' | 'pending') => {
59 const v = bs.map(b => b.jobs?.[k]).filter((x): x is number => typeof x === 'number')
60 return v.length ? v.reduce((a, b) => a + b, 0) : undefined
61 }
62 const run = sum('running')
63 const pend = sum('pending')
64 if (run !== undefined) m.ci_running = run
65 if (pend !== undefined) m.ci_pending = pend
66 return m
67}
68
69const test = (v: number, op: Rule['op'], x: number) => (op === '>' ? v > x : op === '>=' ? v >= x : op === '<' ? v < x : v <= x)
70
71export type Reason = { label: string; effect: string }
72export type Advice = { n: number; base: number; reasons: Reason[]; capped: string | undefined; unknown: string[] }
73
74/** base + first matching rule per metric, then caps, then [floor, ceiling]. */
75export const advise = (m: Metrics, a: Config['advice']): Advice => {
76 let n = a.base
77 const reasons: Reason[] = []
78 const done = new Set<string>()
79 const unknown = new Set<string>()
80 for (const r of a.rules) {
81 const v = m[r.metric as keyof Metrics]
82 if (v === undefined) {
83 unknown.add(r.metric)
84 continue
85 }
86 if (done.has(r.metric) || !test(v, r.op, r.value)) continue
87 done.add(r.metric)
88 n += r.delta
89 reasons.push({ label: r.label, effect: r.delta > 0 ? `+${r.delta}` : String(r.delta) })
90 }
91 let capped: string | undefined
92 for (const cap of a.caps) {
93 const minus = cap.minus ? m[cap.minus as keyof Metrics] : 0
94 if (minus === undefined) unknown.add(cap.minus!)
95 const limit = cap.value - (minus ?? 0)
96 if (n > limit) {
97 n = limit
98 capped = cap.label
99 reasons.push({ label: cap.label, effect: `≤ ${limit}` })
100 }
101 }
102 n = Math.max(a.floor, Math.min(a.ceiling, n))
103 return { n, base: a.base, reasons, capped, unknown: [...unknown] }
104}
105
106// ------------------------------------------------------------------ formatting (French)
107
108const fr = (x: number, d = 1) => x.toFixed(d).replace('.', ',').replace(/,0+$/, '')
109
110export const fmtPct = (x: number | null | undefined) => (x == null ? '?' : `${Math.round(x)} %`)
111
112export const fmtBytes = (b: number) => {
113 if (b >= 1e12) return `${fr(b / 1e12)} To`
114 if (b >= 1e9) return `${fr(b / 1e9)} Go`
115 if (b >= 1e6) return `${Math.round(b / 1e6)} Mo`
116 return `${Math.round(b / 1e3)} ko`
117}
118
119export const level = (pct: number | null | undefined, l: Config['levels']) =>
120 pct == null ? 'dim' : pct >= l.bad ? 'bad' : pct >= l.warn ? 'warn' : 'ok'
121
122const BLOCKS = '▁▂▃▄▅▆▇█'
123/** One character per core, ▁ (idle) to █ (busy). */
124export const coreBar = (cores: number[]) =>
125 cores.map(p => BLOCKS[Math.max(0, Math.min(7, Math.floor((p / 100) * 8 - 1e-9)))] ?? BLOCKS[0]).join('')
126
127/** A 10-cell bar for a %. */
128export const bar = (pct: number | null | undefined, width = 10) => {
129 const f = Math.round(Math.max(0, Math.min(100, pct ?? 0)) / (100 / width))
130 return '█'.repeat(f) + '░'.repeat(width - f)
131}
132
133export const hms = (ms: number) => {
134 const d = new Date(ms)
135 const p = (n: number) => String(n).padStart(2, '0')
136 return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
137}
138
139export const gpuText = (g: GpuInfo | null, L: Record<string, string>) => {
140 if (!g || g.source === 'none' || !g.gpus.length) return L.gpu_none ?? 'non disponible'
141 return g.gpus
142 .map(x => {
143 const mem = x.mem_used_mb != null && x.mem_total_mb ? ` · VRAM ${fr(x.mem_used_mb / 1024)}/${fr(x.mem_total_mb / 1024)} Go` : ''
144 const temp = x.temp_c != null ? ` · ${Math.round(x.temp_c)} °C` : ''
145 return `${x.name} ${fmtPct(x.util)}${mem}${temp}`
146 })
147 .join(' ; ')
148}
149
150export const ciText = (r: RunnerInfo | null, m: Metrics, s: Sample | null, c: Config) => {
151 const L = c.labels
152 if (!c.ci.enabled) return L.ci_off ?? 'désactivé'
153 const parts: string[] = []
154 if (r?.error) parts.push(`ci : ${r.error}`)
155 if (m.ci_running !== undefined) parts.push(`${m.ci_running} job(s) ${L.running ?? 'en cours'}`)
156 if (m.ci_pending !== undefined) parts.push(`${m.ci_pending} ${L.pending ?? 'en attente'}`)
157 if (s) parts.push(s.procs.runner.present ? `processus local présent (${s.procs.runner.children} enfant(s))` : 'processus local absent')
158 return parts.join(' · ') || '?'
159}
160
161// ------------------------------------------------------------------ band and summary
162
163export type Part = { text: string; tone?: string }
164
165export const bandParts = (s: Sample | null, gpu: GpuInfo | null, r: RunnerInfo | null, c: Config): Part[] => {
166 if (!s) return []
167 const m = metrics(s, gpu, r, c)
168 const L = c.labels
169 const out: Part[] = []
170 for (const it of c.band.items) {
171 if (it === 'advice') {
172 const a = advise(m, c.advice)
173 out.push({ text: `${L.advice_short ?? 'agents conseillés'} ${a.n}`, tone: a.n < a.base ? 'warn' : 'ok' })
174 } else if (it === 'cpu' && m.cpu !== undefined) out.push({ text: `${L.cpu} ${fmtPct(m.cpu)}`, tone: level(m.cpu, c.levels) })
175 else if (it === 'ram' && m.ram !== undefined) out.push({ text: `${L.ram} ${fmtPct(m.ram)}`, tone: level(m.ram, c.levels) })
176 else if (it === 'gpu' && m.gpu !== undefined) out.push({ text: `${L.gpu} ${fmtPct(m.gpu)}`, tone: level(m.gpu, c.levels) })
177 else if (it === 'disk' && m.disk_free_gb !== undefined) out.push({ text: `disque ${fr(m.disk_free_gb, 0)} Go ${L.free ?? 'libres'}` })
178 else if (it === 'ci' && c.ci.enabled && m.ci_running !== undefined)
179 out.push({ text: `CI ${m.ci_running} ${L.running ?? 'en cours'}${m.ci_pending ? ` / ${m.ci_pending} ${L.pending ?? 'en attente'}` : ''}` })
180 }
181 return out
182}
183
184export const bandText = (parts: Part[]) => parts.map(p => p.text).join(' · ')
185
186export const summary = (s: Sample | null, gpu: GpuInfo | null, r: RunnerInfo | null, c: Config) => {
187 if (!s) return 'machine : pas encore de mesure'
188 const m = metrics(s, gpu, r, c)
189 const a = advise(m, c.advice)
190 const L = c.labels
191 const lines = [
192 `${L.advice} : ${a.n}${a.reasons.length ? ` (${a.reasons.map(x => `${x.label} ${x.effect}`).join(', ')})` : ''}`,
193 `${L.cpu} ${fmtPct(m.cpu)} sur ${s.ncpu} cœurs · ${L.ram} ${s.ram ? `${fmtPct(s.ram.pct)} (${fmtBytes(s.ram.used)} / ${fmtBytes(s.ram.total)})` : '?'} · ${L.gpu} ${gpuText(gpu, L)}`,
194 `${L.disks} : ${s.disks.map(d => `${d.drive} ${fmtBytes(d.free)} ${L.free ?? 'libres'}`).join(' · ') || '?'}`,
195 `${L.ci} : ${ciText(r, m, s, c)}`,
196 ]
197 return lines.join('\n')
198}
199
200/** Arguments of /machine: '' | refresh | ci. */
201export const parseArgs = (args: string) => {
202 const w = args.trim().toLowerCase()
203 return { refresh: w === 'refresh' || w === 'actualiser', ci: w === 'ci' || w === 'runner' }
204}
205types/index.d.ts 67 lines1// Types of the machine mod: what scanner/machine.py prints (`sample`, `runner`) and the `$.state` values.
2
3export type Proc = { pid: number; name: string; cpu: number | null; ram: number; family: string }
4export type Family = { count: number; cpu: number; ram: number }
5export type Disk = { drive: string; total: number; free: number; pct_used: number }
6export type Gpu = { name: string; util: number | null; mem_used_mb: number | null; mem_total_mb: number | null; temp_c: number | null }
7export type GpuInfo = { source: 'nvidia-smi' | 'perf' | 'none'; gpus: Gpu[] }
8
9export type Raw = { t: number; cores: number[][]; procs: Record<string, number[]> }
10
11export type Sample = {
12 platform: string
13 /** ms since epoch */
14 at: number
15 /** time between the two readings the % come from */
16 interval_ms: number
17 ncpu: number
18 cpu: { total: number | null; cores: number[] }
19 ram: { total: number; used: number; pct: number } | null
20 disks: Disk[]
21 procs: {
22 count: number
23 /** processes this user cannot open (services, protected processes, without admin rights) */
24 unreadable?: number
25 /** sum of the CPU % of the readable processes; total - this = CPU not attributed */
26 cpu_attributed?: number
27 top_cpu: Proc[]
28 top_ram: Proc[]
29 families: Record<string, Family>
30 /** the runner process (ci.runner_process) and how many child processes it has now */
31 runner: { present: boolean; children: number }
32 }
33 /** null when this sample did not read the GPU (read every gpu.refresh_s) */
34 gpu: GpuInfo | null
35 /** previous counters, passed back to the next sample (stripped before it goes into $.state) */
36 raw?: Raw
37 /** sampling time inside the scanner */
38 ms: number
39}
40
41export type RunnerBackend = {
42 id: string
43 label: string
44 tag: string
45 concurrent: number | null
46 usable: 'yes' | 'no' | 'unknown'
47 why: string
48 runners: { online: number; offline: number; total: number } | null
49 jobs: { running: number | null; pending: number | null } | null
50}
51export type RunnerInfo = { generated: string | null; backends: RunnerBackend[]; error?: string }
52
53declare module 'claude-code' {
54 interface PluginState {
55 machine: {
56 /** last sample without its raw counters */
57 sample: Sample | null
58 /** last GPU reading (kept between GPU reads) */
59 gpu: GpuInfo | null
60 /** last runner reading from the ci mod's scanner */
61 runner: RunnerInfo | null
62 /** last error line, '' when fine */
63 error: string
64 }
65 }
66}
67