Cockpit, en lecture seule. Vue par défaut : le Panneau, une seule colonne qui défile, sans onglets, repliable section par section : Fichiers (l'arbre de…

Opening Cockpit shows one scrolling column with no tab bar at all. Since 22 Oct (Julian: « a lot going on, it's not clear; way better UI/UX: visibility, indentation, bullets, agents, sub-agents, workflows, progress bars ») it reads top to bottom as status banners, ONE list of conversations, then one folded line each for Quotas, Débit, Graphe and Fichiers. Every detail of the earlier cards is still there, one click away on a title or its › (⌄ folds it back), and what you unfold is kept in $.store.
node mods/cockpit/scripts/preview.mjs --panneau --cols 60 (or 45, 75) draws it from invented fixtures (--indent 4 tries another panel.list.indent; --busy the fixture with 2 running Workflows, 1 background agent, 1 working sub-conversation and 1 CI pipeline; --main only this conversation's main thread works; --idle nothing runs; --ci idle|busy|stale the ci mod's cache; --open encours,pdetails unfolds sections or nodes). --real draws it from this PC's live data, read-only (scripts/panneau_real.mjs: the scanners' caches are copied to a temp folder) and prints only counts (rows listed, working, waiting, at rest, nested children, Workflows, agents); --show prints the text too, on your terminal: never commit that output. --panneau --busy --cols 60:
● [3 conv.] · [2 wf] · [3 ag.] [voir ›]
◆ 1 t’attend : [Lane beta] · Choisir la versio…
CONVERSATIONS [filtres ›]
● [Lane alpha · tableau de bord] coord ▰▰▱▱▱ 30 %
│ opus·med · 6.0M · 2 h 10 · PR #120 ●
│ ◇ [package-wave] · Wave B ▰▰▰▱▱ 2/4 · 1 ag.
│ │ ● coder: cockpit-claude-style opus · 20 min
│ ◦ coder · p44-migration opus · 1 h 10 · 918K
│ ● [Package four] fil principal ▰▱▱▱▱ 10 %
│ opus·med · 255K
│ ○ [Package two] coord au repos 1 min
│ │ ○ [Package two · verify] au repos 9 h
│ ✓ [Package one: a rather long l…] au repos 3 h
● [Lane delta] proj-one ▰▱▱▱▱ 15 %
│ sonnet·low · 1.5M
│ ◇ [wf_2] · workflow ▰▰▰▱▱ 1/2 · 1 ag.
│ │ ● coder · write the docs sonnet · 12 min
○ [PM BETA] coord proj-two au repos 40 min
│ ◆ [Lane beta] t’attend
│ 2 h 00 · Choisir la version
○ [+ 1 au repos ›]
─ [Quotas] · 852 tok/s [›]
5 h ▰▱▱▱▱ 21 % · dans 2 h 10
sem ▰▰▰▰▱ 85 % · dim 20:30 · à ce rythme 95 %
─ [Aujourd’hui] · 5 conv. · 6.1M · $20.50 [›]
─ [Débit] · opus 56 · sonnet 88 TPS (30 min) [›]
─ [Graphe] · 8 conv · 3 coord · 1 t’attend(… [›]
─ [Fichiers] · proj-one · main · sélection : … [›]
(panel.detail « normal », the default, at 50 columns with --busy; --detail compact draws PR #77's one-line rows.)
panel.detail, Julian 22 Oct: « cleaner, but there is space for more detail, it's too minimalist, but I prefer that style »): the same style, no box, no new colour, more grey. "compact" = PR #77's look (one line per row, every section folded). "normal" (default): a working or waiting conversation gets one grey second line under its title, « opus·med · 12.1M · 2 h 10 · PR #120 ✓ » (model·effort, tokens, since its start, its newest PR from the session file's prs and its branch's CI from the ci mod's cache: ✓ passed, ✕ failed, ● running; a merged / closed PR without CI says « fusionnée » / « fermée »); a waiting one « 2 h 00 · its question »; short of room a field goes whole, the model first, then the tokens, then the time, never cut. A Workflow row says its running phase instead of « · workflow » and « · N ag. » after done/total; an agent row its tokens while its label stays whole. Rows at rest stay one line. Quotas shows two compact rows under its line, « 5 h ▰▱▱▱▱ 21 % · reset dans 2 h 10 » and « sem ▰▰▰▰▱ 85 % · reset dim 20:30 · à ce rythme 95 % » (the word « reset », then the projection, then the reset go when short of room), the curves behind its ›. A folded Aujourd’hui line « 5 conv. · 6.1M · $20.50 › » follows it, its › the day's Gantt (no longer inside Graphe). "full" = normal with Débit unfolded on first open. preview.mjs --panneau --detail compact|normal|full.drawEnCours, drawClick in hooks/panel_view.mjs), on a tinted background (panel.banners: theme keys, ok_bg diffAddedDimmed and warn_bg warning by default; the text renderer ignores the tint). Green while something runs: « ● 6 conversations travaillent · 2 workflows · 5 agents · 1 CI voir › », every count shown (a zero in grey), each one clickable (the conversations filter the list on « en cours », the others and « voir › » unfold « En ce moment »: every running agent, flat, with its path « Conv › workflow › phase »); « 1 bloquée » in red when a Workflow is stalled or a turn is cut. Amber when someone waits for you: « ◆ 1 t’attend : title · reason » (several: one line each under « ◆ N t’attendent »), the title opens that conversation. Nothing runs: « ✓ Rien ne tourne · dernier fini il y a 12 min (name) ». A CI cache older than panel.encours.ci_stale_min says « CI : non lu depuis 40 min ». Short of room the words get short (« 2 conv. · 2 wf · 2 ag. »): a number is never cut. Counted over every conversation of the window (enCours in lib/panel_conv.mjs), detection as before: a conversation works when its main thread has a turn in progress (mainThreadBusy, lib/core/activity.mjs) or a Workflow / agent of it runs.oneList in lib/panel_list.mjs, drawn by drawConvs), replacing CETTE CONVERSATION, CONVERSATIONS and the graph's text list. The sessions of GRAPHE (the app's session files plus factory's scan) with the activity of the banner, over panel.convs.period (24 h; the shipped config said « today », which hid last night's work: fixed 22 Oct). Children nest under their parent (spawnedFrom) even in another project, the child's project in grey on its row; the current conversation says « (ici) ». Each level sits behind a grey « │ » rule (panel.list.indent columns, 3). A row: its state mark (● travaille, ◆ t’attend, ✕ bloquée, ✓ finie, ○ au repos), the title (bold for a parent), a « coord » chip for a coordinator, then on the right « fil principal » / « t’attend » and its context bar (blue, amber from panel.style.ctx_warn_pct 62.5 %, red past the hand-off threshold with ⚠) and %. Under a conversation: its running Workflows « ◇ name · workflow ▰▰▱ 2/4 » (progress bar, done / count, amber « à l’arrêt 1 h 09 » when stalled), under each its running agents « ● role · label model · elapsed » (at most panel.conv.running_max, then « +N agents en cours »), its background agents « ◦ … », then its children. Conversations at rest are one grey line « au repos 40 min »; after the working and waiting ones and the panel.list.rest_shown (3) most recent at rest, « + N au repos › » unfolds the rest. Details only on a click of the title (cost, tokens, cache, the Sankey, the journal, « ouvrir ↗ » for another conversation): the Usage-like card behind the rule; a Workflow's title unfolds its tokens, cost, ETA, Gantt and agents. « Terminés » and the phases are not drawn by default any more. « filtres › » unfolds the filter row (« 24 h ▾ » « Projet ▾ » « État ▾ » « Claude ▾ »); a filter never hides a working conversation.oneLine): Quotas, Débit, Graphe (the diagram only, then the day's Gantt that used to sit under the list) and Fichiers (the file tree, then « Autres vues : /cockpit cartes · … », which used to be a footer). No project group headers any more.lib/filetree/ (tree.ts, icons.ts, types/, LICENSE, NOTICE), synced by scripts/sync-libs.mjs from vendor/filetree. Kept: folders on demand, git letters and branch, selection named to Claude (panel.fichiers.attach_selection, on by default, same words as filetree). Not here: search, preview, activity flashes, sizes, opening files: filetree's own pane (/filetree) still has them.›: Claude's Usage rows: Session 5 h and Semaine with their reset in grey, Codex semaine while Codex is toggled, Crédits cloud when the engine reads a spend limit (« 249 $ sur 250 $ » and « expire le 5 nov 08:59 » under the bar when the engine gives the amounts; the engine's SessionRateLimit has only kind / percentUsed / resetsAt today, so the row shows the % and « montant non lu »), tok/s of all conversations.› (« tableau »): one card per model with a reply in the last 7 days (panel.debit.active_hours), « Modèle : opus 5.5 » (Codex models only while « Codex » is toggled), a row per window 30 min / 1 j / 7 j / 30 j with Réponses · Tokens · Temps · TPS pondéré (sum of tokens ÷ sum of time) · Médiane · Plage (min–max), numbers right-aligned in fixed columns; under the pane's width Plage then Temps are left out (then the headers get short), numbers are never cut. The models with no reply in 7 days (only 30 j figures) are behind « anciens (N) › ». Then « 10 derniers flux »: « 18:29:48 opus 5.5 medium : 59.2 TPS (171 tok en 2.89 s) », TPS green at or above the model's 1 j median, grey under. The footnote is behind « comment c’est mesuré › »: the usage mod's scanner (lib/core/transcripts.mjs) times every API response once (message.id + requestId) from the last transcript line that sent it (the prompt or the tool result) to its last content block, so it includes the wait for the first token (the transcripts have no streaming events); tokens = output tokens, thinking included; under 50 tokens or over 30 min left out; effort per stream from the transcript line. It writes debit.json: TPS rows per hour|model|effort over the 31 kept days and every stream of the last hour, so 30 min is exact and 1 j / 7 j / 30 j are whole clock hours, medians and ranges read in x1.15 buckets (±7 %). Codex: codex.json, the same rule from Codex's own rollout files (task_started or a tool output → token_usage_record), by whole local days; « Codex : non lu » when it has no timings. Cached: debit.json is re-read only when its mtime changes, the model computed again only when a file, the minute or the Codex toggle changes. Code: lib/debit.mjs (pure), drawDebit in hooks/panel_view.mjs; settings panel.debit (windows, ref_window, recent, cards_max, active_hours, aliases).panel.marks, theme tones, the same everywhere; folded behind « Légende › » when the pane is too narrow), one filter row (« 24 h ▾ » cycles 24 h / 7 j / tout, « Projet ▾ », Actives, Archivées; default: last 24 h, archived hidden; kept in $.store) and one group per project like the app's sidebar (folder name of originCwd, worktrees folded into their repo; starred first). A group of 2+ has a small grey header and its rows indented, the children (spawnedFrom) under their parent; a group of one has no header, its project name is grey on the row. Each conversation is ONE line: state mark, title (cut), grey « coord » for a coordinator, tokens today · ctx % and a tiny context bar on the right; a second grey line only for a « ↖ lancée par » / « ⇢ message » relation. State: travaille when the latest of the app's lastActivityAt, the scan's activity and the transcript's mtime is under panel.graphe.working_min (2 min), or a Workflow / background agent of it runs; t'attend; bloquée; finie only after panel.graphe.done_min (10 min) idle with nothing running and a PR merged; else inactive; archivée. The 24 h / 7 j filter tests the same latest sign of life, and a conversation that works, waits or is blocked is always listed (19 Oct fix: the filter read the app's lastActivityAt alone, which hid conversations working for hours in one long turn, and the graph said « aucune conversation avec ces filtres » while 3 worked); a project filter kept in $.store for a project with no conversation left is ignored; a conversation factory's scan reads but whose session file was not read is drawn from the scan. A title (› / ⌄) unfolds that conversation's own factory under its row; « ouvrir ↗ » at the right opens it in the desktop app (the claude://claude.ai/epitaxy/<id> link « Depuis » already uses). Data: scanner/cards.mjs part graphe over lib/core/sessions.mjs graphRow, which keeps a fixed list of session-file fields (ids, title, folders, archived/starred, times, model, effort, spawnedFrom.sessionId, peerReceipts target + sentAt, interruptedAwaitingPerson, prs, branch) and never the prompts or settings; read at most every panel.graphe.refresh_s (60 s), whatever the folds (its line and À toi de cliquer are always drawn). Code: lib/graphe.mjs (pure model); hooks/graphe_view.mjs was removed on 22 Oct (its list is the one list).lib/viz_palette.mjs: opus orange, sonnet blue, haiku green, fable violet, codex grey; states green / amber / red / grey; theme keys for the texts, prefers-color-scheme inks inside the Svg). Cette conversation micro: a Sankey of its tokens, conversation → fil principal / each Workflow / background agents → models, width = tokens, colour = model (lib/viz_sankey.mjs), in place of « Ce qui contribue », which stays the text fallback. QUOTAS unfolded: under the 5 h and week bars, the curve of the window's readings (usage's samples.jsonl, lib/core/usage.mjs claudeSeries) with a dashed projection to the reset at the rate of the last hour (week: last day), red past 100 % with « à ce rythme : 100 % vers HH:MM » (lib/viz_quota.mjs; the sentence shows on every surface). DÉBIT unfolded: a 24 h TPS sparkline per model next to each table's title, one weighted point per hour (lib/viz_spark.mjs; ▁▇ characters as fallback). GRAPHE unfolded: the graph drawn above the list, a box per project, conversations in layers under their parent, arrows parent → child, dashed arrows for peer messages, colour = state, coordinators bigger, at most panel.viz.graph_max (20, the active first) and « +N » (lib/viz_graph.mjs). Drawing: hooks/viz_*.mjs; labels in panel.viz.labels./cockpit <word> (/cockpit cartes for the cards); on any of them the tab row shows, « Panneau » first, so the way back is one press. The left menu is not drawn on the Panneau (the band's buttons above the prompt do that job). /cockpit panneau opens it; panel.enabled: false makes the Cockpit tab the default again. Labels, order, limits: panel in cockpit.config.json.One of the 6 entries of the band's Mods bar (Jauge, Cockpit, Veille, À toi, Registres, Référence).
Cockpit is the one zoomable view over all your agents (méga › projet › conversation › agent), read-only, French labels. It absorbs Usine (factory's screens) and Machine (PC and runner load) as tabs of the same pane, and adds the screens that serve one decision each: Bilan avant la nuit (behind the Nuit button), Claude ↔ Codex and Galerie. A vertical menu at the left runs the band's 6 entries:
[Jauge] [Cockpit] [Mission] [Focus] [Arbre] [Pipeline] [Workflow] [Journal]
[Cockpit] [Machine] [Claude ↔ Codex] [Galerie]
[Veille] [Nuit] [Jour] [Codex]
[À toi] [Méga] › [eta] › [Galaxy finalisation…] [↻] 18:54
[Registres]
[Référence]
╭ Qui consomme [Voir] ╮
│ aujourd’hui $461 · MODS 1 41 % · projet claude-control 52 % │
│ T’attend attend Julian : 2 · Ça t’attend : 1 · Fabric handoff [Voir] │
│ À toi À toi : 5 [Voir] │
│ CI 2 pipelines en cours (Claude 1 · Codex 1), 1 rouge dans │
│ app-c (job lint), runner local à 3/18 [Voir] │
│ Quota Claude 5 h 24 % · semaine 14 % | Codex 5 h 10 % · semaine ? │
│ Problèmes du jour 2 vu(s) aujourd’hui · 1 ouvert(s) · en tête : … │
│ En cours • Workflow package-wave : fin vers 19:40 │
│ • Pipeline app-b (codex/y) · Codex, depuis 2 min : vert ou │
│ rouge vers 18:58 │
╰──────────────────────────────────────────────────────────────────────╯
[▾ Cette conversation] …
[▸ Ce projet · eta] 4 conv · 1 en cours · $7.00 aujourd’hui
[▸ Tous les projets] 2 en cours · 3 au repos · 1 attend Julian · $424
[▸ Qui consomme] aujourd’hui $461 · MODS 1 41 % · claude-control 52 %
[▸ Claude global] 5 h 24 % · sem 14 % · 1 alerte(s)
The band's 6 entries (Jauge, Cockpit, Veille, À toi, Registres, Référence) as a column at the left of every tab. A press runs that hub's command ($.command.run: a mod cannot open another mod's pane; band's « one hub open at a time » closes the others); a hub whose mod is not loaded gives a toast and runs nothing; « Cockpit » goes back to the Cockpit tab. Under menu.min_columns (50) it is one row on top. menu.position left | top | off, menu.width, menu.items (label, command, args, or self).
Julian (17 Oct): « I wake up and want the summary of the night: problems, blockages, progress ». A primary button at the top of the Cockpit tab, or /cockpit depuis [8h|24h|nuit|HH:MM] (also matin, since), opens the « Depuis » screen. Zero tokens, read-only.
~/.claude/projects/*/<id>.jsonl (read from the end, newest file first), never a tool result, system reminder, task notification, ci-monitor event, a mod's slash command, meta / compact summary, or a message another session sent (origin.kind peer). A message younger than skip_recent_min (10) is skipped, so a « bonjour » typed before pressing the button does not cut the night; nothing in lookback_days (14): 24 h. Presets (depuis.presets): Ton dernier message, 8 h, 24 h, Nuit (the most recent 22:00); any « Nh », « Nmin » or « HH:MM » works as an argument.depuis.weights, the first open_groups unfolded): T'attend (lib/core/waiting.mjs rows: question, wrap-up that needs an action, interrupted turn), CI rouge (the ci cache: lanes whose newest run is red and ran inside the window, plus red_branches main / master; since when, red jobs, « nouveau » when the streak started inside the window), Bloquée, Workflow en échec, Problème (new entries of the problemes register), À toi (a-faire items.json: added inside the window or due within due_soon_days), Mergée (gh search prs --author @me --merged-at and glab api merge_requests?scope=created_by_me&state=merged, read-only, cached prs_ttl_s in ~/.claude/effort-board/cockpit_depuis_prs.json), Terminée (app session files: title and the first line of the last assistant message, « Ouvrir » (claude:// link, as Veille's Fil) and « Copier l'id »), Workflow terminé (factory's Workflow scan), and tokens / list-price $ since (every transcript line since, main, subagents and Workflow agents, deduped on message.id + requestId, lib/core/transcripts.mjs). What has no project goes to « Général ». Forge repos map to folder names through the ci cache's apps.night-summary.json (/veille jour) when it covers the window.lib/depuis.mjs (pure model, every label and default in DEPUIS_DEFAULTS), scanner/depuis.mjs (the I/O, part depuis of scanner/cards.mjs), hooks/depuis_view.mjs. A busy night reads in a few seconds; nothing is read unless the screen is open.nuit.wake (07:30): its hours, the hours used by what runs, the hours nobody uses (and how many items have no end time); (3) the quota: 5 h and week, what is left, and what the night would burn at the current week pace (lib/core/usage.mjs rate %/h × night hours; « inconnu » without a pace); (4) Prêt à lancer: lines of each recent repo's handoff/ROADMAP.md that match nuit.ready_match (unchecked boxes, « prêt », « ready »…) outside « Idées de Julian » and done sections, and packet files (handoff/packets/*.md, handoff/PACKET-*.md, GO-*, LANE-*, BRIEF-*). The repos are the folders of the conversations active in the last day_night.recent_days days (worktrees folded onto their repo) plus day_night.repos. Then the choice: Nuit active runs /veille nuit; Lancer on a ready item only shows the command that would start it (cd "<repo>" ; claude --model opus --effort medium "Lis <source> et lance : <item>", nuit.launch*), it never starts a lane; Rien goes back to the Cockpit tab; Jour runs /veille jour./veille jour (mode légère + the night summary, Veille's).cards.who = claude: CI, Quota and En cours count Claude's pipelines and quota); the button adds Codex for the session (« Codex ✓ »).One line per topic, and a Voir button that runs the detail's command ($.command.run: a mod cannot open another mod's pane). The first target loaded in this session wins ($.command.list(), the same check band uses to grey a button); none loaded: a toast says which mod to add.
| Card | Line | Source | Voir |
|---|---|---|---|
| Qui consomme | top conversation and project of the window | usage's classement.json (same ranking code, lib/usage) | /conso classement |
| T’attend | « attend Julian : N » (veille) · « Ça t’attend : N » (factory's rows) · top of the Fil | lib/core/veille.mjs, lib/core/waiting.mjs | /veille fil |
| À toi | « À toi : N » | lib/core/veille.mjs (a-faire's summary.json) | /afaire |
| Claude Control | « N à faire · prochaine échéance 05/11 (dans 19 j) » and the 3 soonest; orange under 7 days, red under 1 | ~/.claude/a-faire/claude-control.md (- [ ] YYYY-MM-DD · priorité · quoi · …, « — » = no date; controle.*) | /afaire |
| CI | the ci Diagramme's sentence: pipelines running (Claude / Codex split), red lanes with their red jobs, runner N/concurrent | ci's `~/.claude/ci-pipelines-cache
hooks/register.mjs 875 lines1// cockpit - the one zoomable side pane (méga › projet › conversation › agent), read-only, with tabs:
2// Panneau the DEFAULT view, one scrolling column with no tab bar (lib/panel.mjs, lib/panel_conv.mjs, hooks/panel_view.mjs,
3// hooks/files.mjs): Fichiers (data-goblin's tree, embedded), Cette conversation (factory view), Contexte, Toutes
4// les conversations, Graphe des conversations (reserved slot); the views below by their /cockpit command
5// Cockpit summary cards (Qui consomme, T'attend, À toi, CI, Quota, Problèmes du jour, En cours), each a button to
6// its detail, then the scoped sections (Cette conversation › Ce projet › Tous les projets › Qui consomme ›
7// Claude global), every section foldable with a one-line summary while closed
8// Mission · Focus · Arbre · Pipeline · Workflow · Journal factory's screens (synced copies in lib/factory)
9// Machine this PC's load (machine.py copy) and the runner load (lib/core/runner.mjs)
10// Claude ↔ Codex · Galerie (Équipe, Carte, Codex) · Nuit (« Bilan avant la nuit », behind the Nuit button)
11// Depuis « Depuis ton dernier message » (its button on the Cockpit tab; /cockpit depuis [8h|24h|nuit|HH:MM])
12// A vertical menu at the left runs the band's 6 entries (Jauge, Cockpit, Veille, À toi, Registres, Référence).
13// /cockpit [conv|projet|mega|agent|mission|focus|arbre|pipeline|workflow [all]|journal|machine|relais|galerie|nuit|depuis [8h|24h|nuit|HH:MM]] [projet ou conversation] | close
14// /factory and /machine keep working: while their own mod is loaded it answers; when it is not, cockpit registers the
15// name as an alias (checked against $.command.list(), as band does to grey a button). Card buttons run the detail's
16// command ($.command.run; a mod cannot open another mod's pane). Opens by itself at session start (userConfig `start`).
17// Read-only: scanners (factory's, workflow's, machine's copies, scanner/cards.mjs over lib/core); nothing blocks or
18// rewrites a prompt; the one addition is filetree's context line for a file clicked in Fichiers (panel.fichiers.
19// attach_selection); nothing calls a model. Refresh only while the pane is the one shown.
20import { deepMerge, scanArgs, pickSession, repoOf, engineAtLeast } from '../lib/factory/hooks/logic.mjs'
21import { waitingArgs, waitingRows } from '../lib/factory/hooks/waiting.mjs'
22import { screensConfig } from '../lib/factory/hooks/screens.mjs'
23import { workflowConfig, workflowArgs, workflowScreen, loneAgents } from '../lib/factory/hooks/workflow.mjs'
24import { viewModel } from '../lib/veille/hooks/view.mjs'
25import { deepMerge as vmerge } from '../lib/veille/lib/modes.mjs'
26import { cardsOf, pickTarget, workflowsLive, runnerBackends, cardsConfig } from '../lib/cards.mjs'
27import { cockpitConfig, initialNav, select, zoomTo, usageCard, findSession, sectionFor, consoCard, parseArgs, aliasArgs, tabValid, nightRepos, titleOf, laneRows } from './nav.mjs'
28import { drawCockpit } from './view.mjs'
29import { readyItems, decideConfig } from '../lib/decide.mjs'
30import { depuisConfig } from '../lib/depuis.mjs'
31import { panelConfig, synthesis, cycle, barsOf, contexteOf, effectiveWindow, detailOf } from '../lib/panel.mjs'
32import { convFactory } from '../lib/panel_conv.mjs'
33import { panelConvs, ciBranches, todayOf } from '../lib/panel_list.mjs'
34import { ciNow } from '../lib/core/activity.mjs'
35import { grapheModel, grapheFilter } from '../lib/graphe.mjs'
36import { debitModel } from '../lib/debit.mjs'
37import { sankeyModel } from '../lib/viz_sankey.mjs'
38import { quotaCurves } from '../lib/viz_quota.mjs'
39import { tpsHourly } from '../lib/viz_spark.mjs'
40import { newFiles, nodesOf, applyListed, applyTop, applyGit, resetTo, openDirs, filesPress, openDir, filesClear, filesRows, filesHead, filesShort, filesContext } from './files.mjs'
41
42const PANE = 'cockpit'
43let options = {}
44let CFG = null // cockpit.config.json (+ user file)
45let FCFG = null // factory.config.json + workflow.config.json (lib copies, + ~/.claude/effort-board/factory.config.json)
46const ALIASES = new Set() // old command names this session serves (their own mod is not loaded)
47const D = {
48 scan: null, nav: null, self: null, busy: false, error: '', at: 0, open: false, filter: 'all', strips: null,
49 usage: null, usageRaw: null, veille: null, columns: 0, conso: null, consoData: null, consoPreset: '', consoScan: false,
50 tab: 'panneau', jfilter: 'all', jproject: '', cardsRaw: null, cardsError: '', cardsAt: 0,
51 wf: null, wfAt: 0, wfBusy: false, wfError: '', wfScope: null, wfOpen: {}, wfAll: null,
52 machine: { sample: null, runner: null, prev: null, tick: 0, missing: false, metricsMissing: false, error: '' },
53 who: null, nuitRaw: null, nuitBusy: false, relaisRaw: null, relaisBusy: false, galerieRaw: null, galerieBusy: false,
54 galView: 'equipe', launch: null, nuitMsg: '', identity: undefined, engine: '',
55 depuisRaw: null, depuisBusy: false, depuisAt: 0, depuisPreset: '', depuisOpen: {}, depuisAsk: false, depuisMsg: '', depuisError: '',
56 // the Panneau (default view): sections open, layers folded, filters (kept in $.store 'panel')
57 popen: null, pclosed: {}, pf: { period: '', project: '', state: '', who: '' }, pLoaded: false, codexData: null,
58 // Débit: usage's debit.json, re-read only when its mtime changes; the model is kept until a file, the minute or the
59 // Codex toggle changes (computing 30 days must not slow the pane)
60 debitData: null, debitMtime: -1, debitMemo: null,
61 F: newFiles(), fLimit: 0, fBusy: false,
62 // Graphe des conversations: the session files' rows (scanner part « graphe »), read at most every panel.graphe.refresh_s
63 grapheRaw: null, grapheBusy: false, grapheAt: 0, pg: null,
64}
65
66const decideLabels = () => decideConfig(CFG || {}).nuit.labels
67
68const norm = (p) => String(p || '').replace(/\\/g, '/')
69const root = ($) => norm($.plugin.root).replace(/\/+$/, '')
70const sibling = ($, mod) => root($).replace(/\/[^/]+$/, '') + '/' + mod
71
72async function homeDir($) {
73 return norm((await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '')
74}
75
76async function board($) {
77 const o = await $.env.get('FACTORY_BOARD')
78 return o ? norm(o) : (await homeDir($)) + '/.claude/effort-board'
79}
80
81async function readJson($, path) {
82 try {
83 if (!(await $.fs.exists(path))) return null
84 return JSON.parse(String(await $.fs.read(path)).replace(/^/, ''))
85 } catch {
86 return null
87 }
88}
89
90function expand(p, home) {
91 return p && p.startsWith('~') ? home + p.slice(1) : p
92}
93
94async function loadConfig($) {
95 const home = await homeDir($)
96 const base = (await readJson($, root($) + '/cockpit.config.json')) || {}
97 const userPath = (options.config || '').trim() ? expand(options.config.trim(), home) : (await board($)) + '/cockpit.config.json'
98 CFG = deepMerge(base, (await readJson($, userPath)) || {})
99 const fbase = (await readJson($, root($) + '/lib/factory/factory.config.json')) || {}
100 const wf = await readJson($, root($) + '/lib/factory/workflow.config.json')
101 FCFG = deepMerge(deepMerge(fbase, wf ? { workflow: wf } : {}), (await readJson($, (await board($)) + '/factory.config.json')) || {})
102 if (!D.strips) D.strips = Object.fromEntries(cockpitConfig(CFG).sections.open.map((k) => [k, true]))
103 return CFG
104}
105
106// ---- data ------------------------------------------------------------------------------------------------------------
107
108async function rescan($) {
109 if (D.busy) return
110 D.busy = true
111 $.ui.invalidate('ui.render')
112 try {
113 if (!CFG) await loadConfig($)
114 const detail = (D.nav && D.nav.conv) || D.self
115 const argv = [FCFG.python || 'python', ...scanArgs(FCFG, {
116 script: root($) + '/lib/factory/scanner/factory_scan.py', cache: (await board($)) + '/cockpit_cache.json', detail,
117 extra: [...waitingArgs(FCFG), '--recent', String(screensConfig(FCFG).recent_events)],
118 })]
119 const r = await $.process.run(argv, { timeoutMs: 180000 })
120 if (r.exitCode !== 0) throw new Error((r.stderr || '').trim().split('\n').pop() || `scanner exit ${r.exitCode}`)
121 D.scan = JSON.parse(r.stdout)
122 D.error = ''
123 D.at = await $.clock.now()
124 if (!D.nav) D.nav = initialNav(D.scan, D.self, CFG)
125 } catch (err) {
126 D.error = String(err && err.message ? err.message : err).slice(0, 300)
127 } finally {
128 D.busy = false
129 }
130 $.ui.invalidate('ui.render')
131}
132
133// factory's Workflow scanner (copy). scope: the tab's choice; the « En cours » card always reads every Workflow.
134async function scanWorkflows($, scope) {
135 const cfg = FCFG || {}
136 const argv = [cfg.python || 'python', ...workflowArgs(cfg, {
137 script: root($) + '/lib/factory/scanner/workflow_scan.py', cache: `${await board($)}/cockpit_workflow_${scope === 'all' ? 'all' : 'conv'}.json`, scope, session: D.self || '',
138 })]
139 const r = await $.process.run(argv, { timeoutMs: 180000 })
140 if (r.exitCode !== 0) throw new Error((r.stderr || '').trim().split('\n').pop() || `workflow_scan exit ${r.exitCode}`)
141 return JSON.parse(r.stdout)
142}
143
144async function rescanWorkflow($) {
145 if (D.wfBusy) return
146 D.wfBusy = true
147 $.ui.invalidate('ui.render')
148 try {
149 if (!CFG) await loadConfig($)
150 const scope = D.wfScope || workflowConfig(FCFG).scope
151 D.wf = await scanWorkflows($, scope)
152 if (scope === 'all') D.wfAll = D.wf
153 D.wfError = ''
154 D.wfAt = await $.clock.now()
155 } catch (err) {
156 D.wfError = String(err && err.message ? err.message : err).slice(0, 300)
157 } finally {
158 D.wfBusy = false
159 }
160 $.ui.invalidate('ui.render')
161}
162
163// The settings the Node scanner needs (the decision screens' and the to-do card's).
164const scanCfg = () => ({ nuit: (CFG && CFG.nuit) || {}, galerie: (CFG && CFG.galerie) || {}, controle: (CFG && CFG.controle) || {}, depuis: (CFG && CFG.depuis) || {} })
165
166async function runScanner($, req) {
167 const r = await $.process.run([(CFG && CFG.node) || 'node', root($) + '/scanner/cards.mjs'], { stdin: JSON.stringify({ home: await homeDir($), cfg: scanCfg(), ...req }), timeoutMs: 120000 })
168 if (r.exitCode !== 0) throw new Error((r.stderr || '').trim().split('\n').pop() || `cards exit ${r.exitCode}`)
169 return JSON.parse(r.stdout)
170}
171
172// One decision screen's data (nuit, relais, galerie) from the same scanner.
173async function loadPart($, part) {
174 const busy = part + 'Busy'
175 if (D[busy]) return
176 D[busy] = true
177 $.ui.invalidate('ui.render')
178 try {
179 const now = await $.clock.now()
180 const req = { now, parts: [part] }
181 if (part === 'nuit') req.repos = nightRepos(D.scan, CFG || {}, now)
182 if (part === 'galerie') req.cache = (await board($)) + '/cockpit_codex_approvals.json'
183 if (part === 'depuis') { req.preset = D.depuisPreset; req.cache = (await board($)) + '/cockpit_depuis_prs.json' }
184 const out = await runScanner($, req)
185 D[part + 'Raw'] = out[part] || null
186 if (part === 'depuis') { D.depuisAt = now; D.depuisError = '' }
187 } catch (err) {
188 D[part + 'Raw'] = null
189 if (part === 'depuis') D.depuisError = String(err && err.message ? err.message : err).slice(0, 200)
190 D.cardsError = String(err && err.message ? err.message : err).slice(0, 200)
191 } finally {
192 D[busy] = false
193 }
194 $.ui.invalidate('ui.render')
195}
196
197const whoOf = () => D.who || cardsConfig(CFG || {}).who || 'claude'
198
199// Everything that runs now, for « Bilan avant la nuit »: Workflows, lone agents and pipelines (Claude and Codex).
200function runningNow(now) {
201 const wf = workflowsLive(D.wfAll ? workflowScreen(D.wfAll, FCFG || {}, now) : null).map((w) => ({ kind: 'workflow', text: `Workflow ${w.name}`, eta: w.eta }))
202 const pipes = ((D.cardsRaw && D.cardsRaw.pipelines) || []).map((p) => ({ kind: 'pipeline', text: `Pipeline ${p.app}${p.branch ? ` (${p.branch})` : ''}${p.who === 'codex' ? ' · Codex' : p.who === 'claude' ? ' · Claude' : ''}`, eta: p.eta }))
203 let lone = []
204 try { lone = D.scan ? loneAgents(D.scan, FCFG || {}, now, { scope: 'all' }) : [] } catch { lone = [] }
205 return [...wf, ...lone.map((a) => ({ kind: 'agent', text: `Agent ${a.type}${a.desc ? ' · ' + a.desc : ''} (${a.conv})`, eta: a.eta })), ...pipes]
206}
207
208// The cards' data: scanner/cards.mjs (Node, lib/core) and every Workflow of the PC for « En cours ».
209async function loadCards($) {
210 const now = await $.clock.now()
211 try {
212 D.cardsRaw = await runScanner($, { now, parts: ['cards'] })
213 D.cardsError = ''
214 } catch (err) {
215 D.cardsError = String(err && err.message ? err.message : err).slice(0, 200)
216 }
217 try { D.wfAll = await scanWorkflows($, 'all') } catch { /* the card says « rien ne tourne » */ }
218 D.cardsAt = now
219 D.machine.runner = runnerBackends(D.cardsRaw && D.cardsRaw.runner)
220 $.ui.invalidate('ui.render')
221}
222
223// The pure card list from what is in memory (drawn at every render).
224function cards(now) {
225 const raw = D.cardsRaw || {}
226 const wfScreen = D.wfAll ? workflowScreen(D.wfAll, FCFG || {}, now) : null
227 let waiting = 0
228 try { waiting = D.scan ? waitingRows(D.scan, FCFG || {}, D.at || now).rows.length : 0 } catch { waiting = 0 }
229 let lone = []
230 try { lone = D.scan ? loneAgents(D.scan, FCFG || {}, D.at || now, { scope: 'all' }) : [] } catch { lone = [] }
231 return cardsOf({
232 conso: D.conso, veille: raw.veille || null, waiting, quotas: raw.quotas || null, runnerText: raw.runnerText || '',
233 ci: raw.ci || null, ciBy: raw.ciBy || null, problemes: raw.problemes || null, workflows: workflowsLive(wfScreen), pipelines: raw.pipelines || [],
234 lone, controle: raw.controle || null, who: whoOf(),
235 }, CFG || {}, now)
236}
237
238// PC load through the machine mod's sampler (copy in lib/machine); the runner load comes with the cards (lib/core).
239async function sampleMachine($) {
240 const c = cockpitConfig(CFG || {})
241 const M = D.machine
242 const py = (FCFG && FCFG.python) || 'python'
243 try {
244 const gpu = M.tick % Math.max(1, c.gpu_every) === 0
245 M.tick += 1
246 const r = await $.process.run([py, '-I', root($) + '/lib/machine/scanner/machine.py', 'sample'], { stdin: JSON.stringify({ prev: M.prev, gpu, top_n: 5 }), timeoutMs: 20000 })
247 if (r.exitCode !== 0) throw new Error((r.stderr || '').trim().split('\n').pop() || `machine exit ${r.exitCode}`)
248 const s = JSON.parse(r.stdout)
249 M.prev = s.raw
250 if (!gpu && M.sample && M.sample.gpu) s.gpu = M.sample.gpu
251 M.sample = s
252 M.error = ''
253 } catch (err) {
254 M.error = String(err && err.message ? err.message : err).slice(0, 200)
255 }
256 if (!M.runner) await loadCards($).catch(() => undefined)
257 $.ui.invalidate('ui.render')
258}
259
260// « Claude global »: the usage card from the engine's figures ($.session.usage, cheap) and veille's files. The context
261// gauge lives in band, above the prompt (where Julian looks), not here.
262async function refreshStrips($) {
263 const now = await $.clock.now()
264 try { D.usageRaw = await $.session.usage() } catch { D.usageRaw = null }
265 const me = D.scan ? findSession(D.scan, D.self) : null
266 D.usage = usageCard(D.usageRaw, me, FCFG || {}, now)
267 await loadConso($, now).catch(() => undefined)
268 await loadVeille($)
269 if (D.tab === 'home') await loadCards($).catch(() => undefined)
270 $.ui.invalidate('ui.render')
271}
272
273// « Qui consomme »: reads the usage mod's classement.json. When it is older than rescan_min and the section is open,
274// runs usage's own incremental scanner once in the background (read-only; byte offsets, so only new lines are read).
275async function loadConso($, now) {
276 const q = cockpitConfig(CFG).qui_consomme
277 const ucfg = (await readJson($, sibling($, 'usage') + '/usage.config.json')) || {}
278 const dir = norm(expand(q.data_dir || ucfg.data_dir || '~/.claude/usage-mod', await homeDir($)))
279 D.consoData = await readJson($, dir + '/classement.json')
280 D.codexData = await readJson($, dir + '/codex.json')
281 // the Débit line of the macro view is always drawn (Julian, 19 Oct): debit.json is read whatever the folds
282 await loadDebit($, dir)
283 D.conso = consoCard(D.consoData, CFG, now, D.consoPreset || q.default)
284 const old = !D.consoData || now - (D.consoData.generated || 0) > q.rescan_min * 60000
285 const wanted = (D.strips || {}).conso || (D.tab === 'panneau' && ((D.popen || {}).convs || (D.popen || {}).debit))
286 if (old && wanted && !D.consoScan && ucfg.data_dir !== undefined) {
287 D.consoScan = true
288 $.clock.after(0, async () => {
289 try { await $.process.run([ucfg.node || 'node', sibling($, 'usage') + '/scanner/usage_scan.mjs', 'scan'], { timeoutMs: 600000 }) } catch { /* the card says so */ }
290 D.consoScan = false
291 D.consoData = await readJson($, dir + '/classement.json')
292 // the Débit line of the macro view is always drawn (Julian, 19 Oct), so debit.json is read whatever the folds
293 await loadDebit($, dir)
294 D.conso = consoCard(D.consoData, CFG, await $.clock.now(), D.consoPreset || q.default)
295 $.ui.invalidate('ui.render')
296 })
297 }
298}
299
300// usage's debit.json (hourly TPS rows over 31 days and the last hour's streams): read again only when it changed
301async function loadDebit($, dir) {
302 const path = dir + '/debit.json'
303 let mtime = 0
304 try { mtime = (await $.fs.stat(path)).mtimeMs || 0 } catch { mtime = 0 }
305 if (mtime === D.debitMtime && D.debitData) return
306 D.debitMtime = mtime
307 D.debitData = mtime ? await readJson($, path) : null
308 D.debitMemo = null
309}
310
311// the ci mod's pipelines cache for EN COURS (« 0 CI », or « CI : non lu depuis N min »): parsed again only when it changed
312async function loadCi($) {
313 const path = (await homeDir($)) + '/.claude/ci-pipelines-cache.json'
314 let mtime = 0
315 try { mtime = (await $.fs.stat(path)).mtimeMs || 0 } catch { mtime = 0 }
316 if (mtime === D.ciMtime && (D.ciCache || !mtime)) return
317 D.ciMtime = mtime
318 D.ciCache = mtime ? await readJson($, path) : null
319}
320
321/** The Débit model, computed again only when debit.json, codex.json, the minute or the Codex toggle changed. */
322function debitView(now, who) {
323 const showCodex = who === 'codex'
324 const key = [D.debitData ? D.debitData.generated : 0, D.codexData ? D.codexData.generated : 0, Math.floor(now / 60000), showCodex].join('|')
325 if (D.debitMemo && D.debitMemo.key === key) return D.debitMemo.m
326 let m = null
327 try { m = debitModel({ debit: D.debitData, codex: showCodex ? D.codexData : null, showCodex, now }, CFG || {}) } catch { m = null }
328 D.debitMemo = { key, m }
329 return m
330}
331
332async function loadVeille($) {
333 const dir = sibling($, 'veille')
334 const base = await readJson($, dir + '/veille.config.json')
335 if (!base) { D.veille = null; return }
336 const o = await $.env.get('VEILLE_HOME')
337 const h = o ? norm(o) : (await homeDir($)) + '/.claude/veille'
338 const cfg = vmerge(base, (await readJson($, h + '/config.json')) || {})
339 const state = await readJson($, h + '/state.json')
340 const heartbeat = await readJson($, h + '/heartbeat.json')
341 let acks = []
342 try {
343 const t = (await $.fs.exists(h + '/acks.jsonl')) ? String(await $.fs.read(h + '/acks.jsonl')) : ''
344 acks = t.split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l).key } catch { return null } }).filter(Boolean)
345 } catch { acks = [] }
346 D.veille = state || heartbeat ? viewModel(cfg, state, heartbeat, acks, await $.clock.now()) : null
347}
348
349// How the session is signed in, for the Galerie's Carte: $.session.identity() (engine 2.1.299+) answers only a mod
350// that managed settings install, { deny } to the others; older engines have no such method. Asked once.
351async function loadIdentity($) {
352 if (D.identity !== undefined) return
353 D.identity = null
354 if (!engineAtLeast(D.engine, '2.1.299')) return
355 try {
356 const r = await $.session.identity()
357 D.identity = r && !r.deny && typeof r.kind === 'string' ? { kind: r.kind } : null
358 } catch { D.identity = null }
359}
360
361// « Ouvrir l'agent »: the engine's transcript view of one running agent of this conversation ($.ui.view, 2.1.300+;
362// a button press is person-initiated, which the engine requires). Older engines: the button is not drawn.
363const VIEW_MIN = '2.1.300'
364
365async function viewAgent($, target) {
366 const L = cockpitConfig(CFG || {}).labels
367 if (!engineAtLeast(D.engine, VIEW_MIN)) { $.ui.toast(L.view_old); return }
368 try {
369 const r = await $.ui.view(target)
370 if (r && r.deny) $.ui.toast(L.view_denied.replace('{reason}', r.deny))
371 } catch (err) {
372 $.ui.toast(L.view_denied.replace('{reason}', String(err && err.message ? err.message : err).slice(0, 120)))
373 }
374}
375
376// ---- the Panneau ------------------------------------------------------------------------------------------------------
377
378// The viewer's sections, folded nodes and filters, kept across sessions ($.store, this mod's own).
379async function loadPanelState($) {
380 if (D.pLoaded) return
381 D.pLoaded = true
382 const pc = panelConfig(CFG || {})
383 let saved = null
384 try { saved = (await $.store.get('panel')) || null } catch { saved = null }
385 // sections added since the viewer's last save start as the config says. Saves before v2 held every section's
386 // default too: their « fichiers » is dropped so the file tree starts folded (Julian, 17 Oct); since v2 only the
387 // sections the viewer toggled are kept.
388 const prev = saved && saved.open && typeof saved.open === 'object' ? { ...saved.open } : {}
389 if (!saved || !(saved.v >= 2)) delete prev.fichiers
390 // saves before v3 (the macro view, 19 Oct) kept Barres, Débit and Graphe unfolded: the Panneau opens on its one-line
391 // rows again, and nothing that 18 Oct unfolded by default stays unfolded
392 if (!saved || !(saved.v >= 3)) { delete prev.barres; delete prev.debit; delete prev.graphe }
393 D.ptouched = Object.fromEntries(Object.keys(prev).map((k) => [k, true]))
394 // panel.detail full: Débit starts unfolded too
395 D.popen = { ...Object.fromEntries([...pc.open, ...(detailOf(CFG || {}) === 'full' ? ['debit'] : [])].map((k) => [k, true])), ...prev }
396 D.pclosed = saved && saved.closed && typeof saved.closed === 'object' ? { ...saved.closed } : {}
397 D.pf = { period: '', project: '', state: '', who: pc.convs.who, ...((saved && saved.filter) || {}) }
398 // saves before v4 (22 Oct) always held the period, the default « aujourd’hui » included: the new default (24 h) applies
399 if (!saved || !(saved.v >= 4)) D.pf.period = ''
400 D.pg = grapheFilter(saved && saved.graphe, CFG || {})
401}
402
403function savePanelState($) {
404 const open = Object.fromEntries(Object.entries(D.popen || {}).filter(([k]) => (D.ptouched || {})[k]))
405 try { void Promise.resolve($.store.set('panel', { v: 4, open, closed: D.pclosed, filter: D.pf, graphe: D.pg })).catch(() => undefined) } catch { /* not kept */ }
406}
407
408const fopts = () => { const f = panelConfig(CFG || {}).fichiers; return { showHidden: Boolean(f.show_hidden), git: f.git !== false } }
409
410// Fichiers' I/O (files.mjs holds the tree as data, filetree's own logic): one folder listed, links resolved as filetree does.
411async function listDir($, dir) {
412 try {
413 const entries = await $.fs.list(dir)
414 const out = []
415 for (const e of entries) {
416 if (!e.isLink) { out.push({ name: e.name, kind: e.kind, mtimeMs: e.mtimeMs, size: e.size, isLink: false }); continue }
417 try {
418 const t = await $.fs.stat(dir + '/' + e.name)
419 out.push(t.kind === 'dir' ? { name: e.name, kind: 'dir', mtimeMs: t.mtimeMs, size: 0, isLink: false } : { name: e.name, kind: 'file', mtimeMs: t.mtimeMs, size: t.size, isLink: true })
420 } catch {
421 out.push({ name: e.name, kind: 'other', mtimeMs: 0, size: 0, isLink: true })
422 }
423 }
424 return nodesOf(dir, out)
425 } catch {
426 return null
427 }
428}
429
430async function listInto($, dirs) {
431 const listed = new Map()
432 for (const d of dirs) {
433 const kids = await listDir($, d)
434 if (kids) listed.set(d, kids)
435 }
436 applyListed(D.F, listed)
437}
438
439async function gitInto($) {
440 if (!fopts().git || !D.F.root) return
441 const run = (args, timeoutMs) => $.process.run(['git', '--no-optional-locks', '-C', D.F.root, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' } })
442 try {
443 if (!D.F.top) {
444 const r = await run(['rev-parse', '--show-prefix', '--show-toplevel'], 5000)
445 applyTop(D.F, r.exitCode === 0 ? r.stdout : null)
446 }
447 if (D.F.top === '-') return
448 const st = await run(['status', '--porcelain=v1', '-b', '-z', '--untracked-files=normal', '--', '.'], 20000)
449 if (st.exitCode === 0) applyGit(D.F, st.stdout)
450 } catch { /* no git: the tree shows without letters */ }
451}
452
453async function refreshFiles($) {
454 if (!(D.popen || {}).fichiers || D.fBusy) return
455 D.fBusy = true
456 try {
457 let cwd = ''
458 try { cwd = String((await $.session.cwd()) || '') } catch { cwd = '' }
459 const want = norm(cwd).replace(/\/+$/, '')
460 if (!D.F.root || D.F.root !== want) {
461 let windows = false
462 try { windows = (await $.env.get('OS')) === 'Windows_NT' } catch { windows = false }
463 resetTo(D.F, want, windows, fopts().showHidden)
464 }
465 await listInto($, openDirs(D.F))
466 await gitInto($)
467 } catch (err) {
468 D.F.error = String(err && err.message ? err.message : err).slice(0, 200)
469 } finally {
470 D.fBusy = false
471 }
472 $.ui.invalidate('ui.render')
473}
474
475async function pressFile($, id) {
476 const dir = filesPress(D.F, id)
477 if (dir) { await listInto($, [dir]); openDir(D.F, dir) }
478 $.ui.invalidate('ui.render')
479}
480
481// Everything the Panneau reads: the engine's usage, usage's classement.json and codex.json, the cards scanner's quotas
482// and every Workflow of the PC, the file tree.
483async function refreshPanel($) {
484 await loadPanelState($)
485 const now = await $.clock.now()
486 try { D.usageRaw = await $.session.usage() } catch { D.usageRaw = null }
487 await loadConso($, now).catch(() => undefined)
488 await loadCi($).catch(() => undefined)
489 // the quotas file and every Workflow of the PC (phases, ETA, cost of this conversation's Workflows too)
490 // Quotas' compact rows (panel.detail normal / full) say the projection: the readings of the cards scanner
491 if ((D.popen || {}).convs || (D.popen || {}).conv || (D.popen || {}).barres || detailOf(CFG || {}) !== 'compact') await loadCards($).catch(() => undefined)
492 // the graph's one line and « À toi de cliquer » at the top are always drawn: the session files are read whatever the folds
493 if (now - D.grapheAt >= Math.max(10, panelConfig(CFG || {}).graphe.refresh_s) * 1000) {
494 D.grapheAt = now
495 await loadPart($, 'graphe').catch(() => undefined)
496 }
497 await refreshFiles($)
498 $.ui.invalidate('ui.render')
499}
500
501function panelView(now) {
502 const pc = panelConfig(CFG || {})
503 const detail = detailOf(CFG || {})
504 const F = D.F
505 const fr = filesRows(F, (pc.fichiers.max_rows || 30) + D.fLimit)
506 const f = D.pf
507 let wfScreen = null
508 try { wfScreen = D.wfAll ? workflowScreen(D.wfAll, FCFG || {}, now) : null } catch { wfScreen = null }
509 const runs = (wfScreen && wfScreen.runs) || []
510 // one context window for every conversation: CLAUDE_CODE_AUTO_COMPACT_WINDOW when set (Julian, 18 Oct: « / 1.0M » here,
511 // « / 400K » elsewhere), else the engine's for this one, else factory's
512 const ctxWindow = effectiveWindow(CFG || {}, D.autoWindow, D.usageRaw && D.usageRaw.context && D.usageRaw.context.window, (FCFG || {}).context_window)
513 const fcfgW = { ...(FCFG || {}), context_window: ctxWindow }
514 const syn = synthesis(D.consoData, D.scan, D.codexData, { ...f, fcfg: FCFG || {}, runs }, CFG || {}, now)
515 const claudeAll = f.who === 'codex' || f.project || f.state ? synthesis(D.consoData, D.scan, null, { ...f, who: 'claude', project: '', state: '', runs }, CFG || {}, now) : syn
516 const tps = claudeAll.tps
517 // EN COURS counts every conversation of the window, not only the top N shown (Julian, 20 Oct)
518 const every = { ...claudeAll, rows: claudeAll.every || claudeAll.rows }
519 // Cette conversation: the lead's Usage-like card and one Background-tasks-like card per Workflow / background agents
520 const me = D.scan && D.self ? findSession(D.scan, D.self) : null
521 const card = usageCard(D.usageRaw, me, FCFG || {}, now)
522 const contexte = contexteOf({ usage: D.usageRaw, ctxTokens: me ? me.ctx : NaN, fwin: (FCFG || {}).context_window, auto: ctxWindow }, CFG || {})
523 const selfRow = (claudeAll.rows || []).find((r) => r.id === D.self)
524 const factory = D.scan && D.self ? convFactory(D.scan, D.self, FCFG || {}, CFG || {}, D.at || now, D.pclosed,
525 { runs, card, contexte, tps: (claudeAll.tpsById || {})[D.self], rowCost: selfRow ? selfRow.cost : null }) : null
526 let graphe = null
527 try {
528 graphe = grapheModel({ rows: D.grapheRaw && D.grapheRaw.rows, scan: D.scan, workflows: runs, conso: D.consoData, fcfg: fcfgW, filter: D.pg, link: depuisConfig(CFG || {}).session_link }, D.self, CFG || {}, now)
529 } catch { graphe = null }
530 // CONVERSATIONS (Julian, 22 Oct: « I don't see all ongoing conversations »): the sessions of GRAPHE with the activity of
531 // EN COURS and usage's tokens when known (lib/panel_list.mjs); each row its factory (counts, détails), its children
532 // unfolded while it runs a Workflow (Julian, 20 Oct). EN COURS counts over the same pool
533 let under = { unfold: new Set(), factories: {}, tree: new Set(), kids: new Set() }
534 let list = null
535 let pool = every
536 try {
537 const x = panelConvs({ syn: claudeAll, graphRows: D.grapheRaw && D.grapheRaw.rows, graphe, scan: D.scan, runs, conso: D.consoData, fcfg: fcfgW, self: D.self, selfFactory: factory,
538 closed: D.pclosed, autoWindow: ctxWindow, tpsById: claudeAll.tpsById || {}, cardOf: (s) => usageCard(null, s, FCFG || {}, now), filter: f, tzMin: (D.consoData && D.consoData.tzMin) || 0 }, CFG || {}, D.at || now)
539 under = x.under
540 list = x.list
541 pool = { ...claudeAll, rows: x.pool }
542 // the « Projet ▾ » filter cycles through the projects of the list (not only those usage has figures for)
543 D.convProjects = [...new Set(x.pool.map((r) => r.project).filter(Boolean))].sort((a, b) => a.localeCompare(b))
544 } catch { /* rows stay folded */ }
545 return {
546 unfold: under.unfold, tree: under.tree || new Set(), kids: under.kids || under.tree || new Set(), convFactories: under.factories, synAll: pool, convList: list, link: depuisConfig(CFG || {}).session_link, ctxWindow,
547 open: D.popen || {}, closed: D.pclosed, self: D.self,
548 ci: ciNow(D.ciCache, now, pc.encours),
549 // panel.detail normal / full: each row's branch CI (« PR #120 ✓ ») and the « Aujourd’hui » line
550 ciBranches: detail !== 'compact' ? safe(() => ciBranches(D.ciCache)) : null,
551 today: detail !== 'compact' ? safe(() => todayOf(f.period === 'today' && claudeAll === syn && f.who !== 'codex' ? syn
552 : synthesis(D.consoData, D.scan, null, { period: 'today', who: 'claude', project: '', state: '', runs }, CFG || {}, now))) : null,
553 files: { head: F.root ? filesHead(F) : '', rows: fr.rows, more: fr.more, selected: F.selected ? filesShort(F, F.selected) : '', attach: pc.fichiers.attach_selection !== false, busy: D.fBusy, error: F.error },
554 factory, treeEmpty: !D.scan ? (D.busy ? cockpitConfig(CFG).labels.scanning : '') : '',
555 syn, filter: f,
556 bars: barsOf({ usage: D.usageRaw, quotas: (D.cardsRaw && D.cardsRaw.quotas) || null, tps, codex: f.who === 'codex', card }, CFG || {}),
557 graphe,
558 debit: debitView(now, f.who),
559 // the diagrams (lib/viz_*.mjs): built only for what is unfolded
560 sankey: D.scan && D.self ? safe(() => sankeyModel(D.scan, D.self, runs)) : null,
561 quotaCurves: (D.popen || {}).barres || detail !== 'compact' ? safe(() => quotaCurves({ series: D.cardsRaw && D.cardsRaw.quotaSeries, usage: D.usageRaw, now }, (CFG && CFG.panel && CFG.panel.viz && CFG.panel.viz.quota) || {})) : null,
562 debitSpark: (D.popen || {}).debit ? safe(() => tpsHourly(D.debitData, now, CFG || {})) : null,
563 }
564}
565
566function safe(f) {
567 try { return f() } catch { return null }
568}
569
570// What the tab on screen needs, now.
571async function refreshTab($) {
572 if (D.tab === 'panneau') { await rescan($); return refreshPanel($) }
573 if (D.tab === 'workflow') return rescanWorkflow($)
574 if (D.tab === 'machine') return sampleMachine($)
575 if (D.tab === 'relais') return loadPart($, 'relais')
576 if (D.tab === 'nuit') { await rescan($); await loadCards($).catch(() => undefined); return loadPart($, 'nuit') }
577 if (D.tab === 'galerie') { await rescan($); await loadIdentity($); return loadPart($, 'galerie') }
578 if (D.tab === 'depuis') {
579 await rescan($) // the waiting rows (lib/core/waiting.mjs) come from factory's scan
580 try { D.wfAll = await scanWorkflows($, 'all') } catch { /* no Workflow lines */ }
581 return loadPart($, 'depuis')
582 }
583 await rescan($)
584 if (D.tab === 'home') await refreshStrips($).catch(() => undefined)
585}
586
587// Shown = open, placed, and the tab on screen (another pane, e.g. filetree, may be the one shown).
588async function shown($) {
589 if (!D.open) return false
590 try {
591 const p = ((await $.ui.panes()) || []).find((x) => x.id === PANE)
592 return Boolean(p && p.isPlaced && p.isShown !== false)
593 } catch {
594 return D.open
595 }
596}
597
598// ---- actions ---------------------------------------------------------------------------------------------------------
599
600// A card's button: the first target loaded in this session ($.command.list(), local), or one of cockpit's own tabs.
601async function runTarget($, candidates) {
602 let names = []
603 try { names = ((await $.command.list()) || []).map((c) => c.name) } catch { names = [] }
604 const t = pickTarget(candidates, names)
605 if (t && t.tab) { setTab($, t.tab); return }
606 if (!t) {
607 const first = (candidates || []).find((c) => c.command)
608 $.ui.toast(cardsConfig(CFG).labels.not_loaded.replace('{command}', first ? '/' + first.command : '?'))
609 return
610 }
611 void $.command.run(t.args ? { command: t.command, args: t.args } : { command: t.command }).catch(() => $.ui.toast(`/${t.command} : échec`))
612}
613
614function setTab($, tab) {
615 D.tab = tabValid(CFG, tab) ? tab : 'home'
616 if (D.tab === 'nuit') { D.launch = null; D.nuitMsg = '' }
617 if (D.tab === 'depuis') D.depuisMsg = ''
618 $.ui.invalidate('ui.render')
619 void refreshTab($)
620}
621
622function actions($) {
623 const redraw = () => $.ui.invalidate('ui.render')
624 return {
625 // the Panneau
626 ptoggle: (id) => {
627 D.popen = { ...(D.popen || {}), [id]: !(D.popen || {})[id] }
628 D.ptouched = { ...(D.ptouched || {}), [id]: true }
629 savePanelState($)
630 redraw()
631 if (D.popen[id] && (id === 'fichiers' || id === 'conv' || id === 'convs' || id === 'barres' || id === 'debit' || id === 'graphe' || id === 'today')) void refreshPanel($)
632 },
633 gfilter: (k, v) => { D.pg = grapheFilter({ ...(D.pg || {}), [k]: v }, CFG || {}); savePanelState($); redraw() },
634 gcycle: async (k) => {
635 const now = await $.clock.now()
636 const m = grapheModel({ rows: D.grapheRaw && D.grapheRaw.rows, scan: D.scan, fcfg: FCFG || {}, filter: { ...(D.pg || {}), project: '' } }, D.self, CFG || {}, now)
637 D.pg = grapheFilter({ ...(D.pg || {}), [k]: cycle(m.ready ? m.projects : [], (D.pg || {})[k]) }, CFG || {})
638 savePanelState($)
639 redraw()
640 },
641 // open: whether the node is unfolded now (nodes with a default of their own, e.g. a running phase); without it, flip
642 nodeToggle: (key, open) => { D.pclosed = { ...D.pclosed, [key]: open === undefined ? !D.pclosed[key] : Boolean(open) }; savePanelState($); redraw() },
643 pfilter: (k, v) => {
644 D.pf = { ...D.pf, [k]: v }
645 if (k === 'who') D.pf = { ...D.pf, project: '', state: '' }
646 savePanelState($)
647 redraw()
648 },
649 // « filtre : … (réinitialiser) » under the CONVERSATIONS header: every filter back to its default
650 preset: () => { D.pf = { period: '', project: '', state: '', who: D.pf.who }; savePanelState($); redraw() },
651 pcycle: async (k) => {
652 const now = await $.clock.now()
653 const list = k === 'state' ? ['running', 'waiting', 'done'] : D.convProjects && D.convProjects.length ? D.convProjects : synthesis(D.consoData, D.scan, D.codexData, { ...D.pf, project: '', fcfg: FCFG || {} }, CFG || {}, now).projects
654 D.pf = { ...D.pf, [k]: cycle(list, D.pf[k]) }
655 savePanelState($)
656 redraw()
657 },
658 filePress: (id) => pressFile($, id),
659 filesClear: () => { filesClear(D.F); redraw() },
660 filesMore: () => { D.fLimit += panelConfig(CFG || {}).fichiers.more_step || 30; redraw() },
661 filesRefresh: () => refreshFiles($),
662 tab: (id) => setTab($, id),
663 crumb: (level) => { D.nav = zoomTo(D.nav, level); redraw() },
664 select: (node) => {
665 const before = D.nav && D.nav.conv
666 D.nav = select(D.nav || { level: 'mega' }, node)
667 const sec = sectionFor(node)
668 if (sec) D.strips = { ...(D.strips || {}), [sec]: true }
669 redraw()
670 if (D.nav.conv && D.nav.conv !== before && !(D.scan && D.scan.detail && D.scan.detail.cli === D.nav.conv)) void rescan($)
671 },
672 refresh: () => { void refreshTab($) },
673 filter: (k) => { D.filter = k; redraw() },
674 jfilter: (k) => { D.jfilter = k; redraw() },
675 jproject: (p) => { D.jproject = p; redraw() },
676 pick: () => { D.tab = 'home'; D.strips = { ...(D.strips || {}), all: true }; redraw() },
677 go: (cli) => {
678 const s = D.scan && findSession(D.scan, cli)
679 D.nav = select(D.nav || { level: 'mega' }, { kind: 'conv', id: cli, repo: s ? repoOf(s) : undefined })
680 D.tab = 'arbre'
681 redraw()
682 void rescan($)
683 },
684 wfScope: (k) => { D.wfScope = k; redraw(); void rescanWorkflow($) },
685 wfToggle: (key, open) => { D.wfOpen = { ...D.wfOpen, [key]: open }; redraw() },
686 toggle: (id) => {
687 D.strips = { ...(D.strips || {}), [id]: !(D.strips || {})[id] }
688 redraw()
689 if ((id === 'global' || id === 'conso') && D.strips[id]) void refreshStrips($)
690 },
691 card: (card) => { void runTarget($, card.button) },
692 files: () => { void $.command.run({ command: 'filetree' }).catch(() => $.ui.toast('filetree non chargé : ajoute vendor/filetree à CLAUDE_CODE_PLUGIN_DIRS')) },
693 consoPreset: (id) => {
694 D.consoPreset = id
695 void $.clock.now().then((now) => { D.conso = consoCard(D.consoData, CFG, now, id); redraw() })
696 },
697 consoFull: () => {
698 const q = cockpitConfig(CFG).qui_consomme
699 void $.command.run({ command: q.command, args: q.args }).catch(() => $.ui.toast(cockpitConfig(CFG).labels.conso_missing))
700 },
701 machine: () => setTab($, 'machine'),
702 // the left menu: another hub's command, or this pane's Cockpit tab
703 menu: (m) => {
704 if (m.self) { setTab($, 'home'); return }
705 void runTarget($, [{ command: m.command, ...(m.args ? { args: m.args } : {}) }])
706 },
707 who: (w) => { D.who = w; redraw() },
708 day: () => { void runTarget($, [{ command: 'veille', args: 'jour' }]) },
709 nightOn: () => { void runTarget($, [{ command: 'veille', args: 'nuit' }]) },
710 nothing: () => { D.launch = null; D.nuitMsg = ''; setTab($, 'home'); $.ui.toast(decideLabels().nothing_done) },
711 launch: (i) => { D.launch = D.launch === i ? null : i; redraw() },
712 galView: (v) => { D.galView = v; redraw() },
713 openAgent: (id) => { void viewAgent($, { agentId: id }) },
714 // « Depuis ton dernier message »
715 depuisPreset: (id) => { D.depuisPreset = id; D.depuisMsg = ''; redraw(); void loadPart($, 'depuis') },
716 depuisToggle: (name, open) => { D.depuisOpen = { ...D.depuisOpen, [name]: open }; redraw() },
717 depuisAsk: () => { D.depuisAsk = !D.depuisAsk; redraw() },
718 openConv: (link) => { void openUrl($, link).then((ok) => { if (!ok) $.ui.toast(link) }) },
719 copy: (text, press, what) => {
720 const L = depuisConfig(CFG || {}).labels
721 const done = what === 'id' ? L.copied.replace('{id}', text) : L.ask_copy + ' ✓'
722 void $.ui.copy({ text, surface: press && press.surface }).then((r) => { D.depuisMsg = r && r.isCopied === false ? text : done; redraw() }).catch(() => { D.depuisMsg = text; redraw() })
723 },
724 backToConv: () => { void viewAgent($, {}) },
725 }
726}
727
728// « Depuis ton dernier message »: the scanner's part plus what the pane already holds (waiting rows, Workflow runs).
729function depuisView() {
730 let waiting = []
731 try { waiting = D.scan ? waitingRows(D.scan, FCFG || {}, D.at || D.depuisAt).rows : [] } catch { waiting = [] }
732 return { raw: D.depuisRaw, busy: D.depuisBusy, at: D.depuisAt, error: D.depuisError, preset: D.depuisPreset, open: D.depuisOpen, ask: D.depuisAsk, msg: D.depuisMsg,
733 extra: { waiting, workflows: (D.wfAll && D.wfAll.runs) || [] } }
734}
735
736// Hands a claude:// link to the OS (its handler is the desktop app), as Veille's Fil does. Windows: url.dll, no shell.
737async function openUrl($, url) {
738 if (!/^claude:\/\/claude\.ai\/epitaxy\/[\w-]+$/.test(String(url || ''))) return false
739 let win = false
740 try { win = (await $.env.get('OS')) === 'Windows_NT' } catch { win = false }
741 const argv = win ? ['rundll32.exe', 'url.dll,FileProtocolHandler', url] : ['xdg-open', url]
742 try { const r = await $.process.run(argv, { timeoutMs: 10000 }); return r.exitCode === 0 } catch { return false }
743}
744
745async function openPane($, focus) {
746 D.open = true
747 const title = (cockpitConfig(CFG).labels || {}).title || 'Cockpit'
748 return $.ui.open(focus ? { id: PANE, title, focus: true } : { id: PANE, title })
749}
750
751// /cockpit <args> (also the /factory and /machine aliases, mapped by aliasArgs).
752async function runCockpit($, args) {
753 await loadConfig($)
754 try { D.self = (await $.session.id()) || D.self } catch { /* keep */ }
755 try { D.engine = ((await $.session.version()) || {}).version || D.engine } catch { /* keep */ }
756 if (D.tab === 'panneau' && !tabValid(CFG, 'panneau')) D.tab = 'home'
757 const p = parseArgs(args)
758 if (p.close) { D.open = false; await $.ui.close({ id: PANE }); return {} }
759 await openPane($, true)
760 if (!D.scan) await rescan($)
761 if (!D.nav && D.scan) D.nav = initialNav(D.scan, D.self, CFG)
762 if (p.tab === 'depuis') D.depuisPreset = p.rest
763 if (p.rest && D.scan && p.tab !== 'depuis') {
764 const s = pickSession(D.scan.sessions, p.rest)
765 const repo = !s && (D.scan.sessions || []).map(repoOf).find((r) => r.toLowerCase().includes(p.rest.toLowerCase()))
766 if (s) D.nav = select(D.nav, { kind: 'conv', id: s.cli, repo: repoOf(s) })
767 else if (repo) D.nav = select(D.nav, { kind: 'project', id: repo })
768 }
769 if (p.level && D.nav) D.nav = zoomTo(D.nav, p.level) // a level whose subject is unknown (micro without an agent) stays put
770 if ((p.level || (p.rest && p.tab !== 'depuis')) && D.nav && (!p.tab || p.tab === 'home')) {
771 const sec = { mega: 'all', macro: 'project', medium: 'conv', micro: 'conv' }[D.nav.level]
772 if (sec) D.strips = { ...(D.strips || {}), [sec]: true }
773 }
774 if (p.scope) D.wfScope = p.scope
775 D.tab = p.tab && tabValid(CFG, p.tab) ? p.tab : p.level || p.rest ? (D.tab === 'arbre' ? 'arbre' : 'home') : D.tab
776 if (D.tab === 'home') { await rescan($); await refreshStrips($).catch(() => undefined) } else await refreshTab($)
777 return {}
778}
779
780// The old names (/factory, /machine) as aliases, only when no loaded command has them already: the same check band
781// makes to grey a button ($.command.list(), local). Registering a name again would replace the other mod's command.
782async function registerAliases($) {
783 const a = cockpitConfig(CFG || {}).aliases
784 if (!a.enabled) return
785 let names = []
786 try { names = ((await $.command.list()) || []).map((c) => c.name) } catch { return }
787 for (const name of a.names) {
788 if (names.includes(name) || ALIASES.has(name)) continue
789 try {
790 await $.command.register({ name, immediate: true, description: `Alias de /cockpit : ${name === 'machine' ? 'onglet Machine (charge du PC et du runner)' : 'écrans de Factory (Mission, Focus, Arbre, Pipeline, Workflow, Journal)'}, maintenant dans Cockpit`,
791 argumentHint: name === 'machine' ? '| close' : '[mission|focus|arbre|pipeline|workflow [all]|journal|systeme] [titre ou id] | close' })
792 ALIASES.add(name)
793 } catch { /* refused: nothing */ }
794 }
795}
796
797async function aliasHook($, e, next, name) {
798 if (!ALIASES.has(name)) return next(e)
799 return runCockpit($, aliasArgs(name, e.args))
800}
801
802export { parseArgs }
803
804export function register(on, opts) {
805 options = opts || {}
806 on('session.start', async ($, e, next) => {
807 try { D.self = await $.session.id() } catch { D.self = null }
808 try { D.engine = ((await $.session.version()) || {}).version || '' } catch { D.engine = '' }
809 // the auto-compact window every context bar is measured against (lib/panel.mjs effectiveWindow)
810 try { D.autoWindow = Number(await $.env.get('CLAUDE_CODE_AUTO_COMPACT_WINDOW')) || 0 } catch { D.autoWindow = 0 }
811 await loadConfig($).catch(() => { CFG = {}; FCFG = {} })
812 const c = cockpitConfig(CFG)
813 if (D.tab === 'panneau' && panelConfig(CFG).enabled === false) D.tab = 'home'
814 try {
815 await $.command.register({ name: 'cockpit', immediate: true, description: 'Cockpit : Depuis ton dernier message (le résumé de la nuit), cartes (conso, t’attend, à toi, Claude Control, CI, quota, problèmes, en cours), Méga › Projet › Conversation › Agent, écrans Factory, Machine, Claude ↔ Codex, Galerie, Bilan avant la nuit',
816 argumentHint: '[conv|projet|mega|agent|mission|focus|arbre|pipeline|workflow [all]|journal|machine|relais|galerie|nuit|depuis [8h|24h|nuit|HH:MM]] [projet ou conversation] | close' })
817 } catch { /* a name clash must not break the rest */ }
818 $.clock.after(Math.max(0, c.aliases.delay_s) * 1000, () => { void registerAliases($).catch(() => undefined) })
819 $.clock.every(Math.max(5, c.refresh_s) * 1000, () => { void shown($).then((s) => (s && D.tab !== 'workflow' && D.tab !== 'machine' ? rescan($) : undefined)) })
820 $.clock.every(Math.max(15, c.strips_refresh_s) * 1000, () => { void shown($).then((s) => (s ? (D.tab === 'workflow' ? rescanWorkflow($) : D.tab === 'panneau' ? refreshPanel($) : refreshStrips($)) : undefined)) })
821 $.clock.every(Math.max(3, c.machine_refresh_s) * 1000, () => { void shown($).then((s) => (s && D.tab === 'machine' ? sampleMachine($) : undefined)) })
822 if (c.auto_open && options.start !== 'off') {
823 void (async () => {
824 await openPane($, false).catch(() => undefined)
825 await rescan($)
826 if (D.tab === 'panneau') await refreshPanel($).catch(() => undefined)
827 else await refreshStrips($).catch(() => undefined)
828 })()
829 }
830 return next(e)
831 })
832
833 on('command.run', { command: 'cockpit' }, async ($, e) => runCockpit($, e.args))
834
835 // Fichiers: while a file is selected by a click in the Panneau's tree, the prompt carries filetree's own context line
836 // (the prompt text itself is never changed). panel.fichiers.attach_selection: false keeps the selection visual only.
837 on('prompt.submit', async ($, e, next) => {
838 const line = filesContext(D.F)
839 if (!line || panelConfig(CFG || {}).fichiers.attach_selection === false) return next(e)
840 let ok = false
841 try { ok = await $.fs.exists(D.F.selected) } catch { ok = false }
842 return ok ? next({ ...e, context: [...(e.context || []), line] }) : next(e)
843 }).catch(($, e, next) => next(e))
844 on('command.run', { command: 'factory' }, async ($, e, next) => aliasHook($, e, next, 'factory')).catch(($, e, next) => next(e))
845 on('command.run', { command: 'machine' }, async ($, e, next) => aliasHook($, e, next, 'machine')).catch(($, e, next) => next(e))
846
847 on('ui.close', { id: PANE }, async ($, e, next) => { D.open = false; return next(e) }).catch(($, e, next) => next(e))
848
849 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
850 const el = $.ui.resolve(e)
851 D.open = true
852 D.columns = Number(e.props && e.props.bodyColumns) || 0
853 const now = D.cardsAt || D.at || 0
854 const teamCli = (D.nav && D.nav.conv) || D.self
855 const teamRow = D.tab === 'galerie' && D.scan ? findSession(D.scan, teamCli) : null
856 const teamDetail = teamRow && D.scan.detail && D.scan.detail.cli === teamCli ? D.scan.detail : null
857 const laneRepo = teamRow ? repoOf(teamRow) : null
858 return drawCockpit(el, {
859 who: whoOf(), now: now || D.at, galView: D.galView, launch: D.launch, nuitMsg: D.nuitMsg,
860 nuitData: D.tab === 'nuit' ? { running: runningNow(now || D.at), quotas: (D.cardsRaw && D.cardsRaw.quotas) || null, ready: readyItems((D.nuitRaw && D.nuitRaw.repos) || [], CFG || {}) } : null,
861 nuitBusy: D.nuitBusy, relaisData: D.relaisRaw && D.relaisRaw.interactions, relaisBusy: D.relaisBusy, galerieData: D.galerieRaw, galerieBusy: D.galerieBusy,
862 teamSession: teamRow ? { ...teamRow, ...(teamDetail || {}) } : null, teamTitle: teamRow ? titleOf(teamRow) : '',
863 laneRepo, laneRows: laneRepo && D.galView === 'lanes' ? laneRows(D.scan, laneRepo, FCFG || {}) : [], identity: D.identity || null,
864 canView: engineAtLeast(D.engine, VIEW_MIN), viewAgent: (e.props && e.props.view && e.props.view.agentId) || null,
865 cfg: CFG || {}, fcfg: FCFG || {}, scan: D.scan, nav: D.nav || { level: 'mega', repo: null, conv: null, agent: null }, self: D.self,
866 busy: D.busy, error: D.error, at: D.at, columns: D.columns, filter: D.filter, usage: D.usage, veille: D.veille, open: D.strips || {}, act: actions($),
867 conso: D.conso, consoPreset: D.consoPreset, tab: D.tab, jfilter: D.jfilter, jproject: D.jproject,
868 cards: D.tab === 'home' ? cards(now || D.at) : [],
869 depuis: D.tab === 'depuis' ? depuisView() : null, cardsError: D.cardsError, cardsAt: D.cardsAt,
870 wf: D.wf, wfAt: D.wfAt, wfBusy: D.wfBusy, wfError: D.wfError, wfScope: D.wfScope, wfOpen: D.wfOpen, machine: D.machine,
871 panel: D.tab === 'panneau' ? { ...panelView(now || D.at || 0), act: actions($) } : null,
872 })
873 })
874}
875lib/factory/hooks/logic.mjs 291 lines1// factory - pure logic (no $, no I/O): config merge, pricing, warnings, and the models the two views draw.
2// Input is the JSON printed by scanner/factory_scan.py. Tested with node --test tests/logic.test.mjs.
3
4export const FAMILIES = ['opus', 'sonnet', 'haiku', 'fable']
5
6export function deepMerge(base, over) {
7 if (!over || typeof over !== 'object' || Array.isArray(over)) return over === undefined ? base : over
8 const out = { ...(base || {}) }
9 for (const [k, v] of Object.entries(over)) {
10 out[k] = v && typeof v === 'object' && !Array.isArray(v) && base && typeof base[k] === 'object' && !Array.isArray(base[k])
11 ? deepMerge(base[k], v) : v
12 }
13 return out
14}
15
16export function family(model) {
17 const m = String(model || '').toLowerCase()
18 return FAMILIES.find((f) => m.includes(f)) || 'other'
19}
20
21// Rate for a model id: exact id, else its family, else default.
22export function rateFor(model, cfg) {
23 const r = cfg.rates || {}
24 return r[model] || r[family(model)] || r.default || { in: 0, out: 0, cache_read: 0, cache_write_5m: 0, cache_write_1h: 0 }
25}
26
27// v = [in, out, cache_read, cache_write_5m, cache_write_1h, calls]
28export function costOf(model, v, cfg) {
29 const r = rateFor(model, cfg)
30 return ((v[0] || 0) * r.in + (v[1] || 0) * r.out + (v[2] || 0) * r.cache_read + (v[3] || 0) * r.cache_write_5m
31 + (v[4] || 0) * r.cache_write_1h) / 1e6
32}
33
34export function tokensOf(v) {
35 return (v[0] || 0) + (v[1] || 0) + (v[2] || 0) + (v[3] || 0) + (v[4] || 0)
36}
37
38// Sum a list of {model: vec} maps into {model: {tokens, cost, calls, out}}.
39export function billByModel(maps, cfg) {
40 const out = {}
41 for (const mp of maps) {
42 for (const [m, v] of Object.entries(mp || {})) {
43 const o = out[m] || (out[m] = { tokens: 0, cost: 0, calls: 0, out: 0 })
44 o.tokens += tokensOf(v)
45 o.cost += costOf(m, v, cfg)
46 o.calls += v[5] || 0
47 o.out += v[1] || 0
48 }
49 }
50 return out
51}
52
53export function sumCost(bill) {
54 return Object.values(bill).reduce((a, b) => a + b.cost, 0)
55}
56
57export function localDay(epochMs) {
58 const d = new Date(epochMs)
59 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
60}
61
62export function fmtTokens(n) {
63 if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B'
64 if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
65 if (n >= 1e3) return Math.round(n / 1e3) + 'K'
66 return String(n || 0)
67}
68
69export function fmtUsd(x) {
70 return '$' + (x >= 100 ? x.toFixed(0) : x.toFixed(2))
71}
72
73export function hhmm(ts) {
74 if (!ts) return '--:--'
75 const d = new Date(ts)
76 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
77}
78
79export function short(model) {
80 const m = String(model || '?')
81 return m.replace(/^claude-/, '').replace(/-\d{8}$/, '').replace(/-(\d+)-(\d+)$/, ' $1.$2')
82}
83
84// The engine's version ("2.1.302", $.session.version().version) is at least min: how a mod turns on an API newer
85// than the oldest engine it runs on (a mod may not read $.noun.event as a value to test it). Unknown: false.
86export function engineAtLeast(version, min) {
87 const a = String(version || '').split(/[.-]/).slice(0, 3).map((x) => Number(x))
88 const b = String(min || '').split('.').map((x) => Number(x))
89 if (a.length < 3 || a.some((x) => !Number.isFinite(x))) return false
90 for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i]
91 return true
92}
93
94export function repoOf(s) {
95 const p = String(s.origin || s.cwd || '').replace(/\\/g, '/')
96 const i = p.indexOf('/.claude/worktrees/')
97 const base = i >= 0 ? p.slice(0, i) : p
98 return base.split('/').filter(Boolean).pop() || '?'
99}
100
101export function titleOf(s) {
102 return s.app_title || s.title || String(s.cli || '').slice(0, 8)
103}
104
105export function architectReason(text, cfg) {
106 const a = cfg.architect || {}
107 for (const r of a.reasons || []) if (new RegExp(r.match, 'i').test(text || '')) return r.label
108 return a.fallback || 'other'
109}
110
111export function isArchitect(sub, cfg) {
112 return ((cfg.architect && cfg.architect.types) || ['architect']).includes(sub.type)
113}
114
115export function ctxPct(ctx, cfg) {
116 return Math.round((100 * (ctx || 0)) / (cfg.context_window || 400000))
117}
118
119export function ctxLevel(ctx, cfg) {
120 const p = (100 * (ctx || 0)) / (cfg.context_window || 400000)
121 const t = cfg.thresholds || {}
122 return p >= (t.ctx_bad_pct ?? 62.5) ? 'bad' : p >= (t.ctx_warn_pct ?? 50) ? 'warn' : 'ok'
123}
124
125function loopText(loops) {
126 return Object.entries(loops || {}).filter(([, n]) => n > 0).map(([k, n]) => `${k} x${n}`).join(', ')
127}
128
129// Warnings for one conversation (row or detail). Each: {level, text}.
130export function warnings(s, cfg) {
131 const t = cfg.thresholds || {}
132 const out = []
133 const subs = s.subs || []
134 const noModel = subs.filter((x) => !x.workflow && !x.explicit_model)
135 if (noModel.length) {
136 out.push({ level: 'warn', text: `${noModel.length} subagent(s) without explicit model (inherit ${short(s.model)}): ${noModel.slice(0, 3).map((x) => x.desc || x.type).join(', ')}` })
137 }
138 const ro = (cfg.readonly_agent_types || [])
139 const exempt = (cfg.readonly_exempt_types || [])
140 const opusRo = subs.filter((x) => family(x.model) === 'opus' && !exempt.includes(x.type) && (ro.includes(x.type)
141 || (x.tools >= (t.readonly_opus_min_calls ?? 5) && x.readonly / Math.max(1, x.tools) >= (t.readonly_opus_share ?? 0.8))))
142 if (opusRo.length) out.push({ level: 'warn', text: `Opus doing read-only search in ${opusRo.length} subagent(s): ${opusRo.slice(0, 3).map((x) => x.desc || x.type).join(', ')} (use Haiku)` })
143 if (family(s.model) === 'opus' && s.tools >= 20 && s.readonly / Math.max(1, s.tools) >= (t.readonly_opus_share ?? 0.8)) {
144 out.push({ level: 'warn', text: `Opus main thread: ${s.readonly}/${s.tools} tool calls are read-only search (delegate to explore-fast on Haiku)` })
145 }
146 if ((s.ctx || 0) > (t.handoff_tokens ?? 250000) && !s.resume_md) {
147 out.push({ level: 'bad', text: `context ${fmtTokens(s.ctx)} > ${fmtTokens(t.handoff_tokens ?? 250000)} and no handoff/RESUME.md` })
148 }
149 const loops = [loopText(s.loops), ...subs.map((x) => loopText(x.loops))].filter(Boolean)
150 if (loops.length) out.push({ level: 'warn', text: `timer/loop tools used: ${loops.join('; ')} (V4.7: no timer wake-ups)` })
151 return out
152}
153
154export function isGuard(text, cfg) {
155 return new RegExp(cfg.guard_match || 'denied|refused', 'i').test(text || '')
156}
157
158export function waitingOnJulian(s) {
159 return Boolean(s.ask_open || (s.needs_action && String(s.needs_action).trim()))
160}
161
162// ---- Global dashboard -------------------------------------------------------------------------------------------
163
164export function dashboard(scan, cfg, nowMs) {
165 const today = localDay(nowMs)
166 const rows = []
167 const byModel = {}
168 const byProject = {}
169 for (const s of scan.sessions || []) {
170 if (s.archived && !s.live) continue
171 const dayMaps = [(s.days || {})[today], ...(s.subs || []).map((x) => (x.days || {})[today])]
172 const billToday = billByModel(dayMaps, cfg)
173 const costToday = sumCost(billToday)
174 for (const [m, b] of Object.entries(billToday)) {
175 const o = byModel[m] || (byModel[m] = { tokens: 0, cost: 0, calls: 0 })
176 o.tokens += b.tokens; o.cost += b.cost; o.calls += b.calls
177 }
178 const repo = repoOf(s)
179 const p = byProject[repo] || (byProject[repo] = { cost: 0, tokens: 0, sessions: 0 })
180 p.cost += costToday; p.tokens += Object.values(billToday).reduce((a, b) => a + b.tokens, 0); p.sessions += 1
181 const w = warnings(s, cfg)
182 const err = s.last_error && s.last_error[2]
183 rows.push({
184 cli: s.cli, sid: s.sid, title: titleOf(s), repo, branch: s.branch || '', model: s.app_model || s.model,
185 effort: s.app_effort || s.effort || '?', ctx: s.ctx, ctxPct: ctxPct(s.ctx, cfg), ctxLevel: ctxLevel(s.ctx, cfg),
186 costToday, running: (s.subs || []).filter((x) => x.status === 'running').length, subs: (s.subs || []).length,
187 pr: s.pr ? { number: s.pr.number, url: s.pr.url, state: (s.ci || {}).state || '?', ci: (s.ci || {}).ci || '?' } : null,
188 problem: w.length ? w[0].text : err || '', problemLevel: w.length ? w[0].level : err ? 'warn' : 'ok',
189 waiting: waitingOnJulian(s), live: Boolean(s.live), last: s.last_activity,
190 })
191 }
192 const totalCalls = Object.values(byModel).reduce((a, b) => a + b.calls, 0)
193 const fam = {}
194 for (const [m, b] of Object.entries(byModel)) {
195 const f = family(m)
196 const o = fam[f] || (fam[f] = { calls: 0, cost: 0, tokens: 0 })
197 o.calls += b.calls; o.cost += b.cost; o.tokens += b.tokens
198 }
199 const targets = cfg.routing_targets || {}
200 const share = ['opus', 'sonnet', 'haiku'].map((f) => {
201 const v = fam[f] || { calls: 0, cost: 0 }
202 const sh = totalCalls ? v.calls / totalCalls : 0
203 const tg = targets[f] || {}
204 const ok = (tg.max === undefined || sh <= tg.max) && (tg.min === undefined || sh >= tg.min)
205 return { family: f, share: sh, calls: v.calls, cost: v.cost, target: tg.max !== undefined ? `<= ${Math.round(tg.max * 100)}%` : tg.min !== undefined ? `>= ${Math.round(tg.min * 100)}%` : '-', ok }
206 })
207 rows.sort((a, b) => (b.live - a.live) || (b.last - a.last))
208 return {
209 today, rows, byModel, byProject, share, totalCost: Object.values(byModel).reduce((a, b) => a + b.cost, 0), totalCalls,
210 }
211}
212
213// ---- One conversation ---------------------------------------------------------------------------------------------
214
215export function conversation(d, cfg, nowMs) {
216 const subs = d.subs || []
217 const mainBill = billByModel([d.models], cfg)
218 const node = (x) => {
219 const bill = billByModel([x.models], cfg)
220 const arch = isArchitect(x, cfg)
221 return {
222 id: x.id, type: x.type, model: x.model, explicit: x.explicit_model || null, effort: x.effort || '?', status: x.status,
223 tokens: Object.values(bill).reduce((a, b) => a + b.tokens, 0), cost: sumCost(bill),
224 task: x.desc || x.prompt || '', architect: arch ? architectReason(`${x.desc || ''} ${x.prompt || ''}`, cfg) : null,
225 phase: x.phase || null, bg: x.bg, ts: x.ts,
226 }
227 }
228 const agents = subs.filter((x) => !x.workflow).map(node)
229 const wfIds = [...new Set(subs.filter((x) => x.workflow).map((x) => x.workflow))]
230 const calls = d.calls || []
231 const workflows = wfIds.map((id) => {
232 const call = calls.find((c) => c.tool === 'Workflow' && c.runId === id)
233 const nodes = subs.filter((x) => x.workflow === id).map(node)
234 return { id, name: call ? call.desc : id, nodes, cost: nodes.reduce((a, b) => a + b.cost, 0),
235 tokens: nodes.reduce((a, b) => a + b.tokens, 0), running: nodes.filter((n) => n.status === 'running').length }
236 })
237 const allBill = billByModel([d.models, ...subs.map((x) => x.models)], cfg)
238 // cost per turn: the main thread's usage in the turn plus the subagents started during it
239 const turns = (d.turns || []).map((t, i, arr) => {
240 const next = arr[i + 1] ? arr[i + 1].ts : '9999'
241 const inTurn = subs.filter((x) => x.ts && x.ts >= t.ts && x.ts < next)
242 const own = sumCost(billByModel([t.models], cfg))
243 const sub = sumCost(billByModel(inTurn.map((x) => x.models), cfg))
244 return { ts: t.ts, kind: t.kind || 'human', text: t.text, cost: own + sub, own, sub, agents: inTurn.length }
245 })
246 const guards = [
247 ...(d.errors || []).filter((e) => isGuard(e[2], cfg)).map((e) => ({ ts: e[0], text: e[2], src: 'tool' })),
248 ...(d.guards || []).map((g) => ({ ts: g[0], text: `${g[1]}: ${g[2]}`, src: 'cc-control' })),
249 ].sort((a, b) => String(a.ts).localeCompare(String(b.ts)))
250 return {
251 title: titleOf(d), repo: repoOf(d), branch: d.branch || '',
252 main: {
253 model: d.app_model || d.model, effort: d.app_effort || d.effort || '?', ctx: d.ctx, ctxPct: ctxPct(d.ctx, cfg),
254 ctxLevel: ctxLevel(d.ctx, cfg), window: cfg.context_window || 400000, cost: sumCost(allBill), ownCost: sumCost(mainBill),
255 live: Boolean(d.live),
256 },
257 agents, workflows, bill: allBill, total: sumCost(allBill), turns: turns.slice(-(cfg.turn_count || 10)),
258 guards: guards.slice(-12), events: compactEvents(d.events || []).slice(-(cfg.event_count || 15)), warnings: warnings(d, cfg),
259 pr: d.pr ? { ...d.pr, ...(d.ci || {}) } : null, waiting: waitingOnJulian(d),
260 }
261}
262
263// Session log: mcp__server__tool -> tool (server); runs of the same event collapse to one row "×n".
264export function compactEvents(events) {
265 const out = []
266 for (const e of events) {
267 const text = String(e[2]).replace(/\bmcp__([^_ ]+(?:_[^_ ]+)*)__([\w-]+)/, (m, srv, tool) => `${tool} (${srv.replace(/^ccd_/, '')})`)
268 const last = out[out.length - 1]
269 if (last && last[1] === e[1] && last[3] === text) { last[4] += 1; last[0] = e[0]; last[2] = `${text} ×${last[4]}`; continue }
270 out.push([e[0], e[1], text, text, 1])
271 }
272 return out.map((e) => [e[0], e[1], e[2]])
273}
274
275// Pick a conversation from the last scan by id prefix or title fragment (most recent first).
276export function pickSession(sessions, query) {
277 const q = String(query || '').trim().toLowerCase()
278 if (!q) return null
279 return (sessions || []).find((s) => String(s.cli).startsWith(q) || String(s.sid || '').replace(/^local_/, '').startsWith(q))
280 || (sessions || []).find((s) => titleOf(s).toLowerCase().includes(q)) || null
281}
282
283export function scanArgs(cfg, opts) {
284 const a = [opts.script, '--cache', opts.cache, '--hours', String((cfg.thresholds || {}).recent_hours ?? 36),
285 '--ci-ttl', String(cfg.ci_ttl_s ?? 600), '--live-s', String((cfg.thresholds || {}).live_s ?? 180)]
286 if (opts.detail) a.push('--detail', opts.detail)
287 if (cfg.include_cli) a.push('--cli')
288 if (opts.extra) a.push(...opts.extra)
289 return a
290}
291lib/factory/hooks/waiting.mjs 238 lines1// factory - "Ça t'attend" strip: pure logic (no $, no I/O), tested with node --test tests/logic.test.mjs.
2// Input: the scanner JSON (sessions + reviews from --waiting). Output: the rows the strip draws and the notifications to raise.
3// Ported from the old coordinator mod (green PR left open, question pending), plus the 3 Oct incidents A2-A4:
4// a lane idle while its PR is red, conflicting or its pipeline skipped. Read-only: nothing here sends or blocks anything.
5// Canonical copy: lib/core/waiting.mjs (repo root). factory/hooks/waiting.mjs and every mods/*/lib/core/waiting.mjs are
6// byte copies made by scripts/sync-libs.mjs: never edit a copy by hand. No imports, so every copy works on its own.
7
8// Same as factory's logic.mjs repoOf / titleOf (kept here so this file has no sibling to carry).
9function repoOf(s) {
10 const p = String(s.origin || s.cwd || '').replace(/\\/g, '/')
11 const i = p.indexOf('/.claude/worktrees/')
12 const base = i >= 0 ? p.slice(0, i) : p
13 return base.split('/').filter(Boolean).pop() || '?'
14}
15
16function titleOf(s) {
17 return s.app_title || s.title || String(s.cli || '').slice(0, 8)
18}
19
20export const WAITING_DEFAULTS = {
21 enabled: true,
22 green_min: 30,
23 idle_min: 20,
24 ask_min: 5,
25 julian_max_age_h: 72,
26 idle_pr_states: ['failed', 'canceled', 'skipped', 'conflict'],
27 max_rows: 12,
28 show_children: true,
29 show_archived_children: false,
30 session_link: 'claude://claude.ai/epitaxy/{id}',
31 notify: true,
32 notify_kinds: ['green', 'ask', 'needs', 'awaiting', 'idle'],
33 notify_existing_on_first_run: false,
34 notify_max_per_scan: 3,
35 notify_keep_days: 14,
36 background_s: 120,
37 pr_ttl_s: 300,
38 pr_max_age_days: 14,
39 pr_max_checks: 25,
40 gitlab_host: '',
41 labels: {
42 title: "Ça t'attend",
43 none: 'Rien ne t’attend.',
44 children: 'Sous-conversations',
45 since: 'depuis',
46 more: '+ {n} autres',
47 open_session: 'Ouvrir',
48 kinds: { green: 'PR verte', ask: 'Question', needs: 'Attend Julian', awaiting: 'Attend Julian', idle: 'Lane à l’arrêt' },
49 green: '{pr} verte depuis {age}, pas mergée',
50 ask: 'Question en attente : {question}',
51 needs: 'Attend Julian : {text}',
52 awaiting: 'Interrompue, attend une personne',
53 idle: 'Aucun tour depuis {age} alors que {pr} est {state}',
54 states: { failed: 'rouge', canceled: 'annulée', skipped: 'sans pipeline (skipped)', conflict: 'en conflit', none: 'sans CI', manual: 'en attente d’un job manuel' },
55 child: { working: 'travaille', julian: 'attend Julian', blocked: 'bloquée', done: 'terminée', review: 'à relire', idle: 'inactive', archived: 'archivée' },
56 notify_title: "Ça t'attend",
57 notify_more: '{n} autres lignes dans /factory',
58 reviews_error: 'PR/MR non lues : {errors}',
59 },
60}
61
62export function waitingConfig(cfg) {
63 const w = (cfg && cfg.waiting) || {}
64 return { ...WAITING_DEFAULTS, ...w, labels: deepLabels(WAITING_DEFAULTS.labels, w.labels || {}) }
65}
66
67function deepLabels(a, b) {
68 const out = { ...a }
69 for (const [k, v] of Object.entries(b)) out[k] = v && typeof v === 'object' && !Array.isArray(v) ? { ...(a[k] || {}), ...v } : v
70 return out
71}
72
73export function fill(tpl, vars) {
74 return String(tpl || '').replace(/\{(\w+)\}/g, (m, k) => (vars[k] !== undefined && vars[k] !== null ? String(vars[k]) : m))
75}
76
77export function age(ms) {
78 const m = Math.max(0, Math.round(ms / 60000))
79 if (m < 60) return `${m} min`
80 const h = Math.floor(m / 60)
81 if (h < 48) return `${h} h ${String(m % 60).padStart(2, '0')}`
82 return `${Math.floor(h / 24)} j`
83}
84
85export function normUrl(u) {
86 return String(u || '').trim().replace(/\/+$/, '').toLowerCase()
87}
88
89export function prLabel(r) {
90 const repo = String(r.repo || '').split('/').filter(Boolean).pop() || '?'
91 return `${repo}${r.provider === 'gitlab' || /gitlab/.test(r.url || '') ? '!' : '#'}${r.number}`
92}
93
94// Small stable hash so a changed needs_action text is a new row (and a new notification), the same text is not.
95export function hash(s) {
96 let h = 5381
97 for (const c of String(s || '')) h = ((h * 33) ^ c.codePointAt(0)) >>> 0
98 return h.toString(36)
99}
100
101function tsMs(v) {
102 if (!v) return null
103 if (typeof v === 'number') return v > 1e12 ? v : v * 1000
104 const t = Date.parse(v)
105 return Number.isFinite(t) ? t : null
106}
107
108function sessionRef(s, w) {
109 return { cli: s.cli, sid: s.sid || null, title: titleOf(s), repo: repoOf(s), link: s.sid ? fill(w.session_link, { id: s.sid }) : null }
110}
111
112// PR state for the idle rule: the review list first (it carries conflicts), else the per-session CI lookup.
113function prState(rev, ci) {
114 if (rev) return rev.conflict ? 'conflict' : rev.ci
115 if (ci && /open/.test(String(ci.state || ''))) return String(ci.ci || '').toLowerCase()
116 return null
117}
118
119export function waitingRows(scan, cfg, nowMs) {
120 const w = waitingConfig(cfg)
121 const L = w.labels
122 const sessions = (scan && scan.sessions) || []
123 const reviews = (scan && scan.reviews && scan.reviews.items) || []
124 const byUrl = new Map(reviews.map((r) => [normUrl(r.url), r]))
125 const owner = new Map()
126 for (const s of sessions) {
127 for (const u of [s.pr && s.pr.url, ...(s.app_prs || []).map((p) => p.url)]) if (u && !owner.has(normUrl(u))) owner.set(normUrl(u), s)
128 }
129 const rows = []
130 // (1) green PR/MR left open
131 for (const r of reviews) {
132 if (r.ci !== 'success' || r.conflict) continue
133 const since = tsMs(r.green_since) || tsMs(r.updated)
134 if (!since || nowMs - since < w.green_min * 60000) continue
135 const s = owner.get(normUrl(r.url))
136 rows.push({ key: `green:${normUrl(r.url)}`, kind: 'green', level: 'warn', since, link: r.url,
137 what: fill(L.green, { pr: prLabel(r), age: age(nowMs - since), title: r.title }), detail: r.title || '', pr: prLabel(r),
138 session: s ? sessionRef(s, w) : null })
139 }
140 for (const s of sessions) {
141 if (s.archived) continue
142 const last = tsMs(s.last_activity) || tsMs(s.mtime) || 0
143 const ref = sessionRef(s, w)
144 // (2) waiting on Julian: pending AskUserQuestion, a wrap-up that needs an action, an interrupted turn awaiting a person
145 if (nowMs - last <= w.julian_max_age_h * 3600000) {
146 const askAt = tsMs(s.ask_ts)
147 if (s.ask_open && (!askAt || nowMs - askAt >= w.ask_min * 60000)) {
148 rows.push({ key: `ask:${s.cli}:${s.ask_ts || ''}`, kind: 'ask', level: 'warn', since: askAt || last, link: ref.link,
149 what: fill(L.ask, { question: s.ask_q || 'AskUserQuestion' }), session: ref })
150 }
151 const needs = String(s.needs_action || '').trim()
152 if (needs) {
153 rows.push({ key: `needs:${s.cli}:${hash(needs)}`, kind: 'needs', level: s.status_category === 'blocked' ? 'bad' : 'warn', since: last,
154 link: ref.link, what: fill(L.needs, { text: needs }), session: ref })
155 }
156 if (s.awaiting) {
157 rows.push({ key: `awaiting:${s.cli}`, kind: 'awaiting', level: 'warn', since: last, link: ref.link, what: L.awaiting, session: ref })
158 }
159 }
160 // (3) lane idle while its PR is red, conflicting or its pipeline skipped
161 if (!s.live && nowMs - last >= w.idle_min * 60000) {
162 const urls = [...new Set([s.pr && s.pr.url, ...(s.app_prs || []).map((p) => p.url)].filter(Boolean).map(normUrl))]
163 for (const u of urls) {
164 const rev = byUrl.get(u)
165 const st = prState(rev, s.pr && normUrl(s.pr.url) === u ? s.ci : null)
166 if (!st || !w.idle_pr_states.includes(st)) continue
167 const label = rev ? prLabel(rev) : prLabel({ ...(s.pr || {}), repo: (s.pr || {}).repo })
168 rows.push({ key: `idle:${s.cli}:${u}:${st}`, kind: 'idle', level: 'bad', since: last, link: rev ? rev.url : s.pr.url, pr: label,
169 what: fill(L.idle, { age: age(nowMs - last), pr: label, state: (L.states || {})[st] || st }), session: ref })
170 }
171 }
172 }
173 rows.sort((a, b) => a.since - b.since)
174 return { rows, children: w.show_children ? childGroups(sessions, w, nowMs) : [], errors: (scan && scan.reviews && scan.reviews.errors) || [] }
175}
176
177// (4) sub-conversations linked to a lead (app session file field spawnedFrom), with their state.
178export function childState(s, w, nowMs) {
179 if (s.archived) return 'archived'
180 if (s.live) return 'working'
181 if (s.ask_open || String(s.needs_action || '').trim() || s.awaiting) return 'julian'
182 if (s.status_category === 'blocked') return 'blocked'
183 if (s.status_category === 'completed') return 'done'
184 if (s.status_category === 'review_ready') return 'review'
185 return 'idle'
186}
187
188export function childGroups(sessions, w, nowMs) {
189 const bySid = new Map(sessions.filter((s) => s.sid).map((s) => [s.sid, s]))
190 const groups = new Map()
191 for (const s of sessions) {
192 if (!s.parent || (s.archived && !w.show_archived_children)) continue
193 const g = groups.get(s.parent) || { sid: s.parent, title: bySid.has(s.parent) ? titleOf(bySid.get(s.parent)) : (s.parent_title || s.parent),
194 link: fill(w.session_link, { id: s.parent }), cli: bySid.has(s.parent) ? bySid.get(s.parent).cli : null, children: [], last: 0 }
195 const st = childState(s, w, nowMs)
196 const last = tsMs(s.last_activity) || 0
197 g.children.push({ title: titleOf(s), cli: s.cli, state: st, label: (w.labels.child || {})[st] || st, since: last,
198 link: s.sid ? fill(w.session_link, { id: s.sid }) : null })
199 g.last = Math.max(g.last, last)
200 groups.set(s.parent, g)
201 }
202 return [...groups.values()].sort((a, b) => b.last - a.last)
203}
204
205// Which rows to announce: those never announced before (one notification per row, ever), of the configured kinds.
206// `seen` is {key: epochMs} kept in $.store; returns {announce, seen} with old keys pruned. On the very first run
207// (seen === null) every current row is marked seen without a notification unless notify_existing_on_first_run.
208export function toAnnounce(rows, seen, cfg, nowMs) {
209 const w = waitingConfig(cfg)
210 const first = seen === null || seen === undefined
211 const next = {}
212 const keep = w.notify_keep_days * 86400000
213 for (const [k, t] of Object.entries(seen || {})) if (nowMs - t < keep) next[k] = t
214 const fresh = rows.filter((r) => !(r.key in next))
215 for (const r of rows) if (!(r.key in next)) next[r.key] = nowMs
216 if (!w.notify || (first && !w.notify_existing_on_first_run)) return { announce: [], more: 0, seen: next }
217 const due = fresh.filter((r) => w.notify_kinds.includes(r.kind))
218 return { announce: due.slice(0, w.notify_max_per_scan), more: Math.max(0, due.length - w.notify_max_per_scan), seen: next }
219}
220
221export function notifyText(row) {
222 return row.session ? `${row.what} · ${row.session.title}` : row.what
223}
224
225// Scanner flags for the review list (gh / glab), from the config.
226export function waitingArgs(cfg) {
227 const w = waitingConfig(cfg)
228 if (!w.enabled) return []
229 const a = ['--waiting', '--pr-ttl', String(w.pr_ttl_s), '--pr-max-age-days', String(w.pr_max_age_days), '--pr-max-checks', String(w.pr_max_checks)]
230 if (w.gitlab_host) a.push('--gl-host', String(w.gitlab_host))
231 return a
232}
233
234// Background scans: one session at a time holds the lease (kept in $.store, shared by every session on the PC).
235export function holdsLease(lease, self, nowMs, intervalMs) {
236 return !lease || !lease.sid || lease.sid === self || nowMs - (lease.at || 0) > 3 * intervalMs
237}
238lib/factory/hooks/screens.mjs 473 lines1// factory v2 - the screens of the side panel, as pure view models (no $, no I/O; node --test tests/logic.test.mjs).
2// Input: the scanner JSON (sessions with their recent events, detail of one conversation, reviews), the usage and
3// machine extras when those mods are present. Output: what draw.mjs stacks top to bottom. Every label, threshold and
4// the list of screens come from factory.config.json (`screens`), so nothing here is hard-coded but the arithmetic.
5import {
6 dashboard, conversation, titleOf, repoOf, short, family, ctxPct, ctxLevel, waitingOnJulian, localDay, fmtTokens,
7 billByModel, sumCost,
8} from './logic.mjs'
9import { waitingRows, age } from './waiting.mjs'
10
11export const SCREEN_DEFAULTS = {
12 order: ['mission', 'focus', 'arbre', 'pipeline', 'quotas', 'systeme', 'journal'],
13 enabled: ['mission', 'focus', 'arbre', 'pipeline', 'quotas', 'systeme', 'journal'],
14 start: 'mission',
15 recent_events: 12,
16 quiet_min: 30,
17 quiet_max_h: 12,
18 timeline_max: 25,
19 journal_max: 60,
20 tree_children: true,
21 tree_max_agents: 12,
22 pipeline_hours: 24,
23 plan_readonly_share: 0.9,
24 quotas_refresh_s: 300,
25 system_refresh_s: 10,
26 gpu_every: 6,
27 stages: ['plan', 'build', 'review', 'test', 'merge'],
28 agent_stage: { Plan: 'plan', architect: 'plan', Explore: 'plan', 'explore-fast': 'plan', coder: 'build', 'general-purpose': 'build',
29 'code-review': 'review', auditor: 'review', verify: 'review', tester: 'test', debug: 'test' },
30 handoff_match: 'handoff|RESUME\\.md|passe le relais|hand(ed)? off',
31 layout: { narrow: 70, wide: 110, bar_min: 8, bar_max: 40 },
32 labels: {
33 tabs: { mission: 'Mission', focus: 'Focus', arbre: 'Arbre', pipeline: 'Pipeline', quotas: 'Quotas', systeme: 'Système', journal: 'Journal' },
34 counters: { working: 'en cours', idle: 'au repos', blocked: 'bloquées', julian: 'attend Julian', done: 'terminées aujourd’hui', cost: 'coût du jour' },
35 states: { working: 'en cours', idle: 'au repos', blocked: 'bloquée', julian: 'attend Julian', done: 'terminée', archived: 'archivée' },
36 agent_states: { running: 'en cours', done: 'terminé', failed: 'échoué', stopped: 'arrêté' },
37 stages: { plan: 'plan', build: 'build', review: 'revue', test: 'test / CI', merge: 'merge' },
38 timeline: { stuck: 'Bloquée', quiet: 'Calme', context: 'Contexte', compact: 'Compactage', merged: 'PR mergée', pr: 'PR liée',
39 blocked: 'Bloquée', handoff: 'Relais', done: 'Terminée', julian: 'Attend Julian', failed: 'Agent échoué' },
40 journal_kinds: { all: 'tout', prompt: 'prompts', tool: 'outils', agent: 'agents', compact: 'compactages', pr: 'PR', notif: 'notifications', error: 'erreurs' },
41 mission_title: 'Mission',
42 projects: 'Projets',
43 lead: 'lead',
44 subconv: 'sous-conv.',
45 workers: 'agents',
46 share_title: 'Part des modèles aujourd’hui vs cibles V6 ({metric})',
47 target: 'cible',
48 list_title: 'Conversations',
49 filters: { all: 'Toutes', live: 'En cours', problems: 'Problèmes', julian: 'Attend Julian' },
50 focus_title: 'Ce qui me concerne',
51 pulse: 'Pouls de la flotte',
52 agents_running: 'agents en cours',
53 timeline_title: 'Fil en direct',
54 progress: 'Progrès aujourd’hui',
55 progress_row: '{done}/{total} terminées · {merged} PR mergées',
56 tree_title: 'Arbre de la conversation',
57 pick: 'Choisir une conversation',
58 this_conv: 'Cette conversation',
59 main: 'lead',
60 subconvs: 'Sous-conversations',
61 workflow: 'workflow',
62 no_agents: 'aucun sous-agent',
63 // « Ouvrir l'agent » on a running agent of this conversation: $.ui.view({ agentId }) (engine 2.1.300+)
64 open_agent: 'Ouvrir l’agent',
65 back_conv: '← Conversation',
66 architect_title: 'architecte · sur appel',
67 architect_triggers: ['before plan', 'error repeats', 'before done'],
68 architect_trigger_labels: { 'before plan': 'avant un plan', 'error repeats': 'erreur répétée', 'before done': 'avant « fini »' },
69 architect_none: 'aucun appel à l’architecte dans cette conversation',
70 architect_calls: 'appels {calls} · lus {tokens}',
71 bill_title: 'Facture (estimation)',
72 bill_every: 'à chaque tour',
73 bill_on_call: 'sur appel (architecte)',
74 guards_title: 'Approbations et garde-fous',
75 guards_none: 'rien de refusé',
76 log_title: 'Journal de session',
77 warnings_title: 'Alertes',
78 warnings_none: 'aucune',
79 status_line: 'lead [{model} · {effort}] agents [{running}/{total}] architecte [{arch}] ctx [{ctx}%]',
80 arch_idle: 'au repos',
81 arch_busy: 'consulté',
82 pipeline_title: 'Travail en vol',
83 pipeline_note: 'Étape déduite des sessions, PR et CI (heuristique, non vérifié).',
84 quotas_title: 'Quotas',
85 five_hour: 'Claude 5 h',
86 seven_day: 'Claude semaine',
87 reset: 'reset {when}',
88 exhausted: 'épuisé {when}',
89 left_at_reset: 'il restera {pct} % au reset',
90 codex: 'Codex',
91 cost_model: 'Coût du jour par modèle',
92 cost_project: 'Coût du jour par projet',
93 system_title: 'Système',
94 cpu: 'CPU', ram: 'RAM', gpu: 'GPU', disk: 'Disque', runner: 'Runner GitLab',
95 journal_title: 'Journal',
96 module_missing: 'module {mod} non chargé',
97 module_missing_hint: 'Installe le mod « {mod} » à côté de factory (même dossier mods/) pour voir cette section.',
98 unavailable: 'non disponible',
99 scanning: 'Lecture des transcripts…',
100 not_found: 'Conversation introuvable dans le dernier passage.',
101 refresh: 'Rafraîchir',
102 refreshing: 'Lecture…',
103 updated: 'mis à jour {time}',
104 nothing: 'rien',
105 open: 'Ouvrir',
106 },
107}
108
109// ---- config ---------------------------------------------------------------------------------------------------------
110
111function merge(a, b) {
112 if (!b || typeof b !== 'object' || Array.isArray(b)) return b === undefined ? a : b
113 const out = { ...(a || {}) }
114 for (const [k, v] of Object.entries(b)) out[k] = v && typeof v === 'object' && !Array.isArray(v) && a && typeof a[k] === 'object' && !Array.isArray(a[k]) ? merge(a[k], v) : v
115 return out
116}
117
118export function screensConfig(cfg) {
119 return merge(SCREEN_DEFAULTS, (cfg && cfg.screens) || {})
120}
121
122export function fill(tpl, vars) {
123 return String(tpl ?? '').replace(/\{(\w+)\}/g, (m, k) => (vars[k] !== undefined && vars[k] !== null ? String(vars[k]) : m))
124}
125
126// The tabs shown, in order: `order` filtered by `enabled`; unknown ids dropped.
127export function tabsOf(cfg) {
128 const c = screensConfig(cfg)
129 const known = new Set(SCREEN_DEFAULTS.order)
130 return c.order.filter((t) => known.has(t) && c.enabled.includes(t)).map((id) => ({ id, label: (c.labels.tabs || {})[id] || id }))
131}
132
133const TAB_ALIASES = { systeme: 'systeme', system: 'systeme', 'système': 'systeme', arbre: 'arbre', tree: 'arbre', journal: 'journal', log: 'journal',
134 mission: 'mission', focus: 'focus', pipeline: 'pipeline', quotas: 'quotas', quota: 'quotas' }
135
136// "/factory quotas" -> {tab: 'quotas', rest: ''}; "/factory My lane" -> {tab: null, rest: 'My lane'}.
137export function parseArgs(args, cfg) {
138 const words = String(args || '').trim().split(/\s+/).filter(Boolean)
139 const t = words.length ? TAB_ALIASES[words[0].toLowerCase()] : null
140 const ok = t && tabsOf(cfg).some((x) => x.id === t)
141 return { tab: ok ? t : null, rest: (ok ? words.slice(1) : words).join(' ') }
142}
143
144// ---- layout: everything stacks; only the counters grid, bar widths and label pads follow the pane width ------------
145
146export function layout(columns, cfg) {
147 const L = screensConfig(cfg).layout
148 const cols = Number(columns) > 0 ? Number(columns) : 80
149 const narrow = cols < L.narrow
150 const wide = cols >= L.wide
151 return {
152 cols, narrow, wide,
153 counterCols: wide ? 6 : narrow ? 2 : 3,
154 cardCols: wide ? 2 : 1,
155 bar: Math.max(L.bar_min, Math.min(L.bar_max, Math.floor(cols * (narrow ? 0.35 : 0.3)))),
156 pad: narrow ? 12 : 18,
157 textMax: Math.max(20, cols - 6),
158 }
159}
160
161export function chunk(list, n) {
162 const out = []
163 for (let i = 0; i < list.length; i += Math.max(1, n)) out.push(list.slice(i, i + Math.max(1, n)))
164 return out
165}
166
167export function bar(pct, width) {
168 const p = Math.max(0, Math.min(100, Number(pct) || 0))
169 const full = Math.round((p / 100) * width)
170 return '█'.repeat(full) + '░'.repeat(Math.max(0, width - full))
171}
172
173const EFFORT = { low: 1, medium: 2, med: 2, high: 3, xhigh: 4, max: 4 }
174
175export function effortBars(effort) {
176 const n = EFFORT[String(effort || '').toLowerCase()]
177 return n ? '▮'.repeat(n) + '▯'.repeat(4 - n) : '····'
178}
179
180export function modelEffort(model, effort) {
181 return `${short(model)} · ${effortBars(effort)} ${effort || '?'}`
182}
183
184export function clip(s, n) {
185 s = String(s ?? '')
186 return s.length <= n ? s : s.slice(0, Math.max(1, n - 1)) + '…'
187}
188
189function tsMs(v) {
190 if (!v) return null
191 if (typeof v === 'number') return v > 1e12 ? v : v * 1000
192 const t = Date.parse(v)
193 return Number.isFinite(t) ? t : null
194}
195
196// ---- one conversation's state ----------------------------------------------------------------------------------------
197
198// working | julian | blocked | done | idle | archived, with a short reason for blocked / julian.
199export function sessionState(s, cfg) {
200 if (s.archived && !s.live) return { state: 'archived', reason: '' }
201 if (s.live) return { state: 'working', reason: '' }
202 if (waitingOnJulian(s) || s.awaiting) return { state: 'julian', reason: String(s.needs_action || s.ask_q || '').trim() }
203 const ci = s.ci || {}
204 if (s.status_category === 'blocked') return { state: 'blocked', reason: String(s.status_detail || s.needs_action || 'bloquée').trim() }
205 if (/open/.test(String(ci.state || '')) && ['failed', 'canceled', 'skipped'].includes(String(ci.ci || ''))) {
206 return { state: 'blocked', reason: `PR #${(s.pr || {}).number} CI ${ci.ci}` }
207 }
208 if (s.status_category === 'completed' || /merged/.test(String(ci.state || ''))) return { state: 'done', reason: '' }
209 return { state: 'idle', reason: '' }
210}
211
212function isToday(s, nowMs) {
213 const t = tsMs(s.last_activity) || tsMs(s.mtime)
214 return Boolean(t) && localDay(t) === localDay(nowMs)
215}
216
217function visible(scan) {
218 return ((scan && scan.sessions) || []).filter((s) => !(s.archived && !s.live))
219}
220
221// ---- Mission -----------------------------------------------------------------------------------------------------
222
223export function mission(scan, cfg, nowMs) {
224 const c = screensConfig(cfg)
225 const dash = dashboard(scan || { sessions: [] }, cfg, nowMs)
226 const sessions = visible(scan)
227 const states = new Map(sessions.map((s) => [s.cli, sessionState(s, cfg)]))
228 const count = (st) => sessions.filter((s) => states.get(s.cli).state === st).length
229 const blocked = sessions.filter((s) => states.get(s.cli).state === 'blocked')
230 const counters = {
231 working: count('working'),
232 idle: count('idle'),
233 blocked: blocked.length,
234 blockedReasons: blocked.slice(0, 3).map((s) => `${clip(titleOf(s), 28)} : ${states.get(s.cli).reason}`),
235 julian: count('julian'),
236 done: sessions.filter((s) => states.get(s.cli).state === 'done' && isToday(s, nowMs)).length,
237 cost: dash.totalCost,
238 }
239 const costByCli = new Map(dash.rows.map((r) => [r.cli, r.costToday]))
240 const groups = new Map()
241 for (const s of sessions) {
242 const repo = repoOf(s)
243 const g = groups.get(repo) || { repo, sessions: [] }
244 g.sessions.push(s)
245 groups.set(repo, g)
246 }
247 const projects = [...groups.values()].map((g) => {
248 const sids = new Set(g.sessions.map((s) => s.sid).filter(Boolean))
249 const parents = new Set(g.sessions.map((s) => s.parent).filter((p) => p && sids.has(p)))
250 const byRecent = [...g.sessions].sort((a, b) => (b.live - a.live) || ((tsMs(b.last_activity) || 0) - (tsMs(a.last_activity) || 0)))
251 const lead = byRecent.find((s) => parents.has(s.sid)) || byRecent.find((s) => !s.parent) || byRecent[0]
252 const subconvs = g.sessions.filter((s) => s.parent && s !== lead)
253 const workers = g.sessions.reduce((a, s) => a + (s.subs || []).filter((x) => x.status === 'running').length, 0)
254 const st = {}
255 for (const s of g.sessions) { const k = states.get(s.cli).state; st[k] = (st[k] || 0) + 1 }
256 return {
257 repo: g.repo, lead: { cli: lead.cli, title: titleOf(lead), model: lead.app_model || lead.model, effort: lead.app_effort || lead.effort || '?',
258 ctxPct: ctxPct(lead.ctx, cfg), ctxLevel: ctxLevel(lead.ctx, cfg), state: states.get(lead.cli).state },
259 subconvs: subconvs.map((s) => ({ cli: s.cli, title: titleOf(s), state: states.get(s.cli).state, model: s.app_model || s.model, effort: s.app_effort || s.effort || '?' })),
260 sessions: g.sessions.length, workers, states: st, live: g.sessions.some((s) => s.live),
261 maxCtxPct: Math.max(0, ...g.sessions.map((s) => ctxPct(s.ctx, cfg))),
262 cost: g.sessions.reduce((a, s) => a + (costByCli.get(s.cli) || 0), 0),
263 }
264 }).sort((a, b) => (b.live - a.live) || (b.cost - a.cost))
265 const metric = (cfg.routing_targets || {}).metric || 'calls'
266 return { counters, projects, share: dash.share, shareTitle: fill(c.labels.share_title, { metric }), rows: dash.rows, dash, states }
267}
268
269// ---- Focus: timeline, pulse, progress ---------------------------------------------------------------------------------
270
271export function timeline(scan, cfg, nowMs) {
272 const c = screensConfig(cfg)
273 const T = c.labels.timeline
274 const hand = new RegExp(c.handoff_match, 'i')
275 const handoffTokens = ((cfg.thresholds || {}).handoff_tokens) ?? 250000
276 const out = []
277 const add = (s, ts, kind, level, text) => {
278 if (!ts) return
279 out.push({ ts, kind, label: T[kind] || kind, level, text, title: titleOf(s), repo: repoOf(s), cli: s.cli })
280 }
281 for (const s of visible(scan)) {
282 const last = tsMs(s.last_activity) || tsMs(s.mtime)
283 const st = sessionState(s, cfg)
284 for (const e of s.recent || []) {
285 const t = tsMs(e[0])
286 if (e[1] === 'compact') add(s, t, 'compact', 'warn', e[2])
287 else if (e[1] === 'pr') add(s, t, 'pr', 'ok', e[2])
288 else if (e[1] === 'notif' && /fail|killed|error|échou/i.test(e[2])) add(s, t, 'failed', 'bad', e[2])
289 else if (hand.test(String(e[2] || ''))) add(s, t, 'handoff', 'ok', e[2])
290 }
291 if ((s.ctx || 0) > handoffTokens && !s.archived) {
292 add(s, last, 'context', s.resume_md ? 'warn' : 'bad', `${fmtTokens(s.ctx)} > ${fmtTokens(handoffTokens)} : passer le relais${s.resume_md ? '' : ' (pas de handoff/RESUME.md)'}`)
293 }
294 if (st.state === 'blocked') add(s, last, 'blocked', 'bad', st.reason)
295 if (st.state === 'julian') add(s, last, 'julian', 'warn', st.reason || 'question en attente')
296 if (st.state === 'done' && /merged/.test(String((s.ci || {}).state || ''))) add(s, last, 'merged', 'ok', `PR #${(s.pr || {}).number}`)
297 else if (st.state === 'done') add(s, last, 'done', 'ok', s.status_detail || '')
298 if (st.state === 'idle' && last && nowMs - last >= c.quiet_min * 60000 && nowMs - last <= c.quiet_max_h * 3600000) {
299 add(s, last, 'quiet', 'dim', `aucun tour depuis ${age(nowMs - last)}`)
300 }
301 if (st.state === 'working' && (s.subs || []).some((x) => x.status === 'running') === false && s.last_error && tsMs(s.last_error[0]) && nowMs - tsMs(s.last_error[0]) < 10 * 60000) {
302 add(s, tsMs(s.last_error[0]), 'stuck', 'warn', s.last_error[2])
303 }
304 }
305 return out.sort((a, b) => b.ts - a.ts).slice(0, c.timeline_max)
306}
307
308export function focus(scan, cfg, nowMs) {
309 const m = mission(scan, cfg, nowMs)
310 const w = waitingRows(scan || { sessions: [] }, cfg, nowMs)
311 const running = visible(scan).reduce((a, s) => a + (s.subs || []).filter((x) => x.status === 'running').length, 0)
312 const progress = m.projects.map((p) => {
313 const ss = visible(scan).filter((s) => repoOf(s) === p.repo && isToday(s, nowMs))
314 const done = ss.filter((s) => m.states.get(s.cli).state === 'done').length
315 const merged = ss.reduce((a, s) => a + (s.merged_prs || 0), 0)
316 return { repo: p.repo, done, total: ss.length, merged, pct: ss.length ? Math.round((100 * done) / ss.length) : 0 }
317 }).filter((p) => p.total > 0)
318 return { waiting: w, pulse: { ...m.counters, running }, timeline: timeline(scan, cfg, nowMs), progress }
319}
320
321// ---- Arbre: one conversation, Codex agent-tree style, top to bottom ---------------------------------------------------
322
323// Running nodes first, then the most recent; past max, one summary line (count per type) instead of the rest.
324export function trimNodes(nodes, max) {
325 const sorted = [...nodes].sort((a, b) => ((b.status === 'running') - (a.status === 'running')) || String(b.ts || '').localeCompare(String(a.ts || '')))
326 const shown = sorted.slice(0, Math.max(1, max))
327 const rest = sorted.slice(shown.length)
328 const by = {}
329 for (const n of rest) by[n.type] = (by[n.type] || 0) + 1
330 return { shown, hidden: rest.length, hiddenText: Object.entries(by).sort((a, b) => b[1] - a[1]).map(([k, n]) => `${k} ×${n}`).join(', ') }
331}
332
333export function tree(scan, cfg, nowMs) {
334 const c = screensConfig(cfg)
335 const d = scan && scan.detail
336 if (!d) return null
337 const conv = conversation(d, cfg, nowMs)
338 const sessions = (scan && scan.sessions) || []
339 const children = c.tree_children && d.sid ? sessions.filter((s) => s.parent === d.sid).map((s) => {
340 const st = sessionState(s, cfg)
341 const bill = billByModel([s.models, ...(s.subs || []).map((x) => x.models)], cfg)
342 return { cli: s.cli, title: titleOf(s), state: st.state, reason: st.reason, model: s.app_model || s.model, effort: s.app_effort || s.effort || '?',
343 ctxPct: ctxPct(s.ctx, cfg), ctxLevel: ctxLevel(s.ctx, cfg), cost: sumCost(bill), tokens: Object.values(bill).reduce((a, b) => a + b.tokens, 0),
344 agents: (s.subs || []).length, running: (s.subs || []).filter((x) => x.status === 'running').length,
345 pr: s.pr ? { number: s.pr.number, state: (s.ci || {}).state || '?', ci: (s.ci || {}).ci || '?' } : null }
346 }) : []
347 const all = [...conv.agents, ...conv.workflows.flatMap((w) => w.nodes)]
348 const arch = all.filter((n) => n.architect)
349 const triggers = c.labels.architect_triggers.map((t) => ({ id: t, label: (c.labels.architect_trigger_labels || {})[t] || t, count: arch.filter((n) => n.architect === t).length }))
350 const archCost = arch.reduce((a, n) => a + n.cost, 0)
351 const running = all.filter((n) => n.status === 'running').length
352 const archBusy = arch.some((n) => n.status === 'running')
353 const status = fill(c.labels.status_line, { model: short(conv.main.model), effort: conv.main.effort, running, total: all.length,
354 arch: archBusy ? c.labels.arch_busy : arch.length ? `${arch.length} appel(s)` : c.labels.arch_idle, ctx: conv.main.ctxPct })
355 return {
356 ...conv, children,
357 agentsView: trimNodes(conv.agents, c.tree_max_agents),
358 workflowsView: conv.workflows.map((w) => ({ ...w, view: trimNodes(w.nodes, c.tree_max_agents) })),
359 architect: { calls: arch.length, tokens: arch.reduce((a, n) => a + n.tokens, 0), cost: archCost, triggers, busy: archBusy,
360 last: arch.length ? arch[arch.length - 1] : null },
361 billSplit: { every: conv.total - archCost, onCall: archCost },
362 running, totalAgents: all.length, status,
363 }
364}
365
366// ---- Pipeline ------------------------------------------------------------------------------------------------------
367
368export function stageOf(s, cfg) {
369 const c = screensConfig(cfg)
370 const ci = s.ci || {}
371 const st = String(ci.state || '')
372 if (s.pr && /merged|closed/.test(st)) return null
373 if (s.pr && /open/.test(st)) return ci.ci === 'success' ? 'merge' : 'test'
374 if ((s.app_prs || []).length) return 'test'
375 const order = c.stages
376 const running = (s.subs || []).filter((x) => x.status === 'running').map((x) => c.agent_stage[x.type]).filter(Boolean)
377 if (running.length) return running.sort((a, b) => order.indexOf(b) - order.indexOf(a))[0]
378 if (!s.tools || (s.readonly || 0) / Math.max(1, s.tools) >= c.plan_readonly_share) return 'plan'
379 return 'build'
380}
381
382export function pipeline(scan, cfg, nowMs) {
383 const c = screensConfig(cfg)
384 const from = nowMs - c.pipeline_hours * 3600000
385 const rows = new Map()
386 for (const s of visible(scan)) {
387 const last = tsMs(s.last_activity) || tsMs(s.mtime) || 0
388 if (last < from && !s.live) continue
389 const st = sessionState(s, cfg)
390 if (st.state === 'done') continue
391 const stage = stageOf(s, cfg)
392 if (!stage) continue
393 const repo = repoOf(s)
394 const r = rows.get(repo) || { repo, stages: Object.fromEntries(c.stages.map((k) => [k, []])) }
395 const ci = s.ci || {}
396 r.stages[stage].push({ cli: s.cli, title: titleOf(s), state: st.state, live: Boolean(s.live),
397 level: st.state === 'blocked' || ci.ci === 'failed' ? 'bad' : st.state === 'julian' ? 'warn' : s.live ? 'running' : 'dim',
398 pr: s.pr ? `#${s.pr.number}${ci.ci ? ' ' + ci.ci : ''}` : '' })
399 rows.set(repo, r)
400 }
401 const list = [...rows.values()].sort((a, b) => c.stages.reduce((n, k) => n + b.stages[k].length, 0) - c.stages.reduce((n, k) => n + a.stages[k].length, 0))
402 const totals = Object.fromEntries(c.stages.map((k) => [k, list.reduce((n, r) => n + r.stages[k].length, 0)]))
403 return { stages: c.stages.map((k) => ({ id: k, label: c.labels.stages[k] || k })), rows: list, totals }
404}
405
406// ---- Quotas --------------------------------------------------------------------------------------------------------
407
408// live: $.session.usage().rateLimits; extras: scanner/extras.mjs quotas output (usage mod), or {available:false}.
409export function quotas(scan, cfg, nowMs, live, extras) {
410 const c = screensConfig(cfg)
411 const L = c.labels
412 const dash = dashboard(scan || { sessions: [] }, cfg, nowMs)
413 const win = (kind, label) => {
414 const fromUsage = extras && extras.available && extras[kind]
415 const l = (live || []).find((r) => r.kind === kind)
416 const pct = fromUsage && Number.isFinite(fromUsage.pct) ? fromUsage.pct : l && Number.isFinite(l.percentUsed) ? l.percentUsed : null
417 if (pct === null) return { kind, label, available: false }
418 const reset = (fromUsage && fromUsage.reset) || (l && Date.parse(l.resetsAt || '')) || null
419 return { kind, label, available: true, pct, reset: Number.isFinite(reset) ? reset : null, forecast: fromUsage ? fromUsage.forecast || '' : '', live: Boolean(l) }
420 }
421 const byModel = Object.entries(dash.byModel).map(([m, b]) => ({ model: m, family: family(m), cost: b.cost, tokens: b.tokens, calls: b.calls })).sort((a, b) => b.cost - a.cost)
422 const byProject = Object.entries(dash.byProject).filter(([, b]) => b.cost > 0).map(([p, b]) => ({ repo: p, cost: b.cost, tokens: b.tokens, sessions: b.sessions })).sort((a, b) => b.cost - a.cost)
423 return {
424 windows: [win('five_hour', L.five_hour), win('seven_day', L.seven_day)],
425 usage: extras && extras.available ? 'ok' : 'missing', usageReason: extras && extras.reason,
426 codex: extras && extras.codex ? extras.codex : null,
427 byModel, byProject, total: dash.totalCost,
428 }
429}
430
431// ---- Système -------------------------------------------------------------------------------------------------------
432
433export function system(sample, runner) {
434 if (!sample) return { available: false }
435 const gb = (b) => (b ? (b / 1e9).toFixed(1) : '?')
436 const gpus = ((sample.gpu && sample.gpu.gpus) || []).map((g) => ({ name: g.name, pct: g.util, mem: g.mem_total_mb ? `${Math.round(g.mem_used_mb)} / ${Math.round(g.mem_total_mb)} Mo` : '', temp: g.temp_c }))
437 return {
438 available: true, at: sample.at, ncpu: sample.ncpu,
439 cpu: sample.cpu && Number.isFinite(sample.cpu.total) ? sample.cpu.total : null,
440 ram: sample.ram ? { pct: sample.ram.pct, text: `${gb(sample.ram.used)} / ${gb(sample.ram.total)} Go` } : null,
441 disks: (sample.disks || []).map((d) => ({ drive: d.drive, pct: d.pct_used, text: `${gb(d.free)} Go libres` })),
442 gpus, gpuRead: Boolean(sample.gpu),
443 families: Object.entries((sample.procs && sample.procs.families) || {}).map(([k, v]) => ({ name: k, count: v.count, cpu: v.cpu })).sort((a, b) => b.cpu - a.cpu).slice(0, 6),
444 runnerProc: sample.procs && sample.procs.runner ? sample.procs.runner : null,
445 runners: runner && !runner.error ? (runner.backends || []).map((b) => ({ label: b.label, usable: b.usable, why: b.why,
446 jobs: b.jobs, runners: b.runners, concurrent: b.concurrent })) : [],
447 runnerError: runner && runner.error ? runner.error : '',
448 }
449}
450
451// ---- Journal ------------------------------------------------------------------------------------------------------
452
453export const JOURNAL_KINDS = ['all', 'prompt', 'tool', 'agent', 'compact', 'pr', 'notif', 'error']
454
455export function journal(scan, cfg, filter = 'all', project = '') {
456 const c = screensConfig(cfg)
457 const out = []
458 for (const s of visible(scan)) {
459 const repo = repoOf(s)
460 if (project && repo !== project) continue
461 for (const e of s.recent || []) {
462 const kind = e[1] === 'peer' || e[1] === 'task-notification' ? 'notif' : e[1]
463 out.push({ ts: tsMs(e[0]) || 0, kind, text: e[2], title: titleOf(s), repo, cli: s.cli })
464 }
465 if (s.last_error) out.push({ ts: tsMs(s.last_error[0]) || 0, kind: 'error', text: s.last_error[2], title: titleOf(s), repo, cli: s.cli })
466 }
467 const counts = Object.fromEntries(JOURNAL_KINDS.map((k) => [k, k === 'all' ? out.length : out.filter((x) => x.kind === k).length]))
468 const rows = out.filter((x) => filter === 'all' || x.kind === filter).sort((a, b) => b.ts - a.ts).slice(0, c.journal_max)
469 const projects = [...new Set(visible(scan).map(repoOf))].sort()
470 return { rows, counts, projects }
471}
472
473lib/factory/hooks/workflow.mjs 603 lines1// factory « Workflow » screen - view models only (no $, no I/O; node --test mods/factory/tests/workflow.test.mjs).
2// Input: the JSON of scanner/workflow_scan.py ({runs, live, history}). Output: per workflow its phases as a vertical
3// timeline, its agents, highlights (failed, stuck, no explicit model, parallel limits), estimates from this PC's past
4// runs (median, range, sample size) with an ETA clock time, a complexity badge, a Gantt and one plain French sentence.
5// Every label, threshold and limit comes from workflow.config.json / factory.config.json → `workflow`.
6import { billByModel, sumCost, tokensOf, family, short, costOf, titleOf, repoOf } from './logic.mjs'
7// One estimate rule and one Gantt for Workflows (here) and pipelines (ci mod): byte copies of lib/core (scripts/sync-libs.mjs).
8import { quantiles, remaining, estimateOf, etaOf } from '../lib/core/estimate.mjs'
9import { gantt as coreGantt, axis as coreAxis, legend as coreLegend } from '../lib/core/gantt.mjs'
10
11export const WORKFLOW_DEFAULTS = {
12 tab_after: 'arbre',
13 scope: 'conversation',
14 hours: 36,
15 stale_h: 6,
16 history_days: 30,
17 stuck_min: 15,
18 min_samples: 3,
19 max_runs: 12,
20 refresh_s: 20,
21 limits: { ci: 4, npm: 3 },
22 ci_match: 'gh (pr|run) (checks|watch)|glab (ci|pipeline) (status|view|list)|--watch|ci status --live',
23 npm_match: '\\bnpm (ci|install|i)\\b|\\btsc\\b|npm run (build|typecheck|check)|\\bpnpm (install|i)\\b',
24 complexity: { m: 1, l: 5, xl: 20 },
25 gantt: { min_bar: 16, label_w: 14 },
26 speed: { cap_max: 16, cpu_reserve: 2, session_guideline: 10, seq_gap_s: 90, buckets: 24 },
27 open_finished: false,
28 // Agent-tool agents launched outside a Workflow (run_in_background or not), from factory's scan (its `subs` rows):
29 // shown under the Workflows with an ETA from past agents of the same type on this PC (min_samples, else none).
30 lone: { enabled: true, max: 12, min_samples: 3 },
31 labels: {
32 tab: 'Workflow',
33 title: 'Workflows',
34 scope_conv: 'Cette conversation',
35 scope_all: 'Toutes les conversations',
36 none: 'Aucun workflow dans cette conversation (dernières {hours} h).',
37 none_all: 'Aucun workflow sur ce PC (dernières {hours} h).',
38 scanning: 'Lecture des workflows…',
39 status: { running: 'en cours', completed: 'terminé', failed: 'échoué', killed: 'arrêté', interrupted: 'interrompu' },
40 agent_status: { running: 'en cours', done: 'fini', failed: 'échoué', skipped: 'sauté', waiting: 'en attente' },
41 phase_status: { done: 'fini', running: 'en cours', waiting: 'à venir', failed: 'échoué' },
42 sentence_running: 'Phase {phase} sur {phases} ({title}), {running} agent(s) en cours, {eta}, ~{tokens} tokens, ~{cost}',
43 sentence_done: 'Terminé à {end} en {dur}, {agents} agent(s), {tokens} tokens, ~{cost}',
44 sentence_failed: '{status} à {end} après {dur}, {failed} agent(s) en échec, {tokens} tokens, ~{cost}',
45 eta: 'fini vers {time}',
46 eta_range: '(entre {lo} et {hi})',
47 no_history: 'pas assez d’historique',
48 samples: 'n = {n}',
49 phases: 'Phases',
50 gantt: 'Gantt',
51 agents: 'Agents',
52 estimates: 'Estimations',
53 highlights: 'À regarder',
54 limits: 'Parallélisme sur ce PC',
55 limits_row: '{agents} agents de workflow en cours · CI {ci} / {ci_max} · npm ci / tsc {npm} / {npm_max}',
56 limits_note: 'Compté sur les workflows en cours de tout le PC, d’après la dernière commande de chaque agent (heuristique).',
57 stuck: '{label} : aucun événement depuis {min} min',
58 failed: '{label} a échoué',
59 no_model: '{n} agent(s) sans modèle explicite (hérité) : {labels}',
60 over_ci: 'CI : {n} pipelines attendus en même temps, la règle dit ≤ {max}',
61 over_npm: 'npm ci / tsc : {n} en même temps, la règle dit ≤ {max}',
62 run_failed: 'Le workflow a échoué : {error}',
63 run_killed: 'Le workflow a été arrêté avant la fin',
64 run_interrupted: 'Plus aucun événement depuis {age} et pas de bilan : interrompu ?',
65 nothing: 'rien à signaler',
66 elapsed: 'depuis {dur}',
67 complexity: 'complexité',
68 now: 'maintenant',
69 est_tail: 'fin estimée',
70 est_row: '{what} : reste {dur} {range}, ~{tokens} tokens, ~{cost} · {n}',
71 est_total: 'Total',
72 est_phase: 'Phase {title}',
73 est_level: { label: 'même étiquette', role: 'même rôle, modèle et phase', phase: 'même modèle et phase', type: 'même modèle et type', family: 'même modèle', workflow: 'mêmes runs de ce workflow', generic: 'toutes les phases passées' },
74 overrun: 'dépasse l’habituel',
75 model_inherited: 'modèle hérité',
76 speed: 'Vitesse',
77 speed_scope: 'Vitesse ({scope})',
78 speed_sentence: 'Utilisé {avg}/{cap} agents en moyenne (pic {max}), {seq}, {limits}',
79 speed_seq_none: 'rien n’aurait pu tourner en parallèle de plus',
80 speed_seq: '{n} agent(s) auraient pu tourner en parallèle',
81 speed_limits_none: 'aucune limite atteinte',
82 speed_limits: 'limites atteintes : {list}',
83 speed_rate: '{n} erreur(s) 429 / surcharge',
84 speed_ci: 'CI {n} > {max}',
85 speed_npm: 'npm ci / tsc {n} > {max}',
86 speed_idle: 'lead sans agent en cours pendant {dur} entre deux agents',
87 speed_cap: 'plafond workflow {cap} (min({cap_max}, {cpus} CPU − {reserve})) · repère conversation {guide} agents',
88 speed_could: 'aurait pu tourner en parallèle',
89 speed_could_row: '{label} a démarré {gap} après la fin de {prev}, même phase, sans dépendance visible',
90 lone: 'Agents hors workflow',
91 lone_none: 'aucun agent lancé hors workflow en cours',
92 lone_row: '{type}{bg} · {desc} ({conv}), depuis {dur} : {eta}',
93 lone_eta: 'fin vers {time} (n = {n})',
94 lone_overrun: 'au-delà de l’habituel (n = {n})',
95 lone_bg: 'arrière-plan',
96 open: '▾', closed: '▸',
97 glossary: 'Phase : une étape du workflow, les phases passent l’une après l’autre · Agent : un Claude qui fait une tâche, en parallèle des autres de sa phase · Worktree : une copie isolée du dépôt pour un agent · Tokens : texte lu et écrit par les modèles · $ : estimation au tarif public',
98 },
99}
100
101function merge(a, b) {
102 if (!b || typeof b !== 'object' || Array.isArray(b)) return b === undefined ? a : b
103 const out = { ...(a || {}) }
104 for (const [k, v] of Object.entries(b)) out[k] = v && typeof v === 'object' && !Array.isArray(v) && a && typeof a[k] === 'object' && !Array.isArray(a[k]) ? merge(a[k], v) : v
105 return out
106}
107
108export function workflowConfig(cfg) {
109 return merge(WORKFLOW_DEFAULTS, (cfg && cfg.workflow) || {})
110}
111
112export function fill(tpl, vars) {
113 return String(tpl ?? '').replace(/\{(\w+)\}/g, (m, k) => (vars[k] !== undefined && vars[k] !== null ? String(vars[k]) : m))
114}
115
116// ---- scrubbing (the scanner scrubs already; this is the second lock before anything is drawn) --------------------
117
118const SECRETS = [
119 [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----|$)/g, '<clé privée>'],
120 [/\b(gh[pousr]_|github_pat_)[A-Za-z0-9_]{16,}/g, '<jeton>'],
121 [/\bglpat-[A-Za-z0-9_-]{12,}/g, '<jeton>'],
122 [/\bsk-[A-Za-z0-9_-]{16,}/g, '<clé>'],
123 [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, '<jeton>'],
124 [/\bAKIA[0-9A-Z]{16}\b/g, '<clé>'],
125 [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/g, '<jeton>'],
126 [/\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{12,}/gi, '$1 <masqué>'],
127 [/\b(password|passwd|pwd|token|secret|api[_-]?key|apikey|authorization|client[_-]?secret)(["']?\s*[:=]\s*)["']?[^\s"',;]+/gi, '$1$2<masqué>'],
128 [/(:\/\/[^/\s:@]+:)[^@\s/]+@/g, '$1<masqué>@'],
129 [/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+\.[A-Za-z.]{2,}\b/g, '<e-mail>'],
130 [/\b[A-Z]:[\\/]+Users[\\/]+[^\\/\s"']+/gi, '~'],
131 [/\/(?:home|Users)\/[^/\s"']+/g, '~'],
132 [/\b[A-Fa-f0-9]{40,}\b/g, '<hex>'],
133 [/[A-Za-z0-9+/_-]{48,}={0,2}/g, '<masqué>'],
134]
135
136export function scrub(s, n = 100) {
137 let t = String(s ?? '')
138 for (const [rx, rep] of SECRETS) t = t.replace(rx, rep)
139 t = t.replace(/\s+/g, ' ').trim()
140 return t.length <= n ? t : t.slice(0, Math.max(1, n - 1)) + '…'
141}
142
143// ---- formatting (French) ---------------------------------------------------------------------------------------------
144
145export function frTokens(n) {
146 n = Number(n) || 0
147 if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace('.', ',')} M`
148 if (n >= 1e3) return `${Math.round(n / 1e3)} K`
149 return String(Math.round(n))
150}
151
152export function frUsd(x) {
153 x = Number(x) || 0
154 return x >= 10 ? `${Math.round(x)} $` : `${x.toFixed(2).replace('.', ',')} $`
155}
156
157export function frDur(ms) {
158 const m = Math.max(0, Math.round((Number(ms) || 0) / 60000))
159 if (m < 1) return '< 1 min'
160 if (m < 60) return `${m} min`
161 const h = Math.floor(m / 60)
162 return `${h} h ${String(m % 60).padStart(2, '0')}`
163}
164
165export function clock(ms) {
166 if (!ms) return '--:--'
167 const d = new Date(ms)
168 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
169}
170
171// ---- estimator -------------------------------------------------------------------------------------------------------
172
173// quantiles: lib/core/estimate.mjs (same linear interpolation), re-exported for the tests and the cockpit copy.
174export { quantiles }
175
176const roleOf = (label) => (String(label || '').includes(':') ? String(label).split(':')[0].trim().toLowerCase() : '')
177
178// history rows: {run, name, label, phase, model, tokens, duration_ms, type}. Matching levels, most specific first; the
179// first level with at least min_samples past agents wins, else null (« pas assez d'historique »).
180export function estimator(history, cfg) {
181 const w = workflowConfig(cfg)
182 const rows = (history || []).filter((h) => Number(h.duration_ms) > 0)
183 .map((h) => ({ ...h, usd: Array.isArray(h.vec) ? costOf(h.model || 'default', [...h.vec, 0], cfg) : 0 }))
184 const levels = [
185 ['label', (a, h) => h.name && a.workflow && h.name === a.workflow && h.label === a.label],
186 ['role', (a, h) => roleOf(a.label) && roleOf(h.label) === roleOf(a.label) && family(h.model) === family(a.model) && h.phase === a.phase],
187 ['phase', (a, h) => family(h.model) === family(a.model) && h.phase === a.phase],
188 ['type', (a, h) => a.type && family(h.model) === family(a.model) && h.type === a.type],
189 ['family', (a, h) => family(h.model) === family(a.model)],
190 ]
191 const agent = (a) => {
192 for (const [level, match] of levels) {
193 const hits = rows.filter((h) => match(a, h))
194 if (hits.length >= w.min_samples) {
195 return { level, n: hits.length, dur: quantiles(hits.map((h) => h.duration_ms)), tok: quantiles(hits.map((h) => Number(h.tokens) || 0)), usd: quantiles(hits.map((h) => h.usd)) }
196 }
197 }
198 return null
199 }
200 // one phase of a past run: its agents (count), wall time (longest agent) and tokens (sum)
201 const runs = new Map()
202 for (const h of rows) {
203 const k = h.run + '\u0000' + h.phase
204 const r = runs.get(k) || { name: h.name, phase: h.phase, agents: 0, dur: 0, tok: 0, usd: 0 }
205 r.usd += h.usd
206 r.agents += 1
207 r.dur = Math.max(r.dur, h.duration_ms)
208 r.tok += Number(h.tokens) || 0
209 runs.set(k, r)
210 }
211 const phasesPast = [...runs.values()]
212 const phase = (name, title) => {
213 let hits = phasesPast.filter((p) => p.name === name && p.phase === title)
214 let level = 'workflow'
215 if (hits.length < w.min_samples) { hits = phasesPast; level = 'generic' }
216 if (hits.length < w.min_samples) return null
217 return { level, n: hits.length, agents: quantiles(hits.map((p) => p.agents)), dur: quantiles(hits.map((p) => p.dur)), tok: quantiles(hits.map((p) => p.tok)), usd: quantiles(hits.map((p) => p.usd)) }
218 }
219 const perAgentTok = quantiles(rows.map((h) => Number(h.tokens) || 0))
220 return { agent, phase, perAgentTok, size: rows.length }
221}
222
223// Remaining work of one running agent from its estimate: {lo, mid, hi} ms, tokens and $, overrun when past p75.
224export function remainingOf(est, elapsedMs, tokensNow, costNow = 0) {
225 if (!est) return null
226 const t = est.tok
227 const u = est.usd || { p25: 0, median: 0, p75: 0 }
228 const r = remaining({ enough: true, ...est.dur }, elapsedMs) // lib/core/estimate.mjs: {lo, mid, hi}, overrun past p75
229 const ms = { lo: r.lo, mid: r.mid, hi: r.hi }
230 const tok = { lo: Math.max(0, t.p25 - tokensNow), mid: Math.max(0, t.median - tokensNow), hi: Math.max(0, t.p75 - tokensNow) }
231 const usd = { lo: Math.max(0, u.p25 - costNow), mid: Math.max(0, u.median - costNow), hi: Math.max(0, u.p75 - costNow) }
232 return { ms, tok, usd, overrun: r.overrun, n: est.n, level: est.level }
233}
234
235// ---- one run -----------------------------------------------------------------------------------------------------------
236
237function phaseList(run) {
238 const out = []
239 const seen = new Set()
240 for (const p of run.phases || []) if (p && p.title && !seen.has(p.title)) { seen.add(p.title); out.push({ title: p.title, detail: p.detail || '' }) }
241 for (const a of run.agents || []) if (a.phase && !seen.has(a.phase)) { seen.add(a.phase); out.push({ title: a.phase, detail: '' }) }
242 if (!out.length) out.push({ title: '', detail: '' })
243 return out
244}
245
246export function runView(run, est, cfg, nowMs) {
247 const w = workflowConfig(cfg)
248 const L = w.labels
249 const live = run.status === 'running'
250 const stuckMs = w.stuck_min * 60000
251 const ciRx = new RegExp(w.ci_match, 'i')
252 const npmRx = new RegExp(w.npm_match, 'i')
253 const allModels = {}
254 const agents = (run.agents || []).map((a) => {
255 const bill = billByModel([a.models || {}], cfg)
256 for (const [m, v] of Object.entries(a.models || {})) {
257 const cur = allModels[m] || (allModels[m] = [0, 0, 0, 0, 0, 0])
258 for (let i = 0; i < 6; i++) cur[i] += v[i] || 0
259 }
260 const tokens = Object.values(bill).reduce((x, b) => x + b.tokens, 0) || Number(a.tokens_record) || 0
261 const end = a.status === 'running' ? null : a.end || a.last || null
262 const start = a.start || null
263 const stuck = a.status === 'running' && live && Boolean(a.last || start) && nowMs - (a.last || start) > stuckMs
264 const view = {
265 id: a.id, label: scrub(a.label, 60), phase: a.phase || '', type: a.type || '', model: a.model || run.default_model || '',
266 explicit: Boolean(a.explicit_model), explicitModel: a.explicit_model || '', effort: a.effort || '', status: a.status, stuck,
267 start, end, durMs: start ? (end || nowMs) - start : 0, tokens, cost: sumCost(bill),
268 lastLine: scrub(a.status === 'running' ? a.last_tool || a.last_text : a.last_text || a.last_tool, 100),
269 ci: a.status === 'running' && ciRx.test(a.last_cmd || ''), npm: a.status === 'running' && npmRx.test(a.last_cmd || ''),
270 worktree: scrub(a.worktree, 80), branch: scrub(a.branch, 60), pr: a.pr || null, idle: a.last ? nowMs - a.last : null,
271 rateLimits: Number(a.rate_limits) || 0, lastCmd: a.last_cmd || '',
272 workflow: run.name,
273 }
274 view.est = view.status === 'running' && est ? remainingOf(est.agent(view), start ? nowMs - start : 0, tokens, view.cost) : null
275 return view
276 })
277 const phases = phaseList(run).map((p, i) => {
278 const ag = agents.filter((a) => (a.phase || '') === p.title || (!a.phase && i === 0 && !p.title))
279 const running = ag.filter((a) => a.status === 'running').length
280 const failed = ag.filter((a) => a.status === 'failed').length
281 const status = running ? 'running' : !ag.length ? (live ? 'waiting' : 'waiting') : failed && !ag.some((a) => a.status === 'done') ? 'failed' : 'done'
282 return { index: i + 1, title: p.title, detail: p.detail, status, agents: ag, running, failed }
283 })
284 // a phase with no agent yet, after the last started one, waits; one before it was skipped by the script (done)
285 const lastStarted = Math.max(-1, ...phases.map((p, i) => (p.agents.length ? i : -1)))
286 for (const [i, p] of phases.entries()) if (!p.agents.length && i < lastStarted) p.status = 'done'
287 if (!live) for (const p of phases) if (p.status === 'waiting') p.status = run.status === 'completed' ? 'done' : 'waiting'
288 const current = phases.find((p) => p.status === 'running') || phases.find((p) => p.status === 'waiting') || phases[phases.length - 1]
289 const tokens = agents.reduce((x, a) => x + a.tokens, 0) || Number(run.tokens_record) || 0
290 const cost = sumCost(billByModel([allModels], cfg))
291 const start = run.start || Math.min(...agents.map((a) => a.start).filter(Boolean)) || null
292 const end = live ? null : run.end || Math.max(0, ...agents.map((a) => a.end || 0)) || run.last_activity || null
293 // estimates: running agents (parallel inside a phase) + phases still waiting (one after the other)
294 let estimate = null
295 if (live && est) {
296 const runningAgents = agents.filter((a) => a.status === 'running')
297 const known = runningAgents.filter((a) => a.est)
298 const max = (k) => Math.max(0, ...known.map((a) => a.est.ms[k]))
299 const sumTok = (k) => known.reduce((x, a) => x + a.est.tok[k], 0)
300 const sumUsd = (k) => known.reduce((x, a) => x + a.est.usd[k], 0)
301 const ns = known.map((a) => a.est.n)
302 const phaseEst = []
303 let ms = { lo: max('lo'), mid: max('mid'), hi: max('hi') }
304 let tok = { lo: sumTok('lo'), mid: sumTok('mid'), hi: sumTok('hi') }
305 let usd = { lo: sumUsd('lo'), mid: sumUsd('mid'), hi: sumUsd('hi') }
306 for (const p of phases) {
307 if (p.status === 'running') {
308 const ka = p.agents.filter((a) => a.est)
309 phaseEst.push({ title: p.title, index: p.index, ms: { lo: Math.max(0, ...ka.map((a) => a.est.ms.lo)), mid: Math.max(0, ...ka.map((a) => a.est.ms.mid)), hi: Math.max(0, ...ka.map((a) => a.est.ms.hi)) },
310 tok: { lo: ka.reduce((x, a) => x + a.est.tok.lo, 0), mid: ka.reduce((x, a) => x + a.est.tok.mid, 0), hi: ka.reduce((x, a) => x + a.est.tok.hi, 0) },
311 usd: { lo: ka.reduce((x, a) => x + a.est.usd.lo, 0), mid: ka.reduce((x, a) => x + a.est.usd.mid, 0), hi: ka.reduce((x, a) => x + a.est.usd.hi, 0) },
312 n: ka.length ? Math.min(...ka.map((a) => a.est.n)) : 0, enough: ka.length === p.running, level: ka.length ? ka[0].est.level : '' })
313 }
314 if (p.status !== 'waiting') continue
315 const pe = est.phase(run.name, p.title)
316 if (!pe) { phaseEst.push({ title: p.title, index: p.index, enough: false, n: 0 }); ns.push(0); continue }
317 ns.push(pe.n)
318 phaseEst.push({ title: p.title, index: p.index, ms: { lo: pe.dur.p25, mid: pe.dur.median, hi: pe.dur.p75 }, tok: { lo: pe.tok.p25, mid: pe.tok.median, hi: pe.tok.p75 }, usd: { lo: pe.usd.p25, mid: pe.usd.median, hi: pe.usd.p75 }, n: pe.n, enough: true, level: pe.level, agents: Math.round(pe.agents.median) })
319 ms = { lo: ms.lo + pe.dur.p25, mid: ms.mid + pe.dur.median, hi: ms.hi + pe.dur.p75 }
320 tok = { lo: tok.lo + pe.tok.p25, mid: tok.mid + pe.tok.median, hi: tok.hi + pe.tok.p75 }
321 usd = { lo: usd.lo + pe.usd.p25, mid: usd.mid + pe.usd.median, hi: usd.hi + pe.usd.p75 }
322 }
323 const enough = known.length === runningAgents.length && phaseEst.every((p) => p.enough) && (runningAgents.length > 0 || phaseEst.length > 0)
324 estimate = {
325 enough, n: ns.length ? Math.min(...ns) : 0, ms, tok, phases: phaseEst,
326 eta: { lo: nowMs + ms.lo, mid: nowMs + ms.mid, hi: nowMs + ms.hi },
327 finalTokens: { lo: tokens + tok.lo, mid: tokens + tok.mid, hi: tokens + tok.hi },
328 finalCost: { lo: cost + usd.lo, mid: cost + usd.mid, hi: cost + usd.hi },
329 remainingCost: usd,
330 overrun: known.filter((a) => a.est.overrun).map((a) => a.label),
331 }
332 }
333 // complexity: agents × phases × typical tokens per past agent (millions)
334 const perAgent = est && est.perAgentTok.n ? est.perAgentTok.median : tokens / Math.max(1, agents.length)
335 const nAgents = Math.max(agents.length, Number(run.agent_count) || 0) + (estimate ? estimate.phases.reduce((x, p) => x + (p.agents || 0), 0) : 0)
336 const score = nAgents * phases.length * (perAgent / 1e6)
337 const C = w.complexity
338 const badge = score >= C.xl ? 'XL' : score >= C.l ? 'L' : score >= C.m ? 'M' : 'S'
339 // highlights: red only for real problems
340 const hl = []
341 if (run.status === 'failed') hl.push({ level: 'bad', text: fill(L.run_failed, { error: scrub(run.error || '?', 120) }) })
342 if (run.status === 'killed') hl.push({ level: 'warn', text: L.run_killed })
343 if (run.status === 'interrupted') hl.push({ level: 'warn', text: fill(L.run_interrupted, { age: frDur(nowMs - (run.last_activity || nowMs)) }) })
344 for (const a of agents.filter((x) => x.status === 'failed')) hl.push({ level: 'bad', text: fill(L.failed, { label: a.label }) })
345 for (const a of agents.filter((x) => x.stuck)) hl.push({ level: 'bad', text: fill(L.stuck, { label: a.label, min: Math.round((nowMs - (a.idle !== null ? nowMs - a.idle : a.start)) / 60000) }) })
346 const noModel = agents.filter((a) => !a.explicit && a.status !== 'skipped')
347 if (noModel.length) hl.push({ level: 'warn', text: fill(L.no_model, { n: noModel.length, labels: noModel.slice(0, 4).map((a) => a.label).join(', ') + (noModel.length > 4 ? '…' : '') }) })
348 // the sentence on top
349 const running = agents.filter((a) => a.status === 'running').length
350 let sentence
351 if (live) {
352 const eta = !estimate || !estimate.enough ? `fin : ${L.no_history}`
353 : estimate.overrun.length && estimate.ms.mid < 60000 ? `${L.overrun} (${estimate.overrun.length} agent(s))` : fill(L.eta, { time: clock(estimate.eta.mid) })
354 sentence = fill(L.sentence_running, {
355 phase: current ? current.index : '?', phases: phases.length, title: current && current.title ? current.title : '-', running, eta,
356 tokens: frTokens(estimate && estimate.enough ? estimate.finalTokens.mid : tokens), cost: frUsd(estimate && estimate.enough ? estimate.finalCost.mid : cost),
357 })
358 } else if (run.status === 'completed') {
359 sentence = fill(L.sentence_done, { end: clock(end), dur: frDur((end || 0) - (start || end || 0)), agents: agents.length, tokens: frTokens(tokens), cost: frUsd(cost) })
360 } else {
361 sentence = fill(L.sentence_failed, { status: (L.status || {})[run.status] || run.status, end: clock(end), dur: frDur((end || 0) - (start || end || 0)),
362 failed: agents.filter((a) => a.status === 'failed').length, tokens: frTokens(tokens), cost: frUsd(cost) })
363 }
364 return {
365 id: run.run_id, cli: run.cli, project: run.project, name: scrub(run.name, 60), description: scrub(run.description, 200), status: run.status, live,
366 start, end, elapsed: start ? (end || nowMs) - start : 0, tokens, cost, agents, phases, current, estimate, badge, score, highlights: hl, sentence,
367 running, models: [...new Set(agents.map((a) => short(a.model)).filter((m) => m && m !== '?'))],
368 }
369}
370
371// ---- Vitesse: parallel width over time, average vs cap, lead idle gaps, sequential agents that could overlap -------
372
373const SPARK = '▁▂▃▄▅▆▇█'
374
375export function capOf(cfg, cpus) {
376 const S = workflowConfig(cfg).speed
377 const n = Number(cpus) || 0
378 return n ? Math.max(1, Math.min(S.cap_max, n - S.cpu_reserve)) : S.cap_max
379}
380
381// views: one run (per workflow) or several (per conversation / all). Pure arithmetic on agent start / end.
382export function speed(views, cfg, cpus, nowMs, limits) {
383 const w = workflowConfig(cfg)
384 const S = w.speed
385 const L = w.labels
386 const cap = capOf(cfg, cpus)
387 const spans = []
388 for (const v of views) for (const a of v.agents) if (a.start) spans.push({ s: a.start, e: Math.max(a.start, a.end || (a.status === 'running' ? nowMs : a.start)), a, run: v.id })
389 const rate = views.reduce((x, v) => x + v.agents.reduce((y, a) => y + (a.rateLimits || 0), 0), 0)
390 const limitList = []
391 if (rate) limitList.push(fill(L.speed_rate, { n: rate }))
392 if (limits && limits.ci > limits.ciMax) limitList.push(fill(L.speed_ci, { n: limits.ci, max: limits.ciMax }))
393 if (limits && limits.npm > limits.npmMax) limitList.push(fill(L.speed_npm, { n: limits.npm, max: limits.npmMax }))
394 const limitsText = limitList.length ? fill(L.speed_limits, { list: limitList.join(', ') }) : L.speed_limits_none
395 if (!spans.length) return { empty: true, cap, avg: 0, max: 0, spark: '', idleMs: 0, could: [], rate, limitsText, limitsHit: limitList.length > 0,
396 sentence: fill(L.speed_sentence, { avg: 0, cap, max: 0, seq: L.speed_seq_none, limits: limitsText }) }
397 // per run: busy time, idle gaps (no agent running between the first start and the last end), peak
398 let busy = 0
399 let wall = 0
400 let idleMs = 0
401 let max = 0
402 const byRun = new Map()
403 for (const sp of spans) { const l = byRun.get(sp.run) || []; l.push(sp); byRun.set(sp.run, l) }
404 for (const list of byRun.values()) {
405 const t0 = Math.min(...list.map((x) => x.s))
406 const t1 = Math.max(...list.map((x) => x.e))
407 wall += t1 - t0
408 busy += list.reduce((x, sp) => x + (sp.e - sp.s), 0)
409 const pts = list.flatMap((sp) => [[sp.s, 1], [sp.e, -1]]).sort((a, b) => a[0] - b[0] || a[1] - b[1])
410 let cur = 0
411 let last = t0
412 for (const [t, d] of pts) {
413 if (cur === 0 && t > last) idleMs += t - last
414 cur += d
415 max = Math.max(max, cur)
416 last = t
417 }
418 }
419 const avg = wall > 0 ? busy / wall : 0
420 // the mini chart: agents running at once, sampled on `buckets` points across the whole span
421 const T0 = Math.min(...spans.map((x) => x.s))
422 const T1 = Math.max(...spans.map((x) => x.e), T0 + 1)
423 const B = Math.max(4, S.buckets)
424 const series = []
425 for (let i = 0; i < B; i++) {
426 const t = T0 + ((i + 0.5) / B) * (T1 - T0)
427 series.push(spans.filter((sp) => sp.s <= t && sp.e > t).length)
428 }
429 const top = Math.max(1, ...series)
430 const spark = series.map((n) => (n ? SPARK[Math.min(7, Math.round((n / top) * 7))] : ' ')).join('')
431 // agents that started right after another of the same phase ended, without overlap: could have run in parallel
432 const could = []
433 const gapMs = S.seq_gap_s * 1000
434 for (const list of byRun.values()) {
435 const byPhase = new Map()
436 for (const sp of list) { const l = byPhase.get(sp.a.phase) || []; l.push(sp); byPhase.set(sp.a.phase, l) }
437 for (const ph of byPhase.values()) {
438 ph.sort((x, y) => x.s - y.s)
439 for (let i = 1; i < ph.length; i++) {
440 const prev = ph.slice(0, i).reduce((p, x) => (x.e > p.e ? x : p))
441 const overlap = ph.slice(0, i).some((x) => x.e > ph[i].s)
442 if (!overlap && ph[i].s - prev.e <= gapMs && prev.a.status !== 'failed') {
443 could.push({ label: ph[i].a.label, prev: prev.a.label, gap: ph[i].s - prev.e, text: fill(L.speed_could_row, { label: ph[i].a.label, prev: prev.a.label, gap: frDur(ph[i].s - prev.e) }) })
444 }
445 }
446 }
447 }
448 const avgTxt = avg >= 10 ? String(Math.round(avg)) : avg.toFixed(1).replace('.', ',').replace(/,0$/, '')
449 return {
450 empty: false, cap, cpus: Number(cpus) || 0, guideline: S.session_guideline, avg, max, series, spark, idleMs, could, rate, limitsText, limitsHit: limitList.length > 0,
451 capText: fill(L.speed_cap, { cap, cap_max: S.cap_max, cpus: Number(cpus) || '?', reserve: S.cpu_reserve, guide: S.session_guideline }),
452 idleText: idleMs >= 60000 ? fill(L.speed_idle, { dur: frDur(idleMs) }) : '',
453 sentence: fill(L.speed_sentence, { avg: avgTxt, cap, max, seq: could.length ? fill(L.speed_seq, { n: could.length }) : L.speed_seq_none, limits: limitsText }),
454 }
455}
456
457// ---- PC-wide parallelism vs the rules' limits -------------------------------------------------------------------------
458
459export function limitsView(views, cfg) {
460 const w = workflowConfig(cfg)
461 const L = w.labels
462 const running = views.filter((v) => v.live).flatMap((v) => v.agents.filter((a) => a.status === 'running'))
463 const ci = running.filter((a) => a.ci).length
464 const npm = running.filter((a) => a.npm).length
465 const hl = []
466 if (ci > w.limits.ci) hl.push({ level: 'bad', text: fill(L.over_ci, { n: ci, max: w.limits.ci }) })
467 if (npm > w.limits.npm) hl.push({ level: 'bad', text: fill(L.over_npm, { n: npm, max: w.limits.npm }) })
468 return {
469 agents: running.length, ci, npm, ciMax: w.limits.ci, npmMax: w.limits.npm, highlights: hl,
470 text: fill(L.limits_row, { agents: running.length, ci, ci_max: w.limits.ci, npm, npm_max: w.limits.npm }),
471 level: hl.length ? 'bad' : ci === w.limits.ci || npm === w.limits.npm ? 'warn' : 'ok',
472 }
473}
474
475// The whole screen. scope 'conversation' shows runs of this conversation (the scanner's --session), 'all' every one.
476export function workflowScreen(scan, cfg, nowMs) {
477 const w = workflowConfig(cfg)
478 const est = estimator((scan && scan.history) || [], cfg)
479 const runs = ((scan && scan.runs) || []).slice(0, w.max_runs).map((r) => runView(r, est, cfg, nowMs))
480 const others = ((scan && scan.live) || []).map((r) => runView(r, est, cfg, nowMs))
481 const limits = limitsView([...runs, ...others], cfg)
482 const cpus = (scan && scan.cpus) || 0
483 for (const v of runs) v.speed = speed([v], cfg, cpus, nowMs, v.live ? limits : null)
484 return { runs, limits, historySize: est.size, speed: speed(runs, cfg, cpus, nowMs, limits), cpus }
485}
486
487// ---- Gantt -----------------------------------------------------------------------------------------------------------
488
489// Horizontal when `width` columns leave at least gantt.min_bar for the bars, else vertical (time goes down).
490// Horizontal: rows grouped by phase, each {label, cells: [{text, kind}]}; kind = done|running|failed|skipped|tail|now|empty.
491export function gantt(view, nowMs, width, cfg) {
492 const w = workflowConfig(cfg)
493 const G = w.gantt
494 const items = view.agents.filter((a) => a.start)
495 if (!items.length) return { mode: 'empty', rows: [] }
496 const t0 = Math.min(...items.map((a) => a.start))
497 const tailEnd = (a) => (a.status === 'running' && a.est ? nowMs + a.est.ms.mid : null)
498 const t1 = Math.max(view.live ? nowMs : 0, ...items.map((a) => a.end || (a.status === 'running' ? nowMs : a.start)), ...items.map((a) => tailEnd(a) || 0), t0 + 60000)
499 const labelW = Math.min(G.label_w, Math.max(6, Math.floor(width / 4)))
500 const barW = width - labelW - 1
501 if (barW < G.min_bar) {
502 // vertical: one line per start / end / estimated end, in time order, with the now line in between
503 const ev = []
504 for (const a of items) {
505 ev.push({ ts: a.start, text: `▶ ${a.label}`, kind: 'running', phase: a.phase })
506 if (a.end && a.status !== 'running') ev.push({ ts: a.end, text: `${a.status === 'failed' ? '✗' : '■'} ${a.label} (${frDur(a.end - a.start)})`, kind: a.status, phase: a.phase })
507 const te = tailEnd(a)
508 if (te) ev.push({ ts: te, text: `┄ ${a.label} ${w.labels.est_tail}`, kind: 'tail', phase: a.phase })
509 }
510 if (view.live) ev.push({ ts: nowMs, text: `── ${w.labels.now} ──`, kind: 'now' })
511 ev.sort((a, b) => a.ts - b.ts || (a.kind === 'now' ? 1 : -1))
512 return { mode: 'vertical', rows: ev.map((e) => ({ ...e, time: clock(e.ts) })) }
513 }
514 // horizontal: ONE renderer with the ci mod's Diagramme (lib/core/gantt.mjs), one span for every phase
515 const flat = []
516 for (const p of view.phases) for (const a of p.agents) if (a.start) flat.push({ p, a })
517 const g = coreGantt(flat.map(({ a }) => ({
518 label: a.label, status: coreStatus(a.status), startMs: a.start, endMs: a.status === 'running' ? null : a.end || a.start,
519 estEndMs: tailEnd(a),
520 })), nowMs, barW)
521 const groups = []
522 flat.forEach(({ p, a }, i) => {
523 let grp = groups.find((x) => x.index === p.index)
524 if (!grp) groups.push((grp = { title: p.title, index: p.index, rows: [] }))
525 const r = g.rows[i]
526 grp.rows.push({ label: a.label, status: a.status, cells: r.segs.map((sg) => ({ kind: KIND[sg.kind] || sg.kind, text: sg.text })) })
527 })
528 return { mode: 'horizontal', labelW, barW, nowCol: g.nowCol, from: clock(g.t0), to: clock(g.t1), t0: g.t0, t1: g.t1, groups }
529}
530
531// agent status -> lib/core/gantt status; core segment kind -> this screen's kind (colours of workflow_draw.mjs)
532const coreStatus = (s) => (s === 'running' ? 'running' : s === 'failed' || s === 'killed' || s === 'interrupted' ? 'failed' : 'success')
533const KIND = { est: 'tail', queue: 'skipped' }
534
535/** The legend under the bars: lib/core/gantt.mjs legend, the same words as ci's Diagramme. */
536export const ganttLegend = (labels) => coreLegend(labels)
537
538export function axis(g) {
539 if (g.mode !== 'horizontal') return ''
540 return coreAxis({ t0: g.t0, t1: g.t1, width: g.barW }, g.labelW + 1, clock)
541}
542
543// ---- command arguments -----------------------------------------------------------------------------------------------
544
545const WORDS = new Set(['workflow', 'workflows', 'wf'])
546
547// "/factory workflow" -> {workflow: true, scope: null}; "/factory workflow all" -> scope 'all'; else {workflow: false}.
548export function parseWorkflowArgs(args) {
549 const words = String(args || '').trim().toLowerCase().split(/\s+/).filter(Boolean)
550 if (!words.length || !WORDS.has(words[0])) return { workflow: false, scope: null }
551 const s = words[1]
552 return { workflow: true, scope: s === 'all' || s === 'tout' || s === 'toutes' ? 'all' : s === 'conv' || s === 'conversation' ? 'conversation' : null }
553}
554
555export function workflowArgs(cfg, opts) {
556 const w = workflowConfig(cfg)
557 const a = [opts.script, '--cache', opts.cache, '--hours', String(w.hours), '--stale-h', String(w.stale_h), '--history-days', String(w.history_days)]
558 if (opts.scope === 'all') a.push('--all')
559 else if (opts.session) a.push('--session', opts.session)
560 return ['-I', ...a]
561}
562
563// ---- Agent-tool agents outside a Workflow -----------------------------------------------------------------------------
564
565const msOf = (v) => (!v ? 0 : typeof v === 'number' ? (v > 1e12 ? v : v * 1000) : Date.parse(v) || 0)
566
567/**
568 * Running Agent-tool agents with no Workflow, from factory's scan (sessions[].subs). scope 'all': every conversation;
569 * else only `cli`. ETA: durations (first → last line) of finished agents of the same type in the scan, then of every
570 * type, through lib/core/estimate.mjs (min_samples, else « pas assez d'historique »). Pure.
571 * Returns [{id, cli, conv, repo, type, desc, model, bg, start, eta: {mid, lo, hi, overrun, n} | null, text}].
572 */
573export function loneAgents(scan, cfg, nowMs, opts = {}) {
574 const w = workflowConfig(cfg)
575 if (!w.lone || w.lone.enabled === false) return []
576 const L = w.labels
577 const sessions = (scan && scan.sessions) || []
578 const done = []
579 for (const s of sessions) for (const x of s.subs || []) {
580 const a = msOf(x.ts)
581 const b = msOf(x.last_ts)
582 if (!x.workflow && x.status === 'done' && a && b > a) done.push({ type: x.type, ms: b - a })
583 }
584 const min = Number(w.lone.min_samples) || 3
585 const out = []
586 for (const s of sessions) {
587 if (opts.scope !== 'all' && opts.cli && s.cli !== opts.cli) continue
588 for (const x of s.subs || []) {
589 if (x.workflow || x.status !== 'running') continue
590 const start = msOf(x.ts) || nowMs
591 const est = estimateOf(done.filter((d) => d.type === x.type).map((d) => d.ms), { fallback: done.map((d) => d.ms), min })
592 const eta = etaOf(est, start, nowMs)
593 const etaText = !eta ? L.no_history : eta.overrun ? fill(L.lone_overrun, { n: eta.n }) : fill(L.lone_eta, { time: clock(eta.mid), n: eta.n })
594 const conv = scrub(titleOf(s), 40)
595 out.push({ id: x.id, cli: s.cli, conv, repo: repoOf(s), type: x.type || 'agent', desc: scrub(x.desc || '', 60), model: x.model || '', bg: Boolean(x.bg),
596 start, eta, text: fill(L.lone_row, { type: x.type || 'agent', bg: x.bg ? ` (${L.lone_bg})` : '', desc: scrub(x.desc || '', 60) || '—', conv, dur: frDur(nowMs - start), eta: etaText }) })
597 }
598 }
599 return out.sort((a, b) => b.start - a.start).slice(0, Math.max(1, Number(w.lone.max) || 12))
600}
601
602export { tokensOf }
603lib/veille/hooks/view.mjs 83 lines1// veille - view model of the pane and the band line: pure (no $, no I/O), tested with node --test.
2// Input: the merged config, ~/.claude/veille/state.json, heartbeat.json and the ack keys. The watcher writes those;
3// the mod only reads them and runs `node watcher/veille.mjs mode|ack|tour|coordinator`.
4import { modeOf, watcherState, fill } from '../lib/modes.mjs'
5
6// « attend Julian : N »: the one count is lib/core/veille.mjs attendJulian (open alerts marked julian, not acked, '*'
7// acks all). A hooks module may not import it (it imports node:fs for readVeille), so this is the same rule in one
8// expression; tests/hub.test.mjs checks it gives attendJulian's number on every fixture.
9export const julianCount = (state, acks = []) => {
10 const ack = new Set(acks)
11 return Object.values((state && state.alerts) || {}).filter((a) => a && a.julian && !(a.acked || ack.has(a.key) || ack.has('*'))).length
12}
13
14export function ago(ms) {
15 const m = Math.max(0, Math.round(ms / 60000))
16 if (m < 60) return `${m} min`
17 const h = Math.floor(m / 60)
18 return h < 48 ? `${h} h ${String(m % 60).padStart(2, '0')}` : `${Math.floor(h / 24)} j`
19}
20
21export function hm(ms) {
22 if (!ms) return ''
23 const d = new Date(ms)
24 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
25}
26
27const RANK = { julian: 0, bad: 1, warn: 2, info: 3 }
28
29export function viewModel(cfg, state, heartbeat, acks, nowMs) {
30 const L = cfg.labels || {}
31 const mode = modeOf(cfg)
32 const w = watcherState(heartbeat, cfg, nowMs)
33 const ackSet = new Set(acks || [])
34 const alerts = Object.values((state && state.alerts) || {})
35 .map((a) => ({ ...a, acked: a.acked || ackSet.has(a.key) || ackSet.has('*') }))
36 .sort((a, b) => (a.acked - b.acked) || ((RANK[a.severity] ?? 9) - (RANK[b.severity] ?? 9)) || ((a.since || 0) - (b.since || 0)))
37 const julian = julianCount(state, acks || [])
38 let watcher
39 if (mode.name === 'rien') watcher = { tone: 'dim', text: `${L.last_tour || 'Dernier tour'} : ${w.last_tour ? hm(w.last_tour) : L.never || 'jamais'} (veille éteinte)` }
40 else if (w.state === 'not_installed') watcher = { tone: 'bad', text: L.not_installed || 'tâche non installée' }
41 else if (w.state === 'stopped') watcher = { tone: 'bad', text: fill(L.stopped, { since: w.last ? `${hm(w.last)} (${ago(nowMs - w.last)})` : L.never || 'jamais' }) }
42 else watcher = { tone: 'ok', text: `${L.last_tour || 'Dernier tour'} : ${w.last_tour ? `${hm(w.last_tour)} (il y a ${ago(nowMs - w.last_tour)})` : L.never || 'jamais'} · tâche vue à ${hm(w.last)}` }
43 const may = mode.may.map((a) => (L.may_names || {})[a] || a)
44 if (mode.may.includes('wake') && !(cfg.wake || {}).enabled) may.push(`(${L.wake_off || 'réveils désactivés'})`)
45 return {
46 mode, modes: Object.entries(cfg.modes || {}).map(([id, m]) => ({ id, label: m.label || id, current: id === mode.name })),
47 may, watcher, alerts, julian,
48 mail: state && state.mail === 'not_configured' ? L.mail_off || 'e-mail non configuré' : state && state.mail === 'error' ? 'e-mail en erreur (voir les événements)' : state && state.mail ? L.mail_on || 'e-mail configuré' : '',
49 coordinator: cfg.coordinator_session || '',
50 error: (state && state.last_error) || (heartbeat && heartbeat.last_error) || '',
51 recent: ((state && state.recent) || []).slice(0, (cfg.pane || {}).events || 25),
52 reviewsErrors: (state && state.reviews_errors) || [],
53 }
54}
55
56// One line above the prompt: mode, last tour, how many wait on Julian. Tone bad when the watcher stopped.
57export function bandLine(vm, cfg) {
58 const L = cfg.labels || {}
59 // the red « attend Julian : N » counter comes first, so it is never cut off the end of the line
60 const parts = vm.julian ? [{ text: String(L.julian_counter || 'attend Julian : {n}').replace('{n}', vm.julian), tone: 'julian' }] : []
61 parts.push({ text: `${L.band || 'Veille'} : ${vm.mode.label || vm.mode.name}`, tone: vm.mode.name === 'rien' ? 'dim' : undefined })
62 if (vm.mode.name !== 'rien') {
63 if (vm.watcher.tone === 'bad') parts.push({ text: vm.watcher.text, tone: 'bad' })
64 else if (vm.watcher.tone === 'ok') parts.push({ text: vm.watcher.text.split(' · ')[0].replace(/^[^:]+: /, 'tour '), tone: 'dim' })
65 }
66 return parts
67}
68
69// What an event of state.recent says, in one line.
70export function eventText(e) {
71 const t = hm(e.at)
72 const what = {
73 merged: `mergée : ${e.pr || e.url} (${e.reason || ''})`, merge_failed: `merge échoué : ${e.pr || e.url} : ${e.error || ''}`,
74 merge_skipped: `pas mergée : ${e.pr || e.url} : ${e.reason || ''}`, alert: `alerte : ${e.what || e.key}${e.title ? ' · ' + e.title : ''}`,
75 resolved: `réglée : ${e.what || e.key}`, notified: `notifiée : ${e.key}`, mailed: `e-mail envoyé (${(e.keys || []).length} ligne(s))`,
76 mail_skipped: `e-mail non envoyé : ${e.reason || ''}`, mail_failed: `e-mail en erreur : ${e.error || ''}`,
77 woken: `réveil de ${e.target || e.cli} : ${e.reason || ''}`, wake_skipped: `pas de réveil (${e.reason || ''}) : ${e.key}`,
78 push: `push Claude demandé : ${e.reason || ''}`, push_skipped: `push non envoyé : ${e.reason || ''}`,
79 tour_failed: `tour échoué : ${e.error || ''}`, exported: `export eta : ${e.reason || ''}`, export_failed: `export eta échoué : ${e.error || ''}`, missed: `veille arrêtée de ${hm(e.from)} à ${hm(e.to)}`,
80 }[e.kind] || `${e.kind}${e.reason ? ' : ' + e.reason : ''}`
81 return `${t} ${what}`
82}
83lib/veille/lib/modes.mjs 66 lines1// veille - modes and watcher health: pure, no import, shared by the watcher (lib/core.mjs) and the mod's hooks.
2export const ACTIONS = ['alert', 'notify', 'notify_julian', 'mail', 'push_claude', 'merge', 'wake']
3export const MODES = ['rien', 'légère', 'active', 'nuit-active', 'nuit-test-légère']
4
5export function deepMerge(a, b) {
6 if (!b || typeof b !== 'object' || Array.isArray(b)) return b === undefined ? a : b
7 const out = { ...(a && typeof a === 'object' && !Array.isArray(a) ? a : {}) }
8 for (const [k, v] of Object.entries(b)) out[k] = v && typeof v === 'object' && !Array.isArray(v) ? deepMerge(out[k], v) : v
9 return out
10}
11
12// Accept « legere », « nuit-test-legere », any case: the stored name is always the config key.
13export function modeName(cfg, x) {
14 const fold = (s) => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim()
15 return Object.keys((cfg && cfg.modes) || {}).find((k) => fold(k) === fold(x)) || null
16}
17
18export function modeOf(cfg) {
19 const name = modeName(cfg, cfg && cfg.mode) || 'rien'
20 const m = (cfg.modes || {})[name] || { interval_min: 0, may: [] }
21 return { name, ...m, may: (m.may || []).filter((a) => ACTIONS.includes(a)) }
22}
23
24// What a mode may do, with the switches that gate two of them (wake.enabled, push.claude).
25export function may(cfg, action, mode = modeOf(cfg)) {
26 if (!mode.may.includes(action)) return false
27 if (action === 'wake') return !!(cfg.wake && cfg.wake.enabled)
28 if (action === 'push_claude') return !!(cfg.push && cfg.push.claude)
29 return true
30}
31
32// "23:00-07:30" -> is `date` (local time) inside? A range may wrap past midnight.
33export function inQuietHours(spec, date) {
34 const m = /^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})$/.exec(String(spec || '').trim())
35 if (!m) return false
36 const a = +m[1] * 60 + +m[2], b = +m[3] * 60 + +m[4]
37 const t = date.getHours() * 60 + date.getMinutes()
38 return a <= b ? t >= a && t < b : t >= a || t < b
39}
40
41// Is a tour due at this fire? (rien: never; else when interval_min has passed since the last tour.)
42export function tourDue(mode, lastTourMs, nowMs) {
43 if (!mode.interval_min) return false
44 return !lastTourMs || nowMs - lastTourMs >= mode.interval_min * 60000 - 30000
45}
46
47// The task fires every fire_every_min. A gap of more than two fires means the watcher was not running.
48export function gapOf(lastFireMs, nowMs, fireEveryMin) {
49 if (!lastFireMs) return null
50 const limit = 2 * fireEveryMin * 60000 + 60000
51 return nowMs - lastFireMs > limit ? { from: lastFireMs, to: nowMs } : null
52}
53
54// For the pane and the band: is the watcher stale (two fires missed)?
55// cfg.installed is written by `veille install` (user config.json); heartbeat.json by every fire of the task.
56export function watcherState(heartbeat, cfg, nowMs) {
57 if (!cfg || !cfg.installed) return { state: 'not_installed', last: heartbeat && heartbeat.last_fire, last_tour: (heartbeat && heartbeat.last_tour) || 0 }
58 const last = (heartbeat && heartbeat.last_fire) || 0
59 const limit = 2 * (cfg.fire_every_min || 5) * 60000 + 60000
60 return { state: nowMs - last > limit ? 'stopped' : 'ok', last, last_tour: (heartbeat && heartbeat.last_tour) || 0 }
61}
62
63export function fill(tpl, vars) {
64 return String(tpl || '').replace(/\{(\w+)\}/g, (m, k) => (vars[k] !== undefined && vars[k] !== null ? String(vars[k]) : m))
65}
66lib/cards.mjs 283 lines1// cockpit - summary cards, pure (no $, no I/O, no imports; node --test mods/cockpit/tests/cards.test.mjs).
2// One card per topic, one line each, with the command its button runs (the first candidate loaded in the session):
3// Qui consomme · T'attend · À toi · Claude Control · CI · Quota · Problèmes du jour · En cours
4// Claude only by default (cards.who 'claude'): CI, Quota and En cours count Claude's pipelines and quota; the « Codex »
5// button on the home tab (or cards.who 'all') adds Codex's.
6// Helpers kept here because lib/core lacks them (cockpit-hub, 16 Oct): ciSummary (the ci mod's Diagramme sentence from
7// its pipelines cache, Claude / Codex split), pipelinesLive (running pipelines + ETA), problemsToday (the problemes
8// register.jsonl, read-only). The figures themselves come from lib/core through scanner/cards.mjs (usage, veille,
9// runner, estimate), so band, cockpit and usage print the same numbers.
10
11const DAYS = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
12const fin = (v) => Number.isFinite(v)
13const pad = (n) => String(n).padStart(2, '0')
14
15export const hm = (ms) => {
16 const d = new Date(ms)
17 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
18}
19export const dayHm = (ms) => `${DAYS[new Date(ms).getDay()]} ${hm(ms)}`
20
21const tms = (v) => (v === null || v === undefined || v === '' ? null : typeof v === 'number' ? (v > 1e12 ? v : v * 1000) : (Number.isFinite(Date.parse(v)) ? Date.parse(v) : null))
22
23export const durText = (ms) => {
24 const m = Math.max(0, Math.round(ms / 60000))
25 if (m < 60) return `${m} min`
26 const h = Math.floor(m / 60)
27 return h < 48 ? `${h} h ${pad(m % 60)}` : `${Math.floor(h / 24)} j`
28}
29
30const plural = (n, one, many) => `${n} ${n > 1 ? many : one}`
31
32export const CARD_DEFAULTS = {
33 order: ['conso', 'attend', 'atoi', 'controle', 'ci', 'quota', 'problemes', 'encours'],
34 enabled: ['conso', 'attend', 'atoi', 'controle', 'ci', 'quota', 'problemes', 'encours'],
35 max_encours: 4,
36 who: 'claude',
37 // Button targets: the first command loaded in this session wins (the hub's new name first, the old mod as fallback).
38 buttons: {
39 conso: [{ command: 'conso', args: 'classement' }],
40 attend: [{ command: 'veille', args: 'fil' }],
41 atoi: [{ command: 'afaire' }],
42 controle: [{ command: 'afaire' }],
43 ci: [{ command: 'reference', args: 'ci diagramme' }, { command: 'ci', args: 'diagramme' }],
44 quota: [{ command: 'conso' }],
45 problemes: [{ command: 'registres', args: 'problemes' }, { command: 'problemes' }],
46 encours: [{ tab: 'workflow' }],
47 },
48 labels: {
49 conso: 'Qui consomme', attend: 'T’attend', atoi: 'À toi', controle: 'Claude Control', ci: 'CI', quota: 'Quota', problemes: 'Problèmes du jour', encours: 'En cours',
50 open: 'Voir', none: 'rien', codex_on: 'Codex ✓', codex_off: 'Codex', unknown: 'non lu', not_loaded: '{command} non chargé : ajoute son mod à CLAUDE_CODE_PLUGIN_DIRS',
51 },
52}
53
54// ---- CI: the ci mod's pipelines cache -----------------------------------------------------------------------------
55
56const isRed = (x) => x && x.status === 'failed' && !x.allow_failure
57const runsOf = (cache) => ((cache && cache.apps) || []).flatMap((a) => (a.runs || []).map((r) => ({ ...r, app: r.app || a.app })))
58const live = (r) => r.status === 'running' || r.status === 'queued'
59
60/**
61 * The ci Diagramme's sentence data from ~/.claude/ci-pipelines-cache.json: running pipelines split by author
62 * (claude / codex / unknown), red lanes (newest run of each lane failed) with their red jobs, the runner queue.
63 */
64export function ciSummary(cache, who = 'all') {
65 if (!cache || !Array.isArray(cache.apps)) return { available: false }
66 const runs = runsOf(cache).filter((r) => who === 'all' || r.who === who)
67 const running = { claude: 0, codex: 0, unknown: 0, total: 0 }
68 for (const r of runs.filter(live)) { running[r.who === 'claude' || r.who === 'codex' ? r.who : 'unknown'] += 1; running.total += 1 }
69 const lastOf = new Map()
70 for (const r of runs) {
71 const k = `${r.app}|${r.backend}`
72 const cur = lastOf.get(k)
73 if (!cur || String(r.created || '') > String(cur.created || '')) lastOf.set(k, r)
74 }
75 const reds = [...lastOf.values()].filter(isRed).sort((a, b) => String(b.created || '').localeCompare(String(a.created || '')))
76 .map((r) => ({ app: r.app, who: r.who || 'unknown', jobs: (r.jobs || []).filter(isRed).map((j) => j.name) }))
77 return { available: true, who, generated: tms(cache.generated), running, reds, queue: (cache.summary && cache.summary.queue) || null }
78}
79
80/** « 2 pipelines en cours (Claude 1 · Codex 1), 1 rouge dans app (job lint), runner local à 3/18 ». */
81export function ciSentence(ci, runnerText = '') {
82 if (!ci || !ci.available) return ''
83 const r = ci.running
84 const split = r.total && (!ci.who || ci.who === 'all') ? ` (${[['Claude', r.claude], ['Codex', r.codex], ['inconnu', r.unknown]].filter(([, n]) => n).map(([k, n]) => `${k} ${n}`).join(' · ')})` : ''
85 const by = ci.who === 'claude' ? ' Claude' : ci.who === 'codex' ? ' Codex' : ''
86 const parts = [r.total ? `${plural(r.total, 'pipeline', 'pipelines')}${by} en cours${split}` : `aucun pipeline${by} en cours`]
87 if (ci.reds.length) {
88 const x = ci.reds[0]
89 const jobs = x.jobs.length ? ` (job${x.jobs.length > 1 ? 's' : ''} ${x.jobs.slice(0, 2).join(', ')}${x.jobs.length > 2 ? '…' : ''})` : ''
90 const more = ci.reds.length > 1 ? ` et ${plural(ci.reds.length - 1, 'autre', 'autres')}` : ''
91 parts.push(`${plural(ci.reds.length, 'rouge', 'rouges')} dans ${x.app}${jobs}${more}`)
92 } else parts.push('aucun rouge')
93 if (runnerText) parts.push(runnerText)
94 return parts.join(', ')
95}
96
97/**
98 * Running pipelines with an ETA: the ci scanner's own estimate when it has one (median of past jobs or pipelines),
99 * else `estimateOf` (lib/core/estimate.mjs, passed in) over the lane's past successful durations; else none.
100 * Returns [{app, who, branch, startMs, eta: {mid, lo, hi, overrun} | null, basis}], newest first.
101 */
102export function pipelinesLive(cache, nowMs, estimateOf, etaOf) {
103 const runs = runsOf(cache)
104 return runs.filter(live).sort((a, b) => String(b.created || '').localeCompare(String(a.created || ''))).map((r) => {
105 const start = tms(r.created) || nowMs
106 const given = r.estimate && tms(r.estimate.eta)
107 let eta = given ? { mid: given, lo: given, hi: given, overrun: given <= nowMs } : null
108 let basis = given ? 'ci' : null
109 if (!eta && estimateOf && etaOf) {
110 const past = runs.filter((x) => x.app === r.app && x.backend === r.backend && x.status === 'success' && fin(x.duration_s)).map((x) => x.duration_s * 1000)
111 const e = etaOf(estimateOf(past), start, nowMs)
112 if (e) { eta = e; basis = 'core' }
113 }
114 return { app: r.app, who: r.who || 'unknown', branch: r.branch || '', title: r.title || '', startMs: start, eta, basis }
115 })
116}
117
118// ---- Problèmes: register.jsonl (append-only ledger of the problemes mod) ------------------------------------------
119
120/** Problems seen today (local midnight on): count, open ones, and the top by occurrences today. */
121export function problemsToday(lines, nowMs) {
122 const d = new Date(nowMs)
123 const midnight = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
124 const map = new Map()
125 for (const l of lines || []) {
126 if (!l || !l.id) continue
127 if (l.op === 'add') {
128 if (!map.has(l.id)) map.set(l.id, { id: l.id, title: l.title || l.id, status: l.status || 'ouvert', today: 0 })
129 const at = tms(l.last_seen ?? l.at)
130 if (at && at >= midnight) map.get(l.id).today += Math.max(1, Number(l.count) || 1)
131 continue
132 }
133 const p = map.get(l.id)
134 if (!p) continue
135 if (l.op === 'seen') {
136 const at = tms(l.last ?? l.at)
137 if (at && at >= midnight) p.today += Math.max(1, Number(l.n) || 1)
138 } else if (l.op === 'update' && l.fields && l.fields.status) p.status = l.fields.status
139 }
140 const today = [...map.values()].filter((p) => p.today > 0).sort((a, b) => b.today - a.today)
141 return { available: true, total: map.size, today: today.length, open: today.filter((p) => p.status === 'ouvert').length, top: today[0] || null }
142}
143
144// ---- the cards ------------------------------------------------------------------------------------------------------
145
146function merge(a, b) {
147 if (!b || typeof b !== 'object' || Array.isArray(b)) return b === undefined ? a : b
148 const out = { ...(a || {}) }
149 for (const [k, v] of Object.entries(b)) {
150 if (k.startsWith('_')) continue
151 out[k] = v && typeof v === 'object' && !Array.isArray(v) && a && typeof a[k] === 'object' && !Array.isArray(a[k]) ? merge(a[k], v) : v
152 }
153 return out
154}
155
156export const cardsConfig = (cfg) => merge(CARD_DEFAULTS, (cfg && cfg.cards) || {})
157
158const pctText = (w) => (w ? `${Math.round(w.pct)} %` : '?')
159
160/** « 5 h 24 % · semaine 41 % · épuisé ven 14:00 » for one provider (lib/core/usage.mjs windows). */
161export function quotaText(p) {
162 if (!p || (!p.five && !p.week)) return ''
163 const bits = [`5 h ${pctText(p.five)}`, `semaine ${pctText(p.week)}`]
164 const out = [p.five, p.week].filter((w) => w && w.runOutAt).sort((a, b) => a.runOutAt - b.runOutAt)[0]
165 if (out) bits.push(`épuisé ${dayHm(out.runOutAt)}`)
166 return bits.join(' · ')
167}
168
169/** The running Workflows of factory's workflowScreen (runs with live true): « name : sentence ». */
170export function workflowsLive(screen) {
171 return ((screen && screen.runs) || []).filter((v) => v.live).map((v) => ({
172 kind: 'workflow', name: v.name, sentence: v.sentence || '',
173 eta: v.estimate && v.estimate.enough && v.estimate.eta ? v.estimate.eta : null,
174 }))
175}
176
177/** One plain sentence per running thing, with its ETA (or « pas assez d'historique »). */
178export function encoursItems(workflows, pipelines, nowMs, max = 4, lone = []) {
179 const items = []
180 for (const w of workflows || []) {
181 items.push({ kind: 'workflow', text: `Workflow ${w.name}${w.eta ? ` : fin vers ${hm(w.eta.mid)}` : ' : fin inconnue (pas assez d’historique)'}`, detail: w.sentence })
182 }
183 for (const a of lone || []) {
184 const eta = !a.eta ? 'fin inconnue (pas assez d’historique)' : a.eta.overrun ? 'au-delà de l’habituel' : `fin vers ${hm(a.eta.mid)}`
185 items.push({ kind: 'agent', text: `Agent ${a.type}${a.desc ? ` · ${clip(a.desc, 40)}` : ''}${a.bg ? ' (arrière-plan)' : ''}, depuis ${durText(nowMs - a.start)} : ${eta}` })
186 }
187 for (const p of pipelines || []) {
188 const who = p.who === 'codex' ? 'Codex' : p.who === 'claude' ? 'Claude' : ''
189 const head = `Pipeline ${p.app}${p.branch ? ` (${p.branch})` : ''}${who ? ` · ${who}` : ''}, depuis ${durText(nowMs - p.startMs)}`
190 const eta = !p.eta ? 'fin inconnue (pas assez d’historique)' : p.eta.overrun ? 'au-delà de l’estimation' : `vert ou rouge vers ${hm(p.eta.mid)}`
191 items.push({ kind: 'pipeline', text: `${head} : ${eta}` })
192 }
193 return { items: items.slice(0, max), more: Math.max(0, items.length - max) }
194}
195
196/**
197 * The cards, in order. data = {conso (cockpit's consoCard), veille (core readVeille slim), waiting (rows count),
198 * quotas (core readQuotas), runnerText, ci (ciSummary), problemes (problemsToday), workflows, pipelines}.
199 */
200export function cardsOf(data, cfg, nowMs) {
201 const c = cardsConfig(cfg)
202 const L = c.labels
203 const d = data || {}
204 const who = d.who || c.who || 'claude'
205 const make = {
206 conso: () => {
207 const k = d.conso
208 const line = k ? [`${k.preset.label} ${usd(k.total.cost)}`, k.rows[0] ? `${clip(k.rows[0].label, 28)} ${k.rows[0].pct} %` : '', k.project ? `projet ${k.project.label} ${k.project.pct} %` : ''].filter(Boolean).join(' · ') : 'pas encore de classement (le mod usage écrit classement.json)'
209 return { line, tone: k ? null : 'dim' }
210 },
211 attend: () => {
212 const v = d.veille
213 const n = v ? v.julian : null
214 const w = Number(d.waiting) || 0
215 if (!v && !w) return { line: 'veille : aucune lecture', tone: 'dim' }
216 const bits = []
217 if (v) bits.push(`attend Julian : ${n}`)
218 bits.push(`Ça t’attend : ${w}`)
219 if (v && v.filTop) bits.push(clip(v.filTop, 40))
220 return { line: bits.join(' · '), tone: n || w ? 'warn' : 'ok', count: (n || 0) + w }
221 },
222 atoi: () => {
223 const v = d.veille
224 if (!v || !v.aToiAvailable) return { line: 'À toi : non lu (le mod a-faire écrit summary.json)', tone: 'dim' }
225 return { line: `À toi : ${v.aToi}`, tone: v.aToi ? 'warn' : 'ok', count: v.aToi }
226 },
227 controle: () => d.controle || { line: 'aucun fichier ~/.claude/a-faire/claude-control.md', tone: 'dim', items: [] },
228 ci: () => {
229 const ci = (d.ciBy && d.ciBy[who]) || d.ci
230 if (!ci || !ci.available) return { line: d.runnerText ? `pas de cache ci · ${d.runnerText}` : 'pas de cache du mod ci', tone: 'dim' }
231 return { line: ciSentence(ci, d.runnerText || ''), tone: ci.reds.length ? 'bad' : ci.running.total ? 'running' : 'ok' }
232 },
233 quota: () => {
234 const q = d.quotas
235 const cl = q ? quotaText(q.claude) : ''
236 const cx = q ? quotaText(q.codex) : ''
237 if (!cl && (who !== 'all' || !cx)) return { line: 'quotas non lus (le mod usage écrit samples.jsonl)', tone: 'dim' }
238 const ws = who === 'all' ? [q.claude.five, q.claude.week, q.codex.five, q.codex.week] : [q.claude.five, q.claude.week]
239 const hot = ws.some((w) => w && (w.pct >= 80 || w.runOutAt))
240 return { line: (who === 'all' ? [`Claude ${cl || L.unknown}`, `Codex ${cx || L.unknown}`] : [`Claude ${cl || L.unknown}`]).join(' | '), tone: hot ? 'warn' : null }
241 },
242 problemes: () => {
243 const p = d.problemes
244 if (!p || !p.available) return { line: 'registre des problèmes non lu', tone: 'dim' }
245 if (!p.today) return { line: 'aucun problème vu aujourd’hui', tone: 'ok' }
246 return { line: `${p.today} vu(s) aujourd’hui · ${p.open} ouvert(s)${p.top ? ` · en tête : ${clip(p.top.title, 40)} (×${p.top.today})` : ''}`, tone: p.open ? 'warn' : null, count: p.today }
247 },
248 encours: () => {
249 const pipes = (d.pipelines || []).filter((p) => who === 'all' || p.who === who)
250 const e = encoursItems(d.workflows, pipes, nowMs, c.max_encours, d.lone || [])
251 if (!e.items.length) return { line: 'rien ne tourne', tone: 'dim', items: [] }
252 return { line: `${e.items.length + e.more} en cours`, tone: 'running', items: e.items, more: e.more }
253 },
254 }
255 return c.order.filter((id) => make[id] && c.enabled.includes(id)).map((id) => ({ id, title: L[id] || id, button: c.buttons[id] || [], ...make[id]() }))
256}
257
258/** The first button target loaded in this session (names from $.command.list()), else null. A tab target is internal. */
259export function pickTarget(candidates, names) {
260 const have = new Set(names || [])
261 for (const t of candidates || []) {
262 if (t.tab) return t
263 if (t.command && have.has(t.command)) return t
264 }
265 return null
266}
267
268/** lib/core/runner.mjs load in the shape factory's Système screen draws (backends), for the Machine tab. */
269export function runnerBackends(r) {
270 if (!r) return { generated: null, backends: [], error: 'runner : pas de cache du mod ci (~/.claude/ci-pipelines-cache.json)' }
271 return { generated: r.generated, backends: [{ label: r.label || 'runner', usable: r.online === null || r.online === undefined ? 'yes' : r.online > 0,
272 jobs: { running: r.running, pending: r.pending }, runners: { online: r.online, total: null }, concurrent: r.concurrent }] }
273}
274
275function usd(x) {
276 return !fin(x) ? '$?' : x >= 100 ? `$${Math.round(x)}` : `$${x.toFixed(2)}`
277}
278
279function clip(s, n) {
280 s = String(s ?? '')
281 return s.length <= n ? s : s.slice(0, n - 1) + '…'
282}
283hooks/nav.mjs 693 lines1// cockpit - navigation and tree, pure (no $, no I/O; node --test mods/cockpit/tests/nav.test.mjs).
2// Four zoom levels: méga (all projects of this PC) > macro (one project: its conversations as a tree through the app's
3// spawnedFrom link) > medium (one conversation: its Workflows and agents) > micro (one agent). The input is the JSON of
4// factory's scanner (lib/factory/scanner/factory_scan.py): sessions with sid, parent (spawnedFrom), subs; detail of one
5// conversation. Every label and limit comes from cockpit.config.json.
6import { titleOf, repoOf, short, ctxPct, billByModel, sumCost, tokensOf, family, fmtTokens, fmtUsd, hhmm } from '../lib/factory/hooks/logic.mjs'
7import { sessionState, mission, effortBars, screensConfig } from '../lib/factory/hooks/screens.mjs'
8import { rank } from '../lib/usage/lib/classement.mjs'
9
10export const LEVELS = ['mega', 'macro', 'medium', 'micro']
11// Tabs of the one pane: « Cockpit » (cards + the scoped sections), then factory's screens (synced copies in
12// lib/factory) and Machine. factory's Quotas and Système are not here: the Quota card and Machine replace them.
13// relais = Claude ↔ Codex, galerie = the Reference Gallery views (Équipe, Carte, Codex), nuit = « Bilan avant la nuit »
14// (opened by the Nuit button of the Cockpit tab; listed in tabs.order only if you add it), depuis = « Depuis ton dernier
15// message » (its button on the Cockpit tab, /cockpit depuis [8h|24h|nuit|HH:MM]; off with depuis.enabled false).
16export const TABS = ['panneau', 'home', 'mission', 'focus', 'arbre', 'pipeline', 'workflow', 'journal', 'machine', 'relais', 'galerie', 'nuit', 'depuis']
17const DEFAULT_TABS = ['home', 'mission', 'focus', 'arbre', 'pipeline', 'workflow', 'journal', 'machine', 'relais', 'galerie']
18// Top to bottom by scope: this conversation, its project, every project, Claude as a whole. Each is foldable.
19export const SECTIONS = ['breadcrumb', 'conv', 'project', 'all', 'conso', 'global', 'fichiers']
20export const FOLDABLE = ['conv', 'project', 'all', 'conso', 'global']
21
22export const DEFAULTS = {
23 sections: { order: SECTIONS, enabled: SECTIONS, open: ['conv'] },
24 tabs: { order: DEFAULT_TABS, enabled: TABS, labels: { home: 'Cockpit', mission: 'Mission', focus: 'Focus', arbre: 'Arbre', pipeline: 'Pipeline', workflow: 'Workflow', journal: 'Journal', machine: 'Machine', relais: 'Claude ↔ Codex', galerie: 'Galerie', nuit: 'Nuit', depuis: 'Depuis' } },
25 // The vertical menu at the left of the pane: the 6 entries of the band's bar. A press runs the entry's command (a mod
26 // cannot open another mod's pane); self: true = this pane (goes to the Cockpit tab). Under min_columns the menu is
27 // one row on top instead. position: left | top | off.
28 menu: {
29 position: 'left', min_columns: 50, width: 12,
30 items: [
31 { label: 'Jauge', command: 'jauge' }, { label: 'Cockpit', self: true }, { label: 'Veille', command: 'veille' },
32 { label: 'À toi', command: 'afaire' }, { label: 'Registres', command: 'registres' }, { label: 'Référence', command: 'reference' },
33 ],
34 },
35 // The Nuit / Jour row of the Cockpit tab: Nuit opens « Bilan avant la nuit », Jour runs /veille jour. repos: extra
36 // repo folders whose handoff/ROADMAP.md and packets are read for « Prêt à lancer » (the recent conversations' folders
37 // are always read). The screen's own settings (wake time, regexes, launch command) are under `nuit` (lib/decide.mjs).
38 day_night: { enabled: true, repos: [], recent_days: 7 },
39 machine_refresh_s: 10,
40 gpu_every: 6,
41 aliases: { enabled: true, delay_s: 4, names: ['factory', 'machine'] },
42 default_level: 'medium',
43 auto_open: true,
44 refresh_s: 20,
45 strips_refresh_s: 60,
46 diagram: { enabled: false, width: 340, row_h: 22, max_rows: 40 },
47 tree: { max_convs: 30, max_agents: 20, show_done_agents: true },
48 qui_consomme: {
49 presets: [{ id: '1h', label: '1 h', hours: 1 }, { id: 'today', label: 'aujourd’hui', since: 'midnight' }, { id: '7d', label: '7 j', hours: 168 }],
50 default: 'today', top: 3, basis: 'cost', rescan_min: 10, data_dir: '', command: 'conso', args: 'classement',
51 },
52 colors: { running: '#58a6ff', ok: '#3fb950', warn: '#d29922', bad: '#f85149', dim: '#8b949e', line: '#8b949e88', text: '#c9d1d9', selected: '#d2a8ff', usage: '#2f81f7' },
53 labels: {
54 title: 'Cockpit',
55 levels: { mega: 'Méga', macro: 'Projet', medium: 'Conversation', micro: 'Agent' },
56 level_hint: { mega: 'tous les projets du PC', macro: 'un projet : lead et sous-conversations', medium: 'une conversation : Workflows et agents', micro: 'un agent' },
57 tree: 'Arbre',
58 detail: 'Détail',
59 diagram_alt: 'Arbre dessiné',
60 jauge: 'Jauge',
61 usage: 'Usage',
62 veille: 'Veille',
63 fichiers: 'Fichiers',
64 fichiers_hint: 'L’arbre des fichiers est le mod filetree, dans son propre onglet du même panneau.',
65 fichiers_button: 'Ouvrir Fichiers (/filetree)',
66 this_conv: 'cette conversation',
67 workflow: 'workflow',
68 agents: 'agents',
69 no_convs: 'aucune conversation récente',
70 no_agents: 'aucun sous-agent',
71 scanning: 'Lecture des transcripts…',
72 refresh: 'Rafraîchir',
73 updated: 'mis à jour {time}',
74 module_missing: 'module {mod} non chargé (mets mods/{mod} à côté de cockpit)',
75 not_readable: 'non lisible depuis un mod',
76 agent_title: 'Agent',
77 agent_log: 'Journal de l’agent',
78 agent_none: 'Agent introuvable dans le dernier passage.',
79 // « Ouvrir l'agent »: $.ui.view({ agentId }) (engine 2.1.300+), only for a running agent of this conversation
80 open_agent: 'Ouvrir l’agent',
81 back_conv: '← Conversation',
82 view_old: 'Ouvrir un agent demande Claude Code 2.1.300 ou plus récent (claude update)',
83 view_denied: 'Agent non ouvert : {reason}',
84 session: 'Session (5 h)',
85 week_all: 'Semaine · tous modèles',
86 week_fable: 'Semaine · Fable',
87 credits: 'Crédits cloud',
88 reset_in: 'reset dans {in}',
89 reset_at: 'reset {at}',
90 this_session: 'cette session',
91 cache_hit: 'cache',
92 sec_conv: 'Cette conversation',
93 sec_conv_other: 'Conversation',
94 sec_project: 'Ce projet',
95 sec_all: 'Tous les projets',
96 sec_global: 'Claude global',
97 sec_conso: 'Qui consomme',
98 conso_full: 'Classement complet',
99 conso_none: 'pas encore de classement : ouvre /conso classement (le mod usage écrit classement.json)',
100 conso_project: 'projet en tête',
101 conso_missing: 'usage non chargé : ajoute mods/usage à CLAUDE_CODE_PLUGIN_DIRS',
102 step: 'étape',
103 no_pr: 'pas de PR',
104 machine: 'Machine',
105 machine_button: 'Onglet Machine',
106 card_open: 'Voir',
107 night_button: 'Nuit',
108 day_button: 'Jour',
109 codex_on: 'Codex ✓',
110 codex_off: 'Codex',
111 menu_missing: '{command} non chargé : ajoute son mod à CLAUDE_CODE_PLUGIN_DIRS',
112 states: { working: 'en cours', julian: 'attend Julian', blocked: 'bloquée', done: 'terminée', idle: 'au repos', archived: 'archivée' },
113 today: 'aujourd’hui',
114 show_done: '+ {n} terminé(s)',
115 hide_done: 'masquer les terminés',
116 contributing: 'Ce qui pèse (cette session)',
117 main_thread: 'fil principal',
118 veille_off: 'veille : aucune lecture (mod veille absent ou jamais lancé)',
119 veille_none: 'aucune alerte',
120 },
121}
122
123function merge(a, b) {
124 if (!b || typeof b !== 'object' || Array.isArray(b)) return b === undefined ? a : b
125 const out = { ...(a || {}) }
126 for (const [k, v] of Object.entries(b)) {
127 if (k.startsWith('_')) continue
128 out[k] = v && typeof v === 'object' && !Array.isArray(v) && a && typeof a[k] === 'object' && !Array.isArray(a[k]) ? merge(a[k], v) : v
129 }
130 return out
131}
132
133export function cockpitConfig(cfg) {
134 return merge(DEFAULTS, cfg || {})
135}
136
137export function fill(tpl, vars) {
138 return String(tpl ?? '').replace(/\{(\w+)\}/g, (m, k) => (vars[k] !== undefined && vars[k] !== null ? String(vars[k]) : m))
139}
140
141// The sections shown, in order: `order` filtered by `enabled`; unknown ids dropped.
142export function sectionsOf(cfg) {
143 const c = cockpitConfig(cfg)
144 return c.sections.order.filter((s) => SECTIONS.includes(s) && c.sections.enabled.includes(s))
145}
146
147// ---- navigation state ---------------------------------------------------------------------------------------------
148// nav = { level, repo, conv, agent }: each level keeps what is above it, so the breadcrumb can zoom back out.
149
150function visible(scan) {
151 return ((scan && scan.sessions) || []).filter((s) => !(s.archived && !s.live))
152}
153
154export function findSession(scan, cli) {
155 return ((scan && scan.sessions) || []).find((s) => s.cli === cli || (s.sid && s.sid === cli)) || null
156}
157
158// At session start: the conversation's own project at the configured level, this conversation selected.
159export function initialNav(scan, self, cfg) {
160 const c = cockpitConfig(cfg)
161 const level = LEVELS.includes(c.default_level) ? c.default_level : 'macro'
162 const me = findSession(scan, self)
163 if (level === 'mega' || !me) return { level: me || level === 'mega' ? level : 'mega', repo: me ? repoOf(me) : null, conv: me ? me.cli : null, agent: null }
164 return { level: level === 'micro' ? 'medium' : level, repo: repoOf(me), conv: me.cli, agent: null }
165}
166
167// Zoom out (or in, when the level's subject is already known) to `level`.
168export function zoomTo(nav, level) {
169 const i = LEVELS.indexOf(level)
170 if (i < 0) return nav
171 if (i >= 1 && !nav.repo) return nav
172 if (i >= 2 && !nav.conv) return nav
173 if (i >= 3 && !nav.agent) return nav
174 return { ...nav, level }
175}
176
177// A click on a tree node: a project zooms to macro, a conversation to medium, an agent to micro.
178export function select(nav, node) {
179 if (!node) return nav
180 if (node.kind === 'project') return { level: 'macro', repo: node.id, conv: nav.repo === node.id ? nav.conv : null, agent: null }
181 if (node.kind === 'conv') return { level: 'medium', repo: node.repo || nav.repo, conv: node.id, agent: null }
182 if (node.kind === 'agent') return { level: 'micro', repo: nav.repo, conv: node.conv || nav.conv, agent: node.id }
183 if (node.kind === 'workflow') return { level: 'medium', repo: nav.repo, conv: node.conv || nav.conv, agent: null }
184 return nav
185}
186
187// Méga › datapass-claude-eta › Galaxy finalisation autonome › coder
188export function crumbs(nav, scan, cfg) {
189 const L = cockpitConfig(cfg).labels
190 const out = [{ level: 'mega', label: L.levels.mega }]
191 const depth = LEVELS.indexOf(nav.level)
192 if (depth >= 1 && nav.repo) out.push({ level: 'macro', label: nav.repo })
193 if (depth >= 2 && nav.conv) {
194 const s = findSession(scan, nav.conv)
195 out.push({ level: 'medium', label: s ? titleOf(s) : String(nav.conv).slice(0, 8) })
196 }
197 if (depth >= 3 && nav.agent) {
198 const a = agentOf(scan, nav.agent)
199 out.push({ level: 'micro', label: a ? `${a.type}${a.desc ? ' · ' + a.desc : ''}` : String(nav.agent).slice(0, 8) })
200 }
201 return out.map((c, i) => ({ ...c, current: i === out.length - 1 }))
202}
203
204export function crumbText(list) {
205 return list.map((c) => c.label).join(' › ')
206}
207
208// ---- the tree --------------------------------------------------------------------------------------------------------
209
210export const STATE_TONE = { working: 'running', julian: 'warn', blocked: 'bad', done: 'ok', idle: 'dim', archived: 'dim', running: 'running', failed: 'bad', stopped: 'dim' }
211
212const tsMs = (v) => (!v ? 0 : typeof v === 'number' ? (v > 1e12 ? v : v * 1000) : Date.parse(v) || 0)
213
214// The projects of the méga level with their counters (mission's project cards).
215export function projectsOf(scan, cfg, nowMs) {
216 const m = mission(scan || { sessions: [] }, cfg, nowMs)
217 return m.projects.map((p) => {
218 const st = p.states || {}
219 return { repo: p.repo, sessions: p.sessions, live: p.live, workers: p.workers, cost: p.cost, maxCtxPct: p.maxCtxPct,
220 working: st.working || 0, julian: st.julian || 0, blocked: st.blocked || 0, done: st.done || 0, idle: st.idle || 0,
221 tone: st.blocked ? 'bad' : st.julian ? 'warn' : p.live ? 'running' : 'dim' }
222 })
223}
224
225// A project's conversations plus every conversation they spawned (spawnedFrom), even in another repo: a lead in
226// datapass-claude-eta that launched a lane in datapass-mosaicstudio shows that lane under it.
227export function projectSessions(scan, repo) {
228 const all = visible(scan)
229 const out = all.filter((s) => repoOf(s) === repo)
230 const sids = new Set(out.map((s) => s.sid).filter(Boolean))
231 for (let grew = true; grew;) {
232 grew = false
233 for (const s of all) {
234 if (s.parent && sids.has(s.parent) && !out.includes(s)) { out.push(s); if (s.sid) sids.add(s.sid); grew = true }
235 }
236 }
237 return out
238}
239
240// One project's conversations as a forest: a root is a conversation whose spawnedFrom parent is not in the project.
241export function convForest(scan, repo, cfg) {
242 const c = cockpitConfig(cfg)
243 const ss = projectSessions(scan, repo)
244 const bySid = new Map(ss.filter((s) => s.sid).map((s) => [s.sid, s]))
245 const node = (s) => {
246 const st = sessionState(s, cfg)
247 return { kind: 'conv', id: s.cli, sid: s.sid, repo, ownRepo: repoOf(s), title: titleOf(s), state: st.state, reason: st.reason, live: Boolean(s.live),
248 model: s.app_model || s.model, effort: s.app_effort || s.effort || '?', ctxPct: ctxPct(s.ctx, cfg),
249 agents: (s.subs || []).length, running: (s.subs || []).filter((x) => x.status === 'running').length,
250 last: tsMs(s.last_activity) || tsMs(s.mtime), children: [] }
251 }
252 const nodes = new Map(ss.map((s) => [s.cli, node(s)]))
253 const roots = []
254 for (const s of ss) {
255 const p = s.parent && bySid.get(s.parent)
256 if (p && p !== s) nodes.get(p.cli).children.push(nodes.get(s.cli))
257 else roots.push(nodes.get(s.cli))
258 }
259 const order = (a, b) => (b.live - a.live) || (b.children.length - a.children.length) || (b.last - a.last)
260 const sortRec = (list) => { list.sort(order); for (const n of list) sortRec(n.children) }
261 sortRec(roots)
262 return roots.slice(0, c.tree.max_convs)
263}
264
265// The detail's subagents (when the scan's detail is that conversation), else the slim rows of the session list.
266function subsOf(scan, cli) {
267 if (scan && scan.detail && scan.detail.cli === cli) return scan.detail.subs || []
268 const s = findSession(scan, cli)
269 return (s && s.subs) || []
270}
271
272export function agentOf(scan, id) {
273 if (!id) return null
274 const d = scan && scan.detail
275 const fromDetail = d && (d.subs || []).find((x) => x.id === id)
276 if (fromDetail) return { ...fromDetail, conv: d.cli }
277 for (const s of (scan && scan.sessions) || []) {
278 const x = (s.subs || []).find((y) => y.id === id)
279 if (x) return { ...x, conv: s.cli }
280 }
281 return null
282}
283
284function workflowName(scan, cli, id) {
285 const d = scan && scan.detail
286 const call = d && d.cli === cli && (d.calls || []).find((c) => c.tool === 'Workflow' && c.runId === id)
287 return call ? call.desc : id
288}
289
290// One conversation's agents: plain Agent subagents, then each Workflow with its agents (running first, then recent).
291export function agentNodes(scan, cli, cfg) {
292 const c = cockpitConfig(cfg)
293 const subs = subsOf(scan, cli).filter((x) => c.tree.show_done_agents || x.status === 'running')
294 const sort = (list) => [...list].sort((a, b) => ((b.status === 'running') - (a.status === 'running')) || String(b.ts || b.last_ts || '').localeCompare(String(a.ts || a.last_ts || '')))
295 const agent = (x) => {
296 const bill = billByModel([x.models], cfg)
297 return { kind: 'agent', id: x.id, conv: cli, title: `${x.type}${x.desc ? ' · ' + x.desc : ''}`, type: x.type, state: x.status, model: x.model,
298 effort: x.effort || '?', tokens: Object.values(bill).reduce((a, b) => a + b.tokens, 0), cost: sumCost(bill), children: [] }
299 }
300 const plain = sort(subs.filter((x) => !x.workflow)).slice(0, c.tree.max_agents).map(agent)
301 const wfIds = [...new Set(subs.filter((x) => x.workflow).map((x) => x.workflow))]
302 const wfs = wfIds.map((id) => {
303 const kids = sort(subs.filter((x) => x.workflow === id))
304 return { kind: 'workflow', id, conv: cli, title: `${c.labels.workflow} ${workflowName(scan, cli, id)}`, state: kids.some((k) => k.status === 'running') ? 'running' : 'done',
305 count: kids.length, children: kids.slice(0, c.tree.max_agents).map(agent), hidden: Math.max(0, kids.length - c.tree.max_agents) }
306 })
307 return { plain, workflows: wfs, hidden: Math.max(0, subs.filter((x) => !x.workflow).length - c.tree.max_agents) }
308}
309
310// The rows the pane draws (and the diagram), top to bottom, each with its depth and whether it is the selection.
311// opts.convOnly: only the lead tree holding nav.conv (lead → sub-conversations → Workflows → agents), no project row,
312// depth from 0: the « Cette conversation » section.
313export function treeRows(nav, scan, cfg, nowMs, self, opts = {}) {
314 const rows = []
315 const add = (r) => rows.push(r)
316 if (opts.convOnly) {
317 if (!nav.repo || !nav.conv) return rows
318 const forest = convForest(scan, nav.repo, cfg)
319 const has = (n) => n.id === nav.conv || n.children.some(has)
320 const root = forest.find(has)
321 if (!root) return rows
322 const sub = treeRows({ ...nav, level: nav.level === 'micro' ? 'micro' : 'medium' }, { ...scan }, cfg, nowMs, self, { only: root.id })
323 return sub.filter((r) => r.kind !== 'project').map((r) => ({ ...r, depth: r.depth - 1 }))
324 }
325 if (nav.level === 'mega' || !nav.repo) {
326 for (const p of projectsOf(scan, cfg, nowMs)) {
327 add({ key: 'p-' + p.repo, kind: 'project', id: p.repo, depth: 0, label: p.repo, tone: p.tone, selected: p.repo === nav.repo,
328 meta: projectMeta(p), counters: p })
329 }
330 return rows
331 }
332 const p = projectsOf(scan, cfg, nowMs).find((x) => x.repo === nav.repo)
333 add({ key: 'p-' + nav.repo, kind: 'project', id: nav.repo, depth: 0, label: nav.repo, tone: p ? p.tone : 'dim', selected: nav.level === 'macro', meta: p ? projectMeta(p) : '' })
334 const walk = (list, depth) => {
335 for (const n of list) {
336 const sel = nav.conv === n.id
337 add({ key: 'c-' + n.id, kind: 'conv', id: n.id, repo: n.repo, depth, label: n.title, tone: STATE_TONE[n.state] || 'dim', state: n.state,
338 selected: sel && nav.level === 'medium', onPath: sel, self: n.id === self,
339 meta: `${n.ownRepo !== n.repo ? n.ownRepo + ' · ' : ''}${short(n.model)} · ${n.effort} · ctx ${n.ctxPct}%${n.agents ? ` · agents ${n.running}/${n.agents}` : ''}` })
340 if (sel && LEVELS.indexOf(nav.level) >= 2) {
341 const a = agentNodes(scan, n.id, cfg)
342 for (const x of a.plain) add(agentRow(x, depth + 1, nav))
343 for (const w of a.workflows) {
344 add({ key: 'w-' + w.id, kind: 'workflow', id: w.id, conv: n.id, depth: depth + 1, label: w.title, tone: STATE_TONE[w.state], state: w.state, meta: `${w.count} agents`, selected: false })
345 for (const x of w.children) add(agentRow(x, depth + 2, nav))
346 }
347 }
348 walk(n.children, depth + 1)
349 }
350 }
351 walk(convForest(scan, nav.repo, cfg).filter((n) => !opts.only || n.id === opts.only), 1)
352 return rows
353}
354
355function agentRow(x, depth, nav) {
356 return { key: 'a-' + x.id, kind: 'agent', id: x.id, conv: x.conv, depth, label: x.title, tone: STATE_TONE[x.state] || 'dim', state: x.state,
357 selected: nav.level === 'micro' && nav.agent === x.id, meta: `${short(x.model)} · ${effortBars(x.effort)} ${x.effort} · ${fmtTokens(x.tokens)} tok · ${fmtUsd(x.cost)}` }
358}
359
360function projectMeta(p) {
361 const bits = [`${p.sessions} conv.`]
362 if (p.working) bits.push(`${p.working} en cours`)
363 if (p.julian) bits.push(`${p.julian} attend Julian`)
364 if (p.blocked) bits.push(`${p.blocked} bloquée(s)`)
365 if (p.workers) bits.push(`agents ${p.workers}`)
366 bits.push(fmtUsd(p.cost))
367 return bits.join(' · ')
368}
369
370// The text of one row as the terminal and the text render show it: indent, a tree elbow, a state dot.
371export function rowText(r) {
372 const pad = r.depth ? ' '.repeat(r.depth - 1) + '└ ' : ''
373 return `${pad}${r.kind === 'workflow' ? '◇' : r.kind === 'project' ? '■' : '●'} ${r.label}`
374}
375
376// ---- scan subsets for the factory screens ----------------------------------------------------------------------------
377
378// The same scan restricted to one project (and the lanes it spawned), so factory's Focus and Pipeline draw that
379// project only; the PR/MR reviews too, by repo name.
380export function scanForRepo(scan, repo) {
381 if (!scan || !repo) return scan
382 const sessions = projectSessions(scan, repo)
383 const repos = new Set(sessions.map(repoOf))
384 const reviews = scan.reviews && Array.isArray(scan.reviews.items)
385 ? { ...scan.reviews, items: scan.reviews.items.filter((it) => repos.has(String(it.repo || '').split('/').pop())) } : scan.reviews
386 return { ...scan, sessions, reviews }
387}
388
389// The Galerie's Lanes: the project's conversations with what lanesView needs (worktree from cwd, PR, state, agents).
390export function laneRows(scan, repo, cfg) {
391 if (!scan || !repo) return []
392 const states = ((screensConfig(cfg || {}).labels || {}).states) || {}
393 return projectSessions(scan, repo).map((s) => {
394 const st = sessionState(s, cfg || {}).state
395 return { cli: s.cli, title: titleOf(s), cwd: s.cwd || '', branch: s.branch || '', live: Boolean(s.live), state: states[st] || st,
396 last: tsMs(s.last_activity) || tsMs(s.mtime), subs: s.subs || [],
397 pr: s.pr && s.pr.number ? { number: s.pr.number, state: (s.ci || {}).state || '?', ci: (s.ci || {}).ci || '?' } : null }
398 })
399}
400
401// « Ouvrir l'agent »: only where the engine has $.ui.view (canView), for a running agent of this very conversation
402// (the engine answers « ended » or « elsewhere » for the others).
403export function canOpenAgent(card, self, canView) {
404 return Boolean(canView && card && self && card.conv === self && card.status === 'running')
405}
406
407// ---- micro: one agent ----------------------------------------------------------------------------------------------
408
409export function agentCard(scan, id, cfg) {
410 const x = agentOf(scan, id)
411 if (!x) return null
412 const bill = billByModel([x.models], cfg)
413 const tokens = Object.values(bill).reduce((a, b) => a + b.tokens, 0)
414 return {
415 id: x.id, conv: x.conv, type: x.type, desc: x.desc || '', status: x.status, model: x.model, explicit: x.explicit_model || null,
416 effort: x.effort || '?', tokens, cost: sumCost(bill), workflow: x.workflow || null, phase: x.phase || null,
417 ctx: x.ctx || 0, tools: x.tools || 0, readonly: x.readonly || 0, lastTool: x.last_tool || '', task: x.prompt || x.desc || '',
418 started: tsMs(x.ts), last: tsMs(x.last_ts), events: (x.events || []).map((e) => ({ at: tsMs(e[0]), kind: e[1], text: e[2] })),
419 hasLog: Array.isArray(x.events),
420 }
421}
422
423// ---- usage card ------------------------------------------------------------------------------------------------------
424
425export function inText(ms) {
426 const m = Math.max(0, Math.round(ms / 60000))
427 if (m < 60) return `${m} min`
428 const h = Math.floor(m / 60)
429 return h < 48 ? `${h} h ${String(m % 60).padStart(2, '0')}` : `${Math.floor(h / 24)} j`
430}
431
432export function atText(ms) {
433 const d = new Date(ms)
434 const days = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
435 return `${days[d.getDay()]} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
436}
437
438function creditsOf(spend, L, nowMs) {
439 if (!spend || !Number.isFinite(spend.percentUsed)) return { available: false, text: L.not_readable }
440 const num = (...ks) => { for (const k of ks) if (Number.isFinite(spend[k])) return spend[k]; return undefined }
441 const used = num('usedUsd', 'amountUsed')
442 const limit = num('limitUsd', 'amountLimit')
443 const exp = Date.parse(spend.expiresAt || '')
444 const reset = Date.parse(spend.resetsAt || '')
445 const when = Number.isFinite(reset) ? (reset - nowMs < 24 * 3600000 ? fill(L.reset_in, { in: inText(reset - nowMs) }) : fill(L.reset_at, { at: atText(reset) })) : ''
446 const amounts = Number.isFinite(used) && Number.isFinite(limit)
447 return {
448 available: true, pct: spend.percentUsed, used, limit, expiresAt: Number.isFinite(exp) ? exp : undefined, when,
449 text: amounts ? `${Math.round(used)} $ / ${Math.round(limit)} $` : `${Math.round(spend.percentUsed)} %`,
450 }
451}
452
453// usage: $.session.usage() ({ rateLimits, cost }); me: this conversation's row in the scan (models, subs).
454export function usageCard(usage, me, cfg, nowMs) {
455 const L = cockpitConfig(cfg).labels
456 const limits = (usage && usage.rateLimits) || []
457 const win = (kind, label, match) => {
458 const r = limits.find((x) => (match ? match.test(x.kind) : x.kind === kind))
459 if (!r || !Number.isFinite(r.percentUsed)) return { label, available: false, text: L.not_readable }
460 const reset = Date.parse(r.resetsAt || '')
461 const when = Number.isFinite(reset) ? (reset - nowMs < 24 * 3600000 ? fill(L.reset_in, { in: inText(reset - nowMs) }) : fill(L.reset_at, { at: atText(reset) })) : ''
462 return { label, available: true, pct: r.percentUsed, when }
463 }
464 const windows = [win('five_hour', L.session), win('seven_day', L.week_all), win(null, L.week_fable, /fable/i)]
465 const spend = limits.find((x) => x.kind === 'spend_limit')
466 // the engine's SessionRateLimit has kind / percentUsed / resetsAt only; amounts and an expiry are read when a later
467 // engine adds them (usedUsd | amountUsed, limitUsd | amountLimit, expiresAt), never guessed
468 const credits = creditsOf(spend, L, nowMs)
469 // this session: cost from the engine, model share, cache hit and contributors from the transcripts (factory's prices)
470 const maps = me ? [me.models, ...(me.subs || []).map((x) => x.models)] : []
471 const bill = billByModel(maps, cfg)
472 const fam = {}
473 let total = 0
474 for (const [m, b] of Object.entries(bill)) { const f = family(m); fam[f] = (fam[f] || 0) + b.cost; total += b.cost }
475 const share = ['opus', 'sonnet', 'haiku', 'fable'].filter((f) => fam[f]).map((f) => ({ family: f, pct: total ? Math.round((100 * fam[f]) / total) : 0 }))
476 let read = 0
477 let all = 0
478 for (const mp of maps) for (const v of Object.values(mp || {})) { read += v[2] || 0; all += (v[0] || 0) + (v[2] || 0) + (v[3] || 0) + (v[4] || 0) }
479 const byType = {}
480 if (me) byType[L.main_thread] = sumCost(billByModel([me.models], cfg))
481 for (const x of (me && me.subs) || []) byType[x.type] = (byType[x.type] || 0) + sumCost(billByModel([x.models], cfg))
482 const contributors = Object.entries(byType).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]).slice(0, 3)
483 .map(([type, cost]) => ({ type, cost, pct: total ? Math.round((100 * cost) / total) : 0 }))
484 const cost = usage && usage.cost && Number.isFinite(usage.cost.usd) ? usage.cost.usd : me ? total : null
485 return { windows, credits, session: { cost, costSource: usage && usage.cost ? 'engine' : 'transcripts', share, cacheHit: all ? Math.round((100 * read) / all) : null, contributors } }
486}
487
488export function usageLine(card) {
489 const s = card.session
490 const parts = []
491 if (s.cost !== null && s.cost !== undefined) parts.push(`${fmtUsd(s.cost)}`)
492 for (const x of s.share) parts.push(`${x.family[0].toUpperCase()}${x.family.slice(1)} ${x.pct} %`)
493 if (s.cacheHit !== null) parts.push(`cache ${s.cacheHit} %`)
494 return parts.join(' · ')
495}
496
497// ---- the four scoped sections: one-line summaries --------------------------------------------------------------------
498
499// One conversation's status: model · effort, ctx, state, its agents (running / all), the running Workflow, the
500// current step (last tool), its PR and CI.
501export function convHead(scan, cli, cfg, nowMs) {
502 const s = findSession(scan, cli)
503 if (!s) return null
504 const L = cockpitConfig(cfg).labels
505 const st = sessionState(s, cfg)
506 const a = agentNodes(scan, cli, cfg)
507 const all = [...a.plain, ...a.workflows.flatMap((w) => w.children)]
508 const wf = a.workflows.find((w) => w.state === 'running')
509 const ci = s.ci || {}
510 const d = scan && scan.detail && scan.detail.cli === cli ? scan.detail : s
511 return {
512 title: titleOf(s), model: short(s.app_model || s.model), effort: s.app_effort || s.effort || '?', ctxPct: ctxPct(s.ctx, cfg),
513 state: st.state, stateLabel: (L.states || {})[st.state] || st.state, tone: STATE_TONE[st.state] || 'dim', reason: st.reason,
514 agents: { running: all.filter((x) => x.state === 'running').length, total: all.length },
515 workflow: wf ? wf.title : '', step: d.last_tool || '',
516 pr: s.pr ? { number: s.pr.number, url: s.pr.url || '', state: ci.state || '?', ci: ci.ci || '?' } : null,
517 }
518}
519
520export function convSummary(h, cfg) {
521 if (!h) return ''
522 const bits = [`${h.model} · ${h.effort}`, `ctx ${h.ctxPct} %`, h.stateLabel]
523 if (h.agents.total) bits.push(`agents ${h.agents.running}/${h.agents.total}`)
524 if (h.pr) bits.push(`PR #${h.pr.number}`)
525 return bits.join(' · ')
526}
527
528// The project's line: « 4 conv · 1 en cours · $7.00 aujourd'hui »
529export function projectSummary(p, cfg) {
530 if (!p) return ''
531 const L = cockpitConfig(cfg).labels
532 const bits = [`${p.sessions} conv`]
533 if (p.working) bits.push(`${p.working} ${L.states.working}`)
534 if (p.julian) bits.push(`${p.julian} ${L.states.julian}`)
535 if (p.blocked) bits.push(`${p.blocked} ${L.states.blocked}`)
536 bits.push(`${fmtUsd(p.cost)} ${L.today}`)
537 return bits.join(' · ')
538}
539
540// Every project: « 2 en cours · 3 au repos · 1 attend Julian · $424 »
541export function allCounters(projects) {
542 const sum = (k) => projects.reduce((a, p) => a + (p[k] || 0), 0)
543 return { working: sum('working'), idle: sum('idle'), julian: sum('julian'), blocked: sum('blocked'), done: sum('done'), cost: sum('cost'), projects: projects.length }
544}
545
546export function allSummary(projects, cfg) {
547 const L = cockpitConfig(cfg).labels
548 const c = allCounters(projects)
549 const bits = [`${c.working} ${L.states.working}`, `${c.idle} ${L.states.idle}`]
550 if (c.julian) bits.push(`${c.julian} ${L.states.julian}`)
551 if (c.blocked) bits.push(`${c.blocked} ${L.states.blocked}`)
552 bits.push(fmtUsd(c.cost))
553 return bits.join(' · ')
554}
555
556// A project's second line in « Tous les projets »: counters only, no prose.
557export function projectLine(p, cfg) {
558 const L = cockpitConfig(cfg).labels
559 const bits = [`${p.sessions} conv`]
560 if (p.working) bits.push(`${p.working} ${L.states.working}`)
561 if (p.julian) bits.push(`${p.julian} ${L.states.julian}`)
562 if (p.blocked) bits.push(`${p.blocked} ${L.states.blocked}`)
563 if (p.workers) bits.push(`agents ${p.workers}`)
564 if (p.maxCtxPct) bits.push(`ctx max ${p.maxCtxPct} %`)
565 return bits.join(' · ')
566}
567
568// Claude global: « 5 h 24 % · sem 14 % · 2 alertes »
569// « Qui consomme »: the top conversations and the top project of one preset, from the usage mod's classement.json
570// (lib/usage/lib/classement.mjs rank, the same figures as /conso classement). null without data.
571export function consoCard(data, cfg, nowMs, presetId) {
572 if (!data || !Array.isArray(data.rows)) return null
573 const q = cockpitConfig(cfg).qui_consomme
574 const preset = q.presets.find((p) => p.id === presetId) || q.presets.find((p) => p.id === q.default) || q.presets[0]
575 const basis = q.basis === 'tokens' ? 'tokens' : 'cost'
576 const convs = rank(data, { now: nowMs, preset, group: 'conversation', top: q.top, basis })
577 const proj = rank(data, { now: nowMs, preset, group: 'project', top: 1, basis })
578 const p = proj.rows[0]
579 return {
580 preset, presets: q.presets, total: { tokens: tokensOf(convs.total), cost: convs.total[7] },
581 rows: convs.rows.map((x) => ({ id: x.id, label: x.label, project: x.project, tokens: tokensOf(x.v), cost: x.v[7], pct: Math.round(100 * x.share) })),
582 project: p ? { label: p.label, tokens: tokensOf(p.v), cost: p.v[7], pct: Math.round(100 * p.share) } : null,
583 generated: data.generated || 0,
584 }
585}
586
587export function consoSummary(card) {
588 if (!card) return ''
589 const bits = [`${card.preset.label} ${fmtUsd(card.total.cost)}`]
590 if (card.rows[0]) bits.push(`${short(card.rows[0].label, 28)} ${card.rows[0].pct} %`)
591 if (card.project) bits.push(`${card.project.label} ${card.project.pct} %`)
592 return bits.join(' · ')
593}
594
595export function globalSummary(card, veille) {
596 const bits = []
597 if (card) for (const [w, k] of [[card.windows[0], '5 h'], [card.windows[1], 'sem']]) if (w && w.available) bits.push(`${k} ${Math.round(w.pct)} %`)
598 if (veille) {
599 const n = veille.alerts.filter((a) => !a.acked).length
600 bits.push(n ? `${n} alerte(s)` : 'aucune alerte')
601 }
602 return bits.join(' · ')
603}
604
605// The tabs shown, in order: `order` filtered by `enabled`; unknown ids dropped; « home » always first.
606export function tabsOf(cfg) {
607 const c = cockpitConfig(cfg).tabs
608 const ids = c.order.filter((t) => TABS.includes(t) && (t === 'home' || c.enabled.includes(t)))
609 return (ids.includes('home') ? ids : ['home', ...ids]).map((id) => ({ id, label: (c.labels || {})[id] || id }))
610}
611
612// A tab id the pane can show: one of the tab bar, or the Nuit screen (opened by its button) while enabled.
613export function tabValid(cfg, id) {
614 const c = cockpitConfig(cfg).tabs
615 const dep = (cfg && cfg.depuis) || {}
616 if (id === 'panneau') return !(cfg && cfg.panel && cfg.panel.enabled === false)
617 return tabsOf(cfg).some((t) => t.id === id) || (id === 'nuit' && c.enabled.includes('nuit')) || (id === 'depuis' && dep.enabled !== false)
618}
619
620// The menu's layout at a width: {position: left|top|off, items, width}.
621export function menuOf(cfg, columns) {
622 const m = cockpitConfig(cfg).menu || {}
623 const items = (m.items || []).filter((x) => x && x.label && (x.self || x.command))
624 if (m.position === 'off' || !items.length) return { position: 'off', items: [], width: 0 }
625 const left = m.position === 'left' && (!columns || columns >= (Number(m.min_columns) || 0))
626 return { position: left ? 'left' : 'top', items, width: left ? Math.max(8, Number(m.width) || 12) : 0 }
627}
628
629// The folders whose handoff/ROADMAP.md and packets « Bilan avant la nuit » reads: the recent conversations' folders
630// (not archived, active in the last recent_days) and day_night.repos.
631export function nightRepos(scan, cfg, nowMs) {
632 const c = cockpitConfig(cfg).day_night || {}
633 const since = nowMs - (Number(c.recent_days) || 7) * 86400000
634 const out = []
635 for (const s of (scan && scan.sessions) || []) {
636 if (s.archived) continue
637 if (tsMs(s.last_activity) < since) continue
638 const p = String(s.origin || s.cwd || '').replace(/\\/g, '/')
639 if (p) out.push(p)
640 }
641 for (const p of c.repos || []) if (p) out.push(String(p))
642 return [...new Set(out)]
643}
644
645const TAB_WORDS = {
646 panneau: 'panneau', panel: 'panneau', un: 'panneau',
647 home: 'home', accueil: 'home', cartes: 'home', cockpit: 'home',
648 mission: 'mission', focus: 'focus', arbre: 'arbre', tree: 'arbre', pipeline: 'pipeline', journal: 'journal',
649 workflow: 'workflow', workflows: 'workflow', wf: 'workflow',
650 machine: 'machine', systeme: 'machine', 'système': 'machine', system: 'machine',
651 depuis: 'depuis', since: 'depuis', matin: 'depuis', reveil: 'depuis', 'réveil': 'depuis', resume: 'depuis', 'résumé': 'depuis',
652 nuit: 'nuit', night: 'nuit', bilan: 'nuit', relais: 'relais', codex: 'relais', galerie: 'galerie', gallery: 'galerie', vues: 'galerie',
653}
654const LEVEL_WORDS = { mega: 'mega', 'méga': 'mega', macro: 'macro', projet: 'macro', project: 'macro', medium: 'medium', conversation: 'medium', conv: 'medium', micro: 'micro', agent: 'micro' }
655const SCOPE_WORDS = { all: 'all', tout: 'all', toutes: 'all', conv: 'conversation', conversation: 'conversation' }
656
657/**
658 * "/cockpit [level|tab] [rest]" -> {level, tab, scope, rest, close}. A level opens the « Cockpit » tab at that zoom;
659 * a tab word opens that tab (« workflow all » = every Workflow of the PC); the rest names a project or a conversation.
660 */
661export function parseArgs(args) {
662 const words = String(args || '').trim().split(/\s+/).filter(Boolean)
663 const w0 = (words[0] || '').toLowerCase()
664 if (w0 === 'close' || w0 === 'fermer') return { close: true, level: null, tab: null, scope: null, rest: '' }
665 const level = LEVEL_WORDS[w0] || null
666 const tab = level ? 'home' : TAB_WORDS[w0] || null
667 let rest = words.slice(level || tab ? 1 : 0)
668 let scope = null
669 if (tab === 'workflow' && rest[0] && SCOPE_WORDS[rest[0].toLowerCase()]) { scope = SCOPE_WORDS[rest[0].toLowerCase()]; rest = rest.slice(1) }
670 return { close: false, level, tab, scope, rest: rest.join(' ') }
671}
672
673/**
674 * The old commands, kept as aliases while their mod is not loaded: /factory [tab] [...] and /machine [...] as cockpit
675 * args. factory's quotas tab -> home (the Quota card), systeme -> machine.
676 */
677export function aliasArgs(name, args) {
678 const q = String(args || '').trim()
679 if (name === 'machine') return /^close$/i.test(q) ? 'close' : 'machine'
680 const [w, ...more] = q.split(/\s+/).filter(Boolean)
681 const lw = (w || '').toLowerCase()
682 if (!lw) return 'mission'
683 if (lw === 'quotas') return 'home'
684 return [lw === 'systeme' || lw === 'système' ? 'machine' : lw, ...more].join(' ')
685}
686
687// Which section a click opens: a project its « Ce projet », a conversation, workflow or agent « Cette conversation ».
688export function sectionFor(node) {
689 return !node ? null : node.kind === 'project' ? 'project' : 'conv'
690}
691
692export { hhmm, fmtTokens, fmtUsd, short, effortBars, titleOf, repoOf, tokensOf }
693hooks/view.mjs 365 lines1// cockpit - draws the one side pane, top to bottom at the pane's width, from native elements (Box, Text, Button, Svg
2// where the surface has it). Pure except for the element factory `el` and the callbacks in V.act.
3// Order by scope: a small breadcrumb, then four foldable sections, each a one-line summary while closed:
4// ▾ Cette conversation (open) status line, its tree lead → sub-conversations → Workflows → agents, step, PR/CI
5// ▸ Ce projet project tree + factory's Focus and Pipeline (lib/factory/hooks/draw.mjs drawScreen)
6// ▸ Tous les projets one or two lines per project
7// ▸ Qui consomme top 3 conversations + top project of a window (usage's classement.json), full view button
8// ▸ Claude global quotas (band's bar, lib/band/hooks/gauge.ts), Veille alerts, a /machine button
9import { drawScreen } from '../lib/factory/hooks/draw.mjs'
10import { drawWorkflowBody } from '../lib/factory/hooks/workflow_draw.mjs'
11import { layout, clip } from '../lib/factory/hooks/screens.mjs'
12import { barSvg } from '../lib/band/hooks/gauge.ts'
13import {
14 cockpitConfig, sectionsOf, crumbs, treeRows, scanForRepo, agentCard, canOpenAgent, usageLine, fill, fmtTokens, fmtUsd, short, effortBars, hhmm, LEVELS,
15 projectsOf, convHead, convSummary, projectSummary, allSummary, projectLine, globalSummary, findSession, repoOf, consoSummary, tabsOf, menuOf, tabValid,
16} from './nav.mjs'
17import { treeSvg } from './svg.mjs'
18import { drawNuit, drawRelais, drawGalerie } from './decide_view.mjs'
19import { drawDepuis } from './depuis_view.mjs'
20import { depuisConfig } from '../lib/depuis.mjs'
21import { drawPanel } from './panel_view.mjs'
22import { panelConfig, otherViews } from '../lib/panel.mjs'
23
24const NOOP = () => {}
25
26export function kit(el, cfg) {
27 const c = cockpitConfig(cfg)
28 const fc = (k) => (c.term_colors && c.term_colors[k]) || { running: 'claude', ok: 'success', warn: 'warning', bad: 'error', dim: 'inactive' }[k]
29 const T = (text, o = {}) => {
30 const p = { children: [String(text ?? '')], wrap: o.wrap || 'wrap' }
31 if (o.tone && fc(o.tone)) p.color = fc(o.tone)
32 else if (o.color) p.color = o.color
33 if (o.bold) p.bold = true
34 if (o.dim) p.dimColor = true
35 if (o.size) p.size = o.size
36 if (o.key) p.key = o.key
37 return el.Text(p)
38 }
39 const row = (parts, o = {}) => el.Box({ flexDirection: 'row', flexWrap: 'wrap', gap: 1, ...o, children: parts.filter(Boolean) })
40 const col = (parts, o = {}) => el.Box({ flexDirection: 'column', ...o, children: parts.filter(Boolean) })
41 const box = (title, parts, o = {}) => el.Box({
42 flexDirection: 'column', borderStyle: 'round', paddingX: 1, marginBottom: 1, ...(o.key ? { key: o.key } : {}),
43 ...(o.tone && fc(o.tone) ? { borderColor: fc(o.tone) } : {}),
44 children: [title ? T(title, { bold: true }) : null, ...parts].filter(Boolean),
45 })
46 const btn = (label, onPress, primary, key) => el.Button({ children: [label], variant: primary ? 'primary' : 'secondary', onPress, ...(key ? { key } : {}) })
47 // a Button whose label is a styled Text (a section title: bold, heading size where the surface has sizes)
48 const tbtn = (label, onPress, key, o = {}) => el.Button({ label, onPress, ...(o.plain ? { plain: true } : {}), ...(key ? { key } : {}),
49 children: [T(label, { bold: o.bold, dim: o.dim, tone: o.tone, color: o.color, size: o.size, wrap: 'truncate-end' })] })
50 const svg = (source, alt, key) => (el.Svg ? el.Svg({ source, alt, ...(key ? { key } : {}) }) : null)
51 // a label on the left, a number on the right (padded so digits line up)
52 const lr = (left, right, o = {}) => row([T(left, { bold: o.bold, dim: o.dimLeft, wrap: 'truncate-end' }), T(right, { dim: o.dimRight, tone: o.tone })],
53 { justifyContent: 'space-between', flexWrap: 'nowrap', ...(o.key ? { key: o.key } : {}) })
54 return { T, row, col, box, btn, tbtn, svg, lr, c }
55}
56
57// V = { cfg, fcfg, scan, nav, self, busy, error, at, columns, filter, usage, veille, open, act, tab, cards, wf…, machine,
58// who, nuitData, relaisData, galerieData, galView, launch, nuitMsg, teamSession, teamTitle, now }
59// The vertical menu (the band's 6 entries) sits at the left; under menu.min_columns it is one row on top.
60export function drawCockpit(el, V0) {
61 const h0 = kit(el, V0.cfg)
62 // the Panneau (default view) takes the whole width: the band's buttons above the prompt replace the left menu
63 const menu = V0.tab === 'panneau' ? { position: 'off', items: [], width: 0 } : menuOf(V0.cfg, V0.columns)
64 const V = { ...V0, el, columns: menu.position === 'left' && V0.columns ? Math.max(30, V0.columns - menu.width - 1) : V0.columns }
65 const body = drawBody(el, V)
66 if (menu.position === 'off') return body
67 const btns = menu.items.map((m, i) => h0.btn(m.label, () => V.act.menu(m), Boolean(m.self), 'menu-' + i))
68 if (menu.position === 'top') return h0.col([h0.row(btns, { key: 'menu', marginBottom: 1 }), body])
69 return el.Box({ flexDirection: 'row', flexWrap: 'nowrap', gap: 1, children: [
70 el.Box({ flexDirection: 'column', width: menu.width, flexShrink: 0, key: 'menu', children: btns }),
71 el.Box({ flexDirection: 'column', flexGrow: 1, flexShrink: 1, key: 'body', children: [body] }),
72 ] })
73}
74
75function drawBody(el, V) {
76 const h = kit(el, V.cfg)
77 const L = h.c.labels
78 const tabs = tabsOf(V.cfg)
79 const pc = panelConfig(V.cfg)
80 const tab = tabs.some((t) => t.id === V.tab) || V.tab === 'nuit' || V.tab === 'depuis' || (V.tab === 'panneau' && pc.enabled !== false) ? V.tab : 'home'
81 // The Panneau is one scrolling column with no tab bar at all (Julian, 17 Oct): the old views are reached by their
82 // /cockpit command only, listed in one grey line at its bottom. On an old view the tab row shows, « Panneau » first.
83 if (tab === 'panneau') {
84 const extra = [tabValid(V.cfg, 'depuis') ? 'depuis' : '', tabValid(V.cfg, 'nuit') ? 'nuit' : ''].filter(Boolean)
85 const out = drawPanel(h, { now: V.at, ...V.panel, cfg: V.cfg, columns: V.columns, otherViews: otherViews(tabs, extra, V.cfg), colors: { track: '#8b949e33', ...(h.c.colors || {}) } })
86 if (V.error) out.push(h.T(`scanner : ${V.error}`, { tone: 'bad', key: 'scan-error' }))
87 return h.col(out)
88 }
89 const out = [h.row([
90 pc.enabled !== false ? h.btn(pc.labels.panneau, () => V.act.tab('panneau'), false, 'tab-panneau') : null,
91 ...tabs.map((t) => h.btn(t.label, () => V.act.tab(t.id), t.id === tab, 'tab-' + t.id)),
92 ], { key: 'tabs', marginBottom: 1 })]
93 if (tab === 'nuit') return h.col([...out, ...drawNuit(h, V)])
94 if (tab === 'depuis') return h.col([...out, ...drawDepuis(h, V)])
95 if (tab === 'relais') return h.col([...out, ...drawRelais(h, V)])
96 if (tab === 'galerie') return h.col([...out, ...drawGalerie(h, V)])
97 if (tab !== 'home') return h.col([...out, ...drawTab(h, V, L, tab)])
98 // Nuit · Jour · Codex: the day/night decision and the Claude-only default of the cards
99 const dn = h.c.day_night || {}
100 const dc = depuisConfig(V.cfg)
101 if (dc.enabled !== false) out.push(h.btn(dc.labels.button, () => V.act.tab('depuis'), true, 'btn-depuis'))
102 out.push(h.row([
103 dn.enabled !== false ? h.btn(L.night_button, () => V.act.tab('nuit'), false, 'btn-nuit') : null,
104 dn.enabled !== false ? h.btn(L.day_button, () => V.act.day(), false, 'btn-jour') : null,
105 h.btn(V.who === 'all' ? L.codex_on : L.codex_off, () => V.act.who(V.who === 'all' ? 'claude' : 'all'), V.who === 'all', 'btn-codex'),
106 ], { key: 'day-night', marginBottom: 1 }))
107 const secs = sectionsOf(V.cfg)
108 if (secs[0] !== 'breadcrumb') out.push(drawCards(h, V, L))
109 for (const s of secs) {
110 const part = { breadcrumb: drawCrumbs, conv: drawConv, project: drawProject, all: drawAll, conso: drawConso, global: drawGlobal, fichiers: drawFiles }[s](h, V, L)
111 if (part) out.push(part)
112 if (s === 'breadcrumb') out.push(drawCards(h, V, L)) // the cards right under the breadcrumb, above the sections
113 }
114 if (V.error) out.push(h.T(`scanner : ${V.error}`, { tone: 'bad', key: 'scan-error' }))
115 return h.col(out)
116}
117
118// ---- 0. the summary cards: one line each, a button to the detail ----------------------------------------------------
119
120function drawCards(h, V, L) {
121 const list = V.cards || []
122 if (!list.length) return null
123 const rows = list.map((c) => h.col([
124 h.row([
125 h.T(c.title, { bold: true, wrap: 'truncate-end' }),
126 h.btn(L.card_open || 'Voir', () => V.act.card(c), false, 'card-btn-' + c.id),
127 ], { justifyContent: 'space-between', alignItems: 'center', flexWrap: 'nowrap' }),
128 h.T(c.line, { tone: c.tone || undefined, dim: c.tone === 'dim', wrap: 'wrap', key: 'card-line-' + c.id }),
129 ...(c.items || []).map((it, i) => h.T(`• ${it.text}`, { dim: true, key: `card-${c.id}-${i}`, wrap: 'wrap' })),
130 c.more ? h.T(`+ ${c.more}`, { dim: true, key: `card-${c.id}-more` }) : null,
131 ], { key: 'card-' + c.id, marginBottom: 1 }))
132 if (V.cardsError) rows.push(h.T(`cartes : ${V.cardsError}`, { tone: 'bad', key: 'cards-error' }))
133 return h.box(null, rows, { key: 'cards' })
134}
135
136// ---- factory's screens and Machine, as tabs of the same pane --------------------------------------------------------
137
138function drawTab(h, V, L, tab) {
139 const head = h.row([h.btn((tab === 'workflow' ? V.wfBusy : V.busy) ? '…' : '↻', () => V.act.refresh(), false, 'tab-refresh'),
140 h.T(V.at ? hhmm(tab === 'workflow' ? V.wfAt || V.at : V.at).slice(0, 5) : '--:--', { dim: true, key: 'tab-time' })], { key: 'tab-head', alignItems: 'center', marginBottom: 1 })
141 const base = { cfg: V.fcfg, at: V.at, busy: V.busy, error: V.error, columns: V.columns, filter: V.filter || 'all', jfilter: V.jfilter || 'all', jproject: V.jproject || '',
142 picking: false, live: [], quotas: null, machine: V.machine || {}, embedded: true, target: V.nav && V.nav.conv, self: V.self, scan: V.scan,
143 canView: V.canView, viewAgent: V.viewAgent,
144 wf: V.wf, wfAt: V.wfAt, wfBusy: V.wfBusy, wfError: V.wfError, wfScope: V.wfScope, wfOpen: V.wfOpen,
145 act: { tab: (id) => V.act.tab(id), go: (cli) => V.act.go(cli), refresh: () => V.act.refresh(), filter: (k) => V.act.filter(k), jfilter: (k) => V.act.jfilter(k),
146 jproject: (p) => V.act.jproject(p), pick: () => V.act.pick(), wfScope: (k) => V.act.wfScope(k), wfToggle: (k, o) => V.act.wfToggle(k, o),
147 openAgent: (id) => V.act.openAgent(id), backToConv: () => V.act.backToConv() } }
148 if (tab === 'workflow') return [head, ...drawWorkflowBody(V.el, base)]
149 if (tab === 'machine') {
150 const m = V.machine || {}
151 const body = m.sample ? drawScreen(V.el, { ...base, scan: V.scan || { sessions: [] } }, 'systeme') : [h.T(m.error ? `machine : ${m.error}` : L.scanning, { tone: m.error ? 'bad' : undefined, dim: !m.error, key: 'm-wait' })]
152 return [head, ...body]
153 }
154 if (!V.scan) return [head, V.error ? h.T(`scanner : ${V.error}`, { tone: 'bad', key: 'tab-err' }) : null, h.T(L.scanning, { dim: true, key: 'tab-scan' })].filter(Boolean)
155 return [head, ...drawScreen(V.el, base, tab)]
156}
157
158// One small row: Méga › projet › conversation › agent, then refresh and the time.
159function drawCrumbs(h, V, L) {
160 const list = crumbs(V.nav, V.scan, V.cfg)
161 const parts = []
162 list.forEach((cr, i) => {
163 if (i) parts.push(h.T('›', { dim: true, key: 'sep' + i }))
164 parts.push(h.btn(clip(cr.label, 24), () => V.act.crumb(cr.level), cr.current, 'crumb-' + cr.level))
165 })
166 parts.push(h.btn(V.busy ? '…' : '↻', () => V.act.refresh(), false, 'refresh'))
167 parts.push(h.T(V.at ? hhmm(V.at).slice(0, 5) : '--:--', { dim: true, key: 'time' }))
168 return h.row(parts, { key: 'breadcrumb', alignItems: 'center', marginBottom: 1 })
169}
170
171// A foldable section: one header button (▸ closed, ▾ open) and its one-line summary; the body only when open.
172// The summary shows only while closed (open, the body says it); it is clipped to stay on the header's line.
173function section(h, V, id, title, summary, body) {
174 const open = Boolean(V.open && V.open[id])
175 const label = `${open ? '▾' : '▸'} ${title}`
176 const room = (V.columns || 75) - label.length - 6
177 return h.col([
178 h.row([h.btn(label, () => V.act.toggle(id), false, 'sec-' + id), summary && !open && room > 8 ? h.T(clip(summary, room), { dim: true, wrap: 'truncate-end' }) : null],
179 { alignItems: 'center', flexWrap: 'nowrap' }),
180 open ? h.col(body(), { marginLeft: 1 }) : null,
181 ], { key: 'sec-' + id, marginBottom: 1 })
182}
183
184function treeItems(h, V, L, rows) {
185 const lay = layout(V.columns, V.fcfg)
186 return rows.map((r) => {
187 const indent = r.depth ? 2 * r.depth : 0
188 const mark = r.kind === 'workflow' ? '◇' : r.kind === 'project' ? '■' : '●'
189 return h.col([
190 h.row([
191 r.depth ? h.T('└', { dim: true }) : null,
192 h.T(mark, { tone: r.tone }),
193 h.btn(clip(`${r.label}${r.self ? ` (${L.this_conv})` : ''}`, Math.max(16, lay.textMax - 8 - indent)), () => V.act.select(r), r.selected, 'node-' + r.key),
194 ], { alignItems: 'center', flexWrap: 'nowrap' }),
195 r.meta ? h.col([h.T(r.meta, { dim: true, wrap: 'truncate-end' })], { marginLeft: r.depth ? 4 : 2 }) : null,
196 ], { key: 'row-' + r.key, ...(indent ? { marginLeft: indent } : {}) })
197 })
198}
199
200// ---- 1. Cette conversation --------------------------------------------------------------------------------------------
201
202function drawConv(h, V, L) {
203 const cli = V.nav.conv || V.self
204 const head = V.scan && cli ? convHead(V.scan, cli, V.fcfg, V.at) : null
205 const mine = cli && cli === V.self
206 const title = mine ? L.sec_conv : `${L.sec_conv_other} · ${clip(head ? head.title : String(cli || ''), 28)}`
207 return section(h, V, 'conv', title, head ? convSummary(head, V.fcfg) : (V.busy ? L.scanning : L.no_convs), () => {
208 if (!head) return [h.T(V.busy ? L.scanning : L.no_convs, { dim: true })]
209 const out = [
210 h.row([h.T(head.model, { bold: true }), h.T(`${effortBars(head.effort)} ${head.effort}`), h.T(`ctx ${head.ctxPct} %`), h.T(head.stateLabel, { tone: head.tone, bold: true })], { key: 'cv-status' }),
211 ]
212 if (head.reason) out.push(h.T(head.reason, { tone: head.tone, key: 'cv-reason', wrap: 'truncate-end' }))
213 const s0 = findSession(V.scan, cli)
214 const nav = V.nav.conv ? V.nav : { ...V.nav, conv: cli, repo: V.nav.repo || (s0 ? repoOf(s0) : null) }
215 const all = treeRows({ ...nav, level: LEVELS.indexOf(nav.level) >= 2 ? nav.level : 'medium' }, V.scan, V.fcfg, V.at, V.self, { convOnly: true })
216 // finished agents and Workflows fold into one line unless asked (a button), so the running work stays in view
217 const showDone = Boolean(V.open && V.open.conv_done)
218 const keep = (r) => r.kind === 'conv' || r.state === 'running' || r.selected
219 const rows = showDone ? all : all.filter(keep)
220 const hidden = all.length - rows.length
221 out.push(...treeItems(h, V, L, rows))
222 if (hidden || showDone) out.push(h.btn(showDone ? L.hide_done : fill(L.show_done, { n: hidden }), () => V.act.toggle('conv_done'), false, 'conv-done'))
223 const bits = []
224 if (head.workflow) bits.push(head.workflow)
225 if (head.step) bits.push(`${L.step} : ${head.step}`)
226 if (bits.length) out.push(h.T(bits.join(' · '), { dim: true, key: 'cv-step', wrap: 'truncate-end' }))
227 out.push(head.pr
228 ? h.T(`PR #${head.pr.number} · ${head.pr.state} · CI ${head.pr.ci}`, { key: 'cv-pr', tone: head.pr.ci === 'failed' ? 'bad' : head.pr.ci === 'success' ? 'ok' : undefined })
229 : h.T(L.no_pr, { dim: true, key: 'cv-pr' }))
230 if (V.nav.level === 'micro') out.push(...drawAgent(h, V, L))
231 return out
232 })
233}
234
235function drawAgent(h, V, L) {
236 const a = agentCard(V.scan, V.nav.agent, V.fcfg)
237 if (!a) return [h.T(L.agent_none, { dim: true, key: 'agent-none' })]
238 const lay = layout(V.columns, V.fcfg)
239 const tone = { running: 'running', done: 'ok', failed: 'bad', stopped: 'dim' }[a.status] || 'dim'
240 const card = h.box(`${L.agent_title} · ${a.type}`, [
241 a.desc ? h.T(a.desc) : null,
242 h.row([h.T(short(a.model), { bold: true }), h.T(`effort ${effortBars(a.effort)} ${a.effort}`), h.T(a.status, { tone, bold: true }),
243 a.explicit ? null : h.T('modèle non précisé (hérité)', { tone: 'warn' })]),
244 h.row([h.T(`${fmtTokens(a.tokens)} tok`), h.T(fmtUsd(a.cost)), h.T(`ctx ${fmtTokens(a.ctx)}`, { dim: true }),
245 h.T(`outils ${a.tools} (lecture ${a.readonly})`, { dim: true })]),
246 a.workflow ? h.T(`workflow ${a.workflow}${a.phase ? ' · phase ' + a.phase : ''}`, { dim: true }) : null,
247 a.started ? h.T(`début ${hhmm(a.started).slice(0, 5)}${a.last ? ' · dernier ' + hhmm(a.last).slice(0, 5) : ''}${a.lastTool ? ' · ' + a.lastTool : ''}`, { dim: true }) : null,
248 a.task ? h.T(clip(a.task, lay.textMax * 3), { dim: true }) : null,
249 ], { key: 'agent-card', tone })
250 // « Ouvrir l'agent »: the engine's own transcript view of this agent ($.ui.view, 2.1.300+), and the way back
251 const nav = h.row([
252 canOpenAgent(a, V.self, V.canView) && V.viewAgent !== a.id ? h.btn(L.open_agent, () => V.act.openAgent(a.id), true, 'agent-open') : null,
253 V.canView && V.viewAgent ? h.btn(L.back_conv, () => V.act.backToConv(), false, 'agent-back') : null,
254 ], { key: 'agent-nav' })
255 const log = h.box(L.agent_log, a.events.length
256 ? a.events.map((e, i) => h.row([h.T(hhmm(e.at).slice(0, 5), { dim: true }), h.T(e.kind, { bold: e.kind === 'prompt' }), h.T(clip(e.text, lay.textMax - 16))], { key: 'ae' + i, flexWrap: 'nowrap' }))
257 : [h.T(a.hasLog ? '—' : L.scanning, { dim: true })], { key: 'agent-log' })
258 return [card, nav, log]
259}
260
261// ---- 2. Ce projet -----------------------------------------------------------------------------------------------------
262
263function drawProject(h, V, L) {
264 const repo = V.nav.repo
265 const p = V.scan && repo ? projectsOf(V.scan, V.fcfg, V.at).find((x) => x.repo === repo) : null
266 return section(h, V, 'project', repo ? `${L.sec_project} · ${clip(repo, 24)}` : L.sec_project, p ? projectSummary(p, V.fcfg) : '', () => {
267 if (!V.scan || !repo) return [h.T(V.busy ? L.scanning : L.no_convs, { dim: true })]
268 const nav = { ...V.nav, level: V.nav.level === 'mega' ? 'macro' : V.nav.level }
269 const rows = treeRows({ ...nav, level: 'macro' }, V.scan, V.fcfg, V.at, V.self).filter((r) => r.kind !== 'project')
270 const out = [...treeItems(h, V, L, rows.map((r) => ({ ...r, depth: r.depth - 1 })))]
271 const c = h.c
272 if (c.diagram.enabled && V.el.Svg && rows.length) {
273 const alt = `${L.diagram_alt} : ${rows.map((r) => `${' '.repeat(r.depth)}${r.label}`).join(' ; ')}`.slice(0, 2000)
274 out.push(h.svg(treeSvg(rows, c.colors, c.diagram), alt, 'diagram-svg'))
275 }
276 const base = { cfg: V.fcfg, at: V.at, busy: V.busy, error: '', columns: V.columns, filter: V.filter || 'all', jfilter: 'all', jproject: '',
277 picking: false, live: [], quotas: null, machine: {}, embedded: true, target: V.nav.conv,
278 act: { tab: NOOP, go: (cli) => V.act.select({ kind: 'conv', id: cli }), refresh: () => V.act.refresh(), filter: (k) => V.act.filter(k), jfilter: NOOP, jproject: NOOP, pick: NOOP } }
279 const s = scanForRepo(V.scan, repo)
280 out.push(...drawScreen(V.el, { ...base, scan: s }, 'focus'), ...drawScreen(V.el, { ...base, scan: s }, 'pipeline'))
281 return out
282 })
283}
284
285// ---- 3. Tous les projets ----------------------------------------------------------------------------------------------
286
287function drawAll(h, V, L) {
288 const ps = V.scan ? projectsOf(V.scan, V.fcfg, V.at) : []
289 return section(h, V, 'all', L.sec_all, ps.length ? allSummary(ps, V.fcfg) : '', () => {
290 if (!ps.length) return [h.T(V.busy ? L.scanning : L.no_convs, { dim: true })]
291 const w = Math.max(...ps.map((p) => fmtUsd(p.cost).length))
292 return ps.map((p) => h.col([
293 h.row([
294 h.row([h.T('■', { tone: p.tone }), h.btn(clip(p.repo, 30), () => V.act.select({ kind: 'project', id: p.repo }), p.repo === V.nav.repo, 'proj-' + p.repo)], { alignItems: 'center', flexWrap: 'nowrap' }),
295 h.T(fmtUsd(p.cost).padStart(w), { dim: true }),
296 ], { justifyContent: 'space-between', alignItems: 'center', flexWrap: 'nowrap' }),
297 h.T(projectLine(p, V.fcfg), { dim: true, wrap: 'truncate-end' }),
298 ], { key: 'pl-' + p.repo, marginLeft: 1 }))
299 })
300}
301
302// ---- 4. Claude global -------------------------------------------------------------------------------------------------
303
304function bar(h, V, pct, key, width) {
305 const c = h.c
306 const s = h.svg(barSvg(pct, c.colors.usage, width || c.usage_bar_width || 160), `${Math.round(pct)} %`, key)
307 if (s) return s
308 const w = 12
309 const full = Math.round((Math.max(0, Math.min(100, pct)) / 100) * w)
310 return h.T('█'.repeat(full) + '░'.repeat(w - full), { tone: 'running', key })
311}
312
313function drawGlobal(h, V, L) {
314 const u = V.usage
315 const vm = V.veille
316 return section(h, V, 'global', L.sec_global, globalSummary(u, vm), () => {
317 const out = []
318 if (u) {
319 u.windows.forEach((w, i) => {
320 if (!w.available) { out.push(h.lr(w.label, w.text, { key: 'uw' + i, dimLeft: true, dimRight: true })); return }
321 out.push(h.lr(w.label, `${String(Math.round(w.pct)).padStart(3)} %`, { key: 'uw' + i, bold: true }))
322 out.push(h.row([bar(h, V, w.pct, 'ub' + i), w.when ? h.T(w.when, { dim: true }) : null], { key: 'ubr' + i, alignItems: 'center', flexWrap: 'nowrap' }))
323 })
324 out.push(h.lr(L.credits, u.credits.text, { key: 'ucr', dimLeft: !u.credits.available, dimRight: !u.credits.available }))
325 const line = usageLine(u)
326 if (line) out.push(h.lr(L.this_session, line, { key: 'uts' }))
327 } else out.push(h.T(L.not_readable, { dim: true, key: 'u-none' }))
328 // Veille
329 if (!vm) out.push(h.lr(L.veille, L.veille_off.replace(/^veille : /, ''), { key: 'v-off', dimLeft: true, dimRight: true }))
330 else {
331 const open = vm.alerts.filter((a) => !a.acked)
332 out.push(h.lr(L.veille, `${vm.mode.label || vm.mode.name} · ${open.length} alerte(s)`, { key: 'v-head', bold: true, tone: vm.watcher.tone === 'bad' ? 'bad' : undefined }))
333 if (vm.watcher.tone === 'bad') out.push(h.T(vm.watcher.text, { tone: 'bad', key: 'v-w' }))
334 open.slice(0, 3).forEach((a, i) => out.push(h.T(`• ${a.what || a.key}${a.title ? ' · ' + a.title : ''}`, { tone: a.julian ? 'warn' : a.severity === 'bad' ? 'bad' : undefined, key: 'va' + i, wrap: 'truncate-end' })))
335 }
336 out.push(h.row([h.T(L.machine, { bold: true }), h.btn(L.machine_button, () => V.act.machine(), false, 'machine')], { key: 'machine-row', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'nowrap' }))
337 return out
338 })
339}
340
341// « Qui consomme »: preset toggle, top conversations, the top project, a button to the full Classement (/conso classement).
342function drawConso(h, V, L) {
343 const c = V.conso
344 return section(h, V, 'conso', L.sec_conso, consoSummary(c), () => {
345 const out = []
346 const presets = (c && c.presets) || (h.c.qui_consomme && h.c.qui_consomme.presets) || []
347 const cur = c ? c.preset.id : V.consoPreset
348 out.push(h.row(presets.map((p) => h.btn(p.label, () => V.act.consoPreset(p.id), p.id === cur, 'cp-' + p.id)), { key: 'conso-presets', alignItems: 'center' }))
349 if (!c) out.push(h.T(L.conso_none, { dim: true, key: 'conso-none' }))
350 else {
351 out.push(h.lr(`${c.preset.label}`, `${fmtTokens(c.total.tokens)} · ${fmtUsd(c.total.cost)}`, { key: 'conso-total', bold: true }))
352 c.rows.forEach((r, i) => out.push(h.lr(`${i + 1}. ${clip(r.label, 34)}${r.project ? ` (${r.project})` : ''}`, `${fmtUsd(r.cost)} · ${r.pct} %`, { key: 'conso-r' + i })))
353 if (c.project) out.push(h.lr(`${L.conso_project} : ${c.project.label}`, `${fmtUsd(c.project.cost)} · ${c.project.pct} %`, { key: 'conso-p', dimLeft: true }))
354 }
355 out.push(h.row([h.btn(L.conso_full, () => V.act.consoFull(), false, 'conso-full')], { key: 'conso-full-row' }))
356 return out
357 })
358}
359
360function drawFiles(h, V, L) {
361 return h.row([h.btn(L.fichiers_button, () => V.act.files(), false, 'files')], { key: 'fichiers', alignItems: 'center', marginBottom: 1 })
362}
363
364export { LEVELS }
365lib/decide.mjs 598 lines1// cockpit - the decision screens, pure (no $, no I/O, no imports; node --test mods/cockpit/tests/decide.test.mjs).
2// Each screen answers one decision of Julian's:
3// Nuit « Bilan avant la nuit »: what runs and until when, the night hours nobody uses, the quota left and what the
4// night would burn, the lanes / packets ready to start; then Nuit active / Lancer X / Rien.
5// Relais Claude ↔ Codex: who opened, fixed, merged what, the hand-offs, dependencies, green rate per agent
6// (the ci mod's dataset ~/.claude/ci-cache/interactions.json).
7// Galerie the Reference Gallery views the mods lacked: Équipe (lead → advisor / worker / reviewer / explorer /
8// researcher lanes, return to lead), Lanes (one per worktree of the project, its reviewer / adversary agents),
9// Séquence (one conversation as a sequence diagram: lead ⇄ agents over time), Carte (the control layers in
10// one compact map), Doux / dur (the soft-control concept map: what the model reads vs what the harness
11// enforces), Codex (approvals and cost).
12// Plus the « Claude Control » to-dos (~/.claude/a-faire/claude-control.md) sorted by deadline, for a home card.
13// Every label, preset and threshold is in DECIDE_DEFAULTS and can be overridden in cockpit.config.json.
14
15const pad = (n) => String(n).padStart(2, '0')
16const hm = (ms) => { const d = new Date(ms); return `${pad(d.getHours())}:${pad(d.getMinutes())}` }
17const fin = (v) => Number.isFinite(v)
18const clip = (s, n) => { s = String(s ?? ''); return s.length <= n ? s : s.slice(0, Math.max(1, n - 1)) + '…' }
19const fill = (tpl, vars) => String(tpl ?? '').replace(/\{(\w+)\}/g, (m, k) => (vars[k] !== undefined && vars[k] !== null ? String(vars[k]) : m))
20const hours = (ms) => { const h = Math.max(0, ms) / 3600000; return h >= 10 ? `${Math.round(h)} h` : `${h.toFixed(1).replace('.', ',')} h` }
21const durText = (ms) => {
22 const m = Math.max(0, Math.round(ms / 60000))
23 if (m < 60) return `${m} min`
24 const h = Math.floor(m / 60)
25 return `${h} h ${pad(m % 60)}`
26}
27
28export const DECIDE_DEFAULTS = {
29 nuit: {
30 wake: '07:30',
31 max_ready: 8,
32 per_repo: 3,
33 // what counts as « ready to start » in a repo's handoff/ROADMAP.md (regex, case-insensitive), and packet files
34 ready_match: '^\\s*[-*]\\s*\\[ \\]\\s+|\\b(prêt|prête|prêts|ready|à lancer|next up)\\b',
35 skip_sections: 'idées de julian|done|fait|terminé|shipped',
36 packet_match: '^(packet|paquet|go|lane|brief)[-_ .].*\\.md$',
37 packet_dirs: ['handoff', 'handoff/packets'],
38 // what « Lancer » shows (it never starts anything): a fresh session in the repo, the rules' coder model and effort
39 launch: 'cd "{repo}" ; claude --model {model} --effort {effort} "{prompt}"',
40 launch_prompt: 'Lis {source} et lance : {item}',
41 launch_model: 'opus',
42 launch_effort: 'medium',
43 labels: {
44 title: 'Bilan avant la nuit',
45 running: 'Ce qui tourne',
46 running_none: 'rien ne tourne',
47 ends: 'fin vers {time}',
48 ends_unknown: 'fin inconnue (pas assez d’historique)',
49 hours_left: 'reste {left}',
50 night: 'Nuit',
51 night_line: 'jusqu’à {wake} : {total} de nuit · {used} occupées par ce qui tourne · {free} inutilisées',
52 night_unknown: '{n} élément(s) sans heure de fin : les heures inutilisées sont au mieux {free}',
53 quota: 'Quota',
54 quota_line: '5 h {five} · semaine {week} (reste {left} %)',
55 quota_burn: 'la nuit consommerait ~{burn} % de la semaine au rythme actuel ({rate} %/h)',
56 quota_burn_unknown: 'rythme de la semaine inconnu : ce que la nuit consommerait n’est pas lisible',
57 quota_none: 'quotas non lus (le mod usage écrit samples.jsonl)',
58 quota_out: 'semaine épuisée vers {at} à ce rythme',
59 ready: 'Prêt à lancer',
60 ready_none: 'aucune lane ni paquet prêt lu dans les ROADMAP.md et handoff/ des repos récents',
61 choose: 'Que fait la nuit ?',
62 night_active: 'Nuit active (/veille nuit)',
63 launch: 'Lancer',
64 nothing: 'Rien',
65 day: 'Jour (/veille jour)',
66 launch_title: 'Pour lancer (rien n’est lancé d’ici) :',
67 launch_hint: 'Colle cette commande dans un terminal, ou ouvre une conversation dans ce dossier avec ce texte.',
68 nothing_done: 'Rien lancé : la nuit reste en mode actuel.',
69 source_roadmap: 'ROADMAP',
70 source_packet: 'paquet',
71 },
72 },
73 relais: {
74 max_repos: 8,
75 max_prs: 8,
76 labels: {
77 title: 'Claude ↔ Codex',
78 none: 'pas encore de données : /ci relais refresh (le mod ci écrit ~/.claude/ci-cache/interactions.json)',
79 generated: 'données du {time} · {days} derniers jours · {prs} PR/MR',
80 agents: 'Qui fait quoi',
81 agent_row: '{agent} : {opened} ouvertes · {fixed} corrigées · {merged} mergées · CI des PR {pr_rate} · pipelines {run_rate}',
82 handoffs: 'Passages de relais',
83 handoffs_row: '{n} relais ({kinds}) dans {prs} PR · {deps} PR avec dépendance',
84 repos: 'Par repo',
85 repo_row: '{repo} : {parts}',
86 prs: 'PR ouvertes ou avec relais',
87 pr_row: '#{number} {repo} · {who} · CI {ci}{handoff}{deps}',
88 unknown: 'inconnu',
89 errors: '{n} repo(s) illisible(s)',
90 },
91 },
92 galerie: {
93 views: ['equipe', 'lanes', 'sequence', 'carte', 'concept', 'codex'],
94 roles: {
95 advisor: ['advisor', 'architect', 'plan'],
96 worker: ['coder', 'general-purpose', 'worker', 'tester', 'debug', 'docs-writer'],
97 // the adversary / reviewer role of the Agent-teams view: whoever checks the workers' output
98 reviewer: ['reviewer', 'review', 'verify', 'verifier', 'auditor', 'adversary', 'code-reviewer', 'critic'],
99 explorer: ['explore', 'explore-fast'],
100 researcher: ['research', 'researcher', 'deep-research', 'web'],
101 },
102 // Codex list prices per million tokens (API equivalent; cached input not deducted), by model id prefix. Empty or
103 // unknown model: « tarif inconnu ». Sources: scenarios/versus.json (pages read 16 Oct 2026).
104 codex_prices: { 'gpt-6-astra': { in: 10, out: 50 }, 'gpt-5-6-sol': { in: 4, out: 20 } },
105 codex_days: 7,
106 // Lanes: conversations of the selected one's project active in the last lanes_days days, one lane per worktree;
107 // a conversation whose title matches lane_reviewer_match is itself a reviewer lane.
108 lanes_days: 3,
109 lanes_max: 8,
110 lane_reviewer_match: '\\b(verify|vérif|review|relecture|relecteur|adversa|audit)',
111 // Séquence: at most seq_agents agent lifelines (the most recent), seq_events events (the latest)
112 seq_agents: 5,
113 seq_events: 24,
114 // Doux / dur: each layer, the Carte field it counts (rules, memory, skills, agents, workflows, mods, mode, hooks, env)
115 concept: {
116 soft: [
117 { label: 'Règles (CLAUDE.md)', from: 'rules' }, { label: 'Mémoire', from: 'memory' },
118 { label: 'Skills', from: 'skills' }, { label: 'Agents (prompts)', from: 'agents' },
119 ],
120 hard: [
121 { label: 'Mode de permission', from: 'mode' }, { label: 'Hooks', from: 'hooks' }, { label: 'Réglages env', from: 'env' },
122 { label: 'Workflows (script)', from: 'workflows' }, { label: 'Mods (montrent, avertissent)', from: 'mods' },
123 ],
124 },
125 labels: {
126 title: 'Galerie',
127 views: { equipe: 'Équipe', lanes: 'Lanes', sequence: 'Séquence', carte: 'Carte', concept: 'Doux / dur', codex: 'Codex' },
128 lead: 'Lead · {title} · {model} · {effort}',
129 lanes: { advisor: 'Conseil (advisor)', worker: 'Workers', reviewer: 'Relecteurs (adversaire)', explorer: 'Explorateurs', researcher: 'Chercheurs', other: 'Autres' },
130 advisor_calls: '/advisor consulté {n} fois{at}',
131 lane_empty: 'aucun',
132 returned: '↩ rendu au lead {time}',
133 running: 'en cours depuis {dur}',
134 failed: '✕ échec',
135 stopped: 'arrêté',
136 no_conv: 'aucune conversation sélectionnée (Cockpit › une conversation)',
137 map: 'Carte du contrôle',
138 map_rows: { rules: 'Règles', settings: 'Réglages', agents: 'Agents', skills: 'Skills', workflows: 'Workflows', mods: 'Mods', memory: 'Mémoire' },
139 map_none: 'non lu',
140 codex: 'Codex : approbations et coût',
141 codex_none: 'Codex non lu (~/.codex/sessions absent, ou le mod usage n’a pas encore écrit codex.json)',
142 codex_policy: 'politique d’approbation : {list}',
143 codex_requests: '{n} demande(s) d’approbation · {sessions} session(s) sur {days} j',
144 codex_row: '{model} : {tokens} tokens · {cost}',
145 codex_total: 'Total {days} j : {tokens} tokens · {cost}',
146 price_unknown: 'tarif inconnu',
147 identity: 'Connexion',
148 wt_title: 'Lanes de {repo} : une par worktree, {days} derniers jours',
149 wt_main: 'checkout principal',
150 wt_none: 'aucune conversation de ce projet active sur {days} j',
151 wt_no_repo: 'aucune conversation sélectionnée (Cockpit › une conversation)',
152 wt_lane: '{folder}{branch}',
153 wt_conv: '{title} · {state}{agents}',
154 wt_pr: 'PR #{number} · {state} · CI {ci}',
155 wt_reviewers: 'relecture : {list}',
156 wt_no_reviewer: 'aucun relecteur (adversaire) lancé',
157 wt_is_reviewer: 'lane de relecture',
158 wt_more: '+ {n} lane(s)',
159 seq_title: 'Séquence · {title}',
160 seq_none: 'aucun agent dans cette conversation : rien à dessiner',
161 seq_lead: 'Lead',
162 seq_call: '{type} · {desc}',
163 seq_done: '{type} ↩ rendu au lead',
164 seq_failed: '{type} ✕ échec',
165 seq_advisor: '/advisor consulté',
166 seq_hidden: '+ {n} agent(s) plus anciens hors du diagramme',
167 seq_older: '+ {n} événement(s) plus anciens',
168 concept_title: 'Contrôle doux / dur (modèle ⇄ harness)',
169 concept_soft: 'Doux : le modèle les lit et peut s’en écarter',
170 concept_hard: 'Dur : le harness les applique, le modèle ne peut pas passer outre',
171 concept_flow: 'consigne → modèle (choisit) → appel d’outil → harness (permet, refuse) → effet',
172 concept_note: 'Ce qui doit tenir à coup sûr va côté dur ; ici aucun hook ne bloque sans le oui de Julian (§6).',
173 concept_default: 'défaut',
174 },
175 },
176 controle: {
177 file: '~/.claude/a-faire/claude-control.md',
178 max: 3,
179 warn_days: 7,
180 bad_days: 1,
181 },
182}
183
184function merge(a, b) {
185 if (!b || typeof b !== 'object' || Array.isArray(b)) return b === undefined ? a : b
186 const out = { ...(a || {}) }
187 for (const [k, v] of Object.entries(b)) {
188 if (k.startsWith('_')) continue
189 out[k] = v && typeof v === 'object' && !Array.isArray(v) && a && typeof a[k] === 'object' && !Array.isArray(a[k]) ? merge(a[k], v) : v
190 }
191 return out
192}
193
194export const decideConfig = (cfg) => merge(DECIDE_DEFAULTS, {
195 nuit: (cfg && cfg.nuit) || {}, relais: (cfg && cfg.relais) || {}, galerie: (cfg && cfg.galerie) || {}, controle: (cfg && cfg.controle) || {},
196})
197
198const rx = (src, flags = 'i') => { try { return src ? new RegExp(src, flags) : null } catch { return null } }
199
200// ---- « Claude Control » to-dos: `- [ ] YYYY-MM-DD · prio · quoi · pourquoi · comment` (date « — » = none) ---------
201
202const TODO_RE = /^\s*-\s*\[ \]\s*(\d{4}-\d{2}-\d{2}|—|-)\s*·\s*([^·]*?)\s*·\s*([^·]+?)\s*(?:·|$)/
203const PRIO = { haute: 0, high: 0, moyenne: 1, medium: 1, basse: 2, low: 2 }
204
205/** Open items sorted by deadline (dated first, soonest first), then priority. days_left in local days. */
206export function parseTodos(text, nowMs) {
207 const d = new Date(nowMs)
208 const today = Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())
209 const out = []
210 for (const line of String(text || '').split(/\r?\n/)) {
211 const m = TODO_RE.exec(line)
212 if (!m) continue
213 const dated = /^\d{4}-/.test(m[1])
214 const day = dated ? Date.UTC(+m[1].slice(0, 4), +m[1].slice(5, 7) - 1, +m[1].slice(8, 10)) : null
215 out.push({ date: dated ? m[1] : null, days_left: dated ? Math.round((day - today) / 86400000) : null, prio: m[2].trim(), what: m[3].trim() })
216 }
217 const p = (x) => PRIO[x.prio.toLowerCase()] ?? 3
218 return out.sort((a, b) => (a.date === null) - (b.date === null) || (a.days_left ?? 0) - (b.days_left ?? 0) || p(a) - p(b))
219}
220
221const whenText = (n) => (n < 0 ? `en retard de ${-n} j` : n === 0 ? 'aujourd’hui' : n === 1 ? 'demain' : `dans ${n} j`)
222
223/** The home card « Claude Control »: count, the next deadline, the top items. */
224export function controleCard(todos, cfg) {
225 const c = decideConfig(cfg).controle
226 if (!todos) return { line: 'aucun fichier ~/.claude/a-faire/claude-control.md', tone: 'dim', items: [] }
227 if (!todos.length) return { line: 'rien à faire pour Claude Control', tone: 'ok', items: [] }
228 const next = todos.find((t) => t.date)
229 const line = `${todos.length} à faire${next ? ` · prochaine échéance ${next.date.slice(8, 10)}/${next.date.slice(5, 7)} (${whenText(next.days_left)})` : ' · aucune échéance datée'}`
230 const tone = next && next.days_left <= c.bad_days ? 'bad' : next && next.days_left <= c.warn_days ? 'warn' : null
231 const items = todos.slice(0, c.max).map((t) => ({ text: `${t.date ? `${t.date.slice(8, 10)}/${t.date.slice(5, 7)} · ` : ''}${clip(t.what, 70)}${t.prio ? ` (${t.prio})` : ''}` }))
232 return { line, tone, items, more: Math.max(0, todos.length - c.max), count: todos.length }
233}
234
235// ---- Nuit: « Bilan avant la nuit » ----------------------------------------------------------------------------------
236
237/** Next wake time (epoch ms) after nowMs for « HH:MM » (local). */
238export function nextWake(wake, nowMs) {
239 const [h, m] = String(wake || '07:30').split(':').map((x) => Number(x) || 0)
240 const d = new Date(nowMs)
241 let t = new Date(d.getFullYear(), d.getMonth(), d.getDate(), h, m).getTime()
242 if (t <= nowMs) t += 86400000
243 return t
244}
245
246/**
247 * Ready lanes and packets from the repos' files. repos: [{repo, path, roadmap: text|null, packets: [{file, title}]}].
248 * A ROADMAP line is ready when it matches ready_match outside a skipped section (« Idées de Julian », done…).
249 */
250export function readyItems(repos, cfg) {
251 const c = decideConfig(cfg).nuit
252 const ready = rx(c.ready_match)
253 const skip = rx(c.skip_sections)
254 const out = []
255 for (const r of repos || []) {
256 const mine = []
257 let skipping = false
258 for (const raw of String(r.roadmap || '').split(/\r?\n/)) {
259 const head = /^#{1,6}\s+(.*)$/.exec(raw)
260 if (head) { skipping = Boolean(skip && skip.test(head[1])); continue }
261 if (skipping || !ready || !ready.test(raw)) continue
262 const text = raw.replace(/^\s*[-*]\s*(\[ \]\s*)?/, '').replace(/\*\*/g, '').trim()
263 if (text) mine.push({ repo: r.repo, path: r.path, source: 'handoff/ROADMAP.md', kind: 'roadmap', text: clip(text, 120) })
264 }
265 for (const p of r.packets || []) mine.push({ repo: r.repo, path: r.path, source: p.file, kind: 'packet', text: clip(p.title || p.file, 120) })
266 out.push(...mine.slice(0, c.per_repo))
267 }
268 return out.slice(0, c.max_ready)
269}
270
271/** The command « Lancer » shows for one ready item (never run from here). */
272export function launchCommand(item, cfg) {
273 const c = decideConfig(cfg).nuit
274 const prompt = fill(c.launch_prompt, { source: item.source, item: item.text }).replace(/"/g, '\'')
275 return fill(c.launch, { repo: item.path || item.repo, model: c.launch_model, effort: c.launch_effort, prompt })
276}
277
278/**
279 * The whole screen. data = {running: [{kind, text, eta: {mid}|null}], quotas (lib/core readQuotas), ready: readyItems}.
280 */
281export function nuitModel(data, cfg, nowMs) {
282 const c = decideConfig(cfg).nuit
283 const L = c.labels
284 const d = data || {}
285 const wake = nextWake(c.wake, nowMs)
286 const night = wake - nowMs
287 const running = (d.running || []).map((x) => {
288 const end = x.eta && fin(x.eta.mid) ? x.eta.mid : null
289 return { ...x, end, line: x.text, left: end ? fill(L.hours_left, { left: durText(end - nowMs) }) : L.ends_unknown }
290 })
291 const ends = running.map((x) => x.end).filter(fin)
292 const lastEnd = ends.length ? Math.min(wake, Math.max(...ends)) : nowMs
293 const used = Math.max(0, lastEnd - nowMs)
294 const free = Math.max(0, wake - lastEnd)
295 const unknown = running.filter((x) => !x.end).length
296 const nightLine = fill(L.night_line, { wake: hm(wake), total: hours(night), used: hours(used), free: hours(free) })
297 // quota: Claude's week pace (lib/core/usage.mjs runOut ratePerH) over the night hours
298 const q = d.quotas && d.quotas.claude
299 let quota = { line: L.quota_none, burn: '', tone: 'dim' }
300 if (q && (q.five || q.week)) {
301 const pct = (w) => (w ? `${Math.round(w.pct)} %` : '?')
302 const left = q.week ? Math.max(0, Math.round(100 - q.week.pct)) : '?'
303 const rate = q.week && fin(q.week.ratePerH) ? q.week.ratePerH : null
304 const burn = rate !== null ? Math.min(100, rate * (night / 3600000)) : null
305 let burnText = burn !== null ? fill(L.quota_burn, { burn: Math.round(burn), rate: rate.toFixed(1).replace('.', ',') }) : L.quota_burn_unknown
306 if (burn !== null && q.week && q.week.pct + burn >= 100 && q.week.runOutAt) burnText += ' · ' + fill(L.quota_out, { at: hm(q.week.runOutAt) })
307 quota = { line: fill(L.quota_line, { five: pct(q.five), week: pct(q.week), left }), burn: burnText,
308 tone: burn !== null && q.week && q.week.pct + burn >= 100 ? 'bad' : burn !== null && q.week && q.week.pct + burn >= 80 ? 'warn' : null }
309 }
310 const ready = (d.ready || []).map((r, i) => ({ ...r, i, label: `${r.repo} · ${r.kind === 'packet' ? L.source_packet : L.source_roadmap} · ${r.text}`, command: launchCommand(r, cfg) }))
311 return {
312 title: L.title, wake, running, night: { line: nightLine, unknown: unknown ? fill(L.night_unknown, { n: unknown, free: hours(free) }) : '', free, used, total: night },
313 quota, ready, labels: L,
314 }
315}
316
317// ---- Relais: Claude ↔ Codex --------------------------------------------------------------------------------------
318
319const AGENT_NAME = { claude: 'Claude', codex: 'Codex', unknown: 'inconnu' }
320const rate = (x) => (x && fin(x.rate) ? `${Math.round(x.rate)} % vert (${x.success}/${x.success + x.failed})` : '—')
321
322/** The Claude ↔ Codex screen from interactions.json (ci mod). null data: {available: false}. */
323export function relaisView(data, cfg) {
324 const c = decideConfig(cfg).relais
325 const L = c.labels
326 if (!data || !Array.isArray(data.repos)) return { available: false, none: L.none }
327 const t = data.totals || {}
328 const by = t.by_agent || {}
329 const agents = ['claude', 'codex'].map((a) => {
330 const x = by[a] || {}
331 return { agent: a, text: fill(L.agent_row, { agent: AGENT_NAME[a], opened: x.opened || 0, fixed: x.fixed || 0, merged: x.merged || 0, pr_rate: rate(x.pr_ci), run_rate: rate(x.runs) }),
332 rate: x.pr_ci && fin(x.pr_ci.rate) ? x.pr_ci.rate : null }
333 })
334 const kinds = Object.entries(t.handoffs_by_kind || {}).map(([k, n]) => `${k} ${n}`).join(', ') || '—'
335 const handoffs = fill(L.handoffs_row, { n: t.handoffs || 0, kinds, prs: t.prs_with_handoff || 0, deps: t.with_dependencies || 0 })
336 const repos = data.repos.filter((r) => (r.totals && r.totals.prs) > 0)
337 .sort((a, b) => b.totals.prs - a.totals.prs).slice(0, c.max_repos)
338 .map((r) => {
339 const ba = r.totals.by_agent || {}
340 const parts = ['claude', 'codex'].map((a) => (ba[a] && ba[a].opened ? `${AGENT_NAME[a]} ${ba[a].opened} PR${ba[a].pr_ci && fin(ba[a].pr_ci.rate) ? ` (${Math.round(ba[a].pr_ci.rate)} % vert)` : ''}` : null)).filter(Boolean)
341 if (r.totals.prs_with_handoff) parts.push(`${r.totals.prs_with_handoff} relais`)
342 return { repo: r.name, text: fill(L.repo_row, { repo: r.name, parts: parts.join(' · ') || `${r.totals.prs} PR` }) }
343 })
344 const prs = data.repos.flatMap((r) => (r.prs || []).map((p) => ({ ...p, repo: r.name })))
345 .filter((p) => p.state === 'open' || (p.handoffs && p.handoffs.length) || (p.depends_on && p.depends_on.length))
346 .sort((a, b) => String(b.created || '').localeCompare(String(a.created || ''))).slice(0, c.max_prs)
347 .map((p) => ({ url: p.url, number: p.number, tone: p.ci === 'failed' ? 'bad' : p.ci === 'success' ? 'ok' : null,
348 text: fill(L.pr_row, { number: p.number, repo: p.repo, who: [AGENT_NAME[p.opened_by] || p.opened_by, ...(p.fixed_by || []).filter((x) => x !== p.opened_by).map((x) => `+${AGENT_NAME[x] || x}`)].join(' '),
349 ci: p.ci || '?', handoff: p.handoffs && p.handoffs.length ? ` · relais ${p.handoffs.join(', ')}` : '', deps: p.depends_on && p.depends_on.length ? ` · dépend de ${p.depends_on.map((n) => '#' + n).join(', ')}` : '' }) }))
350 return {
351 available: true, head: fill(L.generated, { time: data.generated ? hm(Date.parse(data.generated)) : '?', days: data.days || '?', prs: t.prs || 0 }),
352 agents, handoffs, repos, prs, errors: (data.errors || []).length ? fill(L.errors, { n: data.errors.length }) : '', labels: L,
353 }
354}
355
356// ---- Galerie: Équipe (agent tree by role) -------------------------------------------------------------------------
357
358/** The lane of an agent type (roles in config; case-insensitive), else « other ». */
359export function roleOf(type, cfg) {
360 const roles = decideConfig(cfg).galerie.roles
361 const t = String(type || '').toLowerCase()
362 for (const [lane, types] of Object.entries(roles)) if ((types || []).some((x) => t === String(x).toLowerCase())) return lane
363 return 'other'
364}
365
366const msOf = (v) => (!v ? 0 : typeof v === 'number' ? (v > 1e12 ? v : v * 1000) : Date.parse(v) || 0)
367
368/**
369 * The selected conversation as a team: the lead, then one lane per role with its agents and whether each returned to
370 * the lead (done: its last line time). session: factory's scan row (subs, advisor, model, effort); title: its title.
371 */
372export function teamView(session, title, cfg, nowMs) {
373 const g = decideConfig(cfg).galerie
374 const L = g.labels
375 if (!session) return { available: false, none: L.no_conv }
376 const lanes = {}
377 for (const k of [...Object.keys(g.roles), 'other']) lanes[k] = []
378 for (const x of session.subs || []) {
379 const lane = roleOf(x.type, cfg)
380 const st = x.status
381 const state = st === 'done' ? fill(L.returned, { time: x.last_ts ? hm(msOf(x.last_ts)) : '?' })
382 : st === 'running' ? fill(L.running, { dur: durText(nowMs - (msOf(x.ts) || nowMs)) }) : st === 'failed' ? L.failed : L.stopped
383 lanes[lane].push({ id: x.id, text: `${x.type}${x.desc ? ' · ' + clip(x.desc, 50) : ''}${x.workflow ? ' (workflow)' : ''}`, model: x.model || '', state, status: st,
384 tone: st === 'running' ? 'running' : st === 'failed' ? 'bad' : st === 'done' ? 'ok' : 'dim' })
385 }
386 const adv = Number(session.advisor) || 0
387 const out = Object.entries(lanes).filter(([k, v]) => v.length || (k === 'advisor' && adv) || k !== 'other')
388 .map(([k, v]) => ({ id: k, title: `${L.lanes[k] || k} (${v.length})`, agents: v,
389 note: k === 'advisor' && adv ? fill(L.advisor_calls, { n: adv, at: session.advisor_ts ? ` · dernier ${hm(msOf(session.advisor_ts))}` : '' }) : '' }))
390 return { available: true, lead: fill(L.lead, { title: clip(title || session.cli, 40), model: session.app_model || session.model || '?', effort: session.app_effort || session.effort || '?' }), lanes: out }
391}
392
393// ---- Galerie: Carte du contrôle --------------------------------------------------------------------------------------
394
395/** map = scanner's {rules: {title}, settings: {env, hooks}, agents: [names], skills: [names], workflows: [names], mods: n, memory: n}. */
396// identity: what $.session.identity() answered (engine 2.1.299+; a mod outside managed settings gets { deny }): its
397// `kind` (claude.ai, console, api-key…) as one more row, only when known.
398export function controlMap(map, cfg, identity) {
399 const L = decideConfig(cfg).galerie.labels
400 const R = L.map_rows
401 const id = identity && typeof identity.kind === 'string' && identity.kind ? [{ id: 'identity', label: L.identity, text: identity.kind }] : []
402 if (!map) return [{ id: 'none', label: L.map, text: L.map_none }, ...id]
403 const list = (xs) => (Array.isArray(xs) ? `${xs.length}${xs.length ? ' · ' + clip(xs.join(', '), 60) : ''}` : L.map_none)
404 return [
405 { id: 'rules', label: R.rules, text: map.rules ? clip(map.rules.title || 'CLAUDE.md', 60) : L.map_none },
406 { id: 'settings', label: R.settings, text: map.settings ? `env ${map.settings.env} · hooks ${map.settings.hooks}${map.settings.mode ? ' · ' + map.settings.mode : ''}` : L.map_none },
407 { id: 'agents', label: R.agents, text: list(map.agents) },
408 { id: 'skills', label: R.skills, text: list(map.skills) },
409 { id: 'workflows', label: R.workflows, text: list(map.workflows) },
410 { id: 'mods', label: R.mods, text: fin(map.mods) ? String(map.mods) : L.map_none },
411 { id: 'memory', label: R.memory, text: fin(map.memory) ? `${map.memory} fichier(s)` : L.map_none },
412 ...id,
413 ]
414}
415
416// ---- Galerie: Codex approvals and cost -----------------------------------------------------------------------------
417
418const tok = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(1).replace('.', ',')} G` : n >= 1e6 ? `${(n / 1e6).toFixed(1).replace('.', ',')} M` : n >= 1e3 ? `${Math.round(n / 1e3)} K` : String(Math.round(n || 0)))
419const usd = (x) => (x >= 100 ? `${Math.round(x)} $` : `${x.toFixed(2).replace('.', ',')} $`)
420
421/** Price of one Codex model per million tokens: the longest matching prefix in codex_prices (dots and dashes alike). */
422export function codexPrice(model, cfg) {
423 const prices = decideConfig(cfg).galerie.codex_prices || {}
424 const m = String(model || '').toLowerCase().replace(/\./g, '-')
425 const key = Object.keys(prices).filter((k) => m.startsWith(k.toLowerCase().replace(/\./g, '-'))).sort((a, b) => b.length - a.length)[0]
426 return key ? prices[key] : null
427}
428
429/**
430 * codex = {byModel: {model: [input, cached, output, reasoning, total]}, days, approvals: {policies: {never: n}, requests,
431 * sessions}} from the scanner (usage's codex.json + rollouts). Cost = (input − cached) × in + output × out, API equivalent.
432 */
433export function codexPanel(codex, cfg) {
434 const g = decideConfig(cfg).galerie
435 const L = g.labels
436 if (!codex || !codex.available) return { available: false, none: L.codex_none }
437 let all = 0
438 let cost = 0
439 let priced = true
440 const rows = Object.entries(codex.byModel || {}).sort((a, b) => (b[1][4] || 0) - (a[1][4] || 0)).map(([model, v]) => {
441 const p = codexPrice(model, cfg)
442 const c = p ? (((v[0] || 0) - (v[1] || 0)) * p.in + (v[2] || 0) * p.out) / 1e6 : null
443 all += v[4] || 0
444 if (c === null) priced = false
445 else cost += c
446 return { model, text: fill(L.codex_row, { model, tokens: tok(v[4] || 0), cost: c === null ? L.price_unknown : usd(c) }) }
447 })
448 const a = codex.approvals || {}
449 const pol = Object.entries(a.policies || {}).sort((x, y) => y[1] - x[1]).map(([k, n]) => `${k} ${n}`).join(', ') || '—'
450 return {
451 available: true, rows,
452 total: fill(L.codex_total, { days: codex.days || g.codex_days, tokens: tok(all), cost: rows.length ? (priced ? usd(cost) : `${usd(cost)} + ${L.price_unknown}`) : '—' }),
453 policy: fill(L.codex_policy, { list: pol }),
454 requests: fill(L.codex_requests, { n: a.requests || 0, sessions: a.sessions || 0, days: codex.days || g.codex_days }),
455 tone: a.requests ? 'warn' : null,
456 }
457}
458
459// ---- Galerie: Lanes (Agent-teams: one lane per worktree, with its reviewer / adversary) -----------------------------
460
461/** The worktree folder of a working directory: <repo>/.claude/worktrees/<x> gives x; anything else is the main checkout. */
462export function worktreeOf(cwd) {
463 const m = /\/\.claude\/worktrees\/([^/]+)/.exec(String(cwd || '').replace(/\\/g, '/'))
464 return m ? m[1] : null
465}
466
467const subState = (x, L) => (x.status === 'done' ? `✓ ${x.last_ts ? hm(msOf(x.last_ts)) : ''}`.trim() : x.status === 'running' ? '◐' : x.status === 'failed' ? L.failed : L.stopped)
468
469/**
470 * rows: the project's conversations [{cli, title, cwd, branch, pr: {number, state, ci}|null, state, live, last (ms), subs}].
471 * One lane per worktree (the main checkout is one lane), the active ones first; each lane its conversations, its PR,
472 * and its reviewer agents (role « reviewer ») with how they ended, or that none was launched.
473 */
474export function lanesView(rows, repo, cfg, nowMs) {
475 const g = decideConfig(cfg).galerie
476 const L = g.labels
477 if (!repo) return { available: false, none: L.wt_no_repo }
478 const since = nowMs - g.lanes_days * 86400000
479 const revRx = rx(g.lane_reviewer_match)
480 const by = new Map()
481 for (const r of rows || []) {
482 if (!r || (r.last && r.last < since && !r.live)) continue
483 const key = worktreeOf(r.cwd) || ''
484 if (!by.has(key)) by.set(key, { folder: key, convs: [], last: 0, live: false, branch: '', pr: null })
485 const lane = by.get(key)
486 lane.convs.push(r)
487 lane.last = Math.max(lane.last, r.last || 0)
488 lane.live = lane.live || Boolean(r.live)
489 if (!lane.branch && r.branch) lane.branch = r.branch
490 if (!lane.pr && r.pr && r.pr.number) lane.pr = r.pr
491 }
492 const lanes = [...by.values()].sort((a, b) => (b.live - a.live) || (b.last - a.last))
493 const shown = lanes.slice(0, g.lanes_max).map((lane) => {
494 const subs = lane.convs.flatMap((c) => c.subs || [])
495 const reviewers = subs.filter((x) => roleOf(x.type, cfg) === 'reviewer')
496 const isReviewer = lane.convs.some((c) => revRx && revRx.test(String(c.title || '')))
497 const tone = lane.pr && lane.pr.ci === 'failed' ? 'bad' : lane.live ? 'running' : lane.pr && lane.pr.ci === 'success' ? 'ok' : null
498 return {
499 id: lane.folder || 'main', tone, live: lane.live, isReviewer,
500 title: fill(L.wt_lane, { folder: lane.folder || L.wt_main, branch: lane.branch ? ` · ${lane.branch}` : '' }),
501 convs: lane.convs.map((c) => fill(L.wt_conv, { title: clip(c.title || c.cli, 40), state: c.state || '?', agents: (c.subs || []).length ? ` · ${(c.subs || []).length} agent(s)` : '' })),
502 pr: lane.pr ? fill(L.wt_pr, { number: lane.pr.number, state: lane.pr.state || '?', ci: lane.pr.ci || '?' }) : '',
503 reviewers: reviewers.length ? fill(L.wt_reviewers, { list: reviewers.map((x) => `${x.type} ${subState(x, L)}`).join(', ') }) : isReviewer ? L.wt_is_reviewer : L.wt_no_reviewer,
504 reviewerTone: reviewers.some((x) => x.status === 'failed') ? 'bad' : reviewers.length || isReviewer ? 'ok' : 'warn',
505 }
506 })
507 return {
508 available: true, title: fill(L.wt_title, { repo, days: g.lanes_days }), lanes: shown,
509 none: shown.length ? '' : fill(L.wt_none, { days: g.lanes_days }), more: lanes.length > shown.length ? fill(L.wt_more, { n: lanes.length - shown.length }) : '',
510 }
511}
512
513// ---- Galerie: Séquence (one conversation as a sequence diagram) -----------------------------------------------------
514
515/**
516 * The selected conversation as a text sequence diagram: a lifeline for the lead and each agent (the seq_agents most
517 * recent), one arrow per call (lead → agent) and per return (agent ↩ lead), oldest first, the seq_events latest.
518 * width: the columns the diagram may use. Returns {available, title, header, rows: [{line, text, tone}], hidden, older}.
519 */
520export function sequenceView(session, title, cfg, nowMs, width) {
521 const g = decideConfig(cfg).galerie
522 const L = g.labels
523 if (!session) return { available: false, none: L.no_conv }
524 const all = (session.subs || []).filter((x) => msOf(x.ts)).sort((a, b) => msOf(a.ts) - msOf(b.ts))
525 if (!all.length && !session.advisor_ts) return { available: false, none: L.seq_none, title: fill(L.seq_title, { title: clip(title || session.cli, 40) }) }
526 const agents = all.slice(-Math.max(1, g.seq_agents))
527 const P = agents.length + 1
528 const lead = 6
529 const cell = Math.max(4, Math.min(18, Math.floor((Math.max(24, width || 60) - lead) / P)))
530 const center = (k) => lead + k * cell + Math.floor(cell / 2)
531 const span = agents.map((x) => ({ from: msOf(x.ts), to: x.status === 'running' ? nowMs : msOf(x.last_ts) || msOf(x.ts) }))
532 const events = []
533 agents.forEach((x, i) => {
534 events.push({ at: msOf(x.ts), k: i + 1, kind: 'call', text: fill(L.seq_call, { type: x.type, desc: clip(x.desc || x.prompt || '', 40) }).replace(/ · $/, ''), tone: null })
535 if (x.status === 'done' || x.status === 'failed') {
536 events.push({ at: msOf(x.last_ts) || msOf(x.ts), k: i + 1, kind: 'return', text: fill(x.status === 'done' ? L.seq_done : L.seq_failed, { type: x.type }), tone: x.status === 'done' ? 'ok' : 'bad' })
537 }
538 })
539 if (session.advisor_ts) events.push({ at: msOf(session.advisor_ts), k: 0, kind: 'self', text: L.seq_advisor, tone: 'dim' })
540 events.sort((a, b) => a.at - b.at || (a.kind === 'return') - (b.kind === 'return'))
541 const kept = events.slice(-Math.max(1, g.seq_events))
542 const blank = () => Array(lead + P * cell).fill(' ')
543 const header = blank()
544 ;[L.seq_lead, ...agents.map((x) => x.type)].forEach((name, k) => {
545 const n = clip(name, cell - 1)
546 const start = Math.max(lead + k * cell, Math.min(center(k) - Math.floor(n.length / 2), lead + (k + 1) * cell - n.length))
547 for (let i = 0; i < n.length; i++) header[start + i] = n[i]
548 })
549 const rows = kept.map((e) => {
550 const a = blank()
551 const t = hm(e.at)
552 for (let i = 0; i < t.length; i++) a[i] = t[i]
553 a[center(0)] = '│'
554 agents.forEach((_, i) => { const s = span[i]; if (e.at >= s.from && e.at <= s.to) a[center(i + 1)] = '│' })
555 if (e.kind === 'self') a[center(0)] = '◆'
556 else {
557 const from = center(0)
558 const to = center(e.k)
559 for (let i = from + 1; i < to; i++) a[i] = '─'
560 if (e.kind === 'call') { a[from] = '├'; a[to - 1] = '▶'; a[to] = '┐' } else { a[from] = '├'; a[from + 1] = '◀'; a[to] = '┘' }
561 }
562 return { line: a.join('').replace(/\s+$/, ''), text: `${' '.repeat(lead)}${e.text}`, tone: e.tone }
563 })
564 return {
565 available: true, title: fill(L.seq_title, { title: clip(title || session.cli, 40) }), header: header.join('').replace(/\s+$/, ''), rows,
566 hidden: all.length > agents.length ? fill(L.seq_hidden, { n: all.length - agents.length }) : '',
567 older: events.length > kept.length ? fill(L.seq_older, { n: events.length - kept.length }) : '',
568 }
569}
570
571// ---- Galerie: Doux / dur (the soft-control concept map) -------------------------------------------------------------
572
573/** One layer's figure from the Carte's map (names and counts only). */
574function layerValue(map, from, L) {
575 if (!map) return L.map_none
576 const list = (xs) => (Array.isArray(xs) ? String(xs.length) : L.map_none)
577 switch (from) {
578 case 'rules': return map.rules ? clip(map.rules.title || 'CLAUDE.md', 40) : L.map_none
579 case 'memory': return fin(map.memory) ? `${map.memory} fichier(s)` : L.map_none
580 case 'skills': case 'agents': case 'workflows': return list(map[from])
581 case 'mods': return fin(map.mods) ? String(map.mods) : L.map_none
582 case 'mode': return map.settings ? map.settings.mode || L.concept_default : L.map_none
583 case 'hooks': return map.settings ? String(map.settings.hooks) : L.map_none
584 case 'env': return map.settings ? `${map.settings.env} clé(s)` : L.map_none
585 default: return L.map_none
586 }
587}
588
589/** The concept map: the soft layers (the model reads them), the hard ones (the harness enforces them), with our counts. */
590export function conceptMap(map, cfg) {
591 const g = decideConfig(cfg).galerie
592 const L = g.labels
593 const side = (list) => (list || []).map((x, i) => ({ id: `${x.from}-${i}`, label: x.label, text: layerValue(map, x.from, L) }))
594 return { title: L.concept_title, soft: { title: L.concept_soft, rows: side(g.concept.soft) }, hard: { title: L.concept_hard, rows: side(g.concept.hard) }, flow: L.concept_flow, note: L.concept_note }
595}
596
597export { hm, clip, fill, durText }
598lib/depuis.mjs 369 lines1// cockpit - « Depuis ton dernier message », pure (no $, no I/O, no imports; node --test mods/cockpit/tests/depuis.test.mjs).
2// Julian (17 Oct): « I wake up and want the summary of the night: problems, blockages, progress ». One plain French
3// headline, then one foldable group per project (most important first): PRs/MRs merged, CI still red, conversations
4// finished or blocked, conversations waiting on Julian, Workflows finished or failed, new Problèmes, À toi items added or
5// due soon, tokens and $ spent since; the Veille night summary when a night ran; and the text to paste into the VEILLE
6// conversation (shown, never sent).
7// The data comes from scanner/depuis.mjs (transcripts, app sessions, gh/glab cached, ci cache, problemes, a-faire,
8// veille); the waiting rows (lib/core/waiting.mjs) and the Workflow runs come from what the pane already scanned.
9// Every label, preset and threshold is in DEPUIS_DEFAULTS and can be overridden under `depuis` in cockpit.config.json.
10
11const pad = (n) => String(n).padStart(2, '0')
12const DAYS = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
13export const hm = (ms) => { const d = new Date(ms); return `${pad(d.getHours())}:${pad(d.getMinutes())}` }
14const fin = (v) => Number.isFinite(v)
15const clip = (s, n) => { s = String(s ?? ''); return s.length <= n ? s : s.slice(0, Math.max(1, n - 1)) + '…' }
16const fill = (tpl, vars) => String(tpl ?? '').replace(/\{(\w+)\}/g, (m, k) => (vars[k] !== undefined && vars[k] !== null ? String(vars[k]) : m))
17const plural = (n, one, many) => `${n} ${n > 1 ? many : one}`
18const tms = (v) => (v === null || v === undefined || v === '' ? null : typeof v === 'number' ? (v > 1e12 ? v : v * 1000) : (fin(Date.parse(v)) ? Date.parse(v) : null))
19
20export const DEPUIS_DEFAULTS = {
21 enabled: true,
22 // presets of the « depuis » row; `last` = Julian's last real message, `hours` = a sliding window, `at` = the most
23 // recent HH:MM (local) before now. /cockpit depuis [8h|24h|nuit|HH:MM] picks one (any « Nh » or « HH:MM » works).
24 default: 'last',
25 presets: [
26 { id: 'last', label: 'Ton dernier message', last: true },
27 { id: '8h', label: '8 h', hours: 8 },
28 { id: '24h', label: '24 h', hours: 24 },
29 { id: 'nuit', label: 'Nuit', at: '22:00' },
30 ],
31 // a message younger than this is skipped (you just wrote « bonjour »: the night is before it)
32 skip_recent_min: 10,
33 lookback_days: 14, // no real message found in that many days: the 24 h preset
34 fallback_hours: 24,
35 due_soon_days: 3, // À toi items due within this many days
36 // « CI rouge »: lanes whose newest run is red and ran inside the window, plus these branches whatever their age
37 red_branches: ['main', 'master'],
38 open_groups: 2, // the first N projects start unfolded
39 max_lines: 8, // lines per kind and project before « + N »
40 prs_ttl_s: 300, // gh / glab answers kept that long (~/.claude/effort-board/cockpit_depuis_prs.json)
41 prs_limit: 100,
42 gh: true,
43 glab: true,
44 session_link: 'claude://claude.ai/epitaxy/{id}',
45 // the weight of each kind when ordering the projects (most important first)
46 weights: { waiting: 100, red: 60, blocked: 50, wf_failed: 40, problem: 20, atoi: 15, merged: 5, finished: 3, wf_done: 2 },
47 labels: {
48 button: 'Depuis ton dernier message',
49 title: 'Depuis ton dernier message',
50 headline: 'Depuis {since} ({dur})',
51 nothing: 'rien de nouveau',
52 merged: '{n} PR mergée|{n} PR mergées',
53 red: '{n} CI rouge|{n} CI rouges',
54 waiting: '{n} conversation t’attend|{n} conversations t’attendent',
55 blocked: '{n} bloquée|{n} bloquées',
56 finished: '{n} conversation terminée|{n} conversations terminées',
57 wf_failed: '{n} workflow en échec|{n} workflows en échec',
58 problems: '{n} problème nouveau|{n} problèmes nouveaux',
59 spent: '{usd} dépensés',
60 last: 'ton dernier message : {when} dans « {title} »',
61 last_none: 'aucun message de toi trouvé sur {days} j : fenêtre de {hours} h',
62 since_preset: 'fenêtre : {label}',
63 general: 'Général',
64 kinds: {
65 waiting: 'T’attend', red: 'CI rouge', blocked: 'Bloquée', wf_failed: 'Workflow en échec', problem: 'Problème', atoi: 'À toi',
66 merged: 'Mergée', finished: 'Terminée', wf_done: 'Workflow terminé', cost: 'Dépensé',
67 },
68 red_line: '{app}{branch} rouge depuis {at}{jobs}',
69 red_new: ' (nouveau)',
70 merged_line: '{ref} {title} · {at}',
71 finished_line: '{title} · {at}',
72 waiting_line: '{what} · {title}',
73 wf_line: '{name} · {at}{why}',
74 problem_line: '{id} {title}',
75 atoi_added: 'ajouté {at} : {title}',
76 atoi_due: 'échéance {day} : {title}',
77 cost_line: '{usd} · {tokens} tokens',
78 more: '+ {n}',
79 open: 'Ouvrir',
80 copy_id: 'Copier l’id',
81 copied: 'id copié : {id}',
82 prs_error: 'PR non lues : {errors}',
83 ci_none: 'CI : pas de cache du mod ci',
84 ci_stale: 'CI : cache du mod ci lu à {at} (avant la fenêtre ; /ci le rafraîchit)',
85 night: 'Nuit de la Veille',
86 ask: 'Demander un résumé à VEILLE',
87 ask_hint: 'À coller dans la conversation VEILLE (rien n’est envoyé).',
88 ask_copy: 'Copier le texte',
89 ask_text: 'VEILLE : résume-moi ce qui s’est passé depuis {since} ({dur}) : problèmes, blocages, avancées, par projet, et ce qui m’attend en premier. Repères du Cockpit : {headline}.',
90 loading: 'lecture des transcripts, PR et caches…',
91 refresh: '↻',
92 },
93}
94
95function merge(a, b) {
96 if (!b || typeof b !== 'object' || Array.isArray(b)) return b === undefined ? a : b
97 const out = { ...(a || {}) }
98 for (const [k, v] of Object.entries(b)) {
99 if (k.startsWith('_')) continue
100 out[k] = v && typeof v === 'object' && !Array.isArray(v) && a && typeof a[k] === 'object' && !Array.isArray(a[k]) ? merge(a[k], v) : v
101 }
102 return out
103}
104
105export const depuisConfig = (cfg) => merge(DEPUIS_DEFAULTS, (cfg && cfg.depuis) || {})
106
107// ---- Julian's real messages ----------------------------------------------------------------------------------------
108
109const NOT_JULIAN = /^\s*<(system-reminder|ci-monitor-event|task-notification|local-command|command-name|command-message|command-args|user-prompt-submit-hook|bash-|tool_use_error)/
110
111/**
112 * Whether a parsed transcript line is a message Julian typed: a user line with plain-string content, not a tool result
113 * (those are arrays), not meta / compact summary, not sent by another session (origin peer), a task notification, a
114 * plugin's slash command, a ci-monitor event or a system reminder. Lines older than the `origin` field count when their
115 * turnOrigin says human, or when they carry no origin hint at all.
116 */
117export function isJulianLine(o) {
118 if (!o || o.type !== 'user' || o.isSidechain) return false
119 const m = o.message
120 const c = m && m.content
121 if (typeof c !== 'string' || !c.trim()) return false
122 if (o.isMeta || o.isCompactSummary || o.isVisibleInTranscriptOnly) return false
123 if (NOT_JULIAN.test(c)) return false
124 const kind = o.origin && o.origin.kind
125 if (kind) return kind === 'human'
126 if (o.turnOrigin) return o.turnOrigin === 'human'
127 return !o.promptSource
128}
129
130// ---- the window ----------------------------------------------------------------------------------------------------
131
132/** The most recent local HH:MM strictly before now (« 22:00 » at 07:00 = yesterday 22:00). */
133export function lastAt(hhmm, nowMs) {
134 const m = /^(\d{1,2})[:h](\d{2})$/.exec(String(hhmm || '').trim())
135 if (!m) return null
136 const d = new Date(nowMs)
137 let t = new Date(d.getFullYear(), d.getMonth(), d.getDate(), Number(m[1]), Number(m[2])).getTime()
138 if (t >= nowMs) t -= 86400000
139 return t
140}
141
142/** A word of /cockpit depuis -> a preset: a preset id or label, « 8h », « 90min », « 23:12 », « 23h12 »; '' = default. */
143export function parseWhen(arg, cfg) {
144 const c = depuisConfig(cfg)
145 const w = String(arg || '').trim().toLowerCase()
146 const find = (id) => c.presets.find((p) => p && (String(p.id).toLowerCase() === id || String(p.label || '').toLowerCase() === id))
147 if (!w) return find(String(c.default).toLowerCase()) || c.presets[0] || { id: 'last', last: true }
148 const p = find(w) || (/^(dernier|last|message)$/.test(w) ? c.presets.find((x) => x.last) : null) || (/^(night|cette-nuit)$/.test(w) ? find('nuit') : null)
149 if (p) return p
150 let m = /^(\d{1,3})\s*h$/.exec(w)
151 if (m) return { id: `${m[1]}h`, label: `${m[1]} h`, hours: Number(m[1]) }
152 m = /^(\d{1,4})\s*min$/.exec(w)
153 if (m) return { id: `${m[1]}min`, label: `${m[1]} min`, hours: Number(m[1]) / 60 }
154 if (/^\d{1,2}[:h]\d{2}$/.test(w)) return { id: w.replace('h', ':'), label: w.replace('h', ':'), at: w.replace('h', ':') }
155 return find(String(c.default).toLowerCase()) || c.presets[0]
156}
157
158/**
159 * {since, source, label} for a preset. last = {ts, title, project} of Julian's last real message (or null).
160 * source: last | hours | at | fallback.
161 */
162export function resolveSince(preset, nowMs, last, cfg) {
163 const c = depuisConfig(cfg)
164 const p = preset || parseWhen('', cfg)
165 if (p.last) {
166 if (last && fin(last.ts)) return { since: last.ts, source: 'last', label: p.label || 'last', last }
167 return { since: nowMs - c.fallback_hours * 3600000, source: 'fallback', label: `${c.fallback_hours} h`, last: null }
168 }
169 if (fin(Number(p.hours)) && Number(p.hours) > 0) return { since: nowMs - Number(p.hours) * 3600000, source: 'hours', label: p.label || `${p.hours} h`, last }
170 const at = p.at ? lastAt(p.at, nowMs) : null
171 if (at) return { since: at, source: 'at', label: p.label || p.at, last }
172 return { since: nowMs - c.fallback_hours * 3600000, source: 'fallback', label: `${c.fallback_hours} h`, last }
173}
174
175/** « 23:12 » the same day or the day before within 24 h, else « jeu 23:12 ». */
176export function whenText(ms, nowMs) {
177 return nowMs - ms < 20 * 3600000 ? hm(ms) : `${DAYS[new Date(ms).getDay()]} ${hm(ms)}`
178}
179
180export function durOf(ms) {
181 const m = Math.max(0, Math.round(ms / 60000))
182 if (m < 60) return `${m} min`
183 const h = m / 60
184 return h < 10 ? `${Math.floor(h)} h${m % 60 ? ' ' + pad(m % 60) : ''}` : h < 72 ? `${Math.round(h)} h` : `${Math.round(h / 24)} j`
185}
186
187export function usd(x) {
188 return !fin(x) ? '? $' : x >= 10 ? `${Math.round(x)} $` : `${x.toFixed(2).replace('.', ',')} $`
189}
190
191export function tokensText(n) {
192 if (!fin(n)) return '?'
193 return n >= 1e9 ? `${(n / 1e9).toFixed(1).replace('.', ',')} G` : n >= 1e6 ? `${(n / 1e6).toFixed(1).replace('.', ',')} M` : n >= 1e3 ? `${Math.round(n / 1e3)} k` : String(Math.round(n))
194}
195
196// ---- CI still red --------------------------------------------------------------------------------------------------
197
198const isRed = (x) => x && x.status === 'failed' && !x.allow_failure
199
200/**
201 * Lanes of the ci mod's pipelines cache whose newest run is still red: {app, project, branch, who, since (first red run
202 * of the current red streak), jobs, fresh (the streak started inside the window)}. project = the app folder's name.
203 */
204export function stillRed(cache, sinceMs, branches = DEPUIS_DEFAULTS.red_branches) {
205 if (!cache || !Array.isArray(cache.apps)) return { available: false, lanes: [] }
206 const main = new Set((branches || []).map((b) => String(b).toLowerCase()))
207 const lanes = []
208 for (const a of cache.apps) {
209 const project = String(a.path || '').replace(/\\/g, '/').split('/').filter(Boolean).pop() || a.app || '?'
210 const by = new Map()
211 for (const r of a.runs || []) {
212 const k = r.branch || '' // one lane per branch: a GitHub and a GitLab pipeline of the same branch are one line
213 ;(by.get(k) || by.set(k, []).get(k)).push(r)
214 }
215 for (const runs of by.values()) {
216 const done = runs.filter((r) => r.status !== 'running' && r.status !== 'queued' && r.status !== 'cancelled' && r.status !== 'skipped')
217 .sort((x, y) => String(y.created || '').localeCompare(String(x.created || '')))
218 if (!done.length || !isRed(done[0])) continue
219 let first = done[0]
220 for (const r of done) { if (isRed(r)) first = r; else break }
221 const at = tms(first.created)
222 const ranInWindow = (tms(done[0].created) || 0) >= sinceMs
223 // an old branch nobody touched since is not news: only red streaks that started or ran again in the window,
224 // and the default branches (red_branches) whatever their age
225 if (!ranInWindow && !main.has(String(done[0].branch || '').toLowerCase())) continue
226 lanes.push({ app: a.app || project, project, branch: done[0].branch || '', who: done[0].who || 'unknown', url: done[0].url || '',
227 since: at, fresh: Boolean(at && at >= sinceMs), jobs: (done[0].jobs || []).filter(isRed).map((j) => j.name) })
228 }
229 }
230 lanes.sort((x, y) => (y.since || 0) - (x.since || 0))
231 return { available: true, generated: tms(cache.generated), lanes }
232}
233
234// ---- the model -----------------------------------------------------------------------------------------------------
235
236const keyOf = (name) => String(name || '').toLowerCase()
237
238/**
239 * The screen. data = scanner/depuis.mjs output; extra = {waiting: waitingRows(...).rows, workflows: raw Workflow runs
240 * ({project, name, status, start, end, last_activity})}. Returns {headline, sub, groups, general, night, ask, errors}.
241 */
242export function depuisModel(data, extra, cfg, nowMs) {
243 const c = depuisConfig(cfg)
244 const L = c.labels
245 const d = data || {}
246 const x = extra || {}
247 const since = fin(d.since) ? d.since : nowMs - c.fallback_hours * 3600000
248 const groups = new Map()
249 const general = { name: L.general, items: [] }
250 const group = (name) => {
251 if (!name || name === '?') return general
252 const k = keyOf(name)
253 if (!groups.has(k)) groups.set(k, { name, items: [] })
254 return groups.get(k)
255 }
256 const add = (name, kind, item) => group(name).items.push({ kind, ...item })
257 const pl = (tpl, n) => { const [one, many] = String(tpl).split('|'); return fill(n > 1 ? many || one : one, { n }) }
258
259 // PRs / MRs merged
260 const merged = (d.merged || []).filter((p) => (tms(p.at) || 0) >= since)
261 for (const p of merged) add(p.project, 'merged', { at: tms(p.at), url: p.url, text: fill(L.merged_line, { ref: `${p.repo || p.project}${p.forge === 'gitlab' ? '!' : '#'}${p.number}`, title: clip(p.title, 90), at: whenText(tms(p.at), nowMs) }) })
262 // CI still red
263 const reds = (d.red && d.red.lanes) || []
264 for (const r of reds) add(r.project, 'red', { at: r.since, url: r.url, tone: 'bad', text: fill(L.red_line, { app: r.app, branch: r.branch ? ` (${clip(r.branch, 40)})` : '', at: r.since ? whenText(r.since, nowMs) : '?', jobs: r.jobs.length ? ` · ${r.jobs.slice(0, 3).join(', ')}` : '' }) + (r.fresh ? L.red_new : '') })
265 // conversations finished or blocked
266 const link = (sid) => (sid ? fill(c.session_link, { id: sid }) : null)
267 let finished = 0
268 let blocked = 0
269 for (const s of d.conversations || []) {
270 if ((s.at || 0) < since) continue
271 const kind = s.state === 'blocked' ? 'blocked' : 'finished'
272 if (kind === 'blocked') blocked += 1; else finished += 1
273 add(s.project, kind, { at: s.at, sid: s.sid, cli: s.cli, link: link(s.sid), tone: kind === 'blocked' ? 'bad' : undefined,
274 text: fill(L.finished_line, { title: clip(s.title || s.cli, 70), at: whenText(s.at, nowMs) }), detail: s.first ? clip(s.first, 160) : '' })
275 }
276 // waiting on Julian (lib/core/waiting.mjs rows: questions, wrap-ups that need an action, interrupted turns)
277 const seen = new Set()
278 for (const w of x.waiting || []) {
279 if (!['ask', 'needs', 'awaiting'].includes(w.kind) || !w.session) continue
280 const k = w.session.cli || w.session.sid
281 if (seen.has(k)) continue
282 seen.add(k)
283 add(w.session.repo, 'waiting', { at: w.since, sid: w.session.sid, cli: w.session.cli, link: w.session.link, tone: 'warn', text: fill(L.waiting_line, { what: clip(w.what, 90), title: clip(w.session.title, 50) }) })
284 }
285 // Workflows finished or failed
286 let wfFailed = 0
287 for (const r of x.workflows || []) {
288 if (!r || r.status === 'running') continue
289 const end = tms(r.end) || tms(r.last_activity) || 0
290 if (end < since) continue
291 const failed = r.status !== 'completed'
292 if (failed) wfFailed += 1
293 add(r.project, failed ? 'wf_failed' : 'wf_done', { at: end, tone: failed ? 'bad' : undefined, text: fill(L.wf_line, { name: clip(r.name || r.run_id, 50), at: whenText(end, nowMs), why: failed ? ` · ${r.status}` : '' }) })
294 }
295 // new Problèmes
296 const probs = (d.problems || []).filter((p) => (p.at || 0) >= since)
297 for (const p of probs) add(p.project, 'problem', { at: p.at, tone: p.status === 'ouvert' ? 'warn' : undefined, text: fill(L.problem_line, { id: p.id, title: clip(p.title, 90) }) })
298 // À toi: added since, or due soon
299 const dueBy = nowMs + c.due_soon_days * 86400000
300 const atoiSeen = new Set()
301 for (const it of (d.atoi && d.atoi.items) || []) {
302 if (atoiSeen.has(it.id)) continue
303 const added = (it.since || 0) >= since
304 const due = fin(it.due) && it.due <= dueBy
305 if (!added && !due) continue
306 atoiSeen.add(it.id)
307 add(it.project, 'atoi', { at: it.since, tone: due ? 'warn' : undefined, text: due ? fill(L.atoi_due, { day: it.dueDay || new Date(it.due).toISOString().slice(0, 10), title: clip(it.title, 90) }) : fill(L.atoi_added, { at: whenText(it.since, nowMs), title: clip(it.title, 90) }) })
308 }
309 // tokens and $
310 const cost = d.cost || null
311 for (const [name, v] of Object.entries((cost && cost.byProject) || {})) {
312 if (!v || !(v.usd > 0)) continue
313 group(name).cost = v
314 }
315
316 const W = c.weights
317 const out = [...groups.values()].map((g) => {
318 const counts = {}
319 for (const it of g.items) counts[it.kind] = (counts[it.kind] || 0) + 1
320 const score = Object.entries(counts).reduce((a, [k, n]) => a + (W[k] || 1) * n, 0) + (g.cost ? Math.min(9, g.cost.usd / 10) : 0)
321 return { ...g, counts, score }
322 }).filter((g) => g.items.length || g.cost).sort((a, b) => b.score - a.score || (b.cost ? b.cost.usd : 0) - (a.cost ? a.cost.usd : 0) || a.name.localeCompare(b.name))
323 const order = Object.keys(W).sort((a, b) => W[b] - W[a])
324 const shape = (g) => {
325 const kinds = order.filter((k) => g.counts[k]).map((k) => {
326 const items = g.items.filter((it) => it.kind === k).sort((a, b) => (b.at || 0) - (a.at || 0))
327 return { kind: k, title: L.kinds[k] || k, items: items.slice(0, c.max_lines), more: Math.max(0, items.length - c.max_lines) }
328 })
329 const bits = order.filter((k) => g.counts[k]).map((k) => `${L.kinds[k] || k} ${g.counts[k]}`)
330 if (g.cost) bits.push(usd(g.cost.usd))
331 const tone = g.counts.waiting || g.counts.red || g.counts.blocked || g.counts.wf_failed ? (g.counts.red || g.counts.blocked || g.counts.wf_failed ? 'bad' : 'warn') : null
332 return { name: g.name, score: g.score || 0, kinds, summary: bits.join(' · '), tone, cost: g.cost ? fill(L.cost_line, { usd: usd(g.cost.usd), tokens: tokensText(g.cost.tokens) }) : '' }
333 }
334 const shaped = out.map(shape)
335 const gen = general.items.length ? shape({ ...general, counts: general.items.reduce((a, it) => ({ ...a, [it.kind]: (a[it.kind] || 0) + 1 }), {}) }) : null
336
337 // headline
338 const nWaiting = seen.size
339 const nRed = reds.length
340 const parts = []
341 if (merged.length) parts.push(pl(L.merged, merged.length))
342 if (nRed) parts.push(pl(L.red, nRed))
343 if (nWaiting) parts.push(pl(L.waiting, nWaiting))
344 if (blocked) parts.push(pl(L.blocked, blocked))
345 if (wfFailed) parts.push(pl(L.wf_failed, wfFailed))
346 if (probs.length) parts.push(pl(L.problems, probs.length))
347 if (finished && parts.length < 4) parts.push(pl(L.finished, finished))
348 if (cost && fin(cost.usd)) parts.push(fill(L.spent, { usd: usd(cost.usd) }))
349 const sinceText = whenText(since, nowMs)
350 const dur = durOf(nowMs - since)
351 const head = fill(L.headline, { since: sinceText, dur })
352 const headline = `${head} : ${parts.length ? parts.join(', ') : L.nothing}`
353 const sub = d.source === 'last' && d.last ? fill(L.last, { when: whenText(d.last.ts, nowMs), title: clip(d.last.title || d.last.project || '?', 50) })
354 : d.source === 'fallback' ? fill(L.last_none, { days: c.lookback_days, hours: c.fallback_hours })
355 : fill(L.since_preset, { label: d.label || '' })
356 const errs = []
357 if ((d.prsErrors || []).length) errs.push(fill(L.prs_error, { errors: d.prsErrors.join(' · ') }))
358 if (d.red && d.red.available === false) errs.push(L.ci_none)
359 else if (d.red && d.red.generated && d.red.generated < since) errs.push(fill(L.ci_stale, { at: whenText(d.red.generated, nowMs) }))
360 const errors = errs.join(' · ')
361 const night = d.night && Array.isArray(d.night.lines) && d.night.lines.length ? { title: L.night, lines: d.night.lines.slice(0, 20) } : null
362 return {
363 since, sinceText, dur, headline, sub, errors, groups: shaped, general: gen, night,
364 counts: { merged: merged.length, red: nRed, waiting: nWaiting, blocked, finished, wfFailed, problems: probs.length, usd: cost ? cost.usd : null },
365 ask: fill(L.ask_text, { since: sinceText, dur, headline: parts.length ? parts.join(', ') : L.nothing }),
366 open: c.open_groups, labels: L,
367 }
368}
369