Mod pour l'app Claude de bureau (onglet Code) : ligne d'état avec quota et agents en cours, alertes de quota aux seuils réglés (70, 80 et 90 % par défaut)…

Mod pour l'app Claude de bureau (onglet Code). L'app n'appelle pas statusLine : ce mod donne par un autre chemin (les « function hooks » de Claude Code 2.1.299, API en accès anticipé) ce que la barre d'état du terminal affiche. Rien ne sort de la machine.
$.ui.status), mise à jour à chaque changement de quota, au lancement d'un sous-agent et à la fin de chaque tour : 🛎️ <projet> · <n> agents · 5 h <x> % (reset HH:MM) · 7 j <y> % Le projet est le nom du dossier de la session. Sans quota connu : quota ?. Si docs/HANDOFF.md contient une ligne Jalon : <texte>, le jalon (40 caractères au plus) s'ajoute après le projet.$.ui.notify), quand une fenêtre passe un des seuils (70, 80 ou 90 % par défaut, réglage seuils). Une fois par seuil et par fenêtre ; l'état est gardé dans le store du mod, donc aussi entre deux sessions.dia-*** (ou diapason:dia-*) : toast avec son nom et la première ligne de son rapport (le statut), 80 caractères au plus.Le mod lit deux réglages, comme les scripts du terminal : seuils (trois entiers croissants de 10 à 99) et notifications (agent+quota par défaut, tout, quota, rien). Ils viennent de la rubrique ## Réglages du projet du CLAUDE.md du dossier de la session, puis de la rubrique ## Réglages de ~/.claude/diapason/profil.md (à défaut, les anciens ~/.claude/majordome/profil.md puis ~/.claude/maitre-agent/profil.md de la v1.3 ; lu par $.fs.read sur un chemin absolu construit avec $.env.get("HOME"), sinon USERPROFILE). Fichier absent, illisible ou valeur invalide : les défauts. quota et agent+quota laissent les alertes de quota ; quota et rien suppriment le toast de fin d'agent ; rien supprime aussi les alertes (la ligne d'état reste). Le mod ne lit pas barre (sa ligne est fixe) ni les fichiers notifications-on / notifications-off. Détail : skills/majordome/reglages.md.
Une fois le dépôt publié, dans l'app de bureau : le menu + puis Plugins permet d'ajouter un plugin des marketplaces déjà configurées ; comment y ajouter une source GitHub n'est pas documenté (non vérifié). Repli : déclarer la marketplace dans ~/.claude/settings.json avec "extraKnownMarketplaces": { "diapason": { "source": { "source": "github", "repo": "MalikChopa/diapason" } } }, puis ajouter le plugin diapason-bureau. En terminal : claude plugin install diapason-bureau --marketplace MalikChopa/diapason.
Pour essayer depuis un dossier local, sans installer : mettre le chemin absolu du dossier bureau/ dans la variable CLAUDE_CODE_PLUGIN_DIRS (bloc env de ~/.claude/settings.json) ; avec CLAUDE_CODE_PLUGIN_DIR_WATCH=1, un fichier enregistré recharge le mod à chaud.
claude plugin validate bureau --strict
claude plugin test bureau
Les tests ne dessinent rien à l'écran : ils remplacent l'affichage par des enregistreurs et utilisent des données factices.
turn.complete d'un agentId). Un agent relancé par un message donne un nouveau toast.pending, running ou waiting ; un agent idle n'est pas compté.$.ui.notify passe par le canal de notification du terminal de la personne ; dans l'onglet Code, son effet n'est pas vérifié (le toast, lui, est toujours émis).Jalon : ... seulement ; sans elle, ou si elle garde le gabarit non rempli (<…>), rien n'est affiché. La barre d'état du terminal fait pareil (même règle depuis la v1.1.1).agent.spawn, qui conditionne le lancement d'un sous-agent, ne lève jamais d'exception (try/catch et .catch du moteur, claude plugin validate --strict ne signale plus « gating hook without .catch »). Le chemin .catch lui-même n'est pas couvert par un test.$.ui.notify dans l'onglet Code n'est pas vérifié. Les tests automatiques, eux, passent par le moteur, pas par l'écran.HOME simulé par mock.env), pas vue en réel dans l'onglet Code ; que l'app donne bien HOME au mod n'est pas vérifié (sans lui : les défauts).hooks/register.ts 212 lines1// diapason-bureau : ce que la barre d'état du terminal ne peut pas afficher
2// dans l'onglet Code de l'app Claude de bureau.
3// 1. ligne d'état (quota, agents en cours, jalon),
4// 2. alertes de quota aux seuils du réglage `seuils` (70, 80 et 90 % par défaut),
5// 3. toast à la fin d'un sous-agent dia-*.
6// Les réglages `seuils` et `notifications` viennent du profil et du CLAUDE.md du projet (voir loadSettings).
7// Rien ne sort de la machine : tout passe par `$` (affichage, session, fichier local, store du mod).
8import type { EngineInterface, Register } from 'claude-code'
9
10import {
11 agentDoneText,
12 alertText,
13 decideAlerts,
14 formatStatus,
15 isDiaAgent,
16 parseMilestone,
17 projectName,
18 resolveSettings,
19 wantsAgentDone,
20 wantsQuotaAlerts,
21 windowLabel,
22} from './lib'
23import type { AlertState, Settings, Window } from './lib'
24
25type Api = EngineInterface
26
27/** Clé du store : seuils déjà signalés, par fenêtre. */
28const ALERTS_KEY = 'quota-alerts'
29
30/** Statuts d'un agent qui compte comme « en cours ». */
31const ACTIVE = new Set(['pending', 'running', 'waiting'])
32
33/** Type de chaque sous-agent lancé, gardé au `agent.spawn` (repli de `$.agent.list()`). */
34const spawnedTypes = new Map<string, string>()
35
36type RefreshOptions = {
37 /** Fenêtres de quota déjà connues (sinon on les demande à `$.session.usage()`). */
38 rateLimits?: readonly Window[]
39 /** Un agent à compter même si la liste ne le montre pas encore. */
40 includeAgent?: string
41 /** Un agent à ne plus compter (il vient de finir). */
42 excludeAgent?: string
43}
44
45/** Recompose la ligne d'état. Ne lève jamais : un échec laisse l'ancienne ligne. */
46async function refresh($: Api, options: RefreshOptions = {}): Promise<void> {
47 try {
48 let cwd: string | undefined
49 try {
50 cwd = await $.session.cwd()
51 } catch {
52 cwd = undefined
53 }
54
55 let rateLimits = options.rateLimits
56 if (rateLimits === undefined) {
57 try {
58 rateLimits = (await $.session.usage()).rateLimits
59 } catch {
60 rateLimits = []
61 }
62 }
63
64 const active = new Set<string>()
65 try {
66 for (const a of await $.agent.list()) if (ACTIVE.has(a.status)) active.add(a.id)
67 } catch {
68 // liste indisponible : on compte seulement ce qu'on sait
69 }
70 if (options.includeAgent) active.add(options.includeAgent)
71 if (options.excludeAgent) active.delete(options.excludeAgent)
72
73 let milestone: string | undefined
74 try {
75 milestone = parseMilestone(await $.fs.read('docs/HANDOFF.md'))
76 } catch {
77 milestone = undefined // pas de HANDOFF dans ce dossier : pas de jalon
78 }
79
80 $.ui.status(formatStatus({ project: projectName(cwd), agents: active.size, rateLimits, milestone }))
81 } catch {
82 // jamais bloquant
83 }
84}
85
86/**
87 * Réglages `seuils` et `notifications` : CLAUDE.md du projet (dossier de la session) puis profil
88 * (`~/.claude/diapason/profil.md`, sinon l'ancien `~/.claude/majordome/profil.md` de la v1.4, sinon `~/.claude/maitre-agent/profil.md` de la v1.3). Fichier absent, illisible ou valeur invalide : les défauts. Ne lève jamais.
89 */
90async function loadSettings($: Api): Promise<Settings> {
91 const readOr = async (path: string): Promise<string | undefined> => {
92 try {
93 return await $.fs.read(path)
94 } catch {
95 return undefined
96 }
97 }
98 let home: string | undefined
99 try {
100 home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
101 } catch {
102 home = undefined
103 }
104 const project = await readOr('CLAUDE.md')
105 const profile = home
106 ? ((await readOr(`${home}/.claude/diapason/profil.md`)) ??
107 (await readOr(`${home}/.claude/majordome/profil.md`)) ??
108 (await readOr(`${home}/.claude/maitre-agent/profil.md`)))
109 : undefined
110 return resolveSettings(project, profile)
111}
112
113/** Toast, et notification système si le canal existe. Ne lève jamais. */
114async function say($: Api, text: string, title: string): Promise<void> {
115 try {
116 $.ui.toast(text)
117 } catch {
118 // rien
119 }
120 try {
121 await $.ui.notify(text, { title })
122 } catch {
123 // canal absent (appel impossible) ou écriture refusée : le toast a déjà prévenu
124 }
125}
126
127/** Alertes de quota : une fois par seuil et par fenêtre, état gardé dans le store du mod. */
128async function checkQuota($: Api, windows: readonly Window[]): Promise<void> {
129 try {
130 if (windows.length === 0) return
131 const settings = await loadSettings($)
132 const stored = (await $.store.get(ALERTS_KEY)) as AlertState | undefined
133 const { alerts, state } = decideAlerts(
134 stored && typeof stored === 'object' ? stored : undefined,
135 windows,
136 settings.thresholds,
137 )
138 if (JSON.stringify(state) !== JSON.stringify(stored ?? {})) await $.store.set(ALERTS_KEY, state)
139 if (!wantsQuotaAlerts(settings.notifications)) return
140 for (const a of alerts) await say($, alertText(a), `Quota ${windowLabel(a.kind)}`)
141 } catch {
142 // jamais bloquant
143 }
144}
145
146export const register: Register = on => {
147 // Au lancement de la session et à chaque tour : (re)pose la ligne d'état.
148 // `turn.start` couvre aussi un rechargement à chaud du mod (pas de `session.start` alors).
149 on('session.start', async ($, e, next) => {
150 const result = await next(e)
151 await refresh($)
152 return result
153 })
154
155 on('turn.start', async ($, e, next) => {
156 const result = await next(e)
157 if (e.agentId === undefined) await refresh($)
158 return result
159 })
160
161 // Le quota a bougé d'un point : ligne d'état et alertes.
162 on('session.measure', async ($, e, next) => {
163 const result = await next(e)
164 await refresh($, { rateLimits: e.rateLimits })
165 await checkQuota($, e.rateLimits)
166 return result
167 })
168
169 // Un sous-agent démarre : on retient son type et on recompte.
170 on('agent.spawn', async ($, e, next) => {
171 const result = await next(e)
172 // Ce gestionnaire conditionne le lancement d'un sous-agent : jamais d'exception ici.
173 try {
174 const id = result?.agentId
175 if (id !== undefined) {
176 if (typeof e.subagentType === 'string') spawnedTypes.set(id, e.subagentType)
177 await refresh($, { includeAgent: id })
178 }
179 } catch {
180 // jamais bloquant : le compteur sera corrigé au prochain rafraîchissement
181 }
182 return result
183 }).catch(($, e, next) => {
184 // Filet du moteur (forme documentée : `.catch` sur l'enregistrement) : si le gestionnaire lève quand même,
185 // le lancement du sous-agent continue sans nous.
186 return next(e)
187 })
188
189 // Fin d'un tour de sous-agent (pas d'événement « agent.end » dans l'API) : toast pour les dia-*.
190 on('turn.complete', async ($, e, next) => {
191 const result = await next(e)
192 const id = e.agentId
193 if (id === undefined) return result // tour du fil principal
194 try {
195 let type: string | undefined
196 try {
197 type = (await $.agent.list()).find(a => a.id === id)?.type
198 } catch {
199 type = undefined
200 }
201 type = type ?? spawnedTypes.get(id)
202 if (isDiaAgent(type) && wantsAgentDone((await loadSettings($)).notifications)) {
203 await say($, agentDoneText(type as string, e.answer, e.reason), 'Agent terminé')
204 }
205 } catch {
206 // jamais bloquant
207 }
208 await refresh($, { excludeAgent: id })
209 return result
210 })
211}
212hooks/lib.ts 261 lines1// Fonctions pures du mod diapason-bureau : aucune dépendance à `$`,
2// pour pouvoir les tester sans moteur.
3
4/** Une fenêtre de quota, comme `session.measure` la donne. */
5export type Window = { kind: string; percentUsed: number; resetsAt?: string }
6
7/** Seuils d'alerte par défaut, en pourcentage de la fenêtre (réglage `seuils`). */
8export const THRESHOLDS: readonly number[] = [70, 80, 90]
9
10/** État gardé par fenêtre : la fenêtre (resetsAt) et le plus haut seuil déjà signalé. */
11export type AlertState = Record<string, { resetsAt?: string; level: number }>
12
13export type Alert = { kind: string; threshold: number; percent: number; resetsAt?: string }
14
15/** Deux dates de reset à moins de 30 min d'écart = la même fenêtre. */
16const SAME_WINDOW_MS = 30 * 60 * 1000
17
18/** Nom court d'une fenêtre pour l'affichage. */
19export function windowLabel(kind: string): string {
20 if (kind === 'five_hour') return '5 h'
21 if (kind === 'seven_day') return '7 j'
22 if (kind === 'spend_limit') return 'budget'
23 return kind
24}
25
26/** Dernier élément du chemin, séparateurs / ou \ ; vide si rien. */
27export function projectName(cwd: string | undefined): string {
28 if (!cwd) return ''
29 const parts = cwd.split(/[\\/]+/).filter(p => p.length > 0)
30 return parts.length > 0 ? (parts[parts.length - 1] as string) : ''
31}
32
33/** HH:MM en heure locale, ou undefined si la date est absente ou invalide. */
34export function formatReset(iso: string | undefined): string | undefined {
35 if (!iso) return undefined
36 const d = new Date(iso)
37 if (Number.isNaN(d.getTime())) return undefined
38 const hh = String(d.getHours()).padStart(2, '0')
39 const mm = String(d.getMinutes()).padStart(2, '0')
40 return `${hh}:${mm}`
41}
42
43/** Pourcentage arrondi à l'entier, ou undefined s'il n'est pas un nombre. */
44function pct(w: Window | undefined): number | undefined {
45 if (!w || typeof w.percentUsed !== 'number' || Number.isNaN(w.percentUsed)) return undefined
46 return Math.round(w.percentUsed)
47}
48
49/** Morceau de la ligne pour la fenêtre de 5 h. */
50function fiveHourPart(w: Window | undefined): string {
51 const p = pct(w)
52 if (p === undefined) return '5 h ?'
53 const reset = formatReset(w?.resetsAt)
54 return reset ? `5 h ${p} % (reset ${reset})` : `5 h ${p} %`
55}
56
57function sevenDayPart(w: Window | undefined): string {
58 const p = pct(w)
59 return p === undefined ? '7 j ?' : `7 j ${p} %`
60}
61
62export type StatusInput = {
63 project: string
64 agents: number
65 rateLimits: readonly Window[] | undefined
66 milestone?: string
67}
68
69/**
70 * La ligne d'état : `🛎️ <projet> · [jalon · ]<n> agents · 5 h <x> % (reset HH:MM) · 7 j <y> %`.
71 * Sans aucun quota : `... · quota ?`.
72 */
73export function formatStatus(input: StatusInput): string {
74 const five = input.rateLimits?.find(w => w.kind === 'five_hour')
75 const seven = input.rateLimits?.find(w => w.kind === 'seven_day')
76 const parts: string[] = ['🛎️ ' + (input.project || 'session')]
77 if (input.milestone) parts.push(input.milestone)
78 parts.push(input.agents === 1 ? '1 agent' : `${input.agents} agents`)
79 if (pct(five) === undefined && pct(seven) === undefined) {
80 parts.push('quota ?')
81 } else {
82 parts.push(fiveHourPart(five), sevenDayPart(seven))
83 }
84 return parts.join(' · ')
85}
86
87/** Plus haut seuil atteint par ce pourcentage, 0 s'il est sous le premier. */
88export function levelOf(percent: number, thresholds: readonly number[] = THRESHOLDS): number {
89 let level = 0
90 for (const t of thresholds) if (percent >= t) level = t
91 return level
92}
93
94/**
95 * Décide des alertes : au plus une par fenêtre (le plus haut seuil franchi),
96 * jamais deux fois le même seuil dans la même fenêtre. Renvoie les alertes et
97 * le nouvel état à garder. Une nouvelle fenêtre (reset différent de plus de
98 * 30 min, ou pourcentage retombé sous le seuil déjà signalé) repart de zéro.
99 */
100export function decideAlerts(
101 prev: AlertState | undefined,
102 windows: readonly Window[],
103 thresholds: readonly number[] = THRESHOLDS,
104): { alerts: Alert[]; state: AlertState } {
105 const state: AlertState = { ...(prev ?? {}) }
106 const alerts: Alert[] = []
107 for (const w of windows) {
108 if (typeof w.percentUsed !== 'number' || Number.isNaN(w.percentUsed)) continue
109 // Même valeur que celle affichée (69,6 s'affiche « 70 % » : le seuil de 70 % est franchi).
110 const rounded = Math.round(w.percentUsed)
111 const old = state[w.kind]
112 let known = old?.level ?? 0
113 if (old) {
114 const a = old.resetsAt ? Date.parse(old.resetsAt) : NaN
115 const b = w.resetsAt ? Date.parse(w.resetsAt) : NaN
116 const isOtherWindow = !Number.isNaN(a) && !Number.isNaN(b) && Math.abs(a - b) > SAME_WINDOW_MS
117 if (isOtherWindow || rounded < known) known = 0
118 }
119 const level = levelOf(rounded, thresholds)
120 if (level > known) {
121 alerts.push({ kind: w.kind, threshold: level, percent: w.percentUsed, resetsAt: w.resetsAt })
122 known = level
123 }
124 const entry: { resetsAt?: string; level: number } = { level: known }
125 if (w.resetsAt !== undefined) entry.resetsAt = w.resetsAt
126 state[w.kind] = entry
127 }
128 return { alerts, state }
129}
130
131/** Texte d'une alerte de quota. */
132export function alertText(a: Alert): string {
133 const reset = formatReset(a.resetsAt)
134 const base = `Quota ${windowLabel(a.kind)} : ${a.threshold} % atteint (${Math.round(a.percent)} %)`
135 return reset ? `${base}, reset ${reset}` : base
136}
137
138/** Vrai si le type d'agent est un agent de Diapason : `dia-*`, `diapason:dia-*` ou, avant la v1.7, `ma-*` (et les préfixes de plugin `majordome:` / `maitre-agent:` d'avant la v1.5). */
139export function isDiaAgent(type: string | undefined): boolean {
140 return !!type && /^(diapason:|majordome:|maitre-agent:)?(dia|ma)-/.test(type)
141}
142
143/** Nom court d'un type d'agent : sans préfixe de plugin. */
144export function shortAgentName(type: string): string {
145 const i = type.lastIndexOf(':')
146 return i >= 0 ? type.slice(i + 1) : type
147}
148
149/** Coupe à `max` caractères (points de code), avec une ellipse si coupé. */
150export function truncate(text: string, max: number): string {
151 const chars = Array.from(text)
152 if (chars.length <= max) return text
153 return chars.slice(0, Math.max(0, max - 1)).join('').trimEnd() + '…'
154}
155
156/** Première ligne utile d'un rapport : sans titre markdown, gras, code ni puce. */
157export function firstLine(answer: string | undefined): string {
158 for (const raw of (answer ?? '').split(/\r?\n/)) {
159 const line = raw
160 .replace(/^\s*(#{1,6}|[-*>]+|\d+[.)])\s+/, '')
161 .replace(/[*_`]+/g, '')
162 .trim()
163 if (line.length > 0) return line
164 }
165 return ''
166}
167
168/** Texte du toast de fin d'agent, 80 caractères au plus au total. */
169export function agentDoneText(type: string, answer: string | undefined, reason?: string): string {
170 const name = shortAgentName(type)
171 let line = firstLine(answer)
172 if (line === '') line = reason === 'aborted' ? 'interrompu' : reason === 'error' ? 'erreur' : 'terminé (rapport vide)'
173 return truncate(`${name} : ${line}`, 80)
174}
175
176/** Jalon d'un HANDOFF.md : la ligne « Jalon : ... » (puce et gras tolérés), 40 caractères au plus. */
177export function parseMilestone(handoff: string | undefined): string | undefined {
178 if (!handoff) return undefined
179 for (const raw of handoff.split(/\r?\n/)) {
180 const m = /^\s*(?:[-*]\s+)?(?:\*\*)?Jalon(?: actuel)?(?:\*\*)?\s*:(?:\*\*)?\s*(.+?)\s*$/i.exec(raw)
181 if (m && m[1]) {
182 const v = m[1].replace(/[*_`]+/g, '').trim()
183 // un gabarit non rempli (« <jalon …> ») n'est pas un jalon
184 if (v.length > 0 && !v.startsWith('<')) return truncate(v, 40)
185 }
186 }
187 return undefined
188}
189
190// --- Réglages (voir skills/diapason/reglages.md) ------------------------------------------------------
191// Mêmes règles que les scripts du terminal : lignes « - clé : valeur » sous « ## Réglages » du profil, surchargées
192// par « ## Réglages du projet » du CLAUDE.md du projet ; première valeur valide, sinon le défaut.
193
194/** Mode du réglage `notifications`. */
195export type NotifyMode = 'agent+quota' | 'tout' | 'quota' | 'rien'
196
197export type Settings = { thresholds: readonly number[]; notifications: NotifyMode }
198
199/** Lignes « - clé : valeur » de la rubrique `## <titre>` (clés en minuscules, première occurrence), sans le commentaire final entre parenthèses. */
200export function readSection(text: string | undefined, title: string): Record<string, string> {
201 const out: Record<string, string> = {}
202 if (!text) return out
203 let on = false
204 for (const raw of text.split(/\r?\n/)) {
205 if (/^#+[ \t]/.test(raw)) {
206 if (/^##[ \t]/.test(raw)) on = raw.replace(/^##[ \t]+/, '').replace(/[ \t\r]+$/, '') === title
207 else if (/^#[ \t]/.test(raw)) on = false
208 continue
209 }
210 if (!on) continue
211 const m = /^[ \t]*[-*][ \t]+(.*)$/.exec(raw)
212 if (!m) continue
213 const line = m[1] as string
214 const i = line.indexOf(':')
215 if (i < 1) continue
216 const key = line.slice(0, i).replace(/[ \t*`]+/g, '').toLowerCase()
217 if (key in out) continue
218 out[key] = line
219 .slice(i + 1)
220 .replace(/^[ \t*`]+/, '')
221 .replace(/[ \t\r*`]+$/, '')
222 .replace(/[ \t]*\(.*\)$/, '')
223 }
224 return out
225}
226
227/** Trois entiers croissants de 10 à 99 (séparés par espace, virgule ou /), sinon undefined. */
228export function parseThresholds(value: string | undefined): readonly number[] | undefined {
229 if (!value) return undefined
230 const parts = value.split(/[\s,/]+/).filter(p => p.length > 0)
231 if (parts.length !== 3 || !parts.every(p => /^[0-9]+$/.test(p))) return undefined
232 const [a, b, c] = parts.map(Number) as [number, number, number]
233 return a >= 10 && a < b && b < c && c <= 99 ? [a, b, c] : undefined
234}
235
236export function parseNotifications(value: string | undefined): NotifyMode | undefined {
237 return value === 'agent+quota' || value === 'tout' || value === 'quota' || value === 'rien' ? value : undefined
238}
239
240/** Réglages en vigueur : le projet d'abord, puis le profil, sinon les défauts (70 80 90 ; agent+quota). */
241export function resolveSettings(project: string | undefined, profile: string | undefined): Settings {
242 const sources = [readSection(project, 'Réglages du projet'), readSection(profile, 'Réglages')]
243 let thresholds: readonly number[] | undefined
244 let notifications: NotifyMode | undefined
245 for (const src of sources) {
246 thresholds ??= parseThresholds(src['seuils'])
247 notifications ??= parseNotifications(src['notifications'])
248 }
249 return { thresholds: thresholds ?? THRESHOLDS, notifications: notifications ?? 'agent+quota' }
250}
251
252/** Les alertes de quota sont-elles permises ? */
253export function wantsQuotaAlerts(mode: NotifyMode): boolean {
254 return mode !== 'rien'
255}
256
257/** La fin d'un sous-agent dia-* doit-elle prévenir ? */
258export function wantsAgentDone(mode: NotifyMode): boolean {
259 return mode === 'agent+quota' || mode === 'tout'
260}
261