Sopra il prompt: memoria della chat, uso delle 5 ore, della settimana e limite di spesa con data di scadenza, durata della sessione, cache calda, stato della…

Un marketplace di mod per Claude Code, pensato per capire e controllare una sessione: barra della cache e dei consumi, registro di ciò che viene scritto fuori dalla repo, conferma delle proposte, raccolta degli inciampi, modalità rec per le registrazioni e inbox con bozze, etichette e task. Ogni mod è un plugin indipendente, installabile singolarmente con un comando.
Ogni mod si accende e si spegne con un comando /..., che funziona in terminale, nell'app Desktop e da iPad. Lo stato si ricorda tra una sessione e l'altra.
⏳ barra-cache (/barra) — tre righe sopra il prompt. La prima è la memoria della chat: una barra con i token del contesto occupato e quanto è costata la sessione a listino (verde fino al 60%, gialla fino all'80%, rossa oltre, con il suggerimento Nuova chat). La seconda mostra l'*uso delle 5 ore* e l'*uso della settimana* dell'abbonamento, solo dove il motore riporta le cifre. La terza dice da quanto è aperta la sessione e quanto resta di cache calda: la prompt cache dura un'ora dall'ultima richiesta della conversazione principale (le richieste dei subagent non la rinnovano), poi la richiesta successiva riscrive tutto il contesto a prezzo pieno. Con la cache scaduta e almeno 20mila token di contesto compare un avviso rosso. Sotto, una riga con branch, file modificati e commit da pubblicare (letta da git, solo dove $.process esiste: nel cloud non compare) e i bottoni: Commit e push (tasto g), Push (tasto p, compare solo quando ci sono commit da pubblicare e nessuna modifica da committare, senza token) e Handoff (tasto n), che fa scrivere al modello un riassunto di ripartenza in ~/.claude/handoffs/, svuota la chat con /clear e riparte da quel file. Dove la banda non viene disegnata (ad esempio l'app su iPad) restano /cache, che mostra minuti rimasti, contesto, costo dell'ultimo turno e quota di cache letta, /push e /handoff (alias /nuova). I comandi sono anche file statici in barra-cache/commands/, perché l'elenco dello slash dell'app non mostra quelli registrati a runtime; l'hook risponde prima del modello, quindi non costano token.
📒 scritture-esterne (/scritture) — a fine turno elenca tutto ciò che il turno ha scritto fuori dalla repo: Notion, Postpickr, Spreaker, Gmail, Calendar, Drive, git push, gh, curl -X POST e gli script con --applica o --elimina. Ogni riga ha servizio, azione, bersaglio, un link apri se la risposta ne contiene uno, e una croce rossa se la chiamata è fallita o negata. Le letture non compaiono. Il registro sparisce all'inizio del turno successivo. Riconosce dal nome i connettori più comuni; per quelli con id opaco serve l'opzione servizi del plugin.
✅ conferma-proposte (/conferma) — quando l'ultima risposta di Claude chiude con una proposta o una domanda di conferma (una domanda, oppure formule come "procedo", "vuoi che", "confermi"), mostra sopra il prompt il passaggio che propone e due bottoni: Sì, procedi (tasto s) e No, fermati (tasto x). Il bottone invia la risposta come se l'avessi scritta tu. Non usa il modello, quindi non consuma token. Funziona anche nel cloud e da iPad.
🛠 correggi (/correggi) — durante la sessione annota gli inciampi: tool che falliscono, azioni negate dal sistema di permessi e le tue correzioni ("non vedo", "hai sbagliato", "riprova"). Con almeno due inciampi compare una riga con il conteggio, le skill usate e il bottone Proponi correzione (tasto l), che chiede a Claude di individuare la causa e proporre la modifica esatta alla skill o al file di istruzioni, senza applicarla. /correggi elenco mostra gli inciampi raccolti. Consuma token solo quando premi il bottone.
🔴 rec (/rec) — la modalità per registrare un video o lavorare in una sessione live con ospiti. Maschera a schermo chiavi e valori dei .env, email, nomi, telefoni, indirizzi, codice fiscale, partita IVA, IBAN, importi in euro e cifre vicino a parole come fatturato, margine, compenso, preventivo. I risultati di posta, chat, task, file, calendario, Notion e strumenti di pagamento si disegnano nascosti. Claude continua a lavorare sui dati reali: cambia solo ciò che si vede (se modifica un file da 29 a 39 euro, il file cambia davvero, lo schermo no). Tiene chiusi i file privati (.env, credenziali, fatture, contratti, preventivi, buste paga) e gli strumenti di pagamento, e ogni prompt porta una nota nascosta che chiede a Claude di usare segnaposto al posto di nomi e cifre. Un ● REC rosso sopra il prompt e nel piè di pagina ricorda che è acceso. /rec alterna, /rec rigoroso maschera anche ogni cifra grande, /rec off spegne, /rec config crea ~/.claude/mods-data/rec/config.json per il tuo nome (nomiVisibili), le persone da nascondere (nomiNascosti), le cartelle private (percorsiPrivati) e gli strumenti extra (strumentiAffari, strumentiChiusi). Limiti: cambia ciò che è disegnato, non ciò che è memorizzato; il riconoscimento per pattern non prende tutto quello che è scritto a parole, quindi riguarda il girato prima di pubblicarlo; i titoli delle chat nella barra laterale dell'app Desktop non si possono mascherare. Adattata da recording-mode di Nate Herk (MIT, vedi rec/NOTICE.md).
📬 posta (posta o /posta) — apre un pannello con la inbox Gmail: l'elenco delle ultime 12 mail per account (una finestra di 5 righe che scorre con j e k), i bottoni e, sotto, il testo intero della mail selezionata (tutti i messaggi del thread, dal più recente, con allegati), letto da Gmail senza modello. I bottoni sono Bozza (b), Label (l), Bozza+Label (m), Task (t), più Aggiorna (r) e Chiudi (x). Le quattro azioni girano dentro la mod, senza subagent: il modello riceve il thread senza alcun tool e sceglie solo i contenuti, mentre le scritture su Gmail e Notion le fa il codice dopo averle validate (la chat resta pulita: l'esito compare nel pannello e in un avviso). Bozza scrive con le regole di ~/.claude/mods-data/posta/sistematore.md e crea una bozza di risposta (la mail non viene mai inviata). Label fa scegliere al modello economico un'etichetta tra quelle esistenti e la applica solo se il nome coincide (non ne crea). Task fa proporre al modello economico titolo, scadenza, urgenza, importanza, impegno e contesto, li riporta ai valori ammessi (scadenza non valida o passata: domani alle 09:00) e crea la pagina nel database Tasks di Notion con il link alla mail. Solo Label e Bozza+Label tolgono la mail dall'inbox (finisce nella cartella dell'etichetta); Bozza e Task la lasciano in inbox. I lavori in corso mostrano uno spinner, quelli riusciti spariscono dopo 8 secondi e quelli falliti si rilanciano con Riprova (y). Nel dettaglio compare l'ultimo messaggio del thread; i precedenti sono righe ripiegate e si aprono con Mostra precedenti (p). Scrivendo posta senza barra il pannello si apre senza avviso dell'app e senza token; posta off lo chiude. Gli account sono in DEFAULT_ACCOUNTS e si sostituiscono con la chiave accounts dello store. In modalità Auto anche le chiamate della mod passano dal classificatore, che sulle scritture non dà verdetto: vanno autorizzate in permissions.allow le letture Gmail (search_threads, get_thread, list_labels) e le quattro scritture (create_draft, label_thread, unlabel_thread di Gmail, notion-create-pages di Notion), con il prefisso mcp__<id connettore>__. Un errore resta visibile solo sulla mail a cui appartiene e sparisce quando la stessa azione riesce.
claude --version.Una mod è codice eseguito con i tuoi permessi: può leggere e scrivere file, avviare processi e vedere prompt e tool call della sessione. Il sorgente di ogni mod è in hooks/: leggilo prima dell'installazione.
claude plugin marketplace add andreabrugnoli/mods
claude plugin install barra-cache@andrea-mods
claude plugin install scritture-esterne@andrea-mods
claude plugin install conferma-proposte@andrea-mods
claude plugin install correggi@andrea-mods
claude plugin install rec@andrea-mods
claude plugin install posta@andrea-mods
claude.Verifica: apri /plugin e controlla che le mod risultino attive. Per provare una mod in una sola sessione senza installarla, clona la repo e avvia claude --plugin-dir ./mods/barra-cache. Per disattivarla, usa il tab Installed di /plugin.
Le sessioni cloud (claude.ai/code, app per iPad) non leggono le tue impostazioni locali: partono da un contenitore pulito e caricano i plugin dichiarati nella repo su cui lavori. Per attivare una mod in una repo, aggiungi a .claude/settings.json di quella repo:
{
"extraKnownMarketplaces": {
"andrea-mods": { "source": { "source": "github", "repo": "andreabrugnoli/mods" } }
},
"enabledPlugins": { "barra-cache@andrea-mods": true, "rec@andrea-mods": true }
}
Lo fa per te scripts/abilita-cloud.sh <percorso-repo> [mod ...], che unisce la voce alle impostazioni esistenti. Se il tuo gitignore (anche globale) esclude .claude/settings.json, aggiungilo con git add -f .claude/settings.json, poi commit e push. La sessione cloud legge il file dal branch.
Su iPad la banda sopra il prompt può non essere disegnata: restano /cache, /push e /nuova, e tutti i comandi on/off (/barra, /scritture, /conferma, /correggi, /rec) funzionano uguale. Lo stato ($.store) e i riassunti in ~/.claude/handoffs/ vivono nel contenitore della sessione e si perdono alla sua chiusura.
Accendere e spegnere (senza argomento alternano; accettano anche on e off):
/barra: la barra sopra il prompt./scritture: il registro delle scritture esterne./conferma: i bottoni Sì e No sulle proposte./correggi: la raccolta degli inciampi (/correggi elenco li mostra)./rec: la modalità registrazione (/rec rigoroso, /rec off, /rec config).posta o /posta: la inbox con i bottoni Bozza, Label e Task (posta aggiorna, posta off).Altri comandi di barra-cache:
/cache: minuti di cache rimasti, contesto occupato, costo dell'ultimo turno e quota di cache letta./push: pubblica i commit del branch corrente./nuova: riassume la sessione in un file e riparte da una chat pulita.Durata della cache in barra-cache: parte da un'ora (TTL_MS) e si corregge da sola. Dopo una pausa di almeno 6 minuti guarda la quota di cache letta: se la cache è stata riscritta, passa a 5 minuti e lo ricorda tra le sessioni; se è stata letta, conferma l'ora.
Soglie dei colori in barra-cache: CONTEXT_WARN, CONTEXT_BAD, COST_WARN, CACHE_OK e CACHE_BAD, in cima allo stesso file.
Nomi dei connettori in scritture-esterne: opzione servizi del plugin.
Per modificare una mod, clona la repo e caricala con claude --plugin-dir: ogni salvataggio ricarica il modulo nella sessione aperta.
mods/
├── .claude-plugin/marketplace.json # elenco delle mod installabili
├── barra-cache/
├── scritture-esterne/
├── conferma-proposte/
├── correggi/
├── rec/
├── posta/
└── scripts/abilita-cloud.sh
Ogni cartella di mod è un plugin completo: .claude-plugin/plugin.json, hooks/hooks.json, hooks/register.js e tests/ (eseguibili con claude plugin test ./<mod>). claude plugin validate ./<mod> elenca gli eventi intercettati e le chiamate al motore.
Licenza: MIT.
hooks/register.js 501 lines1// Quanto dura la prompt cache dall'ultima richiesta che l'ha letta o scritta
2const TTL_MS = 60 * 60 * 1000
3// La durata breve della cache (5 minuti), e la pausa minima per capire quale delle due è in uso
4const SHORT_TTL_MS = 5 * 60 * 1000
5const GAP_MIN_MS = 6 * 60 * 1000
6// La durata in uso: parte da un'ora e si corregge da sola osservando la cache letta dopo una pausa
7let ttlMs = TTL_MS
8// Ogni quanto la barra si ridisegna da sola
9const TICK_MS = 30 * 1000
10
11// L'istante dell'ultima richiesta della conversazione principale: da lì riparte l'ora
12let lastAt = null
13// L'ora letta all'ultimo tick o all'ultima richiesta
14let now = 0
15// Il timer che fa scorrere la barra, avviato alla prima richiesta
16let ticking = null
17
18// Soglie dei colori: contesto in percentuale, costo di un turno in dollari, cache letta in percentuale
19const CONTEXT_WARN = 60
20const CONTEXT_BAD = 80
21const COST_WARN = 0.5
22const CACHE_OK = 80
23const CACHE_BAD = 50
24
25// Il contesto e il costo letti dal motore: { percent, tokens, window, usd }, null dove non rispondono
26let usage = null
27// Il turno concluso: { usd, fresh, out }, dove "fresh" sono i token pagati interi (non letti dalla cache)
28let lastTurn = null
29// Il costo della sessione al termine del turno precedente, per sottrarlo
30let costBefore = null
31// I token del turno in corso, sommati richiesta per richiesta
32let turnFresh = 0
33let turnOut = 0
34// Quanta parte dell'ultima richiesta è stata letta dalla cache, da 0 a 100
35let cacheReadPct = null
36
37// L'istante in cui è cominciata la sessione, ripreso dall'archivio se è la stessa sessione
38let startedAt = null
39
40// Durata in forma breve: 45 min, 2h 14m
41function duration(ms) {
42 const minutes = Math.max(0, Math.floor(ms / 60000))
43 return minutes < 60 ? minutes + ' min' : Math.floor(minutes / 60) + 'h ' + (minutes % 60) + 'm'
44}
45
46// Token in forma breve: 950, 12k, 1,2M
47function short(n) {
48 const one = (x) => x.toFixed(1).replace('.', ',').replace(/,0$/, '')
49 if (n >= 1e6) return one(n / 1e6) + 'M'
50 if (n >= 1e4) return Math.round(n / 1e3) + 'k'
51 if (n >= 1e3) return one(n / 1e3) + 'k'
52 return String(n)
53}
54
55// Token in parole: 950, 12mila, 1,2 milioni
56function tokensIt(n) {
57 const one = (x) => x.toFixed(1).replace('.', ',').replace(/,0$/, '')
58 if (n >= 1e6) return one(n / 1e6) + ' milioni'
59 if (n >= 1e4) return Math.round(n / 1e3) + 'mila'
60 if (n >= 1e3) return one(n / 1e3) + 'mila'
61 return String(n)
62}
63
64// Dollari con due decimali, la virgola e il simbolo dopo la cifra: 0,42 $
65function dollars(n) {
66 return n.toFixed(2).replace('.', ',') + ' $'
67}
68
69const DAYS_IT = ['dom', 'lun', 'mar', 'mer', 'gio', 'ven', 'sab']
70const MONTHS_IT = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic']
71
72// Il nome di una finestra di consumo: quelle note in italiano, le altre con il nome del motore
73function windowName(kind) {
74 const names = { five_hour: 'uso 5 ore', seven_day: 'uso settimana', spend_limit: 'limite di spesa' }
75 return names[kind] ?? 'limite ' + kind.replace(/_/g, ' ')
76}
77
78// Quando si rinnova una finestra, in ora locale: "14:30" se è oggi, altrimenti "lun 12 ott 14:30". Vuoto se la data manca o non è valida
79function resetLabel(resetsAt, nowMs) {
80 const at = new Date(resetsAt ?? NaN)
81 if (Number.isNaN(at.getTime())) return ''
82 const two = (n) => String(n).padStart(2, '0')
83 const time = two(at.getHours()) + ':' + two(at.getMinutes())
84 const today = new Date(nowMs)
85 const sameDay = at.getFullYear() === today.getFullYear() && at.getMonth() === today.getMonth() && at.getDate() === today.getDate()
86 return sameDay ? time : DAYS_IT[at.getDay()] + ' ' + at.getDate() + ' ' + MONTHS_IT[at.getMonth()] + ' ' + time
87}
88
89// Le finestre di consumo dell'abbonamento riportate dal motore: [{ kind, percentUsed }]
90let limits = []
91// Acceso o spento: lo decide /barra e resta nello store tra una sessione e l'altra
92let enabled = true
93
94// Il nuovo stato dopo un comando: "on" accende, "off" spegne, senza argomento alterna
95export function parseToggle(args, current) {
96 const arg = String(args ?? '').trim().toLowerCase()
97 if (/^(on|acceso|attiva|si|sì)$/.test(arg)) return true
98 if (/^(off|spento|disattiva|no)$/.test(arg)) return false
99 return !current
100}
101
102function contextColor(percent) {
103 return percent > CONTEXT_BAD ? 'red' : percent > CONTEXT_WARN ? 'yellow' : 'green'
104}
105
106function cacheColor(percent) {
107 return percent >= CACHE_OK ? 'green' : percent >= CACHE_BAD ? 'yellow' : 'red'
108}
109
110// Registra i cifre che il motore riporta a ogni misura: contesto e costo (la banda si ridisegna da sola)
111function applyMeasure(e) {
112 usage = e.context.percent === undefined ? usage : { percent: e.context.percent, tokens: e.context.tokens ?? 0, window: e.context.window, usd: e.cost?.usd }
113 if (e.cost && usage) usage.usd = e.cost.usd
114 if (Array.isArray(e.rateLimits)) limits = e.rateLimits
115}
116
117// Chiude il turno: costo e token del turno appena finito, poi si riparte da zero
118function closeTurn() {
119 if (turnFresh > 0 || turnOut > 0) {
120 const usd = usage && usage.usd !== undefined && costBefore !== null ? Math.max(0, usage.usd - costBefore) : null
121 lastTurn = { usd, fresh: turnFresh, out: turnOut }
122 }
123 if (usage && usage.usd !== undefined) costBefore = usage.usd
124 turnFresh = 0
125 turnOut = 0
126}
127
128// Le stesse cifre della banda, in righe di testo per il comando /cache
129function usageLines() {
130 const lines = []
131 if (usage) lines.push('contesto · ' + usage.percent + '% (' + short(usage.tokens) + ' su ' + short(usage.window) + ')' + (usage.percent > CONTEXT_BAD ? ', conviene /nuova' : ''))
132 if (lastTurn) lines.push('ultimo turno · ' + (lastTurn.usd === null ? '' : dollars(lastTurn.usd) + ' · ') + short(lastTurn.fresh) + ' token nuovi, ' + short(lastTurn.out) + ' generati')
133 if (usage && usage.usd !== undefined) lines.push('sessione · ' + dollars(usage.usd))
134 if (startedAt !== null) lines.push('sessione · ' + duration(Math.max(0, now - startedAt)))
135 if (cacheReadPct !== null) lines.push('cache letta · ' + cacheReadPct + '% dell\'ultima richiesta')
136 for (const l of limits) {
137 const reset = resetLabel(l.resetsAt, now)
138 lines.push(windowName(l.kind) + ' · ' + Math.round(l.percentUsed) + '%' + (reset ? ', scade ' + reset : ''))
139 }
140 return lines
141}
142
143// Il prompt del bottone "Commit e push"
144const COMMIT_PROMPT =
145 'Esegui ora: git status, poi git add delle modifiche pertinenti, un commit con messaggio breve in italiano e git push sul branch corrente (git push -u origin <branch>). Prima di aggiungere, controlla i file: se tra le modifiche ci sono file sensibili (.env, chiavi, token, credenziali, certificati), non aggiungerli e chiedimi conferma. Se non ci sono modifiche, dillo e fermati. Non modificare altro.'
146
147// Il prompt del push quando git non è raggiungibile
148const PUSH_PROMPT = 'Esegui ora git push sul branch corrente (git push -u origin <branch> se manca l\'upstream). Non modificare altro.'
149
150// Il prompt del bottone "Crea repo": cartella senza git, repo GitHub sempre privata
151const CREATE_REPO_PROMPT =
152 'Questa cartella non è una repo git. Esegui ora: git init (branch main), un .gitignore adatto se manca, git add delle sole modifiche pertinenti, un primo commit con messaggio breve in italiano, poi gh repo create <nome-cartella-in-kebab-case> --private --source . --push. Prima di aggiungere, controlla i file: se ci sono file sensibili (.env, chiavi, token, credenziali, certificati), non aggiungerli e chiedimi conferma. Non rendere mai pubblica la repo. Non modificare altro.'
153
154// Vero quando la cartella non è dentro una repo git (git risponde, ma con "not a git repository")
155let noRepo = false
156
157// Lo stato della repo letto da git: { branch, changed, ahead, hasUpstream }, null se non disponibile
158// (fuori da una repo, o dove $.process non esiste, come nelle sessioni cloud)
159let git = null
160
161// Legge l'intestazione e le righe di `git status --porcelain=v1 -b`
162function parseGit(stdout) {
163 const lines = stdout.split('\n').filter(Boolean)
164 const head = (lines.shift() ?? '').replace(/^## /, '')
165 const m = head.match(/^(.+?)(?:\.\.\.(\S+))?(?: \[(.+)\])?$/)
166 const ahead = Number(/ahead (\d+)/.exec(m?.[3] ?? '')?.[1] ?? 0)
167 return { branch: m?.[1] ?? head, changed: lines.length, ahead, hasUpstream: Boolean(m?.[2]) }
168}
169
170// Rilegge lo stato; in caso di errore la banda torna ai bottoni che passano dal modello
171async function refreshGit($) {
172 try {
173 const { exitCode, stdout, stderr } = await $.process.run(['git', 'status', '--porcelain=v1', '-b'])
174 git = exitCode === 0 ? parseGit(stdout) : null
175 noRepo = exitCode !== 0 && /not a git repository/i.test(stderr ?? '')
176 } catch {
177 git = null
178 noRepo = false
179 }
180 $.ui.invalidate('ui.render')
181}
182
183// Fa il push direttamente con git, senza token. Dove non si può, lo chiede al modello
184async function pushNow($) {
185 if (git === null) return $.prompt.submit({ text: PUSH_PROMPT })
186 const argv = git.hasUpstream ? ['git', 'push'] : ['git', 'push', '-u', 'origin', 'HEAD']
187 try {
188 const { exitCode, stderr } = await $.process.run(argv, { timeoutMs: 60000 })
189 const last = stderr.trim().split('\n').pop() ?? ''
190 $.ui.toast(exitCode === 0 ? 'Push fatto su ' + git.branch : 'Push fallito: ' + last.slice(0, 120))
191 } catch {
192 $.ui.toast('Push non riuscito: git non risponde')
193 }
194 await refreshGit($)
195}
196
197// Il prompt del bottone "Nuova chat": il riassunto va in un file, poi la chat riparte da lì
198function handoffPrompt(file) {
199 return (
200 'Prepara il passaggio a una nuova chat per risparmiare contesto. Scrivi in ' +
201 file +
202 ' (crea la cartella se manca) un riassunto che permetta di riprendere dallo stesso punto: obiettivo, decisioni prese, stato attuale, file toccati con percorso assoluto, cartella di lavoro corrente, comandi utili, prossimi passi, punti aperti. Solo fatti, niente cronaca. Non fare altro. Rispondi con una riga.'
203 )
204}
205
206// Un'azione chiesta da un comando: prompt.submit non si può chiamare dentro command.run, parte a fine turno
207let queued = null
208
209// Il file di passaggio per la sessione in corso, atteso dopo il riassunto
210let handoffFile = null
211
212// Avvia il passaggio a una nuova chat: il riassunto va in un file, il resto lo fa turn.complete
213async function startHandoff($) {
214 const id = await $.session.id()
215 handoffFile = '~/.claude/handoffs/' + id + '.md'
216 $.ui.toast('Riassunto in corso, poi la chat riparte pulita')
217 await $.prompt.submit({ text: handoffPrompt(handoffFile) })
218}
219
220// Fa scorrere la barra, una volta sola
221function startTicking($) {
222 ticking ??= $.clock.every(TICK_MS, async () => {
223 try {
224 now = await $.clock.now()
225 $.ui.invalidate('ui.render')
226 } catch {}
227 })
228}
229
230// Verde finché c'è margine, rosso quando la cache sta per scadere
231function leftColor(minutes) {
232 if (minutes > 20) return 'green'
233 if (minutes > 5) return 'yellow'
234 return 'red'
235}
236
237// Gestori dei comandi, a livello di modulo: ognuno è agganciato sia a /nome sia a /barra-cache:nome
238async function runBarra($, e) {
239 enabled = parseToggle(e.args, enabled)
240 try {
241 await $.store.set('attiva', enabled)
242 } catch {}
243 if (enabled) void refreshGit($)
244 $.ui.invalidate('ui.render')
245 return { text: 'barra-cache · ' + (enabled ? 'accesa' : 'spenta') }
246}
247
248async function runCache($) {
249 const extra = usageLines()
250 const join = (first) => [first, ...extra].join('\n')
251 if (lastAt === null) return { text: join('cache · in attesa della prima richiesta') }
252 const t = await $.clock.now()
253 const leftMs = Math.max(0, ttlMs - (t - lastAt))
254 if (leftMs === 0) return { text: join('cache scaduta: la prossima richiesta la riscrive') }
255 const minutes = Math.ceil(leftMs / 60000)
256 return { text: join('cache · ' + minutes + ' min rimasti, ' + (60 - minutes) + ' min dall\'ultima richiesta') }
257}
258
259async function runPush() {
260 queued = 'push'
261 return { text: 'Push avviato.' }
262}
263
264async function runHandoff() {
265 queued = 'nuova'
266 return { text: 'Passaggio avviato: riassunto, poi chat pulita.' }
267}
268
269export function register(on) {
270
271 // Gli stessi servizi come comandi, per le superfici che non disegnano la banda
272 on('session.start', async ($, e, next) => {
273 // Un ricaricamento della mod o una ripresa della stessa sessione non azzerano la barra
274 try {
275 const saved = await $.store.get('last')
276 const t = await $.clock.now()
277 const savedTtl = await $.store.get('ttl')
278 if (savedTtl === SHORT_TTL_MS || savedTtl === TTL_MS) ttlMs = savedTtl
279 const started = await $.store.get('started')
280 if (started && started.id === (await $.session.id())) startedAt = started.at
281 if (saved && saved.id === (await $.session.id()) && t - saved.at < ttlMs) {
282 lastAt = saved.at
283 now = t
284 startTicking($)
285 }
286 } catch {}
287 try {
288 if ((await $.store.get('attiva')) === false) enabled = false
289 } catch {}
290 const specs = [
291 { name: 'barra', description: 'Accende o spegne la barra sopra il prompt', argumentHint: '[on|off]', immediate: true },
292 { name: 'cache', description: 'Quanto resta della prompt cache' },
293 { name: 'push', description: 'Pubblica i commit del branch corrente' },
294 { name: 'nuova', description: 'Riassume e riparte da una chat pulita' },
295 { name: 'handoff', description: 'Riassume e riparte da una chat pulita (come il bottone Handoff)' },
296 ]
297 for (const spec of specs) await $.command.register(spec)
298 void refreshGit($)
299 // Nelle chat nuove l'elenco dei comandi può essere già chiuso a questo punto: si ripete dopo poco
300 for (const ms of [1500, 5000]) {
301 $.clock.after(ms, () => {
302 for (const spec of specs) void $.command.register(spec).catch(() => {})
303 })
304 }
305 return next(e)
306 })
307
308 on('command.run', { command: 'barra' }, runBarra)
309 on('command.run', { command: 'barra-cache:barra' }, runBarra)
310 on('command.run', { command: 'cache' }, runCache)
311 on('command.run', { command: 'barra-cache:cache' }, runCache)
312 on('command.run', { command: 'push' }, runPush)
313 on('command.run', { command: 'barra-cache:push' }, runPush)
314 on('command.run', { command: 'nuova' }, runHandoff)
315 on('command.run', { command: 'barra-cache:nuova' }, runHandoff)
316 on('command.run', { command: 'handoff' }, runHandoff)
317 on('command.run', { command: 'barra-cache:handoff' }, runHandoff)
318
319 // Dopo il riassunto: svuota la chat e riparte dal file. Il comando va in coda, non si attende dentro il turno
320 on('turn.complete', async ($, e, next) => {
321 // git status dura pochi millisecondi: lo si attende, così la banda è già aggiornata a fine turno
322 if (!e.agentId) {
323 if (enabled) await refreshGit($)
324 closeTurn()
325 }
326 if (!e.agentId && handoffFile) {
327 const file = handoffFile
328 handoffFile = null
329 void (async () => {
330 try {
331 await $.command.run({ command: 'clear' })
332 await $.prompt.submit({ text: 'Riprendi il lavoro dal punto in cui eravamo: leggi ' + file + ' e continua da lì. Conferma in una riga cosa hai capito, poi procedi.' })
333 } catch {
334 $.ui.toast('Riassunto in ' + file + ': lancia /clear e fallo leggere')
335 }
336 })()
337 }
338 if (!e.agentId && queued) {
339 const action = queued
340 queued = null
341 void (action === 'push' ? pushNow($) : startHandoff($))
342 }
343 return next(e)
344 })
345
346 // Il motore riporta contesto e costo quando si muovono: la banda li mostra
347 on('session.measure', ($, e, next) => {
348 applyMeasure(e)
349 $.ui.invalidate('ui.render')
350 return next(e)
351 })
352
353 // Una richiesta al modello rinnova la cache: l'ora riparte dal suo risultato
354 on('turn.step', async function* ($, e, next) {
355 const result = yield* next(e)
356 // Solo la conversazione principale: i subagent hanno una cache loro
357 if (!e.agentId && result.usage) {
358 const u = result.usage
359 turnFresh += u.input_tokens + u.cache_creation_input_tokens
360 turnOut += u.output_tokens
361 const total = u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens
362 cacheReadPct = total > 0 ? Math.round((u.cache_read_input_tokens / total) * 100) : null
363 const t = await $.clock.now()
364 // Dopo una pausa lunga la cache letta dice quale durata ha davvero: letta = dura, riscritta = è scaduta
365 if (lastAt !== null && total >= 2000 && t - lastAt >= GAP_MIN_MS) {
366 const ratio = u.cache_read_input_tokens / total
367 const learned = ratio > 0.6 ? TTL_MS : ratio < 0.2 && t - lastAt < TTL_MS ? SHORT_TTL_MS : null
368 if (learned !== null && learned !== ttlMs) {
369 ttlMs = learned
370 $.ui.toast('Cache: durata rilevata ' + Math.round(ttlMs / 60000) + ' min')
371 try {
372 await $.store.set('ttl', ttlMs)
373 } catch {}
374 }
375 }
376 lastAt = now = t
377 if (startedAt === null) {
378 startedAt = lastAt
379 try {
380 await $.store.set('started', { id: await $.session.id(), at: startedAt })
381 } catch {}
382 }
383 startTicking($)
384 try {
385 await $.store.set('last', { id: await $.session.id(), at: lastAt })
386 } catch {}
387 $.ui.invalidate('ui.render')
388 }
389 return result
390 })
391
392 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
393 // Quello che disegnano le altre mod nella banda resta sotto le nostre righe
394 const theirs = await next(e)
395 if (!enabled) return theirs
396 // L'ora si rilegge a ogni disegno: non dipende dal timer, che può fermarsi
397 try {
398 now = await $.clock.now()
399 } catch {}
400 const { Box, Text, Button } = $.ui.resolve(e)
401 const dim = (text) => Text({ dimColor: true, children: [text] })
402 const strong = (text, color) => Text(color ? { bold: true, color, children: [text] } : { bold: true, children: [text] })
403 const row = (children) => Box({ flexDirection: 'row', flexWrap: 'wrap', children: children.filter(Boolean) })
404 const gap = Text({ children: [' '] })
405 // Con lo stato di git i bottoni compaiono solo se c'è qualcosa da fare
406 const hasChanges = git === null || git.changed > 0
407 const canPush = git !== null && git.changed === 0 && (git.ahead > 0 || !git.hasUpstream)
408 const buttons = Box({
409 flexDirection: 'row',
410 children: [
411 noRepo && Button({ key: 'create-repo', label: 'Crea repo', hotkey: 'g', onPress: () => $.prompt.submit({ text: CREATE_REPO_PROMPT }) }),
412 noRepo && gap,
413 !noRepo && hasChanges && Button({ key: 'commit', label: 'Commit e push', hotkey: 'g', onPress: () => $.prompt.submit({ text: COMMIT_PROMPT }) }),
414 !noRepo && hasChanges && gap,
415 !noRepo && canPush && Button({ key: 'push', label: 'Push', hotkey: 'p', onPress: () => pushNow($) }),
416 !noRepo && canPush && gap,
417 Button({
418 key: 'fresh',
419 label: 'Handoff',
420 hotkey: 'n',
421 onPress: () => startHandoff($),
422 }),
423 ].filter(Boolean),
424 })
425 // Niente props indefinite: il colore è scelto prima
426 const paint = (text, color) => Text(color ? { color, children: [text] } : { dimColor: true, children: [text] })
427 const barWidth = e.props.bodyColumns >= 110 ? 12 : 8
428
429 // Una barra piena per la parte usata, vuota per il resto
430 const bar = (fraction, color) => {
431 const full = Math.min(barWidth, Math.max(0, Math.ceil(fraction * barWidth)))
432 return [paint('█'.repeat(full), color), dim('░'.repeat(barWidth - full))]
433 }
434
435 // Riga 1: la memoria della chat, cioè il contesto occupato, e quanto è costata a listino
436 const memory = usage
437 ? row([
438 dim('🧠 memoria della chat '),
439 ...bar(usage.percent / 100, contextColor(usage.percent)),
440 dim(' '),
441 strong(tokensIt(usage.tokens) + ' token'),
442 usage.usd !== undefined && dim(' · speso ' + dollars(usage.usd) + ' a listino'),
443 usage.percent > CONTEXT_BAD && paint(' · Nuova chat?', 'red'),
444 ])
445 : null
446
447 // Riga 2: i consumi dell'abbonamento, ognuno solo se il motore ha riportato la cifra
448 const limitRow = (kind, icon, label) => {
449 const found = limits.find((l) => l.kind === kind)
450 if (!found) return null
451 const percent = Math.round(found.percentUsed)
452 const reset = resetLabel(found.resetsAt, now)
453 return row([
454 dim(icon + ' ' + label + ' '),
455 ...bar(found.percentUsed / 100, contextColor(percent)),
456 dim(' '),
457 strong(percent + '%', percent > CONTEXT_BAD ? 'red' : null),
458 reset && dim(' scade ' + reset),
459 ])
460 }
461 // Le finestre note hanno un'etichetta; una finestra che il motore aggiunge in futuro (un mese, per esempio) compare con il suo nome
462 const known = ['five_hour', 'seven_day', 'spend_limit']
463 const windows = [
464 limitRow('five_hour', '⏳', 'uso 5 ore'),
465 limitRow('seven_day', '📅', 'uso settimana'),
466 limitRow('spend_limit', '💳', 'limite di spesa'),
467 ...limits.filter((l) => !known.includes(l.kind)).map((l) => limitRow(l.kind, '📆', windowName(l.kind))),
468 ].filter(Boolean)
469 const usageRow = windows.length ? row(windows.flatMap((w, i) => (i === 0 ? [w] : [gap, w]))) : null
470
471 // Riga 3: da quanto è aperta la sessione e quanto resta della cache
472 let isCold = false
473 let cacheSeg
474 if (lastAt === null) {
475 cacheSeg = dim('🔥 cache in attesa della prima richiesta')
476 } else {
477 const leftMs = Math.max(0, ttlMs - (now - lastAt))
478 if (leftMs === 0) {
479 isCold = true
480 cacheSeg = row([dim('🧊 cache '), paint('scaduta', 'red')])
481 } else {
482 const minutes = Math.ceil(leftMs / 60000)
483 cacheSeg = row([dim('🔥 cache calda ancora '), strong(minutes + ' min', leftColor(minutes) === 'green' ? null : leftColor(minutes))])
484 }
485 }
486 const sessionRow = row([startedAt !== null && dim('🕒 sessione aperta da '), startedAt !== null && strong(duration(Math.max(0, now - startedAt))), startedAt !== null && gap, cacheSeg])
487
488 // Cache scaduta con un contesto grande: il prossimo prompt riscrive tutto a prezzo pieno
489 const coldWarn =
490 isCold && usage && usage.tokens >= 20000
491 ? Box({ flexDirection: 'row', children: [paint('Cache scaduta · il prossimo prompt riscrive ' + tokensIt(usage.tokens) + ' token', 'red'), dim(' · se cambi argomento conviene Nuova chat')] })
492 : null
493
494 const lines = [memory, usageRow, sessionRow]
495 return Box({
496 flexDirection: 'column',
497 children: (e.props.isWorking ? [...lines, theirs] : [...lines, coldWarn, buttons, theirs]).filter(Boolean),
498 })
499 })
500}
501