diff --git a/.gitignore b/.gitignore index b4787a0..bfdeb66 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ logs/ .codex .copilot .env +*.child.pid diff --git a/README.de.md b/README.de.md index 56a9fb9..f162f55 100644 --- a/README.de.md +++ b/README.de.md @@ -1,6 +1,6 @@
Coding Agent Telegram -

Coding Agent Telegram 🚀

+

Claude Code / Codex / Copilot Coding Agent Telegram 🚀

English | Deutsch | @@ -179,6 +179,8 @@ pip install coding-agent-telegram coding-agent-telegram ``` +Der installierte Befehl `coding-agent-telegram` enthält denselben Polling-Watchdog wie `./startup.sh`: Er startet den Bot nach einem Absturz oder einem veralteten Telegram-Heartbeat neu und verwendet einen Backoff, solange DNS oder Netzwerk nicht verfügbar sind. + ### Variante C: Aus einem geklonten Repository starten ```bash git clone https://github.com/daocha/coding-agent-telegram @@ -271,6 +273,14 @@ Der Bot akzeptiert derzeit: /provider Provider für neue Sessions wählen. Die Auswahl wird pro Bot und Chat gespeichert, bis du sie änderst. + + /model + Modell für die aktive Session wählen, aus der Modellliste des aktuellen Providers. Die Auswahl wird für diese Session in state.json gespeichert und verwendet, sobald die Session fortgesetzt wird. Eine neue Session zu starten (/new, /switch zu einer neuen Session oder /compact) setzt sie immer auf das konfigurierte Standardmodell des Providers zurück. + + + /model <model_id> + Eine bestimmte Modell-ID festlegen, die nicht in der kuratierten Liste enthalten ist. Ist sie noch keine bekannte Option, testet der Bot zunächst mit einem schreibgeschützten Testaufruf bei der CLI, ob die ID tatsächlich akzeptiert wird, bevor sie gespeichert wird — lehnt die CLI sie ab, wird der Fehler im Chat zurückgegeben und nichts gespeichert. + /project <project_folder> Aktuellen Projektordner setzen. Falls der Ordner nicht existiert, erstellt die App ihn und markiert ihn als vertrauenswürdig. Wenn er bereits existiert und noch nicht vertraut ist, fragt die App nach einer Bestätigung. @@ -287,6 +297,10 @@ Der Bot akzeptiert derzeit: /current Die aktive Session für den aktuellen Bot und Chat anzeigen. + + /status + Zeigt die Kontingentnutzung jedes Providers: 5-Stunden- und wöchentliche Nutzungsprozentsätze mit Reset-Zeiten. Verursacht nie einen kostenpflichtigen API-Aufruf: Codex ist immer eine kostenlose lokale Abfrage, und Claudes Zahlen werden ausschließlich aus deiner letzten echten Claude-Aktivität über den Bot wiederverwendet, angezeigt als „zuletzt beobachtet vor X” (nur für Pro/Max-Konten, die per OAuth angemeldet sind). Die beiden Fenster werden getrennt verfolgt – ist eines bereits über seine Reset-Zeit hinaus (oder wurde noch nie beobachtet), zeigt es N/A an, bis dein nächster Claude-Zug es aktualisiert, selbst wenn das andere Fenster noch aktuelle Daten hat. Für Copilot gibt es keine unterstützte API dafür, daher wird es als nicht verfügbar gemeldet. + /new [session_name] Eine neue Session für das aktuelle Projekt erstellen. Wenn du keinen Namen angibst, verwendet der Bot die echte Session-ID. Fehlen Provider, Projekt oder branch, führt dich der Bot durch den fehlenden Schritt. @@ -317,11 +331,19 @@ Der Bot akzeptiert derzeit: /pull - Nach Bestätigung origin in den Branch der aktiven Session pullen. Wenn zutreffend, aktualisiert der Bot zusätzlich den Standard-Branch. + Nach Bestätigung per Git von origin in den Branch der aktiven Session pullen. Wenn zutreffend, aktualisiert der Bot zusätzlich den Standard-Branch. /push - origin <branch> für die aktuelle aktive Session pushen. Der Bot fragt vor dem Push nach einer Bestätigung. + Per Git origin <branch> für die aktuelle aktive Session pushen. Der Bot fragt vor dem Push nach einer Bestätigung. + + + /log + Die letzten fünf Git-Commits für das Projekt der aktiven Session anzeigen. + + + /reset + Einen lokalen oder origin-Standard-/aktuellen Branch auswählen und anschließend git reset --hard bestätigen. Remote-Ziele werden zuerst gepullt. /abort @@ -404,6 +426,18 @@ Der Bot akzeptiert derzeit: Claude Code Modellkonfiguration + + CODEX_MODEL_CHOICES + Kommagetrennte Modellliste, die der Befehl /model für Codex anbietet. Wenn nicht gesetzt, wird der Wert aus der mitgelieferten .env.example verwendet. + + + COPILOT_MODEL_CHOICES + Kommagetrennte Modellliste, die der Befehl /model für Copilot anbietet. Wenn nicht gesetzt, wird der Wert aus der mitgelieferten .env.example verwendet. + + + CLAUDE_MODEL_CHOICES + Kommagetrennte Modellliste, die der Befehl /model für Claude Code anbietet. Wenn nicht gesetzt, wird der Wert aus der mitgelieferten .env.example verwendet; fehlt die Vorlage, wird sonnet,opus,fable,haiku verwendet. + CODEX_APPROVAL_POLICY An Codex übergebener Freigabemodus. Standard: never. @@ -436,6 +470,22 @@ Der Bot akzeptiert derzeit: AGENT_HARD_TIMEOUT_SECONDS Hartes Zeitlimit für einen einzelnen Agentenlauf. Standard: 0 (deaktiviert). + + LONG_GAP_WARNING_ENABLED + Warnt, bevor eine Sitzung fortgesetzt wird, die lange im Leerlauf war und genug Kontext angesammelt hat, dass ein erneutes Verarbeiten teuer wäre, dass der Prompt-Cache des Anbieters wahrscheinlich abgelaufen ist — mit Schaltflächen zum vorherigen Komprimieren oder trotzdem Fortsetzen. Standard: true. Siehe FAQ weiter unten. + + + CLAUDE_LONG_GAP_SECONDS + Leerlaufschwelle in Sekunden, ab der die Warnung für Claude-Code-Sitzungen ausgelöst wird. Standard: 3600 (1 Stunde, entsprechend dem erweiterten Prompt-Cache-Fenster von Claude Code). + + + CODEX_LONG_GAP_SECONDS + Leerlaufschwelle in Sekunden, ab der die Warnung für Codex-Sitzungen ausgelöst wird. Standard: 3600 (1 Stunde, wie bei Claude; OpenAI dokumentiert für Codex keine cache-verfallszeit basierend auf Leerlauf, und Codex' eigener Cache ist ohnehin meist kurzlebiger als der von Claude, sodass ein Angleichen an Claudes Schwelle keine Genauigkeit kostet — kombiniert mit dem Größen-Gate, damit kleine Sitzungen nicht nerven). + + + COPILOT_LONG_GAP_SECONDS + Leerlaufschwelle in Sekunden, ab der die Warnung für Copilot-Sitzungen ausgelöst wird. Standard: 0 (deaktiviert). GitHubs eigene Dokumentation besagt, dass die Copilot-CLI kein Inaktivitäts-Timeout hat und ihren Kontext bereits nativ selbst komprimiert (bei ~80–95 % Auslastung) — hier gibt es kein leerlaufbezogenes Risiko zu warnen, daher verlässt sich diese App auf Copilots eigenen Mechanismus, statt einen zu erfinden, der nicht existiert. Setze einen positiven Wert, um trotzdem einen leerlaufbasierten Hinweis für Copilot zu aktivieren. + SNAPSHOT_TEXT_FILE_MAX_BYTES Maximale Dateigröße, die der Bot als Text liest, wenn er Vorher/Nachher-Snapshots für Run-Diffs erstellt. Standard: 200000. @@ -686,6 +736,71 @@ Paketversionen werden aus Git-Tags abgeleitet. - PyPI-Prerelease: `v2026.3.26rc1` - PyPI-Stable: `v2026.3.26` +## ❓ FAQ / Fehlerbehebung + +

+Warum zeigt claude --resume in einem normalen Terminal keine von Telegram erstellten Sitzungen an? + +Das ist erwartetes Verhalten der Claude-Code-CLI, kein Fehler in dieser App. + +Von diesem Bot erstellte Sitzungen laufen über den Headless-Modus `-p`/print von Claude Code. Claude Code markiert jede so gestartete Sitzung im Transkript mit `entrypoint: "sdk-cli"`, im Gegensatz zu `entrypoint: "cli"` für eine Sitzung, die du direkt im Terminal durch Eingabe von `claude` startest. Der interaktive `claude --resume`-Picker (ohne Sitzungs-ID) listet nur Sitzungen mit `cli`-Entrypoint auf — er blendet Headless-/SDK-gesteuerte Läufe bewusst aus und behandelt sie als Automatisierungs-Output statt als Unterhaltungen, die von Hand fortgesetzt werden sollen. + +Die Sitzungsdaten selbst sind weder verloren noch anders — es handelt sich um eine ganz normale, vollständig fortsetzbare Claude-Code-Sitzung, gespeichert unter `~/.claude/projects//.jsonl`. Sobald du die ID hast, kannst du sie direkt fortsetzen: + +```bash +claude --resume +``` + +Genau deshalb bringt diese App eine eigene Sitzungserkennung mit (verwendet von `/switch`), statt sich auf den nativen Picker zu verlassen — sie durchsucht die JSONL-Dateien direkt und gleicht sie über den Projektpfad ab, sodass von Telegram erstellte Sitzungen dort auftauchen, obwohl sie in einem einfachen `claude --resume` nie erscheinen. + +Codex und Copilot machen diesen Unterschied zwischen interaktiv und Headless in ihren eigenen Resume-/Listenbefehlen nicht, weshalb Sitzungen dieser Anbieter in einem normalen Terminal weiterhin ganz normal auftauchen. +
+ +
+Verbraucht diese App mehr Tokens als die direkte Nutzung des Claude-Code-Terminals? + +Nicht wegen eines grundsätzlichen Unterschieds im Overhead pro Aufruf — Headless (`-p`) und interaktives Claude Code nutzen dasselbe zugrunde liegende Protokoll und dieselbe Preisgestaltung. In der Praxis kann 24/7-Telegram-Nutzung aber spürbar mehr Tokens verbrauchen als typische Terminal-Nutzung, aus zwei sich verstärkenden Gründen: + +- **Sitzungen können unbegrenzt wachsen.** Da der Bot bequem dieselbe Sitzung über Stunden oder Tage hinweg fortsetzt, kann eine Sitzung Hunderte von Turns und Megabytes an Transkript ansammeln, wenn du sie nie rotierst. In einem interaktiven Terminal würdest du eher eine Aufgabe abschließen und beim nächsten Mal neu anfangen, wodurch der Kontext kleiner bleibt. +- **Leerlaufphasen zwischen Telegram-Nachrichten lassen den Prompt-Cache ablaufen.** Claudes Prompt-Cache hat eine kurze TTL. Antwortest du innerhalb dieses Fensters, sind Folge-Turns günstige Cache-Reads. Gibt es eine lange Pause (z. B. du schläfst und antwortest erst am nächsten Morgen), muss der *gesamte* angesammelte Kontext bei deiner nächsten Nachricht als deutlich teurerer Cache-Write komplett neu verarbeitet werden — und diese Kosten wachsen mit der bisherigen Größe der Sitzung. Deshalb kann der Verbrauch genau dann in die Höhe schnellen, wenn du deine erste Nachricht des Tages schickst, sogar vor den „Hauptzeiten“. + +**Abhilfe:** Führe bei langlebigen Sitzungen regelmäßig `/compact` aus (dieser Telegram-Befehl wird von der App unterstützt), statt eine Sitzung unbegrenzt weiterlaufen zu lassen — besonders, wenn sie länger im Leerlauf war. Auch eine neue `/new`-Sitzung für nicht zusammenhängende Arbeit hilft, Kontext — und Kosten — begrenzt zu halten. + +Die App tut das inzwischen auch automatisch, indem sie pro Anbieter zwei Signale kombiniert, damit sie dich nur dann unterbricht, wenn es wirklich wichtig ist: eine Leerlaufzeit-Schwelle (`CLAUDE_LONG_GAP_SECONDS` / `CODEX_LONG_GAP_SECONDS` / `COPILOT_LONG_GAP_SECONDS`) *und* wie viel Kontext die Sitzung bereits angesammelt hat (die Warnung wird für kleine/günstige Sitzungen übersprungen, selbst wenn sie eine Weile im Leerlauf waren, da ein erneutes Verarbeiten dann vernachlässigbar wäre). Standardwerte: 1 Stunde sowohl für Claude Code als auch für Codex — Claudes Wert ist durch echte Daten belegt (siehe oben), und obwohl OpenAI für Codex keinen dokumentiert, ist Codex' eigener Prompt-Cache ohnehin meist kurzlebiger als der von Claude, sodass ein Angleichen an Claudes Schwelle keine Genauigkeit kostet und einfach zu weniger Unterbrechungen führt, besonders jetzt kombiniert mit dem Größen-Gate; für Copilot ist es standardmäßig deaktiviert, weil [GitHubs eigene Dokumentation](https://docs.github.com/en/copilot/concepts/agents/copilot-cli/context-management) besagt, dass die Copilot-CLI überhaupt kein Inaktivitäts-Timeout hat und ihren Kontext bereits nativ selbst komprimiert (bei etwa 80–95 % Auslastung) — dort gibt es nichts Leerlaufbezogenes zu warnen, daher verlässt sich diese App auf Copilots eigenen Mechanismus, statt einen zu erfinden. Setze `COPILOT_LONG_GAP_SECONDS` auf einen positiven Wert, wenn du trotzdem einen leerlaufbasierten Hinweis für Copilot möchtest. + +Sind sowohl die Schwelle als auch das Größen-Gate erreicht, hält sie deine Nachricht zurück und fragt: + +> ⏳ Diese Sitzung war {gap} im Leerlauf. Sie jetzt fortzusetzen, verarbeitet die gesamte Unterhaltung wahrscheinlich komplett neu (der Antwort-Cache des Anbieters ist vermutlich abgelaufen), was deutlich mehr Tokens als üblich verbrauchen kann. Auch das Komprimieren verarbeitet den aktuellen Kontext einmal neu, um die Zusammenfassung zu erstellen, und kann daher ebenfalls viele Tokens kosten, wenn diese Sitzung bereits groß ist. Zu einer neuen Sitzung zu wechseln, umgeht diese Neuverarbeitung vollständig, startet dann aber ohne jede Erinnerung an diese Unterhaltung. Zu einer neuen Sitzung wechseln, erst komprimieren oder trotzdem fortsetzen? +> +> [🆕 Zu neuer Sitzung wechseln] +> [🔄 Erst komprimieren] +> [⚠️ Trotzdem fortsetzen] + +Zu beachten ist, dass `/compact` selbst nicht kostenlos ist: Es funktioniert, indem die aktuelle (möglicherweise kalte) Sitzung fortgesetzt wird und diese gebeten wird, sich selbst zusammenzufassen — es fällt also derselbe einmalige Neuverarbeitungsaufwand für das gesamte Transkript an wie beim einfachen Antworten. Der Unterschied ist nur, dass du ihn danach nur einmal statt bei jeder weiteren Runde zahlst, da die entstehende Sitzung klein beginnt. **Zu neuer Sitzung wechseln** ist die einzige Option, die diese Neuverarbeitung vollständig vermeidet: Sie verlässt den Kontext der alten Sitzung, ohne sie je fortzusetzen, und startet komplett neu — auf Kosten davon, diesen Kontext vollständig zu verlieren, statt ihn in eine Zusammenfassung zu verdichten. + +Wählst du **Zu neuer Sitzung wechseln**, wird eine brandneue, leere Sitzung gestartet und deine Nachricht dort fortgesetzt — benannt nach der alten Sitzung mit einem fortlaufenden `-newN`-Suffix (z. B. `fix-bug` → `fix-bug-new1` → `fix-bug-new2` beim nächsten Wechsel), damit du sie in `/switch` weiterhin von der ursprünglichen unterscheiden kannst. Wählst du **Erst komprimieren**, wird die Sitzung zusammengefasst, eine neue Sitzung aus dieser Zusammenfassung gestartet und deine Nachricht anschließend auf der neuen Sitzung fortgesetzt — ähnlich benannt, aber mit einem `-resumeN`-Suffix (z. B. `fix-bug` → `fix-bug-resume1` → `fix-bug-resume2` bei der nächsten Komprimierung). Wählst du **Trotzdem fortsetzen**, wird ganz normal auf der bestehenden Sitzung weitergemacht. Die gesamte Prüfung lässt sich mit `LONG_GAP_WARNING_ENABLED=false` deaktivieren. +
+ +
+Claude-Sitzungen scheitern plötzlich mit „Failed to authenticate: OAuth session expired and could not be refreshed“ + +Das kann selbst dann passieren, wenn `claude auth status` meldet, dass du eingeloggt bist, und sogar direkt nachdem du dich erneut eingeloggt hast. Die interaktive OAuth-Sitzung, die Claude Code normalerweise nutzt, kann speziell für die headless, losgelöste Subprozess-Art, wie dieser Bot Sitzungen erstellt, aufhören zu funktionieren, ohne dass dein eigentlicher Login schuld ist — das wurde nach einem automatischen Claude-Code-CLI-Update beobachtet und kann auch intermittierend auftreten. + +Wenn ein Nutzer live über `/new`, eine fortgesetzte Sitzung oder eine beliebige Nachricht darauf stößt, erkennt der Bot diesen speziellen Fehler und antwortet sofort mit Anweisungen zur Behebung, statt mit dem rohen CLI-Fehler. + +**So behebst du es** auf dem Host, auf dem der Bot läuft: + +1. Führe `claude setup-token` aus und genehmige den Zugriff im geöffneten Browser. **Das eigentliche Token wird danach in deinem Terminal ausgegeben (es beginnt mit `sk-ant-oat01-`), nirgendwo im Browser** — etwas von der Browserseite selbst statt aus dem Terminal zu kopieren ist ein häufiger Fehler und funktioniert nicht. Dadurch wird ein langlebiges (etwa 1 Jahr gültiges) Authentifizierungs-Token erstellt — Anthropics eigener unterstützter Mechanismus für headless/automatisierte Nutzung (derselbe, der für GitHub Actions verwendet wird). Anders als die interaktive OAuth-Sitzung hängt es nicht davon ab, dass Keychain-/Sitzungs-Refresh aus einem losgelösten Hintergrundprozess funktioniert, sodass es nach dem Einrichten voraussichtlich erst wieder angefasst werden muss, wenn es abläuft. +2. Kopiere das ausgegebene Token und speichere es mit dem Befehl, der zu deiner Art passt, wie du diese App betreibst — die eigene Antwort des Bots wählt automatisch den richtigen aus, hier zur Referenz: + - Installiert über `pip` oder das Einzeiler-`install.sh` (Quick-Start-Variante A/B): `coding-agent-telegram claude-auth ` + - Läuft aus einem geklonten Repository mit `./startup.sh` (Quick-Start-Variante C): `./startup.sh claude-auth ` + + Beide Befehle speichern das Token als `CLAUDE_CODE_OAUTH_TOKEN` in deiner Env-Datei und prüfen die Claude-Authentifizierung sofort erneut, sodass du sofort ein Bestanden/Fehlgeschlagen bekommst, statt blind neu zu starten und zu hoffen. +3. Starte den Bot neu, damit der laufende Prozess die Änderung übernimmt. + +Du kannst den Befehl auch überspringen und `CLAUDE_CODE_OAUTH_TOKEN=` direkt selbst in deiner Env-Datei (`.env_coding_agent_telegram`) setzen — die beiden obigen Befehle sind nur ein bequemer Wrapper genau dafür, plus Verifizierung. +
+ ## 📌 Hinweise - Dieses Projekt ist für Nutzer gedacht, die die Agenten lokal auf ihrem eigenen Rechner ausführen. diff --git a/README.fr.md b/README.fr.md index 73df0cc..cda4279 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,6 +1,6 @@
Coding Agent Telegram -

Coding Agent Telegram 🚀

+

Claude Code / Codex / Copilot Coding Agent Telegram 🚀

English | Deutsch | @@ -179,6 +179,8 @@ pip install coding-agent-telegram coding-agent-telegram ``` +La commande installée `coding-agent-telegram` inclut le même mécanisme de surveillance du polling que `./startup.sh` : elle redémarre le bot après un crash ou un heartbeat Telegram obsolète et applique un délai croissant tant que le DNS ou le réseau est indisponible. + ### Variante C : Exécution depuis un dépôt cloné ```bash git clone https://github.com/daocha/coding-agent-telegram @@ -271,6 +273,14 @@ Le bot accepte actuellement : /provider Choisir le fournisseur pour les nouvelles sessions. Le choix est stocké par bot et par chat jusqu’à modification. + + /model + Choisir le modèle pour la session active, parmi la liste de modèles du fournisseur courant. Le choix est stocké sur cette session dans state.json et est utilisé chaque fois que la session est reprise. Démarrer une nouvelle session (/new, /switch vers une session neuve, ou /compact) réinitialise toujours au modèle par défaut configuré pour le fournisseur. + + + /model <model_id> + Définir un id de modèle spécifique absent de la liste sélectionnée. S’il ne s’agit pas déjà d’un choix connu, le bot sonde d’abord la CLI avec un appel de test en lecture seule pour confirmer que l’id est réellement accepté avant de l’enregistrer — si la CLI le rejette, l’erreur est renvoyée dans le chat et rien n’est enregistré. + /project <project_folder> Définir le dossier de projet courant. Si le dossier n’existe pas, l’app le crée et le marque trusted. S’il existe déjà mais reste untrusted, l’app vous demande une confirmation. @@ -287,6 +297,10 @@ Le bot accepte actuellement : /current Afficher la session active pour le bot et le chat courants. + + /status + Affiche l'utilisation du quota de chaque fournisseur : pourcentages d'utilisation sur 5 heures et hebdomadaire, avec les heures de réinitialisation. Ne déclenche jamais un appel API payant : Codex est toujours une requête locale gratuite, et les chiffres de Claude sont réutilisés uniquement depuis votre dernière activité Claude réelle via le bot, affichés comme « observé pour la dernière fois il y a X » (comptes Pro/Max connectés via OAuth uniquement). Les deux fenêtres sont suivies séparément : si l'une a dépassé son heure de réinitialisation (ou n'a encore jamais été observée), elle affiche N/A jusqu'à ce que votre prochain tour Claude la rafraîchisse, même si l'autre fenêtre a encore des données à jour. Copilot n'a pas d'API prise en charge pour cela et est signalé comme indisponible. + /new [session_name] Créer une nouvelle session pour le projet courant. Si vous omettez le nom, le bot utilise le véritable ID de session. Si fournisseur, projet ou branch manque, le bot vous guide. @@ -317,11 +331,19 @@ Le bot accepte actuellement : /pull - Après confirmation, exécuter un pull depuis origin pour la branche de la session active. Le bot rafraîchit aussi la branche par défaut si nécessaire. + Après confirmation, exécuter un Git pull depuis origin pour la branche de la session active. Le bot rafraîchit aussi la branche par défaut si nécessaire. /push - Pousser origin <branch> pour la session active courante. Le bot demande une confirmation avant le push. + Exécuter un Git push vers origin <branch> pour la session active courante. Le bot demande une confirmation avant le push. + + + /log + Afficher les cinq derniers commits Git du projet de la session active. + + + /reset + Sélectionner la branche par défaut ou courante, locale ou sur origin, puis confirmer un git reset --hard. Les cibles distantes sont d’abord pullées. /abort @@ -404,6 +426,18 @@ Le bot accepte actuellement : Configuration des modèles Claude Code + + CODEX_MODEL_CHOICES + Liste de modèles séparés par des virgules proposée par la commande /model pour Codex. Si elle n’est pas définie, la valeur du fichier .env.example fourni est utilisée. + + + COPILOT_MODEL_CHOICES + Liste de modèles séparés par des virgules proposée par la commande /model pour Copilot. Si elle n’est pas définie, la valeur du fichier .env.example fourni est utilisée. + + + CLAUDE_MODEL_CHOICES + Liste de modèles séparés par des virgules proposée par la commande /model pour Claude Code. Si elle n’est pas définie, la valeur du fichier .env.example fourni est utilisée ; si le modèle est indisponible, sonnet,opus,fable,haiku est utilisé. + CODEX_APPROVAL_POLICY Mode d’approbation transmis à Codex. Défaut : never. @@ -436,6 +470,22 @@ Le bot accepte actuellement : AGENT_HARD_TIMEOUT_SECONDS Timeout dur pour une exécution d’agent. Défaut : 0 (désactivé). + + LONG_GAP_WARNING_ENABLED + Avant de reprendre une session restée inactive un moment et ayant accumulé assez de contexte pour qu'un retraitement soit coûteux, avertit que le cache de prompt du fournisseur a probablement expiré — avec des boutons pour compacter d'abord ou continuer quand même. Défaut : true. Voir la FAQ ci-dessous. + + + CLAUDE_LONG_GAP_SECONDS + Seuil d'inactivité en secondes avant que l'avertissement se déclenche pour les sessions Claude Code. Défaut : 3600 (1 heure, correspondant à la fenêtre de cache de prompt étendue de Claude Code). + + + CODEX_LONG_GAP_SECONDS + Seuil d'inactivité en secondes avant que l'avertissement se déclenche pour les sessions Codex. Défaut : 3600 (1 heure, comme pour Claude ; OpenAI ne documente aucun chiffre d'expiration de cache basé sur l'inactivité pour Codex, et le cache propre de Codex est de toute façon généralement plus éphémère que celui de Claude, donc s'aligner sur le seuil de Claude ne coûte rien en précision — combiné au filtre de taille pour que les petites sessions n'agacent pas). + + + COPILOT_LONG_GAP_SECONDS + Seuil d'inactivité en secondes avant que l'avertissement se déclenche pour les sessions Copilot. Défaut : 0 (désactivé). La documentation officielle de GitHub indique que Copilot CLI n'a aucun délai d'inactivité et compacte déjà nativement son propre contexte (autour de 80-95 % d'utilisation) — il n'y a ici aucun risque lié à l'inactivité à signaler, donc ceci s'en remet au mécanisme propre de Copilot plutôt que d'en inventer un. Définissez une valeur positive pour activer quand même une alerte basée sur l'inactivité pour Copilot. + SNAPSHOT_TEXT_FILE_MAX_BYTES Taille maximale de fichier que le bot lira en texte pour construire le instantané avant/après des diffs. Défaut : 200000. @@ -682,6 +732,71 @@ Les versions du paquet sont dérivées des tags Git. - préversion PyPI : `v2026.3.26rc1` - version stable PyPI : `v2026.3.26` +## ❓ FAQ / Dépannage + +

+Pourquoi claude --resume dans un terminal classique n'affiche-t-il aucune session créée depuis Telegram ? + +C'est un comportement attendu de la CLI Claude Code, pas un bug de cette app. + +Les sessions créées par ce bot passent par le mode headless `-p`/print de Claude Code. Claude Code marque toute session démarrée ainsi avec `entrypoint: "sdk-cli"` dans sa transcription, contre `entrypoint: "cli"` pour une session que vous démarrez en tapant directement `claude` dans un terminal. Le sélecteur interactif `claude --resume` (sans ID de session) ne liste que les sessions avec un entrypoint `cli` — il masque délibérément les runs headless/pilotés par le SDK, les traitant comme de la sortie d'automatisation plutôt que des conversations destinées à être reprises à la main. + +Les données de session elles-mêmes ne sont ni perdues ni différentes — c'est une session Claude Code normale et totalement reprenable, stockée sous `~/.claude/projects//.jsonl`. Vous pouvez la reprendre directement une fois l'ID en main : + +```bash +claude --resume +``` + +C'est exactement pour ça que cette app embarque sa propre découverte de sessions (utilisée par `/switch`) au lieu de se fier au sélecteur natif — elle scanne directement les fichiers JSONL et les associe par chemin de projet, si bien que les sessions créées depuis Telegram y apparaissent même si elles n'apparaissent jamais dans un simple `claude --resume`. + +Codex et Copilot ne font pas cette distinction interactif/headless dans leurs propres commandes de reprise/liste, ce qui explique pourquoi les sessions de ces fournisseurs continuent d'apparaître normalement dans un terminal classique. +
+ +
+Cette app consomme-t-elle plus de tokens que l'utilisation directe du terminal Claude Code ? + +Pas à cause d'une différence de surcoût inhérente par appel — le mode headless (`-p`) et Claude Code interactif utilisent le même protocole sous-jacent et la même tarification. Mais en pratique, un usage Telegram 24/7 peut consommer nettement plus de tokens qu'un usage terminal classique, pour deux raisons qui se cumulent : + +- **Les sessions peuvent grossir sans limite.** Comme le bot reprend commodément la même session sur des heures voire des jours, une session peut accumuler des centaines d'échanges et des mégaoctets de transcription si vous ne la faites jamais tourner. Dans un terminal interactif, vous auriez plus naturellement tendance à finir une tâche et repartir de zéro la fois suivante, gardant ainsi un contexte plus petit. +- **Les intervalles d'inactivité entre messages Telegram font expirer le cache de prompt.** Le cache de prompt de Claude a une durée de vie courte. Si vous répondez dans cette fenêtre, les échanges suivants sont des lectures de cache peu coûteuses. S'il y a un long intervalle (par exemple vous dormez et répondez le lendemain matin), tout le contexte accumulé doit être retraité intégralement lors de votre prochain message, sous forme d'une écriture de cache bien plus coûteuse — et ce coût augmente avec la taille déjà atteinte par la session. C'est pourquoi la consommation peut s'envoler dès votre premier message de la journée, même avant les heures de « pointe ». + +**Mitigation :** exécutez périodiquement `/compact` sur les sessions longue durée (cette app le prend en charge comme commande Telegram) plutôt que de laisser une session tourner indéfiniment, surtout si vous remarquez qu'elle est restée inactive longtemps. Démarrer une nouvelle session `/new` pour un travail sans rapport aide aussi à garder le contexte — et le coût — sous contrôle. + +L'app le fait désormais aussi automatiquement, en combinant deux signaux par fournisseur pour ne vous interrompre que quand c'est vraiment susceptible d'importer : un seuil d'inactivité (`CLAUDE_LONG_GAP_SECONDS` / `CODEX_LONG_GAP_SECONDS` / `COPILOT_LONG_GAP_SECONDS`) *et* la quantité de contexte déjà accumulée par la session (l'avertissement est ignoré pour les petites sessions peu coûteuses même après une longue inactivité, puisque les retraiter depuis zéro est alors négligeable). Valeurs par défaut : 1 heure pour Claude Code comme pour Codex — le chiffre de Claude repose sur des données réelles (voir plus haut), et bien qu'OpenAI n'en documente aucun pour Codex, le cache propre de Codex est de toute façon généralement plus éphémère que celui de Claude, donc s'aligner sur le seuil de Claude ne coûte rien en précision et se traduit simplement par moins d'interruptions, surtout désormais combiné au filtre de taille ; et désactivé par défaut pour Copilot, car [la documentation officielle de GitHub](https://docs.github.com/en/copilot/concepts/agents/copilot-cli/context-management) indique que Copilot CLI n'a aucun délai d'inactivité et compacte déjà nativement son propre contexte (autour de 80-95 % d'utilisation) — il n'y a là rien de lié à l'inactivité à signaler, donc ceci s'en remet au mécanisme propre de Copilot plutôt que d'en inventer un. Définissez `COPILOT_LONG_GAP_SECONDS` à une valeur positive si vous voulez quand même une alerte basée sur l'inactivité pour Copilot. + +Quand le seuil et le filtre de taille sont tous deux atteints, elle retient votre message et demande : + +> ⏳ Cette session est inactive depuis {gap}. La reprendre maintenant va probablement retraiter toute la conversation depuis le début (le cache de réponses du fournisseur a sans doute expiré), ce qui peut consommer bien plus de tokens que d'habitude. Compacter retraite aussi le contexte actuel une fois pour rédiger son résumé, donc cela peut aussi consommer beaucoup de tokens si cette session est déjà volumineuse. Basculer vers une nouvelle session évite complètement ce retraitement, mais démarre sans aucune mémoire de cette conversation. Basculer vers une nouvelle session, compacter d'abord, ou continuer quand même ? +> +> [🆕 Basculer vers une nouvelle session] +> [🔄 Compacter d'abord] +> [⚠️ Continuer quand même] + +Notez que `/compact` lui-même n'est pas exempt de ce coût : il fonctionne en reprenant la session actuelle (potentiellement froide) et en lui demandant de se résumer elle-même, donc il paie le même retraitement complet et ponctuel du transcript que le simple fait de répondre — la différence est que vous ne le payez alors qu'une seule fois plutôt qu'à chaque tour suivant, puisque la session résultante démarre petite. **Basculer vers une nouvelle session** est la seule option qui évite entièrement ce retraitement : elle abandonne le contexte de l'ancienne session sans jamais la reprendre et repart entièrement à zéro, au prix de perdre ce contexte entièrement plutôt que de le condenser en résumé. + +Choisir **Basculer vers une nouvelle session** démarre une session toute neuve et vide, puis y poursuit avec votre message — nommée d'après l'ancienne session avec un suffixe `-newN` incrémental (par ex. `fix-bug` → `fix-bug-new1` → `fix-bug-new2` si vous basculez à nouveau), pour pouvoir toujours la distinguer de l'originale dans `/switch`. Choisir **Compacter d'abord** résume la session, en démarre une nouvelle à partir de ce résumé, puis poursuit avec votre message sur la nouvelle session — nommée de façon similaire mais avec un suffixe `-resumeN` (par ex. `fix-bug` → `fix-bug-resume1` → `fix-bug-resume2` à la compaction suivante). Choisir **Continuer quand même** poursuit simplement sur la session existante comme d'habitude. Désactivez tout le mécanisme avec `LONG_GAP_WARNING_ENABLED=false`. +
+ +
+Les sessions Claude échouent soudainement avec « Failed to authenticate: OAuth session expired and could not be refreshed » + +Cela peut se produire même quand `claude auth status` indique que vous êtes connecté, et même juste après vous être reconnecté. La session OAuth interactive que Claude Code utilise normalement peut cesser de fonctionner spécifiquement pour la façon headless, en sous-processus détaché, dont ce bot crée les sessions, sans que votre connexion réelle soit en cause — cela a été observé après une mise à jour automatique du CLI Claude Code, et peut aussi être intermittent. + +Si un utilisateur y est confronté en direct via `/new`, une session reprise, ou n'importe quel message, le bot reconnaît cette défaillance spécifique et répond immédiatement avec des instructions de correction, au lieu de l'erreur brute du CLI. + +**Pour corriger cela**, sur l'hôte exécutant le bot : + +1. Exécutez `claude setup-token` et approuvez l'accès dans le navigateur qui s'ouvre. **Le jeton réel est ensuite affiché dans votre terminal (il commence par `sk-ant-oat01-`), pas dans le navigateur** — copier quelque chose depuis la page du navigateur au lieu du terminal est une erreur courante et ne fonctionnera pas. Cela crée un jeton d'authentification longue durée (environ 1 an), le mécanisme officiellement pris en charge par Anthropic pour un usage headless/automatisé (le même que celui utilisé pour GitHub Actions) — contrairement à la session OAuth interactive, il ne dépend pas du bon fonctionnement du trousseau/rafraîchissement de session depuis un processus d'arrière-plan détaché, donc une fois défini, il ne devrait pas avoir besoin d'être retouché avant son expiration. +2. Copiez le jeton affiché, puis enregistrez-le avec la commande correspondant à votre façon d'exécuter cette application — la réponse du bot choisit automatiquement la bonne, mais pour référence : + - Installée via `pip` ou le `install.sh` en une ligne (Quick Start Variante A/B) : `coding-agent-telegram claude-auth ` + - Exécutée depuis un dépôt cloné avec `./startup.sh` (Quick Start Variante C) : `./startup.sh claude-auth ` + + Les deux commandes enregistrent le jeton en tant que `CLAUDE_CODE_OAUTH_TOKEN` dans votre fichier env et revérifient immédiatement l'authentification Claude, afin d'obtenir un résultat immédiat plutôt qu'un redémarrage à l'aveugle. +3. Redémarrez le bot pour que le processus en cours prenne en compte le changement. + +Vous pouvez aussi ignorer la commande et définir vous-même `CLAUDE_CODE_OAUTH_TOKEN=` directement dans votre fichier env (`.env_coding_agent_telegram`) — les deux commandes ci-dessus ne sont qu'un wrapper pratique faisant exactement cela, avec vérification en plus. +
+ ## 📌 Remarques - Ce projet est conçu pour les utilisateurs qui exécutent les agents localement sur leur propre machine. diff --git a/README.ja.md b/README.ja.md index 8fbe137..0ee22e1 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,6 +1,6 @@
Coding Agent Telegram -

Coding Agent Telegram 🚀

+

Claude Code / Codex / Copilot Coding Agent Telegram 🚀

English | Deutsch | @@ -179,6 +179,8 @@ pip install coding-agent-telegram coding-agent-telegram ``` +インストールされた `coding-agent-telegram` コマンドには `./startup.sh` と同じ polling watchdog が含まれています。bot がクラッシュした場合や Telegram heartbeat が古くなった場合に再起動し、DNS またはネットワークが利用できない間はバックオフして再試行します。 + ### 方法C: クローンしたリポジトリから実行 ```bash git clone https://github.com/daocha/coding-agent-telegram @@ -271,6 +273,14 @@ https://api.telegram.org/bot/getUpdates /provider 新しいセッション用のプロバイダーを選択します。選択は変更するまで bot と chat ごとに保存されます。 + + /model + 現在のプロバイダーのモデル一覧から、アクティブなセッションのモデルを選択します。選択はそのセッションの state.json に保存され、セッションを再開するたびに使用されます。新しいセッションを開始すると(/new、新しいセッションへの /switch、または /compact)、常にプロバイダーに設定された既定モデルにリセットされます。 + + + /model <model_id> + 選定済みの一覧にない特定のモデル ID を設定します。既知の選択肢でない場合、bot はまず使い捨ての読み取り専用呼び出しで CLI に問い合わせ、その ID が実際に受け入れられるかを保存前に確認します。CLI がそれを拒否した場合は、エラーが chat に返され、何も保存されません。 + /project <project_folder> 現在のプロジェクトフォルダを設定します。フォルダが存在しない場合は作成して trusted として扱います。既存で untrusted の場合は明示的に trust を確認します。 @@ -287,6 +297,10 @@ https://api.telegram.org/bot/getUpdates /current 現在の bot と chat の アクティブなセッション を表示します。 + + /status + 各 provider の quota 使用状況(5時間・週次の使用率とリセット時刻)を表示します。有料の API 呼び出しは一切発生しません:Codex は常に無料のローカルクエリであり、Claude の数値は bot 経由の直近の実際の Claude 利用からのみ再利用され、「X前に確認」と表示されます(対象は OAuth でログインした Pro/Max アカウントのみ)。2つのウィンドウは個別に管理されるため、一方がすでにリセット時刻を過ぎている場合(またはまだ一度も観測されていない場合)、もう一方に新しいデータがあっても、次の Claude のやり取りで更新されるまで N/A と表示されます。Copilot にはこれに対応する API がないため、利用不可として表示されます。 + /new [session_name] 現在のプロジェクトに新しいセッションを作成します。名前を省略すると実際のセッション ID を使います。プロバイダー、プロジェクト、branch が不足している場合は bot が不足分を案内します。 @@ -317,11 +331,19 @@ https://api.telegram.org/bot/getUpdates /pull - 確認後に、アクティブなセッションのブランチで origin から pull します。必要に応じてデフォルト ブランチも更新します。 + 確認後に、アクティブなセッションのブランチで origin から Git pull します。必要に応じてデフォルト ブランチも更新します。 /push - 現在の アクティブなセッション に対して origin <branch> を push します。push 前に bot が確認します。 + 現在の アクティブなセッション に対して origin <branch> へ Git push します。push 前に bot が確認します。 + + + /log + アクティブなセッションの project で最新の Git コミット 5 件を表示します。 + + + /reset + local または origin のデフォルト/現在のブランチを選択し、git reset --hard を確認します。remote の対象は先に pull されます。 /abort @@ -404,6 +426,18 @@ https://api.telegram.org/bot/getUpdates Claude Code model configuration + + CODEX_MODEL_CHOICES + /model コマンドが Codex 向けに提示する、カンマ区切りのモデル一覧です。未設定の場合は、同梱の .env.example の値を使用します。 + + + COPILOT_MODEL_CHOICES + /model コマンドが Copilot 向けに提示する、カンマ区切りのモデル一覧です。未設定の場合は、同梱の .env.example の値を使用します。 + + + CLAUDE_MODEL_CHOICES + /model コマンドが Claude Code 向けに提示する、カンマ区切りのモデル一覧です。未設定の場合は、同梱の .env.example の値を使用します。テンプレートが利用できない場合は sonnet,opus,fable,haiku を使用します。 + CODEX_APPROVAL_POLICY Codex に渡す approval mode。既定: never. @@ -436,6 +470,22 @@ https://api.telegram.org/bot/getUpdates AGENT_HARD_TIMEOUT_SECONDS 単一の エージェント実行 に対するハードタイムアウト。既定: 0(無効)。 + + LONG_GAP_WARNING_ENABLED + しばらく idle で、かつ再処理が高くつく程度に context が蓄積された session を resume する前に、provider の prompt cache が切れている可能性がある旨を警告し、先に compact するか、そのまま続行するかのボタンを出します。既定: true。詳細は下の FAQ を参照。 + + + CLAUDE_LONG_GAP_SECONDS + Claude Code の session に対して警告が発火するまでの idle しきい値(秒)。既定: 3600(1時間。Claude Code の拡張 prompt cache ウィンドウに合わせています)。 + + + CODEX_LONG_GAP_SECONDS + Codex の session に対して警告が発火するまでの idle しきい値(秒)。既定: 3600(1時間。Claude と同じ値です。OpenAI は Codex について idle ベースの cache 失効時間を文書化しておらず、また Codex 自身の cache は Claude より短命なことが多いため、Claude の閾値に合わせても精度は損なわれません。size gate と組み合わせることで小さい session が警告を出さないようにもしています)。 + + + COPILOT_LONG_GAP_SECONDS + Copilot の session に対して警告が発火するまでの idle しきい値(秒)。既定: 0(無効)。GitHub 公式ドキュメントによれば Copilot CLI に inactivity timeout はなく、すでに context を自前で(使用率 80〜95% あたりで)native に auto-compact しています。ここには警告すべき idle 起因のリスクがないため、存在しない API を仮定するのではなく Copilot 自身の仕組みに任せています。それでも Copilot に idle ベースの通知が欲しい場合は正の値を設定してください。 + SNAPSHOT_TEXT_FILE_MAX_BYTES 実行ごとの diff 用に 実行前後のスナップショット を作る際、bot がテキストとして読む最大ファイルサイズです。既定: 200000. @@ -680,6 +730,71 @@ package version は Git tag から導出されます。 - PyPI prerelease: `v2026.3.26rc1` - PyPI stable: `v2026.3.26` +## ❓ FAQ / トラブルシューティング + +

+普通のターミナルで claude --resume を実行しても、Telegram から作った session が表示されないのはなぜですか? + +これは Claude Code CLI の想定どおりの挙動であり、このアプリのバグではありません。 + +この bot が作る session は、Claude Code の headless な `-p`/print モードを通じて実行されます。Claude Code はそうやって開始された session を transcript 上で `entrypoint: "sdk-cli"` としてマークします。これは、ターミナルで直接 `claude` と入力して始めた session の `entrypoint: "cli"` とは異なります。session ID を指定しない対話的な `claude --resume` ピッカーは `cli` entrypoint の session しか一覧表示しません — headless / SDK 駆動の実行は意図的に隠されており、手動で再開すべき会話ではなく自動化の出力として扱われます。 + +session データ自体が失われたり別物になったりしているわけではありません。`~/.claude/projects//.jsonl` に保存された、ごく普通の resume 可能な Claude Code session です。ID さえ分かれば直接 resume できます。 + +```bash +claude --resume +``` + +このアプリが native の picker に頼らず独自の session discovery(`/switch` が使用)を持っているのはまさにこのためです — JSONL ファイルを直接スキャンしてプロジェクトパスで照合するので、単純な `claude --resume` には決して現れなくても、Telegram で作った session はここに表示されます。 + +Codex と Copilot は自分たちの resume/list コマンドでこの「対話的 vs headless」という区別をしないため、これらの provider の session は普通のターミナルでも問題なく表示され続けます。 +
+ +
+このアプリは Claude Code のターミナルを直接使う場合より多くの token を消費しますか? + +呼び出しごとの本質的な overhead の違いによるものではありません — headless(`-p`)も対話的な Claude Code も、同じ下層プロトコルと同じ課金体系を使っています。しかし実際には、24時間365日の Telegram 利用は、典型的なターミナル利用よりも顕著に多くの token を消費し得ます。それには重なり合う2つの理由があります。 + +- **session が際限なく肥大化しうる。** bot は同じ session を何時間、何日にもわたって都合よく resume し続けるため、rotate しない限り数百 turn・数メガバイトの transcript が積み上がることがあります。対話的なターミナルであれば、タスクを終えて次回は新しく始めるのが自然で、context は小さく保たれやすいです。 +- **Telegram メッセージ間の idle 間隔が prompt cache を失効させる。** Claude の prompt cache は TTL が短いです。その window 内に返信すれば、続く turn は安価な cache read で済みます。長い間隔が空くと(例えば寝て翌朝返信するなど)、次のメッセージ送信時に蓄積された context 全体を、はるかに高価な cache write としてゼロから再処理しなければなりません — しかもこのコストは、その時点までに session がどれだけ大きくなっているかに比例して増えます。だからこそ、たとえ「ピーク」時間帯より前でも、その日最初のメッセージを送った瞬間に使用量が跳ね上がることがあるのです。 + +**軽減策:** session を無期限に走らせ続けるのではなく、特に長時間 idle だったと気づいたときは、長く使う session に対して定期的に `/compact`(このアプリが Telegram コマンドとしてサポート)を実行してください。関連のない作業には新しく `/new` session を始めることも、context — ひいてはコスト — を抑えるのに役立ちます。 + +このアプリは今ではこれを自動でも行います。provider ごとに2つの signal を組み合わせることで、実際に問題になりそうなときだけ割り込むようにしています: idle しきい値(`CLAUDE_LONG_GAP_SECONDS` / `CODEX_LONG_GAP_SECONDS` / `COPILOT_LONG_GAP_SECONDS`)に加えて、session がすでに蓄積した context の量(idle が長くても小さく安価な session なら、ゼロから再処理してもたいしたことがないため警告をスキップします)です。既定値は Claude Code と Codex がともに1時間 — Claude の数字は実際の証跡に基づいていますが(上記参照)、OpenAI は Codex について文書化していないものの、Codex 自身の cache は Claude より短命なことが多いため、Claude の閾値に合わせても精度は損なわれず、単に割り込みが減るだけです(今回 size gate と組み合わせたことでなおさらです)。Copilot は既定で無効です。[GitHub 公式ドキュメント](https://docs.github.com/en/copilot/concepts/agents/copilot-cli/context-management)によれば Copilot CLI に inactivity timeout はなく、すでに context を自前で(使用率 80〜95% あたりで)native に auto-compact しているため、idle に関して警告すべきことは何もなく、独自の仕組みを仮定するのではなく Copilot 自身の仕組みに任せています。それでも Copilot に idle ベースの通知が欲しい場合は `COPILOT_LONG_GAP_SECONDS` に正の値を設定してください。 + +しきい値と size gate の両方を満たすと、あなたのメッセージを保留してこう尋ねます。 + +> ⏳ この session は {gap} の間 idle でした。今 resume すると会話全体をゼロから再処理する可能性が高く(provider の応答 cache はおそらく失効しています)、通常よりかなり多くの token を消費するおそれがあります。compact も要約を作成するために現在のコンテキストを一度再処理するため、この session がすでに大きい場合は同様に多くの token を消費する可能性があります。新しい session に切り替えれば、この再処理を完全に回避できますが、その代わりこれまでの会話の記憶は一切引き継がれません。新しい session に切り替えますか、先に compact しますか、それともそのまま続行しますか? +> +> [🆕 新しい session に切り替え] +> [🔄 先に compact] +> [⚠️ そのまま続行] + +`/compact` 自体もこのコストと無縁ではないことに注意してください: 現在の(おそらく冷えた)session を resume して自分自身を要約するよう求める仕組みなので、ただ返信する場合と同じ 1 回限りの transcript 全体の再処理コストを負います — 違いは、結果として得られる session が小さく始まるため、それ以降の毎ターンではなく一度だけそのコストを支払えばよいという点です。**新しい session に切り替え** は、その再処理を完全に回避できる唯一の選択肢です: 古い session を一度も resume せずにそのコンテキストを手放し、完全にゼロから始めます — その代償として、コンテキストを要約に圧縮するのではなく完全に失います。 + +**新しい session に切り替え** を選ぶと、まったく新しい空の session を開始し、そこであなたのメッセージを続行します — 名前は元の session 名に `-newN` という連番のサフィックスを付けたもの(例: `fix-bug` → `fix-bug-new1` → 再度切り替えると `fix-bug-new2`)になるので、`/switch` でも元の session と区別できます。**先に compact** を選ぶと、session を要約し、その要約から新しい session を開始したうえで、あなたのメッセージをその新しい session 上で続行します — 同様に命名されますが、代わりに `-resumeN` サフィックスが付きます(例: `fix-bug` → `fix-bug-resume1` → 次の compaction で `fix-bug-resume2`)。**そのまま続行** を選ぶと、既存の session 上でいつもどおり続行するだけです。この仕組み全体は `LONG_GAP_WARNING_ENABLED=false` で無効化できます。 +
+ +
+Claude セッションが突然「Failed to authenticate: OAuth session expired and could not be refreshed」で失敗する + +これは `claude auth status` がログイン済みと報告している場合でも、再ログイン直後であっても発生することがあります。Claude Code が通常使用するインタラクティブな OAuth セッションは、実際のログインには問題がなくても、この bot がセッションを作成する headless かつ切り離された subprocess 方式に限って動作しなくなることがあります — これは Claude Code CLI の自動更新後に観測されており、断続的に発生することもあります。 + +ユーザーが `/new`、再開したセッション、または任意のメッセージでリアルタイムにこれに遭遇した場合、bot はこの特定の失敗を認識し、生の CLI エラーの代わりに修正手順を即座に返信します。 + +**修正するには**、この bot を実行しているホスト上で: + +1. `claude setup-token` を実行し、開いたブラウザでアクセスを承認してください。**実際のトークンはその後ターミナルに表示されます(`sk-ant-oat01-` で始まります)。ブラウザ側には表示されません** — ターミナルではなくブラウザのページから何かをコピーしてしまうのはよくある間違いで、それでは動作しません。これにより長期間有効な(約 1 年間の)認証トークンが作成されます。これは headless/自動化用途向けの Anthropic 公式にサポートされた仕組みで(GitHub Actions でも使われているものと同じです)、インタラクティブな OAuth セッションとは異なり、切り離されたバックグラウンドプロセスからの Keychain・セッションリフレッシュの動作に依存しません。そのため一度設定すれば、期限が来るまで再度触る必要はないと想定されます。 +2. 表示されたトークンをコピーし、このアプリの実行方法に合ったコマンドで保存してください — bot 自身の返信が自動的に正しいものを選びますが、参考までに: + - `pip` またはワンライナーの `install.sh` でインストールした場合(Quick Start 方法A/B): `coding-agent-telegram claude-auth ` + - クローンしたリポジトリから `./startup.sh` で実行している場合(Quick Start 方法C): `./startup.sh claude-auth ` + + どちらのコマンドも、トークンを env ファイルに `CLAUDE_CODE_OAUTH_TOKEN` として保存し、Claude の認証を即座に再チェックします。そのため、ブラインドな再起動と祈りではなく、その場で成功/失敗が分かります。 +3. bot を再起動して、実行中のプロセスに変更を反映させてください。 + +コマンドを使わずに、env ファイル(`.env_coding_agent_telegram`)に直接 `CLAUDE_CODE_OAUTH_TOKEN=` を自分で設定することもできます — 上記の 2 つのコマンドは、まさにそれを行い、さらに検証も行う便利なラッパーにすぎません。 +
+ ## 📌 メモ - このプロジェクトは、自分のマシンで agent をローカル実行するユーザー向けです。 diff --git a/README.ko.md b/README.ko.md index 014cad4..74cb317 100644 --- a/README.ko.md +++ b/README.ko.md @@ -1,6 +1,6 @@
Coding Agent Telegram -

Coding Agent Telegram 🚀

+

Claude Code / Codex / Copilot Coding Agent Telegram 🚀

English | Deutsch | @@ -181,6 +181,8 @@ pip install coding-agent-telegram coding-agent-telegram ``` +설치된 `coding-agent-telegram` 명령에는 `./startup.sh`와 동일한 폴링 watchdog이 포함되어 있습니다. 봇이 충돌하거나 Telegram heartbeat가 오래되면 봇을 재시작하며, DNS 또는 네트워크를 사용할 수 없는 동안에는 backoff를 적용해 재시도합니다. + ### 방법 C: 저장소를 clone해서 실행 ```bash git clone https://github.com/daocha/coding-agent-telegram @@ -273,6 +275,14 @@ https://api.telegram.org/bot/getUpdates /provider 새 세션용 제공자를 선택합니다. 선택 내용은 바꿀 때까지 bot/chat 단위로 저장됩니다. + + /model + 현재 제공자의 모델 목록에서 활성 세션에 사용할 모델을 선택합니다. 선택한 내용은 해당 세션의 state.json 에 저장되며, 세션을 재개할 때마다 사용됩니다. 새 세션을 시작하면(/new, 새 세션으로 전환하는 /switch, 또는 /compact) 항상 제공자가 설정한 기본 모델로 돌아갑니다. + + + /model <model_id> + 선별된 목록에 없는 특정 모델 id 를 설정합니다. 아직 알려진 선택지가 아니면, bot 이 저장하기 전에 먼저 일회성 read-only 호출로 CLI 를 검증해 해당 id 가 실제로 허용되는지 확인합니다 — CLI 가 거부하면 오류가 채팅에 반환되고 아무것도 저장되지 않습니다. + /project <project_folder> 현재 프로젝트 폴더를 설정합니다. 폴더가 없으면 앱이 만들고 trusted 로 표시합니다. 이미 존재하지만 아직 untrusted 이면 trust 확인을 요청합니다. @@ -289,6 +299,10 @@ https://api.telegram.org/bot/getUpdates /current 현재 bot/chat 의 활성 세션 을 보여줍니다. + + /status + 각 provider 의 quota 사용량(5시간/주간 사용률과 초기화 시각)을 보여줍니다. 유료 API 호출은 전혀 발생하지 않습니다: Codex 는 항상 무료 로컬 조회이며, Claude 의 수치는 bot 을 통한 가장 최근의 실제 Claude 사용 내역에서만 재사용되어 "X 전에 확인됨"으로 표시됩니다(OAuth 로 로그인한 Pro/Max 계정만 해당). 두 창은 각각 별도로 추적되므로, 한쪽이 이미 초기화 시각을 지났다면(또는 아직 한 번도 관측되지 않았다면) 다른 쪽에 최신 데이터가 있어도 다음 Claude turn 에서 갱신될 때까지 N/A 로 표시됩니다. Copilot 은 이를 지원하는 API 가 없어 사용 불가로 표시됩니다. + /new [session_name] 현재 프로젝트에 새 세션을 만듭니다. 이름을 생략하면 실제 세션 ID를 사용합니다. 제공자, 프로젝트, branch 가 없으면 bot 이 필요한 단계를 안내합니다. @@ -319,11 +333,19 @@ https://api.telegram.org/bot/getUpdates /pull - 확인 후 활성 세션 브랜치에 대해 origin 에서 pull 합니다. 필요하면 기본 브랜치도 함께 새로고칩니다. + 확인 후 활성 세션 브랜치에 대해 origin 에서 Git pull 합니다. 필요하면 기본 브랜치도 함께 새로고칩니다. /push - 현재 활성 세션 에 대해 origin <branch> 를 push 합니다. push 전에 bot 이 확인합니다. + 현재 활성 세션 에 대해 origin <branch> 로 Git push 합니다. push 전에 bot 이 확인합니다. + + + /log + 활성 세션 project 의 최근 Git 커밋 5개를 표시합니다. + + + /reset + local 또는 origin 의 기본/현재 브랜치를 선택한 뒤 git reset --hard 를 확인합니다. remote 대상은 먼저 pull 합니다. /abort @@ -402,6 +424,18 @@ https://api.telegram.org/bot/getUpdates CLAUDE_MODEL 선택적 Claude Code model override 입니다. 비워 두면 Claude Code CLI 기본 model 을 사용합니다. 예: sonnet, opus, haiku Claude Code model configuration + + CODEX_MODEL_CHOICES + /model 명령이 Codex에 제공하는 쉼표로 구분된 모델 목록입니다. 설정하지 않으면 함께 제공되는 .env.example의 값을 사용합니다. + + + COPILOT_MODEL_CHOICES + /model 명령이 Copilot에 제공하는 쉼표로 구분된 모델 목록입니다. 설정하지 않으면 함께 제공되는 .env.example의 값을 사용합니다. + + + CLAUDE_MODEL_CHOICES + /model 명령이 Claude Code에 제공하는 쉼표로 구분된 모델 목록입니다. 설정하지 않으면 함께 제공되는 .env.example의 값을 사용하며, 템플릿을 사용할 수 없으면 sonnet,opus,fable,haiku를 사용합니다. + CODEX_APPROVAL_POLICY Codex 에 전달할 approval mode 입니다. 기본값: never. @@ -434,6 +468,22 @@ https://api.telegram.org/bot/getUpdates AGENT_HARD_TIMEOUT_SECONDS 단일 에이전트 실행 의 하드 타임아웃입니다. 기본값: 0 (비활성화). + + LONG_GAP_WARNING_ENABLED + 한동안 idle 상태였고 재처리 비용이 클 만큼 context 가 쌓인 session 을 재개하기 전에, provider 의 prompt cache 가 만료되었을 가능성이 높다고 경고하고, 먼저 compact 할지 그대로 진행할지 선택하는 버튼을 보여줍니다. 기본값: true. 아래 FAQ 를 참고하세요. + + + CLAUDE_LONG_GAP_SECONDS + Claude Code session 에 대해 경고가 발동하기까지의 idle 임계값(초). 기본값: 3600 (1시간, Claude Code 의 확장 prompt cache 유지 시간에 맞춤). + + + CODEX_LONG_GAP_SECONDS + Codex session 에 대해 경고가 발동하기까지의 idle 임계값(초). 기본값: 3600 (1시간, Claude 와 동일. OpenAI 는 Codex 의 idle 기반 cache 만료 시간을 문서화하지 않았고, Codex 자체 cache 도 대체로 Claude 보다 더 짧게 유지되는 경우가 많아 Claude 의 임계값에 맞춰도 정확도 손실이 없습니다. size gate 와 결합해 작은 session 은 경고하지 않도록 합니다). + + + COPILOT_LONG_GAP_SECONDS + Copilot session 에 대해 경고가 발동하기까지의 idle 임계값(초). 기본값: 0 (비활성화). GitHub 공식 문서에 따르면 Copilot CLI 에는 inactivity timeout 이 없고, 이미 자체적으로(사용률 약 80~95% 지점에서) context 를 native 하게 auto-compact 합니다. 여기에는 경고할 idle 관련 위험이 없으므로, 존재하지 않는 API 를 가정하는 대신 Copilot 자체 메커니즘에 맡깁니다. 그래도 Copilot 에 idle 기반 알림을 원한다면 양수 값을 설정하세요. + SNAPSHOT_TEXT_FILE_MAX_BYTES 실행별 diff 스냅샷을 만들 때 bot 이 텍스트로 읽을 최대 파일 크기입니다. 기본값: 200000. @@ -678,6 +728,71 @@ package version 은 Git tag 에서 파생됩니다. - PyPI prerelease: `v2026.3.26rc1` - PyPI stable: `v2026.3.26` +## ❓ FAQ / 문제 해결 + +

+일반 터미널에서 claude --resume 를 실행하면 왜 Telegram 에서 만든 session 이 보이지 않나요? + +이는 Claude Code CLI 의 정상적인 동작이며, 이 앱의 버그가 아닙니다. + +이 bot 이 만드는 session 은 Claude Code 의 headless `-p`/print 모드를 통해 실행됩니다. Claude Code 는 이렇게 시작된 session 을 transcript 에 `entrypoint: "sdk-cli"` 로 표시합니다. 반면 터미널에서 직접 `claude` 를 입력해 시작한 session 은 `entrypoint: "cli"` 로 표시됩니다. session ID 없이 실행하는 대화형 `claude --resume` picker 는 `cli` entrypoint session 만 나열합니다 — headless/SDK 기반 실행은 손으로 이어갈 대화가 아니라 자동화 출력으로 취급되어 의도적으로 숨겨집니다. + +session 데이터 자체가 사라지거나 다른 것은 아닙니다. `~/.claude/projects//.jsonl` 에 저장된 완전히 정상적인, 재개 가능한 Claude Code session 입니다. ID 만 있으면 바로 재개할 수 있습니다. + +```bash +claude --resume +``` + +이 앱이 native picker 에 의존하지 않고 자체 session discovery(`/switch` 가 사용)를 갖춘 이유가 바로 이것입니다 — JSONL 파일을 직접 스캔해 project 경로로 매칭하므로, 단순한 `claude --resume` 에는 절대 나타나지 않아도 Telegram 에서 만든 session 은 여기에 표시됩니다. + +Codex 와 Copilot 은 자체 resume/list 명령에서 이런 대화형 vs headless 구분을 하지 않기 때문에, 이 provider 들의 session 은 일반 터미널에서도 정상적으로 계속 보입니다. +
+ +
+이 앱이 Claude Code 터미널을 직접 쓰는 것보다 token 을 더 많이 쓰나요? + +호출당 근본적인 overhead 차이 때문은 아닙니다 — headless(`-p`)와 대화형 Claude Code 는 동일한 하위 프로토콜과 동일한 과금 체계를 사용합니다. 하지만 실제로는 24시간 Telegram 사용이 일반적인 터미널 사용보다 눈에 띄게 많은 token 을 소모할 수 있는데, 서로 겹치는 두 가지 이유 때문입니다. + +- **session 이 제한 없이 커질 수 있습니다.** bot 이 같은 session 을 몇 시간, 며칠에 걸쳐 편리하게 계속 재개하기 때문에, 한 번도 rotate 하지 않으면 session 이 수백 turn·수 메가바이트의 transcript 를 쌓을 수 있습니다. 대화형 터미널에서는 작업을 끝내고 다음번엔 새로 시작하는 경우가 더 자연스러워 context 가 작게 유지되는 경향이 있습니다. +- **Telegram 메시지 사이의 idle 간격이 prompt cache 를 만료시킵니다.** Claude 의 prompt cache 는 TTL 이 짧습니다. 그 window 안에 답장하면 이후 turn 은 저렴한 cache read 로 처리됩니다. 간격이 길면(예: 잠들었다가 다음 날 아침에 답장하는 경우), 다음 메시지를 보낼 때 지금까지 쌓인 context *전체* 를 훨씬 비싼 cache write 로 처음부터 다시 처리해야 하며, 이 비용은 그 시점까지 session 이 얼마나 커졌는지에 비례해 늘어납니다. 그래서 "피크" 시간대 이전이라도 그날의 첫 메시지를 보내는 순간 사용량이 급증할 수 있습니다. + +**완화 방법:** session 을 무기한 계속 돌리기보다, 특히 오래 idle 상태였던 것을 발견했다면 오래 유지되는 session 에 주기적으로 `/compact`(이 앱이 Telegram 명령으로 지원)를 실행하세요. 관련 없는 작업에 새 `/new` session 을 시작하는 것도 context — 그리고 비용 — 를 억제하는 데 도움이 됩니다. + +이 앱은 이제 이를 자동으로도 수행하며, provider 별로 두 가지 신호를 결합해 실제로 문제가 될 때만 방해합니다: idle 시간 임계값(`CLAUDE_LONG_GAP_SECONDS` / `CODEX_LONG_GAP_SECONDS` / `COPILOT_LONG_GAP_SECONDS`)과 session 이 이미 쌓아온 context 양(작고 저렴한 session 은 오래 idle 이었더라도 경고를 건너뜁니다 — 처음부터 다시 처리해도 별 비용이 안 들기 때문)입니다. 기본값은 Claude Code 와 Codex 모두 1시간입니다 — Claude 의 수치는 실제 근거가 있고(위 참고), OpenAI 는 Codex 에 대해 문서화하지 않았지만 Codex 자체 cache 도 대체로 Claude 보다 더 짧게 유지되는 경우가 많아 Claude 의 임계값에 맞춰도 정확도 손실 없이 방해만 줄어듭니다(이제 size gate 와 결합되어 더욱 그렇습니다). Copilot 은 기본적으로 비활성화되어 있는데, [GitHub 공식 문서](https://docs.github.com/en/copilot/concepts/agents/copilot-cli/context-management)에 따르면 Copilot CLI 에는 inactivity timeout 이 전혀 없고 이미 자체적으로(사용률 약 80~95%에서) context 를 native 하게 auto-compact 하기 때문입니다 — 여기엔 idle 관련으로 경고할 것이 없으므로, 직접 만들어내는 대신 Copilot 자체 메커니즘에 맡깁니다. 그래도 Copilot 에 idle 기반 알림을 원한다면 `COPILOT_LONG_GAP_SECONDS` 를 양수로 설정하세요. + +임계값과 size gate 를 모두 충족하면, 메시지를 보류하고 다음과 같이 묻습니다. + +> ⏳ 이 session 은 {gap} 동안 idle 상태였습니다. 지금 재개하면 대화 전체를 처음부터 다시 처리할 가능성이 높아(provider 의 응답 cache 가 만료되었을 가능성이 큼) 평소보다 훨씬 많은 token 을 소모할 수 있습니다. compact 역시 요약을 작성하기 위해 현재 컨텍스트를 한 번 다시 처리하므로, 이 session 이 이미 큰 경우 마찬가지로 많은 token 을 소모할 수 있습니다. 새 session 으로 전환하면 이 재처리를 완전히 건너뛸 수 있지만, 대신 이 대화에 대한 기억 없이 새로 시작합니다. 새 session 으로 전환할까요, 먼저 compact 할까요, 아니면 그대로 진행할까요? +> +> [🆕 새 session 으로 전환] +> [🔄 먼저 compact] +> [⚠️ 그대로 진행] + +`/compact` 자체도 이 비용에서 자유롭지 않다는 점에 유의하세요: 현재의 (아마도 식어버린) session 을 재개해서 스스로를 요약해 달라고 요청하는 방식으로 동작하므로, 그냥 답장하는 것과 동일한 1회성 전체 transcript 재처리 비용을 여전히 지불합니다 — 다만 결과 session 이 작게 시작되므로, 매 턴마다가 아니라 한 번만 그 비용을 지불하게 될 뿐입니다. **새 session 으로 전환** 은 이 재처리를 완전히 피할 수 있는 유일한 선택지입니다: 이전 session 을 한 번도 재개하지 않고 그 컨텍스트를 그대로 버린 뒤 완전히 새로 시작하며, 그 대가로 컨텍스트를 요약으로 압축하는 대신 완전히 잃게 됩니다. + +**새 session 으로 전환** 을 선택하면 완전히 새로운 빈 session 을 시작하고 그곳에서 메시지를 이어서 진행합니다 — 이름은 기존 session 이름에 증가하는 `-newN` 접미사를 붙인 형태(예: `fix-bug` → `fix-bug-new1` → 다시 전환하면 `fix-bug-new2`)이므로 `/switch` 에서도 원본과 구분할 수 있습니다. **먼저 compact** 를 선택하면 session 을 요약하고 그 요약으로 새 session 을 시작한 뒤, 새 session 에서 메시지를 이어서 진행합니다 — 비슷하게 이름이 붙지만 `-resumeN` 접미사를 사용합니다(예: `fix-bug` → `fix-bug-resume1` → 다음 compaction 에서 `fix-bug-resume2`). **그대로 진행** 을 선택하면 기존 session 에서 평소처럼 계속 진행합니다. 전체 검사는 `LONG_GAP_WARNING_ENABLED=false` 로 비활성화할 수 있습니다. +
+ +
+Claude 세션이 갑자기 "Failed to authenticate: OAuth session expired and could not be refreshed" 로 실패합니다 + +이 문제는 `claude auth status` 가 로그인되어 있다고 알려주는 상태에서도, 심지어 다시 로그인한 직후에도 발생할 수 있습니다. Claude Code 가 평소에 사용하는 대화형 OAuth 세션은 실제 로그인 자체는 문제가 없더라도, 이 봇이 세션을 생성하는 방식인 headless·분리된 subprocess 방식에서만 특정적으로 작동을 멈출 수 있습니다 — 이는 Claude Code CLI 자동 업데이트 이후에 관찰된 적이 있으며, 간헐적으로 발생할 수도 있습니다. + +사용자가 `/new`, 재개된 세션, 또는 아무 메시지로든 실시간으로 이 문제를 겪으면, 봇은 이 특정 실패를 인식하고 원본 CLI 오류 대신 해결 안내를 즉시 답장합니다. + +**해결하려면**, 이 봇을 실행 중인 호스트에서: + +1. `claude setup-token` 을 실행하고 열린 브라우저에서 접근을 승인하세요. **실제 토큰은 그 후 터미널에 출력됩니다 (`sk-ant-oat01-` 로 시작), 브라우저에는 표시되지 않습니다** — 터미널이 아니라 브라우저 페이지에서 무언가를 복사하는 건 흔한 실수이며 그렇게 하면 작동하지 않습니다. 이렇게 하면 장기간(약 1년) 유효한 인증 토큰이 생성됩니다. 이는 headless/자동화 용도를 위한 Anthropic 자체 지원 메커니즘(GitHub Actions 에서 쓰이는 것과 동일)으로, 대화형 OAuth 세션과 달리 분리된 백그라운드 프로세스에서 Keychain/세션 갱신이 동작하는지에 의존하지 않습니다. 따라서 한 번 설정하면 만료될 때까지는 다시 손댈 필요가 없을 것으로 예상됩니다. +2. 출력된 토큰을 복사한 뒤, 이 앱을 실행하는 방식에 맞는 명령으로 저장하세요 — 봇 자체의 답장이 알맞은 것을 자동으로 골라주지만, 참고로: + - `pip` 또는 한 줄 `install.sh` 로 설치한 경우 (Quick Start 방법 A/B): `coding-agent-telegram claude-auth ` + - clone한 저장소에서 `./startup.sh` 로 실행하는 경우 (Quick Start 방법 C): `./startup.sh claude-auth ` + + 두 명령 모두 토큰을 env 파일에 `CLAUDE_CODE_OAUTH_TOKEN` 으로 저장하고, 즉시 Claude 인증을 다시 확인하므로, 무작정 재시작해서 결과를 기다리는 대신 바로 성공/실패를 알 수 있습니다. +3. 실행 중인 프로세스가 변경 사항을 반영하도록 봇을 재시작하세요. + +명령을 사용하지 않고, env 파일(`.env_coding_agent_telegram`)에 직접 `CLAUDE_CODE_OAUTH_TOKEN=` 을 설정할 수도 있습니다 — 위 두 명령은 바로 그 작업에 검증까지 더한 편의 래퍼일 뿐입니다. +
+ ## 📌 참고 - 이 프로젝트는 자기 머신에서 agent 를 로컬로 실행하는 사용자를 위해 설계되었습니다. diff --git a/README.md b/README.md index e9b8097..0db35dd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
Coding Agent Telegram -

Coding Agent Telegram 🚀

+

Claude Code / Codex / Copilot Coding Agent Telegram 🚀

English | Deutsch | @@ -181,6 +181,10 @@ pip install coding-agent-telegram coding-agent-telegram ``` +The installed `coding-agent-telegram` command includes the same polling +watchdog as `./startup.sh`: it restarts the bot after a crash or a stale +Telegram heartbeat, and backs off while DNS/network connectivity is down. + ### Option C: Run from a cloned repository ```bash @@ -283,6 +287,14 @@ The bot currently accepts: /provider Choose the provider for new sessions. The selection is stored per bot and chat until you change it. + + /model + Choose the model for the active session, from the current provider's model list. The selection is stored on that session in state.json and is used whenever the session is resumed. Starting a new session (/new, /switch to a fresh session, or /compact) always resets to the provider's configured default model. + + + /model <model_id> + Set a specific model id not on the curated list. If it's not already a known choice, the bot first probes the CLI with a throwaway read-only call to confirm the id is actually accepted before saving it — if the CLI rejects it, the error is returned to the chat and nothing is saved. + /project <project_folder> Set the current project folder. If the folder does not exist, the app creates it and marks it trusted. If it already exists and is still untrusted, the app asks you to trust it explicitly. @@ -299,6 +311,10 @@ The bot currently accepts: /current Show the active session for the current bot and chat. + + /status + Show each provider's quota usage: 5-hour and weekly usage percentages, with reset times. Never makes a paid API call: Codex is always a free local query, and Claude's numbers are reused only from your most recent real Claude activity through the bot, shown as "last observed X ago" (Pro/Max accounts logged in via OAuth only). The two windows are tracked separately, so if one has passed its reset time (or nothing's been observed yet) it shows N/A until your next Claude turn refreshes it, even while the other window still has fresh data. Copilot has no supported API for this and is reported as unavailable. + /new [session_name] Create a new session for the current project. If you omit the name, the bot uses the real session ID. If provider, project, or branch is missing, the bot guides you through the missing step. @@ -329,11 +345,19 @@ The bot currently accepts: /pull - Pull from origin for the active session branch after confirmation. The bot also refreshes the default branch when applicable. + Git pull from origin for the active session branch after confirmation. The bot also refreshes the default branch when applicable. /push - Push origin <branch> for the current active session. The bot asks for confirmation before pushing. + Git push origin <branch> for the current active session. The bot asks for confirmation before pushing. + + + /log + Show the top five Git commits for the active session project. + + + /reset + Select a local or origin default/current branch, then confirm a git reset --hard. Remote targets are pulled first. /abort @@ -423,6 +447,18 @@ The bot currently accepts: Claude Code model configuration + + CODEX_MODEL_CHOICES + Comma-separated model list offered by the /model command for Codex. When unset, the bundled .env.example value is used. + + + COPILOT_MODEL_CHOICES + Comma-separated model list offered by the /model command for Copilot. When unset, the bundled .env.example value is used. + + + CLAUDE_MODEL_CHOICES + Comma-separated model list offered by the /model command for Claude Code. When unset, the bundled .env.example value is used; if the template is unavailable, it falls back to sonnet,opus,fable,haiku. + CODEX_APPROVAL_POLICY Approval mode passed to Codex. Default: never. @@ -455,6 +491,22 @@ The bot currently accepts: AGENT_HARD_TIMEOUT_SECONDS Hard timeout for a single agent run. Default: 0 (disabled). + + LONG_GAP_WARNING_ENABLED + Before resuming a session that has been idle a while and has accumulated enough context for a reprocess to be costly, warn that the provider's prompt cache has likely expired — with buttons to compact first or proceed anyway. Default: true. See the FAQ below. + + + CLAUDE_LONG_GAP_SECONDS + Idle threshold in seconds before the warning fires for Claude Code sessions. Default: 3600 (1 hour, matching Claude Code's extended prompt-cache window). + + + CODEX_LONG_GAP_SECONDS + Idle threshold in seconds before the warning fires for Codex sessions. Default: 3600 (1 hour, matching Claude's threshold; Codex/OpenAI don't document an idle-based cache-expiry number, and Codex's own cache is generally shorter-lived than Claude's anyway, so there's no accuracy cost to matching it — paired with a size gate so small sessions don't nag). + + + COPILOT_LONG_GAP_SECONDS + Idle threshold in seconds before the warning fires for Copilot sessions. Default: 0 (disabled). GitHub's own docs state Copilot CLI has no inactivity timeout and already auto-compacts its own context natively (~80-95% usage) — there's no idle-based risk to warn about here, so this defers to Copilot's own mechanism instead of inventing one. Set a positive value to opt into an idle-based nudge anyway. + SNAPSHOT_TEXT_FILE_MAX_BYTES Maximum file size the bot will read as text when building the before/after snapshot for per-run diffs. Default: 200000. @@ -706,6 +758,71 @@ Package versions are derived from Git tags. - PyPI prerelease: `v2026.3.26rc1` - PyPI stable: `v2026.3.26` +## ❓ FAQ / Troubleshooting + +

+Why doesn't claude --resume in a plain terminal show sessions created from Telegram? + +This is expected Claude Code CLI behavior, not a bug in this app. + +Sessions created by this bot run through Claude Code's headless `-p`/print mode. Claude Code tags any session started that way with `entrypoint: "sdk-cli"` in its transcript, versus `entrypoint: "cli"` for a session you start by typing `claude` directly in a terminal. The interactive `claude --resume` picker (with no session ID) only lists `cli`-entrypoint sessions — it deliberately hides headless/SDK-driven runs, treating them as automation output rather than conversations meant to be picked back up by hand. + +The session data itself is not lost or different — it is a normal, fully resumable Claude Code session stored under `~/.claude/projects//.jsonl`. You can resume it directly once you have the ID: + +```bash +claude --resume +``` + +This is exactly why this app ships its own session discovery (used by `/switch`) instead of relying on the native picker — it scans the JSONL files directly and matches them by project path, so Telegram-created sessions show up there even though they never appear in a plain `claude --resume`. + +Codex and Copilot don't make this interactive-vs-headless distinction in their own resume/list commands, which is why sessions from those providers still show up fine in a plain terminal. +
+ +
+Does this app burn more tokens than using the Claude Code terminal directly? + +Not because of some inherent per-call overhead difference — headless (`-p`) and interactive Claude Code use the same underlying protocol and pricing. But in practice, 24/7 Telegram usage can burn noticeably more tokens than typical terminal usage, for two compounding reasons: + +- **Sessions can grow unbounded.** Since the bot conveniently resumes the same session across hours or days, a session can accumulate hundreds of turns and megabytes of transcript if you never rotate it. In an interactive terminal you'd more naturally finish a task and start fresh next time, keeping context smaller. +- **Idle gaps between Telegram messages expire the prompt cache.** Claude's prompt cache has a short TTL. If you reply within that window, follow-up turns are cheap cache reads. If there's a long gap (e.g. you go to sleep and reply the next morning), the *entire* accumulated context has to be reprocessed from scratch as a much more expensive cache-write on your next message — and this cost grows with how large the session has already become. This is why usage can spike right when you send your first message of the day, even before "peak" hours. + +**Mitigation:** periodically run `/compact` on long-lived sessions (this app supports it as a Telegram command) instead of letting one session run indefinitely, especially if you notice it's been idle for a long stretch. Starting a fresh `/new` session for unrelated work also helps keep context — and cost — bounded. + +The app also does this automatically, combining two signals per provider so it only interrupts you when it's actually likely to matter: an idle-time threshold (`CLAUDE_LONG_GAP_SECONDS` / `CODEX_LONG_GAP_SECONDS` / `COPILOT_LONG_GAP_SECONDS`) *and* how much context the session has already accumulated (skipping the warning for small/cheap sessions even if they've been idle a while, since reprocessing those from scratch is negligible anyway). Defaults: 1 hour for both Claude Code and Codex — Claude's number has real evidence behind it (see above), and while OpenAI doesn't document one for Codex, Codex's own prompt cache is generally shorter-lived than Claude's anyway, so matching Claude's threshold costs nothing in accuracy and just means fewer interruptions, especially now paired with the size gate; and disabled by default for Copilot, because [GitHub's own docs](https://docs.github.com/en/copilot/concepts/agents/copilot-cli/context-management) state Copilot CLI has no inactivity timeout at all and already auto-compacts its own context natively (around 80–95% usage) — there's nothing idle-related to warn about there, so this defers to Copilot's own mechanism rather than inventing one. Set `COPILOT_LONG_GAP_SECONDS` to a positive value if you want an idle-based nudge for Copilot anyway. + +When the threshold and size gate are both met, it holds your message and asks: + +> ⏳ This session has been idle for {gap}. Resuming it now will likely reprocess the whole conversation from scratch (the provider's response cache has probably expired), which can burn significantly more tokens than usual. Compacting also reprocesses the current context once to write its summary, so it can burn a lot of tokens too if this session is already large. Switching to a new session skips that reprocessing entirely, but starts with no memory of this conversation. Switch to a new session, compact first, or proceed anyway? +> +> [🆕 Switch to new session] +> [🔄 Compact first] +> [⚠️ Proceed anyway] + +Note that `/compact` itself is not free of this cost: it works by resuming the current (possibly cold) session and asking it to summarize itself, so it still pays the same one-time full-transcript reprocess as just replying would — it just means you only pay it once instead of on every subsequent turn, since the resulting session starts small. **Switch to new session** is the only option that avoids that reprocess altogether: it abandons the old session's context without ever resuming it and starts completely fresh, at the cost of losing that context entirely rather than compressing it into a summary. + +Choosing **Switch to new session** starts a brand-new, empty session and continues with your message there — named after the old session with an incrementing `-newN` suffix (e.g. `fix-bug` → `fix-bug-new1` → `fix-bug-new2` if you switch again), so you can still tell it apart from the original in `/switch`. Choosing **Compact first** summarizes the session, starts a fresh one from that summary, and then continues with your message on the new session — named similarly but with a `-resumeN` suffix instead (e.g. `fix-bug` → `fix-bug-resume1` → `fix-bug-resume2` on the next compaction). Choosing **Proceed anyway** just continues on the existing session as normal. Disable the whole check with `LONG_GAP_WARNING_ENABLED=false`. +
+ +
+Claude sessions suddenly fail with "Failed to authenticate: OAuth session expired and could not be refreshed" + +This can happen even when `claude auth status` reports you're logged in, and even right after you've logged in again. The interactive OAuth session Claude Code normally uses can stop working specifically for the headless, detached-subprocess way this bot creates sessions, without your actual login being at fault — this has been observed after a Claude Code CLI auto-update, and can also be intermittent. + +If a user hits it live via `/new`, a resumed session, or any message, the bot recognizes this specific failure and replies with fix instructions immediately, instead of the raw CLI error. + +**To fix it**, on the host running the bot: + +1. Run `claude setup-token` and approve access in the browser it opens. **The actual token is then printed back in your terminal (it starts with `sk-ant-oat01-`), not shown anywhere in the browser** — copying something from the browser page itself instead of the terminal is a common mistake and won't work. This creates a long-lived (about 1 year) authentication token, which is Anthropic's own supported mechanism for headless/automated use (the same one used for GitHub Actions) — unlike the interactive OAuth session, it doesn't depend on Keychain/session refresh working from a detached background process, so once set it isn't expected to need touching again until it's due to expire. +2. Copy the token it prints, then save it with whichever command matches how you run this app — the bot's own reply picks the right one automatically, but for reference: + - Installed via `pip` or the one-line `install.sh` (Quick Start Options A/B): `coding-agent-telegram claude-auth ` + - Running from a cloned repository with `./startup.sh` (Quick Start Option C): `./startup.sh claude-auth ` + + Either command saves the token as `CLAUDE_CODE_OAUTH_TOKEN` in your env file and immediately re-checks Claude auth, so you get a pass/fail right away instead of a blind restart-and-hope. +3. Restart the bot so the running process picks up the change. + +You can also skip the command and set `CLAUDE_CODE_OAUTH_TOKEN=` directly in your env file (`.env_coding_agent_telegram`) yourself — the two commands above are just a convenience wrapper around doing exactly that, plus verification. +
+ ## 📌 Notes - This project is designed for users running the agents locally on their own machine. diff --git a/README.nl.md b/README.nl.md index 74de966..bc1ffdc 100644 --- a/README.nl.md +++ b/README.nl.md @@ -1,6 +1,6 @@
Coding Agent Telegram -

Coding Agent Telegram 🚀

+

Claude Code / Codex / Copilot Coding Agent Telegram 🚀

English | Deutsch | @@ -179,6 +179,8 @@ pip install coding-agent-telegram coding-agent-telegram ``` +De geïnstalleerde opdracht `coding-agent-telegram` bevat dezelfde polling-watchdog als `./startup.sh`: deze start de bot opnieuw na een crash of een verouderde Telegram-heartbeat en gebruikt backoff zolang DNS of netwerk niet beschikbaar is. + ### Optie C: Uitvoeren vanuit een gekloonde repository ```bash git clone https://github.com/daocha/coding-agent-telegram @@ -271,6 +273,14 @@ De bot accepteert momenteel: /provider Kies de aanbieder voor nieuwe sessies. Die keuze wordt per bot en chat bewaard totdat je die wijzigt. + + /model + Kies het model voor de actieve sessie, uit de modellijst van de huidige aanbieder. De keuze wordt op die sessie opgeslagen in state.json en wordt gebruikt telkens wanneer de sessie wordt hervat. Het starten van een nieuwe sessie (/new, /switch naar een nieuwe sessie, of /compact) zet dit altijd terug naar het standaardmodel van de aanbieder. + + + /model <model_id> + Stel een specifiek model-ID in dat niet op de samengestelde lijst staat. Als dit nog geen bekende keuze is, test de bot eerst met een wegwerp read-only aanroep naar de CLI of het ID daadwerkelijk wordt geaccepteerd voordat het wordt opgeslagen — wijst de CLI het af, dan wordt de foutmelding teruggestuurd naar de chat en wordt er niets opgeslagen. + /project <project_folder> Stel de huidige projectmap in. Bestaat de map niet, dan maakt de app die aan en markeert hem trusted. Bestaat hij al maar is hij nog untrusted, dan vraagt de app expliciet om trust. @@ -287,6 +297,10 @@ De bot accepteert momenteel: /current Toon de actieve sessie voor de huidige bot en chat. + + /status + Toont het quotagebruik per provider: 5-uurs- en wekelijkse gebruikspercentages, met reset-tijden. Veroorzaakt nooit een betaalde API-aanroep: Codex is altijd een gratis lokale query, en Claudes cijfers worden alleen hergebruikt van je meest recente echte Claude-activiteit via de bot, getoond als "laatst waargenomen X geleden" (alleen Pro/Max-accounts die via OAuth zijn ingelogd). De twee vensters worden apart bijgehouden: als er een al voorbij zijn reset-tijd is (of nog nooit is waargenomen), toont het N/A totdat je volgende Claude-beurt het bijwerkt, ook als het andere venster nog verse data heeft. Copilot heeft hiervoor geen ondersteunde API en wordt als niet beschikbaar gerapporteerd. + /new [session_name] Maak een nieuwe sessie voor het huidige project. Als je geen naam opgeeft, gebruikt de bot de echte sessie-ID. Als aanbieder, project of branch ontbreekt, begeleidt de bot je door de ontbrekende stap. @@ -317,11 +331,19 @@ De bot accepteert momenteel: /pull - Voer na bevestiging een pull uit vanaf origin voor de branch van de actieve sessie. De bot ververst ook de standaardbranch wanneer dat van toepassing is. + Voer na bevestiging een Git pull uit vanaf origin voor de branch van de actieve sessie. De bot ververst ook de standaardbranch wanneer dat van toepassing is. /push - Push origin <branch> voor de huidige actieve sessie. De bot vraagt om bevestiging voordat hij pusht. + Voer een Git push uit naar origin <branch> voor de huidige actieve sessie. De bot vraagt om bevestiging voordat hij pusht. + + + /log + Toon de laatste vijf Git-commits voor het project van de actieve sessie. + + + /reset + Selecteer de lokale of origin-standaard-/huidige branch en bevestig daarna een git reset --hard. Remote doelen worden eerst gepulld. /abort @@ -404,6 +426,18 @@ De bot accepteert momenteel: Claude Code-modelconfiguratie + + CODEX_MODEL_CHOICES + Door komma's gescheiden modellijst die het /model-commando aanbiedt voor Codex. Indien niet ingesteld, wordt de waarde uit de meegeleverde .env.example gebruikt. + + + COPILOT_MODEL_CHOICES + Door komma's gescheiden modellijst die het /model-commando aanbiedt voor Copilot. Indien niet ingesteld, wordt de waarde uit de meegeleverde .env.example gebruikt. + + + CLAUDE_MODEL_CHOICES + Door komma's gescheiden modellijst die het /model-commando aanbiedt voor Claude Code. Indien niet ingesteld, wordt de waarde uit de meegeleverde .env.example gebruikt; zonder template wordt sonnet,opus,fable,haiku gebruikt. + CODEX_APPROVAL_POLICY Goedkeuringsmodus die aan Codex wordt doorgegeven. Standaard: never. @@ -436,6 +470,22 @@ De bot accepteert momenteel: AGENT_HARD_TIMEOUT_SECONDS Harde timeout voor één agent-run. Standaard: 0 (uitgeschakeld). + + LONG_GAP_WARNING_ENABLED + Waarschuwt, voordat een sessie wordt hervat die al een tijdje inactief is en genoeg context heeft opgebouwd om een herverwerking kostbaar te maken, dat de prompt-cache van de provider waarschijnlijk is verlopen — met knoppen om eerst te compacten of toch door te gaan. Standaard: true. Zie de FAQ hieronder. + + + CLAUDE_LONG_GAP_SECONDS + Inactiviteitsdrempel in seconden voordat de waarschuwing afgaat voor Claude Code-sessies. Standaard: 3600 (1 uur, overeenkomend met het uitgebreide prompt-cachevenster van Claude Code). + + + CODEX_LONG_GAP_SECONDS + Inactiviteitsdrempel in seconden voordat de waarschuwing afgaat voor Codex-sessies. Standaard: 3600 (1 uur, gelijk aan Claude; OpenAI documenteert geen op inactiviteit gebaseerd cache-vervalgetal voor Codex, en de eigen cache van Codex is sowieso doorgaans korter dan die van Claude, dus aansluiten bij Claude's drempel kost niets aan nauwkeurigheid — gecombineerd met de omvangdrempel zodat kleine sessies niet vervelend worden). + + + COPILOT_LONG_GAP_SECONDS + Inactiviteitsdrempel in seconden voordat de waarschuwing afgaat voor Copilot-sessies. Standaard: 0 (uitgeschakeld). GitHub's eigen documentatie stelt dat Copilot CLI geen inactiviteitstimeout heeft en zijn eigen context al native compact (rond 80-95% gebruik) — er is hier geen op inactiviteit gebaseerd risico om voor te waarschuwen, dus dit vertrouwt op Copilot's eigen mechanisme in plaats van er zelf een te verzinnen. Stel een positieve waarde in als je toch een op inactiviteit gebaseerde melding voor Copilot wilt. + SNAPSHOT_TEXT_FILE_MAX_BYTES Maximale bestandsgrootte die de bot als tekst leest voor de voor/na-momentopname voor per-run diffs. Standaard: 200000. @@ -682,6 +732,71 @@ Pakketversies worden afgeleid van Git-tags. - PyPI-prerelease: `v2026.3.26rc1` - PyPI-stable: `v2026.3.26` +## ❓ FAQ / Probleemoplossing + +

+Waarom toont claude --resume in een gewone terminal geen sessies die vanuit Telegram zijn gemaakt? + +Dit is verwacht gedrag van de Claude Code CLI, geen bug in deze app. + +Sessies die door deze bot worden gemaakt, lopen via de headless `-p`/print-modus van Claude Code. Claude Code labelt elke zo gestarte sessie in de transcriptie met `entrypoint: "sdk-cli"`, tegenover `entrypoint: "cli"` voor een sessie die je start door direct `claude` in een terminal te typen. De interactieve `claude --resume`-picker (zonder sessie-ID) toont alleen sessies met `cli`-entrypoint — hij verbergt headless/SDK-gestuurde runs bewust en behandelt ze als automatiseringsoutput in plaats van gesprekken die met de hand hervat moeten worden. + +De sessiedata zelf is niet kwijt of anders — het is een heel normale, volledig hervatbare Claude Code-sessie, opgeslagen onder `~/.claude/projects//.jsonl`. Zodra je de ID hebt, kun je hem direct hervatten: + +```bash +claude --resume +``` + +Dit is precies waarom deze app zijn eigen sessie-detectie meelevert (gebruikt door `/switch`) in plaats van te vertrouwen op de native picker — hij doorzoekt de JSONL-bestanden rechtstreeks en matcht ze op projectpad, zodat vanuit Telegram gemaakte sessies daar wél verschijnen, ook al komen ze nooit voor in een gewone `claude --resume`. + +Codex en Copilot maken dit onderscheid tussen interactief en headless niet in hun eigen resume-/lijstcommando's, waardoor sessies van die providers gewoon blijven verschijnen in een normale terminal. +
+ +
+Verbruikt deze app meer tokens dan de Claude Code-terminal rechtstreeks gebruiken? + +Niet vanwege een inherent verschil in overhead per aanroep — headless (`-p`) en interactieve Claude Code gebruiken hetzelfde onderliggende protocol en dezelfde prijsstelling. Maar in de praktijk kan 24/7-Telegramgebruik merkbaar meer tokens verbruiken dan typisch terminalgebruik, om twee elkaar versterkende redenen: + +- **Sessies kunnen onbeperkt groeien.** Omdat de bot gemakshalve dezelfde sessie uren of dagen lang blijft hervatten, kan een sessie honderden beurten en megabytes aan transcriptie opbouwen als je hem nooit roteert. In een interactieve terminal rond je eerder een taak af en begin je de volgende keer opnieuw, waardoor de context kleiner blijft. +- **Inactieve periodes tussen Telegram-berichten laten de prompt-cache verlopen.** Claude's prompt-cache heeft een korte TTL. Antwoord je binnen dat venster, dan zijn vervolgbeurten goedkope cache-reads. Is er een lange pauze (bijvoorbeeld je slaapt en antwoordt de volgende ochtend), dan moet de *volledige* opgebouwde context bij je volgende bericht helemaal opnieuw worden verwerkt als een veel duurdere cache-write — en die kosten groeien met hoe groot de sessie inmiddels is. Daarom kan het verbruik precies pieken op het moment dat je je eerste bericht van de dag stuurt, zelfs nog vóór de "spits". + +**Mitigatie:** draai periodiek `/compact` op langlopende sessies (deze app ondersteunt dit als Telegram-commando) in plaats van een sessie onbeperkt door te laten lopen, zeker als je merkt dat hij lang inactief is geweest. Een nieuwe `/new`-sessie starten voor niet-gerelateerd werk helpt ook om context — en kosten — beperkt te houden. + +De app doet dit inmiddels ook automatisch, door per provider twee signalen te combineren zodat je alleen wordt onderbroken als het er echt toe doet: een inactiviteitsdrempel (`CLAUDE_LONG_GAP_SECONDS` / `CODEX_LONG_GAP_SECONDS` / `COPILOT_LONG_GAP_SECONDS`) *en* hoeveel context de sessie al heeft opgebouwd (de waarschuwing wordt overgeslagen voor kleine/goedkope sessies, zelfs als ze een tijdje inactief zijn geweest, omdat die vanaf nul herverwerken toch verwaarloosbaar is). Standaardwaarden: 1 uur voor zowel Claude Code als Codex — Claude's getal is onderbouwd met echt bewijs (zie hierboven), en hoewel OpenAI er geen documenteert voor Codex, is de eigen cache van Codex sowieso doorgaans korter dan die van Claude, dus aansluiten bij Claude's drempel kost niets aan nauwkeurigheid en levert simpelweg minder onderbrekingen op, zeker nu gecombineerd met de omvangdrempel; en standaard uitgeschakeld voor Copilot, omdat [GitHub's eigen documentatie](https://docs.github.com/en/copilot/concepts/agents/copilot-cli/context-management) stelt dat Copilot CLI helemaal geen inactiviteitstimeout heeft en zijn eigen context al native compact (rond 80-95% gebruik) — daar is niets inactiviteitsgerelateerds om voor te waarschuwen, dus dit vertrouwt op Copilot's eigen mechanisme in plaats van er zelf een te verzinnen. Stel `COPILOT_LONG_GAP_SECONDS` in op een positieve waarde als je toch een op inactiviteit gebaseerde melding voor Copilot wilt. + +Als zowel de drempel als de omvangdrempel worden gehaald, houdt de app je bericht vast en vraagt: + +> ⏳ Deze sessie is {gap} inactief geweest. Hem nu hervatten zal waarschijnlijk het hele gesprek helemaal opnieuw verwerken (de responscache van de provider is vermoedelijk verlopen), wat aanzienlijk meer tokens kan kosten dan normaal. Compacten verwerkt de huidige context ook één keer opnieuw om de samenvatting te schrijven, dus dat kan ook veel tokens kosten als deze sessie al groot is. Overschakelen naar een nieuwe sessie slaat die herverwerking helemaal over, maar begint dan zonder enige herinnering aan dit gesprek. Overschakelen naar een nieuwe sessie, eerst compacten, of toch doorgaan? +> +> [🆕 Overschakelen naar nieuwe sessie] +> [🔄 Eerst compacten] +> [⚠️ Toch doorgaan] + +Let op: `/compact` zelf is niet gratis wat deze kosten betreft: het werkt door de huidige (mogelijk koude) sessie te hervatten en te vragen zichzelf samen te vatten, dus het betaalt dezelfde eenmalige volledige herverwerking van de transcriptie als gewoon antwoorden — het enige verschil is dat je die kosten daarna maar één keer betaalt in plaats van bij elke volgende beurt, omdat de resulterende sessie klein begint. **Overschakelen naar nieuwe sessie** is de enige optie die die herverwerking helemaal vermijdt: ze laat de context van de oude sessie volledig los zonder hem ooit te hervatten en begint compleet opnieuw — ten koste van het volledig verliezen van die context in plaats van hem tot een samenvatting te comprimeren. + +Kies je **Overschakelen naar nieuwe sessie**, dan start er een gloednieuwe, lege sessie en gaat je bericht daar verder — genoemd naar de oude sessie met een oplopend `-newN`-achtervoegsel (bv. `fix-bug` → `fix-bug-new1` → `fix-bug-new2` als je nog eens overschakelt), zodat je hem in `/switch` nog steeds van het origineel kunt onderscheiden. Kies je **Eerst compacten**, dan wordt de sessie samengevat, wordt er een nieuwe sessie gestart op basis van die samenvatting, en gaat je bericht daarna verder op de nieuwe sessie — vergelijkbaar genoemd, maar met een `-resumeN`-achtervoegsel (bv. `fix-bug` → `fix-bug-resume1` → `fix-bug-resume2` bij de volgende compactie). Kies je **Toch doorgaan**, dan gaat het gewoon verder op de bestaande sessie zoals normaal. Schakel de hele check uit met `LONG_GAP_WARNING_ENABLED=false`. +
+ +
+Claude-sessies mislukken plotseling met "Failed to authenticate: OAuth session expired and could not be refreshed" + +Dit kan gebeuren zelfs wanneer `claude auth status` meldt dat je bent ingelogd, en zelfs direct nadat je opnieuw hebt ingelogd. De interactieve OAuth-sessie die Claude Code normaal gebruikt, kan specifiek stoppen met werken voor de headless, losgekoppelde subprocess-manier waarop deze bot sessies aanmaakt, zonder dat je eigenlijke login de schuld heeft — dit is waargenomen na een automatische update van de Claude Code CLI, en kan ook intermitterend zijn. + +Als een gebruiker dit live tegenkomt via `/new`, een hervatte sessie, of een willekeurig bericht, herkent de bot deze specifieke storing en antwoordt direct met herstelinstructies, in plaats van de ruwe CLI-fout. + +**Om dit op te lossen**, op de host waarop de bot draait: + +1. Voer `claude setup-token` uit en keur de toegang goed in de geopende browser. **Het echte token wordt daarna in je terminal getoond (het begint met `sk-ant-oat01-`), niet in de browser** — iets kopiëren van de browserpagina zelf in plaats van de terminal is een veelgemaakte fout en werkt niet. Dit maakt een langdurig geldig (ongeveer 1 jaar) authenticatietoken aan — Anthropics eigen ondersteunde mechanisme voor headless/geautomatiseerd gebruik (hetzelfde dat gebruikt wordt voor GitHub Actions). In tegenstelling tot de interactieve OAuth-sessie is het niet afhankelijk van een werkende Keychain-/sessievernieuwing vanuit een losgekoppeld achtergrondproces, dus eenmaal ingesteld hoef je het naar verwachting pas weer aan te raken als het verloopt. +2. Kopieer het getoonde token en sla het op met het commando dat past bij hoe je deze app draait — het eigen antwoord van de bot kiest automatisch het juiste, maar ter referentie: + - Geïnstalleerd via `pip` of de eenregelige `install.sh` (Quick Start Optie A/B): `coding-agent-telegram claude-auth ` + - Draait vanuit een gekloonde repository met `./startup.sh` (Quick Start Optie C): `./startup.sh claude-auth ` + + Beide commando's slaan het token op als `CLAUDE_CODE_OAUTH_TOKEN` in je env-bestand en controleren direct opnieuw de Claude-authenticatie, zodat je meteen een geslaagd/mislukt resultaat krijgt in plaats van blind te herstarten en te hopen. +3. Herstart de bot zodat het draaiende proces de wijziging oppikt. + +Je kunt het commando ook overslaan en zelf `CLAUDE_CODE_OAUTH_TOKEN=` rechtstreeks in je env-bestand (`.env_coding_agent_telegram`) instellen — de twee commando's hierboven zijn slechts een handig wrapper daaromheen, plus verificatie. +
+ ## 📌 Opmerkingen - Dit project is bedoeld voor gebruikers die agents lokaal op hun eigen machine uitvoeren. diff --git a/README.th.md b/README.th.md index f6d0370..1c209b2 100644 --- a/README.th.md +++ b/README.th.md @@ -1,6 +1,6 @@
Coding Agent Telegram -

Coding Agent Telegram 🚀

+

Claude Code / Codex / Copilot Coding Agent Telegram 🚀

English | Deutsch | @@ -179,6 +179,8 @@ pip install coding-agent-telegram coding-agent-telegram ``` +คำสั่ง `coding-agent-telegram` ที่ติดตั้งแล้วมี polling watchdog แบบเดียวกับ `./startup.sh`: จะรีสตาร์ต bot หลัง crash หรือเมื่อ Telegram heartbeat ล้าสมัย และจะหน่วงเวลาแบบ backoff ระหว่างที่ DNS หรือ network ใช้งานไม่ได้ + ### วิธีที่ C: รันจาก repository ที่ clone มา ```bash git clone https://github.com/daocha/coding-agent-telegram @@ -271,6 +273,14 @@ https://api.telegram.org/bot/getUpdates /provider เลือกผู้ให้บริการสำหรับเซสชันใหม่ โดยค่าที่เลือกจะถูกเก็บแยกตาม bot และ chat จนกว่าคุณจะเปลี่ยน + + /model + เลือก model สำหรับเซสชันที่ใช้งานอยู่ จากรายการ model ของผู้ให้บริการปัจจุบัน ค่าที่เลือกจะถูกเก็บไว้กับเซสชันนั้นใน state.json และจะถูกใช้ทุกครั้งที่ resume เซสชันนั้น การเริ่มเซสชันใหม่ (/new, /switch ไปยังเซสชันใหม่ หรือ /compact) จะรีเซ็ตกลับไปเป็น model เริ่มต้นที่ผู้ให้บริการกำหนดไว้เสมอ + + + /model <model_id> + ตั้งค่า model id ที่ไม่อยู่ในรายการที่คัดสรรไว้ หากยังไม่ใช่ตัวเลือกที่รู้จัก บอตจะทดสอบกับ CLI ด้วยการเรียกแบบ read-only ที่ใช้ทิ้งเพื่อยืนยันก่อนว่า id นั้นใช้ได้จริงก่อนจะบันทึก หาก CLI ปฏิเสธ ข้อความ error จะถูกส่งกลับมาในแชทและจะไม่มีการบันทึกใดๆ + /project <project_folder> ตั้งค่าโฟลเดอร์ project ปัจจุบัน หากโฟลเดอร์ยังไม่มี แอปจะสร้างและทำเครื่องหมายว่า trusted หากมีอยู่แล้วแต่ยัง untrusted แอปจะถามยืนยัน trust ก่อน @@ -287,6 +297,10 @@ https://api.telegram.org/bot/getUpdates /current แสดง เซสชันที่ใช้งานอยู่ ของ bot และ chat ปัจจุบัน + + /status + แสดงปริมาณการใช้โควตาของแต่ละ provider: เปอร์เซ็นต์การใช้งานแบบ 5 ชั่วโมงและรายสัปดาห์ พร้อมเวลารีเซ็ต ไม่มีการเรียก API แบบมีค่าใช้จ่ายเลย: Codex เป็นการสอบถามในเครื่องแบบฟรีเสมอ ส่วนตัวเลขของ Claude จะถูกนำมาใช้ซ้ำจากการใช้งาน Claude จริงล่าสุดผ่าน bot เท่านั้น แสดงเป็น "ตรวจพบล่าสุดเมื่อ X ที่แล้ว" (ใช้ได้เฉพาะบัญชี Pro/Max ที่ล็อกอินผ่าน OAuth เท่านั้น) ทั้งสองช่วงเวลาจะถูกติดตามแยกกัน หากช่วงใดช่วงหนึ่งผ่านเวลารีเซ็ตไปแล้ว (หรือยังไม่เคยถูกตรวจพบเลย) ก็จะแสดงเป็น N/A จนกว่าการใช้งาน Claude ครั้งถัดไปจะอัปเดตข้อมูล แม้ว่าอีกช่วงเวลาหนึ่งจะยังมีข้อมูลล่าสุดอยู่ก็ตาม ส่วน Copilot ไม่มี API ที่รองรับสำหรับข้อมูลนี้ จึงรายงานว่าไม่พร้อมใช้งาน + /new [session_name] สร้างเซสชันใหม่สำหรับ project ปัจจุบัน หากไม่ระบุชื่อ บอตจะใช้รหัสเซสชันจริง หากยังไม่มีผู้ให้บริการ, project หรือ branch บอตจะพาคุณไปยังขั้นตอนที่ขาดอยู่ @@ -317,11 +331,19 @@ https://api.telegram.org/bot/getUpdates /pull - ดึงจาก origin สำหรับ branch ของเซสชันที่ใช้งานอยู่หลังจากยืนยันแล้ว และ bot จะรีเฟรช default branch ให้ด้วยเมื่อเกี่ยวข้อง + Git pull จาก origin สำหรับ branch ของเซสชันที่ใช้งานอยู่หลังจากยืนยันแล้ว และ bot จะรีเฟรช default branch ให้ด้วยเมื่อเกี่ยวข้อง /push - push origin <branch> สำหรับ เซสชันที่ใช้งานอยู่ ปัจจุบัน โดยบอตจะขอการยืนยันก่อน push + Git push ไปยัง origin <branch> สำหรับ เซสชันที่ใช้งานอยู่ ปัจจุบัน โดยบอตจะขอการยืนยันก่อน push + + + /log + แสดง Git commit 5 รายการล่าสุดของ project ในเซสชันที่ใช้งานอยู่ + + + /reset + เลือก default/current branch จาก local หรือ origin แล้วจึงยืนยัน git reset --hard โดยจะ pull เป้าหมาย remote ก่อน /abort @@ -400,6 +422,18 @@ https://api.telegram.org/bot/getUpdates CLAUDE_MODEL กำหนด model ของ Claude Code เพิ่มเติมได้แบบ optional หากปล่อยว่างจะใช้ model เริ่มต้นของ Claude Code CLI ตัวอย่าง: sonnet, opus, haiku Claude Code model configuration + + CODEX_MODEL_CHOICES + รายการ model ที่คั่นด้วยคอมมา ซึ่งคำสั่ง /model จะแสดงให้เลือกสำหรับ Codex หากไม่ได้ตั้งค่า จะใช้ค่าจาก .env.example ที่มาพร้อมโปรแกรม + + + COPILOT_MODEL_CHOICES + รายการ model ที่คั่นด้วยคอมมา ซึ่งคำสั่ง /model จะแสดงให้เลือกสำหรับ Copilot หากไม่ได้ตั้งค่า จะใช้ค่าจาก .env.example ที่มาพร้อมโปรแกรม + + + CLAUDE_MODEL_CHOICES + รายการ model ที่คั่นด้วยคอมมา ซึ่งคำสั่ง /model จะแสดงให้เลือกสำหรับ Claude Code หากไม่ได้ตั้งค่า จะใช้ค่าจาก .env.example ที่มาพร้อมโปรแกรม; หากไม่พบเทมเพลต จะใช้ sonnet,opus,fable,haiku + CODEX_APPROVAL_POLICY โหมด approval ที่ส่งให้ Codex ค่าเริ่มต้น: never @@ -432,6 +466,22 @@ https://api.telegram.org/bot/getUpdates AGENT_HARD_TIMEOUT_SECONDS ฮาร์ดไทม์เอาต์สำหรับ การรันของเอเจนต์ หนึ่งครั้ง ค่าเริ่มต้น: 0 (ปิดใช้งาน) + + LONG_GAP_WARNING_ENABLED + ก่อน resume session ที่ idle มาสักพัก และ สะสม context มากพอที่การประมวลผลใหม่จะมีต้นทุนสูง จะเตือนว่า prompt cache ของ provider น่าจะหมดอายุแล้ว — พร้อมปุ่มให้เลือก compact ก่อน หรือดำเนินการต่อเลย ค่าเริ่มต้น: true ดู FAQ ด้านล่าง + + + CLAUDE_LONG_GAP_SECONDS + ค่า threshold ของช่วง idle (วินาที) ก่อนที่คำเตือนจะทำงานสำหรับ session ของ Claude Code ค่าเริ่มต้น: 3600 (1 ชั่วโมง ตรงกับช่วง extended prompt-cache ของ Claude Code) + + + CODEX_LONG_GAP_SECONDS + ค่า threshold ของช่วง idle (วินาที) ก่อนที่คำเตือนจะทำงานสำหรับ session ของ Codex ค่าเริ่มต้น: 3600 (1 ชั่วโมง เท่ากับ Claude เนื่องจาก OpenAI ไม่มีเอกสารระบุตัวเลข cache หมดอายุแบบอิงตาม idle สำหรับ Codex และ cache ของ Codex เองก็มักจะอยู่ได้สั้นกว่า Claude อยู่แล้ว การตั้งให้เท่ากับ threshold ของ Claude จึงไม่เสียความแม่นยำ — และยังจับคู่กับ size gate เพื่อไม่ให้ session เล็กๆ มากวนใจด้วย) + + + COPILOT_LONG_GAP_SECONDS + ค่า threshold ของช่วง idle (วินาที) ก่อนที่คำเตือนจะทำงานสำหรับ session ของ Copilot ค่าเริ่มต้น: 0 (ปิดใช้งาน) เอกสารของ GitHub เองระบุว่า Copilot CLI ไม่มี inactivity timeout และมันจะ auto-compact context ของตัวเองอยู่แล้วโดยธรรมชาติ (ที่ระดับการใช้งานประมาณ 80-95%) ตรงนี้จึงไม่มีความเสี่ยงจาก idle ที่ต้องเตือน จึงปล่อยให้เป็นหน้าที่ของกลไกของ Copilot เองแทนที่จะสมมติ API ที่ไม่มีอยู่จริง ตั้งค่าเป็นตัวเลขบวกได้ถ้าต้องการให้มีการแจ้งเตือนแบบ idle สำหรับ Copilot เช่นกัน + SNAPSHOT_TEXT_FILE_MAX_BYTES ขนาดไฟล์สูงสุดที่บอตจะอ่านเป็นข้อความเพื่อสร้าง สแนปช็อตก่อนและหลังการรัน สำหรับ diff ของแต่ละ run ค่าเริ่มต้น: 200000 @@ -682,6 +732,71 @@ log จะถูกเขียน **ทั้งไปที่ stdout แล - PyPI prerelease: `v2026.3.26rc1` - PyPI stable: `v2026.3.26` +## ❓ FAQ / การแก้ปัญหา + +

+ทำไม claude --resume ใน terminal ปกติถึงไม่แสดง session ที่สร้างจาก Telegram? + +นี่คือพฤติกรรมปกติของ Claude Code CLI ไม่ใช่บั๊กของแอปนี้ + +session ที่สร้างโดย bot นี้จะรันผ่านโหมด headless `-p`/print ของ Claude Code ซึ่ง Claude Code จะติดแท็ก session ที่เริ่มด้วยวิธีนี้ใน transcript ว่า `entrypoint: "sdk-cli"` ต่างจาก `entrypoint: "cli"` ของ session ที่คุณเริ่มโดยพิมพ์ `claude` ตรงๆ ใน terminal ตัวเลือก `claude --resume` แบบ interactive (ไม่ระบุ session ID) จะแสดงเฉพาะ session ที่มี entrypoint เป็น `cli` เท่านั้น — มันซ่อน run ที่มาจาก headless/SDK โดยตั้งใจ เพราะถือว่าเป็น output ของระบบอัตโนมัติ ไม่ใช่บทสนทนาที่ตั้งใจให้กลับมาต่อด้วยมือ + +ข้อมูล session เองไม่ได้หายหรือแตกต่างไปแต่อย่างใด มันคือ session ของ Claude Code ปกติที่ resume ได้เต็มรูปแบบ เก็บอยู่ที่ `~/.claude/projects//.jsonl` เมื่อคุณมี ID แล้วก็ resume ได้โดยตรง: + +```bash +claude --resume +``` + +นี่คือเหตุผลที่แอปนี้มีระบบค้นหา session ของตัวเอง (ใช้โดย `/switch`) แทนที่จะพึ่ง picker แบบ native — มันสแกนไฟล์ JSONL โดยตรงและจับคู่ด้วย project path ทำให้ session ที่สร้างจาก Telegram ปรากฏที่นั่น แม้จะไม่เคยปรากฏใน `claude --resume` ธรรมดาเลยก็ตาม + +Codex และ Copilot ไม่ได้แยกความแตกต่างระหว่าง interactive กับ headless แบบนี้ในคำสั่ง resume/list ของตัวเอง จึงเป็นเหตุผลว่าทำไม session ของ provider เหล่านั้นยังคงแสดงตามปกติใน terminal ทั่วไป +
+ +
+แอปนี้กิน token มากกว่าการใช้ terminal ของ Claude Code โดยตรงหรือไม่? + +ไม่ใช่เพราะ overhead ต่อการเรียกที่ต่างกันโดยพื้นฐาน — โหมด headless (`-p`) และ Claude Code แบบ interactive ใช้ protocol และโครงสร้างราคาเดียวกัน แต่ในทางปฏิบัติ การใช้งานผ่าน Telegram แบบ 24/7 สามารถกิน token มากกว่าการใช้งานผ่าน terminal ทั่วไปอย่างเห็นได้ชัด ด้วยเหตุผลสองข้อที่ส่งผลซ้อนกัน: + +- **session สามารถโตได้ไม่จำกัด** เพราะ bot จะ resume session เดิมต่อไปเรื่อยๆ ข้ามชั่วโมงหรือข้ามวันอย่างสะดวก session หนึ่งจึงสะสม turn เป็นร้อยและ transcript เป็นเมกะไบต์ได้ ถ้าไม่เคย rotate เลย ใน terminal แบบ interactive คุณมักจะทำงานเสร็จแล้วเริ่มใหม่ในครั้งถัดไปโดยธรรมชาติมากกว่า ทำให้ context เล็กกว่า +- **ช่วง idle ระหว่างข้อความ Telegram ทำให้ prompt cache หมดอายุ** prompt cache ของ Claude มี TTL สั้น ถ้าคุณตอบภายใน window นั้น turn ถัดไปจะเป็น cache read ที่ถูก แต่ถ้ามีช่วงห่างนาน (เช่น คุณไปนอนแล้วตอบตอนเช้า) context ที่สะสมไว้ *ทั้งหมด* จะต้องถูกประมวลผลใหม่ตั้งแต่ต้นในข้อความถัดไปในรูปแบบ cache write ที่แพงกว่ามาก — และค่าใช้จ่ายนี้จะยิ่งสูงขึ้นตามขนาดของ session ที่สะสมมา นี่คือเหตุผลที่ usage อาจพุ่งขึ้นทันทีที่คุณส่งข้อความแรกของวัน แม้จะยังไม่ถึงช่วง "peak" ก็ตาม + +**วิธีบรรเทา:** รัน `/compact` เป็นระยะกับ session ที่ใช้งานยาวนาน (แอปนี้รองรับเป็นคำสั่ง Telegram) แทนที่จะปล่อยให้ session รันไปเรื่อยๆ ไม่มีที่สิ้นสุด โดยเฉพาะถ้าสังเกตว่ามัน idle มานาน การเริ่ม session ใหม่ด้วย `/new` สำหรับงานที่ไม่เกี่ยวข้องกันก็ช่วยควบคุม context — และค่าใช้จ่าย — ให้อยู่ในขอบเขตได้เช่นกัน + +ตอนนี้แอปทำสิ่งนี้ให้โดยอัตโนมัติด้วย โดยผสาน signal สองอย่างต่อ provider เพื่อจะขัดจังหวะคุณเฉพาะตอนที่มีนัยสำคัญจริงๆ: threshold ของช่วง idle (`CLAUDE_LONG_GAP_SECONDS` / `CODEX_LONG_GAP_SECONDS` / `COPILOT_LONG_GAP_SECONDS`) *และ* context ที่ session สะสมไว้แล้วมากแค่ไหน (จะข้ามคำเตือนสำหรับ session เล็กๆ ที่ต้นทุนต่ำ แม้จะ idle มาสักพักแล้วก็ตาม เพราะการประมวลผลใหม่ทั้งหมดแทบไม่มีต้นทุนอยู่แล้ว) ค่าเริ่มต้น: 1 ชั่วโมงทั้งสำหรับ Claude Code และ Codex — ตัวเลขของ Claude มีหลักฐานจริงรองรับ (ดูด้านบน) และแม้ OpenAI จะไม่มีเอกสารระบุไว้สำหรับ Codex แต่ cache ของ Codex เองก็มักจะอยู่ได้สั้นกว่า Claude อยู่แล้ว การตั้งให้เท่ากับ threshold ของ Claude จึงไม่เสียความแม่นยำ แค่ลดจำนวนครั้งที่ขัดจังหวะ (ยิ่งตอนนี้จับคู่กับ size gate ด้วย) ส่วน Copilot ปิดใช้งานโดยค่าเริ่มต้น เพราะ[เอกสารของ GitHub เอง](https://docs.github.com/en/copilot/concepts/agents/copilot-cli/context-management)ระบุว่า Copilot CLI ไม่มี inactivity timeout เลย และ auto-compact context ของตัวเองอยู่แล้วโดยธรรมชาติ (ที่ระดับการใช้งานประมาณ 80-95%) ตรงนี้จึงไม่มีอะไรเกี่ยวกับ idle ที่ต้องเตือน จึงปล่อยให้เป็นหน้าที่ของกลไกของ Copilot เองแทนที่จะสร้างขึ้นมาเอง ตั้งค่า `COPILOT_LONG_GAP_SECONDS` เป็นตัวเลขบวกได้ถ้าต้องการให้มีการแจ้งเตือนแบบ idle สำหรับ Copilot เช่นกัน + +เมื่อเข้าเงื่อนไขทั้ง threshold และ size gate แอปจะกันข้อความของคุณไว้ก่อนแล้วถามว่า: + +> ⏳ session นี้ idle มาแล้ว {gap} การ resume ตอนนี้มีแนวโน้มจะประมวลผลบทสนทนาทั้งหมดใหม่ตั้งแต่ต้น (cache การตอบกลับของ provider น่าจะหมดอายุแล้ว) ซึ่งอาจกิน token มากกว่าปกติอย่างมาก การ compact ก็ต้องประมวลผล context ปัจจุบันใหม่หนึ่งครั้งเพื่อเขียนสรุปเช่นกัน จึงอาจกิน token มากได้เช่นกันหาก session นี้ใหญ่อยู่แล้ว การสลับไปยัง session ใหม่จะข้ามการประมวลผลใหม่นี้ไปทั้งหมด แต่จะเริ่มต้นโดยไม่มีความจำของบทสนทนานี้เลย ต้องการสลับไปยัง session ใหม่ compact ก่อน หรือดำเนินการต่อเลย? +> +> [🆕 สลับไปยัง session ใหม่] +> [🔄 Compact ก่อน] +> [⚠️ ดำเนินการต่อเลย] + +โปรดทราบว่า `/compact` เองก็ไม่ได้ฟรีจากต้นทุนนี้: มันทำงานโดย resume session ปัจจุบัน (ที่อาจเย็นแล้ว) และขอให้มันสรุปตัวเอง จึงยังต้องจ่ายต้นทุนการประมวลผล transcript ทั้งหมดใหม่แบบครั้งเดียวเหมือนกับการตอบกลับตามปกติ — ต่างกันตรงที่คุณจะจ่ายต้นทุนนั้นเพียงครั้งเดียวแทนที่จะจ่ายทุก turn ถัดไป เพราะ session ที่ได้จะเริ่มต้นเล็ก **สลับไปยัง session ใหม่** เป็นตัวเลือกเดียวที่หลีกเลี่ยงการประมวลผลใหม่นี้ได้ทั้งหมด: มันทิ้ง context ของ session เก่าไปโดยไม่เคย resume มันเลย แล้วเริ่มต้นใหม่ทั้งหมด แลกกับการสูญเสีย context นั้นไปทั้งหมดแทนที่จะบีบอัดเป็นสรุป + +ถ้าเลือก **สลับไปยัง session ใหม่** จะเริ่ม session ใหม่ที่ว่างเปล่าทั้งหมด แล้วดำเนินการต่อด้วยข้อความของคุณที่นั่น — ตั้งชื่อตาม session เดิมพร้อม suffix `-newN` ที่เพิ่มขึ้นเรื่อยๆ (เช่น `fix-bug` → `fix-bug-new1` → `fix-bug-new2` ถ้าสลับอีกครั้ง) เพื่อให้ยังแยกจาก session ต้นฉบับได้ใน `/switch` ถ้าเลือก **Compact ก่อน** จะสรุป session, เริ่ม session ใหม่จากสรุปนั้น แล้วดำเนินการต่อด้วยข้อความของคุณบน session ใหม่ — ตั้งชื่อคล้ายกันแต่ใช้ suffix `-resumeN` แทน (เช่น `fix-bug` → `fix-bug-resume1` → `fix-bug-resume2` เมื่อ compact ครั้งถัดไป) ถ้าเลือก **ดำเนินการต่อเลย** ก็จะดำเนินการต่อบน session เดิมตามปกติ ปิดการตรวจสอบทั้งหมดนี้ได้ด้วย `LONG_GAP_WARNING_ENABLED=false` +
+ +
+เซสชัน Claude ล้มเหลวกะทันหันด้วยข้อความ "Failed to authenticate: OAuth session expired and could not be refreshed" + +เรื่องนี้สามารถเกิดขึ้นได้แม้ `claude auth status` จะรายงานว่าคุณล็อกอินอยู่ และแม้จะเพิ่งล็อกอินใหม่ไปหมาด ๆ เซสชัน OAuth แบบโต้ตอบที่ Claude Code ใช้ตามปกติอาจหยุดทำงานเฉพาะสำหรับวิธีที่บอทนี้สร้างเซสชันแบบ headless และแยก subprocess ออกมา โดยไม่ใช่ความผิดของการล็อกอินจริงของคุณ — เคยพบเจอหลังจากที่ Claude Code CLI อัปเดตอัตโนมัติ และอาจเกิดขึ้นเป็นระยะ ๆ ได้เช่นกัน + +หากผู้ใช้เจอปัญหานี้แบบสด ๆ ผ่าน `/new`, เซสชันที่กลับมาทำงานต่อ หรือข้อความใด ๆ บอทจะจดจำความล้มเหลวเฉพาะนี้ได้และตอบกลับด้วยคำแนะนำการแก้ไขทันที แทนที่จะเป็น error ดิบจาก CLI + +**วิธีแก้ไข** บนโฮสต์ที่รันบอทนี้: + +1. รันคำสั่ง `claude setup-token` แล้วอนุมัติการเข้าถึงในเบราว์เซอร์ที่เปิดขึ้น **โทเค็นจริงจะถูกแสดงในเทอร์มินัลหลังจากนั้น (ขึ้นต้นด้วย `sk-ant-oat01-`) ไม่ได้แสดงในเบราว์เซอร์แต่อย่างใด** — การคัดลอกอะไรบางอย่างจากหน้าเบราว์เซอร์แทนที่จะเป็นเทอร์มินัลเป็นความผิดพลาดที่พบบ่อยและจะใช้งานไม่ได้ วิธีนี้จะสร้างโทเค็นยืนยันตัวตนที่มีอายุยาวนาน (ประมาณ 1 ปี) ซึ่งเป็นกลไกที่ Anthropic รองรับอย่างเป็นทางการสำหรับการใช้งานแบบ headless/อัตโนมัติ (เดียวกับที่ใช้กับ GitHub Actions) — ต่างจากเซสชัน OAuth แบบโต้ตอบ มันไม่ต้องพึ่งพาการทำงานของ Keychain/การรีเฟรชเซสชันจากโพรเซสเบื้องหลังที่แยกออกมา ดังนั้นเมื่อตั้งค่าแล้วก็ไม่น่าจะต้องแตะต้องอีกจนกว่าจะหมดอายุ +2. คัดลอกโทเค็นที่แสดง แล้วบันทึกด้วยคำสั่งที่ตรงกับวิธีที่คุณรันแอปนี้ — คำตอบของบอทเองจะเลือกคำสั่งที่ถูกต้องให้อัตโนมัติ แต่ไว้อ้างอิง: + - ติดตั้งผ่าน `pip` หรือ `install.sh` แบบบรรทัดเดียว (Quick Start วิธีที่ A/B): `coding-agent-telegram claude-auth ` + - รันจาก repository ที่ clone มาด้วย `./startup.sh` (Quick Start วิธีที่ C): `./startup.sh claude-auth ` + + คำสั่งทั้งสองจะบันทึกโทเค็นเป็น `CLAUDE_CODE_OAUTH_TOKEN` ในไฟล์ env ของคุณ และตรวจสอบการยืนยันตัวตนของ Claude ใหม่ทันที ทำให้คุณรู้ผลสำเร็จ/ล้มเหลวทันที แทนที่จะรีสตาร์ทแบบมั่ว ๆ แล้วหวังผล +3. รีสตาร์ทบอทเพื่อให้โพรเซสที่กำลังทำงานอยู่รับการเปลี่ยนแปลงนี้ไป + +คุณสามารถข้ามคำสั่งแล้วตั้งค่า `CLAUDE_CODE_OAUTH_TOKEN=` ในไฟล์ env (`.env_coding_agent_telegram`) ด้วยตัวเองโดยตรงก็ได้ — คำสั่งสองคำสั่งข้างต้นเป็นเพียงตัวช่วยห่อหุ้มการทำแบบนั้นให้สะดวกขึ้น พร้อมกับการตรวจสอบยืนยันเพิ่มเติม +
+ ## 📌 หมายเหตุ - โปรเจกต์นี้ออกแบบมาสำหรับผู้ใช้ที่รัน agents แบบ local บนเครื่องของตนเอง diff --git a/README.vi.md b/README.vi.md index adfbde3..8645762 100644 --- a/README.vi.md +++ b/README.vi.md @@ -1,6 +1,6 @@
Coding Agent Telegram -

Coding Agent Telegram 🚀

+

Claude Code / Codex / Copilot Coding Agent Telegram 🚀

English | Deutsch | @@ -179,6 +179,8 @@ pip install coding-agent-telegram coding-agent-telegram ``` +Lệnh `coding-agent-telegram` đã cài đặt có cùng watchdog polling như `./startup.sh`: lệnh sẽ khởi động lại bot sau khi bị lỗi hoặc khi heartbeat Telegram quá cũ, đồng thời lùi thời gian thử lại khi DNS hoặc mạng không khả dụng. + ### Cách C: Chạy từ repository đã clone ```bash git clone https://github.com/daocha/coding-agent-telegram @@ -271,6 +273,14 @@ Hiện tại bot chấp nhận: /provider Chọn nhà cung cấp cho các phiên mới. Lựa chọn này được lưu theo từng bot và chat cho đến khi bạn thay đổi. + + /model + Chọn model cho phiên đang hoạt động, từ danh sách model của nhà cung cấp hiện tại. Lựa chọn này được lưu trên phiên đó trong state.json và được dùng mỗi khi phiên được resume. Bắt đầu một phiên mới (/new, /switch sang một phiên mới, hoặc /compact) luôn đặt lại về model mặc định đã cấu hình của nhà cung cấp. + + + /model <model_id> + Đặt một model id cụ thể không có trong danh sách được chọn sẵn. Nếu đây chưa phải là một lựa chọn đã biết, bot sẽ thử gọi CLI trước bằng một lệnh gọi chỉ-đọc dùng một lần để xác nhận id đó thực sự được chấp nhận trước khi lưu — nếu CLI từ chối, lỗi sẽ được trả về chat và không có gì được lưu. + /project <project_folder> Đặt thư mục project hiện tại. Nếu thư mục chưa tồn tại, app sẽ tạo và đánh dấu là trusted. Nếu đã tồn tại nhưng vẫn untrusted, app sẽ yêu cầu xác nhận trust rõ ràng. @@ -287,6 +297,10 @@ Hiện tại bot chấp nhận: /current Hiển thị phiên hoạt động cho bot và chat hiện tại. + + /status + Hiển thị mức sử dụng quota của từng provider: phần trăm sử dụng trong 5 giờ và hàng tuần, kèm thời gian đặt lại. Không bao giờ tạo ra một lệnh gọi API có tính phí: Codex luôn là một truy vấn cục bộ miễn phí, còn số liệu của Claude chỉ được tái sử dụng từ hoạt động Claude thực gần nhất qua bot, hiển thị dạng "quan sát lần cuối X trước" (chỉ áp dụng cho tài khoản Pro/Max đăng nhập qua OAuth). Hai cửa sổ được theo dõi riêng biệt: nếu một cửa sổ đã qua thời gian đặt lại (hoặc chưa từng được quan sát), nó sẽ hiển thị N/A cho đến khi lượt Claude tiếp theo của bạn làm mới nó, ngay cả khi cửa sổ còn lại vẫn có dữ liệu mới. Copilot không có API được hỗ trợ cho việc này nên sẽ báo là không khả dụng. + /new [session_name] Tạo phiên mới cho project hiện tại. Nếu bỏ qua tên, bot sẽ dùng mã định danh phiên thật. Nếu thiếu nhà cung cấp, project hoặc branch, bot sẽ hướng dẫn bước còn thiếu. @@ -317,11 +331,19 @@ Hiện tại bot chấp nhận: /pull - Sau khi xác nhận, thực hiện pull từ origin cho branch của phiên đang hoạt động. Bot cũng làm mới branch mặc định khi cần. + Sau khi xác nhận, thực hiện Git pull từ origin cho branch của phiên đang hoạt động. Bot cũng làm mới branch mặc định khi cần. /push - Push origin <branch> cho phiên hoạt động hiện tại. Bot sẽ hỏi xác nhận trước khi push. + Thực hiện Git push đến origin <branch> cho phiên hoạt động hiện tại. Bot sẽ hỏi xác nhận trước khi push. + + + /log + Hiển thị năm commit Git mới nhất của project trong phiên đang hoạt động. + + + /reset + Chọn branch mặc định/hiện tại ở local hoặc origin, sau đó xác nhận git reset --hard. Mục tiêu remote sẽ được pull trước. /abort @@ -400,6 +422,18 @@ Hiện tại bot chấp nhận: CLAUDE_MODEL Ghi đè model Claude Code nếu cần. Để trống để dùng model mặc định của Claude Code CLI. Ví dụ: sonnet, opus, haiku Claude Code model configuration + + CODEX_MODEL_CHOICES + Danh sách model, phân tách bằng dấu phẩy, được lệnh /model đưa ra cho Codex. Khi chưa đặt, giá trị trong .env.example đi kèm sẽ được dùng. + + + COPILOT_MODEL_CHOICES + Danh sách model, phân tách bằng dấu phẩy, được lệnh /model đưa ra cho Copilot. Khi chưa đặt, giá trị trong .env.example đi kèm sẽ được dùng. + + + CLAUDE_MODEL_CHOICES + Danh sách model, phân tách bằng dấu phẩy, được lệnh /model đưa ra cho Claude Code. Khi chưa đặt, giá trị trong .env.example đi kèm sẽ được dùng; nếu không có mẫu, dùng sonnet,opus,fable,haiku. + CODEX_APPROVAL_POLICY Chế độ approval truyền cho Codex. Mặc định: never. @@ -432,6 +466,22 @@ Hiện tại bot chấp nhận: AGENT_HARD_TIMEOUT_SECONDS Timeout cứng cho một lần lần chạy tác nhân. Mặc định: 0 (tắt). + + LONG_GAP_WARNING_ENABLED + Trước khi resume một session đã idle một thời gian đã tích lũy đủ context khiến việc xử lý lại tốn kém, cảnh báo rằng prompt cache của provider có thể đã hết hạn — kèm các nút để compact trước hoặc cứ tiếp tục. Mặc định: true. Xem FAQ bên dưới. + + + CLAUDE_LONG_GAP_SECONDS + Ngưỡng idle tính bằng giây trước khi cảnh báo kích hoạt cho session Claude Code. Mặc định: 3600 (1 giờ, khớp với cửa sổ prompt-cache mở rộng của Claude Code). + + + CODEX_LONG_GAP_SECONDS + Ngưỡng idle tính bằng giây trước khi cảnh báo kích hoạt cho session Codex. Mặc định: 3600 (1 giờ, giống Claude; OpenAI không tài liệu hóa số liệu hết hạn cache dựa trên idle cho Codex, và cache riêng của Codex nhìn chung cũng ngắn hạn hơn Claude, nên khớp với ngưỡng của Claude không làm giảm độ chính xác -- kết hợp với size gate để các session nhỏ không gây phiền). + + + COPILOT_LONG_GAP_SECONDS + Ngưỡng idle tính bằng giây trước khi cảnh báo kích hoạt cho session Copilot. Mặc định: 0 (tắt). Tài liệu chính thức của GitHub nêu rõ Copilot CLI không có inactivity timeout và đã tự động nén (auto-compact) context của chính nó (quanh mức sử dụng 80-95%) -- không có rủi ro nào liên quan đến idle cần cảnh báo ở đây, nên phần này để cơ chế riêng của Copilot xử lý thay vì tự bịa ra một cái. Đặt giá trị dương nếu bạn vẫn muốn có nhắc nhở dựa trên idle cho Copilot. + SNAPSHOT_TEXT_FILE_MAX_BYTES Kích thước tệp tối đa mà bot sẽ đọc dưới dạng văn bản khi tạo ảnh chụp nhanh trước/sau cho diff của từng run. Mặc định: 200000. @@ -676,6 +726,71 @@ Phiên bản package được suy ra từ Git tags. - PyPI prerelease: `v2026.3.26rc1` - PyPI stable: `v2026.3.26` +## ❓ FAQ / Khắc phục sự cố + +

+Vì sao claude --resume trong terminal thường không hiển thị các session tạo từ Telegram? + +Đây là hành vi bình thường của Claude Code CLI, không phải lỗi của app này. + +Các session do bot này tạo chạy qua chế độ headless `-p`/print của Claude Code. Claude Code gắn nhãn mọi session khởi động theo cách đó trong transcript là `entrypoint: "sdk-cli"`, khác với `entrypoint: "cli"` của một session bạn khởi động bằng cách gõ trực tiếp `claude` trong terminal. Bộ chọn `claude --resume` tương tác (không kèm session ID) chỉ liệt kê các session có entrypoint `cli` — nó cố tình ẩn các lần chạy headless/do SDK điều khiển, coi đó là output tự động hóa chứ không phải cuộc hội thoại được thiết kế để resume bằng tay. + +Bản thân dữ liệu session không hề mất hay khác đi — đó vẫn là một session Claude Code bình thường, resume được đầy đủ, lưu tại `~/.claude/projects//.jsonl`. Khi đã có ID, bạn có thể resume trực tiếp: + +```bash +claude --resume +``` + +Đây chính là lý do app này có cơ chế dò session riêng (được `/switch` sử dụng) thay vì dựa vào bộ chọn native — nó quét trực tiếp các tệp JSONL và khớp theo project path, nên các session tạo từ Telegram vẫn xuất hiện ở đó dù không bao giờ xuất hiện trong `claude --resume` thông thường. + +Codex và Copilot không tạo ra sự phân biệt tương tác-so-với-headless này trong các lệnh resume/list riêng của chúng, đó là lý do session của các provider này vẫn hiển thị bình thường trong terminal thông thường. +
+ +
+App này có tốn nhiều token hơn việc dùng trực tiếp terminal Claude Code không? + +Không phải do chênh lệch overhead cố hữu trên mỗi lần gọi — headless (`-p`) và Claude Code tương tác dùng cùng giao thức nền và cùng biểu giá. Nhưng trong thực tế, việc dùng Telegram 24/7 có thể tốn token nhiều hơn rõ rệt so với dùng terminal thông thường, vì hai lý do cộng dồn: + +- **Session có thể phình to không giới hạn.** Vì bot tiện lợi tiếp tục resume cùng một session qua nhiều giờ hoặc nhiều ngày, một session có thể tích lũy hàng trăm lượt và transcript nặng vài megabyte nếu bạn không bao giờ luân chuyển nó. Trong terminal tương tác, bạn thường sẽ hoàn thành một việc rồi bắt đầu mới ở lần sau, giữ context nhỏ hơn một cách tự nhiên. +- **Khoảng idle giữa các tin nhắn Telegram làm hết hạn prompt cache.** Prompt cache của Claude có TTL ngắn. Nếu bạn trả lời trong khoảng đó, các lượt tiếp theo là cache read rẻ. Nếu có khoảng nghỉ dài (ví dụ bạn ngủ rồi trả lời vào sáng hôm sau), *toàn bộ* context đã tích lũy phải được xử lý lại từ đầu ở tin nhắn tiếp theo dưới dạng một lần cache write đắt hơn nhiều — và chi phí này tăng theo kích thước session đã đạt được tại thời điểm đó. Đó là lý do usage có thể tăng vọt ngay khi bạn gửi tin nhắn đầu tiên trong ngày, kể cả trước giờ "cao điểm". + +**Cách giảm thiểu:** định kỳ chạy `/compact` trên các session tồn tại lâu (app này hỗ trợ như một lệnh Telegram) thay vì để một session chạy vô thời hạn, đặc biệt nếu bạn nhận thấy nó đã idle một thời gian dài. Bắt đầu một session `/new` mới cho công việc không liên quan cũng giúp giữ context — và chi phí — trong tầm kiểm soát. + +Giờ đây app cũng tự động làm việc này, bằng cách kết hợp hai tín hiệu theo từng provider để chỉ làm gián đoạn bạn khi thực sự đáng: một ngưỡng idle (`CLAUDE_LONG_GAP_SECONDS` / `CODEX_LONG_GAP_SECONDS` / `COPILOT_LONG_GAP_SECONDS`) *và* lượng context mà session đã tích lũy (bỏ qua cảnh báo cho các session nhỏ/rẻ ngay cả khi đã idle một lúc, vì xử lý lại từ đầu khi đó gần như không đáng kể). Mặc định: 1 giờ cho cả Claude Code lẫn Codex -- con số của Claude có bằng chứng thực tế hỗ trợ (xem ở trên), và dù OpenAI không tài liệu hóa cho Codex, cache riêng của Codex nhìn chung cũng ngắn hạn hơn Claude, nên khớp với ngưỡng của Claude không tốn gì về độ chính xác mà chỉ giúp giảm số lần gián đoạn, nhất là khi giờ đã kết hợp với size gate; và tắt theo mặc định cho Copilot, vì [tài liệu chính thức của GitHub](https://docs.github.com/en/copilot/concepts/agents/copilot-cli/context-management) nêu rõ Copilot CLI hoàn toàn không có inactivity timeout và đã tự động nén context của chính nó (quanh mức sử dụng 80-95%) -- không có gì liên quan đến idle cần cảnh báo ở đó, nên phần này để cơ chế riêng của Copilot xử lý thay vì tự bịa ra một cái. Đặt `COPILOT_LONG_GAP_SECONDS` thành giá trị dương nếu bạn vẫn muốn có nhắc nhở dựa trên idle cho Copilot. + +Khi cả ngưỡng idle và size gate đều thỏa mãn, app sẽ giữ tin nhắn của bạn lại và hỏi: + +> ⏳ Session này đã idle {gap}. Resume ngay bây giờ nhiều khả năng sẽ xử lý lại toàn bộ cuộc hội thoại từ đầu (cache phản hồi của provider có lẽ đã hết hạn), có thể tốn token nhiều hơn đáng kể so với bình thường. Compact cũng phải xử lý lại context hiện tại một lần để viết bản tóm tắt, nên cũng có thể tốn nhiều token nếu session này đã lớn. Chuyển sang session mới sẽ bỏ qua hoàn toàn việc xử lý lại đó, nhưng sẽ bắt đầu mà không có bất kỳ ký ức nào về cuộc hội thoại này. Chuyển sang session mới, compact trước, hay cứ tiếp tục? +> +> [🆕 Chuyển sang session mới] +> [🔄 Compact trước] +> [⚠️ Cứ tiếp tục] + +Lưu ý rằng bản thân `/compact` cũng không miễn phí chi phí này: nó hoạt động bằng cách resume session hiện tại (có thể đã nguội) và yêu cầu nó tự tóm tắt, nên vẫn phải trả chi phí xử lý lại toàn bộ transcript một lần giống như khi trả lời bình thường — điểm khác là bạn chỉ phải trả chi phí đó một lần thay vì ở mỗi lượt tiếp theo, vì session kết quả bắt đầu nhỏ. **Chuyển sang session mới** là lựa chọn duy nhất tránh hoàn toàn việc xử lý lại đó: nó từ bỏ context của session cũ mà không bao giờ resume nó, rồi bắt đầu hoàn toàn mới — đánh đổi bằng việc mất hoàn toàn context đó thay vì nén nó thành bản tóm tắt. + +Chọn **Chuyển sang session mới** sẽ khởi tạo một session mới hoàn toàn trống, rồi tiếp tục với tin nhắn của bạn ở đó — được đặt tên theo session cũ với hậu tố `-newN` tăng dần (ví dụ `fix-bug` → `fix-bug-new1` → `fix-bug-new2` nếu chuyển lần nữa), để bạn vẫn phân biệt được với bản gốc trong `/switch`. Chọn **Compact trước** sẽ tóm tắt session, khởi tạo một session mới từ bản tóm tắt đó, rồi tiếp tục với tin nhắn của bạn trên session mới — được đặt tên tương tự nhưng dùng hậu tố `-resumeN` (ví dụ `fix-bug` → `fix-bug-resume1` → `fix-bug-resume2` ở lần compact tiếp theo). Chọn **Cứ tiếp tục** thì chỉ đơn giản là tiếp tục trên session hiện có như bình thường. Có thể tắt toàn bộ cơ chế kiểm tra này bằng `LONG_GAP_WARNING_ENABLED=false`. +
+ +
+Các phiên Claude đột nhiên thất bại với "Failed to authenticate: OAuth session expired and could not be refreshed" + +Điều này có thể xảy ra ngay cả khi `claude auth status` báo bạn đã đăng nhập, và ngay cả ngay sau khi bạn vừa đăng nhập lại. Phiên OAuth tương tác mà Claude Code thường dùng có thể ngừng hoạt động riêng đối với cách bot này tạo phiên theo kiểu headless, subprocess tách biệt, mà không phải do lỗi đăng nhập thực sự của bạn — điều này đã được ghi nhận sau khi Claude Code CLI tự động cập nhật, và cũng có thể xảy ra không liên tục. + +Nếu người dùng gặp phải trực tiếp qua `/new`, một phiên được resume, hoặc bất kỳ tin nhắn nào, bot sẽ nhận ra lỗi cụ thể này và trả lời ngay với hướng dẫn khắc phục, thay vì lỗi CLI thô. + +**Để khắc phục**, trên máy chủ đang chạy bot: + +1. Chạy `claude setup-token` và chấp thuận quyền truy cập trong trình duyệt vừa mở. **Token thật sau đó được in ra trong terminal của bạn (bắt đầu bằng `sk-ant-oat01-`), không hiển thị ở đâu trong trình duyệt** — sao chép thứ gì đó từ chính trang trình duyệt thay vì terminal là lỗi thường gặp và sẽ không hoạt động. Việc này tạo ra một token xác thực tồn tại lâu dài (khoảng 1 năm), là cơ chế được Anthropic chính thức hỗ trợ cho việc sử dụng headless/tự động hóa (giống cơ chế dùng cho GitHub Actions) — khác với phiên OAuth tương tác, nó không phụ thuộc vào việc Keychain/làm mới phiên hoạt động từ một tiến trình nền tách biệt, nên sau khi thiết lập, dự kiến sẽ không cần đụng vào lại cho đến khi nó hết hạn. +2. Sao chép token được in ra, sau đó lưu nó bằng lệnh phù hợp với cách bạn chạy ứng dụng này — phản hồi của chính bot sẽ tự động chọn lệnh đúng, nhưng để tham khảo: + - Cài qua `pip` hoặc `install.sh` một dòng (Quick Start Cách A/B): `coding-agent-telegram claude-auth ` + - Chạy từ repository đã clone bằng `./startup.sh` (Quick Start Cách C): `./startup.sh claude-auth ` + + Cả hai lệnh đều lưu token dưới dạng `CLAUDE_CODE_OAUTH_TOKEN` trong file env của bạn và kiểm tra lại ngay xác thực Claude, nên bạn biết ngay kết quả đạt/không đạt thay vì khởi động lại mù quáng rồi hy vọng. +3. Khởi động lại bot để tiến trình đang chạy nhận thay đổi này. + +Bạn cũng có thể bỏ qua lệnh và tự đặt `CLAUDE_CODE_OAUTH_TOKEN=` trực tiếp trong file env (`.env_coding_agent_telegram`) — hai lệnh ở trên chỉ là lớp bọc tiện lợi để làm đúng việc đó, cộng thêm bước xác minh. +
+ ## 📌 Ghi chú - Dự án này dành cho người dùng chạy agent cục bộ trên chính máy của mình. diff --git a/README.zh-CN.md b/README.zh-CN.md index 2b2c731..1ddd652 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,6 +1,6 @@
Coding Agent Telegram -

Coding Agent Telegram 🚀

+

Claude Code / Codex / Copilot Coding Agent Telegram 🚀

English | Deutsch | @@ -181,6 +181,8 @@ pip install coding-agent-telegram coding-agent-telegram ``` +已安装的 `coding-agent-telegram` 命令包含与 `./startup.sh` 相同的轮询看门狗:bot 崩溃或 Telegram 心跳过期后会自动重启;DNS 或网络不可用时会采用退避策略重试。 + ### 方案 C:从克隆的仓库运行 ```bash git clone https://github.com/daocha/coding-agent-telegram @@ -273,6 +275,14 @@ bot 当前接受: /provider 为新会话选择提供方。该选择会按 bot 和 chat 保存,直到你手动修改。 + + /model + 从当前提供方的模型列表中,为活动会话选择模型。选择会保存在该会话的 state.json 中,并在每次恢复会话时使用。开始新会话(/new、切换到新会话的 /switch/compact)时,始终会重置为提供方配置的默认模型。 + + + /model <model_id> + 设置不在精选列表中的特定模型 ID。如果它尚未是已知选项,bot 会先通过一次性只读调用测试 CLI,确认该 ID 确实被接受后才保存。若 CLI 拒绝,错误会返回到聊天,且不会保存任何设置。 + /project <project_folder> 设置当前 project 文件夹。如果文件夹不存在,应用会创建并标记为 trusted;如果已存在但仍是 untrusted,应用会明确要求确认 trust。 @@ -289,6 +299,10 @@ bot 当前接受: /current 显示当前 bot 和 chat 的活动会话。 + + /status + 显示各 provider 的配额使用情况:5小时和每周使用率百分比,以及重置时间。绝不会产生付费 API 调用:Codex 始终是免费的本地查询,Claude 的数据也只会复用你最近一次通过 bot 产生的真实 Claude 使用记录,显示为”最近一次观测于 X 前”(仅适用于通过 OAuth 登录的 Pro/Max 账户)。两个窗口分别独立追踪:如果某个窗口已经过了重置时间(或从未被观测到),即使另一个窗口仍有最新数据,它也会显示为 N/A,直到你下一次使用 Claude 时才会刷新。Copilot 没有支持此功能的 API,因此会显示为不可用。 + /new [session_name] 为当前项目创建新会话。如果省略名称,bot 会使用真实的会话 ID。若缺少提供方、项目或 branch,bot 会引导你完成缺失步骤。 @@ -319,11 +333,19 @@ bot 当前接受: /pull - 确认后,从 origin 拉取活动会话当前分支。适用时,bot 也会一并刷新默认分支。 + 确认后,通过 Git 从 origin 拉取活动会话当前分支。适用时,bot 也会一并刷新默认分支。 /push - 为当前活动会话执行 origin <branch> push。push 前 bot 会要求确认。 + 为当前活动会话通过 Git push 到 origin <branch>。push 前 bot 会要求确认。 + + + /log + 显示活动会话项目最近的五条 Git 提交。 + + + /reset + 选择 local 或 origin 上的默认/当前分支,然后确认执行 git reset --hard。远程目标会先被拉取。 /abort @@ -406,6 +428,18 @@ bot 当前接受: Claude Code model configuration + + CODEX_MODEL_CHOICES + 以逗号分隔的模型列表,供 Codex 的 /model 命令选用。未设置时,使用随附的 .env.example 中的值。 + + + COPILOT_MODEL_CHOICES + 以逗号分隔的模型列表,供 Copilot 的 /model 命令选用。未设置时,使用随附的 .env.example 中的值。 + + + CLAUDE_MODEL_CHOICES + 以逗号分隔的模型列表,供 Claude Code 的 /model 命令选用。未设置时,使用随附的 .env.example 中的值;若模板不可用,则使用 sonnet,opus,fable,haiku。 + CODEX_APPROVAL_POLICY 传递给 Codex 的 approval mode。默认:never。 @@ -438,6 +472,22 @@ bot 当前接受: AGENT_HARD_TIMEOUT_SECONDS 单次 代理运行 的硬超时。默认:0(关闭)。 + + LONG_GAP_WARNING_ENABLED + 在恢复一个已经空闲一段时间并且已积累足够多 context(重新处理成本较高)的 session 之前,提醒用户 provider 的 prompt cache 很可能已经过期 —— 并提供按钮让你选择先 compact 还是直接继续。默认:true。参见下方 FAQ。 + + + CLAUDE_LONG_GAP_SECONDS + Claude Code session 触发该提醒前的空闲阈值(秒)。默认:3600(1 小时,对应 Claude Code 的扩展 prompt-cache 保留窗口)。 + + + CODEX_LONG_GAP_SECONDS + Codex session 触发该提醒前的空闲阈值(秒)。默认:3600(1 小时,与 Claude 相同;OpenAI 并未为 Codex 公布基于空闲时间的 cache 失效数字,而且 Codex 自身的 cache 通常也比 Claude 更短命,因此对齐 Claude 的阈值不会损失准确性 —— 并结合 size gate,避免小 session 频繁打扰)。 + + + COPILOT_LONG_GAP_SECONDS + Copilot session 触发该提醒前的空闲阈值(秒)。默认:0(关闭)。GitHub 官方文档指出 Copilot CLI 没有 inactivity timeout,并且已经原生自动压缩自身 context(使用率约 80-95% 时)—— 这里没有需要提醒的空闲相关风险,因此交由 Copilot 自身机制处理,而不是臆造一个并不存在的 API。如果仍希望针对 Copilot 启用基于空闲时间的提醒,可设置为正数。 + SNAPSHOT_TEXT_FILE_MAX_BYTES 构建每次运行的前后快照 diff 时,bot 会按文本读取的最大文件大小。默认:200000。 @@ -682,6 +732,71 @@ log 会**同时写入 stdout 和轮转日志文件**,路径为: - PyPI prerelease: `v2026.3.26rc1` - PyPI stable: `v2026.3.26` +## ❓ 常见问题 / 故障排查 + +

+为什么在普通终端里运行 claude --resume 看不到从 Telegram 创建的 session? + +这是 Claude Code CLI 的预期行为,不是本应用的 bug。 + +本 bot 创建的 session 是通过 Claude Code 的 headless `-p`/print 模式运行的。Claude Code 会在 transcript 中把这样启动的 session 标记为 `entrypoint: "sdk-cli"`,而你直接在终端输入 `claude` 启动的 session 则是 `entrypoint: "cli"`。不带 session ID 的交互式 `claude --resume` 选择器只会列出 `entrypoint` 为 `cli` 的 session —— 它有意隐藏 headless/由 SDK 驱动的运行,把它们当作自动化输出而非需要手动接续的对话。 + +session 数据本身并没有丢失或不同——它是一个完全正常、可以完整恢复的 Claude Code session,保存在 `~/.claude/projects//.jsonl` 下。只要拿到 ID,就可以直接恢复: + +```bash +claude --resume +``` + +这正是本应用要自带一套 session 发现机制(供 `/switch` 使用)而不是依赖原生选择器的原因——它直接扫描 JSONL 文件,并按项目路径匹配,所以即便在普通的 `claude --resume` 中永远不会出现,Telegram 创建的 session 依然会在这里显示出来。 + +Codex 和 Copilot 自身的 resume/list 命令并不做这种交互式与 headless 的区分,这就是为什么这两个 provider 的 session 在普通终端里依旧能正常显示。 +
+ +
+这个应用会比直接使用 Claude Code 终端消耗更多 token 吗? + +不是因为每次调用本身存在固有的额外开销差异——headless(`-p`)和交互式 Claude Code 使用的是相同的底层协议和计费方式。但在实际使用中,24/7 的 Telegram 使用方式确实可能比典型的终端使用消耗明显更多 token,原因有两个会相互叠加: + +- **session 可能无限增长。** 由于 bot 会很方便地在数小时甚至数天内持续恢复同一个 session,如果你从不轮换它,一个 session 可能会累积数百个回合和数兆字节的 transcript。而在交互式终端中,你更自然的做法是完成一个任务后下次重新开始,context 因此更容易保持较小。 +- **Telegram 消息之间的空闲间隔会让 prompt cache 过期。** Claude 的 prompt cache TTL 很短。如果你在这个窗口内回复,后续回合就是便宜的 cache read。如果间隔很长(比如你睡了一觉第二天早上才回复),下一条消息就必须把*全部*已累积的 context 作为代价高得多的 cache write 从头重新处理——而且这个成本会随着 session 当时已有的大小而增长。这就是为什么即便还没到"高峰"时段,你当天发出第一条消息时用量也可能骤增。 + +**缓解方法:** 对长期使用的 session 定期运行 `/compact`(本应用已支持作为 Telegram 命令),而不是让一个 session 无限期运行下去,尤其是当你发现它已经空闲了很长时间时。为不相关的工作开一个新的 `/new` session,也有助于把 context——以及成本——控制在合理范围内。 + +现在应用也会自动执行这一步,通过结合每个 provider 的两个信号,只在确实可能有影响时才打断你:一个空闲时间阈值(`CLAUDE_LONG_GAP_SECONDS` / `CODEX_LONG_GAP_SECONDS` / `COPILOT_LONG_GAP_SECONDS`),*以及* session 已经积累了多少 context(对于小型、成本低的 session,即便已经空闲了一段时间,也会跳过提醒,因为从头重新处理它们的代价可以忽略不计)。默认值:Claude Code 和 Codex 均为 1 小时 —— Claude 的数字背后有真实证据支撑(见上文),虽然 OpenAI 并未为 Codex 公布相应数字,但 Codex 自身的 cache 通常也比 Claude 更短命,因此对齐 Claude 的阈值不会损失准确性,只会减少打扰次数,尤其是现在还结合了 size gate;而 Copilot 默认关闭,因为[GitHub 官方文档](https://docs.github.com/en/copilot/concepts/agents/copilot-cli/context-management)明确指出 Copilot CLI 完全没有 inactivity timeout,并且已经原生自动压缩自身 context(使用率约 80-95% 时)—— 这里没有任何与空闲相关的问题需要提醒,因此交由 Copilot 自身机制处理,而不是臆造一个。如果仍希望针对 Copilot 启用基于空闲时间的提醒,可将 `COPILOT_LONG_GAP_SECONDS` 设为正数。 + +当空闲阈值和 size gate 同时满足时,它会先扣住你的消息并询问: + +> ⏳ 这个 session 已经空闲了 {gap}。现在恢复很可能会从头重新处理整段对话(provider 的响应 cache 大概率已经过期),可能会比平时多消耗不少 token。compact 同样需要重新处理一次当前上下文来生成摘要,所以如果这个 session 已经很大,也会消耗不少 token。切换到新 session 可以完全跳过这次重新处理,但会失去这段对话的所有记忆。要切换到新 session、先 compact,还是直接继续? +> +> [🆕 切换到新 session] +> [🔄 先 compact] +> [⚠️ 直接继续] + +请注意,`/compact` 本身也不能免除这项开销:它的原理是恢复当前(可能已经变冷的)session,让它对自己做总结,因此仍要付出和直接回复一样的一次性全量 transcript 重新处理成本——区别只在于之后只需要付一次,而不是每一轮都付,因为得到的新 session 从很小的规模开始。**切换到新 session** 是唯一能完全避免这次重新处理的选项:它不会恢复旧 session,而是直接放弃其上下文、完全重新开始——代价是彻底失去那部分上下文,而不是把它压缩成摘要。 + +选择**切换到新 session** 会开启一个全新的空 session,并在其上继续处理你的消息——新 session 会以旧 session 名称加上递增的 `-newN` 后缀命名(例如 `fix-bug` → `fix-bug-new1` → 再次切换后变为 `fix-bug-new2`),这样你在 `/switch` 中依然能与原始 session 区分开来。选择**先 compact** 会先总结当前 session,再基于该摘要开启一个新 session,然后在新 session 上继续处理你的消息——命名方式类似,但改用 `-resumeN` 后缀(例如 `fix-bug` → `fix-bug-resume1` → 下次 compact 时变为 `fix-bug-resume2`)。选择**直接继续**则只会像平时一样在现有 session 上继续。可以用 `LONG_GAP_WARNING_ENABLED=false` 关闭整个检查机制。 +
+ +
+Claude session 突然报错 "Failed to authenticate: OAuth session expired and could not be refreshed" + +即使 `claude auth status` 显示已登录,甚至你刚刚重新登录之后,这个问题依然可能出现。Claude Code 平时使用的交互式 OAuth session,可能会单独在这个 bot 创建 session 所用的 headless、独立子进程方式下失效,而与你实际的登录状态无关——这种情况在 Claude Code CLI 自动更新之后出现过,也可能是间歇性的。 + +如果用户通过 `/new`、恢复某个 session,或发送任意消息实时遇到了这个问题,bot 会识别出这个特定的失败,并立即回复修复说明,而不是原始的 CLI 报错。 + +**修复方法**:在运行此 bot 的主机上: + +1. 运行 `claude setup-token`,并在打开的浏览器中批准访问。**真正的令牌之后会打印在你的终端里(以 `sk-ant-oat01-` 开头),浏览器里不会显示**——从浏览器页面本身而不是终端复制内容是个常见的错误,那样是不行的。这会创建一个长期有效(约 1 年)的身份验证令牌——这是 Anthropic 官方支持的 headless/自动化用途机制(与 GitHub Actions 所用的相同)。与交互式 OAuth session 不同,它不依赖于从独立后台进程中正常刷新 Keychain/session,因此设置一次后,预计在到期之前都无需再次处理。 +2. 复制打印出的令牌,然后根据你运行本应用的方式,用对应的命令保存它——bot 自身的回复会自动选择正确的命令,这里列出以供参考: + - 通过 `pip` 或一行式 `install.sh` 安装(Quick Start 方案 A/B):`coding-agent-telegram claude-auth ` + - 从克隆的仓库中用 `./startup.sh` 运行(Quick Start 方案 C):`./startup.sh claude-auth ` + + 这两个命令都会把令牌以 `CLAUDE_CODE_OAUTH_TOKEN` 的形式保存到你的 env 文件中,并立即重新验证 Claude 身份验证,因此你会马上知道成功还是失败,而不是盲目重启后干等。 +3. 重启 bot,让正在运行的进程应用这一改动。 + +你也可以跳过该命令,直接在你的 env 文件(`.env_coding_agent_telegram`)里自行设置 `CLAUDE_CODE_OAUTH_TOKEN=`——上面两个命令只是对这一操作的便捷封装,外加自动验证。 +
+ ## 📌 说明 - 本项目面向在自己机器上本地运行 agent 的用户。 diff --git a/README.zh-HK.md b/README.zh-HK.md index 73c9c2a..64a1f7d 100644 --- a/README.zh-HK.md +++ b/README.zh-HK.md @@ -1,6 +1,6 @@
Coding Agent Telegram -

Coding Agent Telegram 🚀

+

Claude Code / Codex / Copilot Coding Agent Telegram 🚀

English | Deutsch | @@ -179,6 +179,8 @@ pip install coding-agent-telegram coding-agent-telegram ``` +已安裝的 `coding-agent-telegram` 指令包含與 `./startup.sh` 相同的輪詢 watchdog:bot crash 或 Telegram heartbeat 過期後會自動重新啟動;DNS 或網絡不可用時會採用 backoff 策略重試。 + ### 方案 C:從 clone 下來的 repository 執行 ```bash git clone https://github.com/daocha/coding-agent-telegram @@ -271,6 +273,14 @@ bot 目前接受: /provider 為新的工作階段選擇提供者。這個選擇會按 bot 與 chat 儲存,直到你手動修改。 + + /model + 從目前提供者的模型清單中,為使用中的工作階段選擇模型。選擇會儲存在該工作階段的 state.json,每次恢復該工作階段時都會使用。開始新工作階段(/new、切換到新工作階段的 /switch/compact)時,一律會重設為提供者設定的預設模型。 + + + /model <model_id> + 設定不在精選清單中的特定模型 ID。如果它尚未是已知選項,bot 會先以一次性的唯讀呼叫測試 CLI,確認該 ID 確實被接受後才儲存。若 CLI 拒絕,錯誤會回傳至聊天,且不會儲存任何設定。 + /project <project_folder> 設定目前的 project 資料夾。如果資料夾不存在,app 會建立並標記為 trusted;如果已存在但仍是 untrusted,app 會明確要求確認 trust。 @@ -287,6 +297,10 @@ bot 目前接受: /current 顯示目前 bot 與 chat 的作用中工作階段。 + + /status + 顯示各 provider 的配額使用情況:5小時與每週使用率百分比,以及重置時間。絕不會產生付費 API 呼叫:Codex 一律是免費的本機查詢,Claude 的數據也只會重複使用你最近一次透過 bot 產生的真實 Claude 使用紀錄,顯示為「最近一次觀測於 X 前」(僅適用於透過 OAuth 登入的 Pro/Max 帳戶)。兩個視窗分別獨立追蹤:如果其中一個已經過了重置時間(或從未被觀測到),即使另一個視窗仍有最新數據,它也會顯示為 N/A,直到你下一次使用 Claude 時才會更新。Copilot 沒有支援此功能的 API,因此會顯示為不可用。 + /new [session_name] 為目前的專案建立新工作階段。如果省略名稱,bot 會使用真實工作階段 ID。若缺少提供者、專案或 branch,bot 會引導你完成缺少的步驟。 @@ -317,11 +331,19 @@ bot 目前接受: /pull - 確認後,從 origin 拉取作用中工作階段目前的分支。適用時,bot 也會一併重新整理預設分支。 + 確認後,透過 Git 從 origin 拉取作用中工作階段目前的分支。適用時,bot 也會一併重新整理預設分支。 /push - 為目前作用中工作階段執行 origin <branch> push。push 前 bot 會要求確認。 + 為目前作用中工作階段透過 Git push 至 origin <branch>。push 前 bot 會要求確認。 + + + /log + 顯示作用中工作階段專案最近的五個 Git 提交。 + + + /reset + 選擇 local 或 origin 上的預設/目前分支,然後確認執行 git reset --hard。遠端目標會先被拉取。 /abort @@ -402,6 +424,18 @@ bot 目前接受: Claude Code model configuration + + CODEX_MODEL_CHOICES + 以逗號分隔的模型清單,供 Codex 的 /model 指令選用。未設定時,會使用隨附 .env.example 中的值。 + + + COPILOT_MODEL_CHOICES + 以逗號分隔的模型清單,供 Copilot 的 /model 指令選用。未設定時,會使用隨附 .env.example 中的值。 + + + CLAUDE_MODEL_CHOICES + 以逗號分隔的模型清單,供 Claude Code 的 /model 指令選用。未設定時,會使用隨附 .env.example 中的值;若範本無法使用,則使用 sonnet,opus,fable,haiku。 + CODEX_APPROVAL_POLICY 傳遞給 Codex 的 approval mode。預設:never。 @@ -434,6 +468,22 @@ bot 目前接受: AGENT_HARD_TIMEOUT_SECONDS 單次 代理執行 的硬性 timeout。預設:0(停用)。 + + LONG_GAP_WARNING_ENABLED + 在恢復一個已經閒置一段時間並且已累積足夠 context(重新處理成本較高)的 session 之前,提醒使用者 provider 的 prompt cache 很可能已經過期 —— 並提供按鈕讓你選擇先 compact 或直接繼續。預設:true。詳見下方 FAQ。 + + + CLAUDE_LONG_GAP_SECONDS + Claude Code session 觸發此警告前的閒置門檻(秒)。預設:3600(1 小時,對應 Claude Code 的延伸 prompt-cache 保留時間)。 + + + CODEX_LONG_GAP_SECONDS + Codex session 觸發此警告前的閒置門檻(秒)。預設:3600(1 小時,與 Claude 相同;OpenAI 並未為 Codex 公布以閒置時間為基礎的 cache 失效數字,而且 Codex 本身的 cache 通常也比 Claude 更短命,因此對齊 Claude 的門檻不會損失準確度 —— 並結合 size gate,避免小型 session 頻繁打擾)。 + + + COPILOT_LONG_GAP_SECONDS + Copilot session 觸發此警告前的閒置門檻(秒)。預設:0(停用)。GitHub 官方文件指出 Copilot CLI 沒有 inactivity timeout,並且已經原生自動壓縮自身 context(使用率約 80-95% 時)—— 這裡沒有需要提醒的閒置相關風險,因此交由 Copilot 自身機制處理,而不是自行假設一個並不存在的 API。若仍希望針對 Copilot 啟用以閒置時間為基礎的提醒,可設為正數。 + SNAPSHOT_TEXT_FILE_MAX_BYTES 建立每次執行的前後 快照 diff 時,bot 會以文字讀取的最大檔案大小。預設:200000。 @@ -684,6 +734,71 @@ log 會**同時寫入 stdout 和輪轉 日誌檔案**,路徑如下: - PyPI prerelease: `v2026.3.26rc1` - PyPI stable: `v2026.3.26` +## ❓ 常見問題 / 疑難排解 + +

+為什麼在一般 terminal 執行 claude --resume,看不到從 Telegram 建立的 session? + +這是 Claude Code CLI 的預期行為,不是這個 app 的 bug。 + +這個 bot 建立的 session 是透過 Claude Code 的 headless `-p`/print 模式執行的。Claude Code 會在 transcript 中把這樣啟動的 session 標記為 `entrypoint: "sdk-cli"`,相對於你直接在 terminal 輸入 `claude` 開始的 session 的 `entrypoint: "cli"`。不帶 session ID 的互動式 `claude --resume` 選擇器只會列出 `entrypoint` 為 `cli` 的 session —— 它刻意隱藏 headless/由 SDK 驅動的執行,把這些視為自動化輸出,而不是打算讓人手動接續的對話。 + +session 資料本身並沒有遺失或不一樣——它是一個完全正常、可以完整恢復的 Claude Code session,儲存在 `~/.claude/projects//.jsonl`。只要拿到 ID,就能直接恢復: + +```bash +claude --resume +``` + +這正是為什麼這個 app 要自備一套 session 探索機制(供 `/switch` 使用),而不是仰賴原生選擇器——它會直接掃描 JSONL 檔案,並依 project path 配對,所以即使在一般的 `claude --resume` 中永遠不會出現,Telegram 建立的 session 依然會在這裡顯示出來。 + +Codex 和 Copilot 在自己的 resume/list 指令中並不會做這種互動式與 headless 的區分,這就是為什麼這兩個 provider 的 session 在一般 terminal 中依舊能正常顯示。 +
+ +
+這個 app 會比直接使用 Claude Code terminal 消耗更多 token 嗎? + +不是因為每次呼叫本身存在固有的額外開銷差異——headless(`-p`)與互動式 Claude Code 使用的是相同的底層協定與計價方式。但實務上,24/7 的 Telegram 使用方式確實可能比一般 terminal 使用方式消耗明顯更多 token,原因有兩個會彼此疊加: + +- **session 可能無限膨脹。** 因為 bot 會很方便地在數小時甚至數天內持續恢復同一個 session,如果你從未輪替它,一個 session 可能會累積數百個回合、數 MB 的 transcript。在互動式 terminal 中,你通常會更自然地完成一項任務後、下次重新開始,context 因此比較容易維持較小。 +- **Telegram 訊息之間的閒置間隔會讓 prompt cache 過期。** Claude 的 prompt cache TTL 很短。如果你在這個 window 內回覆,後續回合就是便宜的 cache read。如果間隔很長(例如你睡了一覺、隔天早上才回覆),下一則訊息就必須把*全部*已累積的 context 當成貴上許多的 cache write 從頭重新處理一次——而且這個成本會隨著 session 當時已有的大小而增加。這就是為什麼即使還沒到「尖峰」時段,你當天發出第一則訊息時用量也可能暴增。 + +**緩解方式:** 對長期使用的 session 定期執行 `/compact`(這個 app 已支援作為 Telegram 指令),而不是讓一個 session 無限期地繼續跑下去,尤其是當你發現它已經閒置很久的時候。為不相關的工作另外開一個新的 `/new` session,也有助於把 context——以及成本——控制在合理範圍內。 + +現在 app 也會自動幫你做這件事,透過結合每個 provider 的兩個訊號,只在真的可能有影響時才打斷你:一個閒置時間門檻(`CLAUDE_LONG_GAP_SECONDS` / `CODEX_LONG_GAP_SECONDS` / `COPILOT_LONG_GAP_SECONDS`),*以及* session 已經累積了多少 context(對於小型、成本低的 session,即使已經閒置一段時間,也會略過提醒,因為從頭重新處理它們的代價可以忽略不計)。預設值:Claude Code 與 Codex 均為 1 小時 —— Claude 的數字背後有實際證據支持(見上文),雖然 OpenAI 並未為 Codex 公布相應數字,但 Codex 本身的 cache 通常也比 Claude 更短命,因此對齊 Claude 的門檻不會損失準確度,只會減少打擾次數,尤其是現在還結合了 size gate;而 Copilot 預設關閉,因為[GitHub 官方文件](https://docs.github.com/en/copilot/concepts/agents/copilot-cli/context-management)明確指出 Copilot CLI 完全沒有 inactivity timeout,並且已經原生自動壓縮自身 context(使用率約 80-95% 時)—— 這裡沒有任何與閒置相關的問題需要提醒,因此交由 Copilot 自身機制處理,而不是自行假設一個。若仍希望針對 Copilot 啟用以閒置時間為基礎的提醒,可將 `COPILOT_LONG_GAP_SECONDS` 設為正數。 + +當閒置門檻與 size gate 同時滿足時,它會先保留你的訊息並詢問: + +> ⏳ 這個 session 已經閒置了 {gap}。現在恢復很可能會把整段對話從頭重新處理一次(provider 的回覆 cache 很可能已經過期),可能會比平常多消耗不少 token。compact 同樣需要重新處理一次目前的內容來寫摘要,所以如果這個 session 已經很大,也可能消耗不少 token。切換到新 session 可以完全略過這次重新處理,但會失去這段對話的所有記憶。要切換到新 session、先 compact,還是直接繼續? +> +> [🆕 切換到新 session] +> [🔄 先 compact] +> [⚠️ 直接繼續] + +請注意,`/compact` 本身也無法免除這項開銷:它的原理是恢復目前(可能已經冷卻的)session,要求它為自己撰寫摘要,因此仍須付出與直接回覆相同的一次性全量 transcript 重新處理成本——分別只在於之後只需付一次,而不是每一輪都要付,因為新產生的 session 會以較小的規模開始。**切換到新 session** 是唯一能完全避免這次重新處理的選項:它不會恢復舊 session,而是直接放棄其內容、完全重新開始——代價是徹底失去那部分內容,而不是將它壓縮成摘要。 + +選擇**切換到新 session** 會開啟一個全新的空 session,並在其上繼續處理你的訊息——新 session 會以舊 session 名稱加上遞增的 `-newN` 後綴命名(例如 `fix-bug` → `fix-bug-new1` → 再次切換後變成 `fix-bug-new2`),讓你在 `/switch` 中仍能與原始 session 區分開來。選擇**先 compact**會先總結目前的 session,再根據該摘要開啟一個新 session,接著在新 session 上繼續處理你的訊息——命名方式類似,但改用 `-resumeN` 後綴(例如 `fix-bug` → `fix-bug-resume1` → 下次 compact 時變成 `fix-bug-resume2`)。選擇**直接繼續**則只會照常在現有的 session 上繼續。可以用 `LONG_GAP_WARNING_ENABLED=false` 關閉整個檢查機制。 +
+ +
+Claude session 突然出現 "Failed to authenticate: OAuth session expired and could not be refreshed" 錯誤 + +即使 `claude auth status` 顯示你已登入,甚至你剛重新登入之後,這個問題仍然可能出現。Claude Code 平時使用的互動式 OAuth session,可能會單獨在這個 bot 建立 session 所用的 headless、獨立子程序方式下失效,而與你實際的登入狀態無關——這種情況在 Claude Code CLI 自動更新之後曾經出現過,也可能是間歇性的。 + +如果使用者透過 `/new`、恢復某個 session,或傳送任何訊息即時遇到這個問題,bot 會辨識出這個特定的失敗,並立即回覆修復說明,而不是原始的 CLI 錯誤。 + +**修復方法**:在執行此 bot 的主機上: + +1. 執行 `claude setup-token`,並在開啟的瀏覽器中批准存取。**真正的 token 之後會顯示在你的終端機(以 `sk-ant-oat01-` 開頭),瀏覽器中不會顯示**——從瀏覽器頁面本身而不是終端機複製內容是常見的錯誤,那樣是無法運作的。這會建立一個長期有效(約 1 年)的驗證權杖——這是 Anthropic 官方支援的 headless/自動化用途機制(與 GitHub Actions 所用的相同)。與互動式 OAuth session 不同,它不依賴於從獨立背景程序中正常重新整理 Keychain/session,因此設定一次之後,預計在到期之前都不需要再次處理。 +2. 複製印出的權杖,然後根據你執行此應用程式的方式,用對應的指令儲存它——bot 自身的回覆會自動選擇正確的指令,這裡列出以供參考: + - 透過 `pip` 或一行式 `install.sh` 安裝(Quick Start 方案 A/B):`coding-agent-telegram claude-auth ` + - 從 clone 下來的 repository 用 `./startup.sh` 執行(Quick Start 方案 C):`./startup.sh claude-auth ` + + 這兩個指令都會將權杖以 `CLAUDE_CODE_OAUTH_TOKEN` 的形式儲存到你的 env 檔案中,並立即重新驗證 Claude 認證,因此你會馬上知道成功或失敗,而不是盲目重新啟動後乾等。 +3. 重新啟動 bot,讓執行中的程序套用這項變更。 + +你也可以跳過這個指令,直接在你的 env 檔案(`.env_coding_agent_telegram`)中自行設定 `CLAUDE_CODE_OAUTH_TOKEN=`——以上兩個指令只是對這項操作的便利封裝,外加自動驗證。 +
+ ## 📌 備註 - 本專案面向在自己機器上本地執行 agent 的使用者。 diff --git a/README.zh-TW.md b/README.zh-TW.md index 4ca3122..f1d6303 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -1,6 +1,6 @@
Coding Agent Telegram -

Coding Agent Telegram 🚀

+

Claude Code / Codex / Copilot Coding Agent Telegram 🚀

English | Deutsch | @@ -181,6 +181,8 @@ pip install coding-agent-telegram coding-agent-telegram ``` +已安裝的 `coding-agent-telegram` 指令包含與 `./startup.sh` 相同的輪詢 watchdog:bot crash 或 Telegram heartbeat 過期後會自動重新啟動;DNS 或網路不可用時會採用 backoff 策略重試。 + ### 方案 C:從 clone 下來的 repository 執行 ```bash git clone https://github.com/daocha/coding-agent-telegram @@ -273,6 +275,14 @@ bot 目前接受: /provider 為新的工作階段選擇提供者。這個選擇會依 bot 與 chat 儲存,直到你手動修改。 + + /model + 從目前提供者的模型列表中,為使用中工作階段選擇模型。此選擇會儲存在該工作階段的 state.json 中,並在每次恢復該工作階段時套用。建立新工作階段(/new、切換到全新工作階段的 /switch,或 /compact)一律會重設為提供者設定的預設模型。 + + + /model <model_id> + 設定不在精選列表中的特定模型 ID。如果它還不是已知選項,bot 會先以一次可拋棄的唯讀呼叫探測 CLI,確認該 ID 確實會被接受後才會儲存——如果 CLI 拒絕該 ID,錯誤訊息會回傳到 chat,且不會儲存任何內容。 + /project <project_folder> 設定目前的 project 資料夾。如果資料夾不存在,app 會建立並標記為 trusted;如果已存在但仍是 untrusted,app 會明確要求確認 trust。 @@ -289,6 +299,10 @@ bot 目前接受: /current 顯示目前 bot 與 chat 的作用中工作階段。 + + /status + 顯示各 provider 的配額使用情況:5小時與每週使用率百分比,以及重置時間。絕不會產生付費 API 呼叫:Codex 一律是免費的本機查詢,Claude 的數據也只會重複使用你最近一次透過 bot 產生的真實 Claude 使用紀錄,顯示為「最近一次觀測於 X 前」(僅適用於透過 OAuth 登入的 Pro/Max 帳戶)。兩個視窗分別獨立追蹤:如果其中一個已經過了重置時間(或從未被觀測到),即使另一個視窗仍有最新數據,它也會顯示為 N/A,直到你下一次使用 Claude 時才會更新。Copilot 沒有支援此功能的 API,因此會顯示為不可用。 + /new [session_name] 為目前的專案建立新的工作階段。如果省略名稱,bot 會使用真實工作階段 ID。若缺少提供者、專案或 branch,bot 會引導你完成缺少的步驟。 @@ -319,11 +333,19 @@ bot 目前接受: /pull - 確認後,從 origin 拉取作用中工作階段目前的分支。適用時,bot 也會一併重新整理預設分支。 + 確認後,透過 Git 從 origin 拉取作用中工作階段目前的分支。適用時,bot 也會一併重新整理預設分支。 /push - 為目前作用中工作階段執行 origin <branch> push。push 前 bot 會要求確認。 + 為目前作用中工作階段透過 Git push 至 origin <branch>。push 前 bot 會要求確認。 + + + /log + 顯示作用中工作階段專案最近的五個 Git 提交。 + + + /reset + 選擇 local 或 origin 上的預設/目前分支,然後確認執行 git reset --hard。遠端目標會先被拉取。 /abort @@ -402,6 +424,18 @@ bot 目前接受: CLAUDE_MODEL 可選的 Claude Code model override。留空則使用 Claude Code CLI 預設 model。例子:sonnetopushaiku Claude Code model configuration + + CODEX_MODEL_CHOICES + 以逗號分隔的 model 列表,供 Codex 的 /model 指令選用。未設定時,會使用隨附 .env.example 中的值。 + + + COPILOT_MODEL_CHOICES + 以逗號分隔的 model 列表,供 Copilot 的 /model 指令選用。未設定時,會使用隨附 .env.example 中的值。 + + + CLAUDE_MODEL_CHOICES + 以逗號分隔的 model 列表,供 Claude Code 的 /model 指令選用。未設定時,會使用隨附 .env.example 中的值;若範本無法使用,則使用 sonnet,opus,fable,haiku。 + CODEX_APPROVAL_POLICY 傳遞給 Codex 的 approval mode。預設:never。 @@ -434,6 +468,22 @@ bot 目前接受: AGENT_HARD_TIMEOUT_SECONDS 單次 代理執行 的硬性 timeout。預設:0(停用)。 + + LONG_GAP_WARNING_ENABLED + 在恢復一個已經閒置一段時間並且已累積足夠 context(重新處理成本較高)的 session 之前,提醒使用者 provider 的 prompt cache 很可能已經過期 —— 並提供按鈕讓你選擇先 compact 或直接繼續。預設:true。詳見下方 FAQ。 + + + CLAUDE_LONG_GAP_SECONDS + Claude Code session 觸發此警告前的閒置門檻(秒)。預設:3600(1 小時,對應 Claude Code 的延伸 prompt-cache 保留時間)。 + + + CODEX_LONG_GAP_SECONDS + Codex session 觸發此警告前的閒置門檻(秒)。預設:3600(1 小時,與 Claude 相同;OpenAI 並未為 Codex 公布以閒置時間為基礎的 cache 失效數字,而且 Codex 本身的 cache 通常也比 Claude 更短命,因此對齊 Claude 的門檻不會損失準確度 —— 並結合 size gate,避免小型 session 頻繁打擾)。 + + + COPILOT_LONG_GAP_SECONDS + Copilot session 觸發此警告前的閒置門檻(秒)。預設:0(停用)。GitHub 官方文件指出 Copilot CLI 沒有 inactivity timeout,並且已經原生自動壓縮自身 context(使用率約 80-95% 時)—— 這裡沒有需要提醒的閒置相關風險,因此交由 Copilot 自身機制處理,而不是自行假設一個並不存在的 API。若仍希望針對 Copilot 啟用以閒置時間為基礎的提醒,可設為正數。 + SNAPSHOT_TEXT_FILE_MAX_BYTES 建立每次執行的前後 快照 diff 時,bot 會以文字讀取的最大檔案大小。預設:200000。 @@ -684,6 +734,71 @@ log 會**同時寫入 stdout 與輪轉 日誌檔案**,路徑如下: - PyPI prerelease: `v2026.3.26rc1` - PyPI stable: `v2026.3.26` +## ❓ 常見問題 / 疑難排解 + +

+為什麼在一般 terminal 執行 claude --resume,看不到從 Telegram 建立的 session? + +這是 Claude Code CLI 的預期行為,不是這個 app 的 bug。 + +這個 bot 建立的 session 是透過 Claude Code 的 headless `-p`/print 模式執行的。Claude Code 會在 transcript 中把這樣啟動的 session 標記為 `entrypoint: "sdk-cli"`,相對於你直接在 terminal 輸入 `claude` 開始的 session 的 `entrypoint: "cli"`。不帶 session ID 的互動式 `claude --resume` 選擇器只會列出 `entrypoint` 為 `cli` 的 session —— 它刻意隱藏 headless/由 SDK 驅動的執行,把這些視為自動化輸出,而不是打算讓人手動接續的對話。 + +session 資料本身並沒有遺失或不一樣——它是一個完全正常、可以完整恢復的 Claude Code session,儲存在 `~/.claude/projects//.jsonl`。只要拿到 ID,就能直接恢復: + +```bash +claude --resume +``` + +這正是為什麼這個 app 要自備一套 session 探索機制(供 `/switch` 使用),而不是仰賴原生選擇器——它會直接掃描 JSONL 檔案,並依 project path 配對,所以即使在一般的 `claude --resume` 中永遠不會出現,Telegram 建立的 session 依然會在這裡顯示出來。 + +Codex 和 Copilot 在自己的 resume/list 指令中並不會做這種互動式與 headless 的區分,這就是為什麼這兩個 provider 的 session 在一般 terminal 中依舊能正常顯示。 +
+ +
+這個 app 會比直接使用 Claude Code terminal 消耗更多 token 嗎? + +不是因為每次呼叫本身存在固有的額外開銷差異——headless(`-p`)與互動式 Claude Code 使用的是相同的底層協定與計價方式。但實務上,24/7 的 Telegram 使用方式確實可能比一般 terminal 使用方式消耗明顯更多 token,原因有兩個會彼此疊加: + +- **session 可能無限膨脹。** 因為 bot 會很方便地在數小時甚至數天內持續恢復同一個 session,如果你從未輪替它,一個 session 可能會累積數百個回合、數 MB 的 transcript。在互動式 terminal 中,你通常會更自然地完成一項任務後、下次重新開始,context 因此比較容易維持較小。 +- **Telegram 訊息之間的閒置間隔會讓 prompt cache 過期。** Claude 的 prompt cache TTL 很短。如果你在這個 window 內回覆,後續回合就是便宜的 cache read。如果間隔很長(例如你睡了一覺、隔天早上才回覆),下一則訊息就必須把*全部*已累積的 context 當成貴上許多的 cache write 從頭重新處理一次——而且這個成本會隨著 session 當時已有的大小而增加。這就是為什麼即使還沒到「尖峰」時段,你當天發出第一則訊息時用量也可能暴增。 + +**緩解方式:** 對長期使用的 session 定期執行 `/compact`(這個 app 已支援作為 Telegram 指令),而不是讓一個 session 無限期地繼續跑下去,尤其是當你發現它已經閒置很久的時候。為不相關的工作另外開一個新的 `/new` session,也有助於把 context——以及成本——控制在合理範圍內。 + +現在 app 也會自動幫你做這件事,透過結合每個 provider 的兩個訊號,只在真的可能有影響時才打斷你:一個閒置時間門檻(`CLAUDE_LONG_GAP_SECONDS` / `CODEX_LONG_GAP_SECONDS` / `COPILOT_LONG_GAP_SECONDS`),*以及* session 已經累積了多少 context(對於小型、成本低的 session,即使已經閒置一段時間,也會略過提醒,因為從頭重新處理它們的代價可以忽略不計)。預設值:Claude Code 與 Codex 均為 1 小時 —— Claude 的數字背後有實際證據支持(見上文),雖然 OpenAI 並未為 Codex 公布相應數字,但 Codex 本身的 cache 通常也比 Claude 更短命,因此對齊 Claude 的門檻不會損失準確度,只會減少打擾次數,尤其是現在還結合了 size gate;而 Copilot 預設關閉,因為[GitHub 官方文件](https://docs.github.com/en/copilot/concepts/agents/copilot-cli/context-management)明確指出 Copilot CLI 完全沒有 inactivity timeout,並且已經原生自動壓縮自身 context(使用率約 80-95% 時)—— 這裡沒有任何與閒置相關的問題需要提醒,因此交由 Copilot 自身機制處理,而不是自行假設一個。若仍希望針對 Copilot 啟用以閒置時間為基礎的提醒,可將 `COPILOT_LONG_GAP_SECONDS` 設為正數。 + +當閒置門檻與 size gate 同時滿足時,它會先保留你的訊息並詢問: + +> ⏳ 這個 session 已經閒置了 {gap}。現在恢復很可能會把整段對話從頭重新處理一次(provider 的回覆 cache 很可能已經過期),可能會比平常多消耗不少 token。compact 同樣需要重新處理一次目前的內容來寫摘要,所以如果這個 session 已經很大,也可能消耗不少 token。切換到新 session 可以完全略過這次重新處理,但會失去這段對話的所有記憶。要切換到新 session、先 compact,還是直接繼續? +> +> [🆕 切換到新 session] +> [🔄 先 compact] +> [⚠️ 直接繼續] + +請注意,`/compact` 本身也無法免除這項開銷:它的原理是恢復目前(可能已經冷卻的)session,要求它為自己撰寫摘要,因此仍須付出與直接回覆相同的一次性全量 transcript 重新處理成本——分別只在於之後只需付一次,而不是每一輪都要付,因為新產生的 session 會以較小的規模開始。**切換到新 session** 是唯一能完全避免這次重新處理的選項:它不會恢復舊 session,而是直接放棄其內容、完全重新開始——代價是徹底失去那部分內容,而不是將它壓縮成摘要。 + +選擇**切換到新 session** 會開啟一個全新的空 session,並在其上繼續處理你的訊息——新 session 會以舊 session 名稱加上遞增的 `-newN` 後綴命名(例如 `fix-bug` → `fix-bug-new1` → 再次切換後變成 `fix-bug-new2`),讓你在 `/switch` 中仍能與原始 session 區分開來。選擇**先 compact**會先總結目前的 session,再根據該摘要開啟一個新 session,接著在新 session 上繼續處理你的訊息——命名方式類似,但改用 `-resumeN` 後綴(例如 `fix-bug` → `fix-bug-resume1` → 下次 compact 時變成 `fix-bug-resume2`)。選擇**直接繼續**則只會照常在現有的 session 上繼續。可以用 `LONG_GAP_WARNING_ENABLED=false` 關閉整個檢查機制。 +
+ +
+Claude session 突然出現 "Failed to authenticate: OAuth session expired and could not be refreshed" 錯誤 + +即使 `claude auth status` 顯示你已登入,甚至剛重新登入之後,這個問題仍可能發生。Claude Code 平常使用的互動式 OAuth session,可能會單獨在這個 bot 建立 session 所用的 headless、獨立子程序方式下失效,而與你實際的登入狀態無關——這種情況在 Claude Code CLI 自動更新之後曾經出現過,也可能是間歇性的。 + +如果使用者透過 `/new`、恢復某個 session,或傳送任何訊息即時遇到這個問題,bot 會辨識出這個特定的失敗,並立即回覆修復說明,而不是原始的 CLI 錯誤。 + +**修復方法**:在執行此 bot 的主機上: + +1. 執行 `claude setup-token`,並在開啟的瀏覽器中核准存取。**實際的權杖之後會印在你的終端機(以 `sk-ant-oat01-` 開頭),瀏覽器中不會顯示**——從瀏覽器頁面本身而不是終端機複製內容是常見的錯誤,那樣行不通。這會建立一個長期有效(約 1 年)的驗證權杖——這是 Anthropic 官方支援的 headless/自動化用途機制(與 GitHub Actions 所用的相同)。與互動式 OAuth session 不同,它不依賴於從獨立背景程序中正常重新整理 Keychain/session,因此設定一次之後,預計在到期之前都不需要再次處理。 +2. 複製印出的權杖,然後依照你執行此應用程式的方式,用對應的指令儲存它——bot 自身的回覆會自動選擇正確的指令,這裡列出以供參考: + - 透過 `pip` 或一行式 `install.sh` 安裝(Quick Start 方案 A/B):`coding-agent-telegram claude-auth ` + - 從 clone 下來的 repository 用 `./startup.sh` 執行(Quick Start 方案 C):`./startup.sh claude-auth ` + + 這兩個指令都會將權杖以 `CLAUDE_CODE_OAUTH_TOKEN` 的形式儲存到你的 env 檔案中,並立即重新驗證 Claude 認證,因此你會馬上知道成功或失敗,而不是盲目重新啟動後乾等。 +3. 重新啟動 bot,讓執行中的程序套用這項變更。 + +你也可以跳過這個指令,直接在你的 env 檔案(`.env_coding_agent_telegram`)中自行設定 `CLAUDE_CODE_OAUTH_TOKEN=`——以上兩個指令只是對這項操作的便利封裝,外加自動驗證。 +
+ ## 📌 備註 - 本專案面向在自己機器上本地執行 agent 的使用者。 diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..0f0888a --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,225 @@ +#!/usr/bin/env bash +# One-time environment setup: creates/activates the venv, installs the +# package, creates the .env file if missing, and validates required +# settings. Does not start the bot itself -- ./startup.sh runs this once +# and then supervises the actual bot process (restarting it across crashes +# and network outages). Run this directly only if you want to (re)prepare +# the environment without starting anything. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +DEFAULT_ENV_FILE=".env_coding_agent_telegram" +PYTHON_BIN="${PYTHON_BIN:-python3}" +ENV_FILE="${ENV_FILE:-}" +ENV_TEMPLATE_FILE="${ENV_TEMPLATE_FILE:-src/coding_agent_telegram/resources/.env.example}" +VENV_DIR="${VENV_DIR:-.venv}" + +resolve_user_home() { + "$PYTHON_BIN" - <<'PY' +from pathlib import Path +import os +import pwd + +sudo_user = os.getenv("SUDO_USER", "").strip() +if sudo_user and sudo_user != "root": + try: + print(pwd.getpwnam(sudo_user).pw_dir) + except KeyError: + print(Path.home()) +else: + print(Path.home()) +PY +} + +if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then + echo "Error: $PYTHON_BIN was not found in PATH." >&2 + exit 1 +fi + +APP_HOME_DIR="$(resolve_user_home)/.coding-agent-telegram" +HOME_ENV_FILE="$APP_HOME_DIR/$DEFAULT_ENV_FILE" +STATE_FILE_DEFAULT="$APP_HOME_DIR/state.json" +STATE_BACKUP_FILE_DEFAULT="$APP_HOME_DIR/state.json.bak" +LOG_DIR_DEFAULT="$APP_HOME_DIR/logs" +LOCAL_PRETEND_VERSION="${SETUPTOOLS_SCM_PRETEND_VERSION_FOR_CODING_AGENT_TELEGRAM:-0.0.dev0}" +INSTALL_STATE_FILE_NAME=".coding-agent-telegram-install-state" +FORCE_REINSTALL="${FORCE_REINSTALL:-0}" + +compute_install_fingerprint() { + local files=() + local file + for file in pyproject.toml setup.py; do + if [[ -f "$file" ]]; then + files+=("$file") + fi + done + if [[ "${#files[@]}" -eq 0 ]]; then + printf 'no-packaging-files\n' + return + fi + shasum -a 256 "${files[@]}" | shasum -a 256 | awk '{print $1}' +} + +if [[ -z "$ENV_FILE" ]]; then + if [[ -f "$HOME_ENV_FILE" ]]; then + ENV_FILE="$HOME_ENV_FILE" + elif [[ -f "$DEFAULT_ENV_FILE" ]]; then + ENV_FILE="$DEFAULT_ENV_FILE" + else + ENV_FILE="$HOME_ENV_FILE" + fi +fi + +NEW_ENV_CREATED=0 +if [[ ! -f "$ENV_FILE" ]]; then + if [[ -f "$ENV_TEMPLATE_FILE" ]]; then + ENV_FILE_TARGET="$ENV_FILE" ENV_TEMPLATE_SOURCE="$ENV_TEMPLATE_FILE" PYTHONPATH="$SCRIPT_DIR/src${PYTHONPATH:+:$PYTHONPATH}" "$PYTHON_BIN" - <<'PY' +from pathlib import Path +import os +from coding_agent_telegram.config import create_initial_env_file +from coding_agent_telegram.i18n import translate + +env_path = Path(os.environ["ENV_FILE_TARGET"]).expanduser() +template_path = Path(os.environ["ENV_TEMPLATE_SOURCE"]).expanduser() +app_locale = create_initial_env_file(env_path, template_path) +print(translate(app_locale, "bootstrap.env_created_locale_line", env_path=env_path, app_locale=app_locale)) +print(translate(app_locale, "bootstrap.env_created_change_line", env_path=env_path)) +PY + NEW_ENV_CREATED=1 + else + echo "Error: $ENV_FILE is missing and $ENV_TEMPLATE_FILE was not found." >&2 + exit 1 + fi +fi + +STATE_FILE="$STATE_FILE_DEFAULT" +STATE_BACKUP_FILE="$STATE_BACKUP_FILE_DEFAULT" +if [[ -f "$APP_HOME_DIR/state.json" ]]; then + STATE_FILE="$APP_HOME_DIR/state.json" +elif [[ -f "./state.json" ]]; then + STATE_FILE="./state.json" +fi +if [[ -f "$APP_HOME_DIR/state.json.bak" ]]; then + STATE_BACKUP_FILE="$APP_HOME_DIR/state.json.bak" +elif [[ -f "./state.json.bak" ]]; then + STATE_BACKUP_FILE="./state.json.bak" +fi +LOG_DIR="$LOG_DIR_DEFAULT" + +mkdir -p "$(dirname "$STATE_FILE")" "$(dirname "$STATE_BACKUP_FILE")" "$LOG_DIR" +touch "$STATE_FILE" "$STATE_BACKUP_FILE" + +if [[ ! -d "$VENV_DIR" ]]; then + "$PYTHON_BIN" -m venv "$VENV_DIR" +fi + +source "$VENV_DIR/bin/activate" + +python -m pip install --upgrade pip >/dev/null +INSTALL_STATE_FILE="$VENV_DIR/$INSTALL_STATE_FILE_NAME" +CURRENT_INSTALL_FINGERPRINT="$(compute_install_fingerprint)" +STORED_INSTALL_FINGERPRINT="" +if [[ -f "$INSTALL_STATE_FILE" ]]; then + STORED_INSTALL_FINGERPRINT="$(<"$INSTALL_STATE_FILE")" +fi + +NEEDS_REINSTALL=0 +if [[ "$FORCE_REINSTALL" == "1" ]]; then + NEEDS_REINSTALL=1 +elif ! python -c "import coding_agent_telegram" >/dev/null 2>&1; then + NEEDS_REINSTALL=1 +elif [[ "$CURRENT_INSTALL_FINGERPRINT" != "$STORED_INSTALL_FINGERPRINT" ]]; then + NEEDS_REINSTALL=1 +fi + +if [[ "$NEEDS_REINSTALL" == "1" ]]; then + echo "Installing local package into $VENV_DIR." + SETUPTOOLS_SCM_PRETEND_VERSION_FOR_CODING_AGENT_TELEGRAM="$LOCAL_PRETEND_VERSION" \ + python -m pip install -e . + printf '%s\n' "$CURRENT_INSTALL_FINGERPRINT" > "$INSTALL_STATE_FILE" +else + echo "Existing editable install detected; skipping reinstall." +fi + +if [[ "$NEW_ENV_CREATED" == "1" ]]; then + python -m coding_agent_telegram.stt_setup offer \ + --env-file "$ENV_FILE" \ + --python-bin "$VENV_DIR/bin/python" \ + --installer-label "./install-stt.sh" +fi + +set -a +source "$ENV_FILE" +set +a + +required_vars=( + WORKSPACE_ROOT + TELEGRAM_BOT_TOKENS +) + +for var_name in "${required_vars[@]}"; do + if [[ -z "${!var_name:-}" ]]; then + echo "Error: $var_name must be set in $ENV_FILE." >&2 + echo "Post-installation checklist:" + echo "1. Edit $ENV_FILE" + echo "2. Set WORKSPACE_ROOT to the parent folder containing your projects" + echo "3. Set TELEGRAM_BOT_TOKENS to one or more bot tokens" + echo "4. Set ALLOWED_CHAT_IDS to your Telegram chat id(s)" + echo "5. Run: ./startup.sh" + exit 1 + fi +done + +if [[ -z "${ALLOWED_CHAT_IDS:-}" ]]; then + echo "Error: set ALLOWED_CHAT_IDS in $ENV_FILE." >&2 + echo "Run: ./startup.sh after updating $ENV_FILE." + exit 1 +fi + +DEFAULT_AGENT_PROVIDER="${DEFAULT_AGENT_PROVIDER:-codex}" +CODEX_BIN="${CODEX_BIN:-codex}" +COPILOT_BIN="${COPILOT_BIN:-copilot}" +CLAUDE_BIN="${CLAUDE_BIN:-claude}" + +case "$DEFAULT_AGENT_PROVIDER" in + codex) + if ! command -v "$CODEX_BIN" >/dev/null 2>&1; then + echo "Error: Codex CLI not found: $CODEX_BIN" >&2 + echo "Check DEFAULT_AGENT_PROVIDER and CODEX_BIN in $ENV_FILE." >&2 + echo "If this machine only has Copilot or Claude Code, set DEFAULT_AGENT_PROVIDER=copilot or claude." >&2 + exit 1 + fi + ;; + copilot) + if ! command -v "$COPILOT_BIN" >/dev/null 2>&1; then + echo "Error: Copilot CLI not found: $COPILOT_BIN" >&2 + echo "Check DEFAULT_AGENT_PROVIDER and COPILOT_BIN in $ENV_FILE." >&2 + echo "If this machine only has Codex or Claude Code, set DEFAULT_AGENT_PROVIDER=codex or claude." >&2 + exit 1 + fi + ;; + claude) + if ! command -v "$CLAUDE_BIN" >/dev/null 2>&1; then + echo "Error: Claude Code CLI not found: $CLAUDE_BIN" >&2 + echo "Check DEFAULT_AGENT_PROVIDER and CLAUDE_BIN in $ENV_FILE." >&2 + echo "If this machine only has Codex or Copilot, set DEFAULT_AGENT_PROVIDER=codex or copilot." >&2 + exit 1 + fi + ;; + *) + echo "Error: DEFAULT_AGENT_PROVIDER must be codex, copilot, or claude." >&2 + exit 1 + ;; +esac + +echo "Post-installation guide:" +echo "1. Confirm $ENV_FILE contains WORKSPACE_ROOT, TELEGRAM_BOT_TOKENS, and ALLOWED_CHAT_IDS." +echo "2. State files are ready at $STATE_FILE and $STATE_BACKUP_FILE." +echo "3. Application logs will be written under $LOG_DIR." +echo "4. Optional voice-to-text: run ./install-stt.sh if you want local Whisper support." +echo "5. Start the server with: ./startup.sh" +echo "6. In Telegram, start conversations." +echo "Environment ready." diff --git a/install.sh b/install.sh index 4424b95..9de66d8 100644 --- a/install.sh +++ b/install.sh @@ -27,5 +27,5 @@ if [[ -z "$COMMAND_PATH" && ":$PATH:" != *":$SCRIPT_DIR:"* ]]; then echo "Note: $SCRIPT_DIR is not currently on PATH." fi -echo "Starting coding-agent-telegram..." +echo "Starting coding-agent-telegram with its recovery supervisor..." exec "$PYTHON_BIN" -m coding_agent_telegram diff --git a/pyproject.toml b/pyproject.toml index 59db972..e137b4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ ] [project.scripts] -coding-agent-telegram = "coding_agent_telegram.cli:main" +coding-agent-telegram = "coding_agent_telegram.supervisor:main" coding-agent-telegram-stt-install = "coding_agent_telegram.stt_setup:main" [tool.setuptools] diff --git a/src/coding_agent_telegram/__main__.py b/src/coding_agent_telegram/__main__.py index 1786eb1..a8d8b70 100644 --- a/src/coding_agent_telegram/__main__.py +++ b/src/coding_agent_telegram/__main__.py @@ -1,4 +1,4 @@ -from coding_agent_telegram.cli import main +from coding_agent_telegram.supervisor import main if __name__ == "__main__": diff --git a/src/coding_agent_telegram/agent_runner.py b/src/coding_agent_telegram/agent_runner.py index d5d94ac..dd49156 100644 --- a/src/coding_agent_telegram/agent_runner.py +++ b/src/coding_agent_telegram/agent_runner.py @@ -13,6 +13,8 @@ from pathlib import Path from typing import Any, Callable, Optional, Sequence, Tuple, Union +from coding_agent_telegram.usage_status import observe_claude_rate_limit_event + logger = logging.getLogger(__name__) @@ -281,6 +283,25 @@ def _extract_claude_assistant_text(self, event: AssistantEvent) -> str: return result_text if isinstance(result_text, str) else "" return "\n".join(self._unique_text_fragments(self._collect_text_fragments(event))) + @staticmethod + def _claude_events_report_session_not_found(events: list[dict]) -> bool: + """True if a Claude "result" event's own "errors" array says the resumed + session ID has no local transcript. Checked against that structured field + directly -- not against whatever text ends up in error_message, since that can + also be a genuine (if failed) turn's model-generated output, which shouldn't be + substring-matched for control decisions like "should this session be replaced." + """ + for ev in events: + if not isinstance(ev, dict) or ev.get("type") != "result" or not ev.get("is_error"): + continue + errors_list = ev.get("errors") + if not isinstance(errors_list, list): + continue + for error in errors_list: + if isinstance(error, str) and error.lower().startswith("no conversation found"): + return True + return False + def _parse_claude_jsonl(self, stdout: str) -> Tuple[Optional[str], bool, str, Optional[str], list[dict]]: events = self._parse_json_lines(stdout) @@ -303,9 +324,21 @@ def _parse_claude_jsonl(self, stdout: str) -> Tuple[Optional[str], bool, str, Op if isinstance(result_text, str) and result_text: assistant_text = result_text if is_error: + # A resume against a session ID the CLI has no local transcript for + # fails before any turn runs: "result" is empty and "subtype" is just + # the generic "error_during_execution", but the actual reason (e.g. + # "No conversation found with session ID: ...") is in "errors". + errors_list = ev.get("errors") + first_error = ( + next((e for e in errors_list if isinstance(e, str) and e), None) + if isinstance(errors_list, list) + else None + ) subtype = str(ev.get("subtype") or "").strip() error_message = ( - result_text if isinstance(result_text, str) and result_text else (subtype or "Claude run failed.") + result_text + if isinstance(result_text, str) and result_text + else (first_error or subtype or "Claude run failed.") ) else: extracted_text = self._extract_claude_assistant_text(ev) @@ -629,6 +662,10 @@ def _watchdog() -> None: session_id, parsed_success, assistant_text, error_message, events = self._parse_codex_jsonl(stdout) elif provider == "claude": session_id, parsed_success, assistant_text, error_message, events = self._parse_claude_jsonl(stdout) + try: + observe_claude_rate_limit_event(events) + except Exception: + logger.exception("Failed to cache Claude rate-limit data from a completed run.") else: session_id, parsed_success, assistant_text, error_message, events = self._parse_copilot_jsonl(stdout) @@ -646,6 +683,8 @@ def _watchdog() -> None: error_message = stripped_stderr else: error_code = "agent_command_failed" + if not success and provider == "claude" and self._claude_events_report_session_not_found(events): + error_code = "session_not_found" return AgentRunResult( session_id=session_id, @@ -739,18 +778,29 @@ def _codex_base( user_message: str, skip_git_repo_check: bool, image_paths: Sequence[Path] = (), + *, + for_session_creation: bool = False, + model: Optional[str] = None, ) -> list[str]: args = [] - if self.codex_model: - args.extend(["-m", self.codex_model]) + effective_model = model if model is not None else self.codex_model + if effective_model: + args.extend(["-m", effective_model]) for image_path in image_paths: args.extend(["--image", str(image_path)]) + # Session creation only primes the CLI to hand back a session ID, so it runs + # read-only regardless of the operator's configured approval/sandbox settings. + # Without this the priming prompt inherits full autopilot permissions and the + # agent may act on it (e.g. running commands from a compaction "next steps" + # summary) behind the bot's back. + approval_policy = "never" if for_session_creation else self.approval_policy + sandbox_mode = "read-only" if for_session_creation else self.sandbox_mode args.extend( [ "-c", - f"approval_policy={self.approval_policy}", + f"approval_policy={approval_policy}", "-c", - f"sandbox_mode={self.sandbox_mode}", + f"sandbox_mode={sandbox_mode}", "--json", "--cd", str(project_path), @@ -766,10 +816,13 @@ def _codex_resume_base( user_message: str, skip_git_repo_check: bool, image_paths: Sequence[Path] = (), + *, + model: Optional[str] = None, ) -> list[str]: args = [] - if self.codex_model: - args.extend(["-m", self.codex_model]) + effective_model = model if model is not None else self.codex_model + if effective_model: + args.extend(["-m", effective_model]) for image_path in image_paths: args.extend(["--image", str(image_path)]) args.extend( @@ -792,9 +845,11 @@ def _codex_resume_args( user_message: str, skip_git_repo_check: bool, image_paths: Sequence[Path] = (), + *, + model: Optional[str] = None, ) -> list[str]: return [ - *self._codex_resume_base(user_message, skip_git_repo_check, image_paths)[:-1], + *self._codex_resume_base(user_message, skip_git_repo_check, image_paths, model=model)[:-1], session_id, f"{self.PROMPT_PREFIX}{user_message}", ] @@ -808,10 +863,19 @@ def _copilot_base( skip_git_repo_check: bool, *, for_session_creation: bool = False, + model: Optional[str] = None, ) -> list[str]: + # Session creation only primes the CLI to hand back a session ID, so the + # operator's permission grants are withheld and the throwaway prompt cannot be + # acted on. Copilot has no positive read-only switch like Claude's + # `--permission-mode plan` or Codex's `sandbox_mode=read-only`, so this is + # expressed by omission: with nothing allowed, Copilot falls back to asking + # before each tool use, and `--no-ask-user` (deliberately not gated here) turns + # that into a decline rather than a hang in this non-interactive run. args = [] - if self.copilot_model: - args.extend(["--model", self.copilot_model]) + effective_model = model if model is not None else self.copilot_model + if effective_model: + args.extend(["--model", effective_model]) if self.copilot_autopilot and not for_session_creation: args.append("--autopilot") if self.copilot_no_ask_user: @@ -835,11 +899,24 @@ def _copilot_base( ) return args - def _claude_base(self, user_message: str) -> list[str]: + def _claude_base( + self, + user_message: str, + *, + for_session_creation: bool = False, + model: Optional[str] = None, + ) -> list[str]: args = [] - if self.claude_model: - args.extend(["--model", self.claude_model]) - if self.claude_permission_mode: + effective_model = model if model is not None else self.claude_model + if effective_model: + args.extend(["--model", effective_model]) + # Session creation only primes the CLI to hand back a session ID, so it runs + # read-only. Without this the priming prompt inherits full autopilot + # permissions and the agent may act on it (e.g. creating a git branch named + # after the session) behind the bot's back. + if for_session_creation: + args.extend(["--permission-mode", "plan"]) + elif self.claude_permission_mode: args.extend(["--permission-mode", self.claude_permission_mode]) if self.claude_allowed_tools: args.extend(["--allowedTools", ",".join(self.claude_allowed_tools)]) @@ -864,14 +941,32 @@ def create_session( *, skip_git_repo_check: bool = False, image_paths: Sequence[Path] = (), + priming_only: bool = False, + model: Optional[str] = None, on_stall: Optional[Callable[[AgentStallInfo], None]] = None, on_progress: Optional[Callable[[AgentProgressInfo], None]] = None, ) -> AgentRunResult: + """Create a session. + + ``priming_only`` marks calls whose prompt exists solely to make the CLI hand + back a session ID; those run read-only so the throwaway prompt cannot be acted + on. Callers that pass a real user request must leave it False. + + ``model`` overrides the configured default model for this call only; leave it + None to use the provider's configured (or CLI) default. + """ if provider == "codex": args = [ self.codex_bin, "exec", - *self._codex_base(project_path, user_message, skip_git_repo_check, image_paths), + *self._codex_base( + project_path, + user_message, + skip_git_repo_check, + image_paths, + for_session_creation=priming_only, + model=model, + ), ] return self._run_with_output_file( args, @@ -882,9 +977,12 @@ def create_session( on_progress=on_progress, ) elif provider == "copilot": - if image_paths: - return AgentRunResult(None, False, "", "Image attachments are not supported for Copilot sessions.", []) - args = [self.copilot_bin, *self._copilot_base(user_message, skip_git_repo_check, for_session_creation=True)] + args = [ + self.copilot_bin, + *self._copilot_base( + user_message, skip_git_repo_check, for_session_creation=priming_only, model=model + ), + ] return self._run( args, provider="copilot", @@ -894,7 +992,10 @@ def create_session( on_progress=on_progress, ) elif provider == "claude": - args = [self.claude_bin, *self._claude_base(user_message)] + args = [ + self.claude_bin, + *self._claude_base(user_message, for_session_creation=priming_only, model=model), + ] return self._run( args, provider="claude", @@ -914,6 +1015,7 @@ def resume_session( *, skip_git_repo_check: bool = False, image_paths: Sequence[Path] = (), + model: Optional[str] = None, on_stall: Optional[Callable[[AgentStallInfo], None]] = None, on_progress: Optional[Callable[[AgentProgressInfo], None]] = None, ) -> AgentRunResult: @@ -922,7 +1024,7 @@ def resume_session( self.codex_bin, "exec", "resume", - *self._codex_resume_args(session_id, user_message, skip_git_repo_check, image_paths), + *self._codex_resume_args(session_id, user_message, skip_git_repo_check, image_paths, model=model), ] return self._run_with_output_file( args, @@ -933,9 +1035,11 @@ def resume_session( on_progress=on_progress, ) elif provider == "copilot": - if image_paths: - return AgentRunResult(None, False, "", "Image attachments are not supported for Copilot sessions.", []) - args = [self.copilot_bin, f"--resume={session_id}", *self._copilot_base(user_message, skip_git_repo_check)] + args = [ + self.copilot_bin, + f"--resume={session_id}", + *self._copilot_base(user_message, skip_git_repo_check, model=model), + ] return self._run( args, provider="copilot", @@ -945,7 +1049,7 @@ def resume_session( on_progress=on_progress, ) elif provider == "claude": - args = [self.claude_bin, "--resume", session_id, *self._claude_base(user_message)] + args = [self.claude_bin, "--resume", session_id, *self._claude_base(user_message, model=model)] return self._run( args, provider="claude", diff --git a/src/coding_agent_telegram/bot.py b/src/coding_agent_telegram/bot.py index 56bc217..75d6069 100644 --- a/src/coding_agent_telegram/bot.py +++ b/src/coding_agent_telegram/bot.py @@ -43,9 +43,11 @@ def _describe_message_types(message) -> list[str]: def default_bot_commands(*, enable_commit_command: bool, locale: str = DEFAULT_LOCALE) -> list[BotCommand]: commands = [ BotCommand("provider", translate(locale, "bot.command.provider")), + BotCommand("model", translate(locale, "bot.command.model")), BotCommand("project", translate(locale, "bot.command.project")), BotCommand("branch", translate(locale, "bot.command.branch")), BotCommand("current", translate(locale, "bot.command.current")), + BotCommand("status", translate(locale, "bot.command.status")), BotCommand("new", translate(locale, "bot.command.new")), BotCommand("switch", translate(locale, "bot.command.switch")), BotCommand("compact", translate(locale, "bot.command.compact")), @@ -55,6 +57,8 @@ def default_bot_commands(*, enable_commit_command: bool, locale: str = DEFAULT_L commands.append(BotCommand("commit", translate(locale, "bot.command.commit"))) commands.append(BotCommand("pull", translate(locale, "bot.command.pull"))) commands.append(BotCommand("push", translate(locale, "bot.command.push"))) + commands.append(BotCommand("log", translate(locale, "bot.command.log"))) + commands.append(BotCommand("reset", translate(locale, "bot.command.reset"))) commands.append(BotCommand("abort", translate(locale, "bot.command.abort"))) return commands @@ -150,9 +154,11 @@ async def log_incoming_private_message(update, _context) -> None: app.add_handler(MessageHandler(allowed_private, log_incoming_private_message, block=False), group=-1) app.add_handler(CommandHandler("provider", router.handle_provider, filters=allowed_private)) + app.add_handler(CommandHandler("model", router.handle_model, filters=allowed_private)) app.add_handler(CommandHandler("project", router.handle_project, filters=allowed_private)) app.add_handler(CommandHandler("branch", router.handle_branch, filters=allowed_private)) app.add_handler(CommandHandler("current", router.handle_current, filters=allowed_private)) + app.add_handler(CommandHandler("status", router.handle_status, filters=allowed_private)) app.add_handler(CommandHandler("new", router.handle_new, filters=allowed_private, block=False)) app.add_handler(CommandHandler("switch", router.handle_switch, filters=allowed_private)) app.add_handler(CommandHandler("compact", router.handle_compact, filters=allowed_private)) @@ -160,6 +166,8 @@ async def log_incoming_private_message(update, _context) -> None: app.add_handler(CommandHandler("commit", router.handle_commit, filters=allowed_private)) app.add_handler(CommandHandler("pull", router.handle_pull, filters=allowed_private)) app.add_handler(CommandHandler("push", router.handle_push, filters=allowed_private)) + app.add_handler(CommandHandler("log", router.handle_log, filters=allowed_private)) + app.add_handler(CommandHandler("reset", router.handle_reset, filters=allowed_private)) app.add_handler(CommandHandler("abort", router.handle_abort, filters=allowed_private)) app.add_handler( CallbackQueryHandler( @@ -168,16 +176,26 @@ async def log_incoming_private_message(update, _context) -> None: block=False, ) ) + app.add_handler(CallbackQueryHandler(router.handle_model_callback, pattern=r"^model:(default|set:\d+)$", block=False)) app.add_handler(CallbackQueryHandler(router.handle_queue_batch_callback, pattern=r"^queuebatch:(group|single|cancel)$", block=False)) app.add_handler(CallbackQueryHandler(router.handle_queue_continue_callback, pattern=r"^queuecontinue:(yes|no)$", block=False)) + app.add_handler(CallbackQueryHandler(router.handle_agent_reply_option_callback, pattern=r"^agentopt:[0-9a-f]{12}:[0-9]$", block=False)) + app.add_handler(CallbackQueryHandler(router.handle_long_gap_callback, pattern=r"^longgap:(compact|proceed|switch)$", block=False)) app.add_handler(CallbackQueryHandler(router.handle_branch_source_callback, pattern=r"^branchsource:[0-9a-f]{12}$", block=False)) app.add_handler(CallbackQueryHandler(router.handle_branch_discrepancy_callback, pattern=r"^branchdiscrepancy:(stored|current)$", block=False)) - app.add_handler(CallbackQueryHandler(router.handle_commit_generate_callback, pattern=r"^commitgen:(confirm|cancel)$")) - app.add_handler(CallbackQueryHandler(router.handle_commit_execute_callback, pattern=r"^commitexec:(confirm|cancel)$")) - app.add_handler(CallbackQueryHandler(router.handle_diff_callback, pattern=r"^diff(?:show|page):\d+$")) + app.add_handler(CallbackQueryHandler(router.handle_git_branch_discrepancy_callback, pattern=r"^gitbranchdiscrepancy:(stored|current)$", block=False)) + app.add_handler(CallbackQueryHandler(router.handle_commit_generate_callback, pattern=r"^commitgen:(confirm|cancel):[0-9a-f]{12}$")) + app.add_handler(CallbackQueryHandler(router.handle_commit_execute_callback, pattern=r"^commitexec:(confirm|cancel):[0-9a-f]{12}$")) + app.add_handler(CallbackQueryHandler(router.handle_diff_callback, pattern=r"^diff(?:show|page):[0-9a-f]{12}:\d+$")) app.add_handler(CallbackQueryHandler(router.handle_switch_page_callback, pattern=r"^switchpage:\d+$")) - app.add_handler(CallbackQueryHandler(router.handle_pull_callback, pattern=r"^pull:(confirm|cancel)$")) - app.add_handler(CallbackQueryHandler(router.handle_push_callback, pattern=r"^push:(confirm|cancel)$")) + app.add_handler(CallbackQueryHandler(router.handle_pull_callback, pattern=r"^pull:(confirm|cancel):[0-9a-f]{12}$")) + app.add_handler(CallbackQueryHandler(router.handle_push_callback, pattern=r"^push:(confirm|cancel):[0-9a-f]{12}$")) + app.add_handler( + CallbackQueryHandler( + router.handle_reset_callback, + pattern=r"^reset:(?:select:[0-9a-f]{12}:(?:local|origin)-(?:default|current)|(?:confirm|cancel):[0-9a-f]{12})$", + ) + ) app.add_handler(CallbackQueryHandler(router.handle_trust_project_callback, pattern=r"^trustproject:(yes|no):")) app.add_handler(MessageHandler(allowed_private & tg_filters.PHOTO, router.handle_photo, block=False)) app.add_handler(MessageHandler(allowed_private & tg_filters.AUDIO, router.handle_audio, block=False)) diff --git a/src/coding_agent_telegram/claude_health.py b/src/coding_agent_telegram/claude_health.py new file mode 100644 index 0000000..1e8353b --- /dev/null +++ b/src/coding_agent_telegram/claude_health.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from coding_agent_telegram.agent_runner import MultiAgentRunner +from coding_agent_telegram.i18n import translate +from coding_agent_telegram.install_detect import claude_auth_command_hint + +# Mirrors SESSION_PRIMING_PROMPT's intent (read-only, no real work) without +# importing router code into this lower-level module. +_PROBE_PROMPT = "Reply with exactly: ok. Do not make any changes, run any commands, or use any tools." +_AUTH_FAILURE_MARKERS = ("authenticat", "oauth") + + +@dataclass(frozen=True) +class ClaudeHealthResult: + healthy: bool + is_auth_failure: bool + detail: str + + +def is_claude_auth_failure(text: Optional[str]) -> bool: + """Whether a Claude error message matches this failure's signature. + + Shared by the background health-check probe below and by the live + /new-time and message-time failure paths (session_lifecycle_commands.py, + session_runtime.py), so both surfaces agree on what counts as this + specific failure and show the identical fix message. + """ + if not text: + return False + return any(marker in text.lower() for marker in _AUTH_FAILURE_MARKERS) + + +def claude_auth_failure_message(locale: str, detail: Optional[str]) -> str: + """The user-facing guidance text for a Claude auth failure, with the + install-mode-appropriate `claude-auth` command filled in.""" + return translate( + locale, + "health.claude_auth_failed_auth", + detail=(detail or "").strip(), + claude_auth_hint=claude_auth_command_hint(), + ) + + +def check_claude_auth(runner: MultiAgentRunner, scratch_dir: Path) -> ClaudeHealthResult: + """Probe whether Claude can create a session the same way a real /new does. + + `claude auth status` is not a substitute for this: during the 2026-09-14 + incident it reported a valid login the entire time a Claude Code CLI + regression broke authentication for every detached-subprocess session + creation (the pattern this bot uses), because the interactive command + exercises a different auth path than a headless one. This runs the exact + same subprocess pattern `create_session` does, against a throwaway + directory, and applies the same success criteria the session-creation + command router uses (see session_lifecycle_commands.py): a run only + counts as healthy if it both succeeds and returns a session ID. + """ + scratch_dir.mkdir(parents=True, exist_ok=True) + result = runner.create_session("claude", scratch_dir, _PROBE_PROMPT, priming_only=True) + if result.success and result.session_id: + return ClaudeHealthResult(healthy=True, is_auth_failure=False, detail="") + detail = (result.error_message or "Claude did not return a session ID.").strip() + return ClaudeHealthResult(healthy=False, is_auth_failure=is_claude_auth_failure(detail), detail=detail) diff --git a/src/coding_agent_telegram/cli.py b/src/coding_agent_telegram/cli.py index 232ce31..103a6c6 100644 --- a/src/coding_agent_telegram/cli.py +++ b/src/coding_agent_telegram/cli.py @@ -3,22 +3,35 @@ import asyncio import hashlib import logging +import os import sys from pathlib import Path -from typing import Sequence +from typing import Optional, Sequence from coding_agent_telegram.agent_runner import MultiAgentRunner from coding_agent_telegram.bot import build_application, default_bot_commands, initialize_bot_commands +from coding_agent_telegram.claude_health import check_claude_auth from coding_agent_telegram.command_router import CommandRouter, RouterDeps -from coding_agent_telegram.config import create_initial_env_file, load_config, resolve_env_file_path +from coding_agent_telegram.config import ( + AppConfig, + create_initial_env_file, + load_config, + read_env_value, + remove_env_value, + resolve_env_file_path, + upsert_env_value, +) from coding_agent_telegram.i18n import translate from coding_agent_telegram.logging_utils import setup_logging +from coding_agent_telegram.polling_heartbeat import touch_heartbeat from coding_agent_telegram.session_store import SessionStore from coding_agent_telegram.stt_setup import ensure_stt_runtime_or_exit, offer_stt_install_for_new_env +from coding_agent_telegram.usage_status import configure_persistence logger = logging.getLogger(__name__) BOT_ID_HASH_PREFIX_LENGTH = 12 +POLLING_HEARTBEAT_INTERVAL_SECONDS = 60.0 def _ensure_env_file() -> tuple[Path, str | None]: @@ -47,8 +60,29 @@ def _env_locale_for_messages(env_path: Path) -> str: return "en" -async def _run_polling_apps(apps: Sequence) -> None: +async def _poll_heartbeat_loop(app, bot_label: str, heartbeat_file: Path) -> None: + """Periodically pings Telegram and touches the heartbeat file on success. + + python-telegram-bot's own get_updates loop gives no external hook to observe + success/failure, so this issues its own lightweight, independent call on the + same event loop -- if the loop or the HTTP stack is wedged, this stops + touching the file too, which is exactly the staleness supervise.sh watches + for (it owns the decision to restart; this only reports liveness). + """ + touch_heartbeat(heartbeat_file) + while True: + await asyncio.sleep(POLLING_HEARTBEAT_INTERVAL_SECONDS) + try: + await app.bot.get_me() + except Exception: + logger.warning("Heartbeat check failed for @%s; will retry.", bot_label, exc_info=True) + continue + touch_heartbeat(heartbeat_file) + + +async def _run_polling_apps(apps: Sequence, heartbeat_file: Path) -> None: started_apps = [] + heartbeat_tasks = [] try: for app in apps: await app.initialize() @@ -79,10 +113,15 @@ async def _run_polling_apps(apps: Sequence) -> None: await app.updater.start_polling() logger.info("Started polling for @%s", me.username or "unknown") started_apps.append(app) + heartbeat_tasks.append( + asyncio.create_task(_poll_heartbeat_loop(app, me.username or "unknown", heartbeat_file)) + ) logger.info("Started %d Telegram bot(s).", len(started_apps)) await asyncio.Event().wait() finally: + for task in heartbeat_tasks: + task.cancel() for app in reversed(started_apps): if app.updater is not None: await app.updater.stop() @@ -92,6 +131,7 @@ async def _run_polling_apps(apps: Sequence) -> None: async def _run(cfg, store: SessionStore, runner: MultiAgentRunner) -> None: apps = [] + heartbeat_file = cfg.app_internal_root / "polling.heartbeat" for token in cfg.telegram_bot_tokens: router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id=_bot_id_from_token(token))) app = build_application(token, router, allowed_chat_ids=cfg.allowed_chat_ids) @@ -101,10 +141,148 @@ async def _run(cfg, store: SessionStore, runner: MultiAgentRunner) -> None: app.bot_data["max_telegram_message_length"] = cfg.max_telegram_message_length apps.append(app) - await _run_polling_apps(apps) + await _run_polling_apps(apps, heartbeat_file) + + +def _build_runner(cfg: AppConfig, *, claude_model: Optional[str] = None) -> MultiAgentRunner: + return MultiAgentRunner( + codex_bin=cfg.codex_bin, + copilot_bin=cfg.copilot_bin, + approval_policy=cfg.codex_approval_policy, + sandbox_mode=cfg.codex_sandbox_mode, + codex_model=cfg.codex_model, + copilot_model=cfg.copilot_model, + copilot_autopilot=cfg.copilot_autopilot, + copilot_no_ask_user=cfg.copilot_no_ask_user, + copilot_allow_all=cfg.copilot_allow_all, + copilot_allow_all_tools=cfg.copilot_allow_all_tools, + copilot_allow_tools=cfg.copilot_allow_tools, + copilot_deny_tools=cfg.copilot_deny_tools, + copilot_available_tools=cfg.copilot_available_tools, + claude_bin=cfg.claude_bin, + claude_model=claude_model if claude_model is not None else cfg.claude_model, + claude_permission_mode=cfg.claude_permission_mode, + claude_allowed_tools=cfg.claude_allowed_tools, + claude_disallowed_tools=cfg.claude_disallowed_tools, + hard_timeout_seconds=cfg.agent_hard_timeout_seconds, + ) + + +CLAUDE_AUTH_SUBCOMMAND = "claude-auth" +# Deliberately not cfg.claude_model: an operator may have that set to a +# premium/expensive alias (e.g. "fable" or "opus"), and this check only needs +# a trivial reply -- Haiku is the cheapest current tier and is enough to prove +# auth works end-to-end. +CLAUDE_AUTH_VERIFY_MODEL = "haiku" + + +def _prompt_yes_no(prompt: str, *, default: bool = True) -> bool: + suffix = "[Y/n]" if default else "[y/N]" + try: + answer = input(f"{prompt} {suffix} ").strip().lower() + except EOFError: + return default + if not answer: + return default + return answer in {"y", "yes"} + + +def _run_claude_auth_subcommand(argv: list[str]) -> None: + """Handles `coding-agent-telegram claude-auth ` (and the equivalent + `./startup.sh claude-auth `): saves a `claude setup-token` result as + CLAUDE_CODE_OAUTH_TOKEN, optionally verifying it immediately so the operator + gets a clear pass/fail instead of a blind restart-and-hope. Verification is + opt-in and clearly costed up front since it's a real, billable API call + (see check_claude_auth's docstring for why a cheap `claude auth status`-style + check isn't a substitute). + """ + if len(argv) != 1 or not argv[0].strip(): + print(f"Usage: coding-agent-telegram {CLAUDE_AUTH_SUBCOMMAND} ", file=sys.stderr) + print("Get by running: claude setup-token", file=sys.stderr) + print( + "Approve access in the browser it opens, then copy the token it prints in the TERMINAL", + file=sys.stderr, + ) + print( + "afterwards (starts with sk-ant-oat01-) -- not anything shown on the browser page itself.", + file=sys.stderr, + ) + raise SystemExit(2) + token = argv[0].strip() + + if not token.startswith("sk-ant-"): + print( + "Warning: that doesn't look like a Claude token (expected it to start with sk-ant-oat01-).", + file=sys.stderr, + ) + print( + "Make sure you copied the token `claude setup-token` printed in the TERMINAL after you", + file=sys.stderr, + ) + print( + "approved access in the browser -- not anything shown on the browser page itself.", + file=sys.stderr, + ) + print("Saving and verifying it anyway in case this is a valid but unexpected format...", file=sys.stderr) + + env_path, _ = _ensure_env_file() + previous_token = read_env_value(env_path, "CLAUDE_CODE_OAUTH_TOKEN") + + should_verify = _prompt_yes_no( + f"Verify this token now? This sends one small request to Claude (using '{CLAUDE_AUTH_VERIFY_MODEL}', " + "the cheapest model, regardless of your configured default) to confirm it actually authenticates -- " + "a small amount of real API usage, typically a fraction of a cent.", + default=True, + ) + + upsert_env_value( + env_path, + "CLAUDE_CODE_OAUTH_TOKEN", + token, + comments=[ + "# Long-lived token from `claude setup-token`, used instead of the interactive", + "# OAuth session for headless Claude session creation.", + ], + ) + print(f"Saved CLAUDE_CODE_OAUTH_TOKEN to {env_path}.") + + if not should_verify: + print("Skipped verification. Restart the bot for the token to take effect.") + return + + try: + cfg = load_config(env_path) + except ValueError as exc: + print(f"Saved, but could not load the config to verify it: {exc}", file=sys.stderr) + print("Restart the bot for the token to take effect.") + return + + os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = token + print(f"Verifying with {CLAUDE_AUTH_VERIFY_MODEL}...") + verify_runner = _build_runner(cfg, claude_model=CLAUDE_AUTH_VERIFY_MODEL) + result = check_claude_auth(verify_runner, cfg.app_internal_root / "claude_health_check") + if result.healthy: + print("Verified: Claude can authenticate headless sessions now.") + print("Restart the bot for the running process to pick it up.") + return + + print(f"Verification failed: {result.detail}", file=sys.stderr) + if previous_token is not None: + upsert_env_value(env_path, "CLAUDE_CODE_OAUTH_TOKEN", previous_token) + print("Rolled back CLAUDE_CODE_OAUTH_TOKEN to its previous value.", file=sys.stderr) + else: + remove_env_value(env_path, "CLAUDE_CODE_OAUTH_TOKEN") + print("Removed the unverified token (no CLAUDE_CODE_OAUTH_TOKEN was set before).", file=sys.stderr) + print("Double-check the token, or see the README's Claude Auth Troubleshooting section.", file=sys.stderr) + raise SystemExit(1) def main() -> None: + argv = sys.argv[1:] + if argv and argv[0] == CLAUDE_AUTH_SUBCOMMAND: + _run_claude_auth_subcommand(argv[1:]) + return + env_path, created_locale = _ensure_env_file() if created_locale is not None: print( @@ -153,6 +331,10 @@ def main() -> None: raise store = SessionStore(cfg.state_file, cfg.state_backup_file) + # Its own small file, deliberately separate from state.json -- see the + # module-level comment in usage_status.py for why (write frequency and + # lock-contention mismatch with session bookkeeping). + configure_persistence(cfg.state_file.parent / "claude_rate_limit.json") cleared_pending_actions = store.clear_all_pending_actions() if cleared_pending_actions: logger.warning( @@ -161,28 +343,12 @@ def main() -> None: cleared_pending_actions, "y" if cleared_pending_actions == 1 else "ies", ) - runner = MultiAgentRunner( - codex_bin=cfg.codex_bin, - copilot_bin=cfg.copilot_bin, - approval_policy=cfg.codex_approval_policy, - sandbox_mode=cfg.codex_sandbox_mode, - codex_model=cfg.codex_model, - copilot_model=cfg.copilot_model, - copilot_autopilot=cfg.copilot_autopilot, - copilot_no_ask_user=cfg.copilot_no_ask_user, - copilot_allow_all=cfg.copilot_allow_all, - copilot_allow_all_tools=cfg.copilot_allow_all_tools, - copilot_allow_tools=cfg.copilot_allow_tools, - copilot_deny_tools=cfg.copilot_deny_tools, - copilot_available_tools=cfg.copilot_available_tools, - claude_bin=cfg.claude_bin, - claude_model=cfg.claude_model, - claude_permission_mode=cfg.claude_permission_mode, - claude_allowed_tools=cfg.claude_allowed_tools, - claude_disallowed_tools=cfg.claude_disallowed_tools, - hard_timeout_seconds=cfg.agent_hard_timeout_seconds, - ) + runner = _build_runner(cfg) try: asyncio.run(_run(cfg, store, runner)) except KeyboardInterrupt: logger.info("Stopping Telegram bot polling.") + + +if __name__ == "__main__": + main() diff --git a/src/coding_agent_telegram/config.py b/src/coding_agent_telegram/config.py index aee9bf8..f0616da 100644 --- a/src/coding_agent_telegram/config.py +++ b/src/coding_agent_telegram/config.py @@ -14,6 +14,7 @@ from dotenv import load_dotenv from coding_agent_telegram.i18n import DEFAULT_LOCALE, normalize_locale +from coding_agent_telegram.models import DEFAULT_MODEL_CHOICES from coding_agent_telegram.providers import SUPPORTED_PROVIDERS DEFAULT_SNAPSHOT_TEXT_FILE_MAX_BYTES = 200_000 @@ -25,6 +26,30 @@ DEFAULT_AGENT_HARD_TIMEOUT_SECONDS = 0 DEFAULT_OPENAI_WHISPER_MODEL = "base" DEFAULT_OPENAI_WHISPER_TIMEOUT_SECONDS = 120 +# How long a session can sit idle before resuming it risks a costly prompt-cache +# miss (see README FAQ: "does this app burn more tokens than the terminal?"). This is +# only the idle-time half of the check -- session_gap.py also reports accumulated +# session size where a provider exposes one, and small/cheap sessions are gated out so +# they don't nag even past this threshold (see _LONG_GAP_PROVIDER_CONFIG in +# router/message_commands.py). +# +# Claude: no official idle-based cache-expiry number is published, but the extended +# prompt-cache checkpoint was empirically confirmed (against real session transcripts) +# to hold for about an hour before a full-context reprocess kicks in. +DEFAULT_CLAUDE_LONG_GAP_SECONDS = 3600 +# Codex: OpenAI doesn't document an idle-based cache-expiry number either, and its API +# prompt cache is generally shorter-lived than Claude's extended checkpoint anyway -- +# by the time either 30 or 60 minutes of idle has passed, the cache is almost +# certainly gone regardless, so there's no accuracy cost to picking the larger number. +# Matches Claude's threshold for a simpler mental model, now that the size gate above +# already filters out small sessions that wouldn't be worth nagging about anyway. +DEFAULT_CODEX_LONG_GAP_SECONDS = 3600 +# Copilot: GitHub's docs state Copilot CLI has no inactivity timeout at all, and it +# already auto-compacts its own context (around ~80-95% usage) without any idle +# involvement. There's nothing analogous to warn about here, so this defaults to +# disabled (0) rather than inventing an idle-cache-expiry assumption that doesn't +# apply to this provider. Set a positive value to opt into an idle-based nudge anyway. +DEFAULT_COPILOT_LONG_GAP_SECONDS = 0 @dataclass(frozen=True) @@ -42,6 +67,9 @@ class AppConfig: codex_model: str copilot_model: str claude_model: str + codex_model_choices: tuple[str, ...] + copilot_model_choices: tuple[str, ...] + claude_model_choices: tuple[str, ...] copilot_autopilot: bool copilot_no_ask_user: bool copilot_allow_all: bool @@ -66,6 +94,10 @@ class AppConfig: default_agent_provider: str agent_hard_timeout_seconds: int app_internal_root: Path + long_gap_warning_enabled: bool + claude_long_gap_seconds: int + codex_long_gap_seconds: int + copilot_long_gap_seconds: int locale: str = DEFAULT_LOCALE @@ -82,6 +114,14 @@ def _parse_csv_env(name: str) -> list[str]: return [item.strip() for item in raw.split(",") if item.strip()] +def _parse_model_choices_env(name: str, default: tuple[str, ...]) -> tuple[str, ...]: + """Like ``_parse_csv_env``, but an unset var falls back to ``default`` while an + explicitly empty one (``NAME=``) is honored as "no curated choices".""" + if os.getenv(name) is None: + return default + return tuple(_parse_csv_env(name)) + + def _parse_allowed_chat_ids() -> set[int]: values = _parse_csv_env("ALLOWED_CHAT_IDS") @@ -203,6 +243,52 @@ def create_initial_env_file(env_path: Path, template_path: Optional[Path] = None return app_locale +def upsert_env_value( + env_path: Path, + key: str, + value: str, + *, + comments: Optional[list[str]] = None, +) -> None: + """Insert or overwrite a single ``KEY=value`` line in an env file in place, + preserving the rest of the file. Creates the file's parent dir if needed. + """ + env_path.parent.mkdir(parents=True, exist_ok=True) + lines = env_path.read_text(encoding="utf-8").splitlines() if env_path.exists() else [] + replacement = f"{key}={value}" + for index, line in enumerate(lines): + if line.startswith(f"{key}="): + lines[index] = replacement + env_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return + if lines and lines[-1].strip(): + lines.append("") + if comments: + lines.extend(comments) + lines.append(replacement) + env_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def read_env_value(env_path: Path, key: str) -> Optional[str]: + """Return the current value of ``key`` in an env file, or None if unset.""" + if not env_path.exists(): + return None + prefix = f"{key}=" + for line in env_path.read_text(encoding="utf-8").splitlines(): + if line.startswith(prefix): + return line[len(prefix):] + return None + + +def remove_env_value(env_path: Path, key: str) -> None: + """Remove the ``key=...`` line from an env file in place, if present.""" + if not env_path.exists(): + return + prefix = f"{key}=" + lines = [line for line in env_path.read_text(encoding="utf-8").splitlines() if not line.startswith(prefix)] + env_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + def resolve_env_file_path(env_file: Optional[Path] = None) -> Path: if env_file is not None: return env_file @@ -280,6 +366,9 @@ def load_config(env_file: Optional[Path] = None) -> AppConfig: codex_model=os.getenv("CODEX_MODEL", "").strip(), copilot_model=os.getenv("COPILOT_MODEL", "").strip(), claude_model=os.getenv("CLAUDE_MODEL", "").strip(), + codex_model_choices=_parse_model_choices_env("CODEX_MODEL_CHOICES", DEFAULT_MODEL_CHOICES["codex"]), + copilot_model_choices=_parse_model_choices_env("COPILOT_MODEL_CHOICES", DEFAULT_MODEL_CHOICES["copilot"]), + claude_model_choices=_parse_model_choices_env("CLAUDE_MODEL_CHOICES", DEFAULT_MODEL_CHOICES["claude"]), copilot_autopilot=_parse_bool(os.getenv("COPILOT_AUTOPILOT", "true"), default=True), copilot_no_ask_user=_parse_bool(os.getenv("COPILOT_NO_ASK_USER", "true"), default=True), copilot_allow_all=_parse_bool(os.getenv("COPILOT_ALLOW_ALL", "true"), default=True), @@ -316,5 +405,15 @@ def load_config(env_file: Optional[Path] = None) -> AppConfig: os.getenv("AGENT_HARD_TIMEOUT_SECONDS", str(DEFAULT_AGENT_HARD_TIMEOUT_SECONDS)) ), app_internal_root=app_internal_root, + long_gap_warning_enabled=_parse_bool(os.getenv("LONG_GAP_WARNING_ENABLED", "true"), default=True), + claude_long_gap_seconds=int( + os.getenv("CLAUDE_LONG_GAP_SECONDS", str(DEFAULT_CLAUDE_LONG_GAP_SECONDS)) + ), + codex_long_gap_seconds=int( + os.getenv("CODEX_LONG_GAP_SECONDS", str(DEFAULT_CODEX_LONG_GAP_SECONDS)) + ), + copilot_long_gap_seconds=int( + os.getenv("COPILOT_LONG_GAP_SECONDS", str(DEFAULT_COPILOT_LONG_GAP_SECONDS)) + ), locale=locale, ) diff --git a/src/coding_agent_telegram/diff_utils.py b/src/coding_agent_telegram/diff_utils.py index d8a1113..d9cac8a 100644 --- a/src/coding_agent_telegram/diff_utils.py +++ b/src/coding_agent_telegram/diff_utils.py @@ -210,21 +210,33 @@ def _git(project_path: Path, args: list[str]) -> str: return proc.stdout -def _parse_status_paths(output: str) -> list[str]: - paths: list[str] = [] - for line in output.splitlines(): - if len(line) < 4: +def _parse_status_entries(output: str) -> list[tuple[str, str]]: + """Parse ``git status --porcelain=v1 -z`` without losing path characters.""" + records = output.split("\0") + entries: list[tuple[str, str]] = [] + index = 0 + while index < len(records): + record = records[index] + index += 1 + if len(record) < 4: continue - path = line[3:].strip() - if " -> " in path: - path = path.split(" -> ", 1)[1].strip() + status = record[:2] + path = record[3:] + if "R" in status or "C" in status: + # In -z mode the destination is in this record and the source path + # follows as a second NUL-terminated record. + index += 1 if path: - paths.append(path) - return paths + entries.append((status, path)) + return entries + + +def _parse_status_paths(output: str) -> list[str]: + return [path for _status, path in _parse_status_entries(output)] def changed_files(project_path: Path) -> list[str]: - output = _git(project_path, ["status", "--short", "--untracked-files=all"]) + output = _git(project_path, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]) return [ path for path in _parse_status_paths(output) @@ -234,16 +246,10 @@ def changed_files(project_path: Path) -> list[str]: def split_changed_files(project_path: Path) -> tuple[list[str], list[str]]: - output = _git(project_path, ["status", "--short", "--untracked-files=all"]) + output = _git(project_path, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]) tracked: list[str] = [] untracked: list[str] = [] - for line in output.splitlines(): - if len(line) < 4: - continue - status = line[:2] - path = line[3:].strip() - if " -> " in path: - path = path.split(" -> ", 1)[1].strip() + for status, path in _parse_status_entries(output): if ( not path or path.startswith(f"{INTERNAL_APP_DIR}/") diff --git a/src/coding_agent_telegram/install_detect.py b/src/coding_agent_telegram/install_detect.py new file mode 100644 index 0000000..eaec71b --- /dev/null +++ b/src/coding_agent_telegram/install_detect.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Optional + + +def detect_startup_script() -> Optional[Path]: + """Return startup.sh's path when running from a cloned repo checkout + (README Quick Start Option C), or None for a pip / install.sh install + (Options A/B -- both end up as a plain `pip install` with no repo + checkout nearby, so `coding-agent-telegram` is the right command instead). + """ + here = Path(__file__).resolve() + for parent in here.parents: + candidate = parent / "startup.sh" + if candidate.is_file() and (parent / "pyproject.toml").is_file(): + return candidate + return None + + +def claude_auth_command_hint(token_placeholder: str = "") -> str: + """The exact command the operator should run to save a `claude setup-token` + result, matching however this install is actually run.""" + startup_script = detect_startup_script() + if startup_script is not None: + return f"{startup_script} claude-auth {token_placeholder}" + return f"coding-agent-telegram claude-auth {token_placeholder}" diff --git a/src/coding_agent_telegram/models.py b/src/coding_agent_telegram/models.py new file mode 100644 index 0000000..5703c19 --- /dev/null +++ b/src/coding_agent_telegram/models.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +"""Default per-provider model choices offered by the /model command.""" + +from importlib import resources + + +_MODEL_CHOICE_ENV_NAMES = { + "codex": "CODEX_MODEL_CHOICES", + "copilot": "COPILOT_MODEL_CHOICES", + "claude": "CLAUDE_MODEL_CHOICES", +} +_CLAUDE_FALLBACK_MODEL_CHOICES = ("sonnet", "opus", "fable", "haiku") + + +def _parse_template_model_choices(template_text: str) -> dict[str, tuple[str, ...]]: + """Read model-picker defaults from the packaged .env.example template.""" + values = {provider: () for provider in _MODEL_CHOICE_ENV_NAMES} + for line in template_text.splitlines(): + name, separator, raw_value = line.partition("=") + if not separator: + continue + for provider, env_name in _MODEL_CHOICE_ENV_NAMES.items(): + if name != env_name: + continue + values[provider] = tuple(item.strip() for item in raw_value.split(",") if item.strip()) + break + return values + + +def _load_default_model_choices() -> dict[str, tuple[str, ...]]: + try: + template_text = resources.files("coding_agent_telegram").joinpath("resources/.env.example").read_text( + encoding="utf-8" + ) + except (FileNotFoundError, ModuleNotFoundError, OSError): + template_text = "" + + choices = _parse_template_model_choices(template_text) + # Claude's stable aliases remain useful even when a package is distributed + # without its configuration template. + if not choices["claude"]: + choices["claude"] = _CLAUDE_FALLBACK_MODEL_CHOICES + return choices + + +DEFAULT_MODEL_CHOICES = _load_default_model_choices() + + +def model_choices_for(provider: str) -> tuple[str, ...]: + """Return the fallback model choices for a provider identifier.""" + return DEFAULT_MODEL_CHOICES.get(provider, ()) diff --git a/src/coding_agent_telegram/native_codex_sessions.py b/src/coding_agent_telegram/native_codex_sessions.py index 0e459cd..7be18da 100644 --- a/src/coding_agent_telegram/native_codex_sessions.py +++ b/src/coding_agent_telegram/native_codex_sessions.py @@ -2,35 +2,52 @@ import sqlite3 from pathlib import Path +from typing import Optional from coding_agent_telegram.native_session_types import NativeSessionRecord from coding_agent_telegram.native_session_utils import iso_from_unix, normalize_init_text, path_matches_project -def discover_codex_sessions(project_path: Path, project_folder: str) -> list[NativeSessionRecord]: - db_path = Path.home() / ".codex" / "state_5.sqlite" +def codex_state_db_path() -> Path: + return Path.home() / ".codex" / "state_5.sqlite" + + +def query_codex_state_db(sql: str, params: tuple = ()) -> Optional[list[tuple]]: + """Run a read-only query against the local Codex state db. + + Returns None if the db doesn't exist or the query fails, so callers can tell "no + rows" (empty list) apart from "couldn't read the db at all" without each + reimplementing the connect/execute/close boilerplate and its error handling. + """ + db_path = codex_state_db_path() if not db_path.exists(): - return [] + return None try: conn = sqlite3.connect(db_path) except sqlite3.Error: - return [] + return None try: cursor = conn.cursor() - cursor.execute( - """ - SELECT id, cwd, title, first_user_message, git_branch, created_at, updated_at - FROM threads - WHERE archived = 0 - ORDER BY updated_at DESC - """ - ) - rows = cursor.fetchall() + cursor.execute(sql, params) + return cursor.fetchall() except sqlite3.Error: - return [] + return None finally: conn.close() + +def discover_codex_sessions(project_path: Path, project_folder: str) -> list[NativeSessionRecord]: + rows = query_codex_state_db( + """ + SELECT id, cwd, title, first_user_message, git_branch, created_at, updated_at + FROM threads + WHERE archived = 0 + ORDER BY updated_at DESC + """ + ) + if not rows: + return [] + records: list[NativeSessionRecord] = [] for session_id, cwd, title, first_user_message, git_branch, created_at, updated_at in rows: if not path_matches_project(str(cwd or ""), project_path): diff --git a/src/coding_agent_telegram/polling_heartbeat.py b/src/coding_agent_telegram/polling_heartbeat.py new file mode 100644 index 0000000..311bae6 --- /dev/null +++ b/src/coding_agent_telegram/polling_heartbeat.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +"""Writes a heartbeat file that an external supervisor watches. + +python-telegram-bot retries network errors indefinitely on its own (see +``telegram.ext._utils.networkloop``), but only for errors it actually sees +raised inside a still-running event loop -- it has no way to notice the loop +itself wedging (observed in production: the process stayed alive with +established TCP sockets to Telegram, but never issued another request for +12+ hours). Since the process never exits, a restart-on-crash supervisor +never triggers either. + +This module only records liveness; it makes no decision about staleness or +restarting. That decision belongs to the installed command's Python supervisor +(or ``startup.sh`` in a repository checkout), which runs this process as a +child, watches this file's mtime, and restarts the child (after waiting for +DNS to actually work again) if it goes stale. +""" + +from pathlib import Path + + +def touch_heartbeat(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_suffix(path.suffix + ".tmp") + tmp_path.write_text("", encoding="utf-8") + tmp_path.replace(path) diff --git a/src/coding_agent_telegram/resources/.env.example b/src/coding_agent_telegram/resources/.env.example index 408409c..2dc9270 100644 --- a/src/coding_agent_telegram/resources/.env.example +++ b/src/coding_agent_telegram/resources/.env.example @@ -37,6 +37,15 @@ COPILOT_MODEL= # Optional Claude Code model override (e.g. sonnet, opus, haiku). CLAUDE_MODEL= +# Comma-separated model list offered by /model for Codex. +CODEX_MODEL_CHOICES=gpt-5.6-luna,gpt-5.6-terra,gpt-5.6-sol,gpt-6-astra + +# Comma-separated model list offered by /model for Copilot. +COPILOT_MODEL_CHOICES=gpt-5.4,claude-sonnet-4.6 + +# Comma-separated model list offered by /model for Claude Code. +CLAUDE_MODEL_CHOICES=fable,sonnet,opus,haiku + # Whether Copilot should run in autopilot mode. COPILOT_AUTOPILOT=true @@ -134,3 +143,23 @@ DEFAULT_AGENT_PROVIDER=codex # Useful as a safeguard when using Codex for shorter, bounded jobs. # Example: AGENT_HARD_TIMEOUT_SECONDS=600 (10 minutes) AGENT_HARD_TIMEOUT_SECONDS=0 + +# Before resuming a session idle past its threshold below (and only if it has +# accumulated enough context for a reprocess to actually be costly), warn that +# the provider's prompt cache has likely expired and offer to compact first or +# proceed anyway. +LONG_GAP_WARNING_ENABLED=true +# Idle threshold in seconds per provider before the warning fires. +# Claude Code: matches its ~1 hour extended prompt-cache window (real evidence +# from session transcripts backs this one). +# Codex: no official idle-based cache-expiry number is documented by OpenAI, and its +# cache is generally shorter-lived than Claude's anyway, so matching Claude's +# threshold costs nothing in accuracy and is paired with a size gate so small +# sessions don't nag. +# Copilot: 0 (disabled) by default. Copilot CLI has no inactivity timeout and +# already auto-compacts its own context natively (~80-95% usage) per GitHub's +# docs, so there's no idle-based risk to warn about -- defer to that instead +# of assuming an API that doesn't exist. Set a positive value to opt in anyway. +CLAUDE_LONG_GAP_SECONDS=3600 +CODEX_LONG_GAP_SECONDS=3600 +COPILOT_LONG_GAP_SECONDS=0 diff --git a/src/coding_agent_telegram/resources/locales/de.json b/src/coding_agent_telegram/resources/locales/de.json index c0b6dbd..3c49da0 100644 --- a/src/coding_agent_telegram/resources/locales/de.json +++ b/src/coding_agent_telegram/resources/locales/de.json @@ -4,11 +4,14 @@ "bot.command.commit": "Geprüfte Git commit-Befehle ausführen", "bot.command.current": "Aktive Sitzung anzeigen", "bot.command.diff": "Geänderte Dateinamen gegenüber HEAD anzeigen", + "bot.command.model": "Modell für die aktive Sitzung wählen", "bot.command.new": "Neue Sitzung erstellen", - "bot.command.pull": "Die aktuelle Sitzungs-Zweig pullen", + "bot.command.log": "Die letzten 5 Git-Commits anzeigen", + "bot.command.pull": "Git: Die aktuelle Sitzungs-Zweig pullen", "bot.command.project": "Aktuellen Projektordner festlegen", "bot.command.provider": "Anbieter für neue Sitzungen wählen", - "bot.command.push": "Die aktuelle Sitzungs-Zweig pushen", + "bot.command.push": "Git: Die aktuelle Sitzungs-Zweig pushen", + "bot.command.reset": "Git reset --hard auf einen Zweig", "bot.command.switch": "Sitzungen auflisten oder wechseln", "bot.error.command_failed": "⚠️ Befehl fehlgeschlagen. Bitte das Server-Log prüfen.", "bot.error.session_store": "⚠️ {error}", @@ -20,6 +23,10 @@ "common.project_folder_missing": "⚠️ Der Projektordner für diese Sitzung existiert nicht mehr: {project_folder}", "common.button_expired": "⚠️ Diese Schaltfläche ist abgelaufen. Bitte führe den Befehl erneut aus.", "git.branch_unknown": "⚠️ Der Zweig der aktuellen Sitzung konnte nicht ermittelt werden.", + "git.branch_discrepancy_warning": "⚠️ Zweigabweichung erkannt. Der Zweig der aktiven Sitzung ist `{session_branch}`, im Repository ist jedoch `{checked_out_branch}` ausgecheckt. Wechsle vor diesem Befehl zum gewünschten Zweig.", + "git.branch_discrepancy_switch_to_session": "Zu {branch_name} wechseln", + "git.branch_discrepancy_use_current": "Sitzungszweig zu {branch_name} ändern", + "git.detached_head_label": "losgelöster HEAD", "git.cancel_button": "Abbrechen", "git.usage_diff": "Verwendung: /diff", "git.usage_pull": "Verwendung: /pull", @@ -30,13 +37,22 @@ "git.pull_confirm_prompt_with_default": "Zweig `{branch_name}` von `origin` pullen und zusätzlich die Standard-Zweig `{default_branch}` aktualisieren?", "git.pull_in_progress": "Zweig `{branch_name}` wird von `origin` gepullt...", "git.pull_in_progress_with_default": "Zweig `{branch_name}` wird von `origin` gepullt und die Standard-Zweig `{default_branch}` wird aktualisiert...", + "git.reset_cancelled": "Git reset abgebrochen.", + "git.reset_confirm_button": "Reset bestätigen", + "git.reset_confirm_prompt": "Den aktuellen Zweig mit `git reset --hard {target_ref}` zurücksetzen?", + "git.reset_in_progress": "`git reset --hard {target_ref}` wird ausgeführt...", + "git.reset_pull_in_progress": "`{target_ref}` wird vor dem Reset gepullt...", + "git.reset_select_prompt": "Wähle den Zweig, auf den zurückgesetzt werden soll:", "git.push_cancelled": "Push abgebrochen.", "git.push_cancelled_checkout_failed": "Push abgebrochen. Wechsel zu `{branch_name}` ist zuerst fehlgeschlagen.", "git.push_confirm_button": "Push bestätigen", "git.push_confirm_prompt": "Zweig `{branch_name}` nach `origin` pushen?", "git.push_in_progress": "Zweig `{branch_name}` wird nach `origin` gepusht...", "git.usage_push": "Verwendung: /push", + "git.usage_log": "Verwendung: /log", + "git.usage_reset": "Verwendung: /reset", "message.photo_only_codex": "Fotoanhänge werden derzeit nur für Codex-Sitzungen unterstützt.", + "message.photo_blocked_by_pending_action": "Eine Aktion aus einer früheren Nachricht wartet noch auf deine Antwort (z. B. eine Compact/Fortsetzen-Abfrage). Löse das bitte zuerst, dann sende dieses Foto erneut.", "message.question_queued": "Frage als Q{question_number} in die Warteschlange gestellt. Sie wird verarbeitet, sobald die aktuelle Agent-Aufgabe abgeschlossen ist.", "message.voice_speech_to_text_disabled": "Sprachnachrichten sind nicht aktiviert.\nSetze ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true und installiere zuerst die lokalen Whisper-Voraussetzungen.", "message.unsupported_message_type": "Nicht unterstützter Nachrichtentyp.\nDieser Bot akzeptiert derzeit Textnachrichten, Fotos, Sprachnachrichten und Audiodateien.", @@ -61,6 +77,7 @@ "runtime.agent_run_failed": "Der Agent-Lauf ist fehlgeschlagen.", "runtime.live_agent_output": "Live-Ausgabe des Agenten", "runtime.photo_too_large": "Das Foto ist zu groß. Die maximal unterstützte Größe beträgt 5 MB.", + "runtime.photo_album_too_many": "Zu viele Fotos. Ein einzelnes Album darf höchstens {limit} Bilder enthalten.", "runtime.voice_audio_too_large": "Die Audiodatei ist zu groß für die lokale Spracherkennung. Die maximal unterstützte Größe beträgt {max_size_mb} MB.", "runtime.provider_output_index": "{provider}-Ausgabe {index}/{total}", "runtime.provider_output_single": "{provider}-Ausgabe", @@ -97,6 +114,8 @@ "switch.imported_into_state_json": "In state.json importiert.", "switch.status_active": "aktiv", "switch.status_idle": "inaktiv", + "switch.last_active_ago": "Zuletzt aktiv: vor {duration}", + "switch.tokens_used": "~{tokens} Tokens verwendet", "bootstrap.env_created_locale_line": "{env_path} wurde erstellt. APP_LOCALE wurde entsprechend der Systemsprache auf {app_locale} gesetzt.", "bootstrap.env_created_change_line": "Wenn du später eine andere Sprache verwenden möchtest, ändere APP_LOCALE in {env_path}.", "queue.button_cancel": "Abbrechen", @@ -162,6 +181,7 @@ "lifecycle.creating_session": "Sitzung wird erstellt...", "lifecycle.failed_create_session": "Sitzung konnte nicht erstellt werden.", "lifecycle.session_created_successfully": "Sitzung erfolgreich erstellt: {session_name}\nSitzung-ID: {session_id}\nProjekt: {project_folder}\nAnbieter: {provider}\nZweig: {branch_name}", + "health.claude_auth_failed_auth": "Claude konnte diese Sitzung nicht authentifizieren, obwohl dein eigener Claude-Login in Ordnung ist ({detail}).\n\nSo behebst du das auf dem Host, auf dem dieser Bot läuft:\n1. Führe aus: claude setup-token\n2. Genehmige den Zugriff im geöffneten Browser, kehre dann zum Terminal zurück -- dort wird das eigentliche Token ausgegeben (beginnt mit sk-ant-oat01-), nicht im Browser. Kopiere es und führe dann aus:\n{claude_auth_hint}\n(Dieser Befehl speichert und überprüft es für dich.)\n3. Starte den Bot neu.\n\nWeitere Optionen findest du im README-Abschnitt „FAQ / Fehlerbehebung“.", "provider.cli_not_found": "{provider_label} CLI nicht gefunden: {bin_name}\nFühre /provider aus, um einen verfügbaren Anbieter zu wählen oder die Bot-Konfiguration zu aktualisieren.", "provider.status_available": "verfügbar", "provider.status_missing": "fehlt", @@ -171,6 +191,20 @@ "provider.not_selected": "(nicht ausgewählt)", "provider.cli_not_found_install_first": "{provider_label} CLI nicht gefunden: {bin_name}\nAktualisiere die Bot-Konfiguration oder installiere zuerst diese CLI.", "provider.current_provider_set": "Aktueller Anbieter gesetzt auf: {provider}", + "model.usage_model": "Verwendung: /model oder /model ", + "model.current_model_prompt": "Aktuelles Modell: {model}\nWähle das Modell für die aktive Sitzung, oder gib /model ein, um ein hier nicht aufgeführtes Modell zu verwenden. Neue Sitzungen starten immer mit dem Standardmodell.", + "model.current_model_prompt_custom": "Aktuelles Modell: {model} (benutzerdefiniert, nicht in der folgenden Liste)\nWähle ein Modell für die aktive Sitzung, oder gib /model ein, um ein anderes benutzerdefiniertes Modell zu verwenden. Neue Sitzungen starten immer mit dem Standardmodell.", + "model.current_model_set": "Modell gesetzt auf: {model}", + "model.default_option": "Standard", + "model.status_current": "aktuell", + "model.cli_default": "CLI-Standard", + "model.validating": "Es wird geprüft, ob '{model}' ein gültiges Modell für diesen Anbieter ist…", + "model.invalid_model": "⚠️ '{model}' wurde von der CLI abgelehnt und daher nicht gespeichert:\n{error}", + "model.invalid_model_generic": "Die CLI hat dieses Modell ohne weitere Angaben abgelehnt.", + "model.stale_selection": "⚠️ Diese Schaltfläche ist nicht mehr gültig (die Modellliste oder der Anbieter hat sich möglicherweise geändert). Führe /model erneut aus.", + "runtime.reply_options_prompt": "⚡ Aktion erforderlich — wählen Sie eine Option, um fortzufahren:", + "runtime.reply_option_select_button": "✅ Diese Option auswählen", + "runtime.reply_option_selected": "▶️ Weiter mit: {choice}", "status.current_session_details": "Aktuelle Sitzung: {session_name}\nSitzung-ID: {session_id}\nProjekt: {project_folder}\nAnbieter: {provider}\nZweig: {branch_name}", "status.current_branch_placeholder": "(aktuelle Zweig)", "git.commit_disabled": "/commit ist deaktiviert.\nSetze ENABLE_COMMIT_COMMAND=true in der Bot-Umgebung, um ihn zu aktivieren.", @@ -208,5 +242,24 @@ "status.usage_compact": "Verwendung: /compact", "runtime.compacting_session": "Aktive Sitzung wird komprimiert...", "runtime.compact_summary_missing": "Der Anbieter hat keine verwendbare kompakte Übergabezusammenfassung zurückgegeben.", - "runtime.session_compacted": "Sitzung erfolgreich komprimiert. Zu {session_name} ({session_id}) gewechselt." + "runtime.session_compacted": "Sitzung erfolgreich komprimiert. Zu {session_name} ({session_id}) gewechselt.", + "runtime.switching_session": "Neue Sitzung wird gestartet...", + "runtime.session_switched": "Neue Sitzung gestartet. Zu {session_name} ({session_id}) gewechselt.", + "runtime.long_gap_warning": "⏳ Diese Sitzung war {gap} im Leerlauf. Sie jetzt fortzusetzen, verarbeitet die gesamte Unterhaltung wahrscheinlich komplett neu (der Antwort-Cache des Anbieters ist vermutlich abgelaufen), was deutlich mehr Tokens als üblich verbrauchen kann. Auch das Komprimieren verarbeitet den aktuellen Kontext einmal neu, um die Zusammenfassung zu erstellen, und kann daher ebenfalls viele Tokens kosten, wenn diese Sitzung bereits groß ist. Zu einer neuen Sitzung zu wechseln, umgeht diese Neuverarbeitung vollständig, startet dann aber ohne jede Erinnerung an diese Unterhaltung. Zu einer neuen Sitzung wechseln, erst komprimieren oder trotzdem fortsetzen?", + "runtime.long_gap_switch_button": "🆕 Zu neuer Sitzung wechseln", + "runtime.long_gap_compact_button": "🔄 Erst komprimieren", + "runtime.long_gap_proceed_button": "⚠️ Trotzdem fortsetzen", + "runtime.long_gap_proceeding": "Setze mit der bestehenden Sitzung fort...", + "runtime.long_gap_compacting": "Komprimiere, bevor es weitergeht...", + "runtime.long_gap_switching": "Wechsle zu einer neuen Sitzung...", + "bot.command.status": "Zeigt die Provider-Kontingentnutzung (5-Stunden/wöchentlich)", + "status.usage_status": "Verwendung: /status", + "status.usage_title": "Provider-Nutzung:", + "status.usage_five_hour": "5-Stunden-Nutzung", + "status.usage_weekly": "Wöchentliche Nutzung", + "status.usage_unknown": "unbekannt", + "status.usage_na": "N/A ({detail})", + "status.usage_resets_in": "setzt zurück in {duration}", + "status.usage_unavailable": "Nicht verfügbar: {detail}", + "status.usage_last_observed": "(zuletzt beobachtet vor {duration})" } diff --git a/src/coding_agent_telegram/resources/locales/en.json b/src/coding_agent_telegram/resources/locales/en.json index 8dfd74e..2465183 100644 --- a/src/coding_agent_telegram/resources/locales/en.json +++ b/src/coding_agent_telegram/resources/locales/en.json @@ -4,11 +4,14 @@ "bot.command.commit": "Run validated Git commit commands", "bot.command.current": "Show the active session", "bot.command.diff": "Show changed filenames vs HEAD", + "bot.command.model": "Choose the model for the active session", "bot.command.new": "Create a new session", - "bot.command.pull": "Pull the current session branch", + "bot.command.log": "Show the top 5 Git commits", + "bot.command.pull": "Git pull the current session branch", "bot.command.project": "Set the current project folder", "bot.command.provider": "Choose the provider for new sessions", - "bot.command.push": "Push the current session branch", + "bot.command.push": "Git push the current session branch", + "bot.command.reset": "Git reset --hard to a branch", "bot.command.switch": "List sessions or switch to one", "bot.error.command_failed": "⚠️ Command failed. Check the server log for details.", "bot.error.session_store": "⚠️ {error}", @@ -20,6 +23,10 @@ "common.project_busy": "An agent is currently running on project '{project_folder}'.\nOnly /current and /abort are supported until it finishes.", "common.project_folder_missing": "⚠️ Project folder no longer exists for this session: {project_folder}", "git.branch_unknown": "⚠️ Could not determine the branch for the current session.", + "git.branch_discrepancy_warning": "⚠️ Branch discrepancy detected. The active session branch is `{session_branch}`, but the repository currently has `{checked_out_branch}` checked out. Switch to the intended branch before running this command.", + "git.branch_discrepancy_switch_to_session": "Switch to {branch_name}", + "git.branch_discrepancy_use_current": "Change session branch to {branch_name}", + "git.detached_head_label": "detached HEAD", "git.cancel_button": "Cancel", "git.usage_diff": "Usage: /diff", "git.pull_cancelled": "Pull cancelled.", @@ -29,6 +36,12 @@ "git.pull_confirm_prompt_with_default": "Pull branch `{branch_name}` from `origin` and also refresh default branch `{default_branch}`?", "git.pull_in_progress": "Pulling branch `{branch_name}` from `origin`...", "git.pull_in_progress_with_default": "Pulling branch `{branch_name}` from `origin` and refreshing default branch `{default_branch}`...", + "git.reset_cancelled": "Git reset cancelled.", + "git.reset_confirm_button": "Confirm reset", + "git.reset_confirm_prompt": "Reset the current branch with `git reset --hard {target_ref}`?", + "git.reset_in_progress": "Running `git reset --hard {target_ref}`...", + "git.reset_pull_in_progress": "Pulling `{target_ref}` before reset...", + "git.reset_select_prompt": "Select the branch to reset to:", "git.push_cancelled": "Push cancelled.", "git.push_cancelled_checkout_failed": "Push cancelled. Failed to switch to `{branch_name}` first.", "git.push_confirm_button": "Confirm push", @@ -36,7 +49,10 @@ "git.push_in_progress": "Pushing branch `{branch_name}` to `origin`...", "git.usage_pull": "Usage: /pull", "git.usage_push": "Usage: /push", + "git.usage_log": "Usage: /log", + "git.usage_reset": "Usage: /reset", "message.photo_only_codex": "Photo attachments are currently supported only for Codex and Claude sessions.", + "message.photo_blocked_by_pending_action": "An action from an earlier message is still waiting for your response (e.g. a compact/proceed prompt). Please resolve that first, then resend this photo.", "message.question_queued": "Question queued as Q{question_number}. It will run after the current agent task finishes.", "message.voice_speech_to_text_disabled": "Voice messages are not enabled.\nSet ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true and install the local Whisper prerequisites first.", "message.unsupported_message_type": "Unsupported message type.\nThis bot currently accepts text messages, photos, voice messages, and audio files.", @@ -63,9 +79,13 @@ "runtime.agent_run_failed": "Agent run failed.", "runtime.live_agent_output": "Live agent output", "runtime.photo_too_large": "Photo is too large. The maximum supported size is 5 MB.", + "runtime.photo_album_too_many": "Too many photos. A single album can contain at most {limit} images.", "runtime.provider_output_index": "{provider} output {index}/{total}", "runtime.provider_output_single": "{provider} output", "runtime.replacement_session_stall": "Replacement session creation appears stuck.\nThe local agent process is still running but has not produced output.\nOn macOS, this might be because a hidden permission dialog is waiting for confirmation on the machine running the bot.", + "runtime.reply_option_select_button": "✅ Select this option", + "runtime.reply_option_selected": "▶️ Continuing with: {choice}", + "runtime.reply_options_prompt": "⚡ Action needed — choose one to continue:", "runtime.resume_created_new": "Resume failed, so a new session was created.\nNew session ID: {session_id}\nNew session name: {session_name}", "runtime.resume_id_changed": "Resume succeeded, but the session ID changed.\nNew session ID: {session_id}\nNew session name: {session_name}", "runtime.sensitive_diff_omitted": "{path}\nThis file contains sensitive content and was omitted.", @@ -99,6 +119,8 @@ "switch.imported_into_state_json": "Imported into state.json.", "switch.status_active": "active", "switch.status_idle": "idle", + "switch.last_active_ago": "Last active: {duration} ago", + "switch.tokens_used": "~{tokens} tokens used", "bootstrap.env_created_locale_line": "Created {env_path}. APP_LOCALE was initialized to {app_locale} based on this system language.", "bootstrap.env_created_change_line": "To use another language later, edit APP_LOCALE in {env_path}.", "project.branch_switch_requires_source": "Switching branch to {new_branch} requires choosing a source first.", @@ -162,6 +184,7 @@ "lifecycle.creating_session": "Creating session...", "lifecycle.failed_create_session": "Failed to create a session.", "lifecycle.session_created_successfully": "Session created successfully: {session_name}\nSession ID: {session_id}\nProject: {project_folder}\nProvider: {provider}\nBranch: {branch_name}", + "health.claude_auth_failed_auth": "Claude couldn't authenticate this session, even though your own Claude login is fine ({detail}).\n\nTo fix, on the host running this bot:\n1. Run: claude setup-token\n2. Approve access in the browser it opens, then go back to the terminal -- the actual token is printed there (starts with sk-ant-oat01-), not shown in the browser. Copy that, then run:\n{claude_auth_hint}\n(That command saves and verifies it for you.)\n3. Restart the bot.\n\nSee the README's FAQ / Troubleshooting section for other options.", "provider.cli_not_found": "{provider_label} CLI not found: {bin_name}\nRun /provider to choose an available provider or update the bot config.", "provider.status_available": "available", "provider.status_missing": "missing", @@ -171,6 +194,17 @@ "provider.not_selected": "(not selected)", "provider.cli_not_found_install_first": "{provider_label} CLI not found: {bin_name}\nUpdate the bot config or install that CLI first.", "provider.current_provider_set": "Current provider set to: {provider}", + "model.usage_model": "Usage: /model or /model ", + "model.current_model_prompt": "Current model: {model}\nChoose the model for the active session, or type /model to use one not listed here. New sessions always start on the default model.", + "model.current_model_prompt_custom": "Current model: {model} (custom, not in the list below)\nChoose a model for the active session, or type /model to use a different custom one. New sessions always start on the default model.", + "model.current_model_set": "Model set to: {model}", + "model.default_option": "Default", + "model.status_current": "current", + "model.cli_default": "CLI default", + "model.validating": "Checking whether '{model}' is a valid model for this provider…", + "model.invalid_model": "⚠️ '{model}' was rejected by the CLI, so it was not saved:\n{error}", + "model.invalid_model_generic": "The CLI rejected this model without further details.", + "model.stale_selection": "⚠️ This button is no longer valid (the model list or provider may have changed). Run /model again.", "status.current_session_details": "Current session: {session_name}\nSession ID: {session_id}\nProject: {project_folder}\nProvider: {provider}\nBranch: {branch_name}", "status.current_branch_placeholder": "(current branch)", "git.commit_disabled": "/commit is disabled.\nSet ENABLE_COMMIT_COMMAND=true in the bot environment to enable it.", @@ -208,5 +242,24 @@ "status.usage_compact": "Usage: /compact", "runtime.compacting_session": "Compacting the active session...", "runtime.compact_summary_missing": "The provider did not return a usable compact handoff summary.", - "runtime.session_compacted": "Session compacted successfully. Switched to {session_name} ({session_id})." + "runtime.session_compacted": "Session compacted successfully. Switched to {session_name} ({session_id}).", + "runtime.switching_session": "Starting a fresh session...", + "runtime.session_switched": "Started a new session. Switched to {session_name} ({session_id}).", + "runtime.long_gap_warning": "⏳ This session has been idle for {gap}. Resuming it now will likely reprocess the whole conversation from scratch (the provider's response cache has probably expired), which can burn significantly more tokens than usual. Compacting also reprocesses the current context once to write its summary, so it can burn a lot of tokens too if this session is already large. Switching to a new session skips that reprocessing entirely, but starts with no memory of this conversation. Switch to a new session, compact first, or proceed anyway?", + "runtime.long_gap_switch_button": "🆕 Switch to new session", + "runtime.long_gap_compact_button": "🔄 Compact first", + "runtime.long_gap_proceed_button": "⚠️ Proceed anyway", + "runtime.long_gap_proceeding": "Proceeding on the existing session...", + "runtime.long_gap_compacting": "Compacting before continuing...", + "runtime.long_gap_switching": "Switching to a new session...", + "bot.command.status": "Show provider quota usage (5h/weekly)", + "status.usage_status": "Usage: /status", + "status.usage_title": "Provider usage:", + "status.usage_five_hour": "5h usage", + "status.usage_weekly": "Weekly usage", + "status.usage_unknown": "unknown", + "status.usage_na": "N/A ({detail})", + "status.usage_resets_in": "resets in {duration}", + "status.usage_unavailable": "Not available: {detail}", + "status.usage_last_observed": "(last observed {duration} ago)" } diff --git a/src/coding_agent_telegram/resources/locales/fr.json b/src/coding_agent_telegram/resources/locales/fr.json index f4b361b..ffefa47 100644 --- a/src/coding_agent_telegram/resources/locales/fr.json +++ b/src/coding_agent_telegram/resources/locales/fr.json @@ -4,11 +4,14 @@ "bot.command.commit": "Exécuter des commandes Git commit validées", "bot.command.current": "Afficher la session active", "bot.command.diff": "Afficher les noms de fichiers modifiés par rapport à HEAD", + "bot.command.model": "Choisir le modèle pour la session active", "bot.command.new": "Créer une nouvelle session", - "bot.command.pull": "Pull la branche de la session actuelle", + "bot.command.log": "Afficher les 5 derniers commits Git", + "bot.command.pull": "Git pull la branche de la session actuelle", "bot.command.project": "Définir le dossier de projet actuel", "bot.command.provider": "Choisir le fournisseur pour les nouvelles sessions", - "bot.command.push": "Push la branche de la session actuelle", + "bot.command.push": "Git push la branche de la session actuelle", + "bot.command.reset": "Git reset --hard vers une branche", "bot.command.switch": "Lister les sessions ou basculer", "bot.error.command_failed": "⚠️ La commande a échoué. Vérifiez le journal du serveur.", "bot.error.session_store": "⚠️ {error}", @@ -20,6 +23,10 @@ "common.project_folder_missing": "⚠️ Le dossier du projet n’existe plus pour cette session : {project_folder}", "common.button_expired": "⚠️ Ce bouton a expiré. Veuillez relancer la commande.", "git.branch_unknown": "⚠️ Impossible de déterminer la branche de la session actuelle.", + "git.branch_discrepancy_warning": "⚠️ Divergence de branche détectée. La branche de la session active est `{session_branch}`, mais le dépôt utilise actuellement `{checked_out_branch}`. Basculez sur la branche voulue avant d’exécuter cette commande.", + "git.branch_discrepancy_switch_to_session": "Basculer vers {branch_name}", + "git.branch_discrepancy_use_current": "Définir la branche de session sur {branch_name}", + "git.detached_head_label": "HEAD détachée", "git.cancel_button": "Annuler", "git.usage_diff": "Utilisation : /diff", "git.usage_pull": "Utilisation : /pull", @@ -30,13 +37,22 @@ "git.pull_confirm_prompt_with_default": "Pull la branche `{branch_name}` depuis `origin` et rafraîchir aussi la branche par défaut `{default_branch}` ?", "git.pull_in_progress": "Pull de la branche `{branch_name}` depuis `origin`...", "git.pull_in_progress_with_default": "Pull de la branche `{branch_name}` depuis `origin` et rafraîchissement de la branche par défaut `{default_branch}`...", + "git.reset_cancelled": "Git reset annulé.", + "git.reset_confirm_button": "Confirmer le reset", + "git.reset_confirm_prompt": "Réinitialiser la branche actuelle avec `git reset --hard {target_ref}` ?", + "git.reset_in_progress": "Exécution de `git reset --hard {target_ref}`...", + "git.reset_pull_in_progress": "Pull de `{target_ref}` avant le reset...", + "git.reset_select_prompt": "Sélectionnez la branche cible du reset :", "git.push_cancelled": "Push annulé.", "git.push_cancelled_checkout_failed": "Push annulé. Échec du basculement vers `{branch_name}`.", "git.push_confirm_button": "Confirmer push", "git.push_confirm_prompt": "Push la branche `{branch_name}` vers `origin` ?", "git.push_in_progress": "Push de la branche `{branch_name}` vers `origin`...", "git.usage_push": "Utilisation : /push", + "git.usage_log": "Utilisation : /log", + "git.usage_reset": "Utilisation : /reset", "message.photo_only_codex": "Les pièces jointes photo sont actuellement prises en charge uniquement pour les sessions Codex.", + "message.photo_blocked_by_pending_action": "Une action liée à un message précédent attend encore votre réponse (par exemple une invite compacter/continuer). Veuillez d'abord la résoudre, puis renvoyez cette photo.", "message.question_queued": "Question mise en file d’attente sous Q{question_number}. Elle sera traitée une fois la tâche actuelle terminée.", "message.voice_speech_to_text_disabled": "Les messages vocaux ne sont pas activés.\nDéfinissez ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true et installez d'abord les prérequis locaux de Whisper.", "message.unsupported_message_type": "Type de message non pris en charge.\nCe bot accepte actuellement les messages texte, les photos, les messages vocaux et les fichiers audio.", @@ -61,6 +77,7 @@ "runtime.agent_run_failed": "L’exécution de l’agent a échoué.", "runtime.live_agent_output": "Sortie en direct de l’agent", "runtime.photo_too_large": "La photo est trop volumineuse. La taille maximale prise en charge est de 5 Mo.", + "runtime.photo_album_too_many": "Trop de photos. Un album ne peut contenir que {limit} images au maximum.", "runtime.voice_audio_too_large": "L’audio est trop volumineux pour la transcription vocale locale. La taille maximale prise en charge est de {max_size_mb} Mo.", "runtime.provider_output_index": "Sortie {provider} {index}/{total}", "runtime.provider_output_single": "Sortie {provider}", @@ -97,6 +114,8 @@ "switch.imported_into_state_json": "Importé dans state.json.", "switch.status_active": "actif", "switch.status_idle": "inactive", + "switch.last_active_ago": "Dernière activité : il y a {duration}", + "switch.tokens_used": "~{tokens} tokens utilisés", "bootstrap.env_created_locale_line": "{env_path} a été créé. APP_LOCALE a été initialisé à {app_locale} d’après la langue du système.", "bootstrap.env_created_change_line": "Pour utiliser une autre langue plus tard, modifiez APP_LOCALE dans {env_path}.", "queue.button_cancel": "Annuler", @@ -162,6 +181,7 @@ "lifecycle.creating_session": "Création de la session...", "lifecycle.failed_create_session": "Échec de la création de la session.", "lifecycle.session_created_successfully": "session créée avec succès : {session_name}\nID de session : {session_id}\nProjet : {project_folder}\nFournisseur : {provider}\nBranche : {branch_name}", + "health.claude_auth_failed_auth": "Claude n'a pas pu authentifier cette session, même si votre propre connexion Claude est valide ({detail}).\n\nPour corriger cela, sur l'hôte exécutant ce bot :\n1. Exécutez : claude setup-token\n2. Approuvez l'accès dans le navigateur qui s'ouvre, puis revenez au terminal -- le jeton réel y est affiché (commence par sk-ant-oat01-), pas dans le navigateur. Copiez-le, puis exécutez :\n{claude_auth_hint}\n(Cette commande l'enregistre et le vérifie pour vous.)\n3. Redémarrez le bot.\n\nConsultez la section « FAQ / Dépannage » du README pour d'autres options.", "provider.cli_not_found": "CLI {provider_label} introuvable : {bin_name}\nExécutez /provider pour choisir un fournisseur disponible ou mettre à jour la configuration du bot.", "provider.status_available": "disponible", "provider.status_missing": "manquant", @@ -171,6 +191,20 @@ "provider.not_selected": "(non sélectionné)", "provider.cli_not_found_install_first": "CLI {provider_label} introuvable : {bin_name}\nMettez à jour la configuration du bot ou installez d'abord cette CLI.", "provider.current_provider_set": "Fournisseur actuel défini sur : {provider}", + "model.usage_model": "Utilisation : /model ou /model ", + "model.current_model_prompt": "Modèle actuel : {model}\nChoisissez le modèle pour la session active, ou saisissez /model pour utiliser un modèle non listé ici. Les nouvelles sessions démarrent toujours avec le modèle par défaut.", + "model.current_model_prompt_custom": "Modèle actuel : {model} (personnalisé, absent de la liste ci-dessous)\nChoisissez un modèle pour la session active, ou saisissez /model pour utiliser un autre modèle personnalisé. Les nouvelles sessions démarrent toujours avec le modèle par défaut.", + "model.current_model_set": "Modèle défini sur : {model}", + "model.default_option": "Par défaut", + "model.status_current": "actuel", + "model.cli_default": "Défaut de la CLI", + "model.validating": "Vérification que '{model}' est un modèle valide pour ce fournisseur…", + "model.invalid_model": "⚠️ '{model}' a été rejeté par la CLI, il n'a donc pas été enregistré :\n{error}", + "model.invalid_model_generic": "La CLI a rejeté ce modèle sans fournir plus de détails.", + "model.stale_selection": "⚠️ Ce bouton n'est plus valide (la liste des modèles ou le fournisseur a peut-être changé). Exécutez à nouveau /model.", + "runtime.reply_options_prompt": "⚡ Action requise — choisissez une option pour continuer :", + "runtime.reply_option_select_button": "✅ Sélectionner cette option", + "runtime.reply_option_selected": "▶️ Poursuite avec : {choice}", "status.current_session_details": "session actuelle : {session_name}\nID de session : {session_id}\nProjet : {project_folder}\nFournisseur : {provider}\nBranche : {branch_name}", "status.current_branch_placeholder": "(branche actuelle)", "git.commit_disabled": "/commit est désactivé.\nDéfinissez ENABLE_COMMIT_COMMAND=true dans l'environnement du bot pour l'activer.", @@ -208,5 +242,24 @@ "status.usage_compact": "Utilisation : /compact", "runtime.compacting_session": "Compression de la session active en cours...", "runtime.compact_summary_missing": "Le fournisseur n'a pas renvoyé de résumé de relais compact exploitable.", - "runtime.session_compacted": "session compactée avec succès. Bascule vers {session_name} ({session_id})." + "runtime.session_compacted": "session compactée avec succès. Bascule vers {session_name} ({session_id}).", + "runtime.switching_session": "Démarrage d'une nouvelle session...", + "runtime.session_switched": "Nouvelle session démarrée. Bascule vers {session_name} ({session_id}).", + "runtime.long_gap_warning": "⏳ Cette session est inactive depuis {gap}. La reprendre maintenant va probablement retraiter toute la conversation depuis le début (le cache de réponses du fournisseur a sans doute expiré), ce qui peut consommer bien plus de tokens que d'habitude. Compacter retraite aussi le contexte actuel une fois pour rédiger son résumé, donc cela peut aussi consommer beaucoup de tokens si cette session est déjà volumineuse. Basculer vers une nouvelle session évite complètement ce retraitement, mais démarre sans aucune mémoire de cette conversation. Basculer vers une nouvelle session, compacter d'abord, ou continuer quand même ?", + "runtime.long_gap_switch_button": "🆕 Basculer vers une nouvelle session", + "runtime.long_gap_compact_button": "🔄 Compacter d'abord", + "runtime.long_gap_proceed_button": "⚠️ Continuer quand même", + "runtime.long_gap_proceeding": "Poursuite sur la session existante...", + "runtime.long_gap_compacting": "Compression avant de continuer...", + "runtime.long_gap_switching": "Bascule vers une nouvelle session...", + "bot.command.status": "Afficher l'utilisation du quota par fournisseur (5h/hebdomadaire)", + "status.usage_status": "Utilisation : /status", + "status.usage_title": "Utilisation par fournisseur :", + "status.usage_five_hour": "Utilisation sur 5h", + "status.usage_weekly": "Utilisation hebdomadaire", + "status.usage_unknown": "inconnu", + "status.usage_na": "N/A ({detail})", + "status.usage_resets_in": "réinitialisation dans {duration}", + "status.usage_unavailable": "Non disponible : {detail}", + "status.usage_last_observed": "(observé pour la dernière fois il y a {duration})" } diff --git a/src/coding_agent_telegram/resources/locales/ja.json b/src/coding_agent_telegram/resources/locales/ja.json index 14fcd9f..45eca7c 100644 --- a/src/coding_agent_telegram/resources/locales/ja.json +++ b/src/coding_agent_telegram/resources/locales/ja.json @@ -4,11 +4,14 @@ "bot.command.commit": "検証済みの Git commit コマンドを実行", "bot.command.current": "現在のアクティブセッションを表示", "bot.command.diff": "HEAD との差分があるファイル名を表示", + "bot.command.model": "アクティブなセッションのモデルを選択", "bot.command.new": "新しいセッションを作成", - "bot.command.pull": "現在のセッション ブランチ を pull", + "bot.command.log": "最新の Git コミット 5 件を表示", + "bot.command.pull": "Git で現在のセッション ブランチ を pull", "bot.command.project": "現在のプロジェクトフォルダーを設定", "bot.command.provider": "新しいセッションのプロバイダーを選択", - "bot.command.push": "現在のセッション ブランチ を push", + "bot.command.push": "Git で現在のセッション ブランチ を push", + "bot.command.reset": "Git reset --hard でブランチに戻す", "bot.command.switch": "セッション一覧または切り替え", "bot.error.command_failed": "⚠️ コマンドが失敗しました。サーバーログを確認してください。", "bot.error.session_store": "⚠️ {error}", @@ -20,6 +23,10 @@ "common.project_folder_missing": "⚠️ このセッションのプロジェクトフォルダーは存在しなくなりました: {project_folder}", "common.button_expired": "⚠️ このボタンの有効期限が切れました。コマンドを再実行してください。", "git.branch_unknown": "⚠️ 現在のセッションの ブランチ を特定できませんでした。", + "git.branch_discrepancy_warning": "⚠️ ブランチの不一致を検出しました。アクティブセッションのブランチは `{session_branch}` ですが、リポジトリでは `{checked_out_branch}` がチェックアウトされています。このコマンドを実行する前に、使用するブランチへ切り替えてください。", + "git.branch_discrepancy_switch_to_session": "{branch_name} に切り替える", + "git.branch_discrepancy_use_current": "セッションのブランチを {branch_name} に変更", + "git.detached_head_label": "detached HEAD", "git.cancel_button": "キャンセル", "git.usage_diff": "使い方: /diff", "git.usage_pull": "使い方: /pull", @@ -30,13 +37,22 @@ "git.pull_confirm_prompt_with_default": "ブランチ `{branch_name}` を `origin` から pull し、あわせてデフォルト ブランチ `{default_branch}` も更新しますか?", "git.pull_in_progress": "ブランチ `{branch_name}` を `origin` から pull 中...", "git.pull_in_progress_with_default": "ブランチ `{branch_name}` を `origin` から pull し、デフォルト ブランチ `{default_branch}` を更新中...", + "git.reset_cancelled": "Git reset をキャンセルしました。", + "git.reset_confirm_button": "reset を確認", + "git.reset_confirm_prompt": "現在のブランチで `git reset --hard {target_ref}` を実行しますか?", + "git.reset_in_progress": "`git reset --hard {target_ref}` を実行中...", + "git.reset_pull_in_progress": "reset の前に `{target_ref}` を pull 中...", + "git.reset_select_prompt": "reset 先のブランチを選択してください:", "git.push_cancelled": "push をキャンセルしました。", "git.push_cancelled_checkout_failed": "push をキャンセルしました。まず `{branch_name}` への切り替えに失敗しました。", "git.push_confirm_button": "push を確認", "git.push_confirm_prompt": "ブランチ `{branch_name}` を `origin` に push しますか?", "git.push_in_progress": "ブランチ `{branch_name}` を `origin` に push 中...", "git.usage_push": "使い方: /push", + "git.usage_log": "使い方: /log", + "git.usage_reset": "使い方: /reset", "message.photo_only_codex": "写真添付は現在 Codex セッションでのみサポートされています。", + "message.photo_blocked_by_pending_action": "以前のメッセージに関する操作がまだあなたの応答待ちです(例: compact/続行 の確認)。先にそれを解決してから、この写真を再送信してください。", "message.question_queued": "質問は Q{question_number} としてキューに追加されました。現在のエージェント処理が終わった後に実行されます。", "message.voice_speech_to_text_disabled": "音声メッセージは有効になっていません。\nENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true を設定し、先にローカル Whisper の前提条件をインストールしてください。", "message.unsupported_message_type": "未対応のメッセージ種類です。\nこのボットは現在、テキストメッセージ、写真、音声メッセージ、音声ファイルを受け付けます。", @@ -61,6 +77,7 @@ "runtime.agent_run_failed": "エージェント実行に失敗しました。", "runtime.live_agent_output": "エージェントのライブ出力", "runtime.photo_too_large": "写真が大きすぎます。対応する最大サイズは 5 MB です。", + "runtime.photo_album_too_many": "写真が多すぎます。1つのアルバムに含められる画像は最大 {limit} 枚です。", "runtime.voice_audio_too_large": "音声がローカル音声文字起こしの上限を超えています。対応する最大サイズは {max_size_mb} MB です。", "runtime.provider_output_index": "{provider} 出力 {index}/{total}", "runtime.provider_output_single": "{provider} 出力", @@ -97,6 +114,8 @@ "switch.imported_into_state_json": "state.json に取り込みました。", "switch.status_active": "使用中", "switch.status_idle": "待機中", + "switch.last_active_ago": "最終アクティブ: {duration}前", + "switch.tokens_used": "~{tokens} トークン使用", "bootstrap.env_created_locale_line": "{env_path} を作成しました。APP_LOCALE はこのシステム言語に基づいて {app_locale} に設定されました。", "bootstrap.env_created_change_line": "後で別の言語に変更する場合は、{env_path} の APP_LOCALE を編集してください。", "queue.button_cancel": "キャンセル", @@ -162,6 +181,7 @@ "lifecycle.creating_session": "セッション を作成しています...", "lifecycle.failed_create_session": "セッション の作成に失敗しました。", "lifecycle.session_created_successfully": "セッション を作成しました: {session_name}\nセッション ID: {session_id}\nプロジェクト: {project_folder}\nプロバイダー: {provider}\nブランチ: {branch_name}", + "health.claude_auth_failed_auth": "ご自身の Claude ログインは問題ないにもかかわらず、Claude はこのセッションを認証できませんでした({detail})。\n\nこのボットを実行しているホスト上で修正するには:\n1. 次を実行してください: claude setup-token\n2. 開いたブラウザでアクセスを承認し、その後ターミナルに戻ってください -- 実際のトークンはブラウザではなくターミナルに表示されます(sk-ant-oat01- で始まります)。それをコピーし、次を実行してください:\n{claude_auth_hint}\n(このコマンドがトークンの保存と検証を行います。)\n3. ボットを再起動してください。\n\n他の対処法については README の「FAQ / トラブルシューティング」セクションを参照してください。", "provider.cli_not_found": "{provider_label} CLI が見つかりません: {bin_name}\n/provider を実行して利用可能な プロバイダー を選択するか、ボット 設定を更新してください。", "provider.status_available": "利用可能", "provider.status_missing": "未検出", @@ -171,6 +191,20 @@ "provider.not_selected": "(未選択)", "provider.cli_not_found_install_first": "{provider_label} CLI が見つかりません: {bin_name}\nボット 設定を更新するか、この CLI を先にインストールしてください。", "provider.current_provider_set": "現在の プロバイダー を {provider} に設定しました", + "model.usage_model": "使い方: /model または /model ", + "model.current_model_prompt": "現在の モデル: {model}\nアクティブな セッション の モデル を選択するか、/model と入力してこの一覧にないモデルを使用してください。新しい セッション は常にデフォルトの モデル で開始されます。", + "model.current_model_prompt_custom": "現在の モデル: {model}(カスタム、以下の一覧にはありません)\nアクティブな セッション の モデル を選択するか、/model と入力して別のカスタム モデル を使用してください。新しい セッション は常にデフォルトの モデル で開始されます。", + "model.current_model_set": "モデル を {model} に設定しました", + "model.default_option": "デフォルト", + "model.status_current": "現在", + "model.cli_default": "CLI のデフォルト", + "model.validating": "'{model}' がこの プロバイダー で有効な モデル かどうかを確認しています…", + "model.invalid_model": "⚠️ '{model}' は CLI に拒否されたため、保存されませんでした:\n{error}", + "model.invalid_model_generic": "CLI はこの モデル を拒否しましたが、詳細情報はありませんでした。", + "model.stale_selection": "⚠️ このボタンは無効になりました(モデル一覧または プロバイダー が変更された可能性があります)。/model を再実行してください。", + "runtime.reply_options_prompt": "⚡ 操作が必要です。続行するには選択してください:", + "runtime.reply_option_select_button": "✅ この選択肢を選ぶ", + "runtime.reply_option_selected": "▶️ 次へ進む:{choice}", "status.current_session_details": "現在の セッション: {session_name}\nセッション ID: {session_id}\nプロジェクト: {project_folder}\nプロバイダー: {provider}\nブランチ: {branch_name}", "status.current_branch_placeholder": "(現在の ブランチ)", "git.commit_disabled": "/commit は無効です。\n有効にするには ボット の環境で ENABLE_COMMIT_COMMAND=true を設定してください。", @@ -208,5 +242,24 @@ "status.usage_compact": "使い方: /compact", "runtime.compacting_session": "アクティブなセッションを圧縮しています...", "runtime.compact_summary_missing": "プロバイダーから利用可能な簡潔な引き継ぎ要約が返されませんでした。", - "runtime.session_compacted": "セッションの圧縮に成功しました。{session_name} ({session_id}) に切り替えました。" + "runtime.session_compacted": "セッションの圧縮に成功しました。{session_name} ({session_id}) に切り替えました。", + "runtime.switching_session": "新しい session を開始しています...", + "runtime.session_switched": "新しい session を開始しました。{session_name} ({session_id}) に切り替えました。", + "runtime.long_gap_warning": "⏳ この session は {gap} の間 idle でした。今 resume すると会話全体をゼロから再処理する可能性が高く(provider の応答 cache はおそらく失効しています)、通常よりかなり多くの token を消費するおそれがあります。compact も要約を作成するために現在のコンテキストを一度再処理するため、この session がすでに大きい場合は同様に多くの token を消費する可能性があります。新しい session に切り替えれば、この再処理を完全に回避できますが、その代わりこれまでの会話の記憶は一切引き継がれません。新しい session に切り替えますか、先に compact しますか、それともそのまま続行しますか?", + "runtime.long_gap_switch_button": "🆕 新しい session に切り替え", + "runtime.long_gap_compact_button": "🔄 先に compact", + "runtime.long_gap_proceed_button": "⚠️ そのまま続行", + "runtime.long_gap_proceeding": "既存の session で続行しています...", + "runtime.long_gap_compacting": "続行前に compact しています...", + "runtime.long_gap_switching": "新しい session に切り替えています...", + "bot.command.status": "各 provider の quota 使用状況(5時間/週次)を表示", + "status.usage_status": "使い方: /status", + "status.usage_title": "Provider 使用状況:", + "status.usage_five_hour": "5時間使用率", + "status.usage_weekly": "週次使用率", + "status.usage_unknown": "不明", + "status.usage_na": "N/A({detail})", + "status.usage_resets_in": "{duration}後にリセット", + "status.usage_unavailable": "利用不可: {detail}", + "status.usage_last_observed": "({duration}前に確認)" } diff --git a/src/coding_agent_telegram/resources/locales/ko.json b/src/coding_agent_telegram/resources/locales/ko.json index e31ab2e..c039491 100644 --- a/src/coding_agent_telegram/resources/locales/ko.json +++ b/src/coding_agent_telegram/resources/locales/ko.json @@ -4,11 +4,14 @@ "bot.command.commit": "검증된 Git commit 명령 실행", "bot.command.current": "현재 활성 세션 표시", "bot.command.diff": "HEAD 대비 변경된 파일 이름 표시", + "bot.command.model": "활성 세션용 모델 선택", "bot.command.new": "새 세션 생성", - "bot.command.pull": "현재 세션 브랜치 pull", + "bot.command.log": "최근 Git 커밋 5개 표시", + "bot.command.pull": "Git 현재 세션 브랜치 pull", "bot.command.project": "현재 프로젝트 폴더 설정", "bot.command.provider": "새 세션용 제공자 선택", - "bot.command.push": "현재 세션 브랜치 push", + "bot.command.push": "Git 현재 세션 브랜치 push", + "bot.command.reset": "Git reset --hard로 브랜치 재설정", "bot.command.switch": "세션 목록 보기 또는 전환", "bot.error.command_failed": "⚠️ 명령이 실패했습니다. 서버 로그를 확인하세요.", "bot.error.session_store": "⚠️ {error}", @@ -20,6 +23,10 @@ "common.project_folder_missing": "⚠️ 이 세션의 프로젝트 폴더가 더 이상 존재하지 않습니다: {project_folder}", "common.button_expired": "⚠️ 이 버튼은 만료되었습니다. 명령을 다시 실행해 주세요.", "git.branch_unknown": "⚠️ 현재 세션의 브랜치 를 확인할 수 없습니다.", + "git.branch_discrepancy_warning": "⚠️ 브랜치 불일치가 감지되었습니다. 활성 세션 브랜치는 `{session_branch}` 이지만 저장소에는 현재 `{checked_out_branch}` 가 체크아웃되어 있습니다. 이 명령을 실행하기 전에 원하는 브랜치로 전환하세요.", + "git.branch_discrepancy_switch_to_session": "{branch_name}(으)로 전환", + "git.branch_discrepancy_use_current": "세션 브랜치를 {branch_name}(으)로 변경", + "git.detached_head_label": "분리된 HEAD", "git.cancel_button": "취소", "git.usage_diff": "사용법: /diff", "git.usage_pull": "사용법: /pull", @@ -30,13 +37,22 @@ "git.pull_confirm_prompt_with_default": "브랜치 `{branch_name}` 를 `origin` 에서 pull 하고 기본 브랜치 `{default_branch}` 도 함께 새로고침할까요?", "git.pull_in_progress": "브랜치 `{branch_name}` 를 `origin` 에서 pull 하는 중...", "git.pull_in_progress_with_default": "브랜치 `{branch_name}` 를 `origin` 에서 pull 하고 기본 브랜치 `{default_branch}` 를 새로고침하는 중...", + "git.reset_cancelled": "Git reset이 취소되었습니다.", + "git.reset_confirm_button": "reset 확인", + "git.reset_confirm_prompt": "현재 브랜치에서 `git reset --hard {target_ref}` 를 실행할까요?", + "git.reset_in_progress": "`git reset --hard {target_ref}` 실행 중...", + "git.reset_pull_in_progress": "reset 전에 `{target_ref}` 를 pull 하는 중...", + "git.reset_select_prompt": "reset할 대상 브랜치를 선택하세요:", "git.push_cancelled": "push 가 취소되었습니다.", "git.push_cancelled_checkout_failed": "push 가 취소되었습니다. 먼저 `{branch_name}` 로 전환하지 못했습니다.", "git.push_confirm_button": "push 확인", "git.push_confirm_prompt": "브랜치 `{branch_name}` 를 `origin` 으로 push 할까요?", "git.push_in_progress": "브랜치 `{branch_name}` 를 `origin` 으로 push 하는 중...", "git.usage_push": "사용법: /push", + "git.usage_log": "사용법: /log", + "git.usage_reset": "사용법: /reset", "message.photo_only_codex": "사진 첨부는 현재 Codex 세션에서만 지원됩니다.", + "message.photo_blocked_by_pending_action": "이전 메시지에서 시작된 작업이 아직 응답을 기다리고 있습니다(예: compact/진행 확인). 먼저 그것을 해결한 뒤 이 사진을 다시 보내주세요.", "message.question_queued": "질문이 Q{question_number} 로 대기열에 추가되었습니다. 현재 에이전트 작업이 끝난 뒤 처리됩니다.", "message.voice_speech_to_text_disabled": "음성 메시지가 활성화되어 있지 않습니다.\nENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true 를 설정하고 먼저 로컬 Whisper 필수 요소를 설치하세요.", "message.unsupported_message_type": "지원되지 않는 메시지 유형입니다.\n이 봇은 현재 텍스트 메시지, 사진, 음성 메시지, 오디오 파일을 받습니다.", @@ -61,6 +77,7 @@ "runtime.agent_run_failed": "에이전트 실행에 실패했습니다.", "runtime.live_agent_output": "에이전트 실시간 출력", "runtime.photo_too_large": "사진이 너무 큽니다. 지원되는 최대 크기는 5MB입니다.", + "runtime.photo_album_too_many": "사진이 너무 많습니다. 앨범 하나에는 이미지가 최대 {limit}장까지 포함될 수 있습니다.", "runtime.voice_audio_too_large": "오디오가 로컬 음성 텍스트 변환 한도를 초과했습니다. 지원되는 최대 크기는 {max_size_mb}MB입니다.", "runtime.provider_output_index": "{provider} 출력 {index}/{total}", "runtime.provider_output_single": "{provider} 출력", @@ -97,6 +114,8 @@ "switch.imported_into_state_json": "state.json으로 가져왔습니다.", "switch.status_active": "사용 중", "switch.status_idle": "대기 중", + "switch.last_active_ago": "마지막 활동: {duration} 전", + "switch.tokens_used": "~{tokens} 토큰 사용", "bootstrap.env_created_locale_line": "{env_path} 파일을 만들었고, 시스템 언어에 따라 APP_LOCALE 을 {app_locale} 로 설정했습니다.", "bootstrap.env_created_change_line": "나중에 다른 언어로 바꾸려면 {env_path} 에서 APP_LOCALE 값을 수정하세요.", "queue.button_cancel": "취소", @@ -162,6 +181,7 @@ "lifecycle.creating_session": "세션을 생성하는 중...", "lifecycle.failed_create_session": "세션 생성에 실패했습니다.", "lifecycle.session_created_successfully": "세션을 생성했습니다: {session_name}\n세션 ID: {session_id}\n프로젝트: {project_folder}\n프로바이더: {provider}\n브랜치: {branch_name}", + "health.claude_auth_failed_auth": "본인의 Claude 로그인은 정상인데도 Claude 가 이 세션을 인증하지 못했습니다 ({detail}).\n\n이 봇을 실행 중인 호스트에서 해결하려면:\n1. 다음을 실행하세요: claude setup-token\n2. 열린 브라우저에서 접근을 승인한 뒤 터미널로 돌아가세요 -- 실제 토큰은 브라우저가 아니라 터미널에 출력됩니다 (sk-ant-oat01- 로 시작). 그걸 복사한 뒤 다음을 실행하세요:\n{claude_auth_hint}\n(이 명령이 토큰을 저장하고 검증까지 해 줍니다.)\n3. 봇을 재시작하세요.\n\n다른 방법은 README의 \"FAQ / 문제 해결\" 섹션을 참고하세요.", "provider.cli_not_found": "{provider_label} CLI를 찾을 수 없습니다: {bin_name}\n/provider 를 실행해서 사용 가능한 프로바이더 를 선택하거나 봇 설정을 업데이트하세요.", "provider.status_available": "사용 가능", "provider.status_missing": "없음", @@ -171,6 +191,20 @@ "provider.not_selected": "(선택 안 됨)", "provider.cli_not_found_install_first": "{provider_label} CLI를 찾을 수 없습니다: {bin_name}\n봇 설정을 업데이트하거나 먼저 이 CLI를 설치하세요.", "provider.current_provider_set": "현재 프로바이더 를 {provider}(으)로 설정했습니다", + "model.usage_model": "사용법: /model 또는 /model ", + "model.current_model_prompt": "현재 모델: {model}\n활성 세션에 사용할 모델을 선택하거나, 여기에 없는 모델을 사용하려면 /model 를 입력하세요. 새 세션은 항상 기본 모델로 시작합니다.", + "model.current_model_prompt_custom": "현재 모델: {model} (사용자 지정, 아래 목록에 없음)\n활성 세션에 사용할 모델을 선택하거나, 다른 사용자 지정 모델을 사용하려면 /model 를 입력하세요. 새 세션은 항상 기본 모델로 시작합니다.", + "model.current_model_set": "모델을 {model}(으)로 설정했습니다", + "model.default_option": "기본", + "model.status_current": "현재", + "model.cli_default": "CLI 기본값", + "model.validating": "'{model}' 가 이 프로바이더에서 유효한 모델인지 확인하는 중...", + "model.invalid_model": "⚠️ CLI가 '{model}' 모델을 거부하여 저장되지 않았습니다:\n{error}", + "model.invalid_model_generic": "CLI가 자세한 이유 없이 이 모델을 거부했습니다.", + "model.stale_selection": "⚠️ 이 버튼은 더 이상 유효하지 않습니다 (모델 목록 또는 프로바이더가 변경되었을 수 있습니다). /model 을 다시 실행해 주세요.", + "runtime.reply_options_prompt": "⚡ 작업이 필요합니다 — 계속하려면 하나를 선택하세요:", + "runtime.reply_option_select_button": "✅ 이 옵션 선택", + "runtime.reply_option_selected": "▶️ 계속 진행: {choice}", "status.current_session_details": "현재 세션: {session_name}\n세션 ID: {session_id}\n프로젝트: {project_folder}\n프로바이더: {provider}\n브랜치: {branch_name}", "status.current_branch_placeholder": "(현재 브랜치)", "git.commit_disabled": "/commit 이 비활성화되어 있습니다.\n활성화하려면 봇 환경에서 ENABLE_COMMIT_COMMAND=true 를 설정하세요.", @@ -208,5 +242,24 @@ "status.usage_compact": "사용법: /compact", "runtime.compacting_session": "활성 세션을 압축하는 중입니다...", "runtime.compact_summary_missing": "프로바이더가 사용할 수 있는 간결한 인계 요약을 반환하지 않았습니다.", - "runtime.session_compacted": "세션 압축을 완료했습니다. {session_name} ({session_id})로 전환했습니다." + "runtime.session_compacted": "세션 압축을 완료했습니다. {session_name} ({session_id})로 전환했습니다.", + "runtime.switching_session": "새 session 을 시작하는 중입니다...", + "runtime.session_switched": "새 session 을 시작했습니다. {session_name} ({session_id})로 전환했습니다.", + "runtime.long_gap_warning": "⏳ 이 session 은 {gap} 동안 idle 상태였습니다. 지금 재개하면 대화 전체를 처음부터 다시 처리할 가능성이 높아(provider 의 응답 cache 가 만료되었을 가능성이 큼) 평소보다 훨씬 많은 token 을 소모할 수 있습니다. compact 역시 요약을 작성하기 위해 현재 컨텍스트를 한 번 다시 처리하므로, 이 session 이 이미 큰 경우 마찬가지로 많은 token 을 소모할 수 있습니다. 새 session 으로 전환하면 이 재처리를 완전히 건너뛸 수 있지만, 대신 이 대화에 대한 기억 없이 새로 시작합니다. 새 session 으로 전환할까요, 먼저 compact 할까요, 아니면 그대로 진행할까요?", + "runtime.long_gap_switch_button": "🆕 새 session 으로 전환", + "runtime.long_gap_compact_button": "🔄 먼저 compact", + "runtime.long_gap_proceed_button": "⚠️ 그대로 진행", + "runtime.long_gap_proceeding": "기존 session 에서 계속 진행합니다...", + "runtime.long_gap_compacting": "계속하기 전에 compact 중입니다...", + "runtime.long_gap_switching": "새 session 으로 전환하는 중입니다...", + "bot.command.status": "provider quota 사용량(5시간/주간)을 표시합니다", + "status.usage_status": "사용법: /status", + "status.usage_title": "provider 사용량:", + "status.usage_five_hour": "5시간 사용량", + "status.usage_weekly": "주간 사용량", + "status.usage_unknown": "알 수 없음", + "status.usage_na": "N/A ({detail})", + "status.usage_resets_in": "{duration} 후 초기화", + "status.usage_unavailable": "사용 불가: {detail}", + "status.usage_last_observed": "({duration} 전에 확인됨)" } diff --git a/src/coding_agent_telegram/resources/locales/nl.json b/src/coding_agent_telegram/resources/locales/nl.json index f4e3f52..72c28f5 100644 --- a/src/coding_agent_telegram/resources/locales/nl.json +++ b/src/coding_agent_telegram/resources/locales/nl.json @@ -4,11 +4,14 @@ "bot.command.commit": "Gevalideerde Git commit-opdrachten uitvoeren", "bot.command.current": "Actieve sessie tonen", "bot.command.diff": "Gewijzigde bestandsnamen ten opzichte van HEAD tonen", + "bot.command.model": "Model voor de actieve sessie kiezen", "bot.command.new": "Nieuwe sessie maken", - "bot.command.pull": "De huidige sessietak pullen", + "bot.command.log": "De laatste 5 Git-commits tonen", + "bot.command.pull": "Git: de huidige sessietak pullen", "bot.command.project": "Huidige projectmap instellen", "bot.command.provider": "Aanbieder voor nieuwe sessies kiezen", - "bot.command.push": "De huidige sessietak pushen", + "bot.command.push": "Git: de huidige sessietak pushen", + "bot.command.reset": "Git reset --hard naar een tak", "bot.command.switch": "Sessies tonen of wisselen", "bot.error.command_failed": "⚠️ Opdracht mislukt. Controleer de serverlog.", "bot.error.session_store": "⚠️ {error}", @@ -20,6 +23,10 @@ "common.project_folder_missing": "⚠️ De projectmap voor deze sessie bestaat niet meer: {project_folder}", "common.button_expired": "⚠️ Deze knop is verlopen. Voer de opdracht opnieuw uit.", "git.branch_unknown": "⚠️ De tak voor de huidige sessie kon niet worden bepaald.", + "git.branch_discrepancy_warning": "⚠️ Takverschil gedetecteerd. De actieve sessietak is `{session_branch}`, maar in de repository is momenteel `{checked_out_branch}` uitgecheckt. Schakel naar de gewenste tak voordat je deze opdracht uitvoert.", + "git.branch_discrepancy_switch_to_session": "Schakel naar {branch_name}", + "git.branch_discrepancy_use_current": "Wijzig sessietak naar {branch_name}", + "git.detached_head_label": "losgekoppelde HEAD", "git.cancel_button": "Annuleren", "git.usage_diff": "Gebruik: /diff", "git.usage_pull": "Gebruik: /pull", @@ -30,13 +37,22 @@ "git.pull_confirm_prompt_with_default": "Tak `{branch_name}` van `origin` pullen en ook de standaardtak `{default_branch}` verversen?", "git.pull_in_progress": "Tak `{branch_name}` wordt van `origin` gepulld...", "git.pull_in_progress_with_default": "Tak `{branch_name}` wordt van `origin` gepulld en de standaardtak `{default_branch}` wordt ververst...", + "git.reset_cancelled": "Git reset geannuleerd.", + "git.reset_confirm_button": "Reset bevestigen", + "git.reset_confirm_prompt": "De huidige tak resetten met `git reset --hard {target_ref}`?", + "git.reset_in_progress": "`git reset --hard {target_ref}` wordt uitgevoerd...", + "git.reset_pull_in_progress": "`{target_ref}` wordt vóór de reset gepulld...", + "git.reset_select_prompt": "Selecteer de tak waarnaar moet worden gereset:", "git.push_cancelled": "Push geannuleerd.", "git.push_cancelled_checkout_failed": "Push geannuleerd. Wisselen naar `{branch_name}` is eerst mislukt.", "git.push_confirm_button": "Push bevestigen", "git.push_confirm_prompt": "Tak `{branch_name}` naar `origin` pushen?", "git.push_in_progress": "Tak `{branch_name}` wordt naar `origin` gepusht...", "git.usage_push": "Gebruik: /push", + "git.usage_log": "Gebruik: /log", + "git.usage_reset": "Gebruik: /reset", "message.photo_only_codex": "Foto-bijlagen worden momenteel alleen ondersteund voor Codex-sessies.", + "message.photo_blocked_by_pending_action": "Er wacht nog een actie van een eerder bericht op je antwoord (bijv. een compact/doorgaan-prompt). Los dat eerst op en stuur deze foto daarna opnieuw.", "message.question_queued": "Vraag in de wachtrij geplaatst als Q{question_number}. Deze wordt verwerkt nadat de huidige agenttaak is voltooid.", "message.voice_speech_to_text_disabled": "Spraakberichten zijn niet ingeschakeld.\nZet ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true en installeer eerst de lokale Whisper-vereisten.", "message.unsupported_message_type": "Niet-ondersteund berichttype.\nDeze bot accepteert momenteel tekstberichten, foto's, spraakberichten en audiobestanden.", @@ -61,6 +77,7 @@ "runtime.agent_run_failed": "De agent-run is mislukt.", "runtime.live_agent_output": "Live agentuitvoer", "runtime.photo_too_large": "De foto is te groot. De maximaal ondersteunde grootte is 5 MB.", + "runtime.photo_album_too_many": "Te veel foto's. Eén album mag maximaal {limit} afbeeldingen bevatten.", "runtime.voice_audio_too_large": "De audio is te groot voor lokale spraak-naar-tekst. De maximaal ondersteunde grootte is {max_size_mb} MB.", "runtime.provider_output_index": "{provider}-uitvoer {index}/{total}", "runtime.provider_output_single": "{provider}-uitvoer", @@ -97,6 +114,8 @@ "switch.imported_into_state_json": "Geïmporteerd in state.json.", "switch.status_active": "actief", "switch.status_idle": "inactief", + "switch.last_active_ago": "Laatst actief: {duration} geleden", + "switch.tokens_used": "~{tokens} tokens gebruikt", "bootstrap.env_created_locale_line": "{env_path} is aangemaakt. APP_LOCALE is ingesteld op {app_locale} op basis van de systeemtaal.", "bootstrap.env_created_change_line": "Wil je later een andere taal gebruiken, wijzig dan APP_LOCALE in {env_path}.", "queue.button_cancel": "Annuleren", @@ -162,6 +181,7 @@ "lifecycle.creating_session": "Sessie wordt aangemaakt...", "lifecycle.failed_create_session": "Maken van de sessie is mislukt.", "lifecycle.session_created_successfully": "Sessie succesvol aangemaakt: {session_name}\nSessie ID: {session_id}\nProject: {project_folder}\nAanbieder: {provider}\nTak: {branch_name}", + "health.claude_auth_failed_auth": "Claude kon deze sessie niet authenticeren, ook al is je eigen Claude-login in orde ({detail}).\n\nOm dit op te lossen, op de host waarop deze bot draait:\n1. Voer uit: claude setup-token\n2. Keur de toegang goed in de geopende browser en ga dan terug naar de terminal -- het echte token wordt daar getoond (begint met sk-ant-oat01-), niet in de browser. Kopieer dat en voer dan uit:\n{claude_auth_hint}\n(Dat commando slaat het op en verifieert het voor je.)\n3. Herstart de bot.\n\nZie de README-sectie \"FAQ / Probleemoplossing\" voor andere opties.", "provider.cli_not_found": "{provider_label} CLI niet gevonden: {bin_name}\nVoer /provider uit om een beschikbare aanbieder te kiezen of de botconfiguratie bij te werken.", "provider.status_available": "beschikbaar", "provider.status_missing": "ontbreekt", @@ -171,6 +191,20 @@ "provider.not_selected": "(niet geselecteerd)", "provider.cli_not_found_install_first": "{provider_label} CLI niet gevonden: {bin_name}\nWerk de botconfiguratie bij of installeer eerst deze CLI.", "provider.current_provider_set": "Huidige aanbieder ingesteld op: {provider}", + "model.usage_model": "Gebruik: /model of /model ", + "model.current_model_prompt": "Huidig model: {model}\nKies het model voor de actieve sessie, of typ /model om een model te gebruiken dat hier niet vermeld staat. Nieuwe sessies beginnen altijd met het standaardmodel.", + "model.current_model_prompt_custom": "Huidig model: {model} (aangepast, niet in de onderstaande lijst)\nKies een model voor de actieve sessie, of typ /model om een ander aangepast model te gebruiken. Nieuwe sessies beginnen altijd met het standaardmodel.", + "model.current_model_set": "Model ingesteld op: {model}", + "model.default_option": "Standaard", + "model.status_current": "huidig", + "model.cli_default": "CLI-standaard", + "model.validating": "Er wordt gecontroleerd of '{model}' een geldig model is voor deze aanbieder…", + "model.invalid_model": "⚠️ '{model}' is afgewezen door de CLI en is daarom niet opgeslagen:\n{error}", + "model.invalid_model_generic": "De CLI heeft dit model afgewezen zonder verdere details.", + "model.stale_selection": "⚠️ Deze knop is niet meer geldig (de modellijst of aanbieder is mogelijk gewijzigd). Voer /model opnieuw uit.", + "runtime.reply_options_prompt": "⚡ Actie vereist — kies een optie om door te gaan:", + "runtime.reply_option_select_button": "✅ Deze optie selecteren", + "runtime.reply_option_selected": "▶️ Doorgaan met: {choice}", "status.current_session_details": "Huidige sessie: {session_name}\nSessie ID: {session_id}\nProject: {project_folder}\nAanbieder: {provider}\nTak: {branch_name}", "status.current_branch_placeholder": "(huidige tak)", "git.commit_disabled": "/commit is uitgeschakeld.\nStel ENABLE_COMMIT_COMMAND=true in in de botomgeving om het in te schakelen.", @@ -208,5 +242,24 @@ "status.usage_compact": "Gebruik: /compact", "runtime.compacting_session": "Actieve sessie wordt gecompact...", "runtime.compact_summary_missing": "De aanbieder gaf geen bruikbare compacte overdrachtssamenvatting terug.", - "runtime.session_compacted": "Sessie succesvol gecompact. Overgeschakeld naar {session_name} ({session_id})." + "runtime.session_compacted": "Sessie succesvol gecompact. Overgeschakeld naar {session_name} ({session_id}).", + "runtime.switching_session": "Nieuwe sessie wordt gestart...", + "runtime.session_switched": "Nieuwe sessie gestart. Overgeschakeld naar {session_name} ({session_id}).", + "runtime.long_gap_warning": "⏳ Deze sessie is {gap} inactief geweest. Hem nu hervatten zal waarschijnlijk het hele gesprek helemaal opnieuw verwerken (de responscache van de provider is vermoedelijk verlopen), wat aanzienlijk meer tokens kan kosten dan normaal. Compacten verwerkt de huidige context ook één keer opnieuw om de samenvatting te schrijven, dus dat kan ook veel tokens kosten als deze sessie al groot is. Overschakelen naar een nieuwe sessie slaat die herverwerking helemaal over, maar begint dan zonder enige herinnering aan dit gesprek. Overschakelen naar een nieuwe sessie, eerst compacten, of toch doorgaan?", + "runtime.long_gap_switch_button": "🆕 Overschakelen naar nieuwe sessie", + "runtime.long_gap_compact_button": "🔄 Eerst compacten", + "runtime.long_gap_proceed_button": "⚠️ Toch doorgaan", + "runtime.long_gap_proceeding": "Doorgaan op de bestaande sessie...", + "runtime.long_gap_compacting": "Compacten voordat we doorgaan...", + "runtime.long_gap_switching": "Overschakelen naar een nieuwe sessie...", + "bot.command.status": "Toon quotagebruik per provider (5u/wekelijks)", + "status.usage_status": "Gebruik: /status", + "status.usage_title": "Providergebruik:", + "status.usage_five_hour": "5u-gebruik", + "status.usage_weekly": "Wekelijks gebruik", + "status.usage_unknown": "onbekend", + "status.usage_na": "N/A ({detail})", + "status.usage_resets_in": "reset over {duration}", + "status.usage_unavailable": "Niet beschikbaar: {detail}", + "status.usage_last_observed": "(laatst waargenomen {duration} geleden)" } diff --git a/src/coding_agent_telegram/resources/locales/th.json b/src/coding_agent_telegram/resources/locales/th.json index c4252b2..35f06c6 100644 --- a/src/coding_agent_telegram/resources/locales/th.json +++ b/src/coding_agent_telegram/resources/locales/th.json @@ -4,11 +4,14 @@ "bot.command.commit": "รันคำสั่ง Git commit ที่ผ่านการตรวจสอบ", "bot.command.current": "แสดงเซสชันที่กำลังใช้งาน", "bot.command.diff": "แสดงชื่อไฟล์ที่เปลี่ยนไปเมื่อเทียบกับ HEAD", + "bot.command.model": "เลือก model สำหรับเซสชันที่ใช้งานอยู่", "bot.command.new": "สร้างเซสชันใหม่", - "bot.command.pull": "pull เซสชัน สาขา ปัจจุบัน", + "bot.command.log": "แสดง 5 Git commit ล่าสุด", + "bot.command.pull": "Git pull เซสชัน สาขา ปัจจุบัน", "bot.command.project": "ตั้งค่าโฟลเดอร์โปรเจ็กต์ปัจจุบัน", "bot.command.provider": "เลือกผู้ให้บริการสำหรับเซสชันใหม่", - "bot.command.push": "push เซสชัน สาขา ปัจจุบัน", + "bot.command.push": "Git push เซสชัน สาขา ปัจจุบัน", + "bot.command.reset": "Git reset --hard ไปยังสาขา", "bot.command.switch": "แสดงรายการเซสชันหรือสลับเซสชัน", "bot.error.command_failed": "⚠️ คำสั่งล้มเหลว โปรดตรวจสอบบันทึกของเซิร์ฟเวอร์", "bot.error.session_store": "⚠️ {error}", @@ -20,6 +23,10 @@ "common.project_folder_missing": "⚠️ โฟลเดอร์โปรเจ็กต์สำหรับเซสชันนี้ไม่มีอยู่แล้ว: {project_folder}", "common.button_expired": "⚠️ ปุ่มนี้หมดอายุแล้ว โปรดลองสั่งคำสั่งอีกครั้ง", "git.branch_unknown": "⚠️ ไม่สามารถระบุ สาขา ของ เซสชัน ปัจจุบันได้", + "git.branch_discrepancy_warning": "⚠️ ตรวจพบสาขาไม่ตรงกัน สาขาของเซสชันที่ใช้งานคือ `{session_branch}` แต่ repository กำลัง checkout `{checked_out_branch}` อยู่ โปรดสลับไปยังสาขาที่ต้องการก่อนเรียกใช้คำสั่งนี้", + "git.branch_discrepancy_switch_to_session": "สลับไปยัง {branch_name}", + "git.branch_discrepancy_use_current": "เปลี่ยนสาขาเซสชันเป็น {branch_name}", + "git.detached_head_label": "HEAD แบบ detached", "git.cancel_button": "ยกเลิก", "git.pull_cancelled": "ยกเลิก pull แล้ว", "git.pull_completed": "pull เสร็จแล้ว", @@ -28,6 +35,12 @@ "git.pull_confirm_prompt_with_default": "ต้องการ pull default สาขา `{default_branch}` และ สาขา `{branch_name}` จาก `origin` หรือไม่?", "git.pull_in_progress": "กำลัง pull สาขา `{branch_name}` จาก `origin`...", "git.pull_in_progress_with_default": "กำลัง pull default สาขา `{default_branch}` และ สาขา `{branch_name}` จาก `origin`...", + "git.reset_cancelled": "ยกเลิก Git reset แล้ว", + "git.reset_confirm_button": "ยืนยัน reset", + "git.reset_confirm_prompt": "ต้องการ reset สาขาปัจจุบันด้วย `git reset --hard {target_ref}` หรือไม่?", + "git.reset_in_progress": "กำลังเรียกใช้ `git reset --hard {target_ref}`...", + "git.reset_pull_in_progress": "กำลัง pull `{target_ref}` ก่อน reset...", + "git.reset_select_prompt": "เลือกสาขาที่ต้องการ reset ไปยัง:", "git.push_cancelled": "ยกเลิก push แล้ว", "git.push_cancelled_checkout_failed": "ยกเลิก push แล้ว เนื่องจากสลับไป `{branch_name}` ไม่สำเร็จก่อน", "git.push_confirm_button": "ยืนยัน push", @@ -36,7 +49,10 @@ "git.usage_diff": "วิธีใช้: /diff", "git.usage_pull": "วิธีใช้: /pull", "git.usage_push": "วิธีใช้: /push", + "git.usage_log": "วิธีใช้: /log", + "git.usage_reset": "วิธีใช้: /reset", "message.photo_only_codex": "ขณะนี้รองรับไฟล์แนบรูปภาพเฉพาะสำหรับเซสชัน Codex เท่านั้น", + "message.photo_blocked_by_pending_action": "มีการดำเนินการจากข้อความก่อนหน้านี้ที่ยังรอการตอบกลับของคุณอยู่ (เช่น พรอมต์ compact/ดำเนินการต่อ) กรุณาจัดการสิ่งนั้นให้เสร็จก่อน แล้วค่อยส่งรูปนี้ใหม่อีกครั้ง", "message.question_queued": "จัดคิวคำถามเป็น Q{question_number} แล้ว จะประมวลผลหลังจากงานเอเจนต์ปัจจุบันเสร็จสิ้น", "message.voice_speech_to_text_disabled": "ยังไม่ได้เปิดใช้งานข้อความเสียง\nตั้งค่า ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true และติดตั้งส่วนที่ Whisper ต้องใช้ในเครื่องก่อน", "message.unsupported_message_type": "ประเภทข้อความไม่รองรับ\nขณะนี้บอตนี้รองรับข้อความตัวอักษร รูปภาพ ข้อความเสียง และไฟล์เสียง", @@ -61,6 +77,7 @@ "runtime.agent_run_failed": "การทำงานของเอเจนต์ล้มเหลว", "runtime.live_agent_output": "ผลลัพธ์สดของเอเจนต์", "runtime.photo_too_large": "รูปภาพมีขนาดใหญ่เกินไป ขนาดสูงสุดที่รองรับคือ 5 MB", + "runtime.photo_album_too_many": "มีรูปภาพมากเกินไป อัลบั้มหนึ่งสามารถมีรูปภาพได้สูงสุด {limit} รูป", "runtime.voice_audio_too_large": "ไฟล์เสียงมีขนาดใหญ่เกินไปสำหรับการถอดเสียงในเครื่อง ขนาดสูงสุดที่รองรับคือ {max_size_mb} MB", "runtime.provider_output_index": "ผลลัพธ์ {provider} {index}/{total}", "runtime.provider_output_single": "ผลลัพธ์ {provider}", @@ -97,6 +114,8 @@ "switch.imported_into_state_json": "นำเข้าไปยัง state.json แล้ว", "switch.status_active": "กำลังใช้งาน", "switch.status_idle": "ว่าง", + "switch.last_active_ago": "ใช้งานล่าสุด: {duration} ที่แล้ว", + "switch.tokens_used": "ใช้ไปแล้วประมาณ {tokens} โทเคน", "bootstrap.env_created_locale_line": "สร้าง {env_path} แล้ว และตั้งค่า APP_LOCALE เป็น {app_locale} ตามภาษาของระบบนี้", "bootstrap.env_created_change_line": "หากต้องการเปลี่ยนภาษาในภายหลัง ให้แก้ค่า APP_LOCALE ใน {env_path}", "queue.button_cancel": "ยกเลิก", @@ -162,6 +181,7 @@ "lifecycle.creating_session": "กำลังสร้าง เซสชัน...", "lifecycle.failed_create_session": "สร้าง เซสชัน ไม่สำเร็จ", "lifecycle.session_created_successfully": "สร้าง เซสชัน สำเร็จ: {session_name}\nเซสชัน ID: {session_id}\nโปรเจ็กต์: {project_folder}\nผู้ให้บริการ: {provider}\nสาขา: {branch_name}", + "health.claude_auth_failed_auth": "Claude ไม่สามารถยืนยันตัวตนสำหรับเซสชันนี้ได้ แม้ว่าการเข้าสู่ระบบ Claude ของคุณจะปกติดี ({detail})\n\nวิธีแก้ไข บนโฮสต์ที่รันบอทนี้:\n1. รันคำสั่ง: claude setup-token\n2. อนุมัติการเข้าถึงในเบราว์เซอร์ที่เปิดขึ้น แล้วกลับมาที่เทอร์มินัล -- โทเค็นจริงจะแสดงที่เทอร์มินัล (ขึ้นต้นด้วย sk-ant-oat01-) ไม่ใช่ในเบราว์เซอร์ คัดลอกอันนั้น แล้วรัน:\n{claude_auth_hint}\n(คำสั่งนี้จะบันทึกและตรวจสอบให้โดยอัตโนมัติ)\n3. รีสตาร์ทบอท\n\nดูตัวเลือกอื่น ๆ ได้ที่หัวข้อ \"FAQ / การแก้ปัญหา\" ใน README", "provider.cli_not_found": "ไม่พบ {provider_label} CLI: {bin_name}\nใช้ /provider เพื่อเลือก ผู้ให้บริการ ที่พร้อมใช้งาน หรืออัปเดตการตั้งค่า บอท", "provider.status_available": "พร้อมใช้งาน", "provider.status_missing": "ไม่พบ", @@ -171,6 +191,20 @@ "provider.not_selected": "(ยังไม่ได้เลือก)", "provider.cli_not_found_install_first": "ไม่พบ {provider_label} CLI: {bin_name}\nอัปเดตการตั้งค่า บอท หรือติดตั้ง CLI นี้ก่อน", "provider.current_provider_set": "ตั้ง ผู้ให้บริการ ปัจจุบันเป็น: {provider}", + "model.usage_model": "วิธีใช้: /model หรือ /model ", + "model.current_model_prompt": "Model ปัจจุบัน: {model}\nเลือก model สำหรับเซสชันที่ใช้งานอยู่ หรือพิมพ์ /model เพื่อใช้ model ที่ไม่มีในรายการนี้ เซสชันใหม่จะเริ่มต้นด้วย model เริ่มต้นเสมอ", + "model.current_model_prompt_custom": "Model ปัจจุบัน: {model} (กำหนดเอง ไม่อยู่ในรายการด้านล่าง)\nเลือก model สำหรับเซสชันที่ใช้งานอยู่ หรือพิมพ์ /model เพื่อใช้ model กำหนดเองตัวอื่น เซสชันใหม่จะเริ่มต้นด้วย model เริ่มต้นเสมอ", + "model.current_model_set": "ตั้ง model เป็น: {model}", + "model.default_option": "ค่าเริ่มต้น", + "model.status_current": "ปัจจุบัน", + "model.cli_default": "ค่าเริ่มต้นของ CLI", + "model.validating": "กำลังตรวจสอบว่า '{model}' เป็น model ที่ใช้ได้สำหรับผู้ให้บริการนี้หรือไม่…", + "model.invalid_model": "⚠️ '{model}' ถูกปฏิเสธโดย CLI จึงไม่ได้บันทึกไว้:\n{error}", + "model.invalid_model_generic": "CLI ปฏิเสธ model นี้โดยไม่มีรายละเอียดเพิ่มเติม", + "model.stale_selection": "⚠️ ปุ่มนี้ไม่สามารถใช้งานได้อีกต่อไป (รายการ model หรือผู้ให้บริการอาจมีการเปลี่ยนแปลง) รัน /model อีกครั้ง", + "runtime.reply_options_prompt": "⚡ ต้องดำเนินการ — เลือกหนึ่งตัวเลือกเพื่อดำเนินการต่อ:", + "runtime.reply_option_select_button": "✅ เลือกตัวเลือกนี้", + "runtime.reply_option_selected": "▶️ ดำเนินการต่อด้วย: {choice}", "status.current_session_details": "เซสชัน ปัจจุบัน: {session_name}\nเซสชัน ID: {session_id}\nโปรเจ็กต์: {project_folder}\nผู้ให้บริการ: {provider}\nสาขา: {branch_name}", "status.current_branch_placeholder": "(สาขา ปัจจุบัน)", "git.commit_disabled": "/commit ถูกปิดใช้งานอยู่\nให้ตั้งค่า ENABLE_COMMIT_COMMAND=true ใน environment ของ บอท เพื่อเปิดใช้งาน", @@ -208,5 +242,24 @@ "status.usage_compact": "วิธีใช้: /compact", "runtime.compacting_session": "กำลังย่อเซสชันที่กำลังใช้งาน...", "runtime.compact_summary_missing": "ผู้ให้บริการไม่ได้ส่งสรุปการส่งต่องานแบบย่อที่ใช้งานได้กลับมา", - "runtime.session_compacted": "ย่อเซสชันสำเร็จแล้ว และสลับไปที่ {session_name} ({session_id})" + "runtime.session_compacted": "ย่อเซสชันสำเร็จแล้ว และสลับไปที่ {session_name} ({session_id})", + "runtime.switching_session": "กำลังเริ่ม session ใหม่...", + "runtime.session_switched": "เริ่ม session ใหม่แล้ว สลับไปที่ {session_name} ({session_id})", + "runtime.long_gap_warning": "⏳ session นี้ idle มาแล้ว {gap} การ resume ตอนนี้มีแนวโน้มจะประมวลผลบทสนทนาทั้งหมดใหม่ตั้งแต่ต้น (cache การตอบกลับของ provider น่าจะหมดอายุแล้ว) ซึ่งอาจกิน token มากกว่าปกติอย่างมาก การ compact ก็ต้องประมวลผล context ปัจจุบันใหม่หนึ่งครั้งเพื่อเขียนสรุปเช่นกัน จึงอาจกิน token มากได้เช่นกันหาก session นี้ใหญ่อยู่แล้ว การสลับไปยัง session ใหม่จะข้ามการประมวลผลใหม่นี้ไปทั้งหมด แต่จะเริ่มต้นโดยไม่มีความจำของบทสนทนานี้เลย ต้องการสลับไปยัง session ใหม่ compact ก่อน หรือดำเนินการต่อเลย?", + "runtime.long_gap_switch_button": "🆕 สลับไปยัง session ใหม่", + "runtime.long_gap_compact_button": "🔄 Compact ก่อน", + "runtime.long_gap_proceed_button": "⚠️ ดำเนินการต่อเลย", + "runtime.long_gap_proceeding": "กำลังดำเนินการต่อบน session เดิม...", + "runtime.long_gap_compacting": "กำลัง compact ก่อนดำเนินการต่อ...", + "runtime.long_gap_switching": "กำลังสลับไปยัง session ใหม่...", + "bot.command.status": "แสดงปริมาณการใช้โควตาของแต่ละ provider (5 ชั่วโมง/รายสัปดาห์)", + "status.usage_status": "วิธีใช้: /status", + "status.usage_title": "การใช้งานของ provider:", + "status.usage_five_hour": "การใช้งาน 5 ชั่วโมง", + "status.usage_weekly": "การใช้งานรายสัปดาห์", + "status.usage_unknown": "ไม่ทราบ", + "status.usage_na": "N/A ({detail})", + "status.usage_resets_in": "รีเซ็ตใน {duration}", + "status.usage_unavailable": "ไม่พร้อมใช้งาน: {detail}", + "status.usage_last_observed": "(ตรวจพบล่าสุดเมื่อ {duration} ที่แล้ว)" } diff --git a/src/coding_agent_telegram/resources/locales/vi.json b/src/coding_agent_telegram/resources/locales/vi.json index 697dc02..97537fc 100644 --- a/src/coding_agent_telegram/resources/locales/vi.json +++ b/src/coding_agent_telegram/resources/locales/vi.json @@ -4,11 +4,14 @@ "bot.command.commit": "Chạy các lệnh Git commit đã được kiểm tra", "bot.command.current": "Hiển thị phiên đang hoạt động", "bot.command.diff": "Hiển thị tên file đã thay đổi so với HEAD", + "bot.command.model": "Chọn model cho phiên đang hoạt động", "bot.command.new": "Tạo phiên mới", - "bot.command.pull": "Pull nhánh của phiên hiện tại", + "bot.command.log": "Hiển thị 5 commit Git mới nhất", + "bot.command.pull": "Git pull nhánh của phiên hiện tại", "bot.command.project": "Đặt thư mục dự án hiện tại", "bot.command.provider": "Chọn nhà cung cấp cho phiên mới", - "bot.command.push": "Push nhánh của phiên hiện tại", + "bot.command.push": "Git push nhánh của phiên hiện tại", + "bot.command.reset": "Git reset --hard về một nhánh", "bot.command.switch": "Liệt kê hoặc chuyển phiên", "bot.error.command_failed": "⚠️ Lệnh thất bại. Hãy kiểm tra log của máy chủ.", "bot.error.session_store": "⚠️ {error}", @@ -20,6 +23,10 @@ "common.project_folder_missing": "⚠️ Thư mục dự án của phiên này không còn tồn tại: {project_folder}", "common.button_expired": "⚠️ Nút này đã hết hạn. Vui lòng chạy lại lệnh.", "git.branch_unknown": "⚠️ Không thể xác định nhánh của phiên hiện tại.", + "git.branch_discrepancy_warning": "⚠️ Phát hiện nhánh không khớp. Nhánh của phiên đang hoạt động là `{session_branch}`, nhưng kho hiện đang checkout `{checked_out_branch}`. Hãy chuyển sang nhánh mong muốn trước khi chạy lệnh này.", + "git.branch_discrepancy_switch_to_session": "Chuyển sang {branch_name}", + "git.branch_discrepancy_use_current": "Đổi nhánh phiên thành {branch_name}", + "git.detached_head_label": "HEAD tách rời", "git.cancel_button": "Hủy", "git.pull_cancelled": "Đã hủy pull.", "git.pull_completed": "Đã pull xong.", @@ -28,6 +35,12 @@ "git.pull_confirm_prompt_with_default": "Pull nhánh mặc định `{default_branch}` và nhánh `{branch_name}` từ `origin`?", "git.pull_in_progress": "Đang pull nhánh `{branch_name}` từ `origin`...", "git.pull_in_progress_with_default": "Đang pull nhánh mặc định `{default_branch}` và nhánh `{branch_name}` từ `origin`...", + "git.reset_cancelled": "Đã hủy Git reset.", + "git.reset_confirm_button": "Xác nhận reset", + "git.reset_confirm_prompt": "Reset nhánh hiện tại bằng `git reset --hard {target_ref}`?", + "git.reset_in_progress": "Đang chạy `git reset --hard {target_ref}`...", + "git.reset_pull_in_progress": "Đang pull `{target_ref}` trước khi reset...", + "git.reset_select_prompt": "Chọn nhánh đích để reset:", "git.push_cancelled": "Đã hủy push.", "git.push_cancelled_checkout_failed": "Đã hủy push. Không thể chuyển sang `{branch_name}` trước.", "git.push_confirm_button": "Xác nhận push", @@ -36,7 +49,10 @@ "git.usage_diff": "Cách dùng: /diff", "git.usage_pull": "Cách dùng: /pull", "git.usage_push": "Cách dùng: /push", + "git.usage_log": "Cách dùng: /log", + "git.usage_reset": "Cách dùng: /reset", "message.photo_only_codex": "Hiện tại tệp đính kèm ảnh chỉ được hỗ trợ cho các phiên Codex.", + "message.photo_blocked_by_pending_action": "Một hành động từ tin nhắn trước đó vẫn đang chờ phản hồi của bạn (ví dụ: lời nhắc compact/tiếp tục). Vui lòng giải quyết việc đó trước, sau đó gửi lại ảnh này.", "message.question_queued": "Câu hỏi đã được xếp hàng dưới dạng Q{question_number}. Nó sẽ được xử lý sau khi tác vụ hiện tại của tác nhân hoàn tất.", "message.voice_speech_to_text_disabled": "Tin nhắn thoại chưa được bật.\nHãy đặt ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true và cài đặt trước các điều kiện cần cục bộ của Whisper.", "message.unsupported_message_type": "Loại tin nhắn không được hỗ trợ.\nbot này hiện chấp nhận tin nhắn văn bản, ảnh, tin nhắn thoại và tệp âm thanh.", @@ -61,6 +77,7 @@ "runtime.agent_run_failed": "Lần chạy của tác nhân đã thất bại.", "runtime.live_agent_output": "Đầu ra trực tiếp của tác nhân", "runtime.photo_too_large": "Ảnh quá lớn. Kích thước tối đa được hỗ trợ là 5 MB.", + "runtime.photo_album_too_many": "Có quá nhiều ảnh. Một album chỉ có thể chứa tối đa {limit} ảnh.", "runtime.voice_audio_too_large": "Tệp âm thanh quá lớn cho chuyển giọng nói thành văn bản cục bộ. Kích thước tối đa được hỗ trợ là {max_size_mb} MB.", "runtime.provider_output_index": "Đầu ra {provider} {index}/{total}", "runtime.provider_output_single": "Đầu ra {provider}", @@ -97,6 +114,8 @@ "switch.imported_into_state_json": "Đã nhập vào state.json.", "switch.status_active": "đang dùng", "switch.status_idle": "nhàn rỗi", + "switch.last_active_ago": "Hoạt động gần nhất: {duration} trước", + "switch.tokens_used": "~{tokens} token đã dùng", "bootstrap.env_created_locale_line": "Đã tạo {env_path}. APP_LOCALE đã được đặt thành {app_locale} theo ngôn ngữ hệ thống.", "bootstrap.env_created_change_line": "Nếu muốn đổi sang ngôn ngữ khác sau này, hãy sửa APP_LOCALE trong {env_path}.", "queue.button_cancel": "Hủy", @@ -162,6 +181,7 @@ "lifecycle.creating_session": "Đang tạo phiên...", "lifecycle.failed_create_session": "Tạo phiên thất bại.", "lifecycle.session_created_successfully": "Đã tạo phiên thành công: {session_name}\nPhiên ID: {session_id}\nDự án: {project_folder}\nNhà cung cấp: {provider}\nNhánh: {branch_name}", + "health.claude_auth_failed_auth": "Claude không thể xác thực phiên này, dù việc đăng nhập Claude của bạn vẫn bình thường ({detail}).\n\nĐể khắc phục, trên máy chủ đang chạy bot này:\n1. Chạy: claude setup-token\n2. Chấp thuận quyền truy cập trong trình duyệt vừa mở, sau đó quay lại terminal -- token thật được in ở đó (bắt đầu bằng sk-ant-oat01-), không phải trong trình duyệt. Sao chép nó, sau đó chạy:\n{claude_auth_hint}\n(Lệnh đó sẽ lưu và xác minh giúp bạn.)\n3. Khởi động lại bot.\n\nXem phần \"FAQ / Khắc phục sự cố\" trong README để biết các cách khác.", "provider.cli_not_found": "Không tìm thấy CLI {provider_label}: {bin_name}\nChạy /provider để chọn nhà cung cấp khả dụng hoặc cập nhật cấu hình bot.", "provider.status_available": "khả dụng", "provider.status_missing": "thiếu", @@ -171,6 +191,20 @@ "provider.not_selected": "(chưa chọn)", "provider.cli_not_found_install_first": "Không tìm thấy CLI {provider_label}: {bin_name}\nCập nhật cấu hình bot hoặc cài CLI này trước.", "provider.current_provider_set": "Đã đặt nhà cung cấp hiện tại thành: {provider}", + "model.usage_model": "Cách dùng: /model hoặc /model ", + "model.current_model_prompt": "Model hiện tại: {model}\nChọn model cho phiên đang hoạt động, hoặc nhập /model để dùng model không có trong danh sách này. Phiên mới luôn bắt đầu với model mặc định.", + "model.current_model_prompt_custom": "Model hiện tại: {model} (tùy chỉnh, không có trong danh sách bên dưới)\nChọn model cho phiên đang hoạt động, hoặc nhập /model để dùng một model tùy chỉnh khác. Phiên mới luôn bắt đầu với model mặc định.", + "model.current_model_set": "Đã đặt model thành: {model}", + "model.default_option": "Mặc định", + "model.status_current": "hiện tại", + "model.cli_default": "Mặc định của CLI", + "model.validating": "Đang kiểm tra xem '{model}' có phải là model hợp lệ cho nhà cung cấp này không…", + "model.invalid_model": "⚠️ '{model}' đã bị CLI từ chối nên không được lưu:\n{error}", + "model.invalid_model_generic": "CLI đã từ chối model này mà không cung cấp thêm chi tiết.", + "model.stale_selection": "⚠️ Nút này không còn hợp lệ (danh sách model hoặc nhà cung cấp có thể đã thay đổi). Hãy chạy lại /model.", + "runtime.reply_options_prompt": "⚡ Cần thao tác — hãy chọn một tùy chọn để tiếp tục:", + "runtime.reply_option_select_button": "✅ Chọn tùy chọn này", + "runtime.reply_option_selected": "▶️ Tiếp tục với: {choice}", "status.current_session_details": "Phiên hiện tại: {session_name}\nPhiên ID: {session_id}\nDự án: {project_folder}\nNhà cung cấp: {provider}\nNhánh: {branch_name}", "status.current_branch_placeholder": "(nhánh hiện tại)", "git.commit_disabled": "/commit hiện đang bị tắt.\nHãy đặt ENABLE_COMMIT_COMMAND=true trong môi trường của bot để bật nó.", @@ -208,5 +242,24 @@ "status.usage_compact": "Cách dùng: /compact", "runtime.compacting_session": "Đang thu gọn phiên đang hoạt động...", "runtime.compact_summary_missing": "Nhà cung cấp không trả về bản tóm tắt bàn giao dạng rút gọn có thể sử dụng được.", - "runtime.session_compacted": "Đã thu gọn phiên thành công. Đã chuyển sang {session_name} ({session_id})." + "runtime.session_compacted": "Đã thu gọn phiên thành công. Đã chuyển sang {session_name} ({session_id}).", + "runtime.switching_session": "Đang bắt đầu session mới...", + "runtime.session_switched": "Đã bắt đầu session mới. Đã chuyển sang {session_name} ({session_id}).", + "runtime.long_gap_warning": "⏳ Session này đã idle {gap}. Resume ngay bây giờ nhiều khả năng sẽ xử lý lại toàn bộ cuộc hội thoại từ đầu (cache phản hồi của provider có lẽ đã hết hạn), có thể tốn token nhiều hơn đáng kể so với bình thường. Compact cũng phải xử lý lại context hiện tại một lần để viết bản tóm tắt, nên cũng có thể tốn nhiều token nếu session này đã lớn. Chuyển sang session mới sẽ bỏ qua hoàn toàn việc xử lý lại đó, nhưng sẽ bắt đầu mà không có bất kỳ ký ức nào về cuộc hội thoại này. Chuyển sang session mới, compact trước, hay cứ tiếp tục?", + "runtime.long_gap_switch_button": "🆕 Chuyển sang session mới", + "runtime.long_gap_compact_button": "🔄 Compact trước", + "runtime.long_gap_proceed_button": "⚠️ Cứ tiếp tục", + "runtime.long_gap_proceeding": "Đang tiếp tục trên session hiện có...", + "runtime.long_gap_compacting": "Đang compact trước khi tiếp tục...", + "runtime.long_gap_switching": "Đang chuyển sang session mới...", + "bot.command.status": "Hiển thị mức sử dụng quota của từng provider (5 giờ/hàng tuần)", + "status.usage_status": "Cách dùng: /status", + "status.usage_title": "Mức sử dụng provider:", + "status.usage_five_hour": "Mức sử dụng 5 giờ", + "status.usage_weekly": "Mức sử dụng hàng tuần", + "status.usage_unknown": "không rõ", + "status.usage_na": "N/A ({detail})", + "status.usage_resets_in": "đặt lại sau {duration}", + "status.usage_unavailable": "Không khả dụng: {detail}", + "status.usage_last_observed": "(quan sát lần cuối {duration} trước)" } diff --git a/src/coding_agent_telegram/resources/locales/zh-CN.json b/src/coding_agent_telegram/resources/locales/zh-CN.json index cffafbc..1c66ba1 100644 --- a/src/coding_agent_telegram/resources/locales/zh-CN.json +++ b/src/coding_agent_telegram/resources/locales/zh-CN.json @@ -4,11 +4,14 @@ "bot.command.commit": "执行已校验的 Git commit 命令", "bot.command.current": "显示当前活动会话", "bot.command.diff": "显示相对 HEAD 已变更的文件名", + "bot.command.model": "为当前活动会话选择模型", "bot.command.new": "创建新会话", - "bot.command.pull": "拉取当前会话分支", + "bot.command.log": "显示最近 5 条 Git 提交", + "bot.command.pull": "Git 拉取当前会话分支", "bot.command.project": "设置当前项目目录", "bot.command.provider": "为新会话选择提供方", - "bot.command.push": "推送当前会话分支", + "bot.command.push": "Git 推送当前会话分支", + "bot.command.reset": "Git reset --hard 到指定分支", "bot.command.switch": "列出会话或切换会话", "bot.error.command_failed": "⚠️ 命令失败。请检查服务器日志。", "bot.error.session_store": "⚠️ {error}", @@ -20,6 +23,10 @@ "common.project_folder_missing": "⚠️ 此会话对应的项目目录已不存在:{project_folder}", "common.button_expired": "⚠️ 此按钮已过期。请重新执行命令。", "git.branch_unknown": "⚠️ 无法确定当前会话的分支。", + "git.branch_discrepancy_warning": "⚠️ 检测到分支不一致。活动会话分支为 `{session_branch}`,但仓库当前检出的分支为 `{checked_out_branch}`。请先切换到预期分支,再运行此命令。", + "git.branch_discrepancy_switch_to_session": "切换到 {branch_name}", + "git.branch_discrepancy_use_current": "将会话分支改为 {branch_name}", + "git.detached_head_label": "分离的 HEAD", "git.cancel_button": "取消", "git.pull_cancelled": "已取消拉取。", "git.pull_completed": "已完成拉取。", @@ -28,6 +35,12 @@ "git.pull_confirm_prompt_with_default": "要从 `origin` 拉取默认分支 `{default_branch}` 和分支 `{branch_name}` 吗?", "git.pull_in_progress": "正在从 `origin` 拉取分支 `{branch_name}`...", "git.pull_in_progress_with_default": "正在从 `origin` 拉取默认分支 `{default_branch}` 和分支 `{branch_name}`...", + "git.reset_cancelled": "已取消 Git reset。", + "git.reset_confirm_button": "确认 reset", + "git.reset_confirm_prompt": "要对当前分支执行 `git reset --hard {target_ref}` 吗?", + "git.reset_in_progress": "正在执行 `git reset --hard {target_ref}`...", + "git.reset_pull_in_progress": "正在 reset 前拉取 `{target_ref}`...", + "git.reset_select_prompt": "请选择 reset 的目标分支:", "git.push_cancelled": "已取消推送。", "git.push_cancelled_checkout_failed": "已取消 push。先切换到 `{branch_name}` 失败。", "git.push_confirm_button": "确认推送", @@ -36,7 +49,10 @@ "git.usage_diff": "用法:/diff", "git.usage_pull": "用法:/pull", "git.usage_push": "用法:/push", + "git.usage_log": "用法:/log", + "git.usage_reset": "用法:/reset", "message.photo_only_codex": "当前仅 Codex 会话支持图片附件。", + "message.photo_blocked_by_pending_action": "之前一条消息触发的操作仍在等待你的回复(例如 compact/继续 的确认提示)。请先处理完那个,再重新发送这张图片。", "message.question_queued": "问题已加入队列,编号为 Q{question_number}。当前代理任务完成后将开始处理。", "message.voice_speech_to_text_disabled": "语音消息功能尚未启用。\n请先设置 ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true,并安装本地 Whisper 依赖。", "message.unsupported_message_type": "不支持的消息类型。\n此机器人当前接受文本消息、图片、语音消息和音频文件。", @@ -61,6 +77,7 @@ "runtime.agent_run_failed": "代理运行失败。", "runtime.live_agent_output": "代理实时输出", "runtime.photo_too_large": "图片过大。支持的最大大小为 5 MB。", + "runtime.photo_album_too_many": "图片过多。单个相册最多只能包含 {limit} 张图片。", "runtime.voice_audio_too_large": "音频过大,无法使用本地语音转文字。支持的最大大小为 {max_size_mb} MB。", "runtime.provider_output_index": "{provider} 输出 {index}/{total}", "runtime.provider_output_single": "{provider} 输出", @@ -97,6 +114,8 @@ "switch.imported_into_state_json": "已导入 state.json。", "switch.status_active": "使用中", "switch.status_idle": "空闲", + "switch.last_active_ago": "上次活动:{duration}前", + "switch.tokens_used": "已用约 {tokens} 个 token", "bootstrap.env_created_locale_line": "已创建 {env_path}。APP_LOCALE 已根据系统语言初始化为 {app_locale}。", "bootstrap.env_created_change_line": "如果之后想切换语言,请修改 {env_path} 中的 APP_LOCALE。", "queue.button_cancel": "取消", @@ -162,6 +181,7 @@ "lifecycle.creating_session": "正在创建会话...", "lifecycle.failed_create_session": "创建会话失败。", "lifecycle.session_created_successfully": "会话创建成功:{session_name}\n会话 ID:{session_id}\n项目:{project_folder}\n提供方:{provider}\n分支:{branch_name}", + "health.claude_auth_failed_auth": "即便你自己的 Claude 登录状态正常,Claude 也未能验证此会话的身份({detail})。\n\n要在运行此机器人的主机上修复:\n1. 运行:claude setup-token\n2. 在打开的浏览器中批准访问,然后回到终端——真正的令牌会显示在终端里(以 sk-ant-oat01- 开头),而不是在浏览器里。复制那个,然后运行:\n{claude_auth_hint}\n(该命令会为你保存并验证令牌。)\n3. 重启机器人。\n\n其他选项请参阅 README 中的「常见问题 / 故障排查」章节。", "provider.cli_not_found": "未找到 {provider_label} CLI:{bin_name}\n运行 /provider 选择可用的提供方,或更新机器人配置。", "provider.status_available": "可用", "provider.status_missing": "缺失", @@ -171,6 +191,20 @@ "provider.not_selected": "(未选择)", "provider.cli_not_found_install_first": "未找到 {provider_label} CLI:{bin_name}\n请更新机器人配置,或先安装该 CLI。", "provider.current_provider_set": "当前提供方已设置为:{provider}", + "model.usage_model": "用法:/model 或 /model ", + "model.current_model_prompt": "当前模型:{model}\n请为当前活动会话选择模型,或输入 /model 使用此处未列出的模型。新会话始终从默认模型开始。", + "model.current_model_prompt_custom": "当前模型:{model}(自定义,不在下方列表中)\n请为当前活动会话选择模型,或输入 /model 使用其他自定义模型。新会话始终从默认模型开始。", + "model.current_model_set": "模型已设置为:{model}", + "model.default_option": "默认", + "model.status_current": "当前", + "model.cli_default": "CLI 默认", + "model.validating": "正在检查 '{model}' 是否为该提供方的有效模型…", + "model.invalid_model": "⚠️ '{model}' 已被 CLI 拒绝,因此未保存:\n{error}", + "model.invalid_model_generic": "CLI 拒绝了该模型,但未提供更多详细信息。", + "model.stale_selection": "⚠️ 此按钮已失效(模型列表或提供方可能已更改)。请重新运行 /model。", + "runtime.reply_options_prompt": "⚡ 需要操作——请选择一项以继续:", + "runtime.reply_option_select_button": "✅ 选择此选项", + "runtime.reply_option_selected": "▶️ 正在继续:{choice}", "status.current_session_details": "当前会话:{session_name}\n会话 ID:{session_id}\n项目:{project_folder}\n提供方:{provider}\n分支:{branch_name}", "status.current_branch_placeholder": "(当前分支)", "git.commit_disabled": "/commit 已禁用。\n请在 机器人 环境中将 ENABLE_COMMIT_COMMAND 设为 true 以启用它。", @@ -208,5 +242,24 @@ "status.usage_compact": "用法:/compact", "runtime.compacting_session": "正在压缩当前活动会话...", "runtime.compact_summary_missing": "提供方没有返回可用的压缩交接摘要。", - "runtime.session_compacted": "会话压缩成功。已切换到 {session_name} ({session_id})。" + "runtime.session_compacted": "会话压缩成功。已切换到 {session_name} ({session_id})。", + "runtime.switching_session": "正在启动新 session...", + "runtime.session_switched": "已启动新 session。已切换到 {session_name} ({session_id})。", + "runtime.long_gap_warning": "⏳ 这个 session 已经空闲了 {gap}。现在恢复很可能会从头重新处理整段对话(provider 的响应 cache 大概率已经过期),可能会比平时多消耗不少 token。compact 同样需要重新处理一次当前上下文来生成摘要,所以如果这个 session 已经很大,也会消耗不少 token。切换到新 session 可以完全跳过这次重新处理,但会失去这段对话的所有记忆。要切换到新 session、先 compact,还是直接继续?", + "runtime.long_gap_switch_button": "🆕 切换到新 session", + "runtime.long_gap_compact_button": "🔄 先 compact", + "runtime.long_gap_proceed_button": "⚠️ 直接继续", + "runtime.long_gap_proceeding": "正在现有 session 上继续...", + "runtime.long_gap_compacting": "继续之前正在 compact...", + "runtime.long_gap_switching": "正在切换到新 session...", + "bot.command.status": "显示各 provider 的配额使用情况(5小时/每周)", + "status.usage_status": "用法:/status", + "status.usage_title": "Provider 使用情况:", + "status.usage_five_hour": "5小时使用率", + "status.usage_weekly": "每周使用率", + "status.usage_unknown": "未知", + "status.usage_na": "N/A({detail})", + "status.usage_resets_in": "{duration}后重置", + "status.usage_unavailable": "不可用:{detail}", + "status.usage_last_observed": "(最近一次观测于 {duration} 前)" } diff --git a/src/coding_agent_telegram/resources/locales/zh-HK.json b/src/coding_agent_telegram/resources/locales/zh-HK.json index ad528c1..f35e275 100644 --- a/src/coding_agent_telegram/resources/locales/zh-HK.json +++ b/src/coding_agent_telegram/resources/locales/zh-HK.json @@ -4,11 +4,14 @@ "bot.command.commit": "執行已驗證的 Git commit 指令", "bot.command.current": "顯示目前使用中的工作階段", "bot.command.diff": "顯示相對 HEAD 已變更的檔案名稱", + "bot.command.model": "為使用中工作階段選擇模型", "bot.command.new": "建立新工作階段", - "bot.command.pull": "拉取目前工作階段分支", + "bot.command.log": "顯示最近 5 個 Git 提交", + "bot.command.pull": "Git 拉取目前工作階段分支", "bot.command.project": "設定目前專案資料夾", "bot.command.provider": "為新工作階段選擇供應方", - "bot.command.push": "推送目前工作階段分支", + "bot.command.push": "Git 推送目前工作階段分支", + "bot.command.reset": "Git reset --hard 至指定分支", "bot.command.switch": "列出工作階段或切換", "bot.error.command_failed": "⚠️ 指令失敗。請檢查伺服器日誌。", "bot.error.session_store": "⚠️ {error}", @@ -20,6 +23,10 @@ "common.project_folder_missing": "⚠️ 此工作階段的專案資料夾已不存在:{project_folder}", "common.button_expired": "⚠️ 此按鈕已過期。請重新執行命令。", "git.branch_unknown": "⚠️ 無法判斷目前工作階段的分支。", + "git.branch_discrepancy_warning": "⚠️ 偵測到分支不一致。目前工作階段分支為 `{session_branch}`,但儲存庫目前檢出的分支為 `{checked_out_branch}`。請先切換至預期分支,再執行此指令。", + "git.branch_discrepancy_switch_to_session": "切換至 {branch_name}", + "git.branch_discrepancy_use_current": "將工作階段分支改為 {branch_name}", + "git.detached_head_label": "分離的 HEAD", "git.cancel_button": "取消", "git.pull_cancelled": "已取消拉取。", "git.pull_completed": "已完成拉取。", @@ -28,6 +35,12 @@ "git.pull_confirm_prompt_with_default": "要從 `origin` 拉取預設分支 `{default_branch}` 與分支 `{branch_name}` 嗎?", "git.pull_in_progress": "正在從 `origin` 拉取分支 `{branch_name}`...", "git.pull_in_progress_with_default": "正在從 `origin` 拉取預設分支 `{default_branch}` 與分支 `{branch_name}`...", + "git.reset_cancelled": "已取消 Git reset。", + "git.reset_confirm_button": "確認 reset", + "git.reset_confirm_prompt": "要對目前分支執行 `git reset --hard {target_ref}` 嗎?", + "git.reset_in_progress": "正在執行 `git reset --hard {target_ref}`...", + "git.reset_pull_in_progress": "正在 reset 前拉取 `{target_ref}`...", + "git.reset_select_prompt": "請選擇 reset 的目標分支:", "git.push_cancelled": "已取消推送。", "git.push_cancelled_checkout_failed": "已取消 push。先切換到 `{branch_name}` 失敗。", "git.push_confirm_button": "確認推送", @@ -36,7 +49,10 @@ "git.usage_diff": "用法:/diff", "git.usage_pull": "用法:/pull", "git.usage_push": "用法:/push", + "git.usage_log": "用法:/log", + "git.usage_reset": "用法:/reset", "message.photo_only_codex": "目前只有 Codex 工作階段支援圖片附件。", + "message.photo_blocked_by_pending_action": "先前一則訊息觸發的操作仍在等待你的回覆(例如 compact/繼續 的確認提示)。請先處理完那個,再重新傳送這張圖片。", "message.question_queued": "問題已加入佇列,編號為 Q{question_number}。目前代理工作完成後會開始處理。", "message.voice_speech_to_text_disabled": "語音訊息功能尚未啟用。\n請先設定 ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true,並安裝本機 Whisper 依賴。", "message.unsupported_message_type": "不支援的訊息類型。\n此機械人目前接受文字訊息、圖片、語音訊息與音訊檔案。", @@ -61,6 +77,7 @@ "runtime.agent_run_failed": "代理執行失敗。", "runtime.live_agent_output": "代理即時輸出", "runtime.photo_too_large": "圖片太大。支援的最大大小為 5 MB。", + "runtime.photo_album_too_many": "圖片太多。單一相簿最多只能包含 {limit} 張圖片。", "runtime.voice_audio_too_large": "音訊太大,無法使用本機語音轉文字。支援的最大大小為 {max_size_mb} MB。", "runtime.provider_output_index": "{provider} 輸出 {index}/{total}", "runtime.provider_output_single": "{provider} 輸出", @@ -97,6 +114,8 @@ "switch.imported_into_state_json": "已匯入 state.json。", "switch.status_active": "使用中", "switch.status_idle": "閒置", + "switch.last_active_ago": "上次活動:{duration}前", + "switch.tokens_used": "已用約 {tokens} 個 token", "bootstrap.env_created_locale_line": "已建立 {env_path}。APP_LOCALE 已按系統語言初始化為 {app_locale}。", "bootstrap.env_created_change_line": "如果之後想切換語言,請修改 {env_path} 內的 APP_LOCALE。", "queue.button_cancel": "取消", @@ -162,6 +181,7 @@ "lifecycle.creating_session": "正在建立工作階段...", "lifecycle.failed_create_session": "建立工作階段失敗。", "lifecycle.session_created_successfully": "工作階段建立成功:{session_name}\n工作階段 ID:{session_id}\n專案:{project_folder}\n供應方:{provider}\n分支:{branch_name}", + "health.claude_auth_failed_auth": "即使你自己的 Claude 登入狀態正常,Claude 都無法驗證這個工作階段({detail})。\n\n要在執行此 bot 的主機上修復:\n1. 執行:claude setup-token\n2. 在開啟的瀏覽器中批准存取,然後返回終端機——真正的 token 會顯示在終端機(以 sk-ant-oat01- 開頭),而不是在瀏覽器中。複製那個,然後執行:\n{claude_auth_hint}\n(這個指令會幫你儲存並驗證。)\n3. 重新啟動 bot。\n\n其他選項請參閱 README 中的「常見問題 / 疑難排解」章節。", "provider.cli_not_found": "找不到 {provider_label} CLI:{bin_name}\n執行 /provider 選擇可用的供應方,或更新機械人設定。", "provider.status_available": "可用", "provider.status_missing": "缺少", @@ -171,6 +191,20 @@ "provider.not_selected": "(未選擇)", "provider.cli_not_found_install_first": "找不到 {provider_label} CLI:{bin_name}\n請更新機械人設定,或先安裝該 CLI。", "provider.current_provider_set": "目前供應方已設為:{provider}", + "model.usage_model": "用法:/model 或 /model ", + "model.current_model_prompt": "目前模型:{model}\n請選擇使用中工作階段的模型,或輸入 /model 以使用不在清單內的模型。新工作階段一律會使用預設模型。", + "model.current_model_prompt_custom": "目前模型:{model}(自訂,不在下方清單內)\n請為使用中工作階段選擇模型,或輸入 /model 以使用其他自訂模型。新工作階段一律會使用預設模型。", + "model.current_model_set": "模型已設為:{model}", + "model.default_option": "預設", + "model.status_current": "目前", + "model.cli_default": "CLI 預設", + "model.validating": "正在檢查 '{model}' 是否為此供應方的有效模型…", + "model.invalid_model": "⚠️ '{model}' 被 CLI 拒絕,因此並未儲存:\n{error}", + "model.invalid_model_generic": "CLI 拒絕了此模型,但沒有提供進一步詳情。", + "model.stale_selection": "⚠️ 此按鈕已失效(模型清單或供應方可能已變更)。請重新執行 /model。", + "runtime.reply_options_prompt": "⚡ 需要操作——請選擇一項以繼續:", + "runtime.reply_option_select_button": "✅ 選擇此選項", + "runtime.reply_option_selected": "▶️ 正在繼續:{choice}", "status.current_session_details": "目前工作階段:{session_name}\n工作階段 ID:{session_id}\n專案:{project_folder}\n供應方:{provider}\n分支:{branch_name}", "status.current_branch_placeholder": "(目前分支)", "git.commit_disabled": "/commit 已停用。\n請在機械人環境中把 ENABLE_COMMIT_COMMAND 設為 true 以啟用它。", @@ -208,5 +242,24 @@ "status.usage_compact": "用法:/compact", "runtime.compacting_session": "正在壓縮目前使用中的工作階段...", "runtime.compact_summary_missing": "供應方沒有返回可用的壓縮交接摘要。", - "runtime.session_compacted": "工作階段壓縮成功。已切換到 {session_name} ({session_id})。" + "runtime.session_compacted": "工作階段壓縮成功。已切換到 {session_name} ({session_id})。", + "runtime.switching_session": "正在啟動新的工作階段...", + "runtime.session_switched": "已啟動新的工作階段。已切換到 {session_name} ({session_id})。", + "runtime.long_gap_warning": "⏳ 這個 session 已經閒置了 {gap}。現在恢復很可能會把整段對話從頭重新處理一次(provider 的回覆 cache 很可能已經過期),可能會比平常多消耗不少 token。compact 同樣需要重新處理一次目前的內容來寫摘要,所以如果這個 session 已經很大,也可能消耗不少 token。切換到新 session 可以完全略過這次重新處理,但會失去這段對話的所有記憶。要切換到新 session、先 compact,還是直接繼續?", + "runtime.long_gap_switch_button": "🆕 切換到新 session", + "runtime.long_gap_compact_button": "🔄 先 compact", + "runtime.long_gap_proceed_button": "⚠️ 直接繼續", + "runtime.long_gap_proceeding": "繼續在現有 session 上...", + "runtime.long_gap_compacting": "繼續前先 compact...", + "runtime.long_gap_switching": "正在切換到新 session...", + "bot.command.status": "顯示各 provider 的配額使用情況(5小時/每週)", + "status.usage_status": "用法:/status", + "status.usage_title": "Provider 使用情況:", + "status.usage_five_hour": "5小時使用率", + "status.usage_weekly": "每週使用率", + "status.usage_unknown": "未知", + "status.usage_na": "N/A({detail})", + "status.usage_resets_in": "{duration}後重置", + "status.usage_unavailable": "不可用:{detail}", + "status.usage_last_observed": "(最近一次觀測於 {duration} 前)" } diff --git a/src/coding_agent_telegram/resources/locales/zh-TW.json b/src/coding_agent_telegram/resources/locales/zh-TW.json index 57a21f5..d4281b8 100644 --- a/src/coding_agent_telegram/resources/locales/zh-TW.json +++ b/src/coding_agent_telegram/resources/locales/zh-TW.json @@ -4,11 +4,14 @@ "bot.command.commit": "執行已驗證的 Git commit 指令", "bot.command.current": "顯示目前使用中的工作階段", "bot.command.diff": "顯示相對 HEAD 已變更的檔案名稱", + "bot.command.model": "為使用中的工作階段選擇模型", "bot.command.new": "建立新工作階段", - "bot.command.pull": "拉取目前工作階段分支", + "bot.command.log": "顯示最近 5 個 Git 提交", + "bot.command.pull": "Git 拉取目前工作階段分支", "bot.command.project": "設定目前專案資料夾", "bot.command.provider": "為新工作階段選擇提供者", - "bot.command.push": "推送目前工作階段分支", + "bot.command.push": "Git 推送目前工作階段分支", + "bot.command.reset": "Git reset --hard 至指定分支", "bot.command.switch": "列出工作階段或切換", "bot.error.command_failed": "⚠️ 指令失敗。請檢查伺服器日誌。", "bot.error.session_store": "⚠️ {error}", @@ -20,6 +23,10 @@ "common.project_folder_missing": "⚠️ 此工作階段的專案資料夾已不存在:{project_folder}", "common.button_expired": "⚠️ 此按鈕已過期。請重新執行命令。", "git.branch_unknown": "⚠️ 無法判斷目前工作階段的分支。", + "git.branch_discrepancy_warning": "⚠️ 偵測到分支不一致。目前工作階段分支為 `{session_branch}`,但儲存庫目前簽出的分支為 `{checked_out_branch}`。請先切換至預期分支,再執行此指令。", + "git.branch_discrepancy_switch_to_session": "切換至 {branch_name}", + "git.branch_discrepancy_use_current": "將工作階段分支改為 {branch_name}", + "git.detached_head_label": "分離的 HEAD", "git.cancel_button": "取消", "git.pull_cancelled": "已取消拉取。", "git.pull_completed": "已完成拉取。", @@ -28,6 +35,12 @@ "git.pull_confirm_prompt_with_default": "要從 `origin` 拉取預設分支 `{default_branch}` 與分支 `{branch_name}` 嗎?", "git.pull_in_progress": "正在從 `origin` 拉取分支 `{branch_name}`...", "git.pull_in_progress_with_default": "正在從 `origin` 拉取預設分支 `{default_branch}` 與分支 `{branch_name}`...", + "git.reset_cancelled": "已取消 Git reset。", + "git.reset_confirm_button": "確認 reset", + "git.reset_confirm_prompt": "要對目前分支執行 `git reset --hard {target_ref}` 嗎?", + "git.reset_in_progress": "正在執行 `git reset --hard {target_ref}`...", + "git.reset_pull_in_progress": "正在 reset 前拉取 `{target_ref}`...", + "git.reset_select_prompt": "請選擇 reset 的目標分支:", "git.push_cancelled": "已取消推送。", "git.push_cancelled_checkout_failed": "已取消 push。先切換到 `{branch_name}` 失敗。", "git.push_confirm_button": "確認推送", @@ -36,7 +49,10 @@ "git.usage_diff": "用法:/diff", "git.usage_pull": "用法:/pull", "git.usage_push": "用法:/push", + "git.usage_log": "用法:/log", + "git.usage_reset": "用法:/reset", "message.photo_only_codex": "目前只有 Codex 工作階段支援圖片附件。", + "message.photo_blocked_by_pending_action": "先前一則訊息觸發的操作仍在等待你的回覆(例如 compact/繼續 的確認提示)。請先處理完那個,再重新傳送這張圖片。", "message.question_queued": "問題已加入佇列,編號為 Q{question_number}。目前代理工作完成後會開始處理。", "message.voice_speech_to_text_disabled": "語音訊息功能尚未啟用。\n請先設定 ENABLE_OPENAI_WHISPER_SPEECH_TO_TEXT=true,並安裝本機 Whisper 依賴。", "message.unsupported_message_type": "不支援的訊息類型。\n此機器人目前接受文字訊息、圖片、語音訊息與音訊檔案。", @@ -61,6 +77,7 @@ "runtime.agent_run_failed": "代理執行失敗。", "runtime.live_agent_output": "代理即時輸出", "runtime.photo_too_large": "圖片太大。支援的最大大小為 5 MB。", + "runtime.photo_album_too_many": "圖片太多。單一相簿最多只能包含 {limit} 張圖片。", "runtime.voice_audio_too_large": "音訊太大,無法使用本機語音轉文字。支援的最大大小為 {max_size_mb} MB。", "runtime.provider_output_index": "{provider} 輸出 {index}/{total}", "runtime.provider_output_single": "{provider} 輸出", @@ -97,6 +114,8 @@ "switch.imported_into_state_json": "已匯入 state.json。", "switch.status_active": "使用中", "switch.status_idle": "閒置", + "switch.last_active_ago": "上次活動:{duration}前", + "switch.tokens_used": "已用約 {tokens} 個 token", "bootstrap.env_created_locale_line": "已建立 {env_path}。APP_LOCALE 已依系統語言初始化為 {app_locale}。", "bootstrap.env_created_change_line": "如果之後想切換語言,請修改 {env_path} 裡的 APP_LOCALE。", "queue.button_cancel": "取消", @@ -162,6 +181,7 @@ "lifecycle.creating_session": "正在建立工作階段...", "lifecycle.failed_create_session": "建立工作階段失敗。", "lifecycle.session_created_successfully": "工作階段建立成功:{session_name}\n工作階段 ID:{session_id}\n專案:{project_folder}\n提供者:{provider}\n分支:{branch_name}", + "health.claude_auth_failed_auth": "即使你自己的 Claude 登入狀態正常,Claude 都無法驗證這個工作階段({detail})。\n\n要在執行此 bot 的主機上修復:\n1. 執行:claude setup-token\n2. 在開啟的瀏覽器中核准存取,然後回到終端機——實際的權杖會顯示在終端機(以 sk-ant-oat01- 開頭),而不是在瀏覽器中。複製那個,然後執行:\n{claude_auth_hint}\n(該指令會幫你儲存並驗證。)\n3. 重新啟動 bot。\n\n其他選項請參閱 README 中的「常見問題 / 疑難排解」章節。", "provider.cli_not_found": "找不到 {provider_label} CLI:{bin_name}\n執行 /provider 選擇可用的提供者,或更新機器人設定。", "provider.status_available": "可用", "provider.status_missing": "缺少", @@ -171,6 +191,20 @@ "provider.not_selected": "(未選擇)", "provider.cli_not_found_install_first": "找不到 {provider_label} CLI:{bin_name}\n請更新機器人設定,或先安裝該 CLI。", "provider.current_provider_set": "目前提供者已設為:{provider}", + "model.usage_model": "用法:/model 或 /model ", + "model.current_model_prompt": "目前模型:{model}\n請選擇使用中工作階段要使用的模型,或輸入 /model 以使用未列在此處的模型。新工作階段一律會從預設模型開始。", + "model.current_model_prompt_custom": "目前模型:{model}(自訂,不在下方列表中)\n請選擇使用中工作階段要使用的模型,或輸入 /model 以使用其他自訂模型。新工作階段一律會從預設模型開始。", + "model.current_model_set": "模型已設為:{model}", + "model.default_option": "預設", + "model.status_current": "目前", + "model.cli_default": "CLI 預設", + "model.validating": "正在檢查 '{model}' 是否為此提供者的有效模型…", + "model.invalid_model": "⚠️ '{model}' 已被 CLI 拒絕,因此未儲存:\n{error}", + "model.invalid_model_generic": "CLI 拒絕了此模型,但未提供進一步細節。", + "model.stale_selection": "⚠️ 此按鈕已失效(模型列表或提供者可能已變更)。請重新執行 /model。", + "runtime.reply_options_prompt": "⚡ 需要操作——請選擇一項以繼續:", + "runtime.reply_option_select_button": "✅ 選擇此選項", + "runtime.reply_option_selected": "▶️ 正在繼續:{choice}", "status.current_session_details": "目前工作階段:{session_name}\n工作階段 ID:{session_id}\n專案:{project_folder}\n提供者:{provider}\n分支:{branch_name}", "status.current_branch_placeholder": "(目前分支)", "git.commit_disabled": "/commit 已停用。\n請在機器人環境中把 ENABLE_COMMIT_COMMAND 設為 true 以啟用它。", @@ -208,5 +242,24 @@ "status.usage_compact": "用法:/compact", "runtime.compacting_session": "正在壓縮目前使用中的工作階段...", "runtime.compact_summary_missing": "提供者沒有回傳可用的壓縮交接摘要。", - "runtime.session_compacted": "工作階段壓縮成功。已切換到 {session_name} ({session_id})。" + "runtime.session_compacted": "工作階段壓縮成功。已切換到 {session_name} ({session_id})。", + "runtime.switching_session": "正在啟動新的工作階段...", + "runtime.session_switched": "已啟動新的工作階段。已切換到 {session_name} ({session_id})。", + "runtime.long_gap_warning": "⏳ 這個 session 已經閒置了 {gap}。現在恢復很可能會把整段對話從頭重新處理一次(provider 的回覆 cache 很可能已經過期),可能會比平常多消耗不少 token。compact 同樣需要重新處理一次目前的內容來寫摘要,所以如果這個 session 已經很大,也可能消耗不少 token。切換到新 session 可以完全略過這次重新處理,但會失去這段對話的所有記憶。要切換到新 session、先 compact,還是直接繼續?", + "runtime.long_gap_switch_button": "🆕 切換到新 session", + "runtime.long_gap_compact_button": "🔄 先 compact", + "runtime.long_gap_proceed_button": "⚠️ 直接繼續", + "runtime.long_gap_proceeding": "繼續在現有 session 上...", + "runtime.long_gap_compacting": "繼續前先 compact...", + "runtime.long_gap_switching": "正在切換到新 session...", + "bot.command.status": "顯示各 provider 的配額使用情況(5小時/每週)", + "status.usage_status": "用法:/status", + "status.usage_title": "Provider 使用情況:", + "status.usage_five_hour": "5小時使用率", + "status.usage_weekly": "每週使用率", + "status.usage_unknown": "未知", + "status.usage_na": "N/A({detail})", + "status.usage_resets_in": "{duration}後重置", + "status.usage_unavailable": "不可用:{detail}", + "status.usage_last_observed": "(最近一次觀測於 {duration} 前)" } diff --git a/src/coding_agent_telegram/router/base.py b/src/coding_agent_telegram/router/base.py index 6bf0760..35615cf 100644 --- a/src/coding_agent_telegram/router/base.py +++ b/src/coding_agent_telegram/router/base.py @@ -6,6 +6,7 @@ import logging import os import re +import secrets import shlex from collections import deque from concurrent.futures import CancelledError, Future @@ -27,12 +28,22 @@ from coding_agent_telegram.session_runtime import PhotoAttachmentStore, SessionRuntime from coding_agent_telegram.session_store import SessionStore from coding_agent_telegram.speech_to_text import WhisperSpeechToText -from coding_agent_telegram.telegram_sender import send_text +from coding_agent_telegram.telegram_sender import ( + affirmative_inline_button_kwargs, + negative_inline_button_kwargs, + send_text, +) logger = logging.getLogger(__name__) TYPING_REFRESH_TIMEOUT_SECONDS = 4 PROGRESS_PREVIEW_MAX_CHARS = 600 +# Cap on pending agent-reply-option tokens. Consumed tokens are popped in +# message_commands.py, but a button the user never taps leaves its token behind +# forever -- bound the dict so an idle bot serving many chats can't grow it without +# limit. Dicts preserve insertion order, so evicting the oldest entries is a cheap +# approximation of "least likely to still be tapped". +MAX_AGENT_REPLY_OPTION_TOKENS = 500 def require_allowed_chat(*, answer_callback: bool = False): @@ -118,6 +129,7 @@ def __init__(self, deps: RouterDeps) -> None: bot_id=deps.bot_id, git=self.git, run_with_typing=self._run_with_typing, + register_reply_options=self._register_agent_reply_options, ) # Per-workspace asyncio locks keyed by project_folder name. # Prevents concurrent agent runs on the same workspace regardless of @@ -131,7 +143,16 @@ def __init__(self, deps: RouterDeps) -> None: self._chat_next_queue_file_index: dict[int, int] = {} self._chat_message_queue_draining: set[int] = set() self._last_run_results: dict[int, object] = {} + # Monotonic deadline before which a session's idle gap provably cannot have + # crossed its provider's long-gap threshold, keyed by "provider:session_id". + # Lets a burst of quick messages on an active session skip the + # filesystem/sqlite lookup instead of repeating it on every message. Stores the + # crossing time rather than the check time so a session checked just *under* + # the threshold can't stay cached past it -- see _maybe_warn_long_gap in + # message_commands.py. + self._session_gap_safe_until: dict[str, float] = {} self._branch_source_tokens: dict[str, tuple[str, str, str]] = {} + self._agent_reply_option_tokens: dict[str, tuple[int, tuple[str, ...]]] = {} def _register_branch_source_token(self, source_kind: str, source_branch: str, new_branch: str) -> str: key = f"{source_kind}:{source_branch}:{new_branch}" @@ -142,6 +163,14 @@ def _register_branch_source_token(self, source_kind: str, source_branch: str, ne def _lookup_branch_source_token(self, token: str) -> tuple[str, str, str] | None: return self._branch_source_tokens.get(token) + def _register_agent_reply_options(self, chat_id: int, options: tuple[str, ...]) -> str: + if len(self._agent_reply_option_tokens) >= MAX_AGENT_REPLY_OPTION_TOKENS: + oldest_token = next(iter(self._agent_reply_option_tokens)) + del self._agent_reply_option_tokens[oldest_token] + token = secrets.token_hex(6) + self._agent_reply_option_tokens[token] = (chat_id, options) + return token + def _sorted_sessions(self, sessions: dict[str, dict[str, str]]) -> list[tuple[str, dict[str, str]]]: indexed_sessions = list(enumerate(sessions.items())) sorted_indexed_sessions = sorted( @@ -227,10 +256,10 @@ def _t(self, update: Update | None, key: str, **kwargs) -> str: return translate(self._locale(update), key, **kwargs) def _affirmative_inline_button_kwargs(self) -> dict[str, dict[str, str]]: - return {"api_kwargs": {"style": "primary"}} + return affirmative_inline_button_kwargs() def _negative_inline_button_kwargs(self) -> dict[str, dict[str, str]]: - return {"api_kwargs": {"style": "danger"}} + return negative_inline_button_kwargs() async def _notify_if_current_project_busy(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool: chat = update.effective_chat diff --git a/src/coding_agent_telegram/router/git_commands.py b/src/coding_agent_telegram/router/git_commands.py index 9fa626b..f265c9f 100644 --- a/src/coding_agent_telegram/router/git_commands.py +++ b/src/coding_agent_telegram/router/git_commands.py @@ -3,12 +3,15 @@ import asyncio import html import os +import secrets +from contextlib import asynccontextmanager from types import SimpleNamespace from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import ContextTypes from coding_agent_telegram.diff_utils import chunk_fenced_diff, collect_diffs, split_changed_files +from coding_agent_telegram.filters import resolve_project_path from coding_agent_telegram.telegram_sender import send_code_block, send_html_text, send_text, split_assistant_output from .base import require_allowed_chat @@ -16,6 +19,12 @@ class GitCommandMixin: DIFF_BUTTON_PAGE_SIZE = 10 + MAX_DIFF_SNAPSHOTS = 500 + MAX_COMMIT_GENERATION_PROMPTS = 500 + MAX_GENERATED_COMMIT_COMMANDS = 500 + MAX_GIT_CONFIRMATIONS = 500 + MAX_RESET_PROMPTS = 500 + MAX_RESET_SELECTIONS = 500 COMMIT_GENERATION_PROMPT = ( 'Execute: Analyze and compare to git HEAD, then Generate a git commit command for the files you changed in this task, with a detailed changelog-style commit message. ' 'Only include files you intentionally modified for this task. ' @@ -34,31 +43,41 @@ def _diff_button_label(index: int, path: str, *, max_name_length: int = 20) -> s name = f"{name[: max_name_length - 1]}…" return f"{index}. {name}" - def _build_diff_button_rows(self, update: Update, tracked_files: list[str], *, page: int) -> list[list[InlineKeyboardButton]]: + @staticmethod + def _diff_display_path(path: str, *, max_length: int = 100) -> str: + display = path.replace("\n", "\\n").replace("\r", "\\r") + if len(display) > max_length: + return f"{display[: max_length - 1]}…" + return display + + def _build_diff_button_rows( + self, + update: Update, + tracked_files: list[str], + *, + page: int, + token: str, + total_pages: int, + ) -> list[list[InlineKeyboardButton]]: rows: list[list[InlineKeyboardButton]] = [] - total_pages = max(1, (len(tracked_files) + self.DIFF_BUTTON_PAGE_SIZE - 1) // self.DIFF_BUTTON_PAGE_SIZE) page = min(max(page, 0), total_pages - 1) start = page * self.DIFF_BUTTON_PAGE_SIZE page_files = tracked_files[start : start + self.DIFF_BUTTON_PAGE_SIZE] - row: list[InlineKeyboardButton] = [] for offset, path in enumerate(page_files, start=1): absolute_index = start + offset - row.append( + rows.append( + [ InlineKeyboardButton( self._diff_button_label(absolute_index, path), - callback_data=f"diffshow:{absolute_index - 1}", + callback_data=f"diffshow:{token}:{absolute_index - 1}", ) + ] ) - if len(row) == 2: - rows.append(row) - row = [] - if row: - rows.append(row) nav_row: list[InlineKeyboardButton] = [] if page > 0: - nav_row.append(InlineKeyboardButton(self._t(update, "diff.button_prev_page"), callback_data=f"diffpage:{page - 1}")) + nav_row.append(InlineKeyboardButton(self._t(update, "diff.button_prev_page"), callback_data=f"diffpage:{token}:{page - 1}")) if page < total_pages - 1: - nav_row.append(InlineKeyboardButton(self._t(update, "diff.button_next_page"), callback_data=f"diffpage:{page + 1}")) + nav_row.append(InlineKeyboardButton(self._t(update, "diff.button_next_page"), callback_data=f"diffpage:{token}:{page + 1}")) if nav_row: rows.append(nav_row) return rows @@ -72,11 +91,15 @@ def _build_diff_message( tracked_files: list[str], untracked_files: list[str], page: int, + token: str, ) -> tuple[str, InlineKeyboardMarkup | None]: - total_pages = max(1, (len(tracked_files) + self.DIFF_BUTTON_PAGE_SIZE - 1) // self.DIFF_BUTTON_PAGE_SIZE) + tracked_pages = (len(tracked_files) + self.DIFF_BUTTON_PAGE_SIZE - 1) // self.DIFF_BUTTON_PAGE_SIZE + untracked_pages = (len(untracked_files) + self.DIFF_BUTTON_PAGE_SIZE - 1) // self.DIFF_BUTTON_PAGE_SIZE + total_pages = max(1, tracked_pages, untracked_pages) page = min(max(page, 0), total_pages - 1) start = page * self.DIFF_BUTTON_PAGE_SIZE page_files = tracked_files[start : start + self.DIFF_BUTTON_PAGE_SIZE] + page_untracked_files = untracked_files[start : start + self.DIFF_BUTTON_PAGE_SIZE] lines = [ self._t(update, "diff.session_label", session_name=session["name"]), f"{self._t(update, 'diff.project_label', project_folder=session['project_folder'])} <{branch_name}>", @@ -94,17 +117,37 @@ def _build_diff_message( total=len(tracked_files), ) ) - lines.extend(f"{start + index}. {path}" for index, path in enumerate(page_files, start=1)) + lines.extend( + f"{start + index}. {self._diff_display_path(path)}" + for index, path in enumerate(page_files, start=1) + ) else: lines.append(f"- {self._t(update, 'diff.none')}") lines.extend(["", self._t(update, "diff.untracked_files")]) - if untracked_files: - lines.extend(f"- {path}" for path in untracked_files) + if page_untracked_files: + if len(untracked_files) > len(page_untracked_files): + lines.append( + self._t( + update, + "diff.tracked_files_page_info", + start=start + 1, + end=start + len(page_untracked_files), + total=len(untracked_files), + ) + ) + lines.extend(f"- {self._diff_display_path(path)}" for path in page_untracked_files) else: lines.append(f"- {self._t(update, 'diff.none')}") - if tracked_files: + if page_files: lines.extend(["", self._t(update, "diff.click_button_to_see_file_diff")]) - reply_markup = InlineKeyboardMarkup(self._build_diff_button_rows(update, tracked_files, page=page)) if tracked_files else None + rows = self._build_diff_button_rows( + update, + tracked_files, + page=page, + token=token, + total_pages=total_pages, + ) + reply_markup = InlineKeyboardMarkup(rows) if rows else None return "\n".join(lines), reply_markup async def _refresh_branch_with_checkout( @@ -126,13 +169,309 @@ async def _refresh_branch_with_checkout( return False, result.message, () return True, result.message, tuple(result.warnings) - def _generated_commit_commands(self) -> dict[int, dict[str, str]]: + def _generated_commit_commands(self) -> dict[str, dict[str, str]]: commands = getattr(self, "_chat_generated_commit_commands", None) if not isinstance(commands, dict): commands = {} self._chat_generated_commit_commands = commands return commands + def _commit_generation_prompts(self) -> dict[str, dict[str, str]]: + prompts = getattr(self, "_chat_commit_generation_prompts", None) + if not isinstance(prompts, dict): + prompts = {} + self._chat_commit_generation_prompts = prompts + return prompts + + def _diff_snapshots(self) -> dict[str, dict[str, object]]: + snapshots = getattr(self, "_chat_diff_snapshots", None) + if not isinstance(snapshots, dict): + snapshots = {} + self._chat_diff_snapshots = snapshots + return snapshots + + def _git_confirmations(self) -> dict[str, dict[str, str]]: + confirmations = getattr(self, "_chat_git_confirmations", None) + if not isinstance(confirmations, dict): + confirmations = {} + self._chat_git_confirmations = confirmations + return confirmations + + @staticmethod + def _new_unique_token(records: dict[str, object]) -> str: + while True: + token = secrets.token_hex(6) + if token not in records: + return token + + @staticmethod + def _escape_markdown_code_value(value: object) -> str: + return str(value).replace("\\", "\\\\").replace("`", "\\`") + + @staticmethod + def _store_bounded_record(records: dict[str, object], token: str, payload: object, *, limit: int) -> None: + if len(records) >= limit: + records.pop(next(iter(records)), None) + records[token] = payload + + def _register_git_confirmation( + self, + *, + chat_id: int, + session: dict[str, object], + action: str, + branch_name: str, + default_branch: str = "", + ) -> str: + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, chat_id) + confirmations = self._git_confirmations() + token = self._new_unique_token(confirmations) + self._store_bounded_record( + confirmations, + token, + { + "action": action, + "chat_id": str(chat_id), + "session_id": str(chat_state.get("active_session_id") or ""), + "project_folder": str(session["project_folder"]), + "branch_name": branch_name, + "default_branch": default_branch, + }, + limit=self.MAX_GIT_CONFIRMATIONS, + ) + return token + + def _get_git_confirmation(self, token: str, *, chat_id: int, action: str) -> dict[str, str] | None: + confirmation = self._git_confirmations().get(token) + if ( + confirmation is None + or confirmation.get("chat_id") != str(chat_id) + or confirmation.get("action") != action + ): + return None + return confirmation + + def _reset_selections(self) -> dict[str, dict[str, str]]: + selections = getattr(self, "_chat_reset_selections", None) + if not isinstance(selections, dict): + selections = {} + self._chat_reset_selections = selections + return selections + + def _reset_prompts(self) -> dict[str, dict[str, str]]: + prompts = getattr(self, "_chat_reset_prompts", None) + if not isinstance(prompts, dict): + prompts = {} + self._chat_reset_prompts = prompts + return prompts + + @asynccontextmanager + async def _workspace_git_operation_lock( + self, + update: Update, + context: ContextTypes.DEFAULT_TYPE, + project_folder: str, + ): + lock = self._workspace_locks.setdefault(project_folder, asyncio.Lock()) + if lock.locked(): + await send_text( + update, + context, + self._t(update, "common.project_busy", project_folder=project_folder), + ) + yield False + return + async with lock: + yield True + + def _new_reset_selection_token(self) -> str: + selections = self._reset_selections() + while True: + token = secrets.token_hex(6) + if token not in selections: + return token + + @staticmethod + def _reset_target(current_branch: str, default_branch: str, target_kind: str) -> tuple[str, bool] | None: + targets = { + "local-default": (default_branch, False), + "origin-default": (f"origin/{default_branch}", True), + "local-current": (current_branch, False), + "origin-current": (f"origin/{current_branch}", True), + } + target = targets.get(target_kind) + if target is None or not target[0] or target[0] == "origin/": + return None + return target + + async def _restore_reset_branch( + self, + project_path, + branch_name: str, + ) -> tuple[bool, str | None]: + if self.git.current_branch(project_path) == branch_name: + return True, None + checkout = await asyncio.to_thread(self.git.checkout_branch, project_path, branch_name) + if checkout.success: + return True, None + return False, checkout.message + + async def _warn_if_session_branch_discrepancy( + self, + update: Update, + context: ContextTypes.DEFAULT_TYPE, + session: dict[str, object], + project_path, + ) -> bool: + session_branch = str(session.get("branch_name") or "").strip() + checked_out_branch = str(self.git.current_branch(project_path) or "").strip() + if not session_branch or session_branch == checked_out_branch: + return False + checked_out_label = checked_out_branch or self._t(update, "git.detached_head_label") + buttons = [ + [ + InlineKeyboardButton( + self._t(update, "git.branch_discrepancy_switch_to_session", branch_name=session_branch), + callback_data="gitbranchdiscrepancy:stored", + ) + ] + ] + # There is no branch that can be recorded for a detached HEAD, so only + # offer the safe restoration action in that case. + if checked_out_branch: + buttons.append( + [ + InlineKeyboardButton( + self._t(update, "git.branch_discrepancy_use_current", branch_name=checked_out_branch), + callback_data="gitbranchdiscrepancy:current", + ) + ] + ) + await send_text( + update, + context, + self._t( + update, + "git.branch_discrepancy_warning", + session_branch=session_branch, + checked_out_branch=checked_out_label, + ), + reply_markup=InlineKeyboardMarkup(buttons), + ) + return True + + @require_allowed_chat(answer_callback=True) + async def handle_git_branch_discrepancy_callback( + self, update: Update, context: ContextTypes.DEFAULT_TYPE + ) -> None: + """Resolve a discrepancy reported by a Git command via the /branch path.""" + query = update.callback_query + if query is None or query.data is None: + return + await query.answer() + + choice = query.data.partition("gitbranchdiscrepancy:")[2] + if choice not in {"stored", "current"}: + return + + chat_id = update.effective_chat.id + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, chat_id) + active_session_id = chat_state.get("active_session_id") + session = chat_state.get("sessions", {}).get(active_session_id) if active_session_id else None + if not isinstance(session, dict): + await query.edit_message_text(self._t(update, "branch_resolution.no_active_session")) + return + + project_folder = str(session.get("project_folder") or "").strip() + project_path = resolve_project_path(self.deps.cfg.workspace_root, project_folder) + if not project_path.exists() or not project_path.is_dir(): + await query.edit_message_text( + self._t(update, "project.project_folder_missing_retry", project_folder=project_folder) + ) + return + if self._is_project_busy(chat_id): + await query.edit_message_text(self._t(update, "common.project_busy", project_folder=project_folder)) + return + + stored_branch = str(session.get("branch_name") or "").strip() + current_branch = str(self.git.current_branch(project_path) or "").strip() + target_branch = stored_branch if choice == "stored" else current_branch + if not target_branch: + await query.edit_message_text(self._t(update, "git.branch_unknown")) + return + + # This is the operation performed by /branch : prefer a + # local branch, otherwise prepare the matching origin branch. + source_kind = "local" if self.git.local_branch_exists(project_path, target_branch) else "origin" + result = await asyncio.to_thread( + self.git.prepare_branch_from_source, + project_path, + source_kind=source_kind, + source_branch=target_branch, + new_branch=target_branch, + ) + if not result.success: + await query.edit_message_text(result.message) + return + + self.deps.store.set_current_branch(self.deps.bot_id, chat_id, result.current_branch) + self.deps.store.set_active_session_branch(self.deps.bot_id, chat_id, result.current_branch or "") + await query.edit_message_text( + "\n".join( + [ + result.message, + self._t(update, "project.current_branch_html", branch_name=result.current_branch), + ] + ) + ) + + async def _execute_confirmed_reset( + self, + update: Update, + context: ContextTypes.DEFAULT_TYPE, + query, + selection: dict[str, str], + project_path, + ) -> None: + target_ref = selection["target_ref"] + if selection["is_origin"] == "True": + await query.edit_message_text( + self._t(update, "git.reset_pull_in_progress", target_ref=self._escape_markdown_code_value(target_ref)), + parse_mode="Markdown", + ) + ok, message, warnings = await self._refresh_branch_with_checkout( + update, + context, + project_path=project_path, + branch_name=selection["target_branch"], + ) + restored, restore_message = await self._restore_reset_branch(project_path, selection["reset_branch"]) + if not restored: + await send_text(update, context, restore_message or self._t(update, "bot.error.command_failed")) + return + if not ok: + await send_text(update, context, message or self._t(update, "bot.error.command_failed")) + return + if warnings: + await send_text(update, context, "\n".join([self._t(update, "project.refresh_warnings"), *[f"- {warning}" for warning in warnings]])) + return + else: + restored, restore_message = await self._restore_reset_branch(project_path, selection["reset_branch"]) + if not restored: + await query.edit_message_text(restore_message or self._t(update, "bot.error.command_failed")) + return + + await query.edit_message_text( + self._t(update, "git.reset_in_progress", target_ref=self._escape_markdown_code_value(target_ref)), + parse_mode="Markdown", + ) + result = await asyncio.to_thread(self.git.run_git_command, project_path, ["reset", "--hard", target_ref]) + await send_html_text( + update, + context, + self._bash_block(self._format_git_response([(["reset", "--hard", target_ref], result)], [])), + ) + def _extract_generated_commit_command(self, assistant_text: str) -> str | None: for segment in split_assistant_output(assistant_text or ""): if segment.kind != "code": @@ -175,17 +514,33 @@ async def handle_commit(self, update: Update, context: ContextTypes.DEFAULT_TYPE ) if session is None or project_path is None: return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, update.effective_chat.id) + prompts = self._commit_generation_prompts() + token = self._new_unique_token(prompts) + self._store_bounded_record( + prompts, + token, + { + "chat_id": str(update.effective_chat.id), + "session_id": str(chat_state.get("active_session_id") or ""), + "project_folder": str(session["project_folder"]), + "branch_name": str(session.get("branch_name") or self.git.current_branch(project_path) or ""), + }, + limit=self.MAX_COMMIT_GENERATION_PROMPTS, + ) confirm_markup = InlineKeyboardMarkup( [ [ InlineKeyboardButton( self._t(update, "git.commit_generate_button"), - callback_data="commitgen:confirm", + callback_data=f"commitgen:confirm:{token}", **self._affirmative_inline_button_kwargs(), ), InlineKeyboardButton( self._t(update, "git.cancel_button"), - callback_data="commitgen:cancel", + callback_data=f"commitgen:cancel:{token}", **self._negative_inline_button_kwargs(), ), ] @@ -205,6 +560,8 @@ async def handle_commit(self, update: Update, context: ContextTypes.DEFAULT_TYPE ) if session is None or project_path is None: return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return commands, ignored = self._validated_commit_commands(raw) if not commands: @@ -221,20 +578,27 @@ async def handle_commit(self, update: Update, context: ContextTypes.DEFAULT_TYPE await send_text(update, context, self._t(update, "git.unsafe_path_arguments")) return - command_results: list[tuple[list[str], object]] = [] - for args in commands: - executed_args = self._effective_git_args(args) - result = await asyncio.to_thread(self.git.run_safe_commit_command, project_path, executed_args) - command_results.append((executed_args, result)) - if not result.success: - await send_html_text( - update, - context, - self._bash_block(self._format_git_response(command_results, ignored)), - ) + async with self._workspace_git_operation_lock( + update, + context, + str(session["project_folder"]), + ) as acquired: + if not acquired: return + command_results: list[tuple[list[str], object]] = [] + for args in commands: + executed_args = self._effective_git_args(args) + result = await asyncio.to_thread(self.git.run_safe_commit_command, project_path, executed_args) + command_results.append((executed_args, result)) + if not result.success: + await send_html_text( + update, + context, + self._bash_block(self._format_git_response(command_results, ignored)), + ) + return - await send_html_text(update, context, self._bash_block(self._format_git_response(command_results, ignored))) + await send_html_text(update, context, self._bash_block(self._format_git_response(command_results, ignored))) @require_allowed_chat() async def handle_diff(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -249,12 +613,30 @@ async def handle_diff(self, update: Update, context: ContextTypes.DEFAULT_TYPE) ) if session is None or project_path is None: return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return branch_name = session.get("branch_name") or self.git.current_branch(project_path) or self._t( update, "status.current_branch_placeholder", ) tracked_files, untracked_files = split_changed_files(project_path) + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, update.effective_chat.id) + snapshots = self._diff_snapshots() + token = self._new_unique_token(snapshots) + self._store_bounded_record( + snapshots, + token, + { + "chat_id": str(update.effective_chat.id), + "session_id": str(chat_state.get("active_session_id") or ""), + "project_folder": str(session["project_folder"]), + "branch_name": str(branch_name), + "tracked_files": tuple(tracked_files), + "untracked_files": tuple(untracked_files), + }, + limit=self.MAX_DIFF_SNAPSHOTS, + ) text, reply_markup = self._build_diff_message( update, session, @@ -262,6 +644,7 @@ async def handle_diff(self, update: Update, context: ContextTypes.DEFAULT_TYPE) tracked_files=tracked_files, untracked_files=untracked_files, page=0, + token=token, ) await context.bot.send_message( chat_id=update.effective_chat.id, @@ -278,30 +661,53 @@ async def handle_diff_callback(self, update: Update, context: ContextTypes.DEFAU await query.answer() data = (query.data or "").strip() - if data.startswith("diffpage:"): - try: - page = int(data.partition(":")[2]) - except ValueError: - return - session, project_path = await self._active_session_project_or_notify( - update, - context, - require_git_repo=True, - ) - if session is None or project_path is None: - return + parts = data.split(":") + if len(parts) != 3: + return + action, token, raw_index = parts + snapshot = self._diff_snapshots().get(token) + if snapshot is None or snapshot.get("chat_id") != str(update.effective_chat.id): + await query.edit_message_text(self._t(update, "common.button_expired")) + return + try: + index = int(raw_index) + except ValueError: + return + + session, project_path = await self._active_session_project_or_notify( + update, + context, + require_git_repo=True, + ) + if session is None or project_path is None: + return + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, update.effective_chat.id) + active_branch = str(session.get("branch_name") or self.git.current_branch(project_path) or "").strip() + if ( + str(chat_state.get("active_session_id") or "") != snapshot.get("session_id") + or str(session["project_folder"]) != snapshot.get("project_folder") + or active_branch != snapshot.get("branch_name") + ): + await query.edit_message_text(self._t(update, "common.button_expired")) + return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return + + tracked_files = list(snapshot.get("tracked_files") or ()) + untracked_files = list(snapshot.get("untracked_files") or ()) + if action == "diffpage": branch_name = session.get("branch_name") or self.git.current_branch(project_path) or self._t( update, "status.current_branch_placeholder", ) - tracked_files, untracked_files = split_changed_files(project_path) text, reply_markup = self._build_diff_message( update, session, branch_name=branch_name, tracked_files=tracked_files, untracked_files=untracked_files, - page=page, + page=index, + token=token, ) await query.edit_message_text( text=html.escape(text), @@ -309,27 +715,13 @@ async def handle_diff_callback(self, update: Update, context: ContextTypes.DEFAU reply_markup=reply_markup, ) return - if not data.startswith("diffshow:"): + if action != "diffshow": return - try: - file_index = int(data.partition(":")[2]) - except ValueError: - return - - session, project_path = await self._active_session_project_or_notify( - update, - context, - require_git_repo=True, - ) - if session is None or project_path is None: - return - - tracked_files, _ = split_changed_files(project_path) - if file_index < 0 or file_index >= len(tracked_files): + if index < 0 or index >= len(tracked_files): await send_text(update, context, self._t(update, "diff.none")) return - file_path = tracked_files[file_index] + file_path = tracked_files[index] diffs = collect_diffs(project_path, [file_path], include_cached=True) if not diffs: await send_text(update, context, self._t(update, "diff.none")) @@ -361,11 +753,42 @@ async def handle_commit_generate_callback(self, update: Update, context: Context await query.answer() action = (query.data or "").strip() - if action == "commitgen:cancel": + parts = action.split(":") + if len(parts) != 3 or parts[0] != "commitgen" or parts[1] not in {"confirm", "cancel"}: + return + _, choice, token = parts + prompt = self._commit_generation_prompts().get(token) + if prompt is None or prompt.get("chat_id") != str(update.effective_chat.id): + await query.edit_message_text(self._t(update, "common.button_expired")) + return + if choice == "cancel": + self._commit_generation_prompts().pop(token, None) await query.edit_message_text(self._t(update, "git.commit_generate_cancelled")) return - if action != "commitgen:confirm": + if await self._notify_if_current_project_busy(update, context): + return + + session, project_path = await self._active_session_project_or_notify( + update, + context, + require_git_repo=True, + ) + if session is None or project_path is None: + return + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, update.effective_chat.id) + active_session_id = str(chat_state.get("active_session_id") or "").strip() + active_branch = str(session.get("branch_name") or self.git.current_branch(project_path) or "").strip() + if ( + active_session_id != prompt["session_id"] + or str(session["project_folder"]) != prompt["project_folder"] + or active_branch != prompt["branch_name"] + ): + self._commit_generation_prompts().pop(token, None) + await query.edit_message_text(self._t(update, "git.commit_execute_context_changed")) + return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): return + self._commit_generation_prompts().pop(token, None) generated_command = await self._generate_commit_command_with_provider(update, context) if generated_command is None: @@ -381,23 +804,39 @@ async def handle_commit_generate_callback(self, update: Update, context: Context if not isinstance(session, dict): await query.edit_message_text(self._t(update, "common.no_active_session")) return - - self._generated_commit_commands()[update.effective_chat.id] = { - "command": generated_command, - "session_id": active_session_id, - "project_folder": str(session.get("project_folder") or ""), - } + generated_branch = str(session.get("branch_name") or self.git.current_branch(project_path) or "").strip() + if ( + active_session_id != prompt["session_id"] + or str(session.get("project_folder") or "") != prompt["project_folder"] + or generated_branch != prompt["branch_name"] + ): + await query.edit_message_text(self._t(update, "git.commit_execute_context_changed")) + return + commands = self._generated_commit_commands() + command_token = self._new_unique_token(commands) + self._store_bounded_record( + commands, + command_token, + { + "chat_id": str(update.effective_chat.id), + "command": generated_command, + "session_id": active_session_id, + "project_folder": str(session.get("project_folder") or ""), + "branch_name": generated_branch, + }, + limit=self.MAX_GENERATED_COMMIT_COMMANDS, + ) execute_markup = InlineKeyboardMarkup( [ [ InlineKeyboardButton( self._t(update, "git.commit_execute_button"), - callback_data="commitexec:confirm", + callback_data=f"commitexec:confirm:{command_token}", **self._affirmative_inline_button_kwargs(), ), InlineKeyboardButton( self._t(update, "git.cancel_button"), - callback_data="commitexec:cancel", + callback_data=f"commitexec:cancel:{command_token}", **self._negative_inline_button_kwargs(), ), ] @@ -414,6 +853,8 @@ async def _generate_commit_command_with_provider(self, update: Update, context: ) if session is None or project_path is None: return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return result = await self.runtime.run_active_session(update, context, user_message=self.COMMIT_GENERATION_PROMPT) if result is None or not result.success: @@ -428,30 +869,49 @@ async def handle_commit_execute_callback(self, update: Update, context: ContextT await query.answer() action = (query.data or "").strip() - if action == "commitexec:cancel": + parts = action.split(":") + if len(parts) != 3 or parts[0] != "commitexec" or parts[1] not in {"confirm", "cancel"}: + return + _, choice, token = parts + payload = self._generated_commit_commands().get(token) + if payload is None or payload.get("chat_id") != str(update.effective_chat.id): + await query.edit_message_text(self._t(update, "common.button_expired")) + return + if choice == "cancel": + self._generated_commit_commands().pop(token, None) await query.edit_message_text(self._t(update, "git.commit_generate_cancelled")) return - if action != "commitexec:confirm": + if await self._notify_if_current_project_busy(update, context): return - - payload = self._generated_commit_commands().get(update.effective_chat.id) - if payload is None: - await query.edit_message_text(self._t(update, "git.no_valid_commit_commands")) + active_session, active_project_path = await self._active_session_project_or_notify( + update, + context, + require_git_repo=True, + ) + if active_session is None or active_project_path is None: return chat_state = self.deps.store.get_chat_state(self.deps.bot_id, update.effective_chat.id) active_session_id = str(chat_state.get("active_session_id") or "").strip() - active_session = chat_state.get("sessions", {}).get(active_session_id) if active_session_id else None - active_project_folder = str(active_session.get("project_folder") or "") if isinstance(active_session, dict) else "" + active_project_folder = str(active_session.get("project_folder") or "") + active_branch = str(active_session.get("branch_name") or self.git.current_branch(active_project_path) or "").strip() if ( active_session_id != str(payload.get("session_id") or "") or active_project_folder != str(payload.get("project_folder") or "") + or active_branch != str(payload.get("branch_name") or "") ): - self._generated_commit_commands().pop(update.effective_chat.id, None) + self._generated_commit_commands().pop(token, None) await query.edit_message_text(self._t(update, "git.commit_execute_context_changed")) return + if await self._warn_if_session_branch_discrepancy( + update, + context, + active_session, + active_project_path, + ): + return command = str(payload.get("command") or "").strip() if not command: - self._generated_commit_commands().pop(update.effective_chat.id, None) + self._generated_commit_commands().pop(token, None) await query.edit_message_text(self._t(update, "git.no_valid_commit_commands")) return @@ -464,7 +924,7 @@ async def handle_commit_execute_callback(self, update: Update, context: ContextT try: await self.handle_commit(synthetic_update, synthetic_context) finally: - self._generated_commit_commands().pop(update.effective_chat.id, None) + self._generated_commit_commands().pop(token, None) @require_allowed_chat() async def handle_push(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -481,23 +941,31 @@ async def handle_push(self, update: Update, context: ContextTypes.DEFAULT_TYPE) ) if session is None or project_path is None: return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return branch_name = session.get("branch_name") or self.git.current_branch(project_path) if not branch_name: await send_text(update, context, self._t(update, "git.branch_unknown")) return + token = self._register_git_confirmation( + chat_id=update.effective_chat.id, + session=session, + action="push", + branch_name=str(branch_name), + ) confirm_markup = InlineKeyboardMarkup( [ [ InlineKeyboardButton( self._t(update, "git.push_confirm_button"), - callback_data="push:confirm", + callback_data=f"push:confirm:{token}", **self._affirmative_inline_button_kwargs(), ), InlineKeyboardButton( self._t(update, "git.cancel_button"), - callback_data="push:cancel", + callback_data=f"push:cancel:{token}", **self._negative_inline_button_kwargs(), ), ] @@ -505,11 +973,236 @@ async def handle_push(self, update: Update, context: ContextTypes.DEFAULT_TYPE) ) await context.bot.send_message( chat_id=update.effective_chat.id, - text=self._t(update, "git.push_confirm_prompt", branch_name=branch_name), + text=self._t(update, "git.push_confirm_prompt", branch_name=self._escape_markdown_code_value(branch_name)), parse_mode="Markdown", reply_markup=confirm_markup, ) + @require_allowed_chat() + async def handle_log(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if await self._notify_if_current_project_busy(update, context): + return + if context.args: + await send_text(update, context, self._t(update, "git.usage_log")) + return + + session, project_path = await self._active_session_project_or_notify( + update, + context, + require_git_repo=True, + ) + if session is None or project_path is None: + return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return + + result = await asyncio.to_thread(self.git.run_git_command, project_path, ["log", "-5", "--oneline"]) + await send_html_text( + update, + context, + self._bash_block(self._format_git_response([(["log", "-5", "--oneline"], result)], [])), + ) + + @require_allowed_chat() + async def handle_reset(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if await self._notify_if_current_project_busy(update, context): + return + if context.args: + await send_text(update, context, self._t(update, "git.usage_reset")) + return + + session, project_path = await self._active_session_project_or_notify( + update, + context, + require_git_repo=True, + ) + if session is None or project_path is None: + return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return + if not self.deps.store.is_project_trusted(session["project_folder"]): + await send_text(update, context, self._t(update, "git.project_not_trusted_for_mutation")) + return + + current_branch = str(session.get("branch_name") or self.git.current_branch(project_path) or "").strip() + default_branch = str(self.git.default_branch(project_path) or current_branch).strip() + if not current_branch or not default_branch: + await send_text(update, context, self._t(update, "git.branch_unknown")) + return + + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, update.effective_chat.id) + prompts = self._reset_prompts() + token = self._new_unique_token(prompts) + self._store_bounded_record( + prompts, + token, + { + "chat_id": str(update.effective_chat.id), + "session_id": str(chat_state.get("active_session_id") or ""), + "project_folder": str(session["project_folder"]), + "current_branch": current_branch, + "default_branch": default_branch, + }, + limit=self.MAX_RESET_PROMPTS, + ) + + rows = [ + [InlineKeyboardButton(f"local/{default_branch}", callback_data=f"reset:select:{token}:local-default")], + [InlineKeyboardButton(f"origin/{default_branch}", callback_data=f"reset:select:{token}:origin-default")], + [InlineKeyboardButton(f"local/{current_branch}", callback_data=f"reset:select:{token}:local-current")], + [InlineKeyboardButton(f"origin/{current_branch}", callback_data=f"reset:select:{token}:origin-current")], + ] + await context.bot.send_message( + chat_id=update.effective_chat.id, + text=self._t(update, "git.reset_select_prompt"), + reply_markup=InlineKeyboardMarkup(rows), + ) + + @require_allowed_chat(answer_callback=True) + async def handle_reset_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + if query is None: + return + + await query.answer() + + action = (query.data or "").strip() + if action.startswith("reset:select:"): + if await self._notify_if_current_project_busy(update, context): + return + parts = action.split(":") + if len(parts) != 4: + return + _, _, token, target_kind = parts + prompt = self._reset_prompts().get(token) + if prompt is None or prompt.get("chat_id") != str(update.effective_chat.id): + await query.edit_message_text(self._t(update, "common.button_expired")) + return + session, project_path = await self._active_session_project_or_notify( + update, + context, + require_git_repo=True, + ) + if session is None or project_path is None: + return + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, update.effective_chat.id) + if ( + str(chat_state.get("active_session_id") or "") != prompt["session_id"] + or str(session["project_folder"]) != prompt["project_folder"] + or str(session.get("branch_name") or self.git.current_branch(project_path) or "").strip() + != prompt["current_branch"] + ): + self._reset_prompts().pop(token, None) + await query.edit_message_text(self._t(update, "common.button_expired")) + return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return + if not self.deps.store.is_project_trusted(session["project_folder"]): + await query.edit_message_text(self._t(update, "git.project_not_trusted_for_mutation")) + return + target = self._reset_target(prompt["current_branch"], prompt["default_branch"], target_kind) + if target is None: + await query.edit_message_text(self._t(update, "git.branch_unknown")) + return + target_ref, is_origin = target + reset_branch = prompt["current_branch"] + if not reset_branch: + await query.edit_message_text(self._t(update, "git.branch_unknown")) + return + self._reset_prompts().pop(token, None) + token = self._new_reset_selection_token() + selections = self._reset_selections() + self._store_bounded_record( + selections, + token, + { + "chat_id": str(update.effective_chat.id), + "session_id": str(chat_state.get("active_session_id") or ""), + "project_folder": str(session["project_folder"]), + "reset_branch": reset_branch, + "target_ref": target_ref, + "target_branch": target_ref.removeprefix("origin/") if is_origin else target_ref, + "is_origin": str(is_origin), + }, + limit=self.MAX_RESET_SELECTIONS, + ) + markup = InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton( + self._t(update, "git.reset_confirm_button"), + callback_data=f"reset:confirm:{token}", + **self._affirmative_inline_button_kwargs(), + ), + InlineKeyboardButton( + self._t(update, "git.cancel_button"), + callback_data=f"reset:cancel:{token}", + **self._negative_inline_button_kwargs(), + ), + ] + ] + ) + await query.edit_message_text( + self._t(update, "git.reset_confirm_prompt", target_ref=self._escape_markdown_code_value(target_ref)), + parse_mode="Markdown", + reply_markup=markup, + ) + return + + if action.startswith("reset:cancel:"): + token = action.removeprefix("reset:cancel:") + selection = self._reset_selections().get(token) + if selection is None or selection.get("chat_id") != str(update.effective_chat.id): + await query.edit_message_text(self._t(update, "common.button_expired")) + return + self._reset_selections().pop(token, None) + await query.edit_message_text(self._t(update, "git.reset_cancelled")) + return + if not action.startswith("reset:confirm:"): + return + + token = action.removeprefix("reset:confirm:") + selection = self._reset_selections().get(token) + if selection is None or selection.get("chat_id") != str(update.effective_chat.id): + await query.edit_message_text(self._t(update, "common.button_expired")) + return + if await self._notify_if_current_project_busy(update, context): + return + session, project_path = await self._active_session_project_or_notify( + update, + context, + require_git_repo=True, + ) + if session is None or project_path is None: + return + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, update.effective_chat.id) + if ( + str(chat_state.get("active_session_id") or "") != selection["session_id"] + or str(session["project_folder"]) != selection["project_folder"] + or str(session.get("branch_name") or self.git.current_branch(project_path) or "").strip() + != selection["reset_branch"] + ): + self._reset_selections().pop(token, None) + await query.edit_message_text(self._t(update, "common.button_expired")) + return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return + if not self.deps.store.is_project_trusted(session["project_folder"]): + await query.edit_message_text(self._t(update, "git.project_not_trusted_for_mutation")) + return + async with self._workspace_git_operation_lock( + update, + context, + selection["project_folder"], + ) as acquired: + if not acquired: + return + selection = self._reset_selections().pop(token, None) + if selection is None: + await query.edit_message_text(self._t(update, "common.button_expired")) + return + await self._execute_confirmed_reset(update, context, query, selection, project_path) + @require_allowed_chat() async def handle_pull(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if await self._notify_if_current_project_busy(update, context): @@ -525,6 +1218,8 @@ async def handle_pull(self, update: Update, context: ContextTypes.DEFAULT_TYPE) ) if session is None or project_path is None: return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return branch_name = session.get("branch_name") or self.git.current_branch(project_path) if not branch_name: @@ -532,6 +1227,13 @@ async def handle_pull(self, update: Update, context: ContextTypes.DEFAULT_TYPE) return default_branch = self.git.default_branch(project_path) or branch_name + token = self._register_git_confirmation( + chat_id=update.effective_chat.id, + session=session, + action="pull", + branch_name=str(branch_name), + default_branch=str(default_branch), + ) prompt_key = "git.pull_confirm_prompt_with_default" if default_branch and default_branch != branch_name else "git.pull_confirm_prompt" confirm_markup = InlineKeyboardMarkup( @@ -539,12 +1241,12 @@ async def handle_pull(self, update: Update, context: ContextTypes.DEFAULT_TYPE) [ InlineKeyboardButton( self._t(update, "git.pull_confirm_button"), - callback_data="pull:confirm", + callback_data=f"pull:confirm:{token}", **self._affirmative_inline_button_kwargs(), ), InlineKeyboardButton( self._t(update, "git.cancel_button"), - callback_data="pull:cancel", + callback_data=f"pull:cancel:{token}", **self._negative_inline_button_kwargs(), ), ] @@ -555,8 +1257,8 @@ async def handle_pull(self, update: Update, context: ContextTypes.DEFAULT_TYPE) text=self._t( update, prompt_key, - branch_name=branch_name, - default_branch=default_branch, + branch_name=self._escape_markdown_code_value(branch_name), + default_branch=self._escape_markdown_code_value(default_branch), ), parse_mode="Markdown", reply_markup=confirm_markup, @@ -568,11 +1270,22 @@ async def handle_pull_callback(self, update: Update, context: ContextTypes.DEFAU if query is None: return + await query.answer() + action = (query.data or "").strip() - if action == "pull:cancel": + parts = action.split(":") + if len(parts) != 3 or parts[0] != "pull" or parts[1] not in {"confirm", "cancel"}: + return + _, choice, token = parts + confirmation = self._get_git_confirmation(token, chat_id=update.effective_chat.id, action="pull") + if confirmation is None: + await query.edit_message_text(self._t(update, "common.button_expired")) + return + if choice == "cancel": + self._git_confirmations().pop(token, None) await query.edit_message_text(self._t(update, "git.pull_cancelled")) return - if action != "pull:confirm": + if await self._notify_if_current_project_busy(update, context): return session, project_path = await self._active_session_project_or_notify( @@ -582,59 +1295,86 @@ async def handle_pull_callback(self, update: Update, context: ContextTypes.DEFAU ) if session is None or project_path is None: return - - branch_name = session.get("branch_name") or self.git.current_branch(project_path) - if not branch_name: - await query.edit_message_text(self._t(update, "git.branch_unknown")) + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, update.effective_chat.id) + if ( + str(chat_state.get("active_session_id") or "") != confirmation["session_id"] + or str(session["project_folder"]) != confirmation["project_folder"] + or str(session.get("branch_name") or self.git.current_branch(project_path) or "").strip() + != confirmation["branch_name"] + ): + self._git_confirmations().pop(token, None) + await query.edit_message_text(self._t(update, "common.button_expired")) return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return + async with self._workspace_git_operation_lock( + update, + context, + confirmation["project_folder"], + ) as acquired: + if not acquired: + return + confirmation = self._git_confirmations().pop(token, None) + if confirmation is None: + await query.edit_message_text(self._t(update, "common.button_expired")) + return - default_branch = self.git.default_branch(project_path) or branch_name - prompt_key = "git.pull_in_progress_with_default" if default_branch and default_branch != branch_name else "git.pull_in_progress" - await query.edit_message_text( - self._t( - update, - prompt_key, - branch_name=branch_name, - default_branch=default_branch, - ), - parse_mode="Markdown", - ) + branch_name = confirmation["branch_name"] + if not branch_name: + await query.edit_message_text(self._t(update, "git.branch_unknown")) + return - completed_messages: list[str] = [] - warnings: list[str] = [] + default_branch = confirmation["default_branch"] or branch_name + prompt_key = "git.pull_in_progress_with_default" if default_branch and default_branch != branch_name else "git.pull_in_progress" + await query.edit_message_text( + self._t( + update, + prompt_key, + branch_name=self._escape_markdown_code_value(branch_name), + default_branch=self._escape_markdown_code_value(default_branch), + ), + parse_mode="Markdown", + ) + + completed_messages: list[str] = [] + warnings: list[str] = [] + + if default_branch and default_branch != branch_name: + ok, message, branch_warnings = await self._refresh_branch_with_checkout( + update, + context, + project_path=project_path, + branch_name=default_branch, + ) + if not ok: + await send_text(update, context, message or self._t(update, "bot.error.command_failed")) + return + if message and not branch_warnings: + completed_messages.append(message) + warnings.extend(branch_warnings) - if default_branch and default_branch != branch_name: ok, message, branch_warnings = await self._refresh_branch_with_checkout( update, context, project_path=project_path, - branch_name=default_branch, + branch_name=branch_name, ) if not ok: await send_text(update, context, message or self._t(update, "bot.error.command_failed")) return - if message: + if message and not branch_warnings: completed_messages.append(message) warnings.extend(branch_warnings) - ok, message, branch_warnings = await self._refresh_branch_with_checkout( - update, - context, - project_path=project_path, - branch_name=branch_name, - ) - if not ok: - await send_text(update, context, message or self._t(update, "bot.error.command_failed")) - return - if message: - completed_messages.append(message) - warnings.extend(branch_warnings) - - lines = completed_messages or [self._t(update, "git.pull_completed")] - if warnings: - lines.extend(["", self._t(update, "project.refresh_warnings")]) - lines.extend(f"- {warning}" for warning in warnings) - await send_text(update, context, "\n".join(lines)) + lines = list(completed_messages) + if not lines and not warnings: + lines.append(self._t(update, "git.pull_completed")) + if warnings: + if lines: + lines.append("") + lines.append(self._t(update, "project.refresh_warnings")) + lines.extend(f"- {warning}" for warning in warnings) + await send_text(update, context, "\n".join(lines)) @require_allowed_chat(answer_callback=True) async def handle_push_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -642,11 +1382,22 @@ async def handle_push_callback(self, update: Update, context: ContextTypes.DEFAU if query is None: return + await query.answer() + action = (query.data or "").strip() - if action == "push:cancel": + parts = action.split(":") + if len(parts) != 3 or parts[0] != "push" or parts[1] not in {"confirm", "cancel"}: + return + _, choice, token = parts + confirmation = self._get_git_confirmation(token, chat_id=update.effective_chat.id, action="push") + if confirmation is None: + await query.edit_message_text(self._t(update, "common.button_expired")) + return + if choice == "cancel": + self._git_confirmations().pop(token, None) await query.edit_message_text(self._t(update, "git.push_cancelled")) return - if action != "push:confirm": + if await self._notify_if_current_project_busy(update, context): return session, project_path = await self._active_session_project_or_notify( @@ -656,27 +1407,57 @@ async def handle_push_callback(self, update: Update, context: ContextTypes.DEFAU ) if session is None or project_path is None: return - - branch_name = session.get("branch_name") or self.git.current_branch(project_path) - if not branch_name: - await query.edit_message_text(self._t(update, "git.branch_unknown")) + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, update.effective_chat.id) + if ( + str(chat_state.get("active_session_id") or "") != confirmation["session_id"] + or str(session["project_folder"]) != confirmation["project_folder"] + or str(session.get("branch_name") or self.git.current_branch(project_path) or "").strip() + != confirmation["branch_name"] + ): + self._git_confirmations().pop(token, None) + await query.edit_message_text(self._t(update, "common.button_expired")) + return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): return + async with self._workspace_git_operation_lock( + update, + context, + confirmation["project_folder"], + ) as acquired: + if not acquired: + return + confirmation = self._git_confirmations().pop(token, None) + if confirmation is None: + await query.edit_message_text(self._t(update, "common.button_expired")) + return - current_branch = self.git.current_branch(project_path) - if current_branch != branch_name: - checkout = await asyncio.to_thread(self.git.checkout_branch, project_path, branch_name) - if not checkout.success: - await query.edit_message_text( - self._t(update, "git.push_cancelled_checkout_failed", branch_name=branch_name), - parse_mode="Markdown", - ) - await send_html_text(update, context, self._bash_block(self._format_git_response([(["checkout", branch_name], checkout)], []))) + branch_name = confirmation["branch_name"] + if not branch_name: + await query.edit_message_text(self._t(update, "git.branch_unknown")) return - await query.edit_message_text(self._t(update, "git.push_in_progress", branch_name=branch_name), parse_mode="Markdown") - result = await asyncio.to_thread(self.git.push_branch, project_path, branch_name) - await send_html_text( - update, - context, - self._bash_block(self._format_git_response([(["push", "origin", branch_name], result)], [])), - ) + current_branch = self.git.current_branch(project_path) + if current_branch != branch_name: + checkout = await asyncio.to_thread(self.git.checkout_branch, project_path, branch_name) + if not checkout.success: + await query.edit_message_text( + self._t( + update, + "git.push_cancelled_checkout_failed", + branch_name=self._escape_markdown_code_value(branch_name), + ), + parse_mode="Markdown", + ) + await send_html_text(update, context, self._bash_block(self._format_git_response([(["checkout", branch_name], checkout)], []))) + return + + await query.edit_message_text( + self._t(update, "git.push_in_progress", branch_name=self._escape_markdown_code_value(branch_name)), + parse_mode="Markdown", + ) + result = await asyncio.to_thread(self.git.push_branch, project_path, branch_name) + await send_html_text( + update, + context, + self._bash_block(self._format_git_response([(["push", "origin", branch_name], result)], [])), + ) diff --git a/src/coding_agent_telegram/router/message_commands.py b/src/coding_agent_telegram/router/message_commands.py index 923dfc8..31d9fea 100644 --- a/src/coding_agent_telegram/router/message_commands.py +++ b/src/coding_agent_telegram/router/message_commands.py @@ -1,13 +1,17 @@ from __future__ import annotations +import asyncio import logging import tempfile +import time from pathlib import Path from types import SimpleNamespace +from typing import NamedTuple, Optional, Sequence -from telegram import Update +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import ContextTypes +from coding_agent_telegram.session_gap import gap_seconds_since, humanize_gap_seconds, native_session_activity from coding_agent_telegram.session_runtime import PhotoAttachmentError from coding_agent_telegram.speech_to_text import SpeechToTextError from coding_agent_telegram.telegram_sender import send_text @@ -17,9 +21,63 @@ logger = logging.getLogger(__name__) MAX_STT_AUDIO_BYTES = 20 * 1024 * 1024 +MAX_PHOTOS_PER_ALBUM = 5 +# Telegram delivers every item in an album as an individual update. A short +# debounce gives the rest of that album time to arrive before dispatching it. +PHOTO_ALBUM_DEBOUNCE_SECONDS = 0.75 +# Size at which the long-gap cache is swept for expired entries before the next insert. +_GAP_CACHE_PRUNE_AT_ENTRIES = 256 + + +class _LongGapProviderConfig(NamedTuple): + threshold_field: str # AppConfig attribute holding the idle-seconds threshold + size_gate_tokens: Optional[int] # below this, skip the warning even past threshold + + +# Single source of truth for per-provider long-gap tuning, keyed by provider. A +# provider missing here has the warning disabled outright (fails closed, unlike a +# lookup that silently no-ops one half of the check). size_gate_tokens is in the same +# units session_gap.py reports for that provider (see its module docstring); None +# means no cheap size signal exists, so the idle-time check alone decides. +_LONG_GAP_PROVIDER_CONFIG: dict[str, _LongGapProviderConfig] = { + "claude": _LongGapProviderConfig("claude_long_gap_seconds", 20_000), # cache_creation + cache_read + input tokens + "codex": _LongGapProviderConfig("codex_long_gap_seconds", 50_000), # cumulative tokens_used from Codex's session db + "copilot": _LongGapProviderConfig("copilot_long_gap_seconds", None), # no local size signal; see session_gap.py +} class MessageCommandMixin: + def _photo_albums(self) -> dict[tuple[int, str], list[tuple[Update, ContextTypes.DEFAULT_TYPE]]]: + albums = getattr(self, "_pending_photo_albums", None) + if albums is None: + albums = {} + self._pending_photo_albums = albums + return albums + + def _reserved_photo_albums(self) -> dict[int, set[str]]: + reservations = getattr(self, "_reserved_photo_albums_by_chat", None) + if reservations is None: + reservations = {} + self._reserved_photo_albums_by_chat = reservations + return reservations + + def _has_pending_photo_album(self, chat_id: int) -> bool: + return bool(self._reserved_photo_albums().get(chat_id)) + + def _is_photo_album_reservation(self, chat_id: int, media_group_id: str) -> bool: + return media_group_id in self._reserved_photo_albums().get(chat_id, set()) + + async def _release_photo_album_reservation( + self, chat_id: int, media_group_id: str, context: ContextTypes.DEFAULT_TYPE + ) -> None: + reservations = self._reserved_photo_albums() + media_group_ids = reservations.get(chat_id) + if media_group_ids is not None and media_group_id in media_group_ids: + media_group_ids.discard(media_group_id) + if not media_group_ids: + reservations.pop(chat_id, None) + await self._drain_chat_message_queue(chat_id, context) + async def _process_user_message( self, update: Update, @@ -37,14 +95,92 @@ async def _process_user_message( and not isinstance(pending_action, dict) ) if self._should_queue_incoming_message(chat_id): + await self._queue_incoming_message( + update, + context, + user_message, + separate_batch=should_prioritize_existing_queue, + drain_after=should_prioritize_existing_queue, + ) + return + if await self._maybe_warn_long_gap(update, context, user_message, suppress_working_notice): + return + # Handlers run concurrently (block=False), and _maybe_warn_long_gap may have + # awaited provider I/O above. That await is the only gap between "nothing else + # is handling this chat" and this message claiming it below, so another message + # for the same chat can have claimed it meanwhile -- queue behind it instead of + # racing it. Everything from here to the claim inside + # _dispatch_pending_message_now is synchronous, so this re-check holds. + if self._should_queue_incoming_message(chat_id): + await self._queue_incoming_message(update, context, user_message, separate_batch=False) + return + await self._dispatch_pending_message_now( + update, context, user_message, suppress_working_notice=suppress_working_notice + ) + + async def _queue_incoming_message( + self, + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user_message: str, + *, + separate_batch: bool, + drain_after: bool = False, + ) -> None: + chat_id = update.effective_chat.id + reply_to_message_id = getattr(update.message, "message_id", None) + _queue_file, question_number = self._enqueue_chat_message( + chat_id, + user_message, + reply_to_message_id=reply_to_message_id, + separate_batch=separate_batch, + ) + logger.info( + "Queued user message for chat %s as Q%s. Preview: %.120r", + chat_id, + question_number, + user_message, + ) + await send_text( + update, + context, + self._t(update, "message.question_queued", question_number=question_number), + reply_to_message_id=reply_to_message_id, + ) + if drain_after: + await self._drain_chat_message_queue(chat_id, context) + + async def _dispatch_pending_message_now( + self, + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user_message: str, + *, + suppress_working_notice: bool = False, + ) -> None: + """Run *user_message* now, skipping the *queue-behind-other-queued-messages* and + long-gap checks -- but still deferring to a genuinely busy workspace so a message + is never silently dropped. + + Used both by the normal (already-checked) path in ``_process_user_message`` and + to replay a message that was held for a confirmation button (long-gap + compact/proceed). For a held message, re-running the full should-queue check + would be wrong: it would push this older, already-approved message behind newer + messages that queued up while the confirmation prompt was waiting for a reply. + A currently-busy workspace is different -- that's not a queue-ordering nicety, + it's the only thing standing between this call and a dropped message, so it's + still checked explicitly. + """ + chat_id = update.effective_chat.id + if self._is_project_busy(chat_id): + reply_to_message_id = getattr(getattr(update, "message", None), "message_id", None) _queue_file, question_number = self._enqueue_chat_message( chat_id, user_message, - reply_to_message_id=getattr(update.message, "message_id", None), - separate_batch=should_prioritize_existing_queue, + reply_to_message_id=reply_to_message_id, ) logger.info( - "Queued user message for chat %s as Q%s. Preview: %.120r", + "Project busy: queued held user message for chat %s as Q%s. Preview: %.120r", chat_id, question_number, user_message, @@ -53,10 +189,8 @@ async def _process_user_message( update, context, self._t(update, "message.question_queued", question_number=question_number), - reply_to_message_id=getattr(update.message, "message_id", None), + reply_to_message_id=reply_to_message_id, ) - if should_prioritize_existing_queue: - await self._drain_chat_message_queue(chat_id, context) return logger.info("Processing user message immediately for chat %s. Preview: %.120r", chat_id, user_message) self._store_pending_action( @@ -73,6 +207,309 @@ async def _process_user_message( finally: await self._drain_chat_message_queue(chat_id, context) + async def _dispatch_active_session_message( + self, + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user_message: str, + *, + image_paths: Sequence[Path] = (), + ) -> None: + """Run *user_message* (optionally with attachments) directly against the active + session, bypassing the queueing/long-gap machinery. Mirrors what ``handle_photo`` + already did, factored out so a held photo message can be replayed identically + after a long-gap confirmation.""" + chat_id = update.effective_chat.id + try: + self._last_run_results[chat_id] = await self.runtime.run_active_session( + update, + context, + user_message=user_message, + image_paths=image_paths, + ) + finally: + await self._drain_chat_message_queue(chat_id, context) + + def _prune_session_gap_cache(self, now_monotonic: float) -> None: + """Drop expired entries so a long-lived bot's cache can't grow without bound. + + Only runs once the dict is big enough for the scan to be worth it; entries are + tiny, so the cap is about keeping memory flat over months of uptime, not about + the check itself being hot. + """ + if len(self._session_gap_safe_until) < _GAP_CACHE_PRUNE_AT_ENTRIES: + return + self._session_gap_safe_until = { + key: safe_until + for key, safe_until in self._session_gap_safe_until.items() + if safe_until > now_monotonic + } + + def _long_gap_threshold_seconds(self, provider: str) -> int: + provider_config = _LONG_GAP_PROVIDER_CONFIG.get(provider) + if provider_config is None: + return 0 + return getattr(self.deps.cfg, provider_config.threshold_field, 0) + + def _mark_active_session_recently_resumed(self, chat_id: int) -> None: + """Suppress another idle prompt while an accepted resume warms the session. + + A successful provider turn normally updates its native transcript immediately. + This local marker also covers the short interval before that write is visible, + and prevents queued messages behind an explicit "Proceed anyway" from asking + the same long-gap question again. + """ + active_id, session, _project_path = self._active_session_context(chat_id) + if active_id is None or session is None: + return + provider = str(session.get("provider") or "codex").strip().lower() or "codex" + threshold_seconds = self._long_gap_threshold_seconds(provider) + if threshold_seconds <= 0: + return + now_monotonic = time.monotonic() + self._prune_session_gap_cache(now_monotonic) + self._session_gap_safe_until[f"{provider}:{active_id}"] = now_monotonic + threshold_seconds + + async def _maybe_warn_long_gap( + self, + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user_message: str, + suppress_working_notice: bool, + *, + image_paths: Sequence[Path] = (), + ) -> bool: + """Ask before resuming a session idle long enough to risk a costly cache miss. + + Returns True if a warning was sent and *user_message* is now held pending a + button response (caller must not dispatch it), False if it's safe to proceed. + """ + if not self.deps.cfg.long_gap_warning_enabled: + return False + chat_id = update.effective_chat.id + active_id, session, _project_path = self._active_session_context(chat_id) + if active_id is None or session is None: + return False + + provider = str(session.get("provider") or "codex").strip().lower() or "codex" + provider_config = _LONG_GAP_PROVIDER_CONFIG.get(provider) + threshold_seconds = self._long_gap_threshold_seconds(provider) + if threshold_seconds <= 0: + return False + + # A burst of quick messages on an already-active session would otherwise hit + # native_session_activity's filesystem/sqlite lookup on every single one. Skip + # it only while the session provably *cannot* have crossed the threshold yet: + # the cached value is the monotonic time the gap measured last check would + # reach the threshold, so a session checked at (threshold - 1s) idle is cached + # for 1s, not for another full threshold. Fresh activity only pushes that + # crossing time further out, so the shortcut can never hide a real long gap. + cache_key = f"{provider}:{active_id}" + now_monotonic = time.monotonic() + safe_until = self._session_gap_safe_until.get(cache_key) + if safe_until is not None and now_monotonic < safe_until: + return False + + # native_session_activity does blocking filesystem/sqlite I/O; keep it off the + # event loop so one chat's check can't stall every other chat's bot. + last_activity, size_tokens = await asyncio.to_thread(native_session_activity, provider, active_id) + gap_seconds = gap_seconds_since(last_activity) + if gap_seconds is None: + # Activity is unknowable (no transcript/db row), so no warning can ever fire + # for it. Deliberately not cached: a future check may find the session once + # its native files appear. + return False + if gap_seconds < threshold_seconds: + self._prune_session_gap_cache(now_monotonic) + self._session_gap_safe_until[cache_key] = now_monotonic + (threshold_seconds - gap_seconds) + return False + + # Avoid nagging about sessions too small for a full reprocess to matter, when + # the provider exposes a cheap size signal at all (session_gap.py returns None + # for providers/situations it can't determine one for -- fail open there rather + # than suppressing a legitimate warning). + size_gate = provider_config.size_gate_tokens if provider_config else None + if size_gate is not None and size_tokens is not None and size_tokens < size_gate: + logger.info( + "Skipping long-gap warning for chat %s session '%s' (%s): idle %.0fs but only " + "~%s tokens accumulated (below the %s-token gate for provider %s).", + chat_id, + session.get("name"), + active_id, + gap_seconds, + size_tokens, + size_gate, + provider, + ) + # Without this, every message on a session stuck under the size gate would + # repeat the blocking native_session_activity lookup forever (the gap-crossing + # cache above never applies once the gap has already crossed the threshold). + # Reusing threshold_seconds as a cooldown means we accept missing a session + # that grows past the gate mid-cooldown, in exchange for not re-checking on + # every single message while it stays small. + self._prune_session_gap_cache(now_monotonic) + self._session_gap_safe_until[cache_key] = now_monotonic + threshold_seconds + return False + + # The activity lookup above awaited, so a concurrently-handled message for this + # chat may have claimed it in the meantime. Storing our own pending action now + # would overwrite whatever it is holding -- orphaning that message and its + # buttons, and putting a second warning in the chat. Bail out instead; the + # caller re-checks and queues this message behind the winner. + if self._pending_action(chat_id) is not None: + logger.info( + "Skipping long-gap warning for chat %s session '%s': another message claimed the " + "chat while the idle check was running.", + chat_id, + session.get("name"), + ) + return False + + logger.info( + "Long idle gap detected for chat %s on session '%s' (%s): %.0fs since last activity " + "(threshold %ss, ~%s tokens accumulated, provider %s). Asking user to compact or proceed.", + chat_id, + session.get("name"), + active_id, + gap_seconds, + threshold_seconds, + size_tokens if size_tokens is not None else "unknown", + provider, + ) + self._store_pending_action( + chat_id, + { + "kind": "long_gap_confirm", + "user_message": user_message, + "suppress_working_notice": suppress_working_notice, + "image_paths": [str(path) for path in image_paths], + }, + ) + markup = InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton( + self._t(update, "runtime.long_gap_switch_button"), + callback_data="longgap:switch", + **self._affirmative_inline_button_kwargs(), + ), + ], + [ + # Deliberately no style kwargs: neither the cheap-but-lossy "switch" + # option nor the risky-but-unmodified "proceed" one, so left unstyled + # rather than colored as affirmative or dangerous. + InlineKeyboardButton( + self._t(update, "runtime.long_gap_compact_button"), + callback_data="longgap:compact", + ), + ], + [ + InlineKeyboardButton( + self._t(update, "runtime.long_gap_proceed_button"), + callback_data="longgap:proceed", + **self._negative_inline_button_kwargs(), + ), + ], + ] + ) + await context.bot.send_message( + chat_id=chat_id, + text=self._t(update, "runtime.long_gap_warning", gap=humanize_gap_seconds(gap_seconds)), + reply_markup=markup, + ) + return True + + @require_allowed_chat(answer_callback=True) + async def handle_long_gap_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + if query is None: + return + await query.answer() + action = (query.data or "").strip() + chat_id = update.effective_chat.id + pending_action = self._pending_action(chat_id) + if not isinstance(pending_action, dict) or pending_action.get("kind") != "long_gap_confirm": + if hasattr(query, "edit_message_reply_markup"): + await query.edit_message_reply_markup(reply_markup=None) + return + + user_message = str(pending_action.get("user_message") or "") + suppress_working_notice = bool(pending_action.get("suppress_working_notice")) + image_paths = tuple(Path(path) for path in pending_action.get("image_paths") or ()) + self._store_pending_action(chat_id, None) + + async def replay() -> None: + # Deliberately bypasses _process_user_message: re-running the should-queue + # and long-gap checks here is wrong for a held message (see + # _dispatch_pending_message_now's docstring). + if image_paths: + await self._dispatch_active_session_message(update, context, user_message, image_paths=image_paths) + else: + await self._dispatch_pending_message_now( + update, context, user_message, suppress_working_notice=suppress_working_notice + ) + + if action == "longgap:proceed": + await query.edit_message_text(self._t(update, "runtime.long_gap_proceeding")) + # The user has approved resuming this session. Queue entries waiting + # behind the held message share that newly-warmed session, rather than + # needing a duplicate confirmation based on the same old activity time. + self._mark_active_session_recently_resumed(chat_id) + await replay() + return + + if action == "longgap:switch": + await query.edit_message_text(self._t(update, "runtime.long_gap_switching")) + # Same reasoning as the compact branch below: replay the held message + # regardless of whether switching succeeds, fails, or can't start. + await self.runtime.switch_to_new_session(update, context) + await replay() + return + + if action == "longgap:compact": + await query.edit_message_text(self._t(update, "runtime.long_gap_compacting")) + # Whether compaction succeeds, fails, or can't start because the workspace is + # busy, still replay the held message rather than silently dropping it: + # - success: runs on the new, freshly-compacted session. + # - failure: compact_active_session already reported the error; replaying + # still delivers the user's message instead of losing it. + # - busy (returns None, no message sent by compact_active_session): the + # text-message path still queues correctly via _continue_pending_action; + # the photo path shares the same busy-handling as handle_photo already had. + await self.runtime.compact_active_session(update, context) + await replay() + + @require_allowed_chat(answer_callback=True) + async def handle_agent_reply_option_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + if query is None or not query.data: + return + + await query.answer() + parts = query.data.split(":", 2) + if len(parts) != 3: + return + _, token, index_text = parts + entry = self._agent_reply_option_tokens.pop(token, None) + if entry is None: + if hasattr(query, "edit_message_reply_markup"): + await query.edit_message_reply_markup(reply_markup=None) + return + + chat_id, options = entry + if update.effective_chat is None or update.effective_chat.id != chat_id: + return + try: + option_text = options[int(index_text)] + except (ValueError, IndexError): + return + + if hasattr(query, "edit_message_reply_markup"): + await query.edit_message_reply_markup(reply_markup=None) + + await send_text(update, context, self._t(update, "runtime.reply_option_selected", choice=option_text)) + await self._process_user_message(update, context, option_text) + @require_allowed_chat() async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.message is None or not update.message.text: @@ -91,32 +528,99 @@ async def handle_photo(self, update: Update, context: ContextTypes.DEFAULT_TYPE) if update.message is None or not update.message.photo: return - session, project_path = await self._active_session_project_or_notify(update, context) - if session is None or project_path is None: + media_group_id = getattr(update.message, "media_group_id", None) + if media_group_id: + key = (update.effective_chat.id, str(media_group_id)) + chat_id = update.effective_chat.id + if not self._is_photo_album_reservation(chat_id, key[1]): + if isinstance(self._pending_action(chat_id), dict) or self._has_pending_photo_album(chat_id): + await send_text(update, context, self._t(update, "message.photo_blocked_by_pending_action")) + return + self._reserved_photo_albums().setdefault(chat_id, set()).add(key[1]) + albums = self._photo_albums() + batch = albums.setdefault(key, []) + batch.append((update, context)) + tasks = getattr(self, "_photo_album_tasks", None) + if tasks is None: + tasks = {} + self._photo_album_tasks = tasks + previous_task = tasks.get(key) + if previous_task is not None: + previous_task.cancel() + tasks[key] = asyncio.create_task(self._flush_photo_album(key, batch)) return - if session.get("provider", "codex") not in ("codex", "claude"): - await send_text(update, context, self._t(update, "message.photo_only_codex")) + await self._process_photo_batch([(update, context)]) + + async def _flush_photo_album( + self, key: tuple[int, str], batch: list[tuple[Update, ContextTypes.DEFAULT_TYPE]] + ) -> None: + try: + await asyncio.sleep(PHOTO_ALBUM_DEBOUNCE_SECONDS) + except asyncio.CancelledError: + return + albums = self._photo_albums() + if albums.get(key) is not batch: + return + albums.pop(key, None) + getattr(self, "_photo_album_tasks", {}).pop(key, None) + # Telegram's delivery order is normally ordered, but message_id makes it + # deterministic if updates arrive out of order. + batch.sort(key=lambda item: getattr(item[0].message, "message_id", 0) or 0) + try: + await self._process_photo_batch(batch) + finally: + await self._release_photo_album_reservation(key[0], key[1], batch[0][1]) + + async def _process_photo_batch(self, batch: Sequence[tuple[Update, ContextTypes.DEFAULT_TYPE]]) -> None: + update, context = batch[0] + chat_id = update.effective_chat.id + if len(batch) > MAX_PHOTOS_PER_ALBUM: + await send_text(update, context, self._t(update, "runtime.photo_album_too_many", limit=MAX_PHOTOS_PER_ALBUM)) + return + + if isinstance(self._pending_action(chat_id), dict): + # Unlike text (which routes through _process_user_message and queues + # behind a pending action), a photo can't be queued -- _enqueue_chat_message + # only stores text. Proceeding anyway would silently overwrite whatever the + # pending action is holding (e.g. a long-gap compact/proceed confirmation + # for an earlier message), orphaning its buttons and losing that message. + await send_text(update, context, self._t(update, "message.photo_blocked_by_pending_action")) + return + + session, project_path = await self._active_session_project_or_notify(update, context) + if session is None or project_path is None: return - caption = update.message.caption or "" try: - attachment_path = await self.photo_attachments.store_photo(update, session["project_folder"]) + attachment_paths = [] + for photo_update, _photo_context in batch: + attachment_paths.append(await self.photo_attachments.store_photo(photo_update, session["project_folder"])) + attachment_paths = tuple(attachment_paths) except PhotoAttachmentError as exc: error_text = self._t(update, "runtime.photo_too_large") if exc.code == "photo_too_large" else str(exc) await send_text(update, context, error_text) return - prompt = self.photo_attachments.build_prompt(attachment_path, project_path, caption) - chat_id = update.effective_chat.id - try: - self._last_run_results[chat_id] = await self.runtime.run_active_session( - update, - context, - user_message=prompt, - image_paths=(attachment_path,), - ) - finally: - await self._drain_chat_message_queue(chat_id, context) + captions = [photo_update.message.caption.strip() for photo_update, _photo_context in batch if photo_update.message.caption] + prompt = self.photo_attachments.build_prompt(attachment_paths, project_path, "\n".join(captions)) + if await self._maybe_warn_long_gap( + update, + context, + prompt, + suppress_working_notice=False, + image_paths=attachment_paths, + ): + return + # Downloading the photo and running the idle check both awaited, so re-run the + # guard from the top of this handler: a pending action may have appeared since. + # A photo can't be queued, so blocking is the only way not to run it behind + # another message's back (see the top of this handler). + if self._pending_action(chat_id) is not None: + await send_text(update, context, self._t(update, "message.photo_blocked_by_pending_action")) + return + await self._dispatch_active_session_message( + update, context, prompt, image_paths=attachment_paths + ) async def _handle_audio_like( self, diff --git a/src/coding_agent_telegram/router/project_commands.py b/src/coding_agent_telegram/router/project_commands.py index 78f37d7..3cab312 100644 --- a/src/coding_agent_telegram/router/project_commands.py +++ b/src/coding_agent_telegram/router/project_commands.py @@ -43,7 +43,9 @@ def _branch_source_keyboard( callback_data=f"branchsource:{token}", ) ) - return InlineKeyboardMarkup([buttons]) + # Branch names are user-defined and can be long. Put each source on a + # separate row so Telegram does not truncate either choice. + return InlineKeyboardMarkup([[button] for button in buttons]) def _multi_branch_source_keyboard( self, @@ -80,8 +82,7 @@ def _multi_branch_source_keyboard( ) ) seen.add(key) - if row: - rows.append(row) + rows.extend([button] for button in row) if not rows: return None return InlineKeyboardMarkup(rows) diff --git a/src/coding_agent_telegram/router/queue_processing.py b/src/coding_agent_telegram/router/queue_processing.py index 9707a12..609e7a7 100644 --- a/src/coding_agent_telegram/router/queue_processing.py +++ b/src/coding_agent_telegram/router/queue_processing.py @@ -240,15 +240,17 @@ async def _prompt_queue_batch_decision( chat_id=chat_id, text="\n".join(lines), reply_markup=InlineKeyboardMarkup( - [[ - InlineKeyboardButton(translate(locale, "queue.button_group"), callback_data="queuebatch:group"), - InlineKeyboardButton(translate(locale, "queue.button_single"), callback_data="queuebatch:single"), - InlineKeyboardButton( - translate(locale, "queue.button_cancel"), - callback_data="queuebatch:cancel", - **self._negative_inline_button_kwargs(), - ), - ]] + [ + [InlineKeyboardButton(translate(locale, "queue.button_group"), callback_data="queuebatch:group")], + [InlineKeyboardButton(translate(locale, "queue.button_single"), callback_data="queuebatch:single")], + [ + InlineKeyboardButton( + translate(locale, "queue.button_cancel"), + callback_data="queuebatch:cancel", + **self._negative_inline_button_kwargs(), + ), + ], + ] ), ) @@ -292,6 +294,23 @@ async def _dispatch_queued_questions( else: user_message = queued_messages[0].text reply_to_message_id = queued_messages[0].reply_to_message_id + + # Queue dispatch resumes the very same provider session as an ordinary + # message. It must therefore pass through the same idle/cache warning before + # it starts the run. In particular, choosing "Group questions" is only a + # batching decision; it must not bypass the user's compact/new-session choice. + # + # The queue file has already been claimed above. If the message is held for a + # long-gap decision, it is now represented by that pending action instead, so + # retire the claimed file and let the callback replay the held message exactly + # once. Returning True lets the drain loop observe the pending action and + # stop without putting this batch back on the queue. + if await self._maybe_warn_long_gap(queued_update, context, user_message, suppress_working_notice=False): + queue_file.unlink(missing_ok=True) + self._queue_lock_path(queue_file).unlink(missing_ok=True) + self._chat_processing_queue_files.pop(chat_id, None) + return True + logger.debug( "Dispatching queued question(s) for chat %s grouped=%s count=%s reply_to_message_id=%s.", chat_id, @@ -309,6 +328,7 @@ async def _dispatch_queued_questions( { "kind": "message", "user_message": user_message, + "reply_to_message_id": reply_to_message_id, }, ) continued = await self._continue_pending_action( @@ -317,6 +337,16 @@ async def _dispatch_queued_questions( drain_queue_after_completion=False, ) if not continued: + # A prerequisite such as selecting a replacement project keeps this + # message in persistent pending_action state. That state is now the sole + # owner of the question: re-adding its queue file would run it once when + # the prerequisite is resolved and again when the queue later drains. + pending_action = self._pending_action(chat_id) + if isinstance(pending_action, dict) and pending_action.get("awaiting_prerequisite"): + queue_file.unlink(missing_ok=True) + self._queue_lock_path(queue_file).unlink(missing_ok=True) + self._chat_processing_queue_files.pop(chat_id, None) + return True self._queue_lock_path(queue_file).unlink(missing_ok=True) self._chat_processing_queue_files.pop(chat_id, None) queue = self._chat_message_queue_files.setdefault(chat_id, deque()) diff --git a/src/coding_agent_telegram/router/session_branch_resolution.py b/src/coding_agent_telegram/router/session_branch_resolution.py index b8b8660..2719b43 100644 --- a/src/coding_agent_telegram/router/session_branch_resolution.py +++ b/src/coding_agent_telegram/router/session_branch_resolution.py @@ -18,6 +18,8 @@ def _branch_discrepancy_keyboard(self, update: Update, stored_branch: str, curre self._t(update, "branch_resolution.use_branch", branch_name=stored_branch), callback_data="branchdiscrepancy:stored", ), + ], + [ InlineKeyboardButton( self._t(update, "branch_resolution.use_branch", branch_name=current_branch), callback_data="branchdiscrepancy:current", diff --git a/src/coding_agent_telegram/router/session_commands.py b/src/coding_agent_telegram/router/session_commands.py index e19dfde..0d59535 100644 --- a/src/coding_agent_telegram/router/session_commands.py +++ b/src/coding_agent_telegram/router/session_commands.py @@ -5,6 +5,7 @@ from .session_branch_resolution import SessionBranchResolutionMixin from .session_common import SessionCommonMixin from .session_lifecycle_commands import SessionLifecycleCommandMixin +from .session_model_commands import SessionModelCommandMixin from .session_provider_commands import SessionProviderCommandMixin from .session_status_commands import SessionStatusCommandMixin @@ -12,6 +13,7 @@ class SessionCommandMixin( SessionCommonMixin, SessionProviderCommandMixin, + SessionModelCommandMixin, SessionBranchResolutionMixin, SessionLifecycleCommandMixin, SessionStatusCommandMixin, diff --git a/src/coding_agent_telegram/router/session_common.py b/src/coding_agent_telegram/router/session_common.py index be601ca..b26a086 100644 --- a/src/coding_agent_telegram/router/session_common.py +++ b/src/coding_agent_telegram/router/session_common.py @@ -47,11 +47,18 @@ def _store_pending_action(self, chat_id: int, pending_action: dict[str, object] def _should_queue_incoming_message(self, chat_id: int) -> bool: pending_action = self._pending_action(chat_id) + has_pending_photo_album = getattr(self, "_has_pending_photo_album", lambda _chat_id: False) return ( self._is_project_busy(chat_id) or self._has_pending_queue_files(chat_id) + # A queue file has already been claimed for dispatch but its message may + # still be going through an async preflight check (such as long-gap + # confirmation). Treat that as queued work too, so a concurrently + # received message cannot jump ahead of it. + or chat_id in self._chat_processing_queue_files or self._has_pending_queue_decision(chat_id) or isinstance(pending_action, dict) + or has_pending_photo_album(chat_id) ) def _auto_session_name(self, project_folder: str, branch_name: str, provider: str, chat_id: int) -> str: diff --git a/src/coding_agent_telegram/router/session_lifecycle_commands.py b/src/coding_agent_telegram/router/session_lifecycle_commands.py index 73275be..2b5f63f 100644 --- a/src/coding_agent_telegram/router/session_lifecycle_commands.py +++ b/src/coding_agent_telegram/router/session_lifecycle_commands.py @@ -1,19 +1,38 @@ from __future__ import annotations import re +from types import SimpleNamespace from telegram import Update from telegram.ext import ContextTypes +from coding_agent_telegram.claude_health import claude_auth_failure_message, is_claude_auth_failure from coding_agent_telegram.filters import resolve_project_path from coding_agent_telegram.telegram_sender import send_text from .base import logger, require_allowed_chat +# Sent only to make the CLI open a session and return its ID. Kept explicitly inert: +# an instruction like "Create session: " reads as a real task to an autonomous +# agent, which would then act on it (e.g. creating a git branch named after the +# session) without the bot knowing. +SESSION_PRIMING_PROMPT = ( + "Reply with exactly: ready. Do not make any changes, run any commands, or use any tools." +) + + class SessionLifecycleCommandMixin: _CREATE_SESSION_TEXT_RE = re.compile(r"^\s*create\s+session\s*:\s*(.*?)\s*$", re.IGNORECASE) + def _hold_pending_action_for_prerequisite(self, chat_id: int, pending_action: dict[str, object] | None) -> None: + """Persist that a pending action is waiting for user-resolvable setup.""" + if pending_action is None: + return + held_action = dict(pending_action) + held_action["awaiting_prerequisite"] = True + self._store_pending_action(chat_id, held_action) + def _parse_create_session_text(self, text: str) -> tuple[bool, str | None]: match = self._CREATE_SESSION_TEXT_RE.match(text) if not match: @@ -53,6 +72,7 @@ async def _resolve_session_prerequisites( chat_state = self.deps.store.get_chat_state(self.deps.bot_id, chat_id) provider = self._selected_provider(chat_state) if not provider: + self._hold_pending_action_for_prerequisite(chat_id, pending_action) await self._prompt_for_provider_selection( update, context, @@ -65,7 +85,7 @@ async def _resolve_session_prerequisites( project_folder = str(chat_state.get("current_project_folder") or "").strip() if not project_folder: - self._store_pending_action(chat_id, pending_action) + self._hold_pending_action_for_prerequisite(chat_id, pending_action) await send_text( update, context, @@ -75,13 +95,13 @@ async def _resolve_session_prerequisites( project_path = resolve_project_path(self.deps.cfg.workspace_root, project_folder) if not project_path.exists() or not project_path.is_dir(): - self._store_pending_action(chat_id, pending_action) + self._hold_pending_action_for_prerequisite(chat_id, pending_action) await send_text(update, context, self._t(update, "project.project_folder_missing_retry", project_folder=project_folder)) return None branch_name = str(chat_state.get("current_branch") or "").strip() if self.git.is_git_repo(project_path) and not branch_name: - self._store_pending_action(chat_id, pending_action) + self._hold_pending_action_for_prerequisite(chat_id, pending_action) await self._send_branch_selection_prompt( update, context, @@ -137,9 +157,10 @@ async def _create_session_for_context( self.deps.agent_runner.create_session, provider, project_path, - f"Create session: {creation_label}", + SESSION_PRIMING_PROMPT, workspace_lock_key=project_folder, skip_git_repo_check=self.runtime.should_skip_git_repo_check(project_folder), + priming_only=True, stall_message=self._t(update, "runtime.replacement_session_stall"), ) @@ -147,7 +168,12 @@ async def _create_session_for_context( return False if not result.success or not result.session_id: - await send_text(update, context, result.error_message or self._t(update, "lifecycle.failed_create_session")) + error_text = ( + claude_auth_failure_message(self.deps.cfg.locale, result.error_message) + if provider == "claude" and is_claude_auth_failure(result.error_message) + else result.error_message or self._t(update, "lifecycle.failed_create_session") + ) + await send_text(update, context, error_text) return False if use_session_id_as_name: @@ -202,6 +228,10 @@ async def _continue_pending_action( if resolved is None: return False provider, project_folder, branch_name, project_path = resolved + if pending_action.get("awaiting_prerequisite"): + pending_action = dict(pending_action) + pending_action.pop("awaiting_prerequisite", None) + self._store_pending_action(chat_id, pending_action) kind = str(pending_action.get("kind") or "") if kind == "new_session": @@ -241,9 +271,19 @@ async def _continue_pending_action( return False if not await self._ensure_active_session_ready_for_run(update, context): return False + # A deferred queued message may be resumed by a control command such + # as /project. Keep its response associated with the original user + # question instead of making Telegram quote that control command. + reply_to_message_id = pending_action.get("reply_to_message_id") + run_update = update + if isinstance(reply_to_message_id, int): + run_update = SimpleNamespace( + effective_chat=update.effective_chat, + message=SimpleNamespace(message_id=reply_to_message_id), + ) try: self._last_run_results[chat_id] = await self.runtime.run_active_session( - update, + run_update, context, user_message=user_message, suppress_working_notice=bool(pending_action.get("suppress_working_notice")), @@ -291,6 +331,7 @@ async def _ensure_active_session_ready_for_run(self, update: Update, context: Co return await self._resolve_branch_discrepancy_if_needed(update, context) pending_action = dict(pending_action) + pending_action["awaiting_prerequisite"] = True pending_action["branch_resolution"] = { "kind": "discrepancy", "session_id": active_session_id, diff --git a/src/coding_agent_telegram/router/session_model_commands.py b/src/coding_agent_telegram/router/session_model_commands.py new file mode 100644 index 0000000..172bf96 --- /dev/null +++ b/src/coding_agent_telegram/router/session_model_commands.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update +from telegram.ext import ContextTypes + +from coding_agent_telegram.telegram_sender import send_text + +from .base import require_allowed_chat +from .session_lifecycle_commands import SESSION_PRIMING_PROMPT + + +class SessionModelCommandMixin: + def _configured_default_model(self, provider: str) -> str: + if provider == "codex": + return self.deps.cfg.codex_model + if provider == "claude": + return self.deps.cfg.claude_model + return self.deps.cfg.copilot_model + + def _model_choices(self, provider: str) -> tuple[str, ...]: + if provider == "codex": + return self.deps.cfg.codex_model_choices + if provider == "claude": + return self.deps.cfg.claude_model_choices + return self.deps.cfg.copilot_model_choices + + def _default_model_label(self, provider: str) -> str: + return self._configured_default_model(provider) or self._t(None, "model.cli_default") + + def _build_model_keyboard(self, provider: str, current_model: str) -> InlineKeyboardMarkup: + def marker(model: str) -> str: + return f" ({self._t(None, 'model.status_current')})" if model == current_model else "" + + default_label = f"{self._t(None, 'model.default_option')} [{self._default_model_label(provider)}]" + rows = [ + [ + InlineKeyboardButton( + f"{default_label}{marker('')}", + callback_data="model:default", + api_kwargs={"style": "success"}, + ) + ] + ] + # Encode the choice's position rather than the model string itself: some + # vendor model ids are long enough that "model:set:" would exceed + # Telegram's 64-byte callback_data limit and silently break the keyboard. + rows.extend( + [ + InlineKeyboardButton( + f"{model}{marker(model)}", + callback_data=f"model:set:{index}", + api_kwargs={"style": "success"}, + ) + ] + for index, model in enumerate(self._model_choices(provider)) + ) + return InlineKeyboardMarkup(rows) + + @require_allowed_chat() + async def handle_model(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if await self._notify_if_current_project_busy(update, context): + return + + chat_id = update.effective_chat.id + active_id, session, project_path = self._active_session_context(chat_id) + if session is None: + await send_text(update, context, self._t(update, "common.no_active_session")) + return + + if len(context.args) > 1: + await send_text(update, context, self._t(update, "model.usage_model")) + return + + provider = session.get("provider", "codex") + + if context.args: + model = context.args[0].strip() + if not model: + await send_text(update, context, self._t(update, "model.usage_model")) + return + await self._validate_and_set_model( + update, + context, + chat_id=chat_id, + active_id=active_id, + session=session, + project_path=project_path, + provider=provider, + model=model, + ) + return + + current_model = (session.get("model") or "").strip() + is_custom = bool(current_model) and current_model not in self._model_choices(provider) + prompt_key = "model.current_model_prompt_custom" if is_custom else "model.current_model_prompt" + await context.bot.send_message( + chat_id=chat_id, + text=self._t( + update, + prompt_key, + model=current_model or self._default_model_label(provider), + ), + reply_markup=self._build_model_keyboard(provider, current_model), + ) + + async def _validate_and_set_model( + self, + update: Update, + context: ContextTypes.DEFAULT_TYPE, + *, + chat_id: int, + active_id: str, + session: dict, + project_path, + provider: str, + model: str, + ) -> None: + # Models already on the curated list came from the button picker or the + # operator's config, so there is no need to re-probe the CLI for them. + if model in self._model_choices(provider): + self.deps.store.set_session_model(self.deps.bot_id, chat_id, active_id, model) + await send_text(update, context, self._t(update, "model.current_model_set", model=model)) + return + + project_folder = session["project_folder"] + if not project_path.exists() or not project_path.is_dir(): + await send_text( + update, + context, + self._t(update, "project.project_folder_missing_retry", project_folder=project_folder), + ) + return + + await send_text(update, context, self._t(update, "model.validating", model=model)) + # A throwaway, read-only priming call is the only way to find out whether a CLI + # accepts a given --model value -- none of the providers expose a way to list or + # validate model ids up front. Nothing is persisted unless this succeeds. + result = await self._run_with_typing( + update, + context, + self.deps.agent_runner.create_session, + provider, + project_path, + SESSION_PRIMING_PROMPT, + workspace_lock_key=project_folder, + skip_git_repo_check=self.runtime.should_skip_git_repo_check(project_folder), + priming_only=True, + model=model, + stall_message=self._t(update, "runtime.replacement_session_stall"), + ) + if result is None: + return + + if not result.success or not result.session_id: + error_text = result.error_message or self._t(update, "model.invalid_model_generic") + await send_text(update, context, self._t(update, "model.invalid_model", model=model, error=error_text)) + return + + self.deps.store.set_session_model(self.deps.bot_id, chat_id, active_id, model) + await send_text(update, context, self._t(update, "model.current_model_set", model=model)) + + @require_allowed_chat(answer_callback=True) + async def handle_model_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + if query is None or query.data is None: + return + + await query.answer() + if await self._notify_if_current_project_busy(update, context): + return + + chat_id = update.effective_chat.id + active_id, session, _ = self._active_session_context(chat_id) + if session is None: + await query.edit_message_text(self._t(update, "common.no_active_session")) + return + + provider = session.get("provider", "codex") + data = query.data + + if data == "model:default": + model = "" + else: + _, _, index_text = data.partition("model:set:") + choices = self._model_choices(provider) + try: + index = int(index_text) + except ValueError: + index = -1 + if not (0 <= index < len(choices)): + # The list or provider changed since this keyboard was rendered + # (e.g. an operator edited *_MODEL_CHOICES, or /provider was run + # in between) -- tell the user instead of silently doing nothing. + await query.edit_message_text(self._t(update, "model.stale_selection")) + return + model = choices[index] + + self.deps.store.set_session_model(self.deps.bot_id, chat_id, active_id, model) + await query.edit_message_text( + self._t( + update, + "model.current_model_set", + model=model or self._default_model_label(provider), + ) + ) diff --git a/src/coding_agent_telegram/router/session_provider_commands.py b/src/coding_agent_telegram/router/session_provider_commands.py index fded396..6211b74 100644 --- a/src/coding_agent_telegram/router/session_provider_commands.py +++ b/src/coding_agent_telegram/router/session_provider_commands.py @@ -77,8 +77,8 @@ def button_label(provider: str) -> str: callback_data=f"provider:set:{provider}", api_kwargs={"style": "success"}, ) - for provider in SUPPORTED_PROVIDERS ] + for provider in SUPPORTED_PROVIDERS ] ) diff --git a/src/coding_agent_telegram/router/session_status_commands.py b/src/coding_agent_telegram/router/session_status_commands.py index 661d1d9..4388435 100644 --- a/src/coding_agent_telegram/router/session_status_commands.py +++ b/src/coding_agent_telegram/router/session_status_commands.py @@ -1,13 +1,23 @@ from __future__ import annotations import asyncio +import time from telegram import Update from telegram.ext import ContextTypes from coding_agent_telegram.filters import resolve_project_path from coding_agent_telegram.i18n import translate +from coding_agent_telegram.providers import provider_label +from coding_agent_telegram.session_gap import humanize_gap_seconds from coding_agent_telegram.telegram_sender import send_text +from coding_agent_telegram.usage_status import ( + ProviderUsage, + RateWindow, + fetch_codex_usage, + fetch_copilot_usage, + get_claude_usage, +) from .base import logger, require_allowed_chat @@ -26,19 +36,77 @@ async def handle_current(self, update: Update, context: ContextTypes.DEFAULT_TYP active_id, chat_id, ) - await send_text( + details = self._t( update, - context, - self._t( - update, - "status.current_session_details", - session_name=session["name"], - session_id=active_id, - project_folder=session["project_folder"], - provider=session.get("provider", "codex"), - branch_name=session.get("branch_name") or self._t(update, "status.current_branch_placeholder"), - ), + "status.current_session_details", + session_name=session["name"], + session_id=active_id, + project_folder=session["project_folder"], + provider=session.get("provider", "codex"), + branch_name=session.get("branch_name") or self._t(update, "status.current_branch_placeholder"), ) + activity_line = await self._switch_activity_line(chat_id, session.get("provider", "codex"), active_id) + if activity_line: + details = f"{details}\n{activity_line}" + await send_text(update, context, details) + + @require_allowed_chat() + async def handle_status(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if context.args: + await send_text(update, context, self._t(update, "status.usage_status")) + return + + def fetch_all() -> tuple[ProviderUsage, ProviderUsage, ProviderUsage]: + claude_usage = get_claude_usage() + codex_usage = fetch_codex_usage(self.deps.cfg.codex_bin) + copilot_usage = fetch_copilot_usage() + return claude_usage, codex_usage, copilot_usage + + claude_usage, codex_usage, copilot_usage = await self._run_with_typing(update, context, fetch_all) + + locale = self._chat_locale(update.effective_chat.id) + lines = [self._t(update, "status.usage_title")] + for usage in (claude_usage, codex_usage, copilot_usage): + lines.append("") + lines.append(self._format_provider_usage(locale, usage)) + + logger.info( + "Reported provider usage status for chat %s (claude=%s, codex=%s, copilot=%s).", + update.effective_chat.id, + claude_usage.available, + codex_usage.available, + copilot_usage.available, + ) + await send_text(update, context, "\n".join(lines)) + + def _format_provider_usage(self, locale: str, usage: ProviderUsage) -> str: + label = provider_label(usage.provider) + if usage.plan: + label = f"{label} ({usage.plan})" + if not usage.available: + detail = usage.error or translate(locale, "status.usage_unknown") + return f"{label}\n {translate(locale, 'status.usage_unavailable', detail=detail)}" + + five_hour_label = translate(locale, "status.usage_five_hour") + weekly_label = translate(locale, "status.usage_weekly") + lines = [label] + if usage.observed_at is not None: + age = max(0.0, time.time() - usage.observed_at) + lines.append(f" {translate(locale, 'status.usage_last_observed', duration=humanize_gap_seconds(age))}") + lines.append(f" {self._format_rate_window(locale, five_hour_label, usage.five_hour, usage.five_hour_note)}") + lines.append(f" {self._format_rate_window(locale, weekly_label, usage.weekly, usage.weekly_note)}") + return "\n".join(lines) + + def _format_rate_window(self, locale: str, label: str, window: RateWindow | None, note: str | None) -> str: + if window is None: + detail = note or translate(locale, "status.usage_unknown") + return f"{label}: {translate(locale, 'status.usage_na', detail=detail)}" + text = f"{label}: {window.used_percent:g}%" + if window.resets_at: + remaining = window.resets_at - time.time() + if remaining > 0: + text += f" ({translate(locale, 'status.usage_resets_in', duration=humanize_gap_seconds(remaining))})" + return text @require_allowed_chat() async def handle_abort(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: diff --git a/src/coding_agent_telegram/router/switch_commands.py b/src/coding_agent_telegram/router/switch_commands.py index b396b9b..ce5e20b 100644 --- a/src/coding_agent_telegram/router/switch_commands.py +++ b/src/coding_agent_telegram/router/switch_commands.py @@ -1,4 +1,5 @@ from __future__ import annotations +import asyncio import html from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update @@ -7,6 +8,12 @@ from coding_agent_telegram.filters import resolve_project_path from coding_agent_telegram.i18n import translate from coding_agent_telegram.native_sessions import discover_native_project_sessions +from coding_agent_telegram.session_gap import ( + gap_seconds_since, + humanize_gap_seconds, + humanize_token_count, + native_session_activity, +) from coding_agent_telegram.telegram_sender import send_html_text, send_text from .base import logger, require_allowed_chat @@ -21,6 +28,24 @@ def _switch_status_label(self, chat_id: int, status: str) -> str: key = "switch.status_active" if status == "active" else "switch.status_idle" return translate(self._chat_locale(chat_id), key) + async def _switch_activity_line(self, chat_id: int, provider: str, session_id: str) -> str | None: + """Best-effort "last active"/"tokens used" line sourced from the same native + transcript/db lookup the long-gap warning uses (session_gap.py), not from the + bot's own state.json bookkeeping timestamps -- those only move when the bot + itself dispatches a message, so they'd miss activity from native CLI use and + go stale exactly for the sessions where this is most useful to see. Returns + None when no native activity signal exists at all (e.g. the transcript was + cleaned up, or the provider has no local record of it).""" + last_activity, size_tokens = await asyncio.to_thread(native_session_activity, provider, session_id) + if last_activity is None: + return None + locale = self._chat_locale(chat_id) + gap_seconds = gap_seconds_since(last_activity) + parts = [translate(locale, "switch.last_active_ago", duration=humanize_gap_seconds(gap_seconds))] + if size_tokens is not None: + parts.append(translate(locale, "switch.tokens_used", tokens=humanize_token_count(size_tokens))) + return " | ".join(parts) + def _switch_listing_entries(self, chat_id: int) -> tuple[list[dict[str, str]], str | None]: chat_state = self.deps.store.get_chat_state(self.deps.bot_id, chat_id) bot_sessions = self.deps.store.list_sessions(self.deps.bot_id, chat_id) @@ -86,7 +111,7 @@ def _switch_listing_entries(self, chat_id: int) -> tuple[list[dict[str, str]], s entries.sort(key=lambda item: (item["updated_at"] or item["created_at"], item["session_id"]), reverse=True) return entries, current_project_folder - def _build_switch_page_from_entries( + async def _build_switch_page_from_entries( self, chat_id: int, entries: list[dict[str, str]], @@ -121,6 +146,9 @@ def _build_switch_page_from_entries( f"{idx}. {marker} {html.escape(entry['name'])} | {html.escape(entry['project_folder'])} <{html.escape(branch_name)}> | {html.escape(entry['provider'])} | {html.escape(status_label)}" ) lines.append(f"session_id: {entry['session_id']}") + activity_line = await self._switch_activity_line(chat_id, entry["provider"], entry["session_id"]) + if activity_line: + lines.append(activity_line) lines.append(f"{translate(locale, 'switch.initialized_label')}: {html.escape(entry['initialized_from'])}") lines.append("") @@ -164,7 +192,7 @@ async def handle_switch(self, update: Update, context: ContextTypes.DEFAULT_TYPE await send_text(update, context, self._t(update, "switch.no_sessions_found")) return logger.info("Listed sessions page 1 for chat %s (%d sessions total).", chat_id, len(entries)) - text, reply_markup = self._build_switch_page_from_entries(chat_id, entries, current_project_folder, 1) + text, reply_markup = await self._build_switch_page_from_entries(chat_id, entries, current_project_folder, 1) await context.bot.send_message( chat_id=chat_id, text=text, @@ -186,7 +214,7 @@ async def handle_switch(self, update: Update, context: ContextTypes.DEFAULT_TYPE await send_text(update, context, self._t(update, "switch.invalid_page_number")) return logger.info("Listed sessions page %d for chat %s (%d sessions total).", page, chat_id, len(entries)) - text, reply_markup = self._build_switch_page_from_entries(chat_id, entries, current_project_folder, page) + text, reply_markup = await self._build_switch_page_from_entries(chat_id, entries, current_project_folder, page) await context.bot.send_message( chat_id=chat_id, text=text, @@ -284,7 +312,7 @@ async def handle_switch_page_callback(self, update: Update, context: ContextType if not entries: await query.edit_message_text(self._t(update, "switch.no_sessions_found")) return - text, reply_markup = self._build_switch_page_from_entries(chat_id, entries, current_project_folder, page) + text, reply_markup = await self._build_switch_page_from_entries(chat_id, entries, current_project_folder, page) await query.edit_message_text( text=text, parse_mode="HTML", diff --git a/src/coding_agent_telegram/session_gap.py b/src/coding_agent_telegram/session_gap.py new file mode 100644 index 0000000..d07d689 --- /dev/null +++ b/src/coding_agent_telegram/session_gap.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +"""Best-effort "how long has this native session been idle, and how big is it" detection. + +Used to warn a user before resuming a session that has been idle long enough for the +provider's prompt cache to have expired, since resuming past that point forces the +whole accumulated conversation to be reprocessed instead of read from cache (see the +"does this app burn more tokens" FAQ in README.md). + +Deliberately uses filesystem/db modification time and, where cheaply available, a +provider-reported token count as proxies rather than parsing the full transcript on +every incoming message: + +- Claude: no official idle-based cache-expiry document exists, but real session + transcripts show the extended prompt-cache checkpoint holding for about an hour and + a full-context reprocess happening reliably past that (see README FAQ). +- Codex: OpenAI doesn't document an idle-based cache-expiry number either; there is an + open feature request against the Codex CLI for auto-compacting idle sessions before + the prompt cache expires, for the same reason. Codex's local session database does + track a cumulative `tokens_used` counter per thread, which is used here as a proxy + for "how expensive would reprocessing this session be" so small/cheap sessions don't + trigger a warning just because they sat idle. +- Copilot: GitHub's own docs state Copilot CLI has no inactivity timeout, and Copilot + manages context itself (auto-compacting around ~80% of the context window, waiting + for compaction to finish around ~95%). There's no idle-based cache-expiry concern to + warn about, so this module intentionally has no Copilot size signal, and the + provider's idle-warning threshold defaults to disabled (see config.py) — Copilot's + own native compaction is the mechanism to trust here, not an invented one. +""" + +import json +import math +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable, NamedTuple, Optional + +from coding_agent_telegram.native_claude_sessions import claude_projects_root +from coding_agent_telegram.native_codex_sessions import query_codex_state_db +from coding_agent_telegram.native_copilot_sessions import copilot_session_roots + +# How far from the end of a Claude transcript to read looking for the most recent +# assistant usage entry. Bounded so a multi-megabyte transcript still costs one cheap +# tail read rather than a full parse. +_CLAUDE_TAIL_READ_BYTES = 65536 + + +class SessionActivity(NamedTuple): + last_activity: Optional[datetime] + size_tokens: Optional[int] # best-effort proxy for accumulated context size; None if unknown + + +def _mtime_utc(path: Path) -> Optional[datetime]: + try: + return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc) + except OSError: + return None + + +def _tail_bytes(path: Path, max_bytes: int) -> bytes: + try: + size = path.stat().st_size + with path.open("rb") as handle: + if size > max_bytes: + handle.seek(size - max_bytes) + return handle.read() + except OSError: + return b"" + + +def _claude_last_assistant_usage_tokens(path: Path) -> Optional[int]: + text = _tail_bytes(path, _CLAUDE_TAIL_READ_BYTES).decode("utf-8", errors="ignore") + for line in reversed(text.splitlines()): + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if entry.get("type") != "assistant": + continue + message = entry.get("message") or {} + usage = message.get("usage") + if not isinstance(usage, dict): + continue + total = 0 + for key in ("input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"): + value = usage.get(key) + if isinstance(value, (int, float)): + total += int(value) + if total == 0: + # Claude Code fabricates client-side "assistant" entries for cases where it + # never called the API at all -- hitting the rate limit, being logged out, + # or skipping a priming/continuation nudge that needed no reply (tagged with + # a "" model marker). Every one of those reports zero usage, and a + # genuine API turn never does (even a minimal one bills a couple of baseline + # input tokens), so treat an all-zero usage block as a stub regardless of the + # model marker's exact spelling -- which keeps this working even if Claude + # Code renames that marker -- and keep scanning for the last turn that + # actually hit the API. + continue + return total + return None + + +def _claude_activity(session_id: str) -> SessionActivity: + projects_root = claude_projects_root() + if not projects_root.exists(): + return SessionActivity(None, None) + # Session IDs are UUIDs, unique across every project directory, so a glob by + # filename alone finds the right transcript without needing to reimplement + # Claude Code's project-path-to-folder-name encoding. + try: + matches = sorted(projects_root.glob(f"*/{session_id}.jsonl")) + except OSError: + return SessionActivity(None, None) + if not matches: + return SessionActivity(None, None) + path = matches[0] + return SessionActivity(_mtime_utc(path), _claude_last_assistant_usage_tokens(path)) + + +def _codex_activity(session_id: str) -> SessionActivity: + rows = query_codex_state_db("SELECT updated_at, tokens_used FROM threads WHERE id = ?", (session_id,)) + if not rows or not rows[0][0]: + return SessionActivity(None, None) + updated_at, tokens_used = rows[0] + size_tokens = int(tokens_used) if isinstance(tokens_used, (int, float)) else None + return SessionActivity(datetime.fromtimestamp(updated_at, tz=timezone.utc), size_tokens) + + +def _copilot_activity(session_id: str) -> SessionActivity: + latest: Optional[datetime] = None + for root in copilot_session_roots(Path.home()): + session_dir = root / "session-state" / session_id + for name in ("workspace.yaml", "events.jsonl"): + candidate = _mtime_utc(session_dir / name) + if candidate is not None and (latest is None or candidate > latest): + latest = candidate + # No local, cheaply-available token/context-size signal for Copilot -- see the + # module docstring for why that's fine (no idle-based cache-expiry concern there). + return SessionActivity(latest, None) + + +# One entry per provider this module knows how to inspect. A provider missing here +# (or not yet added) falls back to "unknown activity" below, rather than needing its +# own if/elif branch kept in sync with this dict. +_ACTIVITY_LOOKUP: dict[str, Callable[[str], SessionActivity]] = { + "claude": _claude_activity, + "codex": _codex_activity, + "copilot": _copilot_activity, +} + + +def native_session_activity(provider: str, session_id: str) -> SessionActivity: + """Return (last_activity, size_tokens) for *session_id*'s native transcript.""" + if not session_id: + return SessionActivity(None, None) + normalized_provider = (provider or "").strip().lower() + lookup = _ACTIVITY_LOOKUP.get(normalized_provider) + if lookup is None: + return SessionActivity(None, None) + return lookup(session_id) + + +def gap_seconds_since(last_activity: Optional[datetime]) -> Optional[float]: + """Seconds since *last_activity*, or None if it's unknown.""" + if last_activity is None: + return None + now = datetime.now(timezone.utc) + return max(0.0, (now - last_activity).total_seconds()) + + +def humanize_gap_seconds(seconds: float) -> str: + """Render a gap as a short human string, e.g. "12h 30m" or "45m".""" + total_minutes = int(seconds // 60) + days, remainder_minutes = divmod(total_minutes, 24 * 60) + hours, minutes = divmod(remainder_minutes, 60) + parts: list[str] = [] + if days: + parts.append(f"{days}d") + if hours: + parts.append(f"{hours}h") + if minutes or not parts: + parts.append(f"{minutes}m") + return " ".join(parts[:2]) + + +# Largest-to-smallest so the first divisor a count actually reaches wins. +_TOKEN_COUNT_UNITS: tuple[tuple[int, str], ...] = ( + (1_000_000_000, "B"), + (1_000_000, "M"), + (1_000, "k"), +) + + +def humanize_token_count(tokens: int) -> str: + """Render a token count as a short human string, e.g. "800", "1k", "200k", "1M", + "11M". Rounds down to one decimal place rather than to nearest, so a count just + under a unit's boundary (e.g. 999,999) reads as "999.9k" rather than rolling over + to a misleading "1000k".""" + if tokens < 1000: + return str(tokens) + for divisor, suffix in _TOKEN_COUNT_UNITS: + if tokens < divisor: + continue + value = math.floor((tokens / divisor) * 10) / 10 + return f"{value:g}{suffix}" + return str(tokens) diff --git a/src/coding_agent_telegram/session_runtime.py b/src/coding_agent_telegram/session_runtime.py index cd3fe7e..8cee78d 100644 --- a/src/coding_agent_telegram/session_runtime.py +++ b/src/coding_agent_telegram/session_runtime.py @@ -10,10 +10,11 @@ import os from typing import Awaitable, Callable, Optional, Sequence -from telegram import Update +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import ContextTypes from coding_agent_telegram.agent_runner import AgentRunResult, MultiAgentRunner +from coding_agent_telegram.claude_health import claude_auth_failure_message, is_claude_auth_failure from coding_agent_telegram.config import AppConfig, DEFAULT_MAX_PHOTO_ATTACHMENT_BYTES from coding_agent_telegram.diff_utils import ( TEXTUAL_DIFF_UNAVAILABLE, @@ -32,6 +33,7 @@ from coding_agent_telegram.providers import provider_label as provider_display_label from coding_agent_telegram.session_store import SessionStore from coding_agent_telegram.telegram_sender import ( + affirmative_inline_button_kwargs, markdownish_to_html, send_code_block, send_html_text, @@ -54,10 +56,90 @@ "{summary}\n\n" "Acknowledge that you have loaded the handoff summary and are ready to continue." ) +# Mirrors session_lifecycle_commands.SESSION_PRIMING_PROMPT (duplicated rather than +# imported to avoid a router -> session_runtime -> router import cycle): makes the CLI +# hand back a session ID without acting on the throwaway prompt. +NEW_SESSION_PRIMING_PROMPT = "Reply with exactly: ready. Do not make any changes, run any commands, or use any tools." # Matches absolute filesystem paths (Unix and Windows styles) in error messages. _ABSOLUTE_PATH_RE = re.compile(r"(?:^|(?<=\s)|(?<=[\"'(]))((?:/[^\s\"',;)]+)+|[A-Za-z]:\\[^\s\"',;)]+)") +# Matches a trailing "-resumeN" suffix so re-compacting an already-compacted +# session rotates the number instead of stacking suffixes. +_RESUME_SUFFIX_RE = re.compile(r"-resume\d+$", re.IGNORECASE) + +# Matches a trailing "-newN" suffix so repeatedly switching to a fresh session rotates +# the number instead of stacking suffixes. +_NEW_SUFFIX_RE = re.compile(r"-new\d+$", re.IGNORECASE) + +# Fallback substring marking an agent-run failure as "this session ID can't be resumed" +# for providers without a structured signal for it, so _replace_invalid_session_if_needed +# knows to create a replacement session instead of just reporting the failure. Claude has +# its own precise signal (AgentRunResult.error_code == "session_not_found", set from the +# CLI's structured "errors" field -- see agent_runner._claude_events_report_session_not_found) +# and is checked separately below; this generic "resume" substring is the only fallback +# available for Codex/Copilot, none of which have a documented, stable error string, so it's +# kept broad and is only ever matched against a failure's error_message, never used to +# override a success. +_UNRESUMABLE_SESSION_FALLBACK_PHRASE = "resume" + +# Matches a numbered/lettered list line, e.g. "1. Do X" or "a) Do Y". +_OPTION_LINE_RE = re.compile(r"^\s*(?:[0-9]{1,2}[.)]|[A-Za-z][.)])\s+(.{2,140}?)\s*$") +# Requires an explicit "which one do you want" style cue near the option list, +# so an ordinary numbered list in a reply doesn't get mistaken for a menu. +_OPTION_QUESTION_CUE_RE = re.compile( + r"\b(which (one|option|approach|way)|let me know which|should i|shall i|" + r"would you like me to|which would you|go with|pick one|choose one|which do you want)\b", + re.IGNORECASE, +) +_MAX_REPLY_OPTIONS = 6 +_REPLY_OPTION_TAIL_LINES = 12 +# Trailing characters that can sit after a label's question mark and hide it. Claude +# routinely bolds numbered questions ("1. **Use Redis or in-memory?**"), which would +# otherwise read as a plain choice rather than a question. +_OPTION_LABEL_TRAILING_NOISE = "*_`)]. \t" +# A list of independent questions has every line ending in a question mark; a menu for +# one decision may still have a single "Something else?" style escape option, so one +# question mark alone must not suppress the whole menu. +_MIN_QUESTION_LABELS_FOR_MULTI_QUESTION = 2 + + +def _label_is_question(label: str) -> bool: + return label.rstrip(_OPTION_LABEL_TRAILING_NOISE).endswith("?") + + +def _detect_reply_options(text: str) -> tuple[str, ...]: + """Return option labels if the assistant's reply is asking the user to pick one.""" + stripped = text.strip() + if not stripped: + return () + tail_lines = stripped.splitlines()[-_REPLY_OPTION_TAIL_LINES:] + if not _OPTION_QUESTION_CUE_RE.search("\n".join(tail_lines)): + return () + + options: list[str] = [] + question_labels = 0 + for line in tail_lines: + match = _OPTION_LINE_RE.match(line) + if match: + label = match.group(1).strip() + if _label_is_question(label): + question_labels += 1 + options.append(label) + + if question_labels >= _MIN_QUESTION_LABELS_FOR_MULTI_QUESTION: + # Each line is its own question (e.g. "1. Should I use A or B?"), not a choice + # for one decision — bail out rather than offering buttons that would resend a + # question as if it were an answer. + return () + if len(options) < 2: + return () + return tuple(options[:_MAX_REPLY_OPTIONS]) + + +def _session_provider(session: dict[str, str]) -> str: + return str(session.get("provider") or "codex").strip().lower() or "codex" + def _reply_to_message_id(update: Update) -> int | None: message = getattr(update, "message", None) @@ -143,17 +225,30 @@ async def store_photo(self, update: Update, project_folder: str) -> Path: attachments_root = self.attachments_root(project_folder) attachments_root.mkdir(parents=True, exist_ok=True) - target = attachments_root / f"{digest}{suffix}" - if not target.exists(): - target.write_bytes(content) - return target - - def build_prompt(self, attachment_path: Path, project_path: Path, caption: str) -> str: - rel_path = os.path.relpath(attachment_path, start=project_path).replace(os.sep, "/") - lines = [ - f"An image is attached at {rel_path}.", - IMAGE_INSPECTION_PROMPT, - ] + # Eight hex characters keep paths readable while still providing roughly + # four billion possible names. Should a prefix collision ever occur, grow + # only that filename until it is unambiguous. + for length in range(8, len(digest) + 1, 8): + target = attachments_root / f"{digest[:length]}{suffix}" + if not target.exists(): + target.write_bytes(content) + return target + if target.read_bytes() == content: + return target + # A full SHA-256 collision is not realistically possible, but keep the + # fallback deterministic rather than overwriting an existing attachment. + raise PhotoAttachmentError("photo_name_collision", "Could not store photo attachment safely.") + + def build_prompt(self, attachment_paths: Sequence[Path], project_path: Path, caption: str) -> str: + rel_paths = [os.path.relpath(path, start=project_path).replace(os.sep, "/") for path in attachment_paths] + if len(rel_paths) == 1: + lines = [f"An image is attached at {rel_paths[0]}.", IMAGE_INSPECTION_PROMPT] + else: + lines = [ + "Images are attached at:", + *(f"- {path}" for path in rel_paths), + "Open and inspect every image before answering.", + ] caption = caption.strip() if caption: lines.extend(["", "User caption:", caption]) @@ -163,6 +258,7 @@ def build_prompt(self, attachment_path: Path, project_path: Path, caption: str) RunWithTyping = Callable[..., Awaitable[object]] +RegisterReplyOptions = Callable[[int, tuple[str, ...]], str] class SessionRuntime: @@ -175,6 +271,7 @@ def __init__( bot_id: str, git: GitWorkspaceManager, run_with_typing: RunWithTyping, + register_reply_options: RegisterReplyOptions, ) -> None: self.cfg = cfg self.store = store @@ -182,6 +279,7 @@ def __init__( self.bot_id = bot_id self.git = git self.run_with_typing = run_with_typing + self.register_reply_options = register_reply_options def _locale(self, update: Update | None) -> str: return self.cfg.locale @@ -189,12 +287,34 @@ def _locale(self, update: Update | None) -> str: def _t(self, update: Update | None, key: str, **kwargs) -> str: return translate(self._locale(update), key, **kwargs) + def _claude_auth_error_text(self, provider: str, error_message: Optional[str]) -> Optional[str]: + """Returns the same guidance text the background Claude auth health + check sends (see claude_health.py) when a run just failed for that + reason, so a user who hits it live (before the periodic check would + have caught it) gets the fix instructions immediately instead of the + raw CLI error.""" + if provider != "claude" or not is_claude_auth_failure(error_message): + return None + return claude_auth_failure_message(self.cfg.locale, error_message) + + def _agent_failure_text(self, update: Update | None, provider: str, result: AgentRunResult) -> str: + if getattr(result, "error_code", None) == "agent_aborted": + return self._t(update, "runtime.agent_run_aborted") + error_message = result.error_message + claude_auth_text = self._claude_auth_error_text(provider, error_message) + if claude_auth_text: + return claude_auth_text + if error_message: + return _sanitize_agent_error(error_message, error_code=getattr(result, "error_code", None)) + return self._t(update, "runtime.agent_run_failed") + def _take_reply_to_message_id(self, reply_state: dict[str, int | None]) -> int | None: reply_to_message_id = reply_state.get("reply_to_message_id") reply_state["reply_to_message_id"] = None return reply_to_message_id - def _next_rotated_session_name(self, chat_id: int, base_name: str) -> str: + def _next_unique_session_name(self, chat_id: int, base_name: str, *, suffix_template: str) -> str: + """Find the first unused name of the form suffix_template.format(base=base_name, n=1), n=2, ...).""" existing = { data.get("name", "").strip().lower() for data in self.store.list_sessions(self.bot_id, chat_id).values() @@ -202,11 +322,28 @@ def _next_rotated_session_name(self, chat_id: int, base_name: str) -> str: } suffix = 1 while True: - candidate = f"{base_name}-{suffix}" + candidate = suffix_template.format(base=base_name, n=suffix) if candidate.lower() not in existing: return candidate suffix += 1 + def _next_rotated_session_name(self, chat_id: int, base_name: str) -> str: + return self._next_unique_session_name(chat_id, base_name, suffix_template="{base}-{n}") + + def _next_resume_session_name(self, chat_id: int, base_name: str) -> str: + """Like ``_next_rotated_session_name``, but for compaction: strips any existing + ``-resumeN`` suffix first so repeated compaction produces "name-resume1", + "name-resume2", ... instead of "name-resume1-resume1-resume1".""" + stripped_base_name = _RESUME_SUFFIX_RE.sub("", base_name) + return self._next_unique_session_name(chat_id, stripped_base_name, suffix_template="{base}-resume{n}") + + def _next_switch_session_name(self, chat_id: int, base_name: str) -> str: + """Like ``_next_resume_session_name``, but for switching to a clean session with no + handoff summary: strips any existing ``-newN`` suffix first so repeated switching + produces "name-new1", "name-new2", ... instead of stacking suffixes.""" + stripped_base_name = _NEW_SUFFIX_RE.sub("", base_name) + return self._next_unique_session_name(chat_id, stripped_base_name, suffix_template="{base}-new{n}") + def should_skip_git_repo_check(self, project_folder: str) -> bool: return self.cfg.codex_skip_git_repo_check or self.store.is_project_trusted(project_folder) @@ -253,8 +390,9 @@ async def run_active_session( return None project_folder = session["project_folder"] - provider = session.get("provider", "codex") + provider = _session_provider(session) branch_name = session.get("branch_name", "") + model = (session.get("model") or "").strip() or None logger.info( "Running message for chat %s on session '%s' (%s) in project '%s' with provider '%s'. " "Prompt (first 200 chars): %.200r", @@ -295,6 +433,7 @@ async def run_active_session( workspace_lock_key=project_folder, skip_git_repo_check=self.should_skip_git_repo_check(project_folder), image_paths=image_paths, + model=model, stall_message=self._t(update, "runtime.active_run_stall"), progress_label=self._t(update, "runtime.live_agent_output"), ) @@ -331,13 +470,7 @@ async def run_active_session( active_id, result.error_message or "unknown error", ) - error_text = ( - _sanitize_agent_error(result.error_message, error_code=getattr(result, "error_code", None)) - if result.error_message - else self._t(update, "runtime.agent_run_failed") - ) - if getattr(result, "error_code", None) == "agent_aborted": - error_text = self._t(update, "runtime.agent_run_aborted") + error_text = self._agent_failure_text(update, provider, result) await send_text(update, context, error_text) return result @@ -351,6 +484,9 @@ async def run_active_session( project_folder, provider, branch_name=branch_name, + # Not a user-initiated new session -- the CLI just rotated the id for + # the same conversation, so the model override carries over. + model=model, ) logger.info( "Resume returned a different session id for chat %s; switched from '%s' (%s) to '%s' (%s).", @@ -400,7 +536,7 @@ async def compact_active_session( return None project_folder = session["project_folder"] - provider = session.get("provider", "codex") + provider = _session_provider(session) branch_name = session.get("branch_name", "") session_name = session["name"] logger.info( @@ -428,6 +564,7 @@ async def compact_active_session( COMPACT_SUMMARY_PROMPT, workspace_lock_key=project_folder, skip_git_repo_check=self.should_skip_git_repo_check(project_folder), + model=(session.get("model") or "").strip() or None, stall_message=self._t(update, "runtime.active_run_stall"), progress_label=self._t(update, "runtime.live_agent_output"), ) @@ -439,13 +576,7 @@ async def compact_active_session( ) return None if not summary_result.success: - error_text = ( - _sanitize_agent_error(summary_result.error_message, error_code=getattr(summary_result, "error_code", None)) - if summary_result.error_message - else self._t(update, "runtime.agent_run_failed") - ) - if getattr(summary_result, "error_code", None) == "agent_aborted": - error_text = self._t(update, "runtime.agent_run_aborted") + error_text = self._agent_failure_text(update, provider, summary_result) await send_text(update, context, error_text) return summary_result @@ -470,23 +601,20 @@ async def compact_active_session( COMPACT_BOOTSTRAP_TEMPLATE.format(summary=compact_summary), workspace_lock_key=project_folder, skip_git_repo_check=self.should_skip_git_repo_check(project_folder), + # Seeds context and returns a session ID only. The summary lists "next + # steps", which an autopilot agent would otherwise start executing here. + priming_only=True, stall_message=self._t(update, "runtime.replacement_session_stall"), progress_label=self._t(update, "runtime.live_agent_output"), ) if create_result is None: return None if not create_result.success or not create_result.session_id: - error_text = ( - _sanitize_agent_error(create_result.error_message, error_code=getattr(create_result, "error_code", None)) - if create_result.error_message - else self._t(update, "runtime.agent_run_failed") - ) - if getattr(create_result, "error_code", None) == "agent_aborted": - error_text = self._t(update, "runtime.agent_run_aborted") + error_text = self._agent_failure_text(update, provider, create_result) await send_text(update, context, error_text) return create_result - switched_session_name = self._next_rotated_session_name(chat_id, session_name) + switched_session_name = self._next_resume_session_name(chat_id, session_name) self.store.create_session( self.bot_id, chat_id, @@ -508,6 +636,85 @@ async def compact_active_session( ) return create_result + async def switch_to_new_session( + self, + update: Update, + context: ContextTypes.DEFAULT_TYPE, + ) -> AgentRunResult | None: + """Abandon the active session's context entirely and start a clean one. + + Unlike ``compact_active_session``, this never resumes the old (possibly cold) + session, so it carries none of that unavoidable full-transcript reprocess cost -- + at the price of the new session having no memory of the old one at all. + """ + chat_id = update.effective_chat.id + active_id, session, project_path = await self._active_session_or_notify(update, context) + if active_id is None or session is None or project_path is None: + return None + + project_folder = session["project_folder"] + provider = _session_provider(session) + branch_name = session.get("branch_name", "") + session_name = session["name"] + logger.info( + "Switching chat %s from session '%s' (%s) to a fresh session in project '%s' with provider '%s'.", + chat_id, + session_name, + active_id, + project_folder, + provider, + ) + + if branch_name and self.git.is_git_repo(project_path): + checkout = await self._checkout_branch(update, context, project_path, branch_name) + if not checkout: + return None + + await send_text(update, context, self._t(update, "runtime.switching_session")) + create_result = await self.run_with_typing( + update, + context, + self.agent_runner.create_session, + provider, + project_path, + NEW_SESSION_PRIMING_PROMPT, + workspace_lock_key=project_folder, + skip_git_repo_check=self.should_skip_git_repo_check(project_folder), + # Priming-only, same reasoning as compact_active_session's replacement + # session: the throwaway prompt must not be actionable. + priming_only=True, + stall_message=self._t(update, "runtime.replacement_session_stall"), + progress_label=self._t(update, "runtime.live_agent_output"), + ) + if create_result is None: + return None + if not create_result.success or not create_result.session_id: + error_text = self._agent_failure_text(update, provider, create_result) + await send_text(update, context, error_text) + return create_result + + switched_session_name = self._next_switch_session_name(chat_id, session_name) + self.store.create_session( + self.bot_id, + chat_id, + create_result.session_id, + switched_session_name, + project_folder, + provider, + branch_name=branch_name, + ) + await send_text( + update, + context, + self._t( + update, + "runtime.session_switched", + session_name=switched_session_name, + session_id=create_result.session_id, + ), + ) + return create_result + async def _checkout_branch( self, update: Update, @@ -538,7 +745,17 @@ async def _replace_invalid_session_if_needed( user_message: str, image_paths: Sequence[Path], ): - if result.success or not result.error_message or "resume" not in result.error_message.lower(): + if result.success or not result.error_message: + return result, active_id, session_name + # Claude has its own precise, structured signal (checked first); the substring + # fallback only kicks in for other providers, since for Claude it would also + # match a genuine (if failed) turn's model-generated result text that happens to + # mention "resume" for an unrelated reason -- exactly the false-positive this + # structured signal exists to avoid. + is_unresumable = getattr(result, "error_code", None) == "session_not_found" or ( + provider != "claude" and _UNRESUMABLE_SESSION_FALLBACK_PHRASE in result.error_message.lower() + ) + if not is_unresumable: return result, active_id, session_name logger.info( @@ -547,6 +764,9 @@ async def _replace_invalid_session_if_needed( active_id, chat_id, ) + # Not a user-initiated new session -- the old one just expired underneath the + # same conversation, so the model override carries over rather than resetting. + model = (session.get("model") or "").strip() or None create_result = await self.run_with_typing( update, context, @@ -557,6 +777,7 @@ async def _replace_invalid_session_if_needed( workspace_lock_key=project_folder, skip_git_repo_check=self.should_skip_git_repo_check(project_folder), image_paths=image_paths, + model=model, stall_message=self._t(update, "runtime.replacement_session_stall"), progress_label=self._t(update, "runtime.live_agent_output"), ) @@ -574,6 +795,7 @@ async def _replace_invalid_session_if_needed( project_folder, provider, branch_name=branch_name, + model=model, ) logger.info( "Created a replacement session for chat %s after resume failure: old='%s' (%s) new='%s' (%s).", @@ -688,6 +910,18 @@ async def _send_assistant_chunks( return total = len(segments) + + # If an agent's final reply reads like it's asking the user to pick between a + # few options, detect them now so we can offer buttons after the reply is sent. + # This is deliberately provider-neutral: Codex and Copilot run as one-shot + # subprocesses just like Claude, so a Telegram reply must become the next + # session turn rather than trying to hold an interactive CLI prompt open. + # Tapping one sends the option text back as the next chat message — the same + # as if the user had typed it. + reply_options: tuple[str, ...] = () + if segments[-1].kind == "prose" and update.effective_chat is not None: + reply_options = _detect_reply_options(segments[-1].text) + for index, segment in enumerate(segments, start=1): if segment.kind == "code": await send_code_block( @@ -700,7 +934,7 @@ async def _send_assistant_chunks( ) continue - provider_label = provider_display_label(provider) or "Codex" + provider_label = provider_display_label(provider) or "Agent" title_prefix = ( self._t(update, "runtime.provider_output_single", provider=provider_label) if total == 1 @@ -720,6 +954,32 @@ async def _send_assistant_chunks( reply_to_message_id=self._take_reply_to_message_id(reply_state), ) + if reply_options and update.effective_chat is not None: + token = self.register_reply_options(update.effective_chat.id, reply_options) + await send_html_text( + update, + context, + f"{html.escape(self._t(update, 'runtime.reply_options_prompt'))}", + ) + # Each option gets its own message with a single button right under it, so + # the full option text is always visible next to the button that picks it — + # no truncation, no guessing which button maps to which paragraph. + for index, option in enumerate(reply_options): + await send_html_text( + update, + context, + html.escape(option), + reply_markup=self._reply_option_keyboard(update, token, index), + ) + + def _reply_option_keyboard(self, update: Update, token: str, index: int) -> InlineKeyboardMarkup: + button = InlineKeyboardButton( + self._t(update, "runtime.reply_option_select_button"), + callback_data=f"agentopt:{token}:{index}", + **affirmative_inline_button_kwargs(), + ) + return InlineKeyboardMarkup([[button]]) + def _chunk_assistant_prose(self, title_prefix: str, text: str) -> list[str]: normalized = text.strip() if not normalized: diff --git a/src/coding_agent_telegram/session_store.py b/src/coding_agent_telegram/session_store.py index e32ba9b..2967ecc 100644 --- a/src/coding_agent_telegram/session_store.py +++ b/src/coding_agent_telegram/session_store.py @@ -11,6 +11,10 @@ T = TypeVar("T") +def _normalize_provider(provider: str) -> str: + return str(provider or "codex").strip().lower() or "codex" + + class SessionStoreError(Exception): """Raised when the session store cannot be accessed due to a file-lock conflict.""" @@ -147,12 +151,15 @@ def _write_session_record( origin: str = "bot", origin_label: Optional[str] = None, initialized_from: Optional[str] = None, + model: Optional[str] = None, ) -> dict[str, str]: now = self._now() + normalized_provider = _normalize_provider(provider) sessions[session_id] = { "name": session_name, "project_folder": project_folder, - "provider": provider, + "provider": normalized_provider, + "model": (model or "").strip(), "branch_name": branch_name or "", "origin": origin, "origin_label": origin_label or ("Bot managed session" if origin == "bot" else origin), @@ -193,10 +200,24 @@ def mutate(chat_data: dict[str, Any]) -> None: def set_current_provider(self, bot_id: str, chat_id: int, provider: str) -> None: def mutate(chat_data: dict[str, Any]) -> None: - chat_data["current_provider"] = provider + chat_data["current_provider"] = _normalize_provider(provider) self._mutate_chat_data(bot_id, chat_id, mutate, create=True) + def set_session_model(self, bot_id: str, chat_id: int, session_id: str, model: Optional[str]) -> bool: + """Store a model override on a session record; empty/None clears it back to the CLI/env default.""" + + def mutate(chat_data: dict[str, Any]) -> bool: + session = chat_data.get("sessions", {}).get(session_id) + if not session: + return False + session["model"] = (model or "").strip() + session["updated_at"] = self._now() + return True + + result = self._mutate_chat_data(bot_id, chat_id, mutate) + return False if result is None else result + def set_pending_action(self, bot_id: str, chat_id: int, pending_action: Optional[dict[str, Any]]) -> None: def mutate(chat_data: dict[str, Any]) -> None: if pending_action: @@ -241,6 +262,7 @@ def create_session( origin: str = "bot", origin_label: Optional[str] = None, initialized_from: Optional[str] = None, + model: Optional[str] = None, ) -> None: def mutate(chat_data: dict[str, Any]) -> None: sessions = chat_data.setdefault("sessions", {}) @@ -254,10 +276,11 @@ def mutate(chat_data: dict[str, Any]) -> None: origin=origin, origin_label=origin_label, initialized_from=initialized_from, + model=model, ) chat_data["active_session_id"] = session_id chat_data["current_project_folder"] = project_folder - chat_data["current_provider"] = provider + chat_data["current_provider"] = _normalize_provider(provider) if branch_name: chat_data["current_branch"] = branch_name @@ -376,7 +399,7 @@ def mutate(chat_data: dict[str, Any]) -> bool: chat_data["active_session_id"] = session_id chat_data["current_project_folder"] = session["project_folder"] - chat_data["current_provider"] = session.get("provider", "codex") + chat_data["current_provider"] = _normalize_provider(session.get("provider", "codex")) if session.get("branch_name"): chat_data["current_branch"] = session["branch_name"] else: diff --git a/src/coding_agent_telegram/supervisor.py b/src/coding_agent_telegram/supervisor.py new file mode 100644 index 0000000..8e52dc3 --- /dev/null +++ b/src/coding_agent_telegram/supervisor.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +"""Keep the installed console command alive across Telegram/network failures. + +The repository's ``startup.sh`` has an equivalent shell supervisor. Installed +users do not have that script, so the public console entry point uses this +portable implementation instead. The polling child remains in ``cli.py``; +keeping it separate means a restart always creates a fresh asyncio loop and +Telegram HTTP client. +""" + +import os +import signal +import socket +import subprocess +import sys +import time +from pathlib import Path +from typing import NoReturn, Sequence + +from coding_agent_telegram.config import default_app_internal_root + + +MIN_BACKOFF_SECONDS = 5.0 +MAX_BACKOFF_SECONDS = 300.0 +HEALTHY_RUN_SECONDS = 60.0 +HEARTBEAT_MAX_AGE_SECONDS = 300.0 +WATCHDOG_CHECK_SECONDS = 30.0 +DNS_MAX_ATTEMPTS = 60 +DNS_RETRY_SECONDS = 5.0 +CHILD_PID_FILE_NAME = "coding-agent-telegram.child.pid" +CHILD_COMMAND_MARKER = "coding_agent_telegram" +CHILD_PID_FILE_ENV = "CODING_AGENT_TELEGRAM_CHILD_PID_FILE" + + +def _log(message: str) -> None: + print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} SUPERVISOR: {message}", flush=True) + + +def _heartbeat_path() -> Path: + # cli.py obtains the same location from the loaded AppConfig. No dotenv + # parsing is needed before the child has validated its configuration. + return default_app_internal_root() / "polling.heartbeat" + + +def _child_pid_path() -> Path: + configured_path = os.getenv(CHILD_PID_FILE_ENV, "").strip() + if configured_path: + return Path(configured_path).expanduser() + return default_app_internal_root() / CHILD_PID_FILE_NAME + + +def _write_child_pid(pid_path: Path, pid: int) -> None: + pid_path.parent.mkdir(parents=True, exist_ok=True) + temporary_path = pid_path.with_suffix(pid_path.suffix + ".tmp") + temporary_path.write_text(f"{pid}\n", encoding="utf-8") + temporary_path.replace(pid_path) + + +def _remove_child_pid(pid_path: Path) -> None: + pid_path.unlink(missing_ok=True) + + +def _process_command(pid: int) -> str | None: + """Return a process command line when the platform exposes one.""" + try: + result = subprocess.run( + ["ps", "-p", str(pid), "-o", "command="], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + except OSError: + return None + return result.stdout.strip() if result.returncode == 0 else None + + +def _stop_orphaned_child(pid_path: Path) -> None: + """Stop a bot left behind by a force-killed supervisor. + + The command check prevents a stale, reused PID from terminating an + unrelated process. It also recognizes the previous startup.sh child + command, so migration does not create a temporary 409 polling conflict. + """ + try: + pid = int(pid_path.read_text(encoding="utf-8").strip()) + except (OSError, ValueError): + _remove_child_pid(pid_path) + return + + try: + os.kill(pid, 0) + except OSError: + _remove_child_pid(pid_path) + return + + command = _process_command(pid) + if command is None or CHILD_COMMAND_MARKER not in command: + _log(f"stale child pid file refers to pid {pid}, but it is not a bot process; leaving it alone.") + _remove_child_pid(pid_path) + return + + _log(f"found an orphaned bot process (pid {pid}) -- stopping it first.") + try: + os.kill(pid, signal.SIGTERM) + except OSError: + pass + for _ in range(20): + try: + os.kill(pid, 0) + except OSError: + break + time.sleep(1) + else: + _log(f"orphaned bot (pid {pid}) did not exit after 20s, sending SIGKILL") + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + _remove_child_pid(pid_path) + + +def _wait_for_dns() -> None: + for attempt in range(DNS_MAX_ATTEMPTS): + try: + socket.getaddrinfo("api.telegram.org", 443) + if attempt: + _log(f"DNS is back after {attempt * DNS_RETRY_SECONDS:.0f}s.") + return + except OSError: + if attempt == 0: + _log("waiting for DNS...") + time.sleep(DNS_RETRY_SECONDS) + _log(f"DNS still down after {DNS_MAX_ATTEMPTS * DNS_RETRY_SECONDS:.0f}s -- starting anyway.") + + +def _stop_child(child: subprocess.Popen[object]) -> None: + if child.poll() is not None: + return + child.terminate() + try: + child.wait(timeout=20) + except subprocess.TimeoutExpired: + _log(f"bot (pid {child.pid}) did not exit after 20s, sending SIGKILL") + child.kill() + child.wait() + + +def _run_supervisor(argv: Sequence[str]) -> int: + # The only public one-shot command must not start a long-lived supervisor. + if argv and argv[0] == "claude-auth": + from coding_agent_telegram.cli import main as cli_main + + original_argv = sys.argv + try: + sys.argv = [original_argv[0], *argv] + cli_main() + finally: + sys.argv = original_argv + return 0 + + heartbeat_file = _heartbeat_path() + child_pid_file = _child_pid_path() + stopping = False + child: subprocess.Popen[object] | None = None + + def stop_requested(_signum: int, _frame: object) -> None: + nonlocal stopping + stopping = True + if child is not None: + _log(f"stop requested, forwarding SIGTERM to bot (pid {child.pid})") + _stop_child(child) + + previous_term = signal.signal(signal.SIGTERM, stop_requested) + previous_int = signal.signal(signal.SIGINT, stop_requested) + try: + _stop_orphaned_child(child_pid_file) + backoff = MIN_BACKOFF_SECONDS + while not stopping: + _wait_for_dns() + if stopping: + break + + # A previous child's heartbeat is stale by definition. + heartbeat_file.unlink(missing_ok=True) + child = subprocess.Popen([sys.executable, "-m", "coding_agent_telegram.cli", *argv]) + _write_child_pid(child_pid_file, child.pid) + _log(f"bot started (pid {child.pid}).") + started_at = time.monotonic() + + while child.poll() is None and not stopping: + time.sleep(WATCHDOG_CHECK_SECONDS) + if heartbeat_file.exists(): + age = time.time() - heartbeat_file.stat().st_mtime + if age > HEARTBEAT_MAX_AGE_SECONDS: + _log(f"no heartbeat for {age:.0f}s -- bot (pid {child.pid}) looks wedged, restarting it.") + _stop_child(child) + break + + if stopping: + break + status = child.wait() + _remove_child_pid(child_pid_file) + ran_for = time.monotonic() - started_at + if ran_for >= HEALTHY_RUN_SECONDS: + backoff = MIN_BACKOFF_SECONDS + _log(f"bot exited (status {status}) after {ran_for:.0f}s -- restarting in {backoff:.0f}s.") + time.sleep(backoff) + backoff = min(backoff * 2, MAX_BACKOFF_SECONDS) + finally: + if child is not None: + _stop_child(child) + _remove_child_pid(child_pid_file) + signal.signal(signal.SIGTERM, previous_term) + signal.signal(signal.SIGINT, previous_int) + _log("supervisor exiting") + return 0 + + +def main() -> NoReturn: + raise SystemExit(_run_supervisor(sys.argv[1:])) + + +if __name__ == "__main__": + main() diff --git a/src/coding_agent_telegram/telegram_sender.py b/src/coding_agent_telegram/telegram_sender.py index 127835d..52c41e8 100644 --- a/src/coding_agent_telegram/telegram_sender.py +++ b/src/coding_agent_telegram/telegram_sender.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import Optional -from telegram import Update +from telegram import InlineKeyboardMarkup, Update from telegram.constants import ParseMode from telegram.error import BadRequest from telegram.ext import ContextTypes @@ -68,6 +68,14 @@ class AssistantSegment: language: Optional[str] = None +def affirmative_inline_button_kwargs() -> dict[str, dict[str, str]]: + return {"api_kwargs": {"style": "primary"}} + + +def negative_inline_button_kwargs() -> dict[str, dict[str, str]]: + return {"api_kwargs": {"style": "danger"}} + + def _max_telegram_message_length(context: ContextTypes.DEFAULT_TYPE) -> int: bot_data = getattr(context, "bot_data", None) if isinstance(bot_data, dict): @@ -87,6 +95,7 @@ async def send_text( text: str, *, reply_to_message_id: Optional[int] = None, + reply_markup: Optional[InlineKeyboardMarkup] = None, ) -> None: if update.effective_chat is None: return @@ -100,12 +109,14 @@ async def send_text( resolved_reply_to_message_id, text, ) + last_index = len(chunks) - 1 for index, chunk in enumerate(chunks): await context.bot.send_message( chat_id=update.effective_chat.id, text=html.escape(chunk), parse_mode=ParseMode.HTML, reply_to_message_id=resolved_reply_to_message_id if index == 0 else None, + reply_markup=reply_markup if index == last_index else None, ) @@ -138,6 +149,7 @@ async def send_html_text( text: str, *, reply_to_message_id: Optional[int] = None, + reply_markup: Optional[InlineKeyboardMarkup] = None, ) -> None: if update.effective_chat is None: return @@ -150,7 +162,13 @@ async def send_html_text( text, ) if len(text) > max_length: - await send_text(update, context, _strip_html_tags(text), reply_to_message_id=reply_to_message_id) + await send_text( + update, + context, + _strip_html_tags(text), + reply_to_message_id=reply_to_message_id, + reply_markup=reply_markup, + ) return try: await context.bot.send_message( @@ -158,11 +176,18 @@ async def send_html_text( text=text, parse_mode=ParseMode.HTML, reply_to_message_id=_default_reply_to_message_id(update, reply_to_message_id), + reply_markup=reply_markup, ) except BadRequest as exc: if "Can't parse entities" not in str(exc): raise - await send_text(update, context, _strip_html_tags(text), reply_to_message_id=reply_to_message_id) + await send_text( + update, + context, + _strip_html_tags(text), + reply_to_message_id=reply_to_message_id, + reply_markup=reply_markup, + ) def markdownish_to_html(text: str) -> str: diff --git a/src/coding_agent_telegram/usage_status.py b/src/coding_agent_telegram/usage_status.py new file mode 100644 index 0000000..92345b5 --- /dev/null +++ b/src/coding_agent_telegram/usage_status.py @@ -0,0 +1,458 @@ +from __future__ import annotations + +"""Best-effort quota lookups for each coding-agent provider. + +None of Claude Code, Codex, or Copilot expose 5-hour/weekly rate-limit +percentages through a documented non-interactive flag -- those numbers only +render inside each CLI's own interactive TUI (Claude's ``/usage``, Codex's +``/status``, Copilot's ``/usage``). + +- Claude: every real ``-p`` turn's ``stream-json`` output includes a + ``rate_limit_event`` with a ``unifiedWindows`` object carrying + ``five_hour``/``seven_day`` utilization -- present only for Pro/Max + subscribers authenticated via OAuth (API-key billing has no such windows). + Since it rides along on any Claude call the bot was already going to make, + ``observe_claude_rate_limit_event`` opportunistically caches it from every + such call (see ``agent_runner._run``), and ``get_claude_usage`` reads + *only* that cache -- there is no live fallback probe. A window that has + never been observed, or whose cached value has rolled past its own + ``resets_at``, reports N/A rather than paying for a dedicated API call just + to answer a status check; it starts reporting again the next time any real + Claude call happens to observe it. The two windows are tracked and expired + independently, since a bot idle for a few hours can easily have a stale + five-hour window sitting next to a still-fresh weekly one. +- Codex: its ``app-server`` JSON-RPC daemon exposes ``account/rateLimits/read``, + returning ``primary`` (5h) / ``secondary`` (weekly) ``usedPercent`` from a + pure local query -- no model call, no cost, so it's always fetched live. + +Copilot has no equivalent, and not just because the API is missing: since +GitHub retired premium requests for a monthly AI-credit balance (June 2026), +Copilot no longer has a 5-hour/weekly rolling window at all -- credits burn +against a monthly cycle, viewable only on GitHub's billing page, with no CLI +or API exposing an individual account's remaining balance. ``fetch_copilot_usage`` +always reports unavailable rather than inventing a window that doesn't exist. +""" + +import json +import logging +import subprocess +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +import portalocker + +logger = logging.getLogger(__name__) + +CODEX_APP_SERVER_TIMEOUT_SECONDS = 15.0 + +# Shown in place of a window's percentage when this cache has nothing usable +# for it. Two distinct reasons, so the message matches reality: a window we +# have simply never seen reads differently from one we saw and watched expire. +CLAUDE_WINDOW_NEVER_OBSERVED_NOTE = "no data yet -- will show after your next Claude turn" +CLAUDE_WINDOW_EXPIRED_NOTE = "past its reset time -- will update after your next Claude turn" + + +@dataclass(frozen=True) +class RateWindow: + used_percent: float + resets_at: Optional[int] = None # unix epoch seconds + + +@dataclass(frozen=True) +class ProviderUsage: + provider: str + available: bool + five_hour: Optional[RateWindow] = None + weekly: Optional[RateWindow] = None + plan: Optional[str] = None + error: Optional[str] = None + # Set only when a window came from the passive cache -- lets callers show + # "as of X ago". None (never cached) reads the same as a live value here. + observed_at: Optional[float] = None + # Populated per-window only for Claude, only when that window is None, + # explaining why (see CLAUDE_WINDOW_*_NOTE above) instead of a bare + # "unknown" -- Codex/Copilot don't need this, they're never partially + # available. + five_hour_note: Optional[str] = None + weekly_note: Optional[str] = None + + +@dataclass(frozen=True) +class _ClaudeRateLimitSnapshot: + five_hour: Optional[RateWindow] + weekly: Optional[RateWindow] + observed_at: float + + +# Process-wide: the underlying `claude` CLI's OAuth login is per-machine, not +# per Telegram bot/chat, so one cache shared across every bot instance on this +# host matches the actual scope of what it's caching. Optionally backed by its +# own small file on disk (see configure_persistence) so a bot restart doesn't +# throw away a window that's still live -- without that, /status would +# misreport "no data yet" for whatever's left of the window after every +# restart, not just report genuinely fresh state. +# +# Deliberately its own file rather than a key in the main session state.json: +# this snapshot refreshes on every real Claude turn (not just rare +# session-lifecycle events like /new or /switch), and it's disposable -- +# worst case a lost write just means one more "no data yet" until the next +# Claude turn observes it again. Writing it into state.json would mean +# re-copying the entire session/backup blob on every single turn just to +# protect a few bytes of best-effort telemetry, and would serialize these +# frequent writes against unrelated, rarer session-lifecycle writes sharing +# that file's lock. +_claude_rate_limit_cache: Optional[_ClaudeRateLimitSnapshot] = None +_claude_rate_limit_lock = threading.Lock() +_claude_rate_limit_path: Optional[Path] = None +_RATE_LIMIT_LOCK_TIMEOUT_SECONDS = 5 + + +def _rate_window_to_dict(window: Optional[RateWindow]) -> Optional[dict]: + if window is None: + return None + return {"used_percent": window.used_percent, "resets_at": window.resets_at} + + +def _rate_window_from_dict(data: object) -> Optional[RateWindow]: + if not isinstance(data, dict): + return None + used_percent = data.get("used_percent") + if not isinstance(used_percent, (int, float)): + return None + resets_at = data.get("resets_at") + return RateWindow( + used_percent=float(used_percent), + resets_at=resets_at if isinstance(resets_at, int) else None, + ) + + +def _snapshot_to_dict(snapshot: _ClaudeRateLimitSnapshot) -> dict: + return { + "five_hour": _rate_window_to_dict(snapshot.five_hour), + "weekly": _rate_window_to_dict(snapshot.weekly), + "observed_at": snapshot.observed_at, + } + + +def _snapshot_from_dict(data: dict) -> Optional[_ClaudeRateLimitSnapshot]: + observed_at = data.get("observed_at") + if not isinstance(observed_at, (int, float)): + return None + return _ClaudeRateLimitSnapshot( + five_hour=_rate_window_from_dict(data.get("five_hour")), + weekly=_rate_window_from_dict(data.get("weekly")), + observed_at=float(observed_at), + ) + + +def _read_locked_json_file(path: Path) -> Optional[dict]: + """Return the parsed JSON object at *path*, or None if it doesn't exist, + is empty, or isn't a JSON object. Locked against other processes writing + the same file (see the module-level comment on why this isn't state.json).""" + lock_file = path.with_suffix(path.suffix + ".lock") + path.parent.mkdir(parents=True, exist_ok=True) + with portalocker.Lock(str(lock_file), timeout=_RATE_LIMIT_LOCK_TIMEOUT_SECONDS): + if not path.exists(): + return None + raw = path.read_text(encoding="utf-8").strip() + if not raw: + return None + try: + data = json.loads(raw) + except json.JSONDecodeError: + return None + return data if isinstance(data, dict) else None + + +def _write_locked_json_file(path: Path, data: dict) -> None: + """Atomically (temp file + rename) and lock-safely overwrite *path* with + *data*. No backup copy -- unlike state.json, this file's contents are + disposable/self-healing, so there's nothing worth preserving a prior + version of.""" + lock_file = path.with_suffix(path.suffix + ".lock") + temp_file = path.with_suffix(path.suffix + ".tmp") + path.parent.mkdir(parents=True, exist_ok=True) + serialized = json.dumps(data, indent=2, ensure_ascii=False) + with portalocker.Lock(str(lock_file), timeout=_RATE_LIMIT_LOCK_TIMEOUT_SECONDS): + temp_file.write_text(serialized + "\n", encoding="utf-8") + temp_file.replace(path) + + +def configure_persistence(path: Path) -> None: + """Back the passive rate-limit cache with a dedicated JSON file at *path* + and seed the cache from it if present. + + Called once at startup. Loading here (rather than lazily on first read) + means the very first ``/status`` after a restart can already show the + last-observed window instead of "no data yet", as long as that window + hasn't rolled past its own reset time -- get_claude_usage's existing + expiry check handles that either way. + """ + global _claude_rate_limit_path, _claude_rate_limit_cache + _claude_rate_limit_path = path + try: + data = _read_locked_json_file(path) + except (OSError, portalocker.LockException): + logger.warning("Could not load persisted Claude rate-limit snapshot; starting empty.", exc_info=True) + return + if data is None: + return + snapshot = _snapshot_from_dict(data) + if snapshot is None: + return + with _claude_rate_limit_lock: + _claude_rate_limit_cache = snapshot + + +def _claude_rate_window(window: Optional[dict]) -> Optional[RateWindow]: + if not isinstance(window, dict): + return None + utilization = window.get("utilization") + if not isinstance(utilization, (int, float)): + return None + resets_at = window.get("resetsAt") + return RateWindow( + used_percent=round(float(utilization) * 100, 1), + resets_at=resets_at if isinstance(resets_at, int) else None, + ) + + +def parse_claude_rate_limit_event(raw_events: list) -> Optional[ProviderUsage]: + """Extract usage windows from a claude ``-p`` run's parsed jsonl events. + + Uses the *last* ``rate_limit_event``, not the first: a single ``-p`` + invocation can make more than one real API turn internally (e.g. a + tool-use loop), each capable of emitting its own event as utilization + climbs, and only the final one reflects the run's actual ending usage. + """ + usage: Optional[ProviderUsage] = None + for event in raw_events: + if not isinstance(event, dict) or event.get("type") != "rate_limit_event": + continue + windows = (event.get("rate_limit_info") or {}).get("unifiedWindows") or {} + usage = ProviderUsage( + provider="claude", + available=True, + five_hour=_claude_rate_window(windows.get("five_hour")), + weekly=_claude_rate_window(windows.get("seven_day")), + ) + return usage + + +def _store_claude_snapshot(usage: ProviderUsage) -> None: + global _claude_rate_limit_cache + snapshot = _ClaudeRateLimitSnapshot( + five_hour=usage.five_hour, + weekly=usage.weekly, + observed_at=time.time(), + ) + with _claude_rate_limit_lock: + _claude_rate_limit_cache = snapshot + path = _claude_rate_limit_path + if path is not None: + # Best-effort: a failed disk write must not lose the in-memory update + # above, which is what every real Claude call up to this point relied + # on already existing. + try: + _write_locked_json_file(path, _snapshot_to_dict(snapshot)) + except (OSError, portalocker.LockException): + logger.warning("Could not persist Claude rate-limit snapshot to disk.", exc_info=True) + + +def observe_claude_rate_limit_event(raw_events: list) -> None: + """Best-effort cache update from any real claude call's raw events. + + Called after every claude subprocess run (see ``agent_runner._run``) + regardless of which command triggered it, so ``get_claude_usage`` can + usually answer ``/status`` from a real, recent observation instead of + paying for a dedicated probe on every check. + """ + usage = parse_claude_rate_limit_event(raw_events) + if usage is not None: + _store_claude_snapshot(usage) + + +# Backstop for a window whose resets_at came back missing (a malformed or +# partial API response -- see _claude_rate_window/_rate_window_from_dict, +# both of which fall back to None rather than guessing) and would otherwise +# never expire on its own below. Set comfortably past the longest real window +# (7 days) so it never second-guesses a legitimately fresh weekly window that +# *does* have a resets_at -- this only kicks in when that field is absent. +# Matters more now than it would have before configure_persistence existed: +# a bad value used to be bounded by the process's own lifetime, and now +# persists across restarts until a fresh event happens to overwrite it. +_MAX_SNAPSHOT_AGE_SECONDS = 8 * 24 * 3600 + + +def _resolve_window( + window: Optional[RateWindow], now: float, observed_at: Optional[float] +) -> tuple[Optional[RateWindow], Optional[str]]: + """Return the window if it's still trustworthy, else ``(None, reason)``. + + A cached window is trustworthy only until its own reported reset time -- + past that point the real window has already rolled over to a fresh count + this cache never observed, so continuing to show the old percentage would + be actively wrong, not just stale. + """ + if window is None: + return None, CLAUDE_WINDOW_NEVER_OBSERVED_NOTE + if window.resets_at is not None: + if now >= window.resets_at: + return None, CLAUDE_WINDOW_EXPIRED_NOTE + elif observed_at is not None and now - observed_at >= _MAX_SNAPSHOT_AGE_SECONDS: + return None, CLAUDE_WINDOW_EXPIRED_NOTE + return window, None + + +def get_claude_usage() -> ProviderUsage: + """Read Claude's usage from the passive cache only -- no live fallback. + + The five-hour and weekly windows are resolved independently: an idle bot + can easily have one window sitting well past its reset while the other is + still fresh, and treating them as a pair would throw away the still-good + one just because its sibling expired. + """ + now = time.time() + with _claude_rate_limit_lock: + snapshot = _claude_rate_limit_cache + + observed_at = snapshot.observed_at if snapshot else None + five_hour, five_hour_note = _resolve_window(snapshot.five_hour if snapshot else None, now, observed_at) + weekly, weekly_note = _resolve_window(snapshot.weekly if snapshot else None, now, observed_at) + + return ProviderUsage( + provider="claude", + available=True, + five_hour=five_hour, + five_hour_note=five_hour_note, + weekly=weekly, + weekly_note=weekly_note, + observed_at=snapshot.observed_at if snapshot is not None else None, + ) + + +def _codex_percent_window(window: Optional[dict]) -> Optional[RateWindow]: + if not isinstance(window, dict): + return None + used_percent = window.get("usedPercent") + if not isinstance(used_percent, (int, float)): + return None + resets_at = window.get("resetsAt") + return RateWindow( + used_percent=round(float(used_percent), 1), + resets_at=resets_at if isinstance(resets_at, int) else None, + ) + + +def parse_codex_rate_limits_result(result: dict) -> ProviderUsage: + rate_limits = result.get("rateLimits") or {} + return ProviderUsage( + provider="codex", + available=True, + five_hour=_codex_percent_window(rate_limits.get("primary")), + weekly=_codex_percent_window(rate_limits.get("secondary")), + plan=rate_limits.get("planType") if isinstance(rate_limits.get("planType"), str) else None, + ) + + +def _send_json_rpc(proc: subprocess.Popen, message: dict) -> None: + proc.stdin.write(json.dumps(message) + "\n") + proc.stdin.flush() + + +def fetch_codex_usage(codex_bin: str) -> ProviderUsage: + """Query Codex's local app-server daemon over JSON-RPC for rate limits. + + Spawns a fresh, short-lived ``codex app-server`` process rather than + reusing a persistent daemon -- this is an on-demand status check, not + something worth keeping a background process alive for. + """ + try: + proc = subprocess.Popen( + [codex_bin, "app-server"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + except OSError as exc: + return ProviderUsage(provider="codex", available=False, error=str(exc)) + + outcome: dict = {} + done = threading.Event() + + def read_stdout() -> None: + try: + for line in proc.stdout: + stripped = line.strip() + if not stripped: + continue + try: + message = json.loads(stripped) + except json.JSONDecodeError: + continue + if message.get("id") != 2: + continue + if "result" in message: + outcome["result"] = message["result"] + elif "error" in message: + outcome["error"] = message["error"] + done.set() + return + except (OSError, ValueError): + done.set() + + reader = threading.Thread(target=read_stdout, daemon=True) + reader.start() + + try: + _send_json_rpc( + proc, + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"clientInfo": {"name": "coding-agent-telegram", "version": "1.0.0"}}, + }, + ) + _send_json_rpc(proc, {"jsonrpc": "2.0", "method": "initialized", "params": {}}) + _send_json_rpc(proc, {"jsonrpc": "2.0", "id": 2, "method": "account/rateLimits/read", "params": {}}) + done.wait(timeout=CODEX_APP_SERVER_TIMEOUT_SECONDS) + except (BrokenPipeError, OSError) as exc: + outcome.setdefault("error", str(exc)) + finally: + try: + proc.terminate() + proc.wait(timeout=3) + except Exception: + proc.kill() + + if "result" in outcome: + return parse_codex_rate_limits_result(outcome["result"]) + + error = outcome.get("error") + if isinstance(error, dict): + error_text = error.get("message") or str(error) + elif error: + error_text = str(error) + else: + try: + error_text = (proc.stderr.read() or "").strip() + except Exception: + error_text = "" + return ProviderUsage(provider="codex", available=False, error=error_text or "No response from codex app-server.") + + +def fetch_copilot_usage() -> ProviderUsage: + return ProviderUsage( + provider="copilot", + available=False, + error=( + "Copilot bills against a monthly AI-credit balance, not a 5-hour/weekly window, and there's " + "no CLI or API to read an individual account's remaining credits (only the GitHub billing page)." + ), + ) diff --git a/startup.sh b/startup.sh index 9775095..2aa9121 100755 --- a/startup.sh +++ b/startup.sh @@ -1,221 +1,28 @@ -#!/usr/bin/env bash - -set -euo pipefail +#!/bin/bash +# Repository entry point. It prepares the checkout once, then replaces this +# shell with the same Python supervisor used by the installed console command. +# That supervisor owns crash recovery, DNS backoff, heartbeat watchdogs, and +# cleanup of an orphaned bot left by a forced kill. +set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" -DEFAULT_ENV_FILE=".env_coding_agent_telegram" -PYTHON_BIN="${PYTHON_BIN:-python3}" -ENV_FILE="${ENV_FILE:-}" -ENV_TEMPLATE_FILE="${ENV_TEMPLATE_FILE:-src/coding_agent_telegram/resources/.env.example}" -VENV_DIR="${VENV_DIR:-.venv}" - -resolve_user_home() { - "$PYTHON_BIN" - <<'PY' -from pathlib import Path -import os -import pwd - -sudo_user = os.getenv("SUDO_USER", "").strip() -if sudo_user and sudo_user != "root": - try: - print(pwd.getpwnam(sudo_user).pw_dir) - except KeyError: - print(Path.home()) -else: - print(Path.home()) -PY -} - -if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then - echo "Error: $PYTHON_BIN was not found in PATH." >&2 - exit 1 -fi - -APP_HOME_DIR="$(resolve_user_home)/.coding-agent-telegram" -HOME_ENV_FILE="$APP_HOME_DIR/$DEFAULT_ENV_FILE" -STATE_FILE_DEFAULT="$APP_HOME_DIR/state.json" -STATE_BACKUP_FILE_DEFAULT="$APP_HOME_DIR/state.json.bak" -LOG_DIR_DEFAULT="$APP_HOME_DIR/logs" -LOCAL_PRETEND_VERSION="${SETUPTOOLS_SCM_PRETEND_VERSION_FOR_CODING_AGENT_TELEGRAM:-0.0.dev0}" -INSTALL_STATE_FILE_NAME=".coding-agent-telegram-install-state" -FORCE_REINSTALL="${FORCE_REINSTALL:-0}" - -compute_install_fingerprint() { - local files=() - local file - for file in pyproject.toml setup.py; do - if [[ -f "$file" ]]; then - files+=("$file") - fi - done - if [[ "${#files[@]}" -eq 0 ]]; then - printf 'no-packaging-files\n' - return - fi - shasum -a 256 "${files[@]}" | shasum -a 256 | awk '{print $1}' -} - -if [[ -z "$ENV_FILE" ]]; then - if [[ -f "$HOME_ENV_FILE" ]]; then - ENV_FILE="$HOME_ENV_FILE" - elif [[ -f "$DEFAULT_ENV_FILE" ]]; then - ENV_FILE="$DEFAULT_ENV_FILE" - else - ENV_FILE="$HOME_ENV_FILE" - fi -fi - -NEW_ENV_CREATED=0 -if [[ ! -f "$ENV_FILE" ]]; then - if [[ -f "$ENV_TEMPLATE_FILE" ]]; then - ENV_FILE_TARGET="$ENV_FILE" ENV_TEMPLATE_SOURCE="$ENV_TEMPLATE_FILE" PYTHONPATH="$SCRIPT_DIR/src${PYTHONPATH:+:$PYTHONPATH}" "$PYTHON_BIN" - <<'PY' -from pathlib import Path -import os -from coding_agent_telegram.config import create_initial_env_file -from coding_agent_telegram.i18n import translate - -env_path = Path(os.environ["ENV_FILE_TARGET"]).expanduser() -template_path = Path(os.environ["ENV_TEMPLATE_SOURCE"]).expanduser() -app_locale = create_initial_env_file(env_path, template_path) -print(translate(app_locale, "bootstrap.env_created_locale_line", env_path=env_path, app_locale=app_locale)) -print(translate(app_locale, "bootstrap.env_created_change_line", env_path=env_path)) -PY - NEW_ENV_CREATED=1 - else - echo "Error: $ENV_FILE is missing and $ENV_TEMPLATE_FILE was not found." >&2 - exit 1 - fi -fi - -STATE_FILE="$STATE_FILE_DEFAULT" -STATE_BACKUP_FILE="$STATE_BACKUP_FILE_DEFAULT" -if [[ -f "$APP_HOME_DIR/state.json" ]]; then - STATE_FILE="$APP_HOME_DIR/state.json" -elif [[ -f "./state.json" ]]; then - STATE_FILE="./state.json" -fi -if [[ -f "$APP_HOME_DIR/state.json.bak" ]]; then - STATE_BACKUP_FILE="$APP_HOME_DIR/state.json.bak" -elif [[ -f "./state.json.bak" ]]; then - STATE_BACKUP_FILE="./state.json.bak" -fi -LOG_DIR="$LOG_DIR_DEFAULT" - -mkdir -p "$(dirname "$STATE_FILE")" "$(dirname "$STATE_BACKUP_FILE")" "$LOG_DIR" -touch "$STATE_FILE" "$STATE_BACKUP_FILE" - -if [[ ! -d "$VENV_DIR" ]]; then - "$PYTHON_BIN" -m venv "$VENV_DIR" +echo "$(date '+%Y-%m-%d %H:%M:%S') SUPERVISOR: running bootstrap.sh..." +./bootstrap.sh +bootstrap_status=$? +if [ "$bootstrap_status" -ne 0 ]; then + echo "$(date '+%Y-%m-%d %H:%M:%S') SUPERVISOR: bootstrap.sh failed (status $bootstrap_status); not starting." >&2 + exit "$bootstrap_status" fi -source "$VENV_DIR/bin/activate" +PYTHON="$SCRIPT_DIR/.venv/bin/python3" -python -m pip install --upgrade pip >/dev/null -INSTALL_STATE_FILE="$VENV_DIR/$INSTALL_STATE_FILE_NAME" -CURRENT_INSTALL_FINGERPRINT="$(compute_install_fingerprint)" -STORED_INSTALL_FINGERPRINT="" -if [[ -f "$INSTALL_STATE_FILE" ]]; then - STORED_INSTALL_FINGERPRINT="$(<"$INSTALL_STATE_FILE")" -fi - -NEEDS_REINSTALL=0 -if [[ "$FORCE_REINSTALL" == "1" ]]; then - NEEDS_REINSTALL=1 -elif ! python -c "import coding_agent_telegram" >/dev/null 2>&1; then - NEEDS_REINSTALL=1 -elif [[ "$CURRENT_INSTALL_FINGERPRINT" != "$STORED_INSTALL_FINGERPRINT" ]]; then - NEEDS_REINSTALL=1 -fi - -if [[ "$NEEDS_REINSTALL" == "1" ]]; then - echo "Installing local package into $VENV_DIR." - SETUPTOOLS_SCM_PRETEND_VERSION_FOR_CODING_AGENT_TELEGRAM="$LOCAL_PRETEND_VERSION" \ - python -m pip install -e . - printf '%s\n' "$CURRENT_INSTALL_FINGERPRINT" > "$INSTALL_STATE_FILE" -else - echo "Existing editable install detected; skipping reinstall." -fi - -if [[ "$NEW_ENV_CREATED" == "1" ]]; then - python -m coding_agent_telegram.stt_setup offer \ - --env-file "$ENV_FILE" \ - --python-bin "$VENV_DIR/bin/python" \ - --installer-label "./install-stt.sh" -fi - -set -a -source "$ENV_FILE" -set +a - -required_vars=( - WORKSPACE_ROOT - TELEGRAM_BOT_TOKENS -) - -for var_name in "${required_vars[@]}"; do - if [[ -z "${!var_name:-}" ]]; then - echo "Error: $var_name must be set in $ENV_FILE." >&2 - echo "Post-installation checklist:" - echo "1. Edit $ENV_FILE" - echo "2. Set WORKSPACE_ROOT to the parent folder containing your projects" - echo "3. Set TELEGRAM_BOT_TOKENS to one or more bot tokens" - echo "4. Set ALLOWED_CHAT_IDS to your Telegram chat id(s)" - echo "5. Run: ./startup.sh" - exit 1 - fi -done - -if [[ -z "${ALLOWED_CHAT_IDS:-}" ]]; then - echo "Error: set ALLOWED_CHAT_IDS in $ENV_FILE." >&2 - echo "Run: ./startup.sh after updating $ENV_FILE." - exit 1 -fi - -DEFAULT_AGENT_PROVIDER="${DEFAULT_AGENT_PROVIDER:-codex}" -CODEX_BIN="${CODEX_BIN:-codex}" -COPILOT_BIN="${COPILOT_BIN:-copilot}" -CLAUDE_BIN="${CLAUDE_BIN:-claude}" - -case "$DEFAULT_AGENT_PROVIDER" in - codex) - if ! command -v "$CODEX_BIN" >/dev/null 2>&1; then - echo "Error: Codex CLI not found: $CODEX_BIN" >&2 - echo "Check DEFAULT_AGENT_PROVIDER and CODEX_BIN in $ENV_FILE." >&2 - echo "If this machine only has Copilot or Claude Code, set DEFAULT_AGENT_PROVIDER=copilot or claude." >&2 - exit 1 - fi - ;; - copilot) - if ! command -v "$COPILOT_BIN" >/dev/null 2>&1; then - echo "Error: Copilot CLI not found: $COPILOT_BIN" >&2 - echo "Check DEFAULT_AGENT_PROVIDER and COPILOT_BIN in $ENV_FILE." >&2 - echo "If this machine only has Codex or Claude Code, set DEFAULT_AGENT_PROVIDER=codex or claude." >&2 - exit 1 - fi - ;; - claude) - if ! command -v "$CLAUDE_BIN" >/dev/null 2>&1; then - echo "Error: Claude Code CLI not found: $CLAUDE_BIN" >&2 - echo "Check DEFAULT_AGENT_PROVIDER and CLAUDE_BIN in $ENV_FILE." >&2 - echo "If this machine only has Codex or Copilot, set DEFAULT_AGENT_PROVIDER=codex or copilot." >&2 - exit 1 - fi - ;; - *) - echo "Error: DEFAULT_AGENT_PROVIDER must be codex, copilot, or claude." >&2 - exit 1 - ;; -esac - -echo "Post-installation guide:" -echo "1. Confirm $ENV_FILE contains WORKSPACE_ROOT, TELEGRAM_BOT_TOKENS, and ALLOWED_CHAT_IDS." -echo "2. State files are ready at $STATE_FILE and $STATE_BACKUP_FILE." -echo "3. Application logs will be written under $LOG_DIR." -echo "4. Optional voice-to-text: run ./install-stt.sh if you want local Whisper support." -echo "5. Start the server with: ./startup.sh" -echo "6. In Telegram, start conversations." -echo "Starting coding-agent-telegram..." +# Preserve the checkout-specific STT setup hint. The installed command keeps +# its normal `coding-agent-telegram-stt-install` hint instead. export CODING_AGENT_TELEGRAM_STT_INSTALL_HINT="./install-stt.sh" -exec python -m coding_agent_telegram +# Keep the existing checkout-local location so an orphan from the former shell +# supervisor is recovered on the first run after upgrading. +export CODING_AGENT_TELEGRAM_CHILD_PID_FILE="$SCRIPT_DIR/coding-agent-telegram.child.pid" + +exec "$PYTHON" -m coding_agent_telegram.supervisor "$@" diff --git a/tests/conftest.py b/tests/conftest.py index 99f1af1..6822df4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,12 @@ import sys +import asyncio from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + + +def pytest_runtest_setup(item): + try: + asyncio.get_event_loop() + except RuntimeError: + asyncio.set_event_loop(asyncio.new_event_loop()) diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py index 7364d39..4baa57b 100644 --- a/tests/test_agent_runner.py +++ b/tests/test_agent_runner.py @@ -131,7 +131,9 @@ def test_copilot_runner_uses_prompt_mode_shape(monkeypatch): sandbox_mode="workspace-write", ) - result = runner.create_session("copilot", Path("/tmp/project"), "hello", skip_git_repo_check=False) + result = runner.create_session( + "copilot", Path("/tmp/project"), "hello", skip_git_repo_check=False, priming_only=True + ) assert calls[0][0] == [ "copilot", @@ -194,7 +196,9 @@ def test_codex_runner_attaches_images_for_create_and_resume(monkeypatch): assert str(image_path) in calls[1][0] -def test_copilot_runner_rejects_image_attachments(): +def test_copilot_runner_accepts_image_paths_in_prompt(monkeypatch): + calls: list = [] + monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls)) runner = MultiAgentRunner( codex_bin="codex", copilot_bin="copilot", @@ -203,10 +207,10 @@ def test_copilot_runner_rejects_image_attachments(): ) image_path = Path("/tmp/project/.coding-agent-telegram/telegram_attachments/img.jpg") - result = runner.create_session("copilot", Path("/tmp/project"), "hello", image_paths=(image_path,)) + result = runner.create_session("copilot", Path("/tmp/project"), f"Read {image_path}", image_paths=(image_path,)) - assert result.success is False - assert result.error_message == "Image attachments are not supported for Copilot sessions." + assert result.success is True + assert "Read /tmp/project/.coding-agent-telegram/telegram_attachments/img.jpg" in calls[0][0] def test_copilot_runner_uses_native_home_when_copilot_home_is_unset(monkeypatch): @@ -221,9 +225,12 @@ def test_copilot_runner_uses_native_home_when_copilot_home_is_unset(monkeypatch) sandbox_mode="workspace-write", ) - runner.create_session("copilot", Path("/tmp/project"), "hello", skip_git_repo_check=True) + runner.create_session( + "copilot", Path("/tmp/project"), "hello", skip_git_repo_check=True, priming_only=True + ) assert "COPILOT_HOME" not in calls[0][2] + # skip_git_repo_check must not smuggle a permission grant into a priming run. assert "--allow-all" not in calls[0][0] assert "--allow-all-tools" not in calls[0][0] @@ -466,6 +473,42 @@ def test_codex_runner_passes_model_when_configured(monkeypatch): assert calls[0][0][:4] == ["codex", "exec", "-m", "gpt-5-codex"] +def test_codex_runner_create_session_model_override_takes_precedence(monkeypatch): + calls = [] + monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls)) + + runner = MultiAgentRunner( + codex_bin="codex", + copilot_bin="copilot", + approval_policy="never", + sandbox_mode="workspace-write", + codex_model="gpt-5-codex", + ) + + runner.create_session("codex", Path("/tmp/project"), "hello", skip_git_repo_check=False, model="o4-mini") + + assert calls[0][0][:4] == ["codex", "exec", "-m", "o4-mini"] + + +def test_codex_runner_resume_session_model_override_takes_precedence(monkeypatch): + calls = [] + monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls)) + + runner = MultiAgentRunner( + codex_bin="codex", + copilot_bin="copilot", + approval_policy="never", + sandbox_mode="workspace-write", + codex_model="gpt-5-codex", + ) + + runner.resume_session( + "codex", "sess_1", Path("/tmp/project"), "hello again", skip_git_repo_check=False, model="o4-mini" + ) + + assert calls[0][0][:5] == ["codex", "exec", "resume", "-m", "o4-mini"] + + def test_copilot_runner_passes_model_when_configured(monkeypatch): calls = [] monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls)) @@ -478,7 +521,9 @@ def test_copilot_runner_passes_model_when_configured(monkeypatch): copilot_model="gpt-5", ) - runner.create_session("copilot", Path("/tmp/project"), "hello", skip_git_repo_check=False) + runner.create_session( + "copilot", Path("/tmp/project"), "hello", skip_git_repo_check=False, priming_only=True + ) assert calls[0][0][:5] == [ "copilot", @@ -489,11 +534,29 @@ def test_copilot_runner_passes_model_when_configured(monkeypatch): ] -def test_copilot_runner_passes_tool_permission_flags(monkeypatch): +def test_copilot_runner_resume_session_model_override_takes_precedence(monkeypatch): calls = [] monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls)) runner = MultiAgentRunner( + codex_bin="codex", + copilot_bin="copilot", + approval_policy="never", + sandbox_mode="workspace-write", + copilot_model="gpt-5", + ) + + runner.resume_session( + "copilot", "sess_1", Path("/tmp/project"), "hello again", skip_git_repo_check=False, model="claude-sonnet-4.6" + ) + + assert calls[0][0][:2] == ["copilot", "--resume=sess_1"] + assert "--model" in calls[0][0] + assert calls[0][0][calls[0][0].index("--model") + 1] == "claude-sonnet-4.6" + + +def _copilot_tool_permission_runner() -> MultiAgentRunner: + return MultiAgentRunner( codex_bin="codex", copilot_bin="copilot", approval_policy="never", @@ -507,7 +570,16 @@ def test_copilot_runner_passes_tool_permission_flags(monkeypatch): copilot_available_tools=("shell", "apply_patch"), ) - runner.create_session("copilot", Path("/tmp/project"), "hello", skip_git_repo_check=False) + +def test_copilot_priming_session_creation_withholds_tool_permission_flags(monkeypatch): + calls = [] + monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls)) + + runner = _copilot_tool_permission_runner() + + runner.create_session( + "copilot", Path("/tmp/project"), "prime me", skip_git_repo_check=False, priming_only=True + ) assert "--allow-all-tools" not in calls[0][0] assert "--allow-tool" not in calls[0][0] @@ -515,6 +587,24 @@ def test_copilot_runner_passes_tool_permission_flags(monkeypatch): assert "--available-tools" not in calls[0][0] +def test_copilot_session_creation_with_real_prompt_passes_tool_permission_flags(monkeypatch): + calls = [] + monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls)) + + runner = _copilot_tool_permission_runner() + + # The replacement-session path (a resume that failed) passes the real user request + # here, so it must run with the operator's configured permissions -- otherwise + # Copilot is left unable to act on a request Codex and Claude would have executed. + runner.create_session("copilot", Path("/tmp/project"), "fix the bug", skip_git_repo_check=False) + + args = calls[0][0] + assert "--allow-all-tools" in args + assert args[args.index("--allow-tool") + 1] == "shell(git)" + assert args[args.index("--deny-tool") + 1] == "shell(rm)" + assert args[args.index("--available-tools") + 1] == "shell,apply_patch" + + # --------------------------------------------------------------------------- # Claude provider # --------------------------------------------------------------------------- @@ -614,6 +704,40 @@ def test_claude_runner_passes_model_and_tool_flags_when_configured(monkeypatch): ] +def test_claude_runner_resume_session_model_override_takes_precedence(monkeypatch): + calls = [] + monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls)) + + runner = MultiAgentRunner( + codex_bin="codex", + copilot_bin="copilot", + approval_policy="never", + sandbox_mode="workspace-write", + claude_model="sonnet", + ) + + runner.resume_session("claude", "sess_1", Path("/tmp/project"), "hello again", model="opus") + + assert calls[0][0][:5] == ["claude", "--resume", "sess_1", "--model", "opus"] + + +def test_claude_runner_create_session_without_override_uses_configured_default(monkeypatch): + calls = [] + monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls)) + + runner = MultiAgentRunner( + codex_bin="codex", + copilot_bin="copilot", + approval_policy="never", + sandbox_mode="workspace-write", + claude_model="sonnet", + ) + + runner.create_session("claude", Path("/tmp/project"), "hello") + + assert calls[0][0][:3] == ["claude", "--model", "sonnet"] + + def test_claude_runner_reports_failure_from_result_event(monkeypatch): calls = [] monkeypatch.setattr( @@ -636,6 +760,69 @@ def test_claude_runner_reports_failure_from_result_event(monkeypatch): assert result.success is False assert result.error_message == "error_max_turns" assert result.session_id == "sess_claude" + assert result.error_code is None + + +def test_claude_runner_prefers_errors_array_over_generic_subtype(monkeypatch): + """A resume against a session ID Claude has no local transcript for fails with an + empty "result" and the generic subtype "error_during_execution" -- the actual reason + only shows up in the "errors" array. That's the message worth surfacing/matching + against for resume-failure recovery, not the opaque subtype.""" + calls = [] + monkeypatch.setattr( + "coding_agent_telegram.agent_runner.subprocess.Popen", + make_fake_popen( + calls, + process_stdout=( + '{"type":"result","subtype":"error_during_execution","is_error":true,"result":"",' + '"session_id":"sess_claude","errors":["No conversation found with session ID: sess_claude"]}\n' + ), + ), + ) + + runner = MultiAgentRunner( + codex_bin="codex", + copilot_bin="copilot", + approval_policy="never", + sandbox_mode="workspace-write", + ) + + result = runner.resume_session("claude", "sess_claude", Path("/tmp/project"), "hello") + + assert result.success is False + assert result.error_message == "No conversation found with session ID: sess_claude" + assert result.error_code == "session_not_found" + + +def test_claude_runner_does_not_set_session_not_found_code_for_other_errors(monkeypatch): + """error_code="session_not_found" is a precise signal, not a generic is_error flag -- + a failure for some other reason (even one that also lacks "result" text) must not be + mistaken for an unresumable session, or _replace_invalid_session_if_needed would + discard a perfectly resumable session over an unrelated failure.""" + calls = [] + monkeypatch.setattr( + "coding_agent_telegram.agent_runner.subprocess.Popen", + make_fake_popen( + calls, + process_stdout=( + '{"type":"result","subtype":"error_during_execution","is_error":true,"result":"",' + '"session_id":"sess_claude","errors":["Network error while contacting the API"]}\n' + ), + ), + ) + + runner = MultiAgentRunner( + codex_bin="codex", + copilot_bin="copilot", + approval_policy="never", + sandbox_mode="workspace-write", + ) + + result = runner.resume_session("claude", "sess_claude", Path("/tmp/project"), "hello") + + assert result.success is False + assert result.error_message == "Network error while contacting the API" + assert result.error_code is None def test_claude_runner_extracts_assistant_message_text_as_progress(monkeypatch): @@ -762,6 +949,102 @@ def test_claude_runner_ignores_image_paths_without_error(monkeypatch): assert result.success is True +def test_claude_priming_session_creation_runs_read_only(monkeypatch): + calls = [] + monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls)) + + runner = MultiAgentRunner( + codex_bin="codex", + copilot_bin="copilot", + approval_policy="never", + sandbox_mode="workspace-write", + claude_permission_mode="bypassPermissions", + ) + + runner.create_session("claude", Path("/tmp/project"), "prime me", priming_only=True) + + args = calls[0][0] + assert args[args.index("--permission-mode") + 1] == "plan" + assert "bypassPermissions" not in args + + +def test_codex_priming_session_creation_runs_read_only(monkeypatch): + calls = [] + monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls)) + + runner = MultiAgentRunner( + codex_bin="codex", + copilot_bin="copilot", + approval_policy="never", + sandbox_mode="workspace-write", + ) + + runner.create_session("codex", Path("/tmp/project"), "prime me", priming_only=True) + + args = calls[0][0] + assert "approval_policy=never" in args + assert "sandbox_mode=read-only" in args + assert "sandbox_mode=workspace-write" not in args + + +def test_codex_session_creation_with_real_prompt_keeps_configured_sandbox_mode(monkeypatch): + calls = [] + monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls)) + + runner = MultiAgentRunner( + codex_bin="codex", + copilot_bin="copilot", + approval_policy="on-failure", + sandbox_mode="workspace-write", + ) + + # The replacement-session path passes the real user request here, so it must not + # be downgraded to read-only. + runner.create_session("codex", Path("/tmp/project"), "fix the bug") + + args = calls[0][0] + assert "sandbox_mode=workspace-write" in args + assert "approval_policy=on-failure" in args + + +def test_claude_session_creation_with_real_prompt_keeps_configured_permission_mode(monkeypatch): + calls = [] + monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls)) + + runner = MultiAgentRunner( + codex_bin="codex", + copilot_bin="copilot", + approval_policy="never", + sandbox_mode="workspace-write", + claude_permission_mode="bypassPermissions", + ) + + # The replacement-session path passes the real user request here, so it must not + # be downgraded to read-only. + runner.create_session("claude", Path("/tmp/project"), "fix the bug") + + args = calls[0][0] + assert args[args.index("--permission-mode") + 1] == "bypassPermissions" + + +def test_claude_resume_keeps_configured_permission_mode(monkeypatch): + calls = [] + monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls)) + + runner = MultiAgentRunner( + codex_bin="codex", + copilot_bin="copilot", + approval_policy="never", + sandbox_mode="workspace-write", + claude_permission_mode="bypassPermissions", + ) + + runner.resume_session("claude", "sess_abc", Path("/tmp/project"), "keep working") + + args = calls[0][0] + assert args[args.index("--permission-mode") + 1] == "bypassPermissions" + + # --------------------------------------------------------------------------- # _validate_session_id # --------------------------------------------------------------------------- @@ -870,7 +1153,7 @@ def test_resume_session_returns_failure_for_unsupported_provider(monkeypatch): assert calls == [] -def test_copilot_resume_rejects_image_attachments(monkeypatch): +def test_copilot_resume_accepts_image_paths_in_prompt(monkeypatch): calls: list = [] monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls)) @@ -884,13 +1167,12 @@ def test_copilot_resume_rejects_image_attachments(monkeypatch): "copilot", "sess_1", Path("/tmp/project"), - "hello", + "Read /tmp/image.png", image_paths=[Path("/tmp/image.png")], ) - assert result.success is False - assert "not supported" in (result.error_message or "").lower() - assert calls == [] # no subprocess launched + assert result.success is True + assert "Read /tmp/image.png" in calls[0][0] def test_runner_uses_internal_code_for_generic_command_failure(monkeypatch): diff --git a/tests/test_bot.py b/tests/test_bot.py index 24e9439..cf1ad4b 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -5,15 +5,35 @@ def test_default_bot_commands_hide_commit_and_push_when_disabled(): commands = default_bot_commands(enable_commit_command=False) names = [command.command for command in commands] - assert names == ["provider", "project", "branch", "current", "new", "switch", "compact", "diff", "pull", "push", "abort"] + assert names == ["provider", "model", "project", "branch", "current", "status", "new", "switch", "compact", "diff", "pull", "push", "log", "reset", "abort"] assert "commit" not in names + descriptions = {command.command: command.description for command in commands} + assert descriptions["pull"] == "Git pull the current session branch" + assert all("Git" in descriptions[name] for name in ("pull", "push", "log", "reset")) def test_default_bot_commands_show_commit_and_push_when_enabled(): commands = default_bot_commands(enable_commit_command=True) names = [command.command for command in commands] - assert names == ["provider", "project", "branch", "current", "new", "switch", "compact", "diff", "commit", "pull", "push", "abort"] + assert names == [ + "provider", + "model", + "project", + "branch", + "current", + "status", + "new", + "switch", + "compact", + "diff", + "commit", + "pull", + "push", + "log", + "reset", + "abort", + ] # --------------------------------------------------------------------------- diff --git a/tests/test_claude_auth_subcommand.py b/tests/test_claude_auth_subcommand.py new file mode 100644 index 0000000..5e7c3d6 --- /dev/null +++ b/tests/test_claude_auth_subcommand.py @@ -0,0 +1,85 @@ +from types import SimpleNamespace + +import pytest + +import coding_agent_telegram.cli as cli +from coding_agent_telegram.claude_health import ClaudeHealthResult + + +def _patch_common(monkeypatch, tmp_path, *, existing_token=None): + env_path = tmp_path / ".env" + if existing_token is not None: + env_path.write_text(f"CLAUDE_CODE_OAUTH_TOKEN={existing_token}\n", encoding="utf-8") + monkeypatch.setattr(cli, "_ensure_env_file", lambda: (env_path, None)) + monkeypatch.setattr(cli, "load_config", lambda path: SimpleNamespace(app_internal_root=tmp_path)) + monkeypatch.setattr(cli, "_build_runner", lambda cfg, **kwargs: object()) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + return env_path + + +def test_declining_verification_just_saves_the_token(monkeypatch, tmp_path): + env_path = _patch_common(monkeypatch, tmp_path) + monkeypatch.setattr(cli, "_prompt_yes_no", lambda *a, **k: False) + checked = {"called": False} + monkeypatch.setattr(cli, "check_claude_auth", lambda *a, **k: checked.__setitem__("called", True)) + + cli._run_claude_auth_subcommand(["sk-ant-oat01-newtoken"]) + + assert checked["called"] is False + assert "CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-newtoken" in env_path.read_text(encoding="utf-8") + + +def test_accepting_verification_uses_the_cheap_model_and_keeps_token_on_success(monkeypatch, tmp_path): + env_path = _patch_common(monkeypatch, tmp_path) + monkeypatch.setattr(cli, "_prompt_yes_no", lambda *a, **k: True) + seen_kwargs = {} + + def fake_check(runner, scratch_dir): + return ClaudeHealthResult(healthy=True, is_auth_failure=False, detail="") + + build_calls = [] + monkeypatch.setattr( + cli, "_build_runner", lambda cfg, **kwargs: build_calls.append(kwargs) or object() + ) + monkeypatch.setattr(cli, "check_claude_auth", fake_check) + + cli._run_claude_auth_subcommand(["sk-ant-oat01-newtoken"]) + + assert build_calls == [{"claude_model": cli.CLAUDE_AUTH_VERIFY_MODEL}] + assert "CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-newtoken" in env_path.read_text(encoding="utf-8") + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + + +def test_failed_verification_rolls_back_to_previous_token(monkeypatch, tmp_path): + env_path = _patch_common(monkeypatch, tmp_path, existing_token="sk-ant-oat01-oldtoken") + monkeypatch.setattr(cli, "_prompt_yes_no", lambda *a, **k: True) + monkeypatch.setattr( + cli, + "check_claude_auth", + lambda *a, **k: ClaudeHealthResult(healthy=False, is_auth_failure=True, detail="401 Invalid bearer token"), + ) + + with pytest.raises(SystemExit): + cli._run_claude_auth_subcommand(["sk-ant-oat01-newtoken"]) + + text = env_path.read_text(encoding="utf-8") + assert "CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-oldtoken" in text + assert "newtoken" not in text + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + + +def test_failed_verification_removes_token_when_none_existed_before(monkeypatch, tmp_path): + env_path = _patch_common(monkeypatch, tmp_path) + monkeypatch.setattr(cli, "_prompt_yes_no", lambda *a, **k: True) + monkeypatch.setattr( + cli, + "check_claude_auth", + lambda *a, **k: ClaudeHealthResult(healthy=False, is_auth_failure=True, detail="401 Invalid bearer token"), + ) + + with pytest.raises(SystemExit): + cli._run_claude_auth_subcommand(["sk-ant-oat01-newtoken"]) + + text = env_path.read_text(encoding="utf-8") if env_path.exists() else "" + assert "CLAUDE_CODE_OAUTH_TOKEN" not in text + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) diff --git a/tests/test_claude_health.py b/tests/test_claude_health.py new file mode 100644 index 0000000..1c10a0b --- /dev/null +++ b/tests/test_claude_health.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import io +from pathlib import Path + +from coding_agent_telegram.agent_runner import MultiAgentRunner +from coding_agent_telegram.claude_health import check_claude_auth + + +class FakePopen: + def __init__(self, stdout: str = "", stderr: str = "", returncode: int = 0): + self.stdout = io.StringIO(stdout) + self.stderr = io.StringIO(stderr) + self.returncode = returncode + + def poll(self): + return self.returncode + + def kill(self): + self.returncode = -9 + + def terminate(self): + self.returncode = -15 + + +def _make_runner() -> MultiAgentRunner: + return MultiAgentRunner( + codex_bin="codex", + copilot_bin="copilot", + approval_policy="never", + sandbox_mode="read-only", + claude_bin="claude", + ) + + +def test_check_claude_auth_healthy(monkeypatch, tmp_path): + stdout = ( + '{"type":"system","subtype":"init","session_id":"abc-123"}\n' + '{"type":"result","is_error":false,"result":"ok"}\n' + ) + monkeypatch.setattr( + "coding_agent_telegram.agent_runner.subprocess.Popen", + lambda *a, **k: FakePopen(stdout=stdout, returncode=0), + ) + + result = check_claude_auth(_make_runner(), tmp_path / "scratch") + + assert result.healthy is True + assert result.is_auth_failure is False + assert result.detail == "" + + +def test_check_claude_auth_detects_auth_failure(monkeypatch, tmp_path): + # No "result" event at all -- the CLI can exit after an early auth failure + # without ever emitting one, so session_lifecycle_commands.py's own success + # check (`not result.success or not result.session_id`) is what actually + # catches this, not `parsed_success`. This mirrors that exact shape. + stdout = ( + '{"type":"system","subtype":"init"}\n' + '{"type":"result","is_error":true,' + '"result":"Failed to authenticate: OAuth session expired and could not be refreshed"}\n' + ) + monkeypatch.setattr( + "coding_agent_telegram.agent_runner.subprocess.Popen", + lambda *a, **k: FakePopen(stdout=stdout, returncode=0), + ) + + result = check_claude_auth(_make_runner(), tmp_path / "scratch") + + assert result.healthy is False + assert result.is_auth_failure is True + assert "authenticate" in result.detail.lower() + + +def test_check_claude_auth_missing_session_id_is_unhealthy(monkeypatch, tmp_path): + # success stays True by default when no "result" event appears at all, but + # there's still no session_id -- must not be reported healthy. + stdout = '{"type":"system","subtype":"init"}\n' + monkeypatch.setattr( + "coding_agent_telegram.agent_runner.subprocess.Popen", + lambda *a, **k: FakePopen(stdout=stdout, returncode=0), + ) + + result = check_claude_auth(_make_runner(), tmp_path / "scratch") + + assert result.healthy is False + assert result.is_auth_failure is False + + +def test_check_claude_auth_non_auth_failure(monkeypatch, tmp_path): + stdout = '{"type":"result","is_error":true,"result":"Rate limit exceeded."}\n' + monkeypatch.setattr( + "coding_agent_telegram.agent_runner.subprocess.Popen", + lambda *a, **k: FakePopen(stdout=stdout, returncode=0), + ) + + result = check_claude_auth(_make_runner(), tmp_path / "scratch") + + assert result.healthy is False + assert result.is_auth_failure is False + assert result.detail == "Rate limit exceeded." + + +def test_check_claude_auth_creates_scratch_dir(monkeypatch, tmp_path): + stdout = '{"type":"result","is_error":false,"result":"ok"}\n' + monkeypatch.setattr( + "coding_agent_telegram.agent_runner.subprocess.Popen", + lambda *a, **k: FakePopen(stdout=stdout, returncode=0), + ) + scratch_dir = tmp_path / "does" / "not" / "exist" / "yet" + + check_claude_auth(_make_runner(), scratch_dir) + + assert scratch_dir.is_dir() diff --git a/tests/test_command_router.py b/tests/test_command_router.py index e5576df..eb4657b 100644 --- a/tests/test_command_router.py +++ b/tests/test_command_router.py @@ -7,12 +7,15 @@ import shlex import sys import threading +import time from pathlib import Path from types import SimpleNamespace import pytest from coding_agent_telegram.agent_runner import AgentProgressInfo, AgentRunResult, AgentStallInfo from coding_agent_telegram.command_router import CommandRouter, RouterDeps +from coding_agent_telegram.router.queue_processing import QueuedQuestion +from coding_agent_telegram.router.session_lifecycle_commands import SESSION_PRIMING_PROMPT from coding_agent_telegram.config import AppConfig from coding_agent_telegram.session_store import SessionStore from coding_agent_telegram.speech_to_text import SpeechToTextError @@ -32,6 +35,8 @@ def create_session( *, skip_git_repo_check=False, image_paths=(), + priming_only=False, + model=None, on_stall=None, on_progress=None, ): @@ -42,6 +47,8 @@ def create_session( "user_message": user_message, "skip_git_repo_check": skip_git_repo_check, "image_paths": image_paths, + "priming_only": priming_only, + "model": model, "on_stall": on_stall, "on_progress": on_progress, } @@ -63,6 +70,7 @@ def resume_session( *, skip_git_repo_check=False, image_paths=(), + model=None, on_stall=None, on_progress=None, ): @@ -74,6 +82,7 @@ def resume_session( "user_message": user_message, "skip_git_repo_check": skip_git_repo_check, "image_paths": image_paths, + "model": model, "on_stall": on_stall, "on_progress": on_progress, } @@ -97,6 +106,7 @@ def resume_session( *, skip_git_repo_check=False, image_paths=(), + model=None, on_stall=None, on_progress=None, ): @@ -108,6 +118,7 @@ def resume_session( "user_message": user_message, "skip_git_repo_check": skip_git_repo_check, "image_paths": image_paths, + "model": model, "on_stall": on_stall, "on_progress": on_progress, } @@ -128,6 +139,8 @@ def create_session( *, skip_git_repo_check=False, image_paths=(), + priming_only=False, + model=None, on_stall=None, on_progress=None, ): @@ -138,6 +151,8 @@ def create_session( "user_message": user_message, "skip_git_repo_check": skip_git_repo_check, "image_paths": image_paths, + "priming_only": priming_only, + "model": model, "on_stall": on_stall, "on_progress": on_progress, } @@ -161,6 +176,7 @@ def resume_session( *, skip_git_repo_check=False, image_paths=(), + model=None, on_stall=None, on_progress=None, ): @@ -172,6 +188,7 @@ def resume_session( "user_message": user_message, "skip_git_repo_check": skip_git_repo_check, "image_paths": image_paths, + "model": model, "on_stall": on_stall, } ) @@ -184,6 +201,38 @@ def resume_session( ) +class ReplyOptionsRunner(DummyRunner): + def resume_session( + self, + provider, + session_id, + project_path, + user_message, + *, + skip_git_repo_check=False, + image_paths=(), + model=None, + on_stall=None, + on_progress=None, + ): + self.resume_calls.append({"provider": provider, "user_message": user_message}) + if len(self.resume_calls) == 1: + text = ( + "I found two ways to fix this. Which approach would you like me to take?\n" + "1. Patch the validator directly\n" + "2. Rewrite the parser" + ) + else: + text = "Done." + return AgentRunResult( + session_id=session_id, + success=True, + assistant_text=text, + error_message=None, + raw_events=[], + ) + + class CommandBlockRunner(DummyRunner): def resume_session( self, @@ -194,6 +243,7 @@ def resume_session( *, skip_git_repo_check=False, image_paths=(), + model=None, on_stall=None, on_progress=None, ): @@ -205,6 +255,7 @@ def resume_session( "user_message": user_message, "skip_git_repo_check": skip_git_repo_check, "image_paths": image_paths, + "model": model, "on_stall": on_stall, } ) @@ -227,6 +278,7 @@ def resume_session( *, skip_git_repo_check=False, image_paths=(), + model=None, on_stall=None, on_progress=None, ): @@ -238,6 +290,7 @@ def resume_session( "user_message": user_message, "skip_git_repo_check": skip_git_repo_check, "image_paths": image_paths, + "model": model, "on_stall": on_stall, } ) @@ -260,6 +313,7 @@ def resume_session( *, skip_git_repo_check=False, image_paths=(), + model=None, on_stall=None, on_progress=None, ): @@ -271,6 +325,7 @@ def resume_session( "user_message": user_message, "skip_git_repo_check": skip_git_repo_check, "image_paths": image_paths, + "model": model, "on_stall": on_stall, } ) @@ -293,6 +348,7 @@ def resume_session( *, skip_git_repo_check=False, image_paths=(), + model=None, on_stall=None, on_progress=None, ): @@ -304,6 +360,7 @@ def resume_session( "user_message": user_message, "skip_git_repo_check": skip_git_repo_check, "image_paths": image_paths, + "model": model, "on_stall": on_stall, } ) @@ -522,6 +579,9 @@ def make_config(tmp_path: Path, *, locale: str = "en") -> AppConfig: codex_model="", copilot_model="", claude_model="", + codex_model_choices=("gpt-5.4",), + copilot_model_choices=("gpt-5.4", "claude-sonnet-4.6"), + claude_model_choices=("sonnet", "opus", "haiku"), copilot_autopilot=True, copilot_no_ask_user=True, copilot_allow_all=True, @@ -546,6 +606,10 @@ def make_config(tmp_path: Path, *, locale: str = "en") -> AppConfig: default_agent_provider="codex", agent_hard_timeout_seconds=0, app_internal_root=tmp_path / ".coding-agent-telegram", + long_gap_warning_enabled=False, + claude_long_gap_seconds=3600, + codex_long_gap_seconds=600, + copilot_long_gap_seconds=600, locale=locale, ) @@ -559,6 +623,7 @@ def seed_codex_native_session( branch: str, created_at: int, updated_at: int, + tokens_used: int = 0, ) -> None: codex_dir = home / ".codex" codex_dir.mkdir(parents=True, exist_ok=True) @@ -575,16 +640,17 @@ def seed_codex_native_session( git_branch TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, + tokens_used INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0 ) """ ) conn.execute( """ - INSERT INTO threads (id, cwd, title, first_user_message, git_branch, created_at, updated_at, archived) - VALUES (?, ?, ?, ?, ?, ?, ?, 0) + INSERT INTO threads (id, cwd, title, first_user_message, git_branch, created_at, updated_at, tokens_used, archived) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0) """, - (session_id, str(cwd), title, title, branch, created_at, updated_at), + (session_id, str(cwd), title, title, branch, created_at, updated_at, tokens_used), ) conn.commit() finally: @@ -1156,6 +1222,8 @@ def create_session( *, skip_git_repo_check=False, image_paths=(), + priming_only=False, + model=None, on_stall=None, on_progress=None, ): @@ -1188,6 +1256,7 @@ def resume_session( *, skip_git_repo_check=False, image_paths=(), + model=None, on_stall=None, on_progress=None, ): @@ -1223,6 +1292,7 @@ def resume_session( *, skip_git_repo_check=False, image_paths=(), + model=None, on_stall=None, on_progress=None, ): @@ -1254,6 +1324,7 @@ def resume_session( *, skip_git_repo_check=False, image_paths=(), + model=None, on_stall=None, on_progress=None, ): @@ -1312,6 +1383,7 @@ def resume_session( *, skip_git_repo_check=False, image_paths=(), + model=None, on_stall=None, on_progress=None, ): @@ -1323,6 +1395,7 @@ def resume_session( "user_message": user_message, "skip_git_repo_check": skip_git_repo_check, "image_paths": image_paths, + "model": model, "on_stall": on_stall, "on_progress": on_progress, } @@ -1364,6 +1437,7 @@ def resume_session( *, skip_git_repo_check=False, image_paths=(), + model=None, on_stall=None, on_progress=None, ): @@ -1375,6 +1449,7 @@ def resume_session( "user_message": user_message, "skip_git_repo_check": skip_git_repo_check, "image_paths": image_paths, + "model": model, "on_stall": on_stall, "on_progress": on_progress, } @@ -1534,7 +1609,8 @@ def test_new_without_name_uses_new_session_as_default_name(tmp_path: Path): state = store.get_chat_state("bot-a", 123) assert state["sessions"]["sess_abc123"]["name"] == "sess_abc123" assert "Session created successfully: sess_abc123" in bot.messages[-1][1] - assert runner.create_calls[-1]["user_message"] == "Create session: new session" + assert runner.create_calls[-1]["user_message"] == SESSION_PRIMING_PROMPT + assert runner.create_calls[-1]["priming_only"] is True def test_new_without_name_ignores_existing_new_session_labels(tmp_path: Path): @@ -1579,7 +1655,8 @@ def test_plain_text_create_session_new_session_uses_unnamed_flow(tmp_path: Path) state = store.get_chat_state("bot-a", 123) assert state["sessions"]["sess_abc123"]["name"] == "sess_abc123" - assert runner.create_calls[-1]["user_message"] == "Create session: new session" + assert runner.create_calls[-1]["user_message"] == SESSION_PRIMING_PROMPT + assert runner.create_calls[-1]["priming_only"] is True def test_plain_text_create_session_with_name_matches_new_command(tmp_path: Path): @@ -1601,7 +1678,8 @@ def test_plain_text_create_session_with_name_matches_new_command(tmp_path: Path) state = store.get_chat_state("bot-a", 123) assert state["sessions"]["sess_abc123"]["name"] == "release prep" - assert runner.create_calls[-1]["user_message"] == "Create session: release prep" + assert runner.create_calls[-1]["user_message"] == SESSION_PRIMING_PROMPT + assert runner.create_calls[-1]["priming_only"] is True def test_provider_command_sends_inline_buttons(tmp_path: Path): @@ -1623,7 +1701,8 @@ def test_provider_command_sends_inline_buttons(tmp_path: Path): assert "Current provider: copilot" in message[1] keyboard = message[3] assert keyboard is not None - buttons = keyboard.inline_keyboard[0] + buttons = [button for row in keyboard.inline_keyboard for button in row] + assert [len(row) for row in keyboard.inline_keyboard] == [1, 1, 1] assert buttons[0].callback_data == "provider:set:codex" assert buttons[1].callback_data == "provider:set:copilot" assert buttons[2].callback_data == "provider:set:claude" @@ -1777,6 +1856,321 @@ async def fake_edit(text): assert state["sessions"][state["active_session_id"]]["provider"] == "copilot" +# --------------------------------------------------------------------------- +# /model +# --------------------------------------------------------------------------- + + +def test_model_command_reports_no_active_session(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = make_update(text="/model") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_model(update, context)) + + assert "No active session" in bot.messages[-1][1] + + +def test_model_command_sends_inline_buttons_for_active_session_provider(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = make_update(text="/model") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_model(update, context)) + + assert len(bot.messages) == 1 + message = bot.messages[0] + keyboard = message[3] + assert keyboard is not None + # One button per row: the default option plus one per configured Claude model. + assert [len(row) for row in keyboard.inline_keyboard] == [1, 1, 1, 1] + callback_data = [button.callback_data for row in keyboard.inline_keyboard for button in row] + assert callback_data == ["model:default", "model:set:0", "model:set:1", "model:set:2"] + + +def test_model_command_prompt_flags_a_custom_model_as_not_in_the_list(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude", model="claude-opus-5-preview") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = make_update(text="/model") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_model(update, context)) + + assert "custom, not in the list below" in bot.messages[-1][1] + assert "claude-opus-5-preview" in bot.messages[-1][1] + + +def test_model_callback_sets_session_model_override(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + answers = [] + edited = [] + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=SimpleNamespace( + data="model:set:1", # index 1 -> "opus" in ("sonnet", "opus", "haiku") + answer=None, + edit_message_text=None, + ), + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + async def fake_answer(): + answers.append("answered") + + async def fake_edit(text): + edited.append(text) + + update.callback_query.answer = fake_answer + update.callback_query.edit_message_text = fake_edit + + asyncio.run(router.handle_model_callback(update, context)) + + assert answers == ["answered"] + assert edited == ["Model set to: opus"] + assert store.list_sessions("bot-a", 123)["sess_1"]["model"] == "opus" + + +def test_model_callback_reports_stale_selection_for_out_of_range_index(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + edited = [] + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=SimpleNamespace( + data="model:set:99", + answer=None, + edit_message_text=None, + ), + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + async def fake_answer(): + return None + + async def fake_edit(text): + edited.append(text) + + update.callback_query.answer = fake_answer + update.callback_query.edit_message_text = fake_edit + + asyncio.run(router.handle_model_callback(update, context)) + + assert edited == ["⚠️ This button is no longer valid (the model list or provider may have changed). Run /model again."] + assert store.list_sessions("bot-a", 123)["sess_1"]["model"] == "" + + +def test_model_callback_default_option_clears_override(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude", model="opus") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + edited = [] + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=SimpleNamespace( + data="model:default", + answer=None, + edit_message_text=None, + ), + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + async def fake_answer(): + return None + + async def fake_edit(text): + edited.append(text) + + update.callback_query.answer = fake_answer + update.callback_query.edit_message_text = fake_edit + + asyncio.run(router.handle_model_callback(update, context)) + + assert edited == ["Model set to: CLI default"] + assert store.list_sessions("bot-a", 123)["sess_1"]["model"] == "" + + +def test_new_session_resets_model_to_default_even_after_override(tmp_path: Path): + """A model override applies to the active session's resume calls, but an explicit + /new session must always start on the provider's configured default model.""" + project = tmp_path / "backend" + project.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + store.set_current_provider("bot-a", 123, "claude") + store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude", model="opus") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = make_update(text="/new") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_new(update, context)) + + assert runner.create_calls[-1]["model"] is None + state = store.get_chat_state("bot-a", 123) + new_session = state["sessions"][state["active_session_id"]] + assert new_session["model"] == "" + + +def test_active_session_resume_uses_stored_model_override(tmp_path: Path): + project = tmp_path / "backend" + project.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude", model="opus") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = make_update(text="do the thing") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_message(update, context)) + + assert runner.resume_calls[-1]["model"] == "opus" + + +class RejectingModelRunner(DummyRunner): + def create_session( + self, + provider, + project_path, + user_message, + *, + skip_git_repo_check=False, + image_paths=(), + priming_only=False, + model=None, + on_stall=None, + on_progress=None, + ): + self.create_calls.append({"provider": provider, "model": model}) + return AgentRunResult( + session_id=None, + success=False, + assistant_text="", + error_message="Error: unknown model 'not-a-real-model'", + raw_events=[], + ) + + +def test_model_command_with_too_many_args_shows_usage(tmp_path: Path): + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = make_update(text="/model foo bar") + bot = FakeBot() + context = SimpleNamespace(args=["foo", "bar"], bot=bot) + + asyncio.run(router.handle_model(update, context)) + + assert "Usage: /model" in bot.messages[-1][1] + assert runner.create_calls == [] + + +def test_model_command_with_curated_model_id_skips_cli_probe(tmp_path: Path): + """Typing a model id that's already on the curated list should save immediately + without spending a CLI round trip to re-validate something already known-good.""" + project = tmp_path / "backend" + project.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = make_update(text="/model opus") + bot = FakeBot() + context = SimpleNamespace(args=["opus"], bot=bot) + + asyncio.run(router.handle_model(update, context)) + + assert runner.create_calls == [] + assert "Model set to: opus" in bot.messages[-1][1] + assert store.list_sessions("bot-a", 123)["sess_1"]["model"] == "opus" + + +def test_model_command_with_valid_custom_model_id_probes_then_saves(tmp_path: Path): + project = tmp_path / "backend" + project.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = make_update(text="/model claude-opus-5-preview") + bot = FakeBot() + context = SimpleNamespace(args=["claude-opus-5-preview"], bot=bot) + + asyncio.run(router.handle_model(update, context)) + + assert runner.create_calls[-1]["model"] == "claude-opus-5-preview" + assert runner.create_calls[-1]["priming_only"] is True + assert "Model set to: claude-opus-5-preview" in bot.messages[-1][1] + assert store.list_sessions("bot-a", 123)["sess_1"]["model"] == "claude-opus-5-preview" + + +def test_model_command_with_invalid_custom_model_id_is_not_saved(tmp_path: Path): + project = tmp_path / "backend" + project.mkdir() + runner = RejectingModelRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = make_update(text="/model not-a-real-model") + bot = FakeBot() + context = SimpleNamespace(args=["not-a-real-model"], bot=bot) + + asyncio.run(router.handle_model(update, context)) + + assert "not-a-real-model" in bot.messages[-1][1] + assert "unknown model" in bot.messages[-1][1] + # The rejected model must not be persisted -- the session keeps its prior (empty) model. + assert store.list_sessions("bot-a", 123)["sess_1"]["model"] == "" + + def test_text_message_is_queued_while_new_session_prerequisites_are_pending(tmp_path: Path): backend = tmp_path / "backend" backend.mkdir() @@ -2112,7 +2506,10 @@ def test_switch_lists_mixed_bot_and_native_project_sessions_with_legend(tmp_path assert "initialized: Native codex review" in message -def test_switch_lists_only_current_provider_native_sessions(tmp_path: Path, monkeypatch): +def test_switch_listing_shows_last_active_and_tokens_for_native_session(tmp_path: Path, monkeypatch): + """/switch should surface each session's real native activity (session_gap.py), + not just the bot's own state.json bookkeeping -- that's the only way to see how + stale/expensive-to-resume a session actually is before picking one.""" home = tmp_path / "home" monkeypatch.setenv("HOME", str(home)) backend = tmp_path / "backend" @@ -2121,7 +2518,7 @@ def test_switch_lists_only_current_provider_native_sessions(tmp_path: Path, monk cfg = make_config(tmp_path) store = SessionStore(cfg.state_file, cfg.state_backup_file) store.set_current_project_folder("bot-a", 123, "backend") - store.set_current_provider("bot-a", 123, "copilot") + store.set_current_provider("bot-a", 123, "codex") seed_codex_native_session( home, session_id="sess_native_codex", @@ -2129,16 +2526,76 @@ def test_switch_lists_only_current_provider_native_sessions(tmp_path: Path, monk title="Native codex review", branch="enhancement", created_at=1_700_000_000, - updated_at=1_700_000_010, - ) - seed_copilot_native_session( - home, - session_id="sess_native_copilot", - branch="enhancement", - created_at="2026-03-27T01:00:00Z", - updated_at="2026-03-27T02:00:00Z", - summary="Native copilot review", - cwd=backend, + updated_at=int(time.time()) - 3600, + tokens_used=12_345, + ) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = make_update(text="/switch") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_switch(update, context)) + + message = bot.messages[-1][1] + assert "Last active:" in message and "ago" in message + assert "~12.3k tokens used" in message + + +def test_switch_listing_omits_activity_line_when_no_native_data_exists(tmp_path: Path, monkeypatch): + """A bot-managed session with no matching native transcript/db row (e.g. one seeded + straight into state.json, or one whose transcript already got cleaned up) has no + real activity to report -- the line should be omitted rather than showing a bogus + zero/unknown value.""" + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_no_native_data", "orphan-session", "backend", "claude") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = make_update(text="/switch") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_switch(update, context)) + + message = bot.messages[-1][1] + assert "orphan-session" in message + assert "Last active:" not in message + assert "tokens used" not in message + + +def test_switch_lists_only_current_provider_native_sessions(tmp_path: Path, monkeypatch): + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.set_current_project_folder("bot-a", 123, "backend") + store.set_current_provider("bot-a", 123, "copilot") + seed_codex_native_session( + home, + session_id="sess_native_codex", + cwd=backend, + title="Native codex review", + branch="enhancement", + created_at=1_700_000_000, + updated_at=1_700_000_010, + ) + seed_copilot_native_session( + home, + session_id="sess_native_copilot", + branch="enhancement", + created_at="2026-03-27T01:00:00Z", + updated_at="2026-03-27T02:00:00Z", + summary="Native copilot review", + cwd=backend, ) router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) @@ -2309,6 +2766,63 @@ def test_current_reports_active_session_details(tmp_path: Path): assert "Branch: feature-1" in message +def test_current_shows_last_active_and_tokens_for_native_session(tmp_path: Path, monkeypatch): + """/current should surface the session's real native activity (session_gap.py), the + same signal /switch shows, so a chat doesn't need to run /switch just to see how + stale/expensive-to-resume the active session actually is.""" + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_native_codex", "session-a", "backend", "codex", branch_name="feature-1") + seed_codex_native_session( + home, + session_id="sess_native_codex", + cwd=backend, + title="session-a", + branch="feature-1", + created_at=1_700_000_000, + updated_at=int(time.time()) - 3600, + tokens_used=12_345, + ) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_current(update, context)) + + message = bot.messages[-1][1] + assert "Current session: session-a" in message + assert "Last active:" in message and "ago" in message + assert "~12.3k tokens used" in message + + +def test_current_omits_activity_line_when_no_native_data_exists(tmp_path: Path, monkeypatch): + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_no_native_data", "orphan-session", "backend", "claude") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + update = make_update() + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_current(update, context)) + + message = bot.messages[-1][1] + assert "Current session: orphan-session" in message + assert "Last active:" not in message + assert "tokens used" not in message + + def test_switch_does_not_checkout_branch_immediately(tmp_path: Path): backend = tmp_path / "backend" backend.mkdir() @@ -2392,6 +2906,7 @@ def test_photo_message_is_saved_and_forwarded_to_codex(tmp_path: Path): image_paths = runner.resume_calls[-1]["image_paths"] assert len(image_paths) == 1 assert image_paths[0].is_file() + assert len(image_paths[0].stem) == 8 assert "/.coding-agent-telegram/telegram_attachments/backend/" in image_paths[0].as_posix() assert runner.resume_calls[-1]["user_message"].startswith("An image is attached at ../.coding-agent-telegram/telegram_attachments/backend/") assert "Open and inspect that image before answering." in runner.resume_calls[-1]["user_message"] @@ -2424,7 +2939,7 @@ def test_photo_message_is_saved_and_forwarded_to_claude(tmp_path: Path): assert image_paths[0].is_file() -def test_photo_message_rejected_for_copilot_session(tmp_path: Path): +def test_photo_message_is_saved_and_forwarded_to_copilot(tmp_path: Path): backend = tmp_path / "backend" backend.mkdir() runner = DummyRunner() @@ -2443,8 +2958,71 @@ def test_photo_message_rejected_for_copilot_session(tmp_path: Path): asyncio.run(router.handle_photo(update, context)) + assert len(runner.resume_calls) == 1 + assert len(runner.resume_calls[-1]["image_paths"]) == 1 + assert "Open and inspect that image before answering." in runner.resume_calls[-1]["user_message"] + + +def test_photo_album_is_forwarded_as_one_request(tmp_path: Path, monkeypatch): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_photo", "photo-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + monkeypatch.setattr("coding_agent_telegram.router.message_commands.PHOTO_ALBUM_DEBOUNCE_SECONDS", 0.01) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + async def send_album(): + for message_id, content in ((102, b"second"), (101, b"first")): + photo = FakePhotoSize(FakeTelegramFile(content, f"photos/{message_id}.png")) + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + message=SimpleNamespace( + text=None, photo=[photo], caption="compare these", message_id=message_id, media_group_id="album-1" + ), + ) + await router.handle_photo(update, context) + await asyncio.sleep(0.03) + + asyncio.run(send_album()) + + assert len(runner.resume_calls) == 1 + call = runner.resume_calls[-1] + assert len(call["image_paths"]) == 2 + assert "Images are attached at:" in call["user_message"] + assert "Open and inspect every image before answering." in call["user_message"] + + +def test_photo_album_over_limit_is_rejected_before_running_agent(tmp_path: Path, monkeypatch): + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_photo", "photo-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + monkeypatch.setattr("coding_agent_telegram.router.message_commands.PHOTO_ALBUM_DEBOUNCE_SECONDS", 0.01) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + async def send_too_many(): + for message_id in range(1, 7): + photo = FakePhotoSize(FakeTelegramFile(b"image", f"photos/{message_id}.png")) + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + message=SimpleNamespace(text=None, photo=[photo], caption=None, message_id=message_id, media_group_id="album-2"), + ) + await router.handle_photo(update, context) + await asyncio.sleep(0.03) + + asyncio.run(send_too_many()) + assert runner.resume_calls == [] - assert "Photo attachments are currently supported only for Codex and Claude sessions." in bot.messages[-1][1] + assert bot.messages[-1][1] == "Too many photos. A single album can contain at most 5 images." def test_voice_message_sends_transcript_preview_before_running_agent(tmp_path: Path): @@ -2742,6 +3320,47 @@ async def exercise(): asyncio.run(exercise()) +def test_text_after_photo_album_is_queued_behind_the_album(tmp_path: Path, monkeypatch): + backend = tmp_path / "backend" + backend.mkdir() + runner = BlockingRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_photo", "photo-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + monkeypatch.setattr("coding_agent_telegram.router.message_commands.PHOTO_ALBUM_DEBOUNCE_SECONDS", 0.01) + + async def exercise(): + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + photo = FakePhotoSize(FakeTelegramFile(b"fake-image-bytes", "photos/pic.png")) + photo_update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + message=SimpleNamespace( + text=None, photo=[photo], caption="inspect this", message_id=101, media_group_id="album-before-text" + ), + ) + + await router.handle_photo(photo_update, context) + await router.handle_message(make_update(text="follow-up text question", message_id=202), context) + assert any("Question queued as Q1." in message for _, message, _, _ in bot.messages) + + started = await asyncio.to_thread(runner.wait_started, 1, 1.0) + assert started is True + runner.release_next() + started_second = await asyncio.to_thread(runner.wait_started, 2, 1.0) + assert started_second is True + runner.release_next() + await asyncio.sleep(0) + + assert len(runner.resume_calls) == 2 + assert "Open and inspect that image before answering." in runner.resume_calls[0]["user_message"] + assert runner.resume_calls[1]["user_message"] == "follow-up text question" + + asyncio.run(exercise()) + + def test_busy_queue_and_final_output_reply_to_original_message(tmp_path: Path): backend = tmp_path / "backend" backend.mkdir() @@ -2932,6 +3551,148 @@ def test_copilot_output_uses_copilot_label(tmp_path: Path): assert any("Copilot output" in message[1] for message in bot.messages) +def test_claude_output_uses_claude_label(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = MarkdownRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_md", "markdown-session", "backend", "claude") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update(text="check formatting") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_message(update, context)) + + assert any("Claude output" in message[1] for message in bot.messages) + assert not any("Codex output" in message[1] for message in bot.messages) + + +@pytest.mark.parametrize("provider", ["claude", "codex", "copilot"]) +def test_provider_reply_with_options_offers_buttons_and_resends_choice(tmp_path: Path, provider: str): + backend = tmp_path / "backend" + backend.mkdir() + runner = ReplyOptionsRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_opt", "opt-session", "backend", provider) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update(text="how should I fix this bug?") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_message(update, context)) + + assert len(runner.resume_calls) == 1 + assert any("Which approach would you like" in message[1] for message in bot.messages) + assert any("Action needed" in message[1] for message in bot.messages) + + option_messages = [message for message in bot.messages if message[3] is not None] + assert [message[1] for message in option_messages] == [ + "Patch the validator directly", + "Rewrite the parser", + ] + for message in option_messages: + buttons = [button for row in message[3].inline_keyboard for button in row] + assert len(buttons) == 1 + assert buttons[0].api_kwargs == {"style": "primary"} + assert buttons[0].callback_data.startswith("agentopt:") + + token_callback_data = option_messages[0][3].inline_keyboard[0][0].callback_data + + query = SimpleNamespace(data=token_callback_data, answer=None, edit_message_reply_markup=None) + edited_markup = [] + + async def fake_answer(): + return None + + async def fake_edit_markup(reply_markup=None): + edited_markup.append(reply_markup) + + query.answer = fake_answer + query.edit_message_reply_markup = fake_edit_markup + callback_update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + ) + + asyncio.run(router.handle_agent_reply_option_callback(callback_update, context)) + + assert edited_markup == [None] + assert len(runner.resume_calls) == 2 + assert runner.resume_calls[1]["user_message"] == "Patch the validator directly" + assert any( + "Continuing with: Patch the validator directly" in message[1] for message in bot.messages + ) + + +def test_agent_reply_option_callback_ignores_unknown_token(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = ReplyOptionsRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_opt", "opt-session", "backend", "claude") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + query = SimpleNamespace(data="agentopt:deadbeef0000:0", answer=None, edit_message_reply_markup=None) + edited_markup = [] + + async def fake_answer(): + return None + + async def fake_edit_markup(reply_markup=None): + edited_markup.append(reply_markup) + + query.answer = fake_answer + query.edit_message_reply_markup = fake_edit_markup + callback_update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + ) + context = SimpleNamespace(args=[], bot=FakeBot()) + + asyncio.run(router.handle_agent_reply_option_callback(callback_update, context)) + + assert edited_markup == [None] + assert runner.resume_calls == [] + + +def test_agent_reply_option_tokens_are_capped_with_fifo_eviction(tmp_path: Path): + """Regression: a button the user never taps used to leave its token in the dict + forever, letting it grow without bound over a long-lived bot's uptime. Registering + past the cap must evict the oldest entries instead.""" + from coding_agent_telegram.router.base import MAX_AGENT_REPLY_OPTION_TOKENS + + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + + first_token = router._register_agent_reply_options(123, ("a",)) + tokens = [first_token] + for _ in range(MAX_AGENT_REPLY_OPTION_TOKENS - 1): + tokens.append(router._register_agent_reply_options(123, ("a",))) + + assert len(router._agent_reply_option_tokens) == MAX_AGENT_REPLY_OPTION_TOKENS + assert first_token in router._agent_reply_option_tokens + + overflow_token = router._register_agent_reply_options(123, ("a",)) + + assert len(router._agent_reply_option_tokens) == MAX_AGENT_REPLY_OPTION_TOKENS + assert first_token not in router._agent_reply_option_tokens, "oldest entry should be evicted first" + assert overflow_token in router._agent_reply_option_tokens + assert tokens[-1] in router._agent_reply_option_tokens + + def test_message_reports_missing_project_folder_before_running_agent(tmp_path: Path): backend = tmp_path / "backend" backend.mkdir() @@ -3621,7 +4382,7 @@ def test_compact_reports_usage_when_args_are_passed(tmp_path: Path): assert bot.messages[-1][1] == "Usage: /compact" -@pytest.mark.parametrize("provider", ["codex", "copilot"]) +@pytest.mark.parametrize("provider", ["codex", "copilot", "claude"]) def test_compact_creates_fresh_session_from_summary(tmp_path: Path, provider: str): backend = tmp_path / "backend" backend.mkdir() @@ -3643,39 +4404,61 @@ def test_compact_creates_fresh_session_from_summary(tmp_path: Path, provider: st assert "compact handoff summary" in runner.resume_calls[-1]["user_message"].lower() assert runner.create_calls[-1]["provider"] == provider assert "Use this compact handoff summary" in runner.create_calls[-1]["user_message"] + # The bootstrap prompt is a handoff summary that lists "next steps"; it must be + # marked priming-only so no provider starts executing them while merely seeding + # the replacement session. + assert runner.create_calls[-1]["priming_only"] is True state = store.get_chat_state("bot-a", 123) assert state["active_session_id"] == "sess_compacted" - assert state["sessions"]["sess_compacted"]["name"] == "current-session-1" + assert state["sessions"]["sess_compacted"]["name"] == "current-session-resume1" assert "Session compacted successfully." in bot.messages[-1][1] -def test_assistant_command_block_is_sent_separately(tmp_path: Path): +def test_compact_run_twice_increments_resume_suffix(tmp_path: Path): backend = tmp_path / "backend" backend.mkdir() - runner = CommandBlockRunner() + runner = CompactingRunner() cfg = make_config(tmp_path) store = SessionStore(cfg.state_file, cfg.state_backup_file) - store.create_session("bot-a", 123, "sess_cmd", "command-session", "backend", "codex") + store.create_session("bot-a", 123, "sess_current", "current-session", "backend", "codex") router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) router.git = FakeGitManager(is_git_repo=False) - update = make_update(text="give me the command") bot = FakeBot() context = SimpleNamespace(args=[], bot=bot) + update = make_update(text="/compact") - asyncio.run(router.handle_message(update, context)) + asyncio.run(router.handle_compact(update, context)) + state = store.get_chat_state("bot-a", 123) + assert state["sessions"]["sess_compacted"]["name"] == "current-session-resume1" - assert any(message[1] == "Command (2/2)" for message in bot.messages) - assert any("git commit -m "test"" in message[1] for message in bot.messages) + asyncio.run(router.handle_compact(update, context)) + state = store.get_chat_state("bot-a", 123) + assert state["sessions"]["sess_compacted"]["name"] == "current-session-resume2" -def test_successful_resume_creates_new_session_and_switches_active_session(tmp_path: Path): +def test_long_gap_warning_sent_and_holds_message_when_native_session_idle_past_threshold( + tmp_path: Path, monkeypatch +): + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) backend = tmp_path / "backend" backend.mkdir() - runner = SessionIdRotatingRunner() + runner = DummyRunner() cfg = make_config(tmp_path) + cfg = AppConfig(**{**cfg.__dict__, "long_gap_warning_enabled": True, "codex_long_gap_seconds": 600}) store = SessionStore(cfg.state_file, cfg.state_backup_file) - store.create_session("bot-a", 123, "sess_original", "rotating-session", "backend", "codex") + store.create_session("bot-a", 123, "sess_idle", "idle-session", "backend", "codex") + seed_codex_native_session( + home, + session_id="sess_idle", + cwd=backend, + title="idle-session", + branch="", + created_at=int(time.time()) - 7200, + updated_at=int(time.time()) - 7200, # 2h ago, well past the 10-minute threshold + tokens_used=100_000, # above the size gate, so the warning isn't skipped as "too small to matter" + ) router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) router.git = FakeGitManager(is_git_repo=False) @@ -3685,261 +4468,1193 @@ def test_successful_resume_creates_new_session_and_switches_active_session(tmp_p asyncio.run(router.handle_message(update, context)) - state = store.get_chat_state("bot-a", 123) - assert state["active_session_id"] == "sess_rotated" - assert "sess_rotated" in state["sessions"] - assert "sess_original" in state["sessions"] - assert state["sessions"]["sess_rotated"]["name"] == "rotating-session-1" - assert "Resume succeeded, but the session ID changed." in bot.messages[1][1] - assert "New session ID: sess_rotated" in bot.messages[1][1] - assert "New session name: rotating-session-1" in bot.messages[1][1] + assert runner.resume_calls == [] + chat_id, text, _parse_mode, reply_markup = bot.messages[-1] + assert chat_id == 123 + assert "idle" in text.lower() + buttons = [button for row in reply_markup.inline_keyboard for button in row] + assert [button.callback_data for button in buttons] == ["longgap:switch", "longgap:compact", "longgap:proceed"] + pending = store.get_chat_state("bot-a", 123)["pending_action"] + assert pending == { + "kind": "long_gap_confirm", + "user_message": "keep going", + "suppress_working_notice": False, + "image_paths": [], + } -def test_invalid_resume_recovery_creates_new_session_and_switches_active_session(tmp_path: Path): +def test_grouped_queued_questions_warn_before_resuming_long_idle_session(tmp_path: Path, monkeypatch): + """Grouping a queue batch must not bypass the same long-gap guard as a new message.""" + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) backend = tmp_path / "backend" backend.mkdir() - runner = ResumeReplacementRunner() + runner = DummyRunner() cfg = make_config(tmp_path) + cfg = AppConfig(**{**cfg.__dict__, "long_gap_warning_enabled": True, "codex_long_gap_seconds": 600}) store = SessionStore(cfg.state_file, cfg.state_backup_file) - store.create_session("bot-a", 123, "sess_original", "recover-session", "backend", "codex") + store.create_session("bot-a", 123, "sess_idle", "idle-session", "backend", "codex") + seed_codex_native_session( + home, + session_id="sess_idle", + cwd=backend, + title="idle-session", + branch="", + created_at=int(time.time()) - 7200, + updated_at=int(time.time()) - 7200, + tokens_used=100_000, + ) router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) router.git = FakeGitManager(is_git_repo=False) - - update = make_update(text="keep going") + queue_file = router._next_queue_file_path(123) + queued_questions = [QueuedQuestion("first queued question"), QueuedQuestion("second queued question")] + router._write_queue_questions(queue_file, queued_questions) bot = FakeBot() context = SimpleNamespace(args=[], bot=bot) - asyncio.run(router.handle_message(update, context)) + continued = asyncio.run( + router._dispatch_queued_questions( + 123, + context, + queue_file=queue_file, + queued_messages=queued_questions, + grouped=True, + ) + ) - state = store.get_chat_state("bot-a", 123) - assert state["active_session_id"] == "sess_abc123" - assert "sess_original" in state["sessions"] - assert state["sessions"]["sess_abc123"]["name"] == "recover-session-1" - assert "Resume failed, so a new session was created." in bot.messages[1][1] - assert "New session ID: sess_abc123" in bot.messages[1][1] - assert "New session name: recover-session-1" in bot.messages[1][1] + assert continued is True + assert runner.resume_calls == [] + assert not queue_file.exists() + assert 123 not in router._chat_processing_queue_files + pending = store.get_chat_state("bot-a", 123)["pending_action"] + assert pending["kind"] == "long_gap_confirm" + assert "Answer the following queued user questions in order." in pending["user_message"] + assert "first queued question" in pending["user_message"] + assert "second queued question" in pending["user_message"] + buttons = [button.callback_data for row in bot.messages[-1][3].inline_keyboard for button in row] + assert buttons == ["longgap:switch", "longgap:compact", "longgap:proceed"] -def test_invalid_resume_recovery_uses_next_available_suffix_for_new_session_name(tmp_path: Path): - backend = tmp_path / "backend" - backend.mkdir() - runner = ResumeReplacementRunner() +def test_queued_message_waiting_for_replaced_project_runs_once_and_replies_to_original_question(tmp_path: Path): + """Resolving a missing project must not duplicate a held queue entry or quote /project.""" + runner = DummyRunner() cfg = make_config(tmp_path) store = SessionStore(cfg.state_file, cfg.state_backup_file) - store.create_session("bot-a", 123, "sess_original", "recover-session", "backend", "codex") - store.create_session("bot-a", 123, "sess_existing", "recover-session-1", "backend", "codex") - store.switch_session("bot-a", 123, "sess_original") + store.set_current_project_folder("bot-a", 123, "renamed-project") + store.create_session("bot-a", 123, "sess_missing", "old-session", "renamed-project", "codex") router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) router.git = FakeGitManager(is_git_repo=False) - - update = make_update(text="keep going") + queue_file = router._next_queue_file_path(123) + queued_question = QueuedQuestion("restore the chat history", reply_to_message_id=321) + router._write_queue_questions(queue_file, [queued_question]) bot = FakeBot() context = SimpleNamespace(args=[], bot=bot) - asyncio.run(router.handle_message(update, context)) + continued = asyncio.run( + router._dispatch_queued_questions( + 123, + context, + queue_file=queue_file, + queued_messages=[queued_question], + grouped=False, + ) + ) - state = store.get_chat_state("bot-a", 123) - assert state["active_session_id"] == "sess_abc123" - assert state["sessions"]["sess_abc123"]["name"] == "recover-session-2" + assert continued is True + assert runner.resume_calls == [] + assert not queue_file.exists() + pending = store.get_chat_state("bot-a", 123)["pending_action"] + assert pending["user_message"] == "restore the chat history" + assert pending["reply_to_message_id"] == 321 + project_update = make_update(text="/project replacement-project", message_id=999) + asyncio.run(router.handle_project(project_update, SimpleNamespace(args=["replacement-project"], bot=bot))) -def test_active_session_reports_stalled_agent_process(tmp_path: Path): + assert [call["user_message"] for call in runner.resume_calls] == ["restore the chat history"] + working_entries = [entry for entry in bot.sent_messages if entry["text"] == "Working on it..."] + assert working_entries[-1]["reply_to_message_id"] == 321 + + +def test_long_gap_warning_skipped_for_small_session_despite_long_idle(tmp_path: Path, monkeypatch): + """A session with little accumulated context shouldn't nag just because it sat idle + -- reprocessing it from scratch is cheap regardless, so the size gate should skip + the warning even though the idle threshold alone would have fired it.""" + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) backend = tmp_path / "backend" backend.mkdir() - runner = StallingRunner() + runner = DummyRunner() cfg = make_config(tmp_path) + cfg = AppConfig(**{**cfg.__dict__, "long_gap_warning_enabled": True, "codex_long_gap_seconds": 600}) store = SessionStore(cfg.state_file, cfg.state_backup_file) - store.create_session("bot-a", 123, "sess_stall", "stall-session", "backend", "codex") + store.create_session("bot-a", 123, "sess_idle_small", "small-session", "backend", "codex") + seed_codex_native_session( + home, + session_id="sess_idle_small", + cwd=backend, + title="small-session", + branch="", + created_at=int(time.time()) - 7200, + updated_at=int(time.time()) - 7200, # well past the idle threshold + tokens_used=500, # well below the size gate + ) router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) router.git = FakeGitManager(is_git_repo=False) - update = make_update(text="continue") + update = make_update(text="keep going") bot = FakeBot() context = SimpleNamespace(args=[], bot=bot) asyncio.run(router.handle_message(update, context)) - assert any("The current agent run appears stuck." in message[1] for message in bot.messages) - assert any("hidden permission dialog" in message[1] for message in bot.messages) + assert runner.resume_calls and runner.resume_calls[-1]["user_message"] == "keep going" + assert store.get_chat_state("bot-a", 123).get("pending_action") is None -def test_active_session_deletes_live_progress_message_when_final_output_is_sent(tmp_path: Path): +def test_long_gap_size_gate_skip_is_cached_to_avoid_repeated_lookups(tmp_path: Path, monkeypatch): + """Regression: a session sitting under the size gate but past the idle threshold + used to repeat the blocking native_session_activity lookup on every single message, + since the gap-crossing cache above only helps while the gap hasn't crossed the + threshold yet. The size-gate skip must cache too.""" + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) backend = tmp_path / "backend" backend.mkdir() - runner = ProgressRunner() + runner = DummyRunner() cfg = make_config(tmp_path) + cfg = AppConfig(**{**cfg.__dict__, "long_gap_warning_enabled": True, "codex_long_gap_seconds": 600}) store = SessionStore(cfg.state_file, cfg.state_backup_file) - store.create_session("bot-a", 123, "sess_progress", "progress-session", "backend", "codex") + store.create_session("bot-a", 123, "sess_idle_small", "small-session", "backend", "codex") + seed_codex_native_session( + home, + session_id="sess_idle_small", + cwd=backend, + title="small-session", + branch="", + created_at=int(time.time()) - 7200, + updated_at=int(time.time()) - 7200, # well past the idle threshold + tokens_used=500, # well below the size gate + ) router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) router.git = FakeGitManager(is_git_repo=False) - update = make_update(text="continue") + from coding_agent_telegram.router import message_commands + from coding_agent_telegram.session_gap import native_session_activity as real_native_session_activity + + call_count = 0 + + def counting_native_session_activity(*args, **kwargs): + nonlocal call_count + call_count += 1 + return real_native_session_activity(*args, **kwargs) + + monkeypatch.setattr(message_commands, "native_session_activity", counting_native_session_activity) + bot = FakeBot() context = SimpleNamespace(args=[], bot=bot) - asyncio.run(router.handle_message(update, context)) + asyncio.run(router.handle_message(make_update(text="first"), context)) + asyncio.run(router.handle_message(make_update(text="second"), context)) - assert len(bot.deleted_messages) == 1 - assert bot.deleted_messages[0][0] == 123 - assert any("Codex output" in message[1] for message in bot.messages) + assert call_count == 1, "size-gate skip should be cached, not re-checked on every message" + assert runner.resume_calls[-1]["user_message"] == "second" + assert store.get_chat_state("bot-a", 123).get("pending_action") is None -def test_active_session_reuses_single_live_progress_message(tmp_path: Path): +def test_long_gap_warning_skipped_when_native_session_recently_active(tmp_path: Path, monkeypatch): + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) backend = tmp_path / "backend" backend.mkdir() - runner = RapidProgressRunner() + runner = DummyRunner() cfg = make_config(tmp_path) + cfg = AppConfig(**{**cfg.__dict__, "long_gap_warning_enabled": True, "codex_long_gap_seconds": 600}) store = SessionStore(cfg.state_file, cfg.state_backup_file) - store.create_session("bot-a", 123, "sess_progress", "progress-session", "backend", "copilot") + store.create_session("bot-a", 123, "sess_fresh", "fresh-session", "backend", "codex") + seed_codex_native_session( + home, + session_id="sess_fresh", + cwd=backend, + title="fresh-session", + branch="", + created_at=int(time.time()) - 30, + updated_at=int(time.time()) - 30, # 30s ago, well under the 10-minute threshold + ) router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) router.git = FakeGitManager(is_git_repo=False) - update = make_update(text="continue") + update = make_update(text="keep going") bot = FakeBot() context = SimpleNamespace(args=[], bot=bot) asyncio.run(router.handle_message(update, context)) - progress_messages = [message for message in bot.messages if "Live agent output" in message[1]] - assert len(progress_messages) == 2 - assert bot.edit_count == 1 + assert runner.resume_calls and runner.resume_calls[-1]["user_message"] == "keep going" + assert store.get_chat_state("bot-a", 123).get("pending_action") is None -def test_active_session_deletes_live_progress_message_even_if_progress_send_is_slow(tmp_path: Path): - backend = tmp_path / "backend" - backend.mkdir() - runner = ProgressRunner() - cfg = make_config(tmp_path) - store = SessionStore(cfg.state_file, cfg.state_backup_file) - store.create_session("bot-a", 123, "sess_progress", "progress-session", "backend", "codex") - router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) - router.git = FakeGitManager(is_git_repo=False) +def test_long_gap_provider_tables_stay_in_sync_with_providers_and_config(): + """The long-gap check is table-driven, and both tables fail *silently* when a + provider is missing: an absent config entry disables the warning, and an absent + activity entry reports "unknown". A typo'd AppConfig field name would likewise turn + the feature off via getattr's default rather than raising. Pin all three.""" + import dataclasses - update = make_update(text="continue") - bot = SlowProgressBot() - context = SimpleNamespace(args=[], bot=bot) + from coding_agent_telegram.providers import SUPPORTED_PROVIDERS + from coding_agent_telegram.router.message_commands import _LONG_GAP_PROVIDER_CONFIG + from coding_agent_telegram.session_gap import _ACTIVITY_LOOKUP - asyncio.run(router.handle_message(update, context)) + assert set(_LONG_GAP_PROVIDER_CONFIG) == set(SUPPORTED_PROVIDERS) + assert set(_ACTIVITY_LOOKUP) == set(SUPPORTED_PROVIDERS) - assert len(bot.deleted_messages) == 1 + config_fields = {field.name for field in dataclasses.fields(AppConfig)} + for provider, provider_config in _LONG_GAP_PROVIDER_CONFIG.items(): + assert provider_config.threshold_field in config_fields, provider -def test_active_session_deletes_previous_live_progress_message_when_edit_falls_back_to_send(tmp_path: Path): - backend = tmp_path / "backend" - backend.mkdir() - runner = RapidProgressRunner() - cfg = make_config(tmp_path) - store = SessionStore(cfg.state_file, cfg.state_backup_file) - store.create_session("bot-a", 123, "sess_progress", "progress-session", "backend", "codex") - router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) - router.git = FakeGitManager(is_git_repo=False) +def _set_codex_thread_updated_at(home: Path, session_id: str, updated_at: int) -> None: + conn = sqlite3.connect(home / ".codex" / "state_5.sqlite") + try: + conn.execute("UPDATE threads SET updated_at = ? WHERE id = ?", (updated_at, session_id)) + conn.commit() + finally: + conn.close() - update = make_update(text="continue") - bot = EditFailingProgressBot() - context = SimpleNamespace(args=[], bot=bot) - asyncio.run(router.handle_message(update, context)) +def _install_fake_monotonic(monkeypatch, clock: list[float]) -> None: + """Swap only message_commands' view of ``time`` so the gap cache can be aged + deterministically without touching the real clock everything else reads.""" + from coding_agent_telegram.router import message_commands - assert len(bot.deleted_messages) == 2 - deleted_ids = [message_id for chat_id, message_id in bot.deleted_messages if chat_id == 123] - assert len(set(deleted_ids)) == 2 + monkeypatch.setattr(message_commands, "time", SimpleNamespace(monotonic=lambda: clock[0])) -def test_second_message_is_queued_while_first_run_is_still_running(tmp_path: Path): - backend = tmp_path / "backend" - backend.mkdir() - runner = BlockingRunner() +def _long_gap_router(tmp_path: Path, home: Path, backend: Path, *, session_id: str, updated_at: int): + runner = DummyRunner() cfg = make_config(tmp_path) + cfg = AppConfig(**{**cfg.__dict__, "long_gap_warning_enabled": True, "codex_long_gap_seconds": 600}) store = SessionStore(cfg.state_file, cfg.state_backup_file) - store.create_session("bot-a", 123, "sess_queue", "queue-session", "backend", "codex") + store.create_session("bot-a", 123, session_id, "gap-cache-session", "backend", "codex") + seed_codex_native_session( + home, + session_id=session_id, + cwd=backend, + title="gap-cache-session", + branch="", + created_at=updated_at, + updated_at=updated_at, + tokens_used=100_000, # above the size gate + ) router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) router.git = FakeGitManager(is_git_repo=False) + return router, store, runner - async def exercise(): - bot = FakeBot() - first_update = make_update(text="first question") - second_update = make_update(text="second question") - first_context = SimpleNamespace(args=[], bot=bot) - second_context = SimpleNamespace(args=[], bot=bot) - first_task = asyncio.create_task(router.handle_message(first_update, first_context)) - started = await asyncio.to_thread(runner.wait_started, 1, 1.0) - assert started is True +def test_long_gap_cache_expires_when_gap_would_cross_threshold(tmp_path: Path, monkeypatch): + """A session checked just *under* the threshold must not stay cached past it. - await router.handle_message(second_update, second_context) + Caching the check time and trusting it for a full threshold window let a session + checked at 590s idle (threshold 600s) skip the real check until 1190s idle -- nearly + double the threshold with no warning. The cache stores the crossing time instead.""" + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + backend = tmp_path / "backend" + backend.mkdir() + clock = [1000.0] + _install_fake_monotonic(monkeypatch, clock) + router, store, runner = _long_gap_router( + tmp_path, + home, + backend, + session_id="sess_edge", + updated_at=int(time.time()) - 590, # 10s short of the 600s threshold + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) - assert any("Question queued as Q1." in message for _, message, _, _ in bot.messages) - assert not any("Working on queued questions:" in message for _, message, _, _ in bot.messages) - assert not any("already running on project" in message for _, message, _, _ in bot.messages) - assert not any("Command failed" in message for _, message, _, _ in bot.messages) + asyncio.run(router.handle_message(make_update(text="first"), context)) + assert runner.resume_calls and runner.resume_calls[-1]["user_message"] == "first" - runner.release_next() - started_second = await asyncio.to_thread(runner.wait_started, 2, 1.0) - assert started_second is True - runner.release_next() - await first_task + # The session goes quiet past the threshold, and only 60s of bot uptime elapses -- + # far less than the 600s a check-time cache would have held for. + _set_codex_thread_updated_at(home, "sess_edge", int(time.time()) - 1200) + clock[0] += 60 - assert len(runner.resume_calls) == 2 - assert runner.resume_calls[0]["user_message"] == "first question" - assert runner.resume_calls[1]["user_message"] == "second question" - assert any("Working on queued questions:" in message for _, message, _, _ in bot.messages) - assert any("1. second question" in message for _, message, _, _ in bot.messages) + asyncio.run(router.handle_message(make_update(text="second"), context)) - asyncio.run(exercise()) + assert runner.resume_calls[-1]["user_message"] == "first", "second message should be held, not dispatched" + pending = store.get_chat_state("bot-a", 123)["pending_action"] + assert pending["kind"] == "long_gap_confirm" + assert pending["user_message"] == "second" -def test_second_message_is_queued_even_before_runner_reports_busy(tmp_path: Path): +def test_long_gap_cache_skips_repeat_lookup_within_safe_window(tmp_path: Path, monkeypatch): + """The flip side: while the gap provably can't have crossed the threshold, the + check short-circuits instead of re-reading the provider's db on every message.""" + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) backend = tmp_path / "backend" backend.mkdir() - runner = BlockingRunner() - cfg = make_config(tmp_path) - store = SessionStore(cfg.state_file, cfg.state_backup_file) - store.create_session("bot-a", 123, "sess_queue", "queue-session", "backend", "codex") - router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) - router.git = FakeGitManager(is_git_repo=False) + clock = [1000.0] + _install_fake_monotonic(monkeypatch, clock) + router, store, runner = _long_gap_router( + tmp_path, + home, + backend, + session_id="sess_active", + updated_at=int(time.time()) - 30, # freshly active: cached for ~570s + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) - async def exercise(): - bot = FakeBot() - first_update = make_update(text="first question", message_id=101) - second_update = make_update(text="second question", message_id=202) + asyncio.run(router.handle_message(make_update(text="first"), context)) - first_task = asyncio.create_task(router.handle_message(first_update, SimpleNamespace(args=[], bot=bot))) - await asyncio.sleep(0) - await router.handle_message(second_update, SimpleNamespace(args=[], bot=bot)) + # Backdate the row well past the threshold. Within the safe window the cache must + # win, so this rewrite is invisible until the window lapses. + _set_codex_thread_updated_at(home, "sess_active", int(time.time()) - 99_999) + clock[0] += 5 - assert any("Question queued as Q1." in message for _, message, _, _ in bot.messages) + asyncio.run(router.handle_message(make_update(text="second"), context)) - started = await asyncio.to_thread(runner.wait_started, 1, 1.0) - assert started is True - runner.release_next() - started_second = await asyncio.to_thread(runner.wait_started, 2, 1.0) - assert started_second is True - runner.release_next() - await first_task + assert runner.resume_calls[-1]["user_message"] == "second" + assert store.get_chat_state("bot-a", 123).get("pending_action") is None - assert len(runner.resume_calls) == 2 - assert runner.resume_calls[0]["user_message"] == "first question" - assert runner.resume_calls[1]["user_message"] == "second question" - asyncio.run(exercise()) +def _run_two_messages_concurrently(router, context) -> None: + """Telegram handlers are registered with block=False, so two messages arriving + together in one chat run as concurrent tasks.""" + + async def both() -> None: + await asyncio.gather( + router.handle_message(make_update(text="first", message_id=1), context), + router.handle_message(make_update(text="second", message_id=2), context), + ) + asyncio.run(both()) -def test_grouped_queue_batch_requires_user_decision_then_processes_remaining_queue(tmp_path: Path): + +def test_concurrent_messages_on_idle_session_warn_once_and_queue_the_loser(tmp_path: Path, monkeypatch): + """The long-gap check awaits provider I/O between "nothing else is handling this + chat" and this message claiming it. Without a re-check afterwards both messages + warn, and the second's pending action overwrites the first's -- two sets of buttons + in the chat and the first message silently dropped.""" + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) backend = tmp_path / "backend" backend.mkdir() - runner = BlockingRunner() - cfg = make_config(tmp_path) - store = SessionStore(cfg.state_file, cfg.state_backup_file) - store.create_session("bot-a", 123, "sess_queue", "queue-session", "backend", "codex") - router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) - router.git = FakeGitManager(is_git_repo=False) + router, store, runner = _long_gap_router( + tmp_path, home, backend, session_id="sess_idle_race", updated_at=int(time.time()) - 7200 + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) - async def exercise(): - bot = FakeBot() - first_update = make_update(text="first question", message_id=101) - second_update = make_update(text="two", message_id=202) - third_update = make_update(text="three", message_id=303) - fourth_update = make_update(text="four four four four four four four", message_id=404) + _run_two_messages_concurrently(router, context) + + warnings = [text for _chat, text, _parse, _markup in bot.messages if "idle for" in str(text)] + assert len(warnings) == 1, "the second message overwrote the first message's held confirmation" + assert runner.resume_calls == [] + pending = store.get_chat_state("bot-a", 123)["pending_action"] + assert pending["kind"] == "long_gap_confirm" + assert pending["user_message"] == "first" + # The loser must be queued, not dropped, so it still runs after the button is tapped. + assert any("queued" in str(text).lower() for _chat, text, _parse, _markup in bot.messages) + + +def test_concurrent_messages_on_active_session_dispatch_once(tmp_path: Path, monkeypatch): + """The same await window exists on the no-warning path, where the idle check passes + and the message goes straight to dispatch. There _is_project_busy is the primary + serializer, so this is an invariant check rather than a regression test for a + reproduced failure: neither message may run twice, whichever order they interleave + in.""" + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + backend = tmp_path / "backend" + backend.mkdir() + router, store, runner = _long_gap_router( + tmp_path, home, backend, session_id="sess_active_race", updated_at=int(time.time()) - 30 + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + _run_two_messages_concurrently(router, context) + + dispatched = [call["user_message"] for call in runner.resume_calls] + assert len(dispatched) == len(set(dispatched)), f"a message ran twice: {dispatched}" + assert "first" in dispatched, dispatched + assert store.get_chat_state("bot-a", 123).get("pending_action") is None + + +def test_long_gap_proceed_anyway_dispatches_held_message(tmp_path: Path, monkeypatch): + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + cfg = AppConfig(**{**cfg.__dict__, "long_gap_warning_enabled": True, "codex_long_gap_seconds": 600}) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_idle", "idle-session", "backend", "codex") + # Deliberately still idle past the threshold: this reproduces the "Proceed anyway" + # regression where replaying the message re-triggered _maybe_warn_long_gap because + # the native transcript's mtime doesn't move until the agent actually runs a turn. + seed_codex_native_session( + home, + session_id="sess_idle", + cwd=backend, + title="idle-session", + branch="", + created_at=int(time.time()) - 7200, + updated_at=int(time.time()) - 7200, + tokens_used=100_000, + ) + store.set_pending_action( + "bot-a", + 123, + {"kind": "long_gap_confirm", "user_message": "keep going", "suppress_working_notice": False}, + ) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + edited = [] + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=SimpleNamespace(data="longgap:proceed", answer=None, edit_message_text=None), + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + async def fake_answer(): + return None + + async def fake_edit(text, parse_mode=None, reply_markup=None): + edited.append(text) + + update.callback_query.answer = fake_answer + update.callback_query.edit_message_text = fake_edit + + asyncio.run(router.handle_long_gap_callback(update, context)) + + assert edited == ["Proceeding on the existing session..."] + assert runner.resume_calls and runner.resume_calls[-1]["user_message"] == "keep going" + assert store.get_chat_state("bot-a", 123).get("pending_action") is None + + +def test_long_gap_compact_failure_still_dispatches_held_message(tmp_path: Path): + """If compaction fails (or the workspace is busy), the held message must still be + delivered instead of silently dropped -- it should fall back to running on the + original session rather than vanishing with no trace.""" + backend = tmp_path / "backend" + backend.mkdir() + # Plain DummyRunner's resume_session returns an empty assistant_text, so the + # compact summary step fails with "no usable handoff summary" -- exercising the + # compaction-failed path without a dedicated failing runner. + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_current", "current-session", "backend", "codex") + store.set_pending_action( + "bot-a", + 123, + {"kind": "long_gap_confirm", "user_message": "keep going", "suppress_working_notice": False}, + ) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + edited = [] + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=SimpleNamespace(data="longgap:compact", answer=None, edit_message_text=None), + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + async def fake_answer(): + return None + + async def fake_edit(text, parse_mode=None, reply_markup=None): + edited.append(text) + + update.callback_query.answer = fake_answer + update.callback_query.edit_message_text = fake_edit + + asyncio.run(router.handle_long_gap_callback(update, context)) + + # No new session was created since compaction failed before that step. + assert runner.create_calls == [] + # The compact summary attempt happened, then -- instead of being dropped -- the + # original held message was dispatched on the still-current session. + assert [call["user_message"] for call in runner.resume_calls][-1] == "keep going" + assert runner.resume_calls[-1]["session_id"] == "sess_current" + state = store.get_chat_state("bot-a", 123) + assert state["active_session_id"] == "sess_current" + assert state.get("pending_action") is None + + +def test_long_gap_switch_starts_fresh_session_without_resuming_old_one(tmp_path: Path): + """Unlike compact, switching must never resume the old session -- that resume is + exactly the full-transcript reprocess cost this button exists to avoid.""" + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_current", "current-session", "backend", "codex") + store.set_pending_action( + "bot-a", + 123, + {"kind": "long_gap_confirm", "user_message": "keep going", "suppress_working_notice": False}, + ) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + edited = [] + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=SimpleNamespace(data="longgap:switch", answer=None, edit_message_text=None), + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + async def fake_answer(): + return None + + async def fake_edit(text, parse_mode=None, reply_markup=None): + edited.append(text) + + update.callback_query.answer = fake_answer + update.callback_query.edit_message_text = fake_edit + + asyncio.run(router.handle_long_gap_callback(update, context)) + + assert edited == ["Switching to a new session..."] + # The old session is never resumed to generate a summary -- that resume is exactly + # the full-transcript reprocess this path exists to avoid. The only resume_session + # call is the replayed message running against the brand-new session afterward. + assert all(call["session_id"] != "sess_current" for call in runner.resume_calls) + assert runner.resume_calls[-1]["session_id"] == "sess_abc123" + assert runner.resume_calls[-1]["user_message"] == "keep going" + assert runner.create_calls[0]["priming_only"] is True + state = store.get_chat_state("bot-a", 123) + assert state["active_session_id"] == "sess_abc123" + assert state["sessions"]["sess_abc123"]["name"] == "current-session-new1" + assert state.get("pending_action") is None + + +def test_long_gap_replay_runs_before_messages_queued_during_the_wait(tmp_path: Path, monkeypatch): + """A message that arrives while the long-gap confirmation is pending gets queued + (as normal). Once the user resolves the prompt, the held (older) message must still + run first, then the queue drains -- not the other way around.""" + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + cfg = AppConfig(**{**cfg.__dict__, "long_gap_warning_enabled": True, "codex_long_gap_seconds": 600}) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_idle_reorder", "reorder-session", "backend", "codex") + seed_codex_native_session( + home, + session_id="sess_idle_reorder", + cwd=backend, + title="reorder-session", + branch="", + created_at=int(time.time()) - 7200, + updated_at=int(time.time()) - 7200, + tokens_used=100_000, + ) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + # First message triggers the warning and gets held. + asyncio.run(router.handle_message(make_update(text="first message"), context)) + assert runner.resume_calls == [] + assert store.get_chat_state("bot-a", 123)["pending_action"]["kind"] == "long_gap_confirm" + + # A second message arrives before the user answers the prompt -- it must queue, + # not be dropped or jump ahead. + asyncio.run(router.handle_message(make_update(text="second message"), context)) + assert runner.resume_calls == [] + + callback_update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=SimpleNamespace(data="longgap:proceed", answer=None, edit_message_text=None), + ) + + async def fake_answer(): + return None + + async def fake_edit(text, parse_mode=None, reply_markup=None): + return None + + callback_update.callback_query.answer = fake_answer + callback_update.callback_query.edit_message_text = fake_edit + + asyncio.run(router.handle_long_gap_callback(callback_update, context)) + + dispatched_messages = [call["user_message"] for call in runner.resume_calls] + assert dispatched_messages == ["first message", "second message"] + + +def test_long_gap_warning_also_applies_to_photo_messages(tmp_path: Path, monkeypatch): + """handle_photo must not bypass the long-gap check -- an image sent to a session + idle past its threshold should be held for confirmation just like a text message, + and the button reply should still deliver the image once resolved.""" + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + cfg = AppConfig(**{**cfg.__dict__, "long_gap_warning_enabled": True, "codex_long_gap_seconds": 600}) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_photo_idle", "photo-session", "backend", "codex") + seed_codex_native_session( + home, + session_id="sess_photo_idle", + cwd=backend, + title="photo-session", + branch="", + created_at=int(time.time()) - 7200, + updated_at=int(time.time()) - 7200, + tokens_used=100_000, + ) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + photo = FakePhotoSize(FakeTelegramFile(b"fake-image-bytes", "photos/pic.png")) + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + message=SimpleNamespace(text=None, photo=[photo], caption="what is shown here?"), + ) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_photo(update, context)) + + # Held, not dispatched. + assert runner.resume_calls == [] + pending = store.get_chat_state("bot-a", 123)["pending_action"] + assert pending["kind"] == "long_gap_confirm" + assert len(pending["image_paths"]) == 1 + stored_image_path = Path(pending["image_paths"][0]) + assert stored_image_path.is_file() + + # Resolving with "proceed anyway" must still deliver the image. + callback_update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=SimpleNamespace(data="longgap:proceed", answer=None, edit_message_text=None), + ) + + async def fake_answer(): + return None + + async def fake_edit(text, parse_mode=None, reply_markup=None): + return None + + callback_update.callback_query.answer = fake_answer + callback_update.callback_query.edit_message_text = fake_edit + + asyncio.run(router.handle_long_gap_callback(callback_update, context)) + + assert runner.resume_calls + dispatched_image_paths = runner.resume_calls[-1]["image_paths"] + assert dispatched_image_paths == (stored_image_path,) + assert "what is shown here?" in runner.resume_calls[-1]["user_message"] + assert store.get_chat_state("bot-a", 123).get("pending_action") is None + + +def test_photo_does_not_clobber_pending_long_gap_confirmation(tmp_path: Path, monkeypatch): + """A photo sent while a text message's long-gap confirmation is still pending must + not silently overwrite it -- that would orphan the original warning's buttons and + lose the held text message when they're pressed.""" + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + backend = tmp_path / "backend" + backend.mkdir() + runner = DummyRunner() + cfg = make_config(tmp_path) + cfg = AppConfig(**{**cfg.__dict__, "long_gap_warning_enabled": True, "codex_long_gap_seconds": 600}) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_idle_clobber", "clobber-session", "backend", "codex") + seed_codex_native_session( + home, + session_id="sess_idle_clobber", + cwd=backend, + title="clobber-session", + branch="", + created_at=int(time.time()) - 7200, + updated_at=int(time.time()) - 7200, + tokens_used=100_000, + ) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + # First, a text message triggers and holds the long-gap warning. + asyncio.run(router.handle_message(make_update(text="original text message"), context)) + pending_after_text = store.get_chat_state("bot-a", 123)["pending_action"] + assert pending_after_text["kind"] == "long_gap_confirm" + assert pending_after_text["user_message"] == "original text message" + + # A photo arrives before the user answers -- it must be rejected, not silently + # replace the pending confirmation. + photo = FakePhotoSize(FakeTelegramFile(b"fake-image-bytes", "photos/pic.png")) + photo_update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + message=SimpleNamespace(text=None, photo=[photo], caption="ignore me"), + ) + asyncio.run(router.handle_photo(photo_update, context)) + + pending_after_photo = store.get_chat_state("bot-a", 123)["pending_action"] + assert pending_after_photo == pending_after_text # untouched + assert runner.resume_calls == [] + + # Resolving the (still-original) prompt must run the original text, not the photo. + callback_update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=SimpleNamespace(data="longgap:proceed", answer=None, edit_message_text=None), + ) + + async def fake_answer(): + return None + + async def fake_edit(text, parse_mode=None, reply_markup=None): + return None + + callback_update.callback_query.answer = fake_answer + callback_update.callback_query.edit_message_text = fake_edit + + asyncio.run(router.handle_long_gap_callback(callback_update, context)) + + assert runner.resume_calls + assert runner.resume_calls[-1]["user_message"] == "original text message" + assert runner.resume_calls[-1]["image_paths"] == () + + +def test_assistant_command_block_is_sent_separately(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = CommandBlockRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_cmd", "command-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update(text="give me the command") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_message(update, context)) + + assert any(message[1] == "Command (2/2)" for message in bot.messages) + assert any("git commit -m "test"" in message[1] for message in bot.messages) + + +def test_successful_resume_creates_new_session_and_switches_active_session(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = SessionIdRotatingRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_original", "rotating-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update(text="keep going") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_message(update, context)) + + state = store.get_chat_state("bot-a", 123) + assert state["active_session_id"] == "sess_rotated" + assert "sess_rotated" in state["sessions"] + assert "sess_original" in state["sessions"] + assert state["sessions"]["sess_rotated"]["name"] == "rotating-session-1" + assert "Resume succeeded, but the session ID changed." in bot.messages[1][1] + assert "New session ID: sess_rotated" in bot.messages[1][1] + assert "New session name: rotating-session-1" in bot.messages[1][1] + + +def test_invalid_resume_recovery_creates_new_session_and_switches_active_session(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = ResumeReplacementRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_original", "recover-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update(text="keep going") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_message(update, context)) + + state = store.get_chat_state("bot-a", 123) + assert state["active_session_id"] == "sess_abc123" + assert "sess_original" in state["sessions"] + assert state["sessions"]["sess_abc123"]["name"] == "recover-session-1" + assert "Resume failed, so a new session was created." in bot.messages[1][1] + assert "New session ID: sess_abc123" in bot.messages[1][1] + assert "New session name: recover-session-1" in bot.messages[1][1] + + +def test_invalid_resume_recovery_uses_next_available_suffix_for_new_session_name(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = ResumeReplacementRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_original", "recover-session", "backend", "codex") + store.create_session("bot-a", 123, "sess_existing", "recover-session-1", "backend", "codex") + store.switch_session("bot-a", 123, "sess_original") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update(text="keep going") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_message(update, context)) + + state = store.get_chat_state("bot-a", 123) + assert state["active_session_id"] == "sess_abc123" + assert state["sessions"]["sess_abc123"]["name"] == "recover-session-2" + + +def test_invalid_resume_recovery_recognizes_claude_session_not_found_error_code(tmp_path: Path): + """Claude's CLI reports an unresumable session ID as "No conversation found with + session ID: ..." (see agent_runner._claude_events_report_session_not_found), which + MultiAgentRunner surfaces as error_code="session_not_found" -- a structured signal + rather than a substring match on error_message, since that text can also be a + genuine (if failed) turn's model-generated output. The recovery check has to honor + that error_code, or a session with no local transcript fails identically forever + instead of ever recovering.""" + + class ClaudeNoConversationRunner(DummyRunner): + def resume_session( + self, + provider, + session_id, + project_path, + user_message, + *, + skip_git_repo_check=False, + image_paths=(), + model=None, + on_stall=None, + on_progress=None, + ): + self.resume_calls.append( + { + "provider": provider, + "session_id": session_id, + "project_path": project_path, + "user_message": user_message, + "skip_git_repo_check": skip_git_repo_check, + "image_paths": image_paths, + "model": model, + "on_stall": on_stall, + } + ) + return AgentRunResult( + session_id=None, + success=False, + assistant_text="", + error_message=f"No conversation found with session ID: {session_id}", + raw_events=[], + error_code="session_not_found", + ) + + backend = tmp_path / "backend" + backend.mkdir() + runner = ClaudeNoConversationRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_original", "recover-session", "backend", "claude") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update(text="keep going") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_message(update, context)) + + state = store.get_chat_state("bot-a", 123) + assert state["active_session_id"] == "sess_abc123" + assert "sess_original" in state["sessions"] + assert "Resume failed, so a new session was created." in bot.messages[1][1] + + +def test_invalid_resume_recovery_ignores_resume_substring_in_claude_error_without_error_code( + tmp_path: Path, +): + """A Claude failure whose error_message happens to contain "resume" for an unrelated + reason (e.g. a genuine model turn discussing a file called resume.pdf) must NOT be + treated as an unresumable session -- only error_code == "session_not_found" (a + structured signal, not a substring guess) should trigger replacing it. Doing + otherwise would discard a perfectly good session over unrelated content.""" + + class ClaudeUnrelatedFailureRunner(DummyRunner): + def resume_session( + self, + provider, + session_id, + project_path, + user_message, + *, + skip_git_repo_check=False, + image_paths=(), + model=None, + on_stall=None, + on_progress=None, + ): + self.resume_calls.append( + { + "provider": provider, + "session_id": session_id, + "project_path": project_path, + "user_message": user_message, + "skip_git_repo_check": skip_git_repo_check, + "image_paths": image_paths, + "model": model, + "on_stall": on_stall, + } + ) + return AgentRunResult( + session_id=session_id, + success=False, + assistant_text="", + error_message="I couldn't finish reviewing resume.pdf before running out of turns.", + raw_events=[], + error_code=None, + ) + + backend = tmp_path / "backend" + backend.mkdir() + runner = ClaudeUnrelatedFailureRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_original", "recover-session", "backend", "claude") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update(text="keep going") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_message(update, context)) + + state = store.get_chat_state("bot-a", 123) + assert state["active_session_id"] == "sess_original" + assert "resume.pdf" in bot.messages[-1][1] + + +def test_active_session_reports_stalled_agent_process(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = StallingRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_stall", "stall-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update(text="continue") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_message(update, context)) + + assert any("The current agent run appears stuck." in message[1] for message in bot.messages) + assert any("hidden permission dialog" in message[1] for message in bot.messages) + + +def test_active_session_deletes_live_progress_message_when_final_output_is_sent(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = ProgressRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_progress", "progress-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update(text="continue") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_message(update, context)) + + assert len(bot.deleted_messages) == 1 + assert bot.deleted_messages[0][0] == 123 + assert any("Codex output" in message[1] for message in bot.messages) + + +def test_active_session_reuses_single_live_progress_message(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = RapidProgressRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_progress", "progress-session", "backend", "copilot") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update(text="continue") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_message(update, context)) + + progress_messages = [message for message in bot.messages if "Live agent output" in message[1]] + assert len(progress_messages) == 2 + assert bot.edit_count == 1 + + +def test_active_session_deletes_live_progress_message_even_if_progress_send_is_slow(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = ProgressRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_progress", "progress-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update(text="continue") + bot = SlowProgressBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_message(update, context)) + + assert len(bot.deleted_messages) == 1 + + +def test_active_session_deletes_previous_live_progress_message_when_edit_falls_back_to_send(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = RapidProgressRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_progress", "progress-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update(text="continue") + bot = EditFailingProgressBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_message(update, context)) + + assert len(bot.deleted_messages) == 2 + deleted_ids = [message_id for chat_id, message_id in bot.deleted_messages if chat_id == 123] + assert len(set(deleted_ids)) == 2 + + +def test_second_message_is_queued_while_first_run_is_still_running(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = BlockingRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_queue", "queue-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + async def exercise(): + bot = FakeBot() + first_update = make_update(text="first question") + second_update = make_update(text="second question") + first_context = SimpleNamespace(args=[], bot=bot) + second_context = SimpleNamespace(args=[], bot=bot) + + first_task = asyncio.create_task(router.handle_message(first_update, first_context)) + started = await asyncio.to_thread(runner.wait_started, 1, 1.0) + assert started is True + + await router.handle_message(second_update, second_context) + + assert any("Question queued as Q1." in message for _, message, _, _ in bot.messages) + assert not any("Working on queued questions:" in message for _, message, _, _ in bot.messages) + assert not any("already running on project" in message for _, message, _, _ in bot.messages) + assert not any("Command failed" in message for _, message, _, _ in bot.messages) + + runner.release_next() + started_second = await asyncio.to_thread(runner.wait_started, 2, 1.0) + assert started_second is True + runner.release_next() + await first_task + + assert len(runner.resume_calls) == 2 + assert runner.resume_calls[0]["user_message"] == "first question" + assert runner.resume_calls[1]["user_message"] == "second question" + assert any("Working on queued questions:" in message for _, message, _, _ in bot.messages) + assert any("1. second question" in message for _, message, _, _ in bot.messages) + + asyncio.run(exercise()) + + +def test_second_message_is_queued_even_before_runner_reports_busy(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = BlockingRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_queue", "queue-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + async def exercise(): + bot = FakeBot() + first_update = make_update(text="first question", message_id=101) + second_update = make_update(text="second question", message_id=202) + + first_task = asyncio.create_task(router.handle_message(first_update, SimpleNamespace(args=[], bot=bot))) + await asyncio.sleep(0) + await router.handle_message(second_update, SimpleNamespace(args=[], bot=bot)) + + assert any("Question queued as Q1." in message for _, message, _, _ in bot.messages) + + started = await asyncio.to_thread(runner.wait_started, 1, 1.0) + assert started is True + runner.release_next() + started_second = await asyncio.to_thread(runner.wait_started, 2, 1.0) + assert started_second is True + runner.release_next() + await first_task + + assert len(runner.resume_calls) == 2 + assert runner.resume_calls[0]["user_message"] == "first question" + assert runner.resume_calls[1]["user_message"] == "second question" + + asyncio.run(exercise()) + + +def test_grouped_queue_batch_requires_user_decision_then_processes_remaining_queue(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = BlockingRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_queue", "queue-session", "backend", "codex") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + async def exercise(): + bot = FakeBot() + first_update = make_update(text="first question", message_id=101) + second_update = make_update(text="two", message_id=202) + third_update = make_update(text="three", message_id=303) + fourth_update = make_update(text="four four four four four four four", message_id=404) first_context = SimpleNamespace(args=[], bot=bot) first_task = asyncio.create_task(router.handle_message(first_update, first_context)) @@ -3955,7 +5670,8 @@ async def exercise(): assert len(prompt_messages) == 1 keyboard = prompt_messages[0][3] assert keyboard is not None - buttons = keyboard.inline_keyboard[0] + buttons = [button for row in keyboard.inline_keyboard for button in row] + assert [len(row) for row in keyboard.inline_keyboard] == [1, 1, 1] assert buttons[0].callback_data == "queuebatch:group" assert buttons[1].callback_data == "queuebatch:single" assert buttons[2].callback_data == "queuebatch:cancel" @@ -4420,6 +6136,22 @@ def _run_pull_command(router: CommandRouter, *, args: list[str] | None = None) - return bot +def _run_log_command(router: CommandRouter, *, args: list[str] | None = None) -> FakeBot: + update = make_update(text="/log" if not args else "/log " + " ".join(args)) + bot = FakeBot() + context = SimpleNamespace(args=args or [], bot=bot) + asyncio.run(router.handle_log(update, context)) + return bot + + +def _run_reset_command(router: CommandRouter, *, args: list[str] | None = None) -> FakeBot: + update = make_update(text="/reset" if not args else "/reset " + " ".join(args)) + bot = FakeBot() + context = SimpleNamespace(args=args or [], bot=bot) + asyncio.run(router.handle_reset(update, context)) + return bot + + def _run_diff_command(router: CommandRouter, *, args: list[str] | None = None) -> FakeBot: update = make_update(text="/diff" if not args else "/diff " + " ".join(args)) bot = FakeBot() @@ -4439,6 +6171,14 @@ def test_commit_executes_only_valid_git_commands_and_ignores_non_git_segments(tm ], ), ) + lock_states = [] + original_run_safe_commit_command = router.git.run_safe_commit_command + + def run_safe_commit_command(project_path, args): + lock_states.append(router._workspace_locks["backend"].locked()) + return original_run_safe_commit_command(project_path, args) + + router.git.run_safe_commit_command = run_safe_commit_command bot = _run_commit_command(router, '/commit git add -u && rm -rf / && git commit -m "safe"') @@ -4457,6 +6197,7 @@ def test_commit_executes_only_valid_git_commands_and_ignores_non_git_segments(tm assert "[telegram-enhance 5b9a263] safe" in bot.messages[-1][1] assert "Ignored non-git commands:" in bot.messages[-1][1] assert "- rm -rf /" in bot.messages[-1][1] + assert lock_states == [True, True] def test_commit_is_rejected_when_disabled(tmp_path: Path): @@ -4781,6 +6522,23 @@ def test_push_uses_current_session_branch(tmp_path: Path): assert buttons[1].api_kwargs == {"style": "danger"} +def test_push_escapes_backticks_in_branch_name_for_markdown(tmp_path: Path): + backend = (tmp_path / "backend").resolve() + backend.mkdir() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + branch_name = "feature/foo`bar" + store.create_session("bot-a", 123, "sess_push", "push-session", "backend", "codex", branch_name=branch_name) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=DummyRunner(), bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True, current_branch=branch_name) + router.runtime.git = router.git + + bot = _run_push_command(router) + + assert bot.messages[-1][1] == "Push branch `feature/foo\\`bar` to `origin`?" + assert bot.messages[-1][2] == "Markdown" + + def test_push_confirmation_executes_push(tmp_path: Path): backend = (tmp_path / "backend").resolve() backend.mkdir() @@ -4796,11 +6554,21 @@ def test_push_confirmation_executes_push(tmp_path: Path): push_result=SimpleNamespace(success=True, message="Pushed branch 'feature-1' to origin.", current_branch="feature-1"), ) router.runtime.git = router.git + lock_states = [] + original_push_branch = router.git.push_branch + + def push_branch(project_path, branch_name): + lock_states.append(router._workspace_locks["backend"].locked()) + return original_push_branch(project_path, branch_name) + + router.git.push_branch = push_branch + prompt_bot = _run_push_command(router) + confirm_callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][0].callback_data edited = [] update = SimpleNamespace( effective_chat=SimpleNamespace(id=123, type="private"), callback_query=SimpleNamespace( - data="push:confirm", + data=confirm_callback_data, answer=None, edit_message_text=None, ), @@ -4824,6 +6592,7 @@ async def fake_edit(text, parse_mode=None): assert bot.messages[-1][1].startswith('
')
     assert f"${shlex.join(['git', 'push', 'origin', 'feature-1'])}" in bot.messages[-1][1]
     assert "[Completed]" in bot.messages[-1][1]
+    assert lock_states == [True]
 
 
 def test_push_confirmation_cancel_does_not_push(tmp_path: Path):
@@ -4836,11 +6605,13 @@ def test_push_confirmation_cancel_does_not_push(tmp_path: Path):
     router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
     router.git = FakeGitManager(is_git_repo=True, current_branch="feature-1")
     router.runtime.git = router.git
+    prompt_bot = _run_push_command(router)
+    cancel_callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][1].callback_data
     edited = []
     update = SimpleNamespace(
         effective_chat=SimpleNamespace(id=123, type="private"),
         callback_query=SimpleNamespace(
-            data="push:cancel",
+            data=cancel_callback_data,
             answer=None,
             edit_message_text=None,
         ),
@@ -4862,6 +6633,38 @@ async def fake_edit(text):
     assert edited == ["Push cancelled."]
 
 
+def test_push_confirmation_expires_when_active_session_branch_changes(tmp_path: Path):
+    backend = (tmp_path / "backend").resolve()
+    backend.mkdir()
+    cfg = make_config(tmp_path)
+    store = SessionStore(cfg.state_file, cfg.state_backup_file)
+    store.create_session("bot-a", 123, "sess_push", "push-session", "backend", "codex", branch_name="feature-1")
+    router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=DummyRunner(), bot_id="bot-a"))
+    router.git = FakeGitManager(is_git_repo=True, current_branch="feature-1")
+    router.runtime.git = router.git
+    prompt_bot = _run_push_command(router)
+    callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][0].callback_data
+    store.set_active_session_branch("bot-a", 123, "feature-2")
+    router.git._current_branch = "feature-2"
+
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text, parse_mode=None):
+        edited.append(text)
+
+    update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_push_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+    assert "expired" in edited[-1].lower()
+    assert router.git.push_calls == []
+
+
 def test_pull_refreshes_active_session_branch(tmp_path: Path):
     backend = (tmp_path / "backend").resolve()
     backend.mkdir()
@@ -4886,8 +6689,8 @@ def test_pull_refreshes_active_session_branch(tmp_path: Path):
     assert router.git.refresh_calls == []
     assert bot.messages[-1][1] == "Pull branch `feature-1` from `origin`?"
     buttons = bot.messages[-1][3].inline_keyboard[0]
-    assert buttons[0].callback_data == "pull:confirm"
-    assert buttons[1].callback_data == "pull:cancel"
+    assert buttons[0].callback_data.startswith("pull:confirm:")
+    assert buttons[1].callback_data.startswith("pull:cancel:")
     assert buttons[0].text == "Confirm pull"
     assert buttons[1].text == "Cancel"
 
@@ -4919,7 +6722,7 @@ def test_pull_confirmation_refreshes_default_and_session_branch(tmp_path: Path):
     router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
     router.git = FakeGitManager(
         is_git_repo=True,
-        current_branch="main",
+        current_branch="feature-1",
         default_branch="develop",
         checkout_result=SimpleNamespace(success=True, message="Checked out branch"),
     )
@@ -4928,12 +6731,22 @@ def test_pull_confirmation_refreshes_default_and_session_branch(tmp_path: Path):
         warnings=("git fetch origin failed.",),
     )
     router.runtime.git = router.git
+    lock_states = []
+    original_refresh_current_branch = router.git.refresh_current_branch
+
+    def refresh_current_branch(project_path):
+        lock_states.append(router._workspace_locks["backend"].locked())
+        return original_refresh_current_branch(project_path)
+
+    router.git.refresh_current_branch = refresh_current_branch
+    prompt_bot = _run_pull_command(router)
+    confirm_callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][0].callback_data
 
     edited = []
     update = SimpleNamespace(
         effective_chat=SimpleNamespace(id=123, type="private"),
         callback_query=SimpleNamespace(
-            data="pull:confirm",
+            data=confirm_callback_data,
             answer=None,
             edit_message_text=None,
         ),
@@ -4957,11 +6770,11 @@ async def fake_edit(text, parse_mode=None):
         (backend, "develop"),
         (backend, "feature-1"),
     ]
-    assert "Updated branch 'develop' from origin." in bot.messages[-1][1]
-    assert "Updated branch 'feature-1' from origin." in bot.messages[-1][1]
+    assert "Updated branch" not in bot.messages[-1][1]
     assert "Refresh warnings:" in bot.messages[-1][1]
     assert "- git fetch origin failed." in bot.messages[-1][1]
     assert router.git.push_calls == []
+    assert lock_states == [True, True]
 
 
 def test_pull_confirmation_cancel_does_not_refresh(tmp_path: Path):
@@ -4974,11 +6787,13 @@ def test_pull_confirmation_cancel_does_not_refresh(tmp_path: Path):
     router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
     router.git = FakeGitManager(is_git_repo=True, current_branch="feature-1")
     router.runtime.git = router.git
+    prompt_bot = _run_pull_command(router)
+    cancel_callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][1].callback_data
     edited = []
     update = SimpleNamespace(
         effective_chat=SimpleNamespace(id=123, type="private"),
         callback_query=SimpleNamespace(
-            data="pull:cancel",
+            data=cancel_callback_data,
             answer=None,
             edit_message_text=None,
         ),
@@ -4995,10 +6810,430 @@ async def fake_edit(text):
     update.callback_query.answer = fake_answer
     update.callback_query.edit_message_text = fake_edit
 
-    asyncio.run(router.handle_pull_callback(update, context))
+    asyncio.run(router.handle_pull_callback(update, context))
+
+    assert edited == ["Pull cancelled."]
+    assert router.git.refresh_calls == []
+
+
+def test_pull_confirmation_expires_when_active_session_branch_changes(tmp_path: Path):
+    backend = (tmp_path / "backend").resolve()
+    backend.mkdir()
+    cfg = make_config(tmp_path)
+    store = SessionStore(cfg.state_file, cfg.state_backup_file)
+    store.create_session("bot-a", 123, "sess_pull", "pull-session", "backend", "codex", branch_name="feature-1")
+    router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=DummyRunner(), bot_id="bot-a"))
+    router.git = FakeGitManager(is_git_repo=True, current_branch="feature-1")
+    router.runtime.git = router.git
+    prompt_bot = _run_pull_command(router)
+    callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][0].callback_data
+    store.set_active_session_branch("bot-a", 123, "feature-2")
+    router.git._current_branch = "feature-2"
+
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text, parse_mode=None):
+        edited.append(text)
+
+    update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_pull_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+    assert "expired" in edited[-1].lower()
+    assert router.git.refresh_calls == []
+
+
+def test_log_shows_top_five_commits(tmp_path: Path):
+    router, backend = _make_commit_router(tmp_path, git_manager=FakeGitManager(is_git_repo=True))
+
+    bot = _run_log_command(router)
+
+    assert router.git.git_commands == [(backend, ["log", "-5", "--oneline"])]
+    assert f"${shlex.join(['git', 'log', '-5', '--oneline'])}" in bot.messages[-1][1]
+
+
+def test_reset_selects_four_targets_and_confirms_before_reset(tmp_path: Path):
+    router, backend = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(
+            is_git_repo=True,
+            current_branch="feature-1",
+            default_branch="main",
+            checkout_result=SimpleNamespace(success=True, message="Checked out branch"),
+        ),
+    )
+    router.runtime.git = router.git
+
+    bot = _run_reset_command(router)
+
+    keyboard = bot.messages[-1][3].inline_keyboard
+    assert [[button.text for button in row] for row in keyboard] == [
+        ["local/main"],
+        ["origin/main"],
+        ["local/feature-1"],
+        ["origin/feature-1"],
+    ]
+
+    edited = []
+    select_callback_data = keyboard[1][0].callback_data
+    select_update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=select_callback_data, answer=None, edit_message_text=None),
+    )
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text, parse_mode=None, reply_markup=None):
+        edited.append((text, parse_mode, reply_markup))
+
+    select_update.callback_query.answer = fake_answer
+    select_update.callback_query.edit_message_text = fake_edit
+    asyncio.run(router.handle_reset_callback(select_update, SimpleNamespace(args=[], bot=bot)))
+
+    assert edited[-1][0] == "Reset the current branch with `git reset --hard origin/main`?"
+    confirm_callback_data = edited[-1][2].inline_keyboard[0][0].callback_data
+    assert confirm_callback_data.startswith("reset:confirm:")
+    assert router.git.git_commands == []
+
+    confirm_update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=confirm_callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_reset_callback(confirm_update, SimpleNamespace(args=[], bot=bot)))
+
+    assert router.git.refresh_calls == [(backend, "main")]
+    assert router.git.git_commands == [(backend, ["reset", "--hard", "origin/main"])]
+    assert router.git.current_branch(backend) == "feature-1"
+
+
+def test_reset_confirmation_is_bound_to_its_selected_target(tmp_path: Path):
+    router, backend = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(
+            is_git_repo=True,
+            current_branch="feature-1",
+            default_branch="main",
+            checkout_result=SimpleNamespace(success=True, message="Checked out branch"),
+        ),
+    )
+    router.runtime.git = router.git
+    bot = FakeBot()
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text, parse_mode=None, reply_markup=None):
+        edited.append((text, parse_mode, reply_markup))
+
+    async def select(callback_data: str) -> str:
+        update = SimpleNamespace(
+            effective_chat=SimpleNamespace(id=123, type="private"),
+            callback_query=SimpleNamespace(data=callback_data, answer=fake_answer, edit_message_text=fake_edit),
+        )
+        await router.handle_reset_callback(update, SimpleNamespace(args=[], bot=bot))
+        return edited[-1][2].inline_keyboard[0][0].callback_data
+
+    first_keyboard = _run_reset_command(router).messages[-1][3].inline_keyboard
+    first_confirmation = asyncio.run(select(first_keyboard[1][0].callback_data))
+    second_keyboard = _run_reset_command(router).messages[-1][3].inline_keyboard
+    second_confirmation = asyncio.run(select(second_keyboard[2][0].callback_data))
+
+    assert first_confirmation != second_confirmation
+
+    confirm_update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=first_confirmation, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_reset_callback(confirm_update, SimpleNamespace(args=[], bot=bot)))
+
+    assert router.git.git_commands == [(backend, ["reset", "--hard", "origin/main"])]
+
+
+def test_reset_selection_expires_when_active_session_branch_changes(tmp_path: Path):
+    router, _ = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(is_git_repo=True, current_branch="feature-1", default_branch="main"),
+    )
+    router.runtime.git = router.git
+    callback_data = _run_reset_command(router).messages[-1][3].inline_keyboard[0][0].callback_data
+    router.deps.store.set_active_session_branch("bot-a", 123, "feature-2")
+    router.git._current_branch = "feature-2"
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text, parse_mode=None, reply_markup=None):
+        edited.append(text)
+
+    update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_reset_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+    assert "expired" in edited[-1].lower()
+    assert router._reset_selections() == {}
+
+
+def test_reset_confirmation_survives_retryable_branch_discrepancy(tmp_path: Path):
+    router, backend = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(is_git_repo=True, current_branch="feature-1", default_branch="main"),
+    )
+    router.deps.store.set_active_session_branch("bot-a", 123, "feature-1")
+    router.runtime.git = router.git
+    bot = FakeBot()
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text, parse_mode=None, reply_markup=None):
+        edited.append((text, parse_mode, reply_markup))
+
+    select_callback = _run_reset_command(router).messages[-1][3].inline_keyboard[2][0].callback_data
+    select_update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=select_callback, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_reset_callback(select_update, SimpleNamespace(args=[], bot=bot)))
+    confirm_callback = edited[-1][2].inline_keyboard[0][0].callback_data
+    confirm_update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=confirm_callback, answer=fake_answer, edit_message_text=fake_edit),
+    )
+
+    router.git._current_branch = "main"
+    asyncio.run(router.handle_reset_callback(confirm_update, SimpleNamespace(args=[], bot=bot)))
+
+    assert "Branch discrepancy detected" in bot.messages[-1][1]
+    assert router.git.git_commands == []
+
+    router.git._current_branch = "feature-1"
+    asyncio.run(router.handle_reset_callback(confirm_update, SimpleNamespace(args=[], bot=bot)))
+
+    assert router.git.git_commands == [(backend, ["reset", "--hard", "feature-1"])]
+
+
+def test_reset_confirmation_stops_when_project_becomes_busy(tmp_path: Path):
+    router, backend = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(is_git_repo=True, current_branch="feature-1", default_branch="main"),
+    )
+    router.runtime.git = router.git
+    bot = FakeBot()
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text, parse_mode=None, reply_markup=None):
+        edited.append((text, parse_mode, reply_markup))
+
+    select_callback_data = _run_reset_command(router).messages[-1][3].inline_keyboard[2][0].callback_data
+    select_update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=select_callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_reset_callback(select_update, SimpleNamespace(args=[], bot=bot)))
+    confirm_callback_data = edited[-1][2].inline_keyboard[0][0].callback_data
+    router._is_project_busy = lambda _chat_id: True
+
+    confirm_update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        message=None,
+        callback_query=SimpleNamespace(data=confirm_callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_reset_callback(confirm_update, SimpleNamespace(args=[], bot=bot)))
+
+    assert router.git.git_commands == []
+    assert f"An agent is currently running on project '{backend.name}'." in bot.messages[-1][1]
+
+
+def test_reset_restores_session_branch_when_origin_pull_fails(tmp_path: Path):
+    router, backend = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(
+            is_git_repo=True,
+            current_branch="feature-1",
+            default_branch="main",
+            checkout_result=SimpleNamespace(success=True, message="Checked out branch"),
+        ),
+    )
+    router.git.refresh_result = SimpleNamespace(success=True, warnings=("git pull failed for branch: main",))
+    router.runtime.git = router.git
+    bot = FakeBot()
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text, parse_mode=None, reply_markup=None):
+        edited.append((text, parse_mode, reply_markup))
+
+    select_callback_data = _run_reset_command(router).messages[-1][3].inline_keyboard[1][0].callback_data
+    select_update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=select_callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_reset_callback(select_update, SimpleNamespace(args=[], bot=bot)))
+    confirm_callback_data = edited[-1][2].inline_keyboard[0][0].callback_data
+    confirm_update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=confirm_callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+
+    asyncio.run(router.handle_reset_callback(confirm_update, SimpleNamespace(args=[], bot=bot)))
+
+    assert router.git.current_branch(backend) == "feature-1"
+    assert router.git.git_commands == []
+    assert "git pull failed for branch: main" in bot.messages[-1][1]
+
+
+@pytest.mark.parametrize("command_name", ["commit", "diff", "log", "pull", "push", "reset"])
+def test_git_commands_warn_and_stop_on_session_branch_discrepancy(tmp_path: Path, command_name: str):
+    router, _ = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(is_git_repo=True, current_branch="main", default_branch="main"),
+    )
+    router.deps.store.set_active_session_branch("bot-a", 123, "feature-1")
+    router.runtime.git = router.git
+
+    if command_name == "commit":
+        bot = _run_commit_command(router, "/commit git status")
+    elif command_name == "diff":
+        bot = _run_diff_command(router)
+    elif command_name == "log":
+        bot = _run_log_command(router)
+    elif command_name == "pull":
+        bot = _run_pull_command(router)
+    elif command_name == "push":
+        bot = _run_push_command(router)
+    else:
+        bot = _run_reset_command(router)
+
+    assert "Branch discrepancy detected" in bot.messages[-1][1]
+    assert "main" in bot.messages[-1][1]
+    assert "git status" not in bot.messages[-1][1]
+    assert router.git.git_commands == []
+    assert router.git.safe_git_commands == []
+    assert router.git.push_calls == []
+    assert router.git.refresh_calls == []
+    keyboard = bot.messages[-1][3]
+    assert [button.callback_data for row in keyboard.inline_keyboard for button in row] == [
+        "gitbranchdiscrepancy:stored",
+        "gitbranchdiscrepancy:current",
+    ]
+
+
+def test_git_branch_discrepancy_stored_choice_switches_branch_like_branch_command(tmp_path: Path):
+    router, _ = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(
+            is_git_repo=True,
+            current_branch="main",
+            default_branch="main",
+            local_branches=["main", "feature-1"],
+            prepare_from_source_result=SimpleNamespace(
+                success=True,
+                message="Switched to existing local branch 'feature-1'.",
+                current_branch="feature-1",
+            ),
+        ),
+    )
+    router.deps.store.set_active_session_branch("bot-a", 123, "feature-1")
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text, reply_markup=None):
+        edited.append((text, reply_markup))
+
+    query = SimpleNamespace(
+        data="gitbranchdiscrepancy:stored",
+        answer=fake_answer,
+        edit_message_text=fake_edit,
+    )
+    update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=query,
+        message=None,
+    )
+
+    asyncio.run(router.handle_git_branch_discrepancy_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+    assert router.git.prepare_from_source_calls[-1][1:] == ("local", "feature-1", "feature-1")
+    state = router.deps.store.get_chat_state("bot-a", 123)
+    assert state["current_branch"] == "feature-1"
+    assert state["sessions"]["sess_commit"]["branch_name"] == "feature-1"
+    assert "Current branch: feature-1" in edited[-1][0]
+
+
+def test_git_command_warns_when_repository_has_detached_head(tmp_path: Path):
+    router, _ = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(is_git_repo=True, current_branch=None, default_branch="main"),
+    )
+    router.deps.store.set_active_session_branch("bot-a", 123, "feature-1")
+    router.runtime.git = router.git
+
+    bot = _run_log_command(router)
+
+    assert "Branch discrepancy detected" in bot.messages[-1][1]
+    assert "detached HEAD" in bot.messages[-1][1]
+    assert router.git.git_commands == []
+
+
+def test_reset_acknowledges_callback_and_holds_workspace_lock_during_reset(tmp_path: Path):
+    router, backend = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(is_git_repo=True, current_branch="feature-1", default_branch="main"),
+    )
+    router.runtime.git = router.git
+    bot = FakeBot()
+    edited = []
+    answers = []
+    lock_states = []
+
+    async def fake_answer():
+        answers.append(True)
+
+    async def fake_edit(text, parse_mode=None, reply_markup=None):
+        edited.append((text, parse_mode, reply_markup))
+
+    def run_git_command(project_path, args):
+        lock_states.append(router._workspace_locks["backend"].locked())
+        router.git.git_commands.append((project_path, args))
+        return SimpleNamespace(success=True, message="reset complete")
+
+    router.git.run_git_command = run_git_command
+    select_callback_data = _run_reset_command(router).messages[-1][3].inline_keyboard[2][0].callback_data
+    select_update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=select_callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_reset_callback(select_update, SimpleNamespace(args=[], bot=bot)))
+    confirm_callback_data = edited[-1][2].inline_keyboard[0][0].callback_data
+    confirm_update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=confirm_callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+
+    asyncio.run(router.handle_reset_callback(confirm_update, SimpleNamespace(args=[], bot=bot)))
 
-    assert edited == ["Pull cancelled."]
-    assert router.git.refresh_calls == []
+    assert len(answers) == 2
+    assert lock_states == [True]
+    assert router.git.git_commands == [(backend, ["reset", "--hard", "feature-1"])]
+    assert not router._workspace_locks["backend"].locked()
 
 
 def test_diff_lists_tracked_and_untracked_filenames(monkeypatch, tmp_path: Path):
@@ -5030,7 +7265,9 @@ def test_diff_lists_tracked_and_untracked_filenames(monkeypatch, tmp_path: Path)
     labels = [button.text for row in reply_markup.inline_keyboard for button in row]
     callback_data = [button.callback_data for row in reply_markup.inline_keyboard for button in row]
     assert labels == ["1. app.py"]
-    assert callback_data == ["diffshow:0"]
+    assert len(callback_data) == 1
+    assert callback_data[0].startswith("diffshow:")
+    assert callback_data[0].endswith(":0")
 
 
 def test_diff_callback_sends_selected_file_diff(monkeypatch, tmp_path: Path):
@@ -5055,11 +7292,13 @@ def test_diff_callback_sends_selected_file_diff(monkeypatch, tmp_path: Path):
         if include_cached
         else [],
     )
+    prompt_bot = _run_diff_command(router)
+    show_callback_data = prompt_bot.messages[-1][3].inline_keyboard[1][0].callback_data
 
     update = SimpleNamespace(
         effective_chat=SimpleNamespace(id=123, type="private"),
         callback_query=SimpleNamespace(
-            data="diffshow:1",
+            data=show_callback_data,
             answer=None,
         ),
     )
@@ -5078,6 +7317,102 @@ async def fake_answer():
     assert "new" in bot.messages[-1][1]
 
 
+def test_diff_callback_uses_the_file_snapshot_shown_to_the_user(monkeypatch, tmp_path: Path):
+    backend = (tmp_path / "backend").resolve()
+    backend.mkdir()
+    cfg = make_config(tmp_path)
+    store = SessionStore(cfg.state_file, cfg.state_backup_file)
+    store.create_session("bot-a", 123, "sess_diff", "diff-session", "backend", "codex", branch_name="feature-1")
+    router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=DummyRunner(), bot_id="bot-a"))
+    router.git = FakeGitManager(is_git_repo=True, current_branch="feature-1")
+    router.runtime.git = router.git
+    changed_files = ["src/first.py", "src/selected.py"]
+    monkeypatch.setattr(
+        "coding_agent_telegram.router.git_commands.split_changed_files",
+        lambda _project_path: (list(changed_files), []),
+    )
+    collected_files = []
+
+    def fake_collect(_project_path, files, *, against_ref=None, include_cached=False):
+        collected_files.extend(files)
+        return [SimpleNamespace(path=files[0], diff="--- a/file\n+++ b/file\n@@\n-old\n+new")]
+
+    monkeypatch.setattr("coding_agent_telegram.router.git_commands.collect_diffs", fake_collect)
+    prompt_bot = _run_diff_command(router)
+    callback_data = prompt_bot.messages[-1][3].inline_keyboard[1][0].callback_data
+    changed_files[:] = ["src/replacement.py"]
+
+    async def fake_answer():
+        return None
+
+    update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=callback_data, answer=fake_answer),
+    )
+    asyncio.run(router.handle_diff_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+    assert collected_files == ["src/selected.py"]
+
+
+def test_diff_snapshot_expires_when_same_session_switches_branch(monkeypatch, tmp_path: Path):
+    backend = (tmp_path / "backend").resolve()
+    backend.mkdir()
+    cfg = make_config(tmp_path)
+    store = SessionStore(cfg.state_file, cfg.state_backup_file)
+    store.create_session("bot-a", 123, "sess_diff", "diff-session", "backend", "codex", branch_name="feature-1")
+    router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=DummyRunner(), bot_id="bot-a"))
+    router.git = FakeGitManager(is_git_repo=True, current_branch="feature-1")
+    router.runtime.git = router.git
+    monkeypatch.setattr(
+        "coding_agent_telegram.router.git_commands.split_changed_files",
+        lambda _project_path: (["src/app.py"], []),
+    )
+    callback_data = _run_diff_command(router).messages[-1][3].inline_keyboard[0][0].callback_data
+    store.set_active_session_branch("bot-a", 123, "feature-2")
+    router.git._current_branch = "feature-2"
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text, parse_mode=None, reply_markup=None):
+        edited.append(text)
+
+    update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_diff_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+    assert "expired" in edited[-1].lower()
+
+
+def test_diff_paginates_untracked_files_and_bounds_message_size(monkeypatch, tmp_path: Path):
+    backend = (tmp_path / "backend").resolve()
+    backend.mkdir()
+    cfg = make_config(tmp_path)
+    store = SessionStore(cfg.state_file, cfg.state_backup_file)
+    store.create_session("bot-a", 123, "sess_diff", "diff-session", "backend", "codex", branch_name="feature-1")
+    router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=DummyRunner(), bot_id="bot-a"))
+    router.git = FakeGitManager(is_git_repo=True, current_branch="feature-1")
+    router.runtime.git = router.git
+    untracked_files = [f"notes/{index}-{'x' * 240}.txt" for index in range(25)]
+    monkeypatch.setattr(
+        "coding_agent_telegram.router.git_commands.split_changed_files",
+        lambda _project_path: ([], untracked_files),
+    )
+
+    bot = _run_diff_command(router)
+
+    assert "Showing 1-10 of 25." in bot.messages[-1][1]
+    assert "notes/0-" in bot.messages[-1][1]
+    assert "notes/10-" not in bot.messages[-1][1]
+    assert len(bot.messages[-1][1]) < 4096
+    reply_markup = bot.messages[-1][3]
+    assert reply_markup is not None
+    assert reply_markup.inline_keyboard[-1][0].text == "Next"
+
+
 def test_diff_limits_buttons_to_ten_per_page(monkeypatch, tmp_path: Path):
     backend = (tmp_path / "backend").resolve()
     backend.mkdir()
@@ -5101,11 +7436,12 @@ def test_diff_limits_buttons_to_ten_per_page(monkeypatch, tmp_path: Path):
     rows = reply_markup.inline_keyboard
     file_buttons = [button for row in rows[:-1] for button in row]
     nav_buttons = rows[-1]
+    assert [len(row) for row in rows[:-1]] == [1] * 10
     assert len(file_buttons) == 10
     assert [button.text for button in file_buttons[:3]] == ["1. file_1.py", "2. file_2.py", "3. file_3.py"]
-    assert [button.callback_data for button in file_buttons[-2:]] == ["diffshow:8", "diffshow:9"]
+    assert [button.callback_data.rsplit(":", 1)[1] for button in file_buttons[-2:]] == ["8", "9"]
     assert [button.text for button in nav_buttons] == ["Next"]
-    assert [button.callback_data for button in nav_buttons] == ["diffpage:1"]
+    assert [button.callback_data.rsplit(":", 1)[1] for button in nav_buttons] == ["1"]
     assert "Showing 1-10 of 12." in bot.messages[-1][1]
     assert "10. src/file_10.py" in bot.messages[-1][1]
     assert "11. src/file_11.py" not in bot.messages[-1][1]
@@ -5126,12 +7462,14 @@ def test_diff_pagination_edits_message_for_next_page(monkeypatch, tmp_path: Path
         "coding_agent_telegram.router.git_commands.split_changed_files",
         lambda _project_path: (tracked_files, []),
     )
+    prompt_bot = _run_diff_command(router)
+    next_callback_data = prompt_bot.messages[-1][3].inline_keyboard[-1][0].callback_data
 
     edited = []
     update = SimpleNamespace(
         effective_chat=SimpleNamespace(id=123, type="private"),
         callback_query=SimpleNamespace(
-            data="diffpage:1",
+            data=next_callback_data,
             answer=None,
             edit_message_text=None,
         ),
@@ -5158,7 +7496,8 @@ async def fake_edit(text, parse_mode=None, reply_markup=None):
     labels = [button.text for row in reply_markup.inline_keyboard for button in row]
     callback_data = [button.callback_data for row in reply_markup.inline_keyboard for button in row]
     assert "Prev" in labels
-    assert callback_data[-1] == "diffpage:0"
+    assert callback_data[-1].startswith("diffpage:")
+    assert callback_data[-1].endswith(":0")
 
 
 def test_diff_sends_usage_when_extra_args_provided(tmp_path: Path):
@@ -5591,7 +7930,16 @@ def test_handle_provider_sends_keyboard_when_no_args(tmp_path: Path):
 
     # Should have sent a message with a reply_markup keyboard
     assert len(bot.messages) >= 1
-    assert bot.messages[-1][3] is not None  # reply_markup present
+    reply_markup = bot.messages[-1][3]
+    assert reply_markup is not None
+    # Provider labels include availability/current-state text, so keep every
+    # provider on its own row to avoid Telegram truncating the labels.
+    assert [len(row) for row in reply_markup.inline_keyboard] == [1, 1, 1]
+    assert [button.callback_data for row in reply_markup.inline_keyboard for button in row] == [
+        "provider:set:codex",
+        "provider:set:copilot",
+        "provider:set:claude",
+    ]
 
 
 def test_handle_provider_localizes_prompt_text(tmp_path: Path):
@@ -5800,10 +8148,11 @@ def test_commit_no_args_shows_generate_prompt(monkeypatch, tmp_path: Path):
     assert reply_markup is not None
     buttons = reply_markup.inline_keyboard[0]
     assert buttons[0].text == "Generate command"
-    assert buttons[0].callback_data == "commitgen:confirm"
+    assert buttons[0].callback_data.startswith("commitgen:confirm:")
     assert buttons[0].api_kwargs == {"style": "primary"}
     assert buttons[1].text == "Cancel"
-    assert buttons[1].callback_data == "commitgen:cancel"
+    assert buttons[1].callback_data.startswith("commitgen:cancel:")
+    assert buttons[0].callback_data.rsplit(":", 1)[1] == buttons[1].callback_data.rsplit(":", 1)[1]
     assert buttons[1].api_kwargs == {"style": "danger"}
 
 
@@ -5819,12 +8168,14 @@ async def fake_run_active_session(_update, _context, *, user_message, image_path
         )
 
     router.runtime.run_active_session = fake_run_active_session
+    prompt_bot = _run_commit_command(router, "/commit")
+    generate_callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][0].callback_data
 
     edited = []
     update = SimpleNamespace(
         effective_chat=SimpleNamespace(id=123, type="private"),
         callback_query=SimpleNamespace(
-            data="commitgen:confirm",
+            data=generate_callback_data,
             answer=None,
             edit_message_text=None,
         ),
@@ -5844,20 +8195,23 @@ async def fake_edit(text):
     asyncio.run(router.handle_commit_generate_callback(update, context))
 
     assert edited == ["Generated commit command below."]
-    assert router._generated_commit_commands()[123] == {
+    command_token = bot.messages[-1][3].inline_keyboard[0][0].callback_data.rsplit(":", 1)[1]
+    assert router._generated_commit_commands()[command_token] == {
+        "chat_id": "123",
         "command": 'git add src/app.py && git commit -m "Update app"',
         "session_id": "sess_commit",
         "project_folder": "backend",
+        "branch_name": "",
     }
     assert bot.messages[-1][1] == "Do you want to execute the commit?"
     reply_markup = bot.messages[-1][3]
     assert reply_markup is not None
     buttons = reply_markup.inline_keyboard[0]
     assert buttons[0].text == "Execute commit"
-    assert buttons[0].callback_data == "commitexec:confirm"
+    assert buttons[0].callback_data == f"commitexec:confirm:{command_token}"
     assert buttons[0].api_kwargs == {"style": "primary"}
     assert buttons[1].text == "Cancel"
-    assert buttons[1].callback_data == "commitexec:cancel"
+    assert buttons[1].callback_data == f"commitexec:cancel:{command_token}"
     assert buttons[1].api_kwargs == {"style": "danger"}
 
 
@@ -5885,17 +8239,20 @@ def test_commit_execute_callback_runs_generated_commit_command(monkeypatch, tmp_
             ],
         ),
     )
-    router._generated_commit_commands()[123] = {
+    token = "0123456789ab"
+    router._generated_commit_commands()[token] = {
+        "chat_id": "123",
         "command": 'git add src/app.py && git commit -m "Update app"',
         "session_id": "sess_commit",
         "project_folder": "backend",
+        "branch_name": "",
     }
 
     edited = []
     update = SimpleNamespace(
         effective_chat=SimpleNamespace(id=123, type="private"),
         callback_query=SimpleNamespace(
-            data="commitexec:confirm",
+            data=f"commitexec:confirm:{token}",
             answer=None,
             edit_message_text=None,
         ),
@@ -5927,17 +8284,20 @@ def test_commit_execute_callback_rejects_when_active_session_changes(tmp_path: P
     router, _ = _make_commit_router(tmp_path, git_manager=FakeGitManager(is_git_repo=True))
     (tmp_path / "frontend").mkdir()
     router.deps.store.create_session("bot-a", 123, "sess_other", "other-session", "frontend", "codex")
-    router._generated_commit_commands()[123] = {
+    token = "0123456789ab"
+    router._generated_commit_commands()[token] = {
+        "chat_id": "123",
         "command": 'git add src/app.py && git commit -m "Update app"',
         "session_id": "sess_commit",
         "project_folder": "backend",
+        "branch_name": "",
     }
 
     edited = []
     update = SimpleNamespace(
         effective_chat=SimpleNamespace(id=123, type="private"),
         callback_query=SimpleNamespace(
-            data="commitexec:confirm",
+            data=f"commitexec:confirm:{token}",
             answer=None,
             edit_message_text=None,
         ),
@@ -5958,7 +8318,114 @@ async def fake_edit(text):
 
     assert edited == ["The active session or project changed. Please generate the commit command again."]
     assert router.git.safe_git_commands == []
-    assert 123 not in router._generated_commit_commands()
+    assert token not in router._generated_commit_commands()
+
+
+def test_commit_generation_prompt_expires_when_branch_changes(tmp_path: Path):
+    router, _ = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(is_git_repo=True, current_branch="feature-1"),
+    )
+    router.deps.store.set_active_session_branch("bot-a", 123, "feature-1")
+    prompt_bot = _run_commit_command(router, "/commit")
+    callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][0].callback_data
+    router.deps.store.set_active_session_branch("bot-a", 123, "feature-2")
+    router.git._current_branch = "feature-2"
+    run_calls = []
+
+    async def fake_run_active_session(*args, **kwargs):
+        run_calls.append((args, kwargs))
+        return None
+
+    router.runtime.run_active_session = fake_run_active_session
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text):
+        edited.append(text)
+
+    update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_commit_generate_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+    assert edited == ["The active session or project changed. Please generate the commit command again."]
+    assert run_calls == []
+
+
+def test_commit_execute_cancel_consumes_only_its_token(tmp_path: Path):
+    router, _ = _make_commit_router(tmp_path, git_manager=FakeGitManager(is_git_repo=True))
+    cancelled_token = "0123456789ab"
+    other_token = "abcdef012345"
+    payload = {
+        "chat_id": "123",
+        "command": 'git add src/app.py && git commit -m "Update app"',
+        "session_id": "sess_commit",
+        "project_folder": "backend",
+        "branch_name": "",
+    }
+    router._generated_commit_commands()[cancelled_token] = dict(payload)
+    router._generated_commit_commands()[other_token] = dict(payload)
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text):
+        edited.append(text)
+
+    update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(
+            data=f"commitexec:cancel:{cancelled_token}",
+            answer=fake_answer,
+            edit_message_text=fake_edit,
+        ),
+    )
+    asyncio.run(router.handle_commit_execute_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+    assert cancelled_token not in router._generated_commit_commands()
+    assert other_token in router._generated_commit_commands()
+    assert edited == ["Commit command generation cancelled."]
+
+
+def test_commit_execute_rejects_when_branch_changes(tmp_path: Path):
+    router, _ = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(is_git_repo=True, current_branch="feature-2"),
+    )
+    router.deps.store.set_active_session_branch("bot-a", 123, "feature-2")
+    token = "0123456789ab"
+    router._generated_commit_commands()[token] = {
+        "chat_id": "123",
+        "command": 'git add src/app.py && git commit -m "Update app"',
+        "session_id": "sess_commit",
+        "project_folder": "backend",
+        "branch_name": "feature-1",
+    }
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text):
+        edited.append(text)
+
+    update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(
+            data=f"commitexec:confirm:{token}",
+            answer=fake_answer,
+            edit_message_text=fake_edit,
+        ),
+    )
+    asyncio.run(router.handle_commit_execute_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+    assert edited == ["The active session or project changed. Please generate the commit command again."]
+    assert router.git.safe_git_commands == []
 
 
 def test_commit_no_valid_git_commands_found(tmp_path: Path):
@@ -6059,7 +8526,7 @@ async def fake_edit(text, parse_mode=None):
     assert bot.messages == []
 
 
-def test_push_callback_empty_branch_warns(tmp_path: Path):
+def test_push_empty_branch_warns(tmp_path: Path):
     backend = (tmp_path / "backend").resolve()
     backend.mkdir()
     runner = DummyRunner()
@@ -6071,33 +8538,12 @@ def test_push_callback_empty_branch_warns(tmp_path: Path):
     router.git = FakeGitManager(is_git_repo=True, current_branch=None)
     router.runtime.git = router.git
 
-    edited = []
-    update = SimpleNamespace(
-        effective_chat=SimpleNamespace(id=123, type="private"),
-        callback_query=SimpleNamespace(
-            data="push:confirm",
-            answer=None,
-            edit_message_text=None,
-        ),
-    )
-    bot = FakeBot()
-    context = SimpleNamespace(args=[], bot=bot)
-
-    async def fake_answer():
-        return None
-
-    async def fake_edit(text, parse_mode=None):
-        edited.append(text)
-
-    update.callback_query.answer = fake_answer
-    update.callback_query.edit_message_text = fake_edit
-
-    asyncio.run(router.handle_push_callback(update, context))
+    bot = _run_push_command(router)
 
-    assert any("Could not determine the branch" in e for e in edited)
+    assert "Could not determine the branch" in bot.messages[-1][1]
 
 
-def test_push_callback_checkout_failure_sends_edit(tmp_path: Path):
+def test_push_warns_instead_of_checking_out_session_branch(tmp_path: Path):
     backend = (tmp_path / "backend").resolve()
     backend.mkdir()
     runner = DummyRunner()
@@ -6105,7 +8551,7 @@ def test_push_callback_checkout_failure_sends_edit(tmp_path: Path):
     store = SessionStore(cfg.state_file, cfg.state_backup_file)
     store.create_session("bot-a", 123, "sess_push", "push-session", "backend", "codex", branch_name="feature-x")
     router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
-    # current_branch differs from session branch so checkout is attempted
+    # A discrepancy is reported instead of silently checking out another branch.
     router.git = FakeGitManager(
         is_git_repo=True,
         current_branch="main",
@@ -6113,30 +8559,11 @@ def test_push_callback_checkout_failure_sends_edit(tmp_path: Path):
     )
     router.runtime.git = router.git
 
-    edited = []
-    update = SimpleNamespace(
-        effective_chat=SimpleNamespace(id=123, type="private"),
-        callback_query=SimpleNamespace(
-            data="push:confirm",
-            answer=None,
-            edit_message_text=None,
-        ),
-    )
-    bot = FakeBot()
-    context = SimpleNamespace(args=[], bot=bot)
-
-    async def fake_answer():
-        return None
-
-    async def fake_edit(text, parse_mode=None):
-        edited.append(text)
-
-    update.callback_query.answer = fake_answer
-    update.callback_query.edit_message_text = fake_edit
-
-    asyncio.run(router.handle_push_callback(update, context))
+    bot = _run_push_command(router)
 
-    assert any("Push cancelled" in e for e in edited)
+    assert "Branch discrepancy detected" in bot.messages[-1][1]
+    assert "feature-x" in bot.messages[-1][1]
+    assert "main" in bot.messages[-1][1]
     assert router.git.push_calls == []
 
 
@@ -8004,6 +10431,8 @@ def create_session(
         *,
         skip_git_repo_check=False,
         image_paths=(),
+        priming_only=False,
+        model=None,
         on_stall=None,
         on_progress=None,
     ):
@@ -8919,6 +11348,32 @@ def test_prompt_queue_batch_decision_early_exit_no_send_message(tmp_path: Path):
     asyncio.run(router._prompt_queue_batch_decision(123, context, msgs))  # should not raise
 
 
+def test_prompt_queue_batch_decision_uses_one_button_per_row(tmp_path: Path):
+    from coding_agent_telegram.router.queue_processing import QueuedQuestion
+
+    runner = DummyRunner()
+    cfg = make_config(tmp_path)
+    store = SessionStore(cfg.state_file, cfg.state_backup_file)
+    router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+    bot = FakeBot()
+
+    asyncio.run(
+        router._prompt_queue_batch_decision(
+            123,
+            SimpleNamespace(bot=bot),
+            [QueuedQuestion(text="q1"), QueuedQuestion(text="q2")],
+        )
+    )
+
+    keyboard = bot.messages[-1][3]
+    assert [len(row) for row in keyboard.inline_keyboard] == [1, 1, 1]
+    assert [button.callback_data for row in keyboard.inline_keyboard for button in row] == [
+        "queuebatch:group",
+        "queuebatch:single",
+        "queuebatch:cancel",
+    ]
+
+
 def test_clear_chat_message_queue_removes_processing_and_pending(tmp_path: Path):
     runner = DummyRunner()
     cfg = make_config(tmp_path)
@@ -10007,3 +12462,108 @@ async def always_false(*a, **kw):
     bot = FakeBot()
     context = SimpleNamespace(args=[], bot=bot)
     asyncio.run(router._drain_chat_message_queue(123, context))  # should return without error
+
+
+def test_status_command_reports_each_provider_usage(tmp_path: Path, monkeypatch):
+    from coding_agent_telegram.usage_status import ProviderUsage, RateWindow
+
+    runner = DummyRunner()
+    cfg = make_config(tmp_path)
+    store = SessionStore(cfg.state_file, cfg.state_backup_file)
+    router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+    claude_calls = []
+
+    def fake_get_claude_usage():
+        claude_calls.append(True)
+        return ProviderUsage(
+            provider="claude",
+            available=True,
+            five_hour=RateWindow(used_percent=53.0, resets_at=int(time.time()) + 3600),
+            weekly=RateWindow(used_percent=5.0, resets_at=int(time.time()) + 86400),
+        )
+
+    monkeypatch.setattr(
+        "coding_agent_telegram.router.session_status_commands.get_claude_usage",
+        fake_get_claude_usage,
+    )
+    monkeypatch.setattr(
+        "coding_agent_telegram.router.session_status_commands.fetch_codex_usage",
+        lambda codex_bin: ProviderUsage(
+            provider="codex",
+            available=True,
+            five_hour=RateWindow(used_percent=0.0, resets_at=int(time.time()) + 3600),
+            weekly=RateWindow(used_percent=23.0, resets_at=int(time.time()) + 86400),
+            plan="plus",
+        ),
+    )
+
+    update = make_update()
+    bot = FakeBot()
+    context = SimpleNamespace(args=[], bot=bot)
+
+    asyncio.run(router.handle_status(update, context))
+
+    assert claude_calls == [True]
+
+    text = bot.messages[-1][1]
+    assert "Claude" in text
+    assert "53%" in text
+    assert "5%" in text
+    assert "Codex (plus)" in text
+    assert "23%" in text
+    assert "Copilot" in text
+    assert "Not available" in text
+
+
+def test_status_command_shows_na_note_for_expired_claude_window(tmp_path: Path, monkeypatch):
+    from coding_agent_telegram.usage_status import CLAUDE_WINDOW_EXPIRED_NOTE, ProviderUsage, RateWindow
+
+    runner = DummyRunner()
+    cfg = make_config(tmp_path)
+    store = SessionStore(cfg.state_file, cfg.state_backup_file)
+    router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+    monkeypatch.setattr(
+        "coding_agent_telegram.router.session_status_commands.get_claude_usage",
+        lambda: ProviderUsage(
+            provider="claude",
+            available=True,
+            five_hour=None,
+            five_hour_note=CLAUDE_WINDOW_EXPIRED_NOTE,
+            weekly=RateWindow(used_percent=10.0, resets_at=int(time.time()) + 86400),
+            observed_at=time.time() - 3600,
+        ),
+    )
+    monkeypatch.setattr(
+        "coding_agent_telegram.router.session_status_commands.fetch_codex_usage",
+        lambda codex_bin: ProviderUsage(provider="codex", available=True),
+    )
+
+    update = make_update()
+    bot = FakeBot()
+    context = SimpleNamespace(args=[], bot=bot)
+
+    asyncio.run(router.handle_status(update, context))
+
+    text = bot.messages[-1][1]
+    assert "N/A" in text
+    assert CLAUDE_WINDOW_EXPIRED_NOTE in text
+    assert "10%" in text
+    assert "last observed" in text
+
+
+def test_status_command_rejects_extra_args(tmp_path: Path):
+    runner = DummyRunner()
+    cfg = make_config(tmp_path)
+    store = SessionStore(cfg.state_file, cfg.state_backup_file)
+    router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+    update = make_update()
+    bot = FakeBot()
+    context = SimpleNamespace(args=["extra"], bot=bot)
+
+    asyncio.run(router.handle_status(update, context))
+
+    assert bot.messages[-1][1] == "Usage: /status"
+    assert not runner.create_calls
diff --git a/tests/test_config.py b/tests/test_config.py
index ce2e822..03ea5d2 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -4,6 +4,7 @@
 import pytest
 
 import coding_agent_telegram.config as config_module
+import coding_agent_telegram.models as models_module
 from coding_agent_telegram.config import (
     DEFAULT_MAX_TELEGRAM_MESSAGE_LENGTH,
     DEFAULT_OPENAI_WHISPER_MODEL,
@@ -35,6 +36,9 @@ def _isolate_env(monkeypatch, tmp_path):
         "CODEX_MODEL",
         "COPILOT_MODEL",
         "CLAUDE_MODEL",
+        "CODEX_MODEL_CHOICES",
+        "COPILOT_MODEL_CHOICES",
+        "CLAUDE_MODEL_CHOICES",
         "COPILOT_AUTOPILOT",
         "COPILOT_NO_ASK_USER",
         "COPILOT_ALLOW_ALL",
@@ -108,6 +112,9 @@ def test_load_config_required(monkeypatch, tmp_path):
     assert cfg.codex_model == ""
     assert cfg.copilot_model == ""
     assert cfg.claude_model == ""
+    assert cfg.codex_model_choices == models_module.DEFAULT_MODEL_CHOICES["codex"]
+    assert cfg.copilot_model_choices == models_module.DEFAULT_MODEL_CHOICES["copilot"]
+    assert cfg.claude_model_choices == models_module.DEFAULT_MODEL_CHOICES["claude"]
     assert cfg.copilot_autopilot is True
     assert cfg.copilot_no_ask_user is True
     assert cfg.copilot_allow_all is True
@@ -142,6 +149,47 @@ def test_load_config_accepts_claude_as_default_provider(monkeypatch, tmp_path):
     assert cfg.default_agent_provider == "claude"
 
 
+def test_load_config_model_choices_override(monkeypatch, tmp_path):
+    _isolate_env(monkeypatch, tmp_path)
+    monkeypatch.setenv("WORKSPACE_ROOT", "~/git")
+    monkeypatch.setenv("TELEGRAM_BOT_TOKENS", "token-a")
+    monkeypatch.setenv("ALLOWED_CHAT_IDS", "123")
+    monkeypatch.setenv("CODEX_MODEL_CHOICES", "o4-mini, gpt-5.4")
+
+    cfg = load_config()
+
+    assert cfg.codex_model_choices == ("o4-mini", "gpt-5.4")
+    # Unset entirely -> falls back to the bundled template defaults.
+    assert cfg.copilot_model_choices == models_module.DEFAULT_MODEL_CHOICES["copilot"]
+
+
+def test_load_config_model_choices_can_be_explicitly_emptied(monkeypatch, tmp_path):
+    _isolate_env(monkeypatch, tmp_path)
+    monkeypatch.setenv("WORKSPACE_ROOT", "~/git")
+    monkeypatch.setenv("TELEGRAM_BOT_TOKENS", "token-a")
+    monkeypatch.setenv("ALLOWED_CHAT_IDS", "123")
+    monkeypatch.setenv("CODEX_MODEL_CHOICES", "")
+
+    cfg = load_config()
+
+    # Explicitly set to empty (as opposed to unset) must be honored as "no curated
+    # choices", not silently fall back to the hardcoded default.
+    assert cfg.codex_model_choices == ()
+
+
+def test_model_choices_fall_back_when_template_is_missing(monkeypatch):
+    def missing_resources(_package):
+        raise FileNotFoundError
+
+    monkeypatch.setattr(models_module.resources, "files", missing_resources)
+
+    assert models_module._load_default_model_choices() == {
+        "codex": (),
+        "copilot": (),
+        "claude": ("sonnet", "opus", "fable", "haiku"),
+    }
+
+
 def test_load_config_rejects_unknown_default_provider(monkeypatch, tmp_path):
     _isolate_env(monkeypatch, tmp_path)
     monkeypatch.setenv("WORKSPACE_ROOT", "~/git")
diff --git a/tests/test_diff_chunking.py b/tests/test_diff_chunking.py
index 36099ea..063de33 100644
--- a/tests/test_diff_chunking.py
+++ b/tests/test_diff_chunking.py
@@ -56,15 +56,21 @@ def test_build_summary_includes_branch_next_to_project():
 
 
 def test_parse_status_paths_includes_renames_and_untracked():
-    output = " M src/app.py\n?? src/new.py\nR  old.py -> new.py\n"
+    output = " M src/app.py\0?? src/new.py\0R  new.py\0old.py\0"
     assert _parse_status_paths(output) == ["src/app.py", "src/new.py", "new.py"]
 
 
+def test_parse_status_paths_preserves_unquoted_special_filenames_from_z_mode():
+    output = " M café file.py\0?? trailing-space \0"
+
+    assert _parse_status_paths(output) == ["café file.py", "trailing-space "]
+
+
 def test_split_changed_files_separates_tracked_and_untracked(monkeypatch, tmp_path: Path):
     monkeypatch.setattr(
         diff_utils_module,
         "_git",
-        lambda _project_path, _args: " M src/app.py\n?? src/new.py\nR  old.py -> new.py\n",
+        lambda _project_path, _args: " M src/app.py\0?? src/new.py\0R  new.py\0old.py\0",
     )
 
     tracked, untracked = split_changed_files(tmp_path)
diff --git a/tests/test_env_file_helpers.py b/tests/test_env_file_helpers.py
new file mode 100644
index 0000000..8bbb6ed
--- /dev/null
+++ b/tests/test_env_file_helpers.py
@@ -0,0 +1,67 @@
+from coding_agent_telegram.config import read_env_value, remove_env_value, upsert_env_value
+
+
+def test_upsert_env_value_appends_when_missing(tmp_path):
+    env_path = tmp_path / ".env"
+    env_path.write_text("WORKSPACE_ROOT=~/git\n", encoding="utf-8")
+
+    upsert_env_value(env_path, "CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-abc", comments=["# a comment"])
+
+    text = env_path.read_text(encoding="utf-8")
+    assert "WORKSPACE_ROOT=~/git" in text
+    assert "CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-abc" in text
+    assert "# a comment" in text
+
+
+def test_upsert_env_value_overwrites_existing(tmp_path):
+    env_path = tmp_path / ".env"
+    env_path.write_text("CLAUDE_CODE_OAUTH_TOKEN=old-value\nOTHER=1\n", encoding="utf-8")
+
+    upsert_env_value(env_path, "CLAUDE_CODE_OAUTH_TOKEN", "new-value")
+
+    text = env_path.read_text(encoding="utf-8")
+    assert "CLAUDE_CODE_OAUTH_TOKEN=new-value" in text
+    assert "old-value" not in text
+    assert "OTHER=1" in text
+
+
+def test_upsert_env_value_creates_missing_file(tmp_path):
+    env_path = tmp_path / "nested" / ".env"
+
+    upsert_env_value(env_path, "KEY", "value")
+
+    assert env_path.read_text(encoding="utf-8") == "KEY=value\n"
+
+
+def test_read_env_value_returns_none_when_missing(tmp_path):
+    env_path = tmp_path / ".env"
+    env_path.write_text("OTHER=1\n", encoding="utf-8")
+
+    assert read_env_value(env_path, "CLAUDE_CODE_OAUTH_TOKEN") is None
+
+
+def test_read_env_value_returns_none_when_file_missing(tmp_path):
+    assert read_env_value(tmp_path / "does-not-exist.env", "KEY") is None
+
+
+def test_read_env_value_returns_current_value(tmp_path):
+    env_path = tmp_path / ".env"
+    env_path.write_text("CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-xyz\n", encoding="utf-8")
+
+    assert read_env_value(env_path, "CLAUDE_CODE_OAUTH_TOKEN") == "sk-ant-oat01-xyz"
+
+
+def test_remove_env_value_drops_only_matching_key(tmp_path):
+    env_path = tmp_path / ".env"
+    env_path.write_text("CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-xyz\nOTHER=1\n", encoding="utf-8")
+
+    remove_env_value(env_path, "CLAUDE_CODE_OAUTH_TOKEN")
+
+    text = env_path.read_text(encoding="utf-8")
+    assert "CLAUDE_CODE_OAUTH_TOKEN" not in text
+    assert "OTHER=1" in text
+
+
+def test_remove_env_value_noop_when_file_missing(tmp_path):
+    # Should not raise.
+    remove_env_value(tmp_path / "does-not-exist.env", "KEY")
diff --git a/tests/test_session_gap.py b/tests/test_session_gap.py
new file mode 100644
index 0000000..d512eb3
--- /dev/null
+++ b/tests/test_session_gap.py
@@ -0,0 +1,115 @@
+"""Tests for session_gap.py."""
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+from unittest.mock import patch
+
+from coding_agent_telegram.session_gap import humanize_token_count, native_session_activity
+
+
+def _write_jsonl(path: Path, entries: list[dict]) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text("\n".join(json.dumps(entry) for entry in entries) + "\n", encoding="utf-8")
+
+
+def _real_usage_entry(total_tokens: int) -> dict:
+    return {
+        "type": "assistant",
+        "message": {
+            "model": "claude-sonnet-5",
+            "role": "assistant",
+            "usage": {
+                "input_tokens": total_tokens,
+                "cache_creation_input_tokens": 0,
+                "cache_read_input_tokens": 0,
+            },
+        },
+    }
+
+
+def _synthetic_stub_entry(text: str, error: str | None = None) -> dict:
+    entry = {
+        "type": "assistant",
+        "message": {
+            "model": "",
+            "role": "assistant",
+            "usage": {
+                "input_tokens": 0,
+                "cache_creation_input_tokens": 0,
+                "cache_read_input_tokens": 0,
+            },
+            "content": [{"type": "text", "text": text}],
+        },
+    }
+    if error is not None:
+        entry["error"] = error
+    return entry
+
+
+def test_claude_size_skips_trailing_synthetic_rate_limit_stub(tmp_path: Path):
+    claude_home = tmp_path / "claude-home"
+    session_file = claude_home / "projects" / "-tmp-proj" / "sess-1.jsonl"
+    _write_jsonl(
+        session_file,
+        [
+            _real_usage_entry(50_000),
+            _synthetic_stub_entry("You've hit your session limit · resets 12:40am (Asia/Shanghai)", error="rate_limit"),
+        ],
+    )
+
+    with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": str(claude_home)}):
+        _last_activity, size_tokens = native_session_activity("claude", "sess-1")
+
+    assert size_tokens == 50_000
+
+
+def test_claude_size_skips_trailing_synthetic_auth_and_no_response_stubs(tmp_path: Path):
+    claude_home = tmp_path / "claude-home"
+    session_file = claude_home / "projects" / "-tmp-proj" / "sess-2.jsonl"
+    _write_jsonl(
+        session_file,
+        [
+            _real_usage_entry(30_000),
+            _synthetic_stub_entry("Not logged in · Please run /login", error="authentication_failed"),
+            _synthetic_stub_entry("No response requested."),
+        ],
+    )
+
+    with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": str(claude_home)}):
+        _last_activity, size_tokens = native_session_activity("claude", "sess-2")
+
+    assert size_tokens == 30_000
+
+
+def test_claude_size_is_none_when_only_synthetic_entries_exist(tmp_path: Path):
+    claude_home = tmp_path / "claude-home"
+    session_file = claude_home / "projects" / "-tmp-proj" / "sess-3.jsonl"
+    _write_jsonl(
+        session_file,
+        [_synthetic_stub_entry("No response requested.")],
+    )
+
+    with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": str(claude_home)}):
+        _last_activity, size_tokens = native_session_activity("claude", "sess-3")
+
+    assert size_tokens is None
+
+
+def test_humanize_token_count_examples():
+    assert humanize_token_count(0) == "0"
+    assert humanize_token_count(800) == "800"
+    assert humanize_token_count(999) == "999"
+    assert humanize_token_count(1_000) == "1k"
+    assert humanize_token_count(200_000) == "200k"
+    assert humanize_token_count(1_000_000) == "1M"
+    assert humanize_token_count(11_000_000) == "11M"
+    assert humanize_token_count(1_000_000_000) == "1B"
+
+
+def test_humanize_token_count_rounds_down_instead_of_rolling_over_the_unit():
+    """A count just under a unit boundary should read as e.g. "999.9k", not round up
+    to a misleading "1000k" that looks like a typo for 1M."""
+    assert humanize_token_count(999_999) == "999.9k"
+    assert humanize_token_count(12_345) == "12.3k"
diff --git a/tests/test_session_runtime_claude_auth.py b/tests/test_session_runtime_claude_auth.py
new file mode 100644
index 0000000..4b4cc03
--- /dev/null
+++ b/tests/test_session_runtime_claude_auth.py
@@ -0,0 +1,67 @@
+from types import SimpleNamespace
+
+from coding_agent_telegram.agent_runner import AgentRunResult
+from coding_agent_telegram.session_runtime import SessionRuntime
+
+
+def _runtime(locale: str = "en") -> SessionRuntime:
+    return SessionRuntime(
+        cfg=SimpleNamespace(locale=locale),
+        store=None,
+        agent_runner=None,
+        bot_id="bot-a",
+        git=None,
+        run_with_typing=None,
+        register_reply_options=None,
+    )
+
+
+def _result(error_message, *, error_code=None) -> AgentRunResult:
+    return AgentRunResult(
+        session_id=None,
+        success=False,
+        assistant_text="",
+        error_message=error_message,
+        raw_events=[],
+        error_code=error_code,
+    )
+
+
+def test_agent_failure_text_uses_claude_auth_guidance_for_claude_auth_error():
+    runtime = _runtime()
+    result = _result("Failed to authenticate: OAuth session expired and could not be refreshed")
+
+    text = runtime._agent_failure_text(None, "claude", result)
+
+    assert "claude setup-token" in text
+    assert "claude-auth" in text
+
+
+def test_agent_failure_text_ignores_auth_wording_for_other_providers():
+    runtime = _runtime()
+    result = _result("Failed to authenticate: OAuth session expired and could not be refreshed")
+
+    text = runtime._agent_failure_text(None, "codex", result)
+
+    assert text == "Failed to authenticate: OAuth session expired and could not be refreshed"
+
+
+def test_agent_failure_text_falls_back_to_sanitized_error_for_non_auth_failures():
+    runtime = _runtime()
+    result = _result("Rate limit exceeded at /Users/daocha/git/some-project/file.py")
+
+    text = runtime._agent_failure_text(None, "claude", result)
+
+    assert text == "Rate limit exceeded at "
+
+
+def test_agent_failure_text_aborted_takes_priority_over_auth_wording():
+    runtime = _runtime()
+    result = _result(
+        "Failed to authenticate: OAuth session expired and could not be refreshed",
+        error_code="agent_aborted",
+    )
+
+    text = runtime._agent_failure_text(None, "claude", result)
+
+    assert text == "Agent run aborted by /abort."
diff --git a/tests/test_session_runtime_diff_merge.py b/tests/test_session_runtime_diff_merge.py
index d9916a4..63e2a53 100644
--- a/tests/test_session_runtime_diff_merge.py
+++ b/tests/test_session_runtime_diff_merge.py
@@ -1,5 +1,5 @@
 from coding_agent_telegram.diff_utils import FileDiff, TEXTUAL_DIFF_UNAVAILABLE
-from coding_agent_telegram.session_runtime import SessionRuntime
+from coding_agent_telegram.session_runtime import SessionRuntime, _detect_reply_options
 
 
 def _runtime() -> SessionRuntime:
@@ -10,6 +10,7 @@ def _runtime() -> SessionRuntime:
         bot_id="bot-a",
         git=None,
         run_with_typing=None,
+        register_reply_options=None,
     )
 
 
@@ -113,3 +114,90 @@ def test_merge_snapshot_diffs_handles_empty_inputs():
     runtime = _runtime()
     merged = runtime._merge_snapshot_diffs([], {})
     assert merged == []
+
+
+# ---------------------------------------------------------------------------
+# _detect_reply_options
+# ---------------------------------------------------------------------------
+
+
+def test_detect_reply_options_finds_numbered_choices_with_question_cue():
+    text = (
+        "I found two ways to fix this. Which approach would you like me to take?\n"
+        "1. Patch the validator directly\n"
+        "2. Rewrite the parser"
+    )
+    assert _detect_reply_options(text) == ("Patch the validator directly", "Rewrite the parser")
+
+
+def test_detect_reply_options_ignores_plain_numbered_list_without_question_cue():
+    text = "Here is what I changed:\n1. Updated the validator\n2. Added a regression test"
+    assert _detect_reply_options(text) == ()
+
+
+def test_detect_reply_options_ignores_question_without_option_list():
+    text = "Should I proceed with these changes? Let me know and I'll continue."
+    assert _detect_reply_options(text) == ()
+
+
+def test_detect_reply_options_ignores_single_option_line():
+    text = "Which approach would you like me to take?\n1. Patch the validator directly"
+    assert _detect_reply_options(text) == ()
+
+
+def test_detect_reply_options_caps_at_max_options():
+    lines = [f"{i}. Option {i}" for i in range(1, 10)]
+    text = "Which one do you want?\n" + "\n".join(lines)
+    options = _detect_reply_options(text)
+    assert len(options) == 6
+    assert options[0] == "Option 1"
+
+
+def test_detect_reply_options_returns_empty_for_blank_text():
+    assert _detect_reply_options("   ") == ()
+
+
+def test_detect_reply_options_ignores_multiple_independent_questions():
+    text = (
+        "I have a couple of questions before proceeding:\n"
+        "1. Should I use approach A or B for the caching layer?\n"
+        "2. Do you want unit tests included in this PR?"
+    )
+    assert _detect_reply_options(text) == ()
+
+
+def test_detect_reply_options_ignores_multiple_questions_wrapped_in_markdown():
+    """Claude routinely bolds numbered questions, which puts the question mark inside
+    the emphasis markers. Matching on a bare trailing "?" missed exactly the formatting
+    the provider uses most."""
+    text = (
+        "I need a couple of decisions before I proceed - should I go ahead?\n"
+        "1. **Use Redis or in-memory for the cache?**\n"
+        "2. **Include unit tests in this PR?**"
+    )
+    assert _detect_reply_options(text) == ()
+
+
+def test_detect_reply_options_ignores_multiple_questions_in_italics_or_code():
+    text = (
+        "Which do you want me to settle first?\n"
+        "1. *Should the cache be write-through?*\n"
+        "2. `Do we keep the legacy endpoint?`"
+    )
+    assert _detect_reply_options(text) == ()
+
+
+def test_detect_reply_options_keeps_menu_with_a_single_trailing_escape_question():
+    """One question mark is an "or something else?" escape hatch on a real menu, not a
+    second independent question, so the menu must survive."""
+    text = (
+        "Which approach do you want?\n"
+        "1. Refactor the module first\n"
+        "2. Patch it in place\n"
+        "3. Something else?"
+    )
+    assert _detect_reply_options(text) == (
+        "Refactor the module first",
+        "Patch it in place",
+        "Something else?",
+    )
diff --git a/tests/test_session_store.py b/tests/test_session_store.py
index 9f26d9b..f969a20 100644
--- a/tests/test_session_store.py
+++ b/tests/test_session_store.py
@@ -28,6 +28,60 @@ def test_create_and_switch_session(tmp_path: Path):
     assert chat["current_branch"] == "feature-1"
 
 
+def test_create_session_defaults_to_no_model_override(tmp_path: Path):
+    state = tmp_path / "state.json"
+    backup = tmp_path / "state.json.bak"
+    store = SessionStore(state, backup)
+
+    store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude")
+
+    sessions = store.list_sessions("bot-a", 123)
+    assert sessions["sess_1"]["model"] == ""
+
+
+def test_create_session_can_carry_over_a_model(tmp_path: Path):
+    state = tmp_path / "state.json"
+    backup = tmp_path / "state.json.bak"
+    store = SessionStore(state, backup)
+
+    store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude", model="opus")
+
+    sessions = store.list_sessions("bot-a", 123)
+    assert sessions["sess_1"]["model"] == "opus"
+
+
+def test_set_session_model_updates_existing_session(tmp_path: Path):
+    state = tmp_path / "state.json"
+    backup = tmp_path / "state.json.bak"
+    store = SessionStore(state, backup)
+    store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude")
+
+    assert store.set_session_model("bot-a", 123, "sess_1", "opus") is True
+
+    sessions = store.list_sessions("bot-a", 123)
+    assert sessions["sess_1"]["model"] == "opus"
+
+
+def test_set_session_model_clears_override_with_empty_string(tmp_path: Path):
+    state = tmp_path / "state.json"
+    backup = tmp_path / "state.json.bak"
+    store = SessionStore(state, backup)
+    store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude", model="opus")
+
+    assert store.set_session_model("bot-a", 123, "sess_1", "") is True
+
+    sessions = store.list_sessions("bot-a", 123)
+    assert sessions["sess_1"]["model"] == ""
+
+
+def test_set_session_model_returns_false_for_unknown_session(tmp_path: Path):
+    state = tmp_path / "state.json"
+    backup = tmp_path / "state.json.bak"
+    store = SessionStore(state, backup)
+
+    assert store.set_session_model("bot-a", 123, "does-not-exist", "opus") is False
+
+
 def test_set_current_provider_persists_in_chat_state(tmp_path: Path):
     state = tmp_path / "state.json"
     backup = tmp_path / "state.json.bak"
@@ -39,6 +93,55 @@ def test_set_current_provider_persists_in_chat_state(tmp_path: Path):
     assert chat["current_provider"] == "copilot"
 
 
+def test_empty_provider_normalizes_to_codex_when_creating_session(tmp_path: Path):
+    state = tmp_path / "state.json"
+    backup = tmp_path / "state.json.bak"
+    store = SessionStore(state, backup)
+
+    store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "")
+
+    chat = store.get_chat_state("bot-a", 123)
+    assert chat["current_provider"] == "codex"
+    assert chat["sessions"]["sess_1"]["provider"] == "codex"
+
+
+def test_empty_current_provider_normalizes_to_codex(tmp_path: Path):
+    state = tmp_path / "state.json"
+    backup = tmp_path / "state.json.bak"
+    store = SessionStore(state, backup)
+
+    store.set_current_provider("bot-a", 123, "")
+
+    assert store.get_chat_state("bot-a", 123)["current_provider"] == "codex"
+
+
+def test_switch_session_normalizes_empty_legacy_provider_to_codex(tmp_path: Path):
+    state = tmp_path / "state.json"
+    backup = tmp_path / "state.json.bak"
+    state.write_text(
+        json.dumps(
+            {
+                "chats": {
+                    "bot-a:123": {
+                        "sessions": {
+                            "sess_legacy": {
+                                "name": "legacy",
+                                "project_folder": "backend",
+                                "provider": "",
+                            }
+                        }
+                    }
+                }
+            }
+        ),
+        encoding="utf-8",
+    )
+    store = SessionStore(state, backup)
+
+    assert store.switch_session("bot-a", 123, "sess_legacy")
+    assert store.get_chat_state("bot-a", 123)["current_provider"] == "codex"
+
+
 def test_set_pending_action_persists_and_clears(tmp_path: Path):
     state = tmp_path / "state.json"
     backup = tmp_path / "state.json.bak"
diff --git a/tests/test_speech_to_text.py b/tests/test_speech_to_text.py
index 8490146..2ecb645 100644
--- a/tests/test_speech_to_text.py
+++ b/tests/test_speech_to_text.py
@@ -23,6 +23,9 @@ def _cfg(tmp_path: Path, *, model: str = "base", timeout: int = 120) -> AppConfi
         codex_model="",
         copilot_model="",
         claude_model="",
+        codex_model_choices=("gpt-5.4",),
+        copilot_model_choices=("gpt-5.4", "claude-sonnet-4.6"),
+        claude_model_choices=("sonnet", "opus", "haiku"),
         copilot_autopilot=True,
         copilot_no_ask_user=True,
         copilot_allow_all=True,
@@ -47,6 +50,10 @@ def _cfg(tmp_path: Path, *, model: str = "base", timeout: int = 120) -> AppConfi
         default_agent_provider="codex",
         agent_hard_timeout_seconds=0,
         app_internal_root=tmp_path / ".coding-agent-telegram",
+        long_gap_warning_enabled=False,
+        claude_long_gap_seconds=3600,
+        codex_long_gap_seconds=600,
+        copilot_long_gap_seconds=600,
         locale="en",
     )
 
diff --git a/tests/test_telegram_sender.py b/tests/test_telegram_sender.py
index fd768bf..6a2f908 100644
--- a/tests/test_telegram_sender.py
+++ b/tests/test_telegram_sender.py
@@ -36,7 +36,7 @@ def test_send_html_text_falls_back_to_plain_text_on_parse_error():
     calls = []
 
     class FakeBot:
-        async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None):
+        async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None):
             calls.append((chat_id, text, parse_mode))
             if len(calls) == 1:
                 raise BadRequest("Can't parse entities: can't find end tag corresponding to start tag \"code\"")
@@ -54,7 +54,7 @@ def test_send_text_chunks_long_messages():
     calls = []
 
     class FakeBot:
-        async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None):
+        async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None):
             calls.append((chat_id, text, parse_mode))
 
     update = SimpleNamespace(effective_chat=SimpleNamespace(id=123))
@@ -70,7 +70,7 @@ def test_send_html_text_chunks_long_messages_as_plain_text():
     calls = []
 
     class FakeBot:
-        async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None):
+        async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None):
             calls.append((chat_id, text, parse_mode))
 
     update = SimpleNamespace(effective_chat=SimpleNamespace(id=123))
@@ -86,7 +86,7 @@ def test_send_code_block_chunks_long_code_blocks():
     calls = []
 
     class FakeBot:
-        async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None):
+        async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None):
             calls.append((chat_id, text, parse_mode))
 
     update = SimpleNamespace(effective_chat=SimpleNamespace(id=123))
@@ -108,7 +108,7 @@ def test_send_text_does_nothing_when_effective_chat_is_none():
     called = []
 
     class FakeBot:
-        async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None):
+        async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None):
             called.append(text)
 
     update = SimpleNamespace(effective_chat=None)
@@ -122,7 +122,7 @@ def test_send_html_text_does_nothing_when_effective_chat_is_none():
     called = []
 
     class FakeBot:
-        async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None):
+        async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None):
             called.append(text)
 
     update = SimpleNamespace(effective_chat=None)
@@ -136,7 +136,7 @@ def test_send_code_block_does_nothing_when_effective_chat_is_none():
     called = []
 
     class FakeBot:
-        async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None):
+        async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None):
             called.append(text)
 
     update = SimpleNamespace(effective_chat=None)
@@ -156,7 +156,7 @@ def test_send_text_uses_default_length_when_no_bot_data():
     calls = []
 
     class FakeBot:
-        async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None):
+        async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None):
             calls.append(text)
 
     update = SimpleNamespace(effective_chat=SimpleNamespace(id=1))
@@ -230,7 +230,7 @@ def test_send_markdown_text_sends_message():
     calls = []
 
     class FakeBot:
-        async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None):
+        async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None):
             calls.append((chat_id, text, parse_mode))
 
     from telegram.constants import ParseMode
@@ -270,7 +270,7 @@ def test_send_html_text_reraises_non_parse_bad_request():
     from telegram.error import BadRequest
 
     class FakeBot:
-        async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None):
+        async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None):
             raise BadRequest("Message is too long")
 
     update = SimpleNamespace(effective_chat=SimpleNamespace(id=1))
@@ -387,7 +387,7 @@ def test_send_code_block_without_language():
     calls = []
 
     class FakeBot:
-        async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None):
+        async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None):
             calls.append(text)
 
     update = SimpleNamespace(effective_chat=SimpleNamespace(id=7))
diff --git a/tests/test_usage_status.py b/tests/test_usage_status.py
new file mode 100644
index 0000000..e1695b7
--- /dev/null
+++ b/tests/test_usage_status.py
@@ -0,0 +1,338 @@
+from __future__ import annotations
+
+import json
+import time
+from pathlib import Path
+
+import pytest
+
+from coding_agent_telegram.usage_status import (
+    CLAUDE_WINDOW_EXPIRED_NOTE,
+    CLAUDE_WINDOW_NEVER_OBSERVED_NOTE,
+    ProviderUsage,
+    configure_persistence,
+    fetch_copilot_usage,
+    get_claude_usage,
+    observe_claude_rate_limit_event,
+    parse_claude_rate_limit_event,
+    parse_codex_rate_limits_result,
+)
+
+
+@pytest.fixture(autouse=True)
+def _reset_claude_rate_limit_cache(monkeypatch):
+    """The passive cache and its optional persistence backing are process-wide
+    module state; isolate each test from whatever an earlier test left behind."""
+    monkeypatch.setattr("coding_agent_telegram.usage_status._claude_rate_limit_cache", None)
+    monkeypatch.setattr("coding_agent_telegram.usage_status._claude_rate_limit_path", None)
+    yield
+
+
+def test_parse_claude_rate_limit_event_extracts_both_windows():
+    events = [
+        {"type": "system", "subtype": "init"},
+        {
+            "type": "rate_limit_event",
+            "rate_limit_info": {
+                "unifiedWindows": {
+                    "five_hour": {"utilization": 0.53, "resetsAt": 1788990600},
+                    "seven_day": {"utilization": 0.05, "resetsAt": 1789570800},
+                }
+            },
+        },
+    ]
+
+    usage = parse_claude_rate_limit_event(events)
+
+    assert usage is not None
+    assert usage.provider == "claude"
+    assert usage.available is True
+    assert usage.five_hour.used_percent == 53.0
+    assert usage.five_hour.resets_at == 1788990600
+    assert usage.weekly.used_percent == 5.0
+    assert usage.weekly.resets_at == 1789570800
+
+
+def test_parse_claude_rate_limit_event_uses_last_not_first_when_multiple_present():
+    """A single -p invocation can make more than one real API turn internally
+    (e.g. a tool-use loop), each capable of emitting its own rate_limit_event
+    as utilization climbs -- only the last one reflects the run's ending usage."""
+    events = [
+        {
+            "type": "rate_limit_event",
+            "rate_limit_info": {
+                "unifiedWindows": {
+                    "five_hour": {"utilization": 0.10, "resetsAt": 1},
+                    "seven_day": {"utilization": 0.01, "resetsAt": 2},
+                }
+            },
+        },
+        {"type": "assistant", "message": {}},
+        {
+            "type": "rate_limit_event",
+            "rate_limit_info": {
+                "unifiedWindows": {
+                    "five_hour": {"utilization": 0.15, "resetsAt": 1},
+                    "seven_day": {"utilization": 0.02, "resetsAt": 2},
+                }
+            },
+        },
+    ]
+
+    usage = parse_claude_rate_limit_event(events)
+
+    assert usage.five_hour.used_percent == 15.0
+    assert usage.weekly.used_percent == 2.0
+
+
+def test_parse_claude_rate_limit_event_missing_returns_none():
+    events = [{"type": "system", "subtype": "init"}, {"type": "result", "result": "hi"}]
+
+    assert parse_claude_rate_limit_event(events) is None
+
+
+def test_parse_codex_rate_limits_result_extracts_primary_and_secondary():
+    result = {
+        "rateLimits": {
+            "primary": {"usedPercent": 0, "windowDurationMins": 300, "resetsAt": 1789005642},
+            "secondary": {"usedPercent": 23, "windowDurationMins": 10080, "resetsAt": 1789446557},
+            "planType": "plus",
+        }
+    }
+
+    usage = parse_codex_rate_limits_result(result)
+
+    assert usage.provider == "codex"
+    assert usage.available is True
+    assert usage.five_hour.used_percent == 0.0
+    assert usage.five_hour.resets_at == 1789005642
+    assert usage.weekly.used_percent == 23.0
+    assert usage.plan == "plus"
+
+
+def test_parse_codex_rate_limits_result_handles_missing_windows():
+    usage = parse_codex_rate_limits_result({"rateLimits": {}})
+
+    assert usage.available is True
+    assert usage.five_hour is None
+    assert usage.weekly is None
+    assert usage.plan is None
+
+
+def test_fetch_copilot_usage_always_unavailable():
+    usage = fetch_copilot_usage()
+
+    assert isinstance(usage, ProviderUsage)
+    assert usage.provider == "copilot"
+    assert usage.available is False
+    assert usage.error
+
+
+def _rate_limit_events(*, five_hour_resets_at: float, weekly_resets_at: float) -> list:
+    return [
+        {
+            "type": "rate_limit_event",
+            "rate_limit_info": {
+                "unifiedWindows": {
+                    "five_hour": {"utilization": 0.4, "resetsAt": int(five_hour_resets_at)},
+                    "seven_day": {"utilization": 0.1, "resetsAt": int(weekly_resets_at)},
+                }
+            },
+        }
+    ]
+
+
+def test_get_claude_usage_reports_na_for_both_windows_when_never_observed():
+    usage = get_claude_usage()
+
+    assert usage.provider == "claude"
+    assert usage.available is True
+    assert usage.five_hour is None
+    assert usage.five_hour_note == CLAUDE_WINDOW_NEVER_OBSERVED_NOTE
+    assert usage.weekly is None
+    assert usage.weekly_note == CLAUDE_WINDOW_NEVER_OBSERVED_NOTE
+    assert usage.observed_at is None
+
+
+def test_get_claude_usage_serves_both_windows_from_cache_when_fresh():
+    now = time.time()
+    observe_claude_rate_limit_event(_rate_limit_events(five_hour_resets_at=now + 3600, weekly_resets_at=now + 86400))
+
+    usage = get_claude_usage()
+
+    assert usage.five_hour.used_percent == 40.0
+    assert usage.five_hour_note is None
+    assert usage.weekly.used_percent == 10.0
+    assert usage.weekly_note is None
+    assert usage.observed_at is not None
+
+
+def test_get_claude_usage_reports_na_for_five_hour_window_that_has_reset_but_keeps_fresh_weekly():
+    now = time.time()
+    # five_hour already rolled past its reset; weekly is still within its window.
+    observe_claude_rate_limit_event(_rate_limit_events(five_hour_resets_at=now - 10, weekly_resets_at=now + 86400))
+
+    usage = get_claude_usage()
+
+    assert usage.five_hour is None
+    assert usage.five_hour_note == CLAUDE_WINDOW_EXPIRED_NOTE
+    # The still-fresh weekly window is not thrown away just because its sibling expired.
+    assert usage.weekly.used_percent == 10.0
+    assert usage.weekly_note is None
+    # A snapshot still exists (just partially expired), so "last observed" stays meaningful.
+    assert usage.observed_at is not None
+
+
+def test_get_claude_usage_reports_na_for_weekly_window_that_has_reset_but_keeps_fresh_five_hour():
+    now = time.time()
+    observe_claude_rate_limit_event(_rate_limit_events(five_hour_resets_at=now + 3600, weekly_resets_at=now - 10))
+
+    usage = get_claude_usage()
+
+    assert usage.five_hour.used_percent == 40.0
+    assert usage.five_hour_note is None
+    assert usage.weekly is None
+    assert usage.weekly_note == CLAUDE_WINDOW_EXPIRED_NOTE
+
+
+def test_resolve_window_treats_missing_resets_at_as_expired_past_max_age():
+    """A window with no resets_at (a malformed/partial rate_limit_event) must
+    still eventually expire -- otherwise a bad value could get stuck reading
+    as "fresh" forever, which matters more now that it survives a restart."""
+    from coding_agent_telegram.usage_status import _MAX_SNAPSHOT_AGE_SECONDS, RateWindow, _resolve_window
+
+    now = time.time()
+    window = RateWindow(used_percent=50.0, resets_at=None)
+
+    resolved, note = _resolve_window(window, now, now - _MAX_SNAPSHOT_AGE_SECONDS - 1)
+
+    assert resolved is None
+    assert note == CLAUDE_WINDOW_EXPIRED_NOTE
+
+
+def test_resolve_window_keeps_missing_resets_at_fresh_within_max_age():
+    from coding_agent_telegram.usage_status import RateWindow, _resolve_window
+
+    now = time.time()
+    window = RateWindow(used_percent=50.0, resets_at=None)
+
+    resolved, note = _resolve_window(window, now, now - 60)
+
+    assert resolved == window
+    assert note is None
+
+
+def test_get_claude_usage_expires_window_with_missing_resets_at_after_max_age(monkeypatch):
+    """Integration-level check: a stale, resets_at-less window doesn't get
+    reported as live, while its sibling (with its own valid resets_at) is
+    unaffected."""
+    from coding_agent_telegram import usage_status as usage_status_module
+    from coding_agent_telegram.usage_status import _MAX_SNAPSHOT_AGE_SECONDS, RateWindow, _ClaudeRateLimitSnapshot
+
+    now = time.time()
+    stale_snapshot = _ClaudeRateLimitSnapshot(
+        five_hour=RateWindow(used_percent=50.0, resets_at=None),
+        weekly=RateWindow(used_percent=20.0, resets_at=now + 86400),
+        observed_at=now - _MAX_SNAPSHOT_AGE_SECONDS - 1,
+    )
+    monkeypatch.setattr(usage_status_module, "_claude_rate_limit_cache", stale_snapshot)
+
+    usage = get_claude_usage()
+
+    assert usage.five_hour is None
+    assert usage.five_hour_note == CLAUDE_WINDOW_EXPIRED_NOTE
+    assert usage.weekly.used_percent == 20.0
+    assert usage.weekly_note is None
+
+
+def test_get_claude_usage_never_makes_a_subprocess_call(monkeypatch):
+    """There is no live-probe fallback anymore -- get_claude_usage must be a
+    pure, free cache read regardless of cache state."""
+
+    def fail_if_called(*args, **kwargs):
+        raise AssertionError("get_claude_usage must not spawn a subprocess")
+
+    monkeypatch.setattr("coding_agent_telegram.usage_status.subprocess.run", fail_if_called)
+    monkeypatch.setattr("coding_agent_telegram.usage_status.subprocess.Popen", fail_if_called)
+
+    # No cache at all.
+    assert get_claude_usage().five_hour is None
+
+    # Cache present but expired.
+    now = time.time()
+    observe_claude_rate_limit_event(_rate_limit_events(five_hour_resets_at=now - 10, weekly_resets_at=now - 10))
+    usage = get_claude_usage()
+    assert usage.five_hour is None
+    assert usage.weekly is None
+
+
+def _rate_limit_path(tmp_path: Path) -> Path:
+    return tmp_path / "claude_rate_limit.json"
+
+
+def test_observed_rate_limit_survives_a_restart_via_persistence(tmp_path: Path):
+    """Reproduces the bug report: a real Claude turn observes usage, the bot
+    process restarts (wiping the in-memory-only cache), and /status should
+    still show the last-observed window instead of "no data yet"."""
+    path = _rate_limit_path(tmp_path)
+    configure_persistence(path)
+    now = time.time()
+    observe_claude_rate_limit_event(_rate_limit_events(five_hour_resets_at=now + 3600, weekly_resets_at=now + 86400))
+
+    # Simulate a process restart: fresh cache, re-wired the same way cli.py
+    # does, pointed at the same file.
+    from coding_agent_telegram import usage_status as usage_status_module
+
+    usage_status_module._claude_rate_limit_cache = None
+    configure_persistence(path)
+
+    usage = get_claude_usage()
+    assert usage.five_hour.used_percent == 40.0
+    assert usage.five_hour_note is None
+    assert usage.weekly.used_percent == 10.0
+    assert usage.observed_at is not None
+
+
+def test_configure_persistence_with_no_prior_snapshot_leaves_cache_empty(tmp_path: Path):
+    configure_persistence(_rate_limit_path(tmp_path))
+
+    usage = get_claude_usage()
+    assert usage.five_hour is None
+    assert usage.five_hour_note == CLAUDE_WINDOW_NEVER_OBSERVED_NOTE
+
+
+def test_persisted_rate_limit_snapshot_lives_in_its_own_file(tmp_path: Path):
+    path = _rate_limit_path(tmp_path)
+    configure_persistence(path)
+    now = time.time()
+
+    observe_claude_rate_limit_event(_rate_limit_events(five_hour_resets_at=now + 3600, weekly_resets_at=now + 86400))
+
+    assert path.exists()
+    data = json.loads(path.read_text(encoding="utf-8"))
+    assert data["five_hour"]["used_percent"] == 40.0
+    assert data["weekly"]["used_percent"] == 10.0
+    # No sibling state.json/backup churn from this write -- it's a standalone file.
+    assert sorted(p.name for p in tmp_path.iterdir()) == [
+        "claude_rate_limit.json",
+        "claude_rate_limit.json.lock",
+    ]
+
+
+def test_restart_after_window_rolled_over_reports_expired_not_stale_data(tmp_path: Path):
+    """A snapshot persisted just before its reset time should be reported as
+    expired after a restart, not served as if it were still live."""
+    path = _rate_limit_path(tmp_path)
+    configure_persistence(path)
+    now = time.time()
+    observe_claude_rate_limit_event(_rate_limit_events(five_hour_resets_at=now - 1, weekly_resets_at=now + 86400))
+
+    from coding_agent_telegram import usage_status as usage_status_module
+
+    usage_status_module._claude_rate_limit_cache = None
+    configure_persistence(path)
+
+    usage = get_claude_usage()
+    assert usage.five_hour is None
+    assert usage.five_hour_note == CLAUDE_WINDOW_EXPIRED_NOTE
+    assert usage.weekly.used_percent == 10.0