SLOPSHOPPER

cockpit

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…

panecommandpromptprocesstimer
v0.6.0MITupdated 2026-10-22julian-passebecq/claude-mod/mods/cockpit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cockpit
│ ┃ Cockpit ✕ › fix the failing auth test and add an audit log call │ ┃ ✓ Rien ne tourne │ ┃ CI : non lu ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ CONVERSATIONS filtres › ⏺ Update(src/auth.ts) │ ┃ rien sur cette période ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ─ Quotas › ⎿ 3 pass, 1 fail │ ┃ 5 h ▰▰▱▱▱ 31 % │ ┃ ─ Aujourd’hui › ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ─ Débit · pas encore lu › │ ┃ ─ Graphe · lecture des sessions… › ✻ Worked for 42s · done 4:20 PM │ ┃ ─ Fichiers › │ ┃ scanner : JSON Parse error: Unexpected › /cockpit │ ┃ identifier "dev" │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Cockpit
✓ Rien ne tourne CI : non lu CONVERSATIONS filtres › rien sur cette période ─ Quotas › 5 h ▰▰▱▱▱ 31 % ─ Aujourd’hui › ─ Débit · pas encore lu › ─ Graphe · lecture des sessions… › ─ Fichiers › scanner : JSON Parse error: Unexpected identifier "dev"
README

cockpit

Panneau (default view; one list since 22 Oct)

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

  • Density (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.
  • Status banners (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.
  • ONE list (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.
  • Folded lines at the bottom, one line each with its summary (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.
  • Fichiers: data-goblin's file tree, embedded, folded by default (two mods' panes in one column become tabs, so it can only sit above the rest if Cockpit draws it). The tree's own logic is filetree's code, byte copies in 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.
  • Quotas: one line « 5 h ▰▰▱ 21 % · reset 2 h 10 sem ▰▰▰▰ 85 % 852 tok/s ›» (the reset goes first when short of room, then the bars get short, then they go). ›: 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.
  • Débit (Julian, 18 Oct, after Codex's « tokens per second » table): one line « opus 85 · sonnet 76 TPS (30 min) › » = the weighted TPS of each model in use over the shortest window that has replies (30 min, else 1 j…). › (« 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).
  • Graphe answers « who is active, who consumes, who coordinates, who is blocked or idle » (Julian, 17 Oct): one folded line « Graphe · 6 conv · 1 coord · rien ne t’attend › »; unfolded it draws the diagram only (the list above is the one list), then the day's Gantt. The model below still decides each state: one short legend line of coloured marks (● travaille green, ◆ attend amber, ✕ bloquée red, ✓ finie and ○ inactive grey: 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).
  • Diagrams (Julian, 19 Oct: « judicious visual diagrams, colours, progression, sankey »), drawn as Svg on the desktop (a picture: the clickable rows stay next to it), a text fallback elsewhere, one palette (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.
  • The engine has no line limit for a pane (it scrolls) but draws only the first 100,000 characters of a tree's texts: the macro view keeps a busy evening far under it (a test draws 6 busy conversations unfolded in both lists).
  • The old views (Cockpit cards, Mission, Focus, Arbre, Pipeline, Workflow, Journal, Machine, Claude ↔ Codex, Galerie, Depuis, Nuit) open with /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)

Left menu

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

Depuis ton dernier message (tab « Cockpit »)

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.

  • Since when: by default Julian's last real message in any conversation: a user line with plain-string content in ~/.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.
  • Headline: one French sentence, « Depuis 23:12 (8 h) : 9 PR mergées, 2 CI rouges, 3 conversations t'attendent, 14 $ dépensés », then when and where the last message was.
  • Per project (foldable, most important first by 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.
  • Nuit de la Veille: night-summary.json (/veille jour) when it covers the window.
  • Demander un résumé à VEILLE: shows the text to paste into the VEILLE conversation, with a Copier button; never sends it.
  • Code: 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 · Jour · Codex (tab « Cockpit »)

  • Nuit opens « Bilan avant la nuit », a decision screen: (1) what runs (Workflows, Agent-tool agents launched outside a Workflow, pipelines of Claude and Codex) with its end time and the hours left; (2) the night until 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.
  • Jour runs /veille jour (mode légère + the night summary, Veille's).
  • Codex: the cards are Claude only by default (cards.who = claude: CI, Quota and En cours count Claude's pipelines and quota); the button adds Codex for the session (« Codex ✓ »).

Cards (tab « Cockpit »)

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.

CardLineSourceVoir
Qui consommetop conversation and project of the windowusage'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 Fillib/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

Source 44 files
hooks/register.mjs 875 lines
1// 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}
875
lib/factory/hooks/logic.mjs 291 lines
1// 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}
291
lib/factory/hooks/waiting.mjs 238 lines
1// 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}
238
lib/factory/hooks/screens.mjs 473 lines
1// 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
473
lib/factory/hooks/workflow.mjs 603 lines
1// 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 }
603
lib/veille/hooks/view.mjs 83 lines
1// 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}
83
lib/veille/lib/modes.mjs 66 lines
1// 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}
66
lib/cards.mjs 283 lines
1// 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}
283
hooks/nav.mjs 693 lines
1// 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 }
693
hooks/view.mjs 365 lines
1// 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 }
365
lib/decide.mjs 598 lines
1// 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 }
598
lib/depuis.mjs 369 lines
1// 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