SLOPSHOPPER

machine

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…

panebandcommandprocesstimer
v0.1.1MITupdated 2026-10-16julian-passebecq/claude-mod/mods/machine
A shopper browsing a rack in a slop shop
README

claude-mod

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 mods

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).

EntryCommandHub modShows inside it
Jauge/jaugebandthe band above the prompt and its pane
Cockpit/cockpitcockpitzoomable 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/veilleveilletabs Alertes, Fil, À archiver, Export eta; older mods: export-eta
À toi/afairea-fairewhat only you can do
Registres/registresregistrestabs Problèmes, Tests réels, Règles, Ménage; older mods: problemes, tests-reels, metrics, menage
Référence/referencereferencetabs Config, Guide, Scénarios, Santé, CI; older mods: control, guide, scenarios, sante, ci

filetree (external) stays apart: its own Fichiers tab next to Cockpit.

Jauge

ModCommandWhat 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.

Cockpit

ModCommandWhat 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).

Veille

ModCommandWhat 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.

À toi

ModCommandWhat 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.

Registres

ModCommandWhat 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.

Référence

ModCommandWhat 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

Source 3 files
hooks/register.tsx 422 lines
1// 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}
422
hooks/logic.ts 205 lines
1// 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}
205
types/index.d.ts 67 lines
1// 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