Ajouter l'agent claude-usage : limites du plan, cache local et envoi #50

Closed
opened 2026-10-04 12:59:46 +00:00 by maximus · 0 comments
Owner

Refs : spec-decisions-suivi-claude.md + spec-plan-suivi-claude.md (dépôt la-compagnie-maximus). Ce corps est autosuffisant : tout ce qu'il faut pour implémenter est ci-dessous.

Point d'entrée de l'agent lancé toutes les 15 min par le crontab du ThinkPad : lit les limites du plan Claude, maintient le cache des jours figés, assemble le snapshot et le pousse vers POST /claude-usage.

Depends on

Contrat du snapshot (schemaVersion: 1, figé)

Corps de POST /claude-usage, partagé entre vps-health-api (agent + serveur) et la-compagnie-maximus (dashboard) :

{
  "schemaVersion": 1,
  "generatedAt": "2026-09-27T19:00:00.000Z",     // horloge du poste, informatif
  "claudeCodeVersion": "2.1.283",                 // champ `version` du transcript le plus récent
  "timeZone": "America/Toronto",
  "limits": {
    "status": "ok",          // ok | token_expired | http_error | unavailable | disabled
    "detail": "http_429",    // optionnel : no_credentials | parse_error | no_version | timeout | network | bad_response | http_<code>
    "fetchedAt": "2026-09-27T19:00:00.000Z",     // date des items (dernières valeurs valides), ou null
    "items": [
      { "kind": "session", "group": "session", "percent": 1,
        "severity": "normal", "resetsAt": "2026-09-27T21:30:00Z", "model": null },
      { "kind": "weekly_scoped", "group": "weekly", "percent": 63,
        "severity": "normal", "resetsAt": "2026-10-01T07:00:00Z", "model": "Fable" }
    ]
  },
  "days": [                  // exactement 30 jours Toronto, du plus ancien à aujourd'hui, jours vides inclus
    { "date": "2026-09-27",
      "models": { "claude-opus-5-5": { "input": 2, "output": 1950, "cacheCreation": 43176,
                                        "cacheRead": 33814, "requests": 1 } } }
  ],
  "projects": [              // top 8 sur 30 j (tokens de travail = input + output + cacheCreation), puis "(autres)"
    { "name": "simpl-resultat", "work": 123456, "cacheRead": 7890123, "requests": 812 }
  ]
}

Bornes serveur (sanitizeClaudeUsage, liste blanche, tout champ inconnu jeté) : corps ≤ 64 Ko ; days ≤ 31, date ^\d{4}-\d{2}-\d{2}$ ; models ≤ 12 clés ^[a-z0-9][a-z0-9.\-]{0,63}$ ; limits.items ≤ 12 ; projects ≤ 9 ; nombres Number.isSafeInteger ≥ 0, percent 0-100 ; status dans l'enum ; detail ^[a-z0-9_]{1,32}$ sinon jeté ; autres chaînes ≤ 128 car. ; dates ISO validées. Le serveur ajoute receivedAt.

Réponse de GET /claude-usage : { staleAfterSeconds, receivedAt, ageSeconds, stale, snapshot }, ou { "status": "no_data" } si le fichier est absent ou illisible.

Faits sondés (2026-09-27)

  • ~/.claude/.credentials.json contient claudeAiOauth.{accessToken, refreshToken, expiresAt (ms epoch), ...}. Les refresh tokens sont à usage unique : l'agent ne doit jamais rafraîchir ni écrire ce fichier, sinon Claude Code est déconnecté.
  • GET https://api.anthropic.com/api/oauth/usage (en-têtes Authorization: Bearer <accessToken>, anthropic-beta: oauth-2025-04-20, User-Agent: claude-code/<version>) renvoie un tableau limits[] : {kind, group, percent, severity, resets_at, scope: {model: {display_name}} | null, is_active}, ex. session 1 %, weekly_all 50 %, weekly_scoped Fable 63 %. Aussi five_hour / seven_day {utilization, resets_at} et une vingtaine de clés opaques null. Endpoint non documenté, rate-limit fort sans Retry-After.

