---
name: local-dashboard
description: Creare dashboard locali con widget live (agenti, costi token, salute sistema) serviti su rete locale via HTTP server.
category: productivity
---

# Local Dashboard — Emma

Crea dashboard HTML live con widget live (agenti, costi token, salute sistema) accessibili via LAN o internet. I dati sono generati da uno script Python periodico (cron) e serviti via HTTP.

## Trigger

Usa questa skill quando l'utente chiede:
- Una dashboard locale / un cruscotto / un pannello di controllo
- Widget per agenti, tools, skills, cron, providers, tickets, salute sistema
- Un "termometro" dello stato del sistema accessibile dal browser
- Monitoraggio costi token API
- "Voglio vedere X in una pagina web locale"

## Flusso di lavoro

### 1. Generazione dati (Python → JSON)

Crea uno script che raccoglie tutti i dati e li scrive in `~/.hermes/dashboard_data.json` (DEVE stare nella stessa cartella del server HTTP — solitamente `~/.hermes/`).

**Categorie di dati da raccogliere:**

| Widget | Dati | Comando/fonte |
|--------|------|---------------|
| Agenti | Versione Hermes, processi, utente, host | `hermes --version`, `ps aux`, `whoami` |
| Tools | Lista tools con stato | Lista hardcoded (online/idle) |
| Skills | Conteggio, categorie, top skill | `skills_list()` |
| **FAK Tariffe** | Confronto carrier, rotte caricate, link dashboard dettaglio | `fak_comparison` in `dashboard_data.json` |
| Cron Jobs | Job schedulati | `cronjob(action='list')` |
| Providers | Provider attivo, modello, endpoint | `~/.hermes/config.yaml` |
| Tickets | Ticket HR attivo da file JSON in `~/.hermes/tickets/` | Scansiona cartella `tickets/` per `.json` più recente (non path fisso — elimina file residuali come `current_status.json`) |
| Salute Acer | CPU, RAM, disco, uptime, kernel | `top`, `free`, `df`, `uname` |
| Costi Token | Token oggi/settimana/mese, costo $ | File JSON persistente + session data |

**Skills — Solo Emma (STRICT separation):**
L'utente ha corretto: "leva tutti i rf [riferimenti a Friz/Superboy]". Nel widget Skills:
- **Mostra SOLO skill di Emma**. Zero tracce di Friz/Superboy nella dashboard.
- Escludi completamente: GIS (flood-riverine, sentinel-*, watershed...), gaming (minecraft, pokemon), red-teaming (godmode), apple/* e qualsiasi skill non di Emma.
- Elenca `skills_emma` come tag viola (#a855f7) nella sezione top skill
- Le categorie nel grafico: Emma Skills, Produttivita, Ricerca & Media, Software Dev, Condivise (Base)
- **Mai menzionare altri agenti** nel display — l'utente vuole separazione netta.

**Token cost tracking persistente (con delta tracking via file) — FALLBACK:**

Il token tracking usa un file `last_lpt.txt` come marcatore per calcolare il delta di token consumato tra una esecuzione del cron e la successiva.

**⚠️ NOTA IMPORTANTE — DeepSeek V4 Flash su api.deepseek.com è GRATUITO:**
Non ci sono addebiti reali per le chiamate DeepSeek. I prezzi listati ($0.15/$0.30 per 1M) sono **teorici** — il provider non addebita. Lo script deve:
- Tracciare token e chiamate per statistiche
- Tenere COSTO a $0.00 per DeepSeek
- Solo Claude Sonnet 4 (via OpenRouter) ha costi reali, e quei costi arrivano dalla sessione Hermes (`estimated_cost_usd`)

**Logica di separazione dei costi nello script:**
```python
if delta_tokens > 0 and session_cost > 0 and ('sonnet' in session_model or 'claude' in session_model):
    # Costo reale Sonnet dalla sessione — usa quello
    tc_today['cost'] += session_cost
    tc_today['source'] = 'Sonnet 4 (reale)'
elif delta_tokens > 0:
    # DeepSeek = gratuito, track solo token/calls
    tc_today['input_tokens'] += stima
    tc_today['calls'] += 1
    tc_today['source'] = 'DeepSeek V4 Flash (gratuito)'
    # NON aggiungere costo!
```

**⚠️ Stallo da last_lpt bloccato — il problema più comune (DEPRECATO v2.0+):**
Se `last_lpt.txt` contiene un valore PIÙ ALTO della sessione corrente (es. vecchia sessione con 198k token, nuova sessione con 32k), il delta non si attiva mai e il costo resta a $0. Questo succede quando Hermes resetta la sessione o cambia formato.

**Diagnostica rapida — confrontare i due valori:**
```bash
cat ~/.hermes/last_lpt.txt
python3 -c "import json; d=json.load(open('~/.hermes/sessions/sessions.json')); print(f'lpt={d.get(\"last_prompt_tokens\",0)}')"
```
Se last_lpt > current_lpt → è bloccato.

**Fix con attenzione (solo per debug — il nuovo sistema separa i costi per provider):**
1. **PRIMA** — cerca nella cronologia (`session_search`) messaggi di Kuki sui costi Sonnet (es. "+$1.70 in 3 minuti"). Se trovi costi reali, scrivili in KNOWN_COSTS nello script.
2. **POI** — azzera il tracker: `echo 0 > ~/.hermes/last_lpt.txt`
3. **VERIFICA** — riesegui lo script: `python3 ~/.hermes/scripts/dashboard_refresh.py`. Il delta dovrebbe ripartire e KNOWN_COSTS correggere il totale.
4. Se **dimentichi il passo 1**, i costi Sonnet della sessione precedente (es. $1.70) vengono persi e sostituiti dalla stima DeepSeek ($0.006). Recuperabili solo da cronologia conversazione.

**NOTA v2.0+:** Con la nuova logica che separa DeepSeek (gratuito) da Sonnet (pagato), lo stallo di last_lpt è meno critico — anche se il delta non parte, i costi Sonnet vengono dalla sessione e quelli DeepSeek sono $0. Ma last_lpt serve ancora per tracciare il conteggio chiamate.

**Formato sessioni Hermes attuale:**
Hermes salva le sessioni in `~/.hermes/sessions/sessions.json` con un singolo oggetto che ha `last_prompt_tokens`, `input_tokens`, `output_tokens`, `estimated_cost_usd` e `total_tokens`. NON più un array di dump. `estimated_cost_usd` è spesso 0 per DeepSeek (non riporta costi) — va calcolato manualmente dai prezzi stimati.

Logica di scanning: cerca PRIMA `sessions.json`, poi cade sui vecchi `request_dump_*.json` come fallback.

### 2. Dashboard HTML

Crea il file HTML della dashboard (es. `~/.hermes/emma-dashboard.html`) come singolo file auto-contenuto con:
- Tema scuro (GitHub-dark style: `#0d1117` sfondo, `#161b22` card)
- **Titolo**: "Emma Dashboard 🦊" (non "Emma's Dashboard" — usa l'emoji 🦊)
- Layout a 2 griglie: `grid-template-columns: repeat(auto-fill, minmax(340px, 1fr))` e `minmax(480px, 1fr)` per card più larghe
- Card per ogni widget: header (titolo + badge count) + body (metriche, barre, liste)
- Barre di progresso colorate per CPU/RAM/disco (verde <60%, giallo 60-80%, rosso >80%)
- Icone emoji per ogni sezione (📡 🤖 🛠️ 📚 ⏰ 🎫 💰 🖥️ 🔗)
- **Auto-refresh JS ogni 30 secondi** (non solo pulsante manuale)

**Layout v2.1 (6 widget box + 1 tabella + token counter):**

```
┌─ RIGA 1 (2 colonne) ────────────────────────────┐
│  📡 Stato Sistema        │  💰 Costo LLM        │
│  Gateway ● Modello ●     │  Oggi  Sett  Mese    │
│  API ● HTTP ●            │  DeepSeek (gratuito)  │
│                          │  ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│                          │  Token IN  OUT  TOT  │
├─ RIGA 2 (2 colonne) ────────────────────────────┤
│  🖥️ Salute Acer          │  🎫 Ticket           │
│  CPU ████  RAM ███       │  #TICKET-001 Talent  │
│  Disco ██   Uptime 5h    │  APERTO · alta       │
├─ RIGA 3 (2 colonne) ────────────────────────────┤
│  ⏰ Cron Jobs (tabella)  │  📚 Skills            │
│  Nome│Sched│Stato│Esito  │  32 totali · 1 Emma  │
└──────────────────────────────────────────────────┘
```

**Caricamento dati via fetch (con cache-busting e auto-refresh):**
```javascript
async function loadData() {
  const resp = await fetch('/dashboard_data.json?_t='+Date.now());
  const d = await resp.json();
  render(d);
}
loadData();
setInterval(loadData, 30000); // refresh ogni 30 secondi
```

**Widget costi token (v2.0 — con separazione gratuito/pagato):**
- 3 metriche in riga: Oggi, Settimana, Mese
- Sotto: dettaglio testo (es. "DeepSeek V4 Flash (gratuito)" oppure "⚠️ Claude Sonnet 4 · $1.70 oggi")
- Se la source contiene "Sonnet" o "Claude" → mostra costo reale con ⚠️
- Se la source contiene "gratuito" o è DeepSeek → mostra "$0.00" senza warning

**Widget token counter (sotto i costi — richiesto esplicitamente da Kuki):**
Aggiungere una terza riga sotto il cost-row con divisore e Token IN / Token OUT / Totale:

```html
<div class="cost-row" style="margin-top:6px;padding-top:6px;border-top:1px solid #30363d;">
  <div class="box"><div class="v" id="token-input">0</div><div class="l">Token IN</div></div>
  <div class="box"><div class="v" id="token-output">0</div><div class="l">Token OUT</div></div>
  <div class="box"><div class="v" id="token-totali">0</div><div class="l">Totale</div></div>
</div>
```

JS rendering pattern:
```javascript
const tokIn = oggi.input_tokens || 0;
const tokOut = oggi.output_tokens || 0;
document.getElementById('token-input').textContent = tokIn.toLocaleString('it-CH');
document.getElementById('token-output').textContent = tokOut.toLocaleString('it-CH');
document.getElementById('token-totali').textContent = (tokIn + tokOut).toLocaleString('it-CH');
```

Layout di riferimento (RIGA 1 aggiornata):
```
┌─ STATO ─────────────┐ ┌─ COSTO LLM ──────────────────┐
│ Gateway ● Modello ●│ │ Oggi  Settimana  Mese       │
│ API ● HTTP ●        │ │ DeepSeek (gratuito)          │
│                      │ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│                      │ │ Token IN  Token OUT  Totale │
└──────────────────────┘ └──────────────────────────────┘
```

**Pattern di riconoscimento provider dal JSON:**
```javascript
const today = d.costi.today || {};
let costDetail = 'DeepSeek V4 Flash (gratuito)';
if (today.source && today.source.includes('Sonnet')) {
  costDetail = `⚠️ Claude Sonnet 4 · $${today.cost.toFixed(2)} oggi`;
}
document.getElementById('cost-detail').textContent = costDetail;
```

### 3. Server HTTP

Avvia il server per rendere la dashboard accessibile dalla rete.

**Metodo corretto (background Hermes):**
```python
terminal(
    command="python3 -m http.server 8080 --bind 0.0.0.0",
    background=True,
)
```

**Bind address — regole per scenario:**

| Scenario | Server su | URL di accesso |
|----------|-----------|----------------|
| Solo LAN (rete casa) | `192.168.1.102` | `http://192.168.1.102:8080/dashboard.html` |
| Tailscale Serve proxy | `127.0.0.1` | `https://acer.tail164efe.ts.net:8443/dashboard.html` |
| Tutti gli IP (massima flessibilità) | `0.0.0.0` | Entrambi + localhost |
| Aziendale senza Tailscale sul PC | `127.0.0.1` + Funnel | `https://acer.tail164efe.ts.net:443/...` |

**⚠️ Regola critica:** Quando usi Tailscale Serve o Funnel, il server backend DEVE ascoltare su `127.0.0.1` (localhost). Serve punta a `http://127.0.0.1:8080` — se il server è su `192.168.1.102`, ottieni **502 Bad Gateway**.

Quando Kuki accede dalla LAN (stessa rete), il server deve essere su `192.168.1.102` (o `0.0.0.0`).

**Verifica:**
```bash
ss -tlnp | grep 8080
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/dashboard.html
```

**⚠️ Process lifecycle:**
- `terminal(background=True)` SENZA `notify_on_complete` è la scelta GIUSTA per un server — non deve mai "completare"
- Hermes NON terrà traccia del PID. `process(action='list')` mostrerà `[]`
- Per verificare: `ps aux | grep http.server`

**⚠️ Pitfall — due server sulla stessa porta ma IP diversi:**

Se avvii un server su `192.168.1.102:8080` e un secondo su `127.0.0.1:8080`, **entrambi coesistono** perché Linux considera IP+porta come socket diversi. `ss -tlnp | grep 8080` mostra entrambi.

Questo confonde il debug: Tailscale Serve punta a `127.0.0.1`, ma il secondo server serve su `192.168.1.102`.

**Fix:** killare TUTTI i processi sulla porta PRIMA di riavviare:
```bash
# Trova TUTTI i processi http.server sulla 8080
ps aux | grep 'python3.*http.server.*8080' | grep -v grep
# Killali tutti
kill <PID1> <PID2> 2>/dev/null
sleep 1
# POI riavvia sul server giusto
python3 -m http.server 8080 --bind <IP corretto>
```

Alternativa one-liner: `kill $(lsof -ti:8080) 2>/dev/null`

**⚠️ Pitfall — NON fare questo:**
```python
# SBAGLIATO — foreground con & interno
terminal(command="cd /home/oem && nohup python3 -m http.server... &")
# Causa errore: "Foreground command uses shell-level background wrappers"
```

**⚠️ Background completion notification:**
Quando il server muore (kill, crash), Hermes mostra `[IMPORTANT: Background process proc_xxx completed]` con il LOG COMPLETO nel prossimo turno. Questo log è una **fonte di diagnostica**:
- Quali IP connessi (es. `192.168.1.105` = PC principale)
- File serviti (`dashboard.html` → `dashboard_data.json`)
- Codici risposta (200=ok, 304=cached, 404=non trovato)

### 4. Accesso remoto — Tailscale Serve vs Funnel

**Decision tree (in ordine di prova):**

1. **Tailscale Serve** (solo tailnet — Kuki DEVE avere Tailscale sul PC):
   ```bash
   # Avvia server su 127.0.0.1
   python3 -m http.server 8080 --bind 127.0.0.1
   # Esponi via Serve sulla 8443
   sudo tailscale serve --bg --https=8443 8080
   ```
   → URL: `https://acer.tail164efe.ts.net:8443/dashboard.html`

2. **Tailscale Funnel** (pubblico su internet — Kuki NON ha Tailscale):
   ```bash
   sudo tailscale funnel --bg --https=443 8080
   ```
   → URL: `https://acer.tail164efe.ts.net/dashboard.html`

3. **Funnel su porta alternativa:**
   ```bash
   sudo tailscale funnel --bg --https=10000 8080
   ```
   → URL: `https://acer.tail164efe.ts.net:10000/dashboard.html`

4. **Serve con URL esplicita** (quando Funnel dà 502 perché backend su IP sbagliato):
   ```bash
   sudo tailscale funnel --https=8443 off
   sudo tailscale serve --bg --https=8443 http://127.0.0.1:8080
   ```
   Poi verifica backend: `curl -s http://127.0.0.1:8080/dashboard.html`

**Regola pratica:** dopo 3 tentativi (Serve + Funnel 443 + Funnel 10000) senza successo, il proxy/firewall aziendale è impenetrabile. Vai al Telegram fallback.

### 5. Riavvio dopo shutdown Acer

Tailscale Serve/Funnel NON sono persistenti tra riavvii. Dopo un reboot:

```bash
# 1. Riavvia server backend
cd /home/oem && python3 -m http.server 8080 --bind 127.0.0.1 &

# 2. Riconfigura Serve (o Funnel)
sudo tailscale serve --bg --https=8443 8080
```

Verifica sempre con `tailscale serve status` prima di dire a Kuki di riprovare.

### 6. Diagnostica — "il link non funziona"

**Decision tree diagnostica (dalla macchina host, Acer):**

1. `ss -tlnp | grep 8080` — il server è in ascolto?
2. `curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/dashboard.html` — risponde localmente?
3. `ip neigh show | grep 192.168.1.105` — Kuki è nella stessa LAN? (NON usare ping — Windows blocca ICMP)
4. Se MAC è REACHABLE ma HTTP fallisce → firewall aziendale blocca la porta. Vai a passo 5.
5. Prova `https://acer.tail164efe.ts.net:8443/dashboard.html` (Serve)
6. Prova `https://acer.tail164efe.ts.net/dashboard.html` (Funnel 443)
7. Prova `https://acer.tail164efe.ts.net:10000/dashboard.html` (Funnel 10000)
8. Prova `https://acer.tail164efe.ts.net:10000/dashboard.html` (Funnel 10000)
9. Prova `https://<random>.trycloudflare.com/dashboard.html` (Cloudflare Tunnel)
10. **TUTTO FALLISCE** → non offrire Telegram sostitutivo. Kuki ha rifiutato esplicitamente.

> Per diagnostica completa, vedi `references/dashboard-server-troubleshooting.md`.

## Flask Web App (dinamica, non solo HTML statico)

Quando serve una dashboard INTERATTIVA che i colleghi possono consultare autonomamente (non solo HTML statico), crea una **mini web app Flask** con:

**Pattern — web app con form richiesta + API REST:**
```python
from flask import Flask, render_template_string, request, jsonify
import json

app = Flask(__name__)

@app.route("/")
def home():
    # Dati JSON inline nel template (no API separata)
    return render_template_string(TEMPLATE, routes_json=json.dumps(ROUTES))

@app.route("/api/search")
def api_search():
    pol = request.args.get("pol", "").upper()
    pod = request.args.get("pod", "").upper()
    # Cerca e restituisce JSON
    return jsonify({"best": best_price, "all": matches})

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000, debug=False)
```

**Installazione Flask (sempre nel venv di Hermes, non system Python):**
```bash
/home/oem/.local/share/pipx/venvs/hermes-agent/bin/python3 -m pip install flask
```

**Avvio:**
```bash
cd ~/.hermes/fak-comparison && /home/oem/.local/share/pipx/venvs/hermes-agent/bin/python3 fak_webapp.py 5000
```

**⚠️ Pitfall — file troncati con f-string multilinee:**
Quando generi HTML con una singola f-string gigante di 17K+ caratteri, lo script di salvataggio (`write_file` o `skill_manage`) può troncare il file a ~17KB. **Fix:** costruisci l'HTML con concatenazione di stringhe brevi:
```python
# SBAGLIATO — si tronca
html = f"""<!DOCTYPE html>
<html>...molte righe...</html>"""

# GIUSTO — concatenazione
html = '<!DOCTYPE html>\n<html>\n'
html += '<head>...'
html += '<style>...'  # CSS inline come stringhe brevi
html += '<body>...'
html += '<script>'  # JS inline come stringhe brevi
```

**⚠️ Pitfall — porta già in uso:** Controlla sempre prima di avviare:
```bash
ss -tlnp | grep 5000
kill $(lsof -ti:5000) 2>/dev/null  # libera se necessario
```

## Cloudflare Tunnel (TryCloudflare — instabile, usa solo come fallback)

Quando il firewall/proxy aziendale blocca anche il Funnel su porta 443 e 8443, si può usare **Cloudflare Tunnel** (trycloudflare) come alternativa HTTPS pubblica.

**⚠️ Limitazioni principali:**
- L'URL trycloudflare **cambia ogni riavvio** del tunnel — NON è persistente
- Il tunnel è instabile: timeout frequenti su `api.trycloudflare.com` (context deadline exceeded)
- Serve un dominio Cloudflare personale per URL fisso (cloudflared tunnel route DNS)
- **Preferire sempre Tailscale Funnel** come prima opzione

**Installazione (one-time):**
```bash
curl -sL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /tmp/cloudflared
chmod +x /tmp/cloudflared
/tmp/cloudflared version
```

**Avvio tunnel (con porta metrics unica per evitare conflitti):**
```bash
# Usa porta metrics diversa ogni volta per evitare conflitti da processi orfani
/tmp/cloudflared tunnel --url http://localhost:8080 --no-autoupdate --metrics 0.0.0.0:54323
```

**Ottenere URL pubblico:**
```bash
# Aspetta ~8 secondi
sleep 8 && curl -s http://localhost:54323/quicktunnel
# → {"hostname":"random-words.trycloudflare.com"}
```

**URL finale:** `https://random-words.trycloudflare.com/emma-dashboard.html`

**⚠️ Pitfall — porta metrics già in uso da processo orfano:**
Se cloudflared muore ma il processo metrics rimane sulla porta, il nuovo tunnel fallisce silenziosamente. **Usa sempre una porta metrics diversa** (54321, 54322, 54323, ...) o kill esplicito:
```bash
pkill -f "cloudflared tunnel" 2>/dev/null
sleep 1
```

**Ordine di prova completo (dal più probabile al più improbabile, favorisci Tailscale Funnel):**

| # | Metodo | URL | Stabilità | Note |
|---|--------|-----|-----------|------|
| 1 | **Tailscale Funnel** 443 | `https://acer.tail164efe.ts.net/dashboard.html` | ✅ **Stabile, URL FISSO** | Prima scelta assoluta |
| 2 | Tailscale Funnel 8443 | `https://acer.tail164efe.ts.net:8443/dashboard.html` | ✅ Stabile | Se 443 occupata |
| 3 | Cloudflare Tunnel | `https://<random>.trycloudflare.com/dashboard.html` | ❌ URL cambia | Fallback |
| 4 | Tailscale Serve | Solo tailnet | ✅ Stabile | Solo chi ha Tailscale |
| 5 | LAN diretta | `http://10.205.63.252:8080/` | ✅ Stabile | Solo stessa rete |

**⚠️ Kuki NON vuole il fallback Telegram per la dashboard.** Ha rifiutato esplicitamente: "non li voglio su Telegram, voglio averla su internet con gli aggiornamenti che avevamo stabilito". Non proporre Telegram come alternativa — usa solo come ultima risorsa per comunicazione SUPPLEMENTARE (alert, anomalie), non sostitutiva.

## Multi-provider cost tracking (Sonnet vs DeepSeek)

**Il problema:** Kuki usa due provider con prezzi molto diversi:
- **DeepSeek V4 Flash** via `api.deepseek.com`: **GRATUITO** (nessun addebito reale)
- **Claude Sonnet 4** via OpenRouter: ~$15/1M input, $75/1M output (costo reale)

Hermes salva `estimated_cost_usd: 0.0` per DeepSeek (non popolato). Per Sonnet, Hermes a volte popola il costo reale dalla sessione.

**Nuovo approccio (v2.0+): separare per provider, non forzare con KNOWN_COSTS:**

Lo script `dashboard_refresh.py` ora:
1. Legge `estimated_cost_usd` + `model` + `provider` dalla sessione corrente
2. Se il modello contiene "sonnet" o "claude" E c'è un costo reale → usa quello
3. Se è DeepSeek → costo $0 (gratuito), traccia solo token/calls
4. KNOWN_COSTS non serve più — il dato reale arriva dalla sessione

**KNOWN_COSTS è deprecato (mantenuto solo per backward compatibility).**

## Criteri di progettazione dashboard

| Elemento | Scelta | Perché |
|----------|--------|--------|
| Tema | Scuro (GitHub Dark) | Leggibile, professionale |
| Dati | JSON statico via fetch | Nessun backend necessario |
| Refresh | Cron job 5min + JS auto-refresh 30s + aggiornamento manuale | Leggero, no websocket |
| Layout | CSS Grid, auto-fill, 340px e 480px min | Responsivo, 1-2 colonne |
| Metriche | Grandi numeri + label sottili | Colpo d'occhio immediato |
| Barre | Colorate per soglia | Semaforo immediato |
| Piattaforme | Chip inline | Compatto, informativo |
| Costi | DeepSeek = gratuito (tracciato ma $0), Sonnet = reale (da sessione) | Costi realistici |

**⚠️ Critical Pitfalls:**
- **Stallo da last_lpt bloccato (v2.0+ minor):** ora DeepSeek è gratuito e Sonnet letto da sessione, ma last_lpt serve ancora per conteggio chiamate. Se bloccato, azzera `last_lpt.txt`.
- **Multi-provider cost confusion (v2.0+ risolto):** Separazione per provider via `session_model` e `session_provider`.
- **JSON in cartella sbagliata:** `dashboard_data.json` DEVE stare in `~/.hermes/` (dove punta il server HTTP), non in `~/`.
- **no_agent=True fondamentale:** il cron job DEVE usare `no_agent=True` + `script=`. Lo script non deve fare print a stdout.
- **Titolo: "Emma Dashboard 🦊"** — con emoji volpe, non "Emma's Dashboard".
- **Accesso LAN vs WAN:** Kuki dalla LAN usa `http://192.168.1.102:8080/emma-dashboard.html`. Da PC aziendale bloccato → solo Tailscale (Serve o Funnel).
- **File JSON residuali nella cartella tickets:** Eliminare `current_status.json` vecchi per non falsare il conteggio ticket.

## Script di riferimento (v2.0)

Lo script ``~/.hermes/scripts/dashboard_refresh.py`` è il riferimento attuale. Contiene:
1. Scansione sessioni Hermes in due formati (sessions.json + request_dump_*.json)
2. Calcolo delta token con salvataggio su last_lpt.txt
3. **Separazione costi per provider:** DeepSeek gratuito vs Sonnet reale
4. **Scansione ticket:** Cerca file `.json` nella cartella `~/.hermes/tickets/` (prende il più recente)
5. Generazione JSON della dashboard

Per vedere lo script attuale: leggi `~/.hermes/scripts/dashboard_refresh.py`.

## Script di supporto

### Parsing cron jobs (`scripts/cron-list-parser.py`)

`hermes cron list` NON supporta `--json`. L'output è tabellare ANSI con formato:
```
  a066d6c0271e [active]           # 2 spazi + job_id + [stato]
    Name:      dashboard-refresh  # 4 spazi + campo: valore
    Schedule:  every 5m
    Last run:  2026-06-14T13:56:30.673079+02:00  ok
```

Uno script riutilizzabile per parsare questo output è in `scripts/cron-list-parser.py`. Pattern chiave:
- `line.rstrip()` (non `line.strip()`) per mantenere spazi iniziali
- `\s{2}([a-f0-9]+)\s+\[(\w+)\]` per job_id e stato
- `\s{4}(\w[\w\s-]*?):\s+(.*)` per campi indentati
- Aggiungere `env['PATH']` per esecuzione in contesto no_agent cron