Tâches

  • agent/claude-usage/limits.js :
    • URL de l'endpoint en constante (aucun override par env), fetch avec redirect: "error", timeout 10 s, un seul appel, aucun retry.
    • CLAUDE_USAGE_LIMITS=off → status: "disabled", aucun appel.
    • Credentials ouverts en lecture seule ; absent → unavailable/no_credentials ; illisible ou JSON tronqué (réécriture en cours par Claude Code) → unavailable/parse_error, sans retry ni log du contenu ; expiresAt dépassé → token_expired, aucun appel ; claudeCodeVersion null → unavailable/no_version.
    • Réponse : limits[] → items (resets_at → resetsAt, scope.model.display_name → model) ; sinon repli five_hour → {kind:"session", group:"session"} et seven_day → {kind:"weekly_all", group:"weekly"} ; tout vide ou forme inattendue → unavailable/bad_response ; HTTP non 2xx → http_error/http_<code> ; timeout → timeout ; autre erreur réseau → network. detail vient uniquement de cet enum, jamais d'un message d'erreur ou d'un corps de réponse.
  • agent/claude-usage/state.js : ~/.local/state/maximus-claude-usage/state.json (mode 600, écriture atomique tmp suffixé pid + rename) ; figer tout jour antérieur à hier Toronto, vide compris ({models:{}, projects:{}}) ; élaguer les jours figés de plus de 35 j ; lastGoodLimits mis à jour seulement sur status: "ok" et renvoyé avec son fetchedAt sinon ; fichier illisible → état vide (scan complet) sans échec.
  • agent/claude-usage/push-claude-usage.js : agrège (module aggregate.js), met à jour et écrit le cache avant le push, assemble le snapshot, mesure le corps (> 65 536 octets → log payload_too_large, code 2, pas d'envoi), pousse avec Authorization: Bearer $CLAUDE_USAGE_INGEST_TOKEN, timeout 10 s, sans retry. CLAUDE_USAGE_API_URL doit être en https: (sinon code 1). --dry-run imprime le snapshot sans l'envoyer. Codes : 0 succès, 1 configuration, 2 push échoué ou payload trop gros. Aucun jeton dans la sortie ; erreurs HTTP journalisées par code, jamais par objet d'erreur brut.
  • agent/claude-usage/run-claude-usage.sh : flock -n ~/.local/state/maximus-claude-usage/run.lock (sortie 0 si un passage tourne déjà) ; source ~/.config/maximus-claude-usage.env (CLAUDE_USAGE_API_URL, CLAUDE_USAGE_INGEST_TOKEN, CLAUDE_USAGE_LIMITS et NODE_BIN optionnels) ; même style que agent/run-push.sh.
  • agent/README.md, section dédiée : commande cp exacte de agent/claude-usage/ vers ~/.local/share/maximus-claude-usage/ (copie figée, jamais le working tree), fichier env (mode 600), ligne crontab */15 * * * * $HOME/.local/share/maximus-claude-usage/run-claude-usage.sh 2>&1 | logger -t claude-usage, et une phrase expliquant pourquoi cet agent vit dans vps-health-api (contrat et sanitizer testés ensemble).

Fichiers concernés

  • agent/claude-usage/limits.js (nouveau)
  • agent/claude-usage/state.js (nouveau)
  • agent/claude-usage/push-claude-usage.js (nouveau)
  • agent/claude-usage/run-claude-usage.sh (nouveau, exécutable)
  • agent/README.md
  • __tests__/agent-claude-usage.test.js
  • __tests__/fixtures/claude-usage/ (réponse de l'endpoint anonymisée)

Critères d'acceptation

  • Réponse anonymisée de l'endpoint → items attendus, dont weekly_scoped avec model: "Fable" ; limits[] absent → repli five_hour/seven_day ; tout null → unavailable
  • Jeton expiré, CLAUDE_USAGE_LIMITS=off, credentials tronqués, credentials absents : fetch (mocké) jamais appelé
  • 429 → http_error/http_429, items = dernières limites valides avec leur fetchedAt
  • Corps de réponse hostile (texte arbitraire, en-têtes renvoyés) → detail reste dans l'enum
  • L'en-tête Authorization du jeton OAuth n'est envoyé qu'à https://api.anthropic.com ; aucune variable d'env ne change cette URL
  • Le fichier de credentials n'est jamais ouvert en écriture (espion sur fs)
  • Le jeton OAuth et CLAUDE_USAGE_INGEST_TOKEN n'apparaissent ni dans le snapshot ni dans stdout/stderr
  • Deux passages consécutifs autour d'un jour vide : le jour vide est figé, et le second passage n'ouvre aucun fichier antérieur au seuil
  • state.json corrompu → passage complet sans échec ; élagage au-delà de 35 j
  • Snapshot produit sur fixtures accepté sans perte par sanitizeClaudeUsage (import de ../claude-usage.js)
  • CLAUDE_USAGE_API_URL en http: → code 1 ; snapshot > 64 Ko → code 2 sans envoi
  • run-claude-usage.sh lancé deux fois en parallèle : le second sort 0 sans travailler

Review caveats

  • 🟡 (résolu) URL Anthropic épinglée et redirect: "error" : le même processus tient le bearer OAuth et une URL de push configurable.
  • 🟢 (résolu) Verrou flock : le premier scan complet peut chevaucher le passage suivant.
  • 🟢 (résolu) Lecture des credentials pendant leur réécriture.
  • Risque accepté : User-Agent: claude-code/<version> imite le client officiel sur un endpoint non documenté (un appel / 15 min, interrupteur off).

Décisions prises en planification

  • Ne jamais rafraîchir le jeton ; limites sautées si expiré.
  • Cache dans ~/.local/state/, séparé de la copie figée du code dans ~/.local/share/.
  • Tout l'agent (lanceur compris) dans agent/claude-usage/.

Spec source

la-compagnie-maximus : spec-plan-suivi-claude.md, Issue 3

Refs : [spec-decisions-suivi-claude.md](https://git.lacompagniemaximus.com/maximus/la-compagnie-maximus/src/branch/main/spec-decisions-suivi-claude.md) + [spec-plan-suivi-claude.md](https://git.lacompagniemaximus.com/maximus/la-compagnie-maximus/src/branch/main/spec-plan-suivi-claude.md) (dépôt la-compagnie-maximus). Ce corps est autosuffisant : tout ce qu'il faut pour implémenter est ci-dessous. Point d'entrée de l'agent lancé toutes les 15 min par le crontab du ThinkPad : lit les limites du plan Claude, maintient le cache des jours figés, assemble le snapshot et le pousse vers `POST /claude-usage`. ## Depends on - #48 - #49 ## Contrat du snapshot (`schemaVersion: 1`, figé) Corps de `POST /claude-usage`, partagé entre `vps-health-api` (agent + serveur) et `la-compagnie-maximus` (dashboard) : ```jsonc { "schemaVersion": 1, "generatedAt": "2026-09-27T19:00:00.000Z", // horloge du poste, informatif "claudeCodeVersion": "2.1.283", // champ `version` du transcript le plus récent "timeZone": "America/Toronto", "limits": { "status": "ok", // ok | token_expired | http_error | unavailable | disabled "detail": "http_429", // optionnel : no_credentials | parse_error | no_version | timeout | network | bad_response | http_<code> "fetchedAt": "2026-09-27T19:00:00.000Z", // date des items (dernières valeurs valides), ou null "items": [ { "kind": "session", "group": "session", "percent": 1, "severity": "normal", "resetsAt": "2026-09-27T21:30:00Z", "model": null }, { "kind": "weekly_scoped", "group": "weekly", "percent": 63, "severity": "normal", "resetsAt": "2026-10-01T07:00:00Z", "model": "Fable" } ] }, "days": [ // exactement 30 jours Toronto, du plus ancien à aujourd'hui, jours vides inclus { "date": "2026-09-27", "models": { "claude-opus-5-5": { "input": 2, "output": 1950, "cacheCreation": 43176, "cacheRead": 33814, "requests": 1 } } } ], "projects": [ // top 8 sur 30 j (tokens de travail = input + output + cacheCreation), puis "(autres)" { "name": "simpl-resultat", "work": 123456, "cacheRead": 7890123, "requests": 812 } ] } ``` Bornes serveur (`sanitizeClaudeUsage`, liste blanche, tout champ inconnu jeté) : corps ≤ 64 Ko ; `days` ≤ 31, `date` `^\d{4}-\d{2}-\d{2}$` ; `models` ≤ 12 clés `^[a-z0-9][a-z0-9.\-]{0,63}$` ; `limits.items` ≤ 12 ; `projects` ≤ 9 ; nombres `Number.isSafeInteger` ≥ 0, `percent` 0-100 ; `status` dans l'enum ; `detail` `^[a-z0-9_]{1,32}$` sinon jeté ; autres chaînes ≤ 128 car. ; dates ISO validées. Le serveur ajoute `receivedAt`. Réponse de `GET /claude-usage` : `{ staleAfterSeconds, receivedAt, ageSeconds, stale, snapshot }`, ou `{ "status": "no_data" }` si le fichier est absent ou illisible. ## Faits sondés (2026-09-27) - `~/.claude/.credentials.json` contient `claudeAiOauth.{accessToken, refreshToken, expiresAt (ms epoch), ...}`. Les refresh tokens sont à usage unique : l'agent ne doit **jamais** rafraîchir ni écrire ce fichier, sinon Claude Code est déconnecté. - `GET https://api.anthropic.com/api/oauth/usage` (en-têtes `Authorization: Bearer <accessToken>`, `anthropic-beta: oauth-2025-04-20`, `User-Agent: claude-code/<version>`) renvoie un tableau `limits[]` : `{kind, group, percent, severity, resets_at, scope: {model: {display_name}} | null, is_active}`, ex. `session` 1 %, `weekly_all` 50 %, `weekly_scoped` Fable 63 %. Aussi `five_hour` / `seven_day` `{utilization, resets_at}` et une vingtaine de clés opaques `null`. Endpoint non documenté, rate-limit fort sans `Retry-After`. ## Tâches - [ ] `agent/claude-usage/limits.js` : - URL de l'endpoint en **constante** (aucun override par env), `fetch` avec `redirect: "error"`, timeout 10 s, un seul appel, aucun retry. - `CLAUDE_USAGE_LIMITS=off` → `status: "disabled"`, aucun appel. - Credentials ouverts en lecture seule ; absent → `unavailable`/`no_credentials` ; illisible ou JSON tronqué (réécriture en cours par Claude Code) → `unavailable`/`parse_error`, sans retry ni log du contenu ; `expiresAt` dépassé → `token_expired`, aucun appel ; `claudeCodeVersion` null → `unavailable`/`no_version`. - Réponse : `limits[]` → items (`resets_at` → `resetsAt`, `scope.model.display_name` → `model`) ; sinon repli `five_hour` → `{kind:"session", group:"session"}` et `seven_day` → `{kind:"weekly_all", group:"weekly"}` ; tout vide ou forme inattendue → `unavailable`/`bad_response` ; HTTP non 2xx → `http_error`/`http_<code>` ; timeout → `timeout` ; autre erreur réseau → `network`. `detail` vient **uniquement** de cet enum, jamais d'un message d'erreur ou d'un corps de réponse. - [ ] `agent/claude-usage/state.js` : `~/.local/state/maximus-claude-usage/state.json` (mode 600, écriture atomique tmp suffixé pid + rename) ; figer **tout** jour antérieur à hier Toronto, vide compris (`{models:{}, projects:{}}`) ; élaguer les jours figés de plus de 35 j ; `lastGoodLimits` mis à jour seulement sur `status: "ok"` et renvoyé avec son `fetchedAt` sinon ; fichier illisible → état vide (scan complet) sans échec. - [ ] `agent/claude-usage/push-claude-usage.js` : agrège (module `aggregate.js`), met à jour et écrit le cache **avant** le push, assemble le snapshot, mesure le corps (> 65 536 octets → log `payload_too_large`, code 2, pas d'envoi), pousse avec `Authorization: Bearer $CLAUDE_USAGE_INGEST_TOKEN`, timeout 10 s, sans retry. `CLAUDE_USAGE_API_URL` doit être en `https:` (sinon code 1). `--dry-run` imprime le snapshot sans l'envoyer. Codes : 0 succès, 1 configuration, 2 push échoué ou payload trop gros. Aucun jeton dans la sortie ; erreurs HTTP journalisées par code, jamais par objet d'erreur brut. - [ ] `agent/claude-usage/run-claude-usage.sh` : `flock -n ~/.local/state/maximus-claude-usage/run.lock` (sortie 0 si un passage tourne déjà) ; source `~/.config/maximus-claude-usage.env` (`CLAUDE_USAGE_API_URL`, `CLAUDE_USAGE_INGEST_TOKEN`, `CLAUDE_USAGE_LIMITS` et `NODE_BIN` optionnels) ; même style que `agent/run-push.sh`. - [ ] `agent/README.md`, section dédiée : commande `cp` exacte de `agent/claude-usage/` vers `~/.local/share/maximus-claude-usage/` (copie figée, jamais le working tree), fichier env (mode 600), ligne crontab `*/15 * * * * $HOME/.local/share/maximus-claude-usage/run-claude-usage.sh 2>&1 | logger -t claude-usage`, et une phrase expliquant pourquoi cet agent vit dans `vps-health-api` (contrat et sanitizer testés ensemble). ## Fichiers concernés - `agent/claude-usage/limits.js` (nouveau) - `agent/claude-usage/state.js` (nouveau) - `agent/claude-usage/push-claude-usage.js` (nouveau) - `agent/claude-usage/run-claude-usage.sh` (nouveau, exécutable) - `agent/README.md` - `__tests__/agent-claude-usage.test.js` - `__tests__/fixtures/claude-usage/` (réponse de l'endpoint anonymisée) ## Critères d'acceptation - [ ] Réponse anonymisée de l'endpoint → items attendus, dont `weekly_scoped` avec `model: "Fable"` ; `limits[]` absent → repli `five_hour`/`seven_day` ; tout `null` → `unavailable` - [ ] Jeton expiré, `CLAUDE_USAGE_LIMITS=off`, credentials tronqués, credentials absents : `fetch` (mocké) jamais appelé - [ ] 429 → `http_error`/`http_429`, items = dernières limites valides avec leur `fetchedAt` - [ ] Corps de réponse hostile (texte arbitraire, en-têtes renvoyés) → `detail` reste dans l'enum - [ ] L'en-tête `Authorization` du jeton OAuth n'est envoyé qu'à `https://api.anthropic.com` ; aucune variable d'env ne change cette URL - [ ] Le fichier de credentials n'est jamais ouvert en écriture (espion sur `fs`) - [ ] Le jeton OAuth et `CLAUDE_USAGE_INGEST_TOKEN` n'apparaissent ni dans le snapshot ni dans stdout/stderr - [ ] Deux passages consécutifs autour d'un jour vide : le jour vide est figé, et le second passage n'ouvre aucun fichier antérieur au seuil - [ ] `state.json` corrompu → passage complet sans échec ; élagage au-delà de 35 j - [ ] Snapshot produit sur fixtures accepté sans perte par `sanitizeClaudeUsage` (import de `../claude-usage.js`) - [ ] `CLAUDE_USAGE_API_URL` en `http:` → code 1 ; snapshot > 64 Ko → code 2 sans envoi - [ ] `run-claude-usage.sh` lancé deux fois en parallèle : le second sort 0 sans travailler ## Review caveats - 🟡 (résolu) URL Anthropic épinglée et `redirect: "error"` : le même processus tient le bearer OAuth et une URL de push configurable. - 🟢 (résolu) Verrou `flock` : le premier scan complet peut chevaucher le passage suivant. - 🟢 (résolu) Lecture des credentials pendant leur réécriture. - Risque accepté : `User-Agent: claude-code/<version>` imite le client officiel sur un endpoint non documenté (un appel / 15 min, interrupteur `off`). ## Décisions prises en planification - Ne jamais rafraîchir le jeton ; limites sautées si expiré. - Cache dans `~/.local/state/`, séparé de la copie figée du code dans `~/.local/share/`. - Tout l'agent (lanceur compris) dans `agent/claude-usage/`. ## Spec source la-compagnie-maximus : `spec-plan-suivi-claude.md`, Issue 3
maximus added this to the planned-2026-10-04-suivi-claude milestone 2026-10-04 12:59:46 +00:00
maximus added the
status:ready
type:feature
source:human
labels 2026-10-04 12:59:46 +00:00
maximus added
status:in-progress
and removed
status:ready
labels 2026-10-04 13:18:58 +00:00
maximus added
status:review
and removed
status:in-progress
labels 2026-10-04 13:29:20 +00:00
maximus added
status:approved
and removed
status:review
labels 2026-10-04 14:50:14 +00:00
maximus removed the
status:approved
label 2026-10-04 19:30:05 +00:00
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/vps-health-api#50
No description provided.