smlflg commited on
Commit
981aa5d
·
verified ·
1 Parent(s): 5b1ff96

Initial public upload from Projekte/CodexDashBoard

Browse files
.codex/config.toml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Coding MCPs: project-local registration (2026-09-07).
2
+
3
+ [mcp_servers.serena]
4
+ command = "/home/smlflg/.local/share/coding-mcp/serena/.venv/bin/serena"
5
+ args = ["start-mcp-server", "--context=codex", "--project", "/home/smlflg/Projekte/CodexDashBoard", "--enable-web-dashboard", "false", "--open-web-dashboard", "false", "--enable-gui-log-window", "false"]
6
+ cwd = "/home/smlflg/Projekte/CodexDashBoard"
7
+ enabled = true
8
+ startup_timeout_sec = 120
9
+ tool_timeout_sec = 180
10
+
11
+ [mcp_servers.serena.env]
12
+ "SERENA_HOME" = "/home/smlflg/.local/share/coding-mcp/serena/state"
13
+
14
+ [mcp_servers.gitnexus]
15
+ command = "/home/smlflg/.nvm/versions/node/v24.13.0/bin/node"
16
+ args = ["/home/smlflg/.local/share/coding-mcp/gitnexus/node_modules/gitnexus/dist/cli/index.js", "mcp"]
17
+ cwd = "/home/smlflg/Projekte/CodexDashBoard"
18
+ enabled = true
19
+ startup_timeout_sec = 120
20
+ tool_timeout_sec = 180
21
+
22
+ [mcp_servers.gitnexus.env]
23
+ "GITNEXUS_HOME" = "/home/smlflg/.local/share/coding-mcp/gitnexus/runtime"
24
+
25
+ [mcp_servers.context7]
26
+ command = "/home/smlflg/.nvm/versions/node/v24.13.0/bin/node"
27
+ args = ["/home/smlflg/.local/share/coding-mcp/context7/node_modules/@upstash/context7-mcp/dist/index.js"]
28
+ cwd = "/home/smlflg/Projekte/CodexDashBoard"
29
+ enabled = true
30
+ startup_timeout_sec = 120
31
+ tool_timeout_sec = 180
.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .mypy_cache/
6
+ .venv/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ *.log
11
+
.hermes/plans/2026-04-22_155500-codex-dashboard-100-punkte-plan.md ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CodexDashBoard — 100-Punkte-Plan v2
2
+
3
+ > Für Hermes: Dies ist ein priorisierter Arbeitsplan für das Projekt. Fokus: lokal nutzbar machen, System-Dependencies stabilisieren, Packaging fertigziehen, echte Dashboard-Funktionen verdrahten und den Scope Codex-first halten.
4
+
5
+ Ziel:
6
+ Ein lokal installierbares, stabiles Linux-Dashboard für Codex auf Linux bauen, das Sessions, Prozesse und Logs zuverlässig zeigt. Claude-nahe Funktionen werden in v0.2 nur unterstützt, wenn sie klar optional sind oder vollständig fertig implementiert werden.
7
+
8
+ Architektur:
9
+ Read-only Data Layer, GTK3-UI, klare Trennung zwischen Datenzugriff, Prozessbeobachtung und UI-Rendering. Packaging so aufsetzen, dass Dev ohne GUI-Stack funktioniert und GUI mit expliziten System-Dependencies sauber installierbar ist. Import- und Startup-Pfade müssen auch ohne `gi` kontrolliert und mit verständlicher Fehlermeldung enden.
10
+
11
+ Tech Stack:
12
+ Python >=3.10 (laut `pyproject.toml`), GTK3/PyGObject, SQLite, systemd user service, Desktop launcher, pytest.
13
+
14
+ Prioritäten:
15
+ - P0: Packaging, import-sicherer Startup, Codex-Kernviews (Sessions/Processes/Logs), README-Installationsgenauigkeit
16
+ - P1: Tray, systemd, Desktop-Datei, Operability auf Pop!_OS/Ubuntu
17
+ - P2: Claude/Agent-Ops/Recovery nur optional oder vollständig fertig
18
+
19
+ ---
20
+
21
+ ## Phase 1 — Produktbild und Scope klären
22
+
23
+ 1. Projektziel in einem Satz formulieren.
24
+ 2. Scope-Gate setzen: v0.2 ist Codex-first.
25
+ 3. Entscheiden: Claude-Views für v0.2 verstecken, entfernen oder vollständig liefern.
26
+ 4. Zielnutzer definieren: nur lokal auf Linux.
27
+ 5. v0.2-Scope mit P0/P1/P2 festlegen.
28
+ 6. Nicht-Ziele explizit auflisten.
29
+ 7. Read-only-Grenze definieren.
30
+ 8. Erlaubte Side-Effects definieren.
31
+ 9. Recovery-Use-Cases definieren.
32
+ 10. Erfolgskriterien für „lokal nutzbar“ messbar festlegen.
33
+
34
+ ## Phase 2 — Dependency-Triage
35
+
36
+ 11. Aktuelle Installationsfehler dokumentieren.
37
+ 12. Fehlende Systempakete für PyGObject bestätigen.
38
+ 13. Prüfen, welches Paket `girepository-2.0` liefert.
39
+ 14. Prüfen, welches Paket `gobject-introspection-1.0` liefert.
40
+ 15. Prüfen, ob `pkg-config` vorhanden ist.
41
+ 16. Prüfen, ob `libgtk-3-dev` nötig ist.
42
+ 17. Prüfen, ob `libcairo2-dev` nötig ist.
43
+ 18. Prüfen, ob Ayatana-Indikator-Dev-Pakete nötig sind.
44
+ 19. Installationsmatrix für Ubuntu/Pop!_OS notieren.
45
+ 20. Minimal- vs Voll-Installationspfad definieren.
46
+
47
+ ## Phase 3 — Packaging stabilisieren
48
+
49
+ 21. Python-Dev-Install ohne GUI verifizieren.
50
+ 22. GUI-Extrastruktur in `pyproject.toml` prüfen.
51
+ 23. Optionales Extra `gui` beibehalten oder anpassen.
52
+ 24. Optionales Extra `dev` prüfen.
53
+ 25. Optionales Extra `all` erwägen.
54
+ 26. README-Installationspfade vereinheitlichen.
55
+ 27. System-Dependencies in README ergänzen.
56
+ 28. Installationsfehler mit klaren Hinweisen dokumentieren.
57
+ 29. `pip install -e '.[gui]'` nach Dependency-Fix testen.
58
+ 30. Paket-Metadaten auf Konsistenz prüfen.
59
+
60
+ ## Phase 4 — Runtime-Robustheit
61
+
62
+ 31. Entry-Point so umbauen, dass fehlendes `gi` nicht beim Import crasht.
63
+ 32. Freundliche Fehlermeldung bei fehlendem GTK ergänzen.
64
+ 33. Freundliche Fehlermeldung bei fehlendem Ayatana ergänzen.
65
+ 34. Startverhalten ohne Tray sauber definieren.
66
+ 35. Startverhalten ohne Codex-DB sauber definieren.
67
+ 36. Startverhalten ohne Claude-Verzeichnisse sauber definieren.
68
+ 37. Logging-Pfade verifizieren.
69
+ 38. Singleton-Lock-Verhalten testen.
70
+ 39. Fehlerdialog oder Terminal-Output-Strategie festlegen.
71
+ 40. App-Start ohne Crashes verifizieren.
72
+
73
+ ## Phase 5 — Datenmodell prüfen
74
+
75
+ 41. `codex_data.py` vollständig gegen echtes DB-Schema prüfen.
76
+ 42. `claude_data.py` gegen echte Dateistrukturen prüfen.
77
+ 43. Ungültige JSON/JSONL-Dateien robuster behandeln.
78
+ 44. Leere Felder im Datenmodell sauber normalisieren.
79
+ 45. Zeitstempel-Fallbacks vereinheitlichen.
80
+ 46. Suchfelder für Threads prüfen.
81
+ 47. Suchfelder für Claude-Tasks prüfen.
82
+ 48. Log-Limits und Sortierung validieren.
83
+ 49. Große Datenmengen auf Performance prüfen.
84
+ 50. Read-only-Verträge im Code dokumentieren.
85
+
86
+ ## Phase 6 — UI-Verdrahtung und Scope-Entscheidung
87
+
88
+ 51. Alle angelegten Views in `app.py` inventarisieren.
89
+ 52. Alle referenzierten aber undefinierten Callbacks in `app.py` finden.
90
+ 53. Alle fehlenden Refresh-Methoden in `app.py` finden.
91
+ 54. Alle fehlenden Render-Methoden in `app.py` finden.
92
+ 55. Entscheiden: unfertige Claude-Views in v0.2 verstecken/entfernen.
93
+ 56. Falls behalten: `Claude Tasks`-View vollständig verdrahten.
94
+ 57. Falls behalten: `Agent Ops`- und `Recovery`-View vollständig verdrahten.
95
+ 58. Sichtbarkeitsbasiertes Refresh für alle Tabs einführen.
96
+ 59. Statuszeile pro View konsistent aktualisieren.
97
+ 60. Leere Zustände für jede View gestalten.
98
+
99
+ ## Phase 7 — Prozesse und Process Monitor
100
+
101
+ 61. Prozess-Keywords auf Relevanz prüfen.
102
+ 62. Falsch-positive Prozessmatches minimieren.
103
+ 63. Entscheiden: `Agent Ops` behalten oder als `Process Monitor` benennen.
104
+ 64. Sortierung nach stale/ram/cpu validieren.
105
+ 65. Prozessdetails um CWD und Uptime verlässlich machen.
106
+ 66. Safe-terminate-Flow mit Bestätigung testen.
107
+ 67. Prozessliste periodisch refreshen.
108
+ 68. Falls behalten: Familienstatistik mit klaren Acceptance Criteria ergänzen.
109
+ 69. Stale-Prozesse visuell hervorheben.
110
+ 70. Recovery-Hinweise aus Prozessstatus ableiten.
111
+
112
+ ## Phase 8 — Sessions, Logs, Tasks
113
+
114
+ 71. Overview-Metriken exakt definieren und dann umsetzen.
115
+ 72. Sessions-Liste für Codex optimieren.
116
+ 73. Falls Claude in Scope: Claude-Sessions optional einblendbar machen.
117
+ 74. Logs-Suche auf definierte Felder und Sortierung festlegen.
118
+ 75. Log-Detailansicht mit Acceptance Criteria definieren.
119
+ 76. Task-Gruppierung pro Projekt testen.
120
+ 77. Blocked/Active/Completed visuell unterscheiden.
121
+ 78. Event-Zusammenfassungen mit Maxlänge/Fallback-Regeln definieren.
122
+ 79. Quick-Actions pro Session prüfen.
123
+ 80. Open-folder/resume-Aktionen absichern.
124
+
125
+ ## Phase 9 — Tests und Verifikation
126
+
127
+ 81. Fehlende Tests für `claude_data.py` hinzufügen.
128
+ 82. Tests für Packaging-Extras erweitern.
129
+ 83. Tests für Runtime-Importfehler und fehlendes `gi` ergänzen.
130
+ 84. Tests für leere/malformte Claude-Dateien ergänzen.
131
+ 85. Smoke-Test/Guard für fehlende UI-Callbacks und Methoden ergänzen.
132
+ 86. Tests für Prozess-Familienzuordnung ergänzen.
133
+ 87. Tests für Recovery-Hinweise ergänzen.
134
+ 88. `pytest -q` als Basis-Check etablieren.
135
+ 89. `python3 -m compileall src tests` als Check etablieren.
136
+ 90. Manuelle Smoke-Checkliste für GUI erstellen.
137
+
138
+ ## Phase 10 — Distribution und Polishing
139
+
140
+ 91. systemd-User-Service als Basis-Operability testen.
141
+ 92. Desktop-Datei als Basis-Operability validieren.
142
+ 93. Icon/Name/Klassen konsistent machen.
143
+ 94. README-Screenshots oder Text-Flow verbessern.
144
+ 95. Installationsanleitung für Pop!_OS ergänzen.
145
+ 96. Troubleshooting-Sektion schreiben.
146
+ 97. Konkurrenzvergleich in Produktpositionierung übersetzen.
147
+ 98. v0.2-Milestone mit Prioritäten festziehen.
148
+ 99. Release-Checkliste definieren.
149
+ 100. Erste lokal nutzbare Beta mit klarem Scope schneiden.
150
+
151
+ ---
152
+
153
+ ## Kompakte Checkliste zum Abhaken
154
+
155
+ [ ] 1. Produktziel klar
156
+ [ ] 2. Codex-first Scope gesetzt
157
+ [ ] 3. Claude-Support entschieden
158
+ [ ] 4. Linux-Ziel klar
159
+ [ ] 5. v0.2-Scope mit Prioritäten definiert
160
+ [ ] 6. Nicht-Ziele definiert
161
+ [ ] 7. Read-only-Regeln definiert
162
+ [ ] 8. Side-Effects definiert
163
+ [ ] 9. Recovery-Use-Cases klar
164
+ [ ] 10. Erfolgskriterien messbar
165
+ [ ] 11. Installationsfehler dokumentiert
166
+ [ ] 12. GI-Root-Cause bestätigt
167
+ [ ] 13. `girepository-2.0` Paket gefunden
168
+ [ ] 14. `gobject-introspection-1.0` Paket gefunden
169
+ [ ] 15. `pkg-config` geprüft
170
+ [ ] 16. GTK-Dev-Bedarf geprüft
171
+ [ ] 17. Cairo-Dev-Bedarf geprüft
172
+ [ ] 18. Ayatana-Bedarf geprüft
173
+ [ ] 19. Paketmatrix notiert
174
+ [ ] 20. Minimal/Voll-Pfad definiert
175
+ [ ] 21. Dev-Install ok
176
+ [ ] 22. `gui`-Extra geprüft
177
+ [ ] 23. `gui`-Extra sinnvoll
178
+ [ ] 24. `dev`-Extra sinnvoll
179
+ [ ] 25. `all`-Extra bewertet
180
+ [ ] 26. README konsistent
181
+ [ ] 27. Sysdeps dokumentiert
182
+ [ ] 28. Fehlermeldungen dokumentiert
183
+ [ ] 29. GUI-Install getestet
184
+ [ ] 30. Metadaten konsistent
185
+ [ ] 31. Import ohne `gi` crasht nicht
186
+ [ ] 32. GTK-Fehler freundlich
187
+ [ ] 33. Ayatana-Fehler freundlich
188
+ [ ] 34. Kein-Tray-Verhalten ok
189
+ [ ] 35. Keine-Codex-DB ok
190
+ [ ] 36. Keine-Claude-Daten ok
191
+ [ ] 37. Logging ok
192
+ [ ] 38. Lock ok
193
+ [ ] 39. Fehlerstrategie klar
194
+ [ ] 40. Start ohne Crash
195
+ [ ] 41. Codex-Schema geprüft
196
+ [ ] 42. Claude-Dateien geprüft
197
+ [ ] 43. JSON-Fehler robust
198
+ [ ] 44. Leere Felder sauber
199
+ [ ] 45. Zeitstempel sauber
200
+ [ ] 46. Thread-Suche validiert
201
+ [ ] 47. Task-Suche validiert
202
+ [ ] 48. Logs validiert
203
+ [ ] 49. Performance geprüft
204
+ [ ] 50. Read-only dokumentiert
205
+ [ ] 51. Views inventarisiert
206
+ [ ] 52. Fehlende Callbacks gefunden
207
+ [ ] 53. Fehlende Refreshes gefunden
208
+ [ ] 54. Fehlende Renderer gefunden
209
+ [ ] 55. Unfertige Claude-Views entschieden
210
+ [ ] 56. Claude Tasks ggf. fertig
211
+ [ ] 57. Agent Ops/Recovery ggf. fertig
212
+ [ ] 58. Sichtbarkeits-Refresh komplett
213
+ [ ] 59. Statuszeile konsistent
214
+ [ ] 60. Empty States gut
215
+ [ ] 61. Keywords geprüft
216
+ [ ] 62. False Positives reduziert
217
+ [ ] 63. Monitor-Begriff entschieden
218
+ [ ] 64. Sortierung validiert
219
+ [ ] 65. Details verlässlich
220
+ [ ] 66. Terminate-Flow sicher
221
+ [ ] 67. Prozess-Refresh ok
222
+ [ ] 68. Familienstatistik klar definiert
223
+ [ ] 69. Stale-Hinweise gut
224
+ [ ] 70. Recovery aus Prozessen ableitbar
225
+ [ ] 71. Overview-Metriken definiert
226
+ [ ] 72. Sessions gut
227
+ [ ] 73. Claude-Sessions ggf. steuerbar
228
+ [ ] 74. Logs-Suche spezifiziert
229
+ [ ] 75. Log-Detailansicht klar
230
+ [ ] 76. Task-Gruppierung ok
231
+ [ ] 77. Statusfarben sinnvoll
232
+ [ ] 78. Event-Summaries spezifiziert
233
+ [ ] 79. Quick-Actions geprüft
234
+ [ ] 80. Resume/Open abgesichert
235
+ [ ] 81. Claude-Tests da
236
+ [ ] 82. Packaging-Tests erweitert
237
+ [ ] 83. Importfehler-Tests da
238
+ [ ] 84. Malformed-Datei-Tests da
239
+ [ ] 85. UI-Smoke-Guard da
240
+ [ ] 86. Prozess-Familien-Tests da
241
+ [ ] 87. Recovery-Tests da
242
+ [ ] 88. Pytest-Check etabliert
243
+ [ ] 89. Compileall-Check etabliert
244
+ [ ] 90. GUI-Smoke-Checkliste da
245
+ [ ] 91. systemd getestet
246
+ [ ] 92. Desktop-Datei validiert
247
+ [ ] 93. Naming konsistent
248
+ [ ] 94. README verbessert
249
+ [ ] 95. Pop!_OS-Guide da
250
+ [ ] 96. Troubleshooting da
251
+ [ ] 97. Positionierung klar
252
+ [ ] 98. Milestone fest
253
+ [ ] 99. Release-Checkliste da
254
+ [ ] 100. Beta gebaut
.hermes/plans/2026-04-22_155500-codex-dashboard-p0-v3.md ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CodexDashBoard — P0-Plan v3
2
+
3
+ > Fokus: nur der kritische Pfad bis zu einer lokal nutzbaren, stabil startbaren CodexDashBoard-Basis.
4
+
5
+ Ziel:
6
+ Eine lokal installierbare Linux-App, die ohne Import-Crash startet, klare Fehlermeldungen bei fehlenden GUI-Systemabhängigkeiten zeigt und die Kernansichten Sessions, Prozesse und Logs für Codex zuverlässig bereitstellt.
7
+
8
+ Definition of Done für P0:
9
+ - `python3 -m pip install -e '.[dev]'` funktioniert
10
+ - README dokumentiert nötige Systempakete für GUI
11
+ - Start ohne `gi` crasht nicht beim Import, sondern endet mit verständlicher Meldung
12
+ - Start mit erfüllten Abhängigkeiten öffnet die App
13
+ - Codex-Kernviews funktionieren: Overview, Sessions, Processes, Logs
14
+ - `python3 -m pytest -q` grün
15
+ - `python3 -m compileall src tests` grün
16
+
17
+ Kritischer Pfad:
18
+ 1. Scope einfrieren
19
+ 2. Dependency-Root-Cause dokumentieren
20
+ 3. Import-sicheren Startup bauen
21
+ 4. README/Packaging angleichen
22
+ 5. Kernviews verifizieren
23
+ 6. Tests ergänzen
24
+ 7. Smoke-Test durchführen
25
+
26
+ ---
27
+
28
+ ## Die ersten 10 Tasks zum sofort Ausführen
29
+
30
+ ### Task 1 — Scope einfrieren
31
+ Acceptance Criteria:
32
+ - v0.2 ist Codex-first schriftlich festgehalten
33
+ - Unfertige Claude-Views sind als out-of-scope oder optional markiert
34
+
35
+ ### Task 2 — GUI-Systemabhängigkeiten exakt ermitteln
36
+ Acceptance Criteria:
37
+ - Liste der nötigen apt-Pakete steht fest
38
+ - `girepository-2.0` Root Cause ist dokumentiert
39
+
40
+ ### Task 3 — README um Systempakete ergänzen
41
+ Acceptance Criteria:
42
+ - README enthält einen GUI-Install-Block für Pop!_OS/Ubuntu
43
+ - README trennt klar zwischen `.[dev]` und `.[gui]`
44
+
45
+ ### Task 4 — Entry-Point gegen `gi`-Import-Crash absichern
46
+ Acceptance Criteria:
47
+ - `python3 -m codex_dashboard` ohne `gi` zeigt verständliche Fehlermeldung
48
+ - Prozess endet kontrolliert mit non-zero exit code
49
+
50
+ ### Task 5 — App-Importstruktur bereinigen
51
+ Acceptance Criteria:
52
+ - `__main__.py` importiert nicht unkontrolliert GTK-lastige Module
53
+ - GUI-Importe passieren erst im geschützten Startup-Pfad
54
+
55
+ ### Task 6 — No-GUI-Startup testen
56
+ Acceptance Criteria:
57
+ - automatischer Test deckt fehlendes `gi` ab
58
+ - Fehlermeldung ist stabil und verständlich
59
+
60
+ ### Task 7 — Kernviews inventarisieren und Scope kürzen
61
+ Acceptance Criteria:
62
+ - Für P0 bleiben nur Overview, Sessions, Processes, Logs aktiv
63
+ - Nicht fertige Views sind versteckt oder entfernt
64
+
65
+ ### Task 8 — Kernviews manuell durchtesten
66
+ Acceptance Criteria:
67
+ - Keine Crashes beim Wechsel zwischen Kernviews
68
+ - Leere Zustände funktionieren ohne Daten
69
+
70
+ ### Task 9 — Test-Suite und Compileall grün halten
71
+ Acceptance Criteria:
72
+ - `python3 -m pytest -q` grün
73
+ - `python3 -m compileall src tests` grün
74
+
75
+ ### Task 10 — P0-Smoke-Checkliste abhaken
76
+ Acceptance Criteria:
77
+ - Installation dokumentiert
78
+ - Startpfade verifiziert
79
+ - Kernviews verifiziert
80
+ - bekannte Restpunkte notiert
81
+
82
+ ---
83
+
84
+ ## P0-Checkliste
85
+
86
+ [ ] 1. v0.2 Codex-first eingefroren
87
+ [ ] 2. Claude-Views Scope-Entscheidung getroffen
88
+ [ ] 3. GI-Systempakete identifiziert
89
+ [ ] 4. README-Systempakete ergänzt
90
+ [ ] 5. Dev/GUI-Install sauber getrennt
91
+ [ ] 6. `gi`-Import-Crash verhindert
92
+ [ ] 7. Freundliche Fehlermeldung vorhanden
93
+ [ ] 8. Entry-Point abgesichert
94
+ [ ] 9. Kernviews auf P0 reduziert
95
+ [ ] 10. Overview funktioniert
96
+ [ ] 11. Sessions funktioniert
97
+ [ ] 12. Processes funktioniert
98
+ [ ] 13. Logs funktioniert
99
+ [ ] 14. Leere Zustände funktionieren
100
+ [ ] 15. Tests grün
101
+ [ ] 16. Compileall grün
102
+ [ ] 17. Smoke-Test grün
103
+ [ ] 18. Restpunkte dokumentiert
.hermes_async/jobs/audit-2026-05-03-codexdashboard-crash-risk/result.json ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "job_id": "audit-2026-05-03-codexdashboard-crash-risk",
3
+ "status": "succeeded_bounded_pass_2",
4
+ "objective": "Implement crash-risk regression tests, startup fanout fixes, service throttling, process-scan determinism, and refresh coalescing without starting GUI or systemd service.",
5
+ "changed_files": [
6
+ "/home/smlflg/Projekte/CodexDashBoard/tests/test_crash_safety.py",
7
+ "/home/smlflg/Projekte/CodexDashBoard/tests/test_processes.py",
8
+ "/home/smlflg/Projekte/CodexDashBoard/src/codex_dashboard/codex_data.py",
9
+ "/home/smlflg/Projekte/CodexDashBoard/src/codex_dashboard/app.py",
10
+ "/home/smlflg/Projekte/CodexDashBoard/src/codex_dashboard/processes.py",
11
+ "/home/smlflg/Projekte/CodexDashBoard/systemd/codex-dashboard.service",
12
+ "/home/smlflg/Projekte/CodexDashBoard/.hermes_async/jobs/audit-2026-05-03-codexdashboard-crash-risk/result.json"
13
+ ],
14
+ "tests_run": [
15
+ {
16
+ "command": "python -m pytest tests/test_crash_safety.py -q",
17
+ "red_status_before_fix": "4 failed, 1 passed",
18
+ "failure_modes": [
19
+ "locked DB path exceeded 0.75s",
20
+ "CodexDashboard.__init__ called refresh_all",
21
+ "refresh_all refreshed every view at once",
22
+ "systemd service had Restart=on-failure RestartSec=5 / Burst=5"
23
+ ]
24
+ },
25
+ {
26
+ "command": "python -m pytest tests/test_processes.py::test_scan_processes_reads_proc_metadata_for_live_ps -q",
27
+ "red_status_before_fix": "failed: live ps scan returned cwd='' because read_proc_metadata was disabled after ps_output was populated",
28
+ "green_status_after_fix": "1 passed"
29
+ },
30
+ {
31
+ "command": "python -m pytest tests/test_crash_safety.py::test_refresh_methods_use_in_flight_guard_instead_of_raw_threads tests/test_crash_safety.py::test_in_flight_guard_discards_key_when_refresh_done -q",
32
+ "red_status_before_fix": "2 failed: refresh methods used raw _run_bg and _run_refresh_once helper did not exist",
33
+ "green_status_after_fix": "2 passed"
34
+ },
35
+ {
36
+ "command": "python -m pytest -q && python -m compileall -q src tests",
37
+ "status": "passed",
38
+ "summary": "16 passed in 0.28s; compileall passed"
39
+ }
40
+ ],
41
+ "fixes": [
42
+ "Reduced SQLite read-only timeout from 0.35s to 0.05s so locked DB refresh fails fast.",
43
+ "Changed startup from refresh_all() to _refresh_visible() to avoid constructor fanout across all views.",
44
+ "Changed refresh_all() to delegate to _refresh_visible() rather than refreshing all views at once.",
45
+ "Added _refresh_in_flight set and _run_refresh_once() so duplicate refresh requests per view are coalesced while a worker is active.",
46
+ "Updated refresh_overview, refresh_sessions, refresh_processes, and refresh_logs to use _run_refresh_once instead of raw _run_bg.",
47
+ "Made scan_processes(ps_output=...) deterministic by not reading live /proc metadata for supplied test ps output.",
48
+ "Preserved live scan /proc metadata by tracking whether ps_output was originally supplied before populating it from subprocess.run.",
49
+ "Changed user service restart throttling from RestartSec=5/StartLimitBurst=5 to RestartSec=30/StartLimitBurst=3."
50
+ ],
51
+ "root_cause_status": "bounded_verified_not_full_crash_verified",
52
+ "verifier_note": "Regression-test and code-level crash-risk claims are verified. The historical PC crash remains not fully root-cause-proven because no GUI/service run and no kernel/OOM/journal evidence were collected.",
53
+ "remaining_risks": [
54
+ "No real GTK GUI smoke test was run, intentionally, to avoid reproducing the reported PC crash.",
55
+ "No journalctl/kernel/OOM logs were inspected, so the historical PC crash remains unproven.",
56
+ "Coalescing is protected by static/AST tests; a future controlled GUI or fake-GTK runtime test would increase confidence."
57
+ ],
58
+ "human_review_needed": true,
59
+ "next_action": "Review diff. If acceptable, run a controlled headless/GUI smoke in a guarded environment before enabling or starting the user service."
60
+ }
README.md ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CodexDashBoard
2
+
3
+ Lightweight native dashboard for Codex on Linux.
4
+
5
+ The goal is not to clone the existing Claude Code panel. This project keeps the
6
+ surface small and stable:
7
+
8
+ - recent Codex threads from `~/.codex/state_5.sqlite`
9
+ - Codex-related processes
10
+ - recent Codex logs
11
+ - quick actions for resume, open folder, refresh, and safe process stop
12
+
13
+ Current v0.2 scope is Codex-first. Unfinished Claude-specific views are not part
14
+ of the shipped P0 UI.
15
+
16
+ ## GUI System Dependencies
17
+
18
+ On Ubuntu / Pop!_OS install the required GTK / GI development packages first:
19
+
20
+ ```bash
21
+ sudo apt-get update
22
+ sudo apt-get install -y \
23
+ libgirepository-2.0-dev \
24
+ gobject-introspection \
25
+ libgtk-3-dev \
26
+ libcairo2-dev \
27
+ pkg-config
28
+ ```
29
+
30
+ ## Run
31
+
32
+ ```bash
33
+ cd /home/smlflg/Projekte/CodexDashBoard
34
+ python3 -m pip install -e '.[gui]'
35
+ python3 -m codex_dashboard
36
+ ```
37
+
38
+ ## Test
39
+
40
+ ```bash
41
+ cd /home/smlflg/Projekte/CodexDashBoard
42
+ python3 -m pip install -e '.[dev]'
43
+ python3 -m pytest
44
+ python3 -m compileall src tests
45
+ ```
46
+
47
+ ## Install As User Service
48
+
49
+ ```bash
50
+ cp systemd/codex-dashboard.service ~/.config/systemd/user/
51
+ cp desktop/codex-dashboard.desktop ~/.local/share/applications/
52
+ systemctl --user daemon-reload
53
+ systemctl --user enable --now codex-dashboard.service
54
+ ```
55
+
56
+ The app logs to `~/.cache/codex-dashboard.log`.
57
+
artifacts/codex-audit/repo-audit.md ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Repo audit: CodexDashBoard
2
+
3
+ Job: work-2026-05-04-codexdashboard-audit
4
+ Scope: read-only audit of /home/smlflg/Projekte/CodexDashBoard; source code not intentionally modified.
5
+
6
+ ## Repository shape
7
+
8
+ CodexDashBoard is a small Python GTK3 desktop dashboard for Codex on Linux. The shipped UI is Codex-first with Overview, Sessions, Processes, and Logs views.
9
+
10
+ Key files inspected:
11
+ - README.md: documents GUI system packages, editable install, tests, and user service install.
12
+ - docs/PLAN.md: defines v1 scope and stability rules.
13
+ - pyproject.toml: package metadata; Python >=3.10; optional extras `gui` = PyGObject and `dev` = pytest.
14
+ - src/codex_dashboard/app.py: GTK UI, tray integration, refresh orchestration, process stop/resume/open-folder actions.
15
+ - src/codex_dashboard/codex_data.py: read-only SQLite data access for Codex state/logs.
16
+ - src/codex_dashboard/processes.py: ps/proc process scanner and SIGTERM helper.
17
+ - src/codex_dashboard/bootstrap.py: startup guard for missing GTK/PyGObject imports.
18
+ - src/codex_dashboard/claude_data.py: Claude data helpers present but not wired into current P0 UI.
19
+ - tests/: 5 test modules covering data access, process scanning, startup packaging, crash-safety invariants.
20
+ - systemd/codex-dashboard.service and desktop/codex-dashboard.desktop: local-path launch entries.
21
+
22
+ ## Commands run
23
+
24
+ - `python3 -m pytest` from repo root: PASSED, 16 tests.
25
+ - `python3 -m compileall -q src tests` from repo root: PASSED.
26
+ - `git status --short` from repo root: repository contents appear untracked in this checkout, so git cannot currently distinguish source changes from baseline.
27
+
28
+ ## Main findings
29
+
30
+ 1. Current tests pass quickly and cover several previous crash-risk themes: missing/locked SQLite DBs, refresh fan-out control, restart policy, optional GUI dependency, and WebKit exclusion.
31
+ 2. The data layer is intentionally tolerant: missing or locked Codex DBs return empty results/stats rather than crashing, using SQLite URI read-only mode and a very short timeout.
32
+ 3. The GUI keeps refreshes scoped to the visible view and uses `_refresh_in_flight` guards to avoid concurrent duplicate refreshes.
33
+ 4. Potentially unfinished code remains in `claude_data.py`; README/PLAN say Claude-specific views are outside shipped P0 UI, but this module is not tested and could confuse future maintenance.
34
+ 5. Process scanning still includes broad non-Codex keywords such as `claude`, `gemini`, and MCP servers despite the product being Codex-first. This may be intentional context, but it broadens the blast radius for display and termination actions.
35
+ 6. GUI behavior is mostly untested because tests avoid importing GTK. That is useful for headless CI but leaves click wiring, row rendering, dialogs, tray behavior, search debounce behavior, and xdg-open/resume flows uncovered.
36
+ 7. Packaging/install assets are hard-coded to `/home/smlflg/Projekte/CodexDashBoard`, which works for this machine but is brittle if copied, relocated, or packaged.
37
+ 8. Error handling favors silent empty lists for SQLite errors. This prevents crashes but may hide schema drift, permission issues, or corrupted databases from the user.
38
+ 9. The repo appears not to have tracked files in this checkout (`git status --short` reports all inspected content as untracked). That makes auditability and future change review risky.
39
+
40
+ ## High-value next tasks
41
+
42
+ 1. Add schema-drift tests and visible error reporting for Codex SQLite access.
43
+ 2. Add headless GTK/unit tests or an adapter layer for UI render and action wiring.
44
+ 3. Narrow and document process matching/termination scope for Codex-first behavior.
45
+ 4. Replace hard-coded install paths with templated/generated service and desktop entries.
46
+ 5. Decide whether to remove, hide behind feature flags, or test/document `claude_data.py`.
47
+
48
+ ## Audit limitations
49
+
50
+ - No dependency installation was performed.
51
+ - No GUI was launched to avoid side effects in a desktop session.
52
+ - No commits, pushes, deletes, or source edits were performed intentionally.
53
+ - Git baseline is unclear because the checkout reports all files as untracked.
artifacts/codex-audit/result.json ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "job_id": "work-2026-05-04-codexdashboard-audit",
3
+ "status": "complete",
4
+ "objective": "Read-only audit of /home/smlflg/Projekte/CodexDashBoard: identify unfinished/risky areas, missing tests, brittle code paths, and top 5 high-value next tasks.",
5
+ "changed_files": [
6
+ "/home/smlflg/Projekte/CodexDashBoard/artifacts/codex-audit/repo-audit.md",
7
+ "/home/smlflg/Projekte/CodexDashBoard/artifacts/codex-audit/top-tasks.json",
8
+ "/home/smlflg/Projekte/CodexDashBoard/artifacts/codex-audit/test-gaps.md",
9
+ "/home/smlflg/Projekte/CodexDashBoard/artifacts/codex-audit/risk-scan.md",
10
+ "/home/smlflg/Projekte/CodexDashBoard/artifacts/codex-audit/result.json"
11
+ ],
12
+ "artifact_paths": [
13
+ "/home/smlflg/Projekte/CodexDashBoard/artifacts/codex-audit/repo-audit.md",
14
+ "/home/smlflg/Projekte/CodexDashBoard/artifacts/codex-audit/top-tasks.json",
15
+ "/home/smlflg/Projekte/CodexDashBoard/artifacts/codex-audit/test-gaps.md",
16
+ "/home/smlflg/Projekte/CodexDashBoard/artifacts/codex-audit/risk-scan.md",
17
+ "/home/smlflg/Projekte/CodexDashBoard/artifacts/codex-audit/result.json"
18
+ ],
19
+ "verifier": {
20
+ "command": "test -s artifacts/codex-audit/repo-audit.md && test -s artifacts/codex-audit/top-tasks.json && test -s artifacts/codex-audit/test-gaps.md && test -s artifacts/codex-audit/risk-scan.md && test -s artifacts/codex-audit/result.json",
21
+ "exit_code": 0
22
+ },
23
+ "passed_checks": [
24
+ "python3 -m pytest: 16 passed",
25
+ "python3 -m compileall -q src tests: passed",
26
+ "required artifact verifier: passed"
27
+ ],
28
+ "failed_checks": [],
29
+ "top_next_actions": [
30
+ "Add Codex SQLite schema-drift coverage and user-visible data errors",
31
+ "Introduce testable UI adapter seams for GTK actions and row rendering",
32
+ "Tighten and document process matching and termination scope",
33
+ "Make service and desktop launch assets relocatable",
34
+ "Resolve Claude helper module status"
35
+ ],
36
+ "needs_human": false,
37
+ "next_action": "Parent/owner should review artifacts and choose one of the top-ranked tasks; no further worker action pending.",
38
+ "notes": [
39
+ "No dependencies were installed and no GUI was launched.",
40
+ "Git status showed all repo content as untracked in this checkout, limiting baseline auditability."
41
+ ]
42
+ }
artifacts/codex-audit/risk-scan.md ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Risk scan
2
+
3
+ ## Risk level summary
4
+
5
+ Overall observed risk: moderate.
6
+
7
+ The codebase is small and has useful crash-safety tests, but several risks remain around silent data failures, GUI code not covered by tests, broad process matching connected to termination actions, hard-coded local install paths, and unclear repository tracking state.
8
+
9
+ ## Findings
10
+
11
+ ### R1. Silent SQLite failures can mask broken data access
12
+
13
+ Severity: high
14
+ Files: src/codex_dashboard/codex_data.py, src/codex_dashboard/app.py
15
+
16
+ `list_threads`, `get_stats`, and `list_logs` catch FileNotFoundError/sqlite3.Error and return empty results. This protects the UI from crashes and is consistent with stability rules, but a schema change or permission/corruption issue appears identical to "no data". The UI currently renders empty-state rows/status messages rather than explicit degraded-state diagnostics.
17
+
18
+ Recommended next step: preserve crash-safety while returning/reporting a lightweight error state that the UI can show and tests can assert.
19
+
20
+ ### R2. Process scanner has broad matching connected to stop actions
21
+
22
+ Severity: high
23
+ Files: src/codex_dashboard/processes.py, src/codex_dashboard/app.py
24
+
25
+ The dashboard is documented as Codex-first, but `PROCESS_KEYWORDS` includes Claude, Gemini, OpenCode, and MCP terms. The Processes view exposes a stop button for every scanned item after a confirmation dialog. Broad substring matching can produce false positives or include non-Codex tools unexpectedly.
26
+
27
+ Recommended next step: define a family/stoppable policy, make Codex the safest default, and add false-positive tests.
28
+
29
+ ### R3. GUI action paths are brittle and mostly untested
30
+
31
+ Severity: medium-high
32
+ Files: src/codex_dashboard/app.py
33
+
34
+ GTK-specific rendering, tray integration, status updates, and button callbacks are not directly tested. Important subprocess paths include `xdg-open`, `codex resume`, and optional `kitty`. `_open_path` has no OSError guard; `_resume_thread` guards OSError after constructing commands.
35
+
36
+ Recommended next step: extract side-effect adapters or command builders so they can be tested without launching GUI processes.
37
+
38
+ ### R4. Hard-coded local paths in install assets
39
+
40
+ Severity: medium
41
+ Files: systemd/codex-dashboard.service, desktop/codex-dashboard.desktop, README.md
42
+
43
+ Service and desktop files point to `/home/smlflg/Projekte/CodexDashBoard`. This is acceptable for a personal local checkout but risky for reuse, relocation, or packaging. It can also become stale if the repo moves.
44
+
45
+ Recommended next step: generate these files from templates or document them as local examples only.
46
+
47
+ ### R5. Untracked repository state reduces auditability
48
+
49
+ Severity: medium
50
+ Evidence: `git status --short` reported all repository contents as untracked.
51
+
52
+ Because files are not tracked in this checkout, future audits cannot reliably use git diffs to separate intentional changes, generated files, or stale artifacts. This also makes it hard to verify that this read-only audit did not alter non-artifact files beyond command-generated caches.
53
+
54
+ Recommended next step: establish a clean tracked baseline or intentionally document this as an unversioned local project.
55
+
56
+ ### R6. Claude helper module is unfinished relative to product scope
57
+
58
+ Severity: medium
59
+ Files: src/codex_dashboard/claude_data.py, docs/PLAN.md, README.md
60
+
61
+ The plan says unfinished Claude-specific views stay hidden/out of scope. `claude_data.py` is still substantial and untested. It may be harmless dead code, but it adds maintenance and future regression surface.
62
+
63
+ Recommended next step: remove it, mark it as experimental, or add tests if it is expected to remain maintained.
64
+
65
+ ### R7. Singleton lock uses fixed /tmp path
66
+
67
+ Severity: low-medium
68
+ Files: src/codex_dashboard/paths.py, src/codex_dashboard/app.py
69
+
70
+ `LOCK_PATH = /tmp/codex-dashboard.lock` is simple but global per host/user namespace rather than per-user runtime directory. On multi-user systems or stale lock edge cases, behavior may be surprising. `flock` mitigates stale lock content because locks release with fd close, but path placement is still coarse.
71
+
72
+ Recommended next step: consider XDG_RUNTIME_DIR or a per-user lock path.
73
+
74
+ ### R8. No explicit lint/static typing configuration
75
+
76
+ Severity: low
77
+ Files: pyproject.toml
78
+
79
+ There is no ruff/mypy/pyright configuration. The small codebase may not need it yet, but GTK callback-heavy code benefits from static checks and import hygiene.
80
+
81
+ Recommended next step: add lightweight linting once dependency/tooling policy is decided.
82
+
83
+ ## Positive controls observed
84
+
85
+ - Tests pass: 16/16.
86
+ - Compileall passes for src and tests.
87
+ - PyGObject is optional in packaging, improving headless testability.
88
+ - Startup has a friendly missing-GI message.
89
+ - SQLite is opened read-only with a short timeout.
90
+ - Refreshes are limited to visible views and guarded against duplicate in-flight work.
91
+ - Process stop asks for confirmation.
92
+ - systemd restart policy includes a non-aggressive RestartSec and burst limit.
artifacts/codex-audit/test-gaps.md ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Test gaps
2
+
3
+ ## Existing test coverage observed
4
+
5
+ Test modules inspected:
6
+ - tests/test_codex_data.py
7
+ - tests/test_processes.py
8
+ - tests/test_startup.py
9
+ - tests/test_packaging.py
10
+ - tests/test_crash_safety.py
11
+
12
+ Commands run:
13
+ - `python3 -m pytest`: 16 passed.
14
+ - `python3 -m compileall -q src tests`: passed.
15
+
16
+ Covered areas:
17
+ - Codex thread listing hides archived sessions by default.
18
+ - Archived search path works.
19
+ - Missing state DB returns zero stats.
20
+ - Locked SQLite DBs return quickly without exceptions.
21
+ - Process scanner filters simple ps output and reads /proc metadata in mocked live mode.
22
+ - Uptime formatting.
23
+ - Optional GUI dependency packaging.
24
+ - Missing GI/GTK startup message.
25
+ - Refresh fan-out and in-flight guards via AST tests.
26
+ - systemd restart policy guard.
27
+ - WebKit exclusion.
28
+
29
+ ## Important gaps
30
+
31
+ 1. GUI behavior is mostly untested.
32
+ - No tests instantiate/render rows under GTK.
33
+ - No tests validate button wiring for resume/open-folder/stop.
34
+ - No tests cover tray menu behavior, close-to-tray behavior, or status text transitions.
35
+ - No tests cover search entry behavior beyond data-layer query filtering.
36
+
37
+ 2. SQLite schema-drift handling is under-tested.
38
+ - The locked-DB test uses a minimal table that triggers sqlite errors safely, but there are no explicit tests for real Codex schema changes.
39
+ - Missing columns, renamed columns, null-heavy rows, unexpected timestamp units, or invalid token types are not separately asserted.
40
+ - `list_logs` has no normal-path fixture test for logs_2.sqlite structure.
41
+
42
+ 3. Silent failure behavior is untested from the UI perspective.
43
+ - Data functions return empty lists/stats on sqlite errors. No tests assert whether the UI can tell "no sessions" apart from "database unreadable".
44
+
45
+ 4. Process matching false positives are under-tested.
46
+ - Current tests use simple positive/negative lines.
47
+ - No tests cover command arguments, paths, or filenames containing keywords accidentally.
48
+ - No tests define which process families should be stoppable in a Codex-first dashboard.
49
+
50
+ 5. Destructive process action is minimally covered.
51
+ - `terminate_process` directly sends SIGTERM, but no tests cover `_confirm_stop` wiring with mocked dialog response and mocked termination.
52
+ - No test ensures the app never stops its own dashboard process from the UI list beyond scanner-level filtering.
53
+
54
+ 6. Packaging/install behavior is lightly checked only.
55
+ - No tests for desktop entry correctness beyond WebKit absence.
56
+ - No tests that hard-coded service paths match current repo or installed package entry points.
57
+ - No console_scripts entry point exists to test.
58
+
59
+ 7. Claude data helpers have no tests.
60
+ - `claude_data.py` includes JSON, JSONL, grouping, stats, session, and event parsing helpers but is not covered by the current test suite.
61
+ - If this module stays in scope, it needs malformed-input and large-directory tests. If it is intentionally out of P0 scope, the repo should clarify that.
62
+
63
+ ## Suggested next test additions
64
+
65
+ - `tests/test_logs_data.py`: fixture logs database; normal listing, search, missing/corrupt DB behavior.
66
+ - `tests/test_schema_drift.py`: expected Codex schema variants; missing/renamed columns; invalid/null values.
67
+ - `tests/test_process_false_positives.py`: keyword-in-path/argument cases and family/stoppable policy.
68
+ - `tests/test_ui_actions.py`: command construction and stop confirmation with GTK/subprocess/os.kill mocked or moved behind adapter functions.
69
+ - `tests/test_install_assets.py`: service/desktop paths, relocatability assumptions, package launch entry point if added.
70
+ - `tests/test_claude_data.py` only if Claude helpers remain maintained code.
artifacts/codex-audit/top-tasks.json ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "rank": 1,
4
+ "title": "Add Codex SQLite schema-drift coverage and user-visible data errors",
5
+ "why": "The app currently converts FileNotFoundError/sqlite3.Error into empty lists or zero stats. That prevents crashes but can silently hide DB schema changes, permission failures, or corrupt data.",
6
+ "suggested_checks": [
7
+ "test missing expected columns in threads/logs tables",
8
+ "test malformed timestamp/token fields",
9
+ "test UI/status layer can distinguish no data from data access error"
10
+ ],
11
+ "files_to_touch_later": [
12
+ "src/codex_dashboard/codex_data.py",
13
+ "src/codex_dashboard/app.py",
14
+ "tests/test_codex_data.py",
15
+ "tests/test_crash_safety.py"
16
+ ]
17
+ },
18
+ {
19
+ "rank": 2,
20
+ "title": "Introduce testable UI adapter seams for GTK actions and row rendering",
21
+ "why": "Headless tests cover startup/import safety but not most GUI behavior: buttons, dialogs, row contents, tray menu, refresh status updates, or xdg-open/resume command construction.",
22
+ "suggested_checks": [
23
+ "unit-test command construction for resume/open-folder without launching processes",
24
+ "test stop action confirmation path with mocked terminate_process",
25
+ "test visible-child refresh routing and status text behavior"
26
+ ],
27
+ "files_to_touch_later": [
28
+ "src/codex_dashboard/app.py",
29
+ "tests/"
30
+ ]
31
+ },
32
+ {
33
+ "rank": 3,
34
+ "title": "Tighten and document process matching and termination scope",
35
+ "why": "The process scanner is used by a UI with stop buttons, but it matches broad keywords including claude, gemini, opencode, and MCP servers although the product is described as Codex-first.",
36
+ "suggested_checks": [
37
+ "test false-positive commands such as filenames or arguments containing codex/claude substrings",
38
+ "add family filters or safer default for stoppable processes",
39
+ "document why non-Codex families are included if retained"
40
+ ],
41
+ "files_to_touch_later": [
42
+ "src/codex_dashboard/processes.py",
43
+ "src/codex_dashboard/app.py",
44
+ "tests/test_processes.py"
45
+ ]
46
+ },
47
+ {
48
+ "rank": 4,
49
+ "title": "Make service and desktop launch assets relocatable",
50
+ "why": "systemd and desktop files contain hard-coded absolute paths for this checkout. That is brittle for other users, relocated clones, or packaging.",
51
+ "suggested_checks": [
52
+ "generate files from templates or document local-only nature",
53
+ "test ExecStart/Exec paths are not stale",
54
+ "consider console_scripts entry point for installed package launch"
55
+ ],
56
+ "files_to_touch_later": [
57
+ "pyproject.toml",
58
+ "systemd/codex-dashboard.service",
59
+ "desktop/codex-dashboard.desktop",
60
+ "README.md"
61
+ ]
62
+ },
63
+ {
64
+ "rank": 5,
65
+ "title": "Resolve Claude helper module status",
66
+ "why": "README and PLAN say unfinished Claude-specific views are not shipped, yet src/codex_dashboard/claude_data.py remains substantial and untested. This increases maintenance surface and ambiguity.",
67
+ "suggested_checks": [
68
+ "decide keep/remove/feature-flag",
69
+ "if kept, add focused tests for malformed JSONL, bad task JSON, and large directories",
70
+ "make README explicitly state why Claude data helpers exist"
71
+ ],
72
+ "files_to_touch_later": [
73
+ "src/codex_dashboard/claude_data.py",
74
+ "tests/",
75
+ "README.md",
76
+ "docs/PLAN.md"
77
+ ]
78
+ }
79
+ ]
artifacts/codex-implement/sqlite-error-tests/diff-summary.md ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Diff summary
2
+
3
+ Implemented narrow SQLite DB error visibility coverage and minimal production seam.
4
+
5
+ Changed files:
6
+ - `src/codex_dashboard/codex_data.py`
7
+ - Added `DataAccessError` and `DataResult` result seam.
8
+ - Added `list_threads_result()` and `list_logs_result()` to preserve non-crashing list APIs while exposing schema/data access errors to callers.
9
+ - Added optional `DashboardStats.error` so stats schema drift is distinguishable from a missing/empty database.
10
+ - Missing DB remains non-error empty/zero state for existing behavior.
11
+ - `src/codex_dashboard/app.py`
12
+ - Session refresh now uses `list_threads_result()` and shows `Session database error: ...` when a DB/schema error is reported.
13
+ - Log refresh now uses `list_logs_result()` and shows `Log database error: ...` when a DB/schema error is reported.
14
+ - `tests/test_codex_data.py`
15
+ - Added failing-first schema drift and malformed row coverage for Codex state.sqlite.
16
+ - Added stats schema drift error visibility coverage.
17
+ - `tests/test_logs_data.py`
18
+ - Added normal logs_2.sqlite fixture/search coverage.
19
+ - Added logs schema drift error visibility coverage.
20
+ - `tests/test_data_error_visibility.py`
21
+ - Added AST-level guard that session/log refresh code distinguishes DB errors from empty data.
22
+
23
+ TDD evidence:
24
+ - Initial focused test run after writing tests failed with missing `list_threads_result` / `list_logs_result` imports.
25
+ - After minimal source changes, focused tests passed: `10 passed in 0.08s`.
26
+ - Full validation passed: `23 passed in 0.32s`; compileall passed.
artifacts/codex-implement/sqlite-error-tests/result.json ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "job_id": "work-2026-05-04-codexdashboard-sqlite-error-tests",
3
+ "status": "completed",
4
+ "repo": "/home/smlflg/Projekte/CodexDashBoard",
5
+ "tdd": {
6
+ "failing_tests_first": {
7
+ "command": "python3 -m pytest tests/test_codex_data.py tests/test_logs_data.py tests/test_data_error_visibility.py",
8
+ "exit_code": 2,
9
+ "expected_failure": "ImportError: missing list_threads_result and list_logs_result before implementation"
10
+ },
11
+ "focused_tests": {
12
+ "command": "python3 -m pytest tests/test_codex_data.py tests/test_logs_data.py tests/test_data_error_visibility.py",
13
+ "exit_code": 0,
14
+ "result": "10 passed in 0.08s"
15
+ },
16
+ "full_suite": {
17
+ "command": "python3 -m pytest && python3 -m compileall -q src tests",
18
+ "exit_code": 0,
19
+ "result": "23 passed in 0.32s; compileall passed"
20
+ }
21
+ },
22
+ "files_modified": [
23
+ "src/codex_dashboard/codex_data.py",
24
+ "src/codex_dashboard/app.py",
25
+ "tests/test_codex_data.py"
26
+ ],
27
+ "files_created": [
28
+ "tests/test_logs_data.py",
29
+ "tests/test_data_error_visibility.py",
30
+ "artifacts/codex-implement/sqlite-error-tests/result.json",
31
+ "artifacts/codex-implement/sqlite-error-tests/diff-summary.md"
32
+ ],
33
+ "notes": [
34
+ "No commit, push, merge, dependency install, or unrelated refactor performed.",
35
+ "Repository baseline appears untracked in git status; changes were limited to allowed paths."
36
+ ]
37
+ }
artifacts/codex-validate/sqlite-tests-validation.json ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "job_id": "work-2026-05-04-codexdashboard-validate-sqlite-tests",
3
+ "status": "validated",
4
+ "validated_at": "2026-05-04",
5
+ "objective": "Validate that adding tests for SQLite schema drift / visible DB errors is narrow enough and identify exact files/tests. No implementation.",
6
+ "repo": "/home/smlflg/Projekte/CodexDashBoard",
7
+ "audit_input": "artifacts/codex-audit/result.json",
8
+ "current_checks": [
9
+ {
10
+ "command": "python3 -m pytest",
11
+ "exit_code": 0,
12
+ "result": "16 passed in 0.28s"
13
+ }
14
+ ],
15
+ "evidence": [
16
+ "Audit top task rank 1 is 'Add Codex SQLite schema-drift coverage and user-visible data errors'.",
17
+ "src/codex_dashboard/codex_data.py:list_threads catches FileNotFoundError/sqlite3.Error and returns []; get_stats returns zero DashboardStats; list_logs returns [].",
18
+ "src/codex_dashboard/app.py refresh handlers treat returned empty lists/stats as successful refreshes and render 'No Codex sessions found' or 'No Codex logs found', so DB errors are not currently distinguishable from no data.",
19
+ "tests/test_codex_data.py currently covers normal state.sqlite listing and missing stats DB only.",
20
+ "tests/test_crash_safety.py currently covers locked/minimal DB returning empty quickly, but not real schema drift, malformed values, normal logs DB, or visible DB error state."
21
+ ],
22
+ "recommended_scope": {
23
+ "size": "small_to_medium",
24
+ "narrow_enough": true,
25
+ "summary": "Keep the implementation limited to Codex SQLite data access error reporting and focused data-layer/UI-status tests. Do not combine with process matching, launch assets, GTK action refactors, or Claude helper cleanup.",
26
+ "phases": [
27
+ "Add focused failing tests for state.sqlite schema drift and logs_2.sqlite fixtures.",
28
+ "Add/adjust a minimal data-access result/error seam so SQLite schema/permission/corruption failures remain crash-safe but are visible to callers.",
29
+ "Add a narrow UI/status test seam or AST-level guard proving sessions/log refresh can present a data-access error distinct from empty data."
30
+ ],
31
+ "out_of_scope": [
32
+ "GTK row rendering and button behavior beyond DB-error status visibility",
33
+ "process scanner false-positive policy",
34
+ "systemd/desktop relocatability",
35
+ "Claude data helpers",
36
+ "dependency installation or broad GUI integration testing"
37
+ ],
38
+ "production_code_likely_needs_change": true,
39
+ "production_change_reason": "Tests can be added first, but user-visible DB errors cannot pass with tests alone because current codex_data functions collapse sqlite3.Error into empty results/zero stats and app.py marks those refreshes successful. A minimal non-crashing error state or result wrapper is likely needed."
40
+ },
41
+ "files_to_touch": [
42
+ {
43
+ "path": "src/codex_dashboard/codex_data.py",
44
+ "purpose": "Likely add minimal error-reporting/data-result seam while preserving no-crash behavior for FileNotFoundError/sqlite3.Error."
45
+ },
46
+ {
47
+ "path": "src/codex_dashboard/app.py",
48
+ "purpose": "Likely teach refresh/status layer to distinguish empty data from data access errors for sessions/logs/overview."
49
+ },
50
+ {
51
+ "path": "tests/test_codex_data.py",
52
+ "purpose": "Add focused state.sqlite schema drift, malformed/null field, and stats error tests."
53
+ },
54
+ {
55
+ "path": "tests/test_logs_data.py",
56
+ "purpose": "New focused logs_2.sqlite fixture tests for normal list_logs/search and logs schema drift/corrupt DB handling."
57
+ },
58
+ {
59
+ "path": "tests/test_crash_safety.py",
60
+ "purpose": "Optionally keep/add crash-safety regression that DB failures return quickly and never raise; avoid broadening this file into schema fixture coverage."
61
+ },
62
+ {
63
+ "path": "tests/test_data_error_visibility.py",
64
+ "purpose": "Optional new narrow test module for app/status seam if UI visibility is not covered in codex_data tests."
65
+ }
66
+ ],
67
+ "tests_to_add": [
68
+ {
69
+ "file": "tests/test_codex_data.py",
70
+ "test": "test_list_threads_schema_drift_missing_timestamp_columns_reports_data_error",
71
+ "intent": "Create a threads table missing created_at_ms/updated_at_ms and/or created_at/updated_at expected by the SELECT; assert the failure is represented as a data access error, not silently indistinguishable from no sessions."
72
+ },
73
+ {
74
+ "file": "tests/test_codex_data.py",
75
+ "test": "test_list_threads_schema_drift_missing_search_columns_reports_data_error",
76
+ "intent": "Create a threads table missing title/first_user_message/cwd/git_branch and assert query/listing does not crash and exposes schema drift."
77
+ },
78
+ {
79
+ "file": "tests/test_codex_data.py",
80
+ "test": "test_list_threads_malformed_tokens_or_timestamps_are_bounded",
81
+ "intent": "Use non-integer/null-heavy tokens_used and timestamp fields; assert either safe coercion or explicit row/data error behavior is defined and tested."
82
+ },
83
+ {
84
+ "file": "tests/test_codex_data.py",
85
+ "test": "test_get_stats_schema_drift_reports_error_without_exception",
86
+ "intent": "Assert get_stats preserves crash safety while exposing database/schema failure instead of only DashboardStats(0,0,0,0,None)."
87
+ },
88
+ {
89
+ "file": "tests/test_logs_data.py",
90
+ "test": "test_list_logs_reads_codex_logs_fixture_and_searches",
91
+ "intent": "Create logs table with id, ts, ts_nanos, level, target, thread_id, module_path, feedback_log_body; assert ordering, timestamp ms conversion, shortening/search."
92
+ },
93
+ {
94
+ "file": "tests/test_logs_data.py",
95
+ "test": "test_list_logs_schema_drift_missing_expected_columns_reports_data_error",
96
+ "intent": "Create logs table missing module_path or feedback_log_body/ts_nanos; assert no exception and visible error state."
97
+ },
98
+ {
99
+ "file": "tests/test_data_error_visibility.py",
100
+ "test": "test_session_refresh_status_distinguishes_database_error_from_no_sessions",
101
+ "intent": "Using a headless seam/mocked data function or extracted presenter, assert DB error maps to an error/degraded status rather than '0 sessions' and 'No Codex sessions found'."
102
+ },
103
+ {
104
+ "file": "tests/test_data_error_visibility.py",
105
+ "test": "test_log_refresh_status_distinguishes_database_error_from_no_logs",
106
+ "intent": "Using the same seam, assert logs DB error maps to visible error status rather than '0 log rows' and 'No Codex logs found'."
107
+ }
108
+ ],
109
+ "verifier_command": "test -s artifacts/codex-validate/sqlite-tests-validation.json",
110
+ "notes": [
111
+ "No source code was changed by this validation worker.",
112
+ "The artifact recommends exact implementation/test scope only; it does not implement tests or production changes."
113
+ ]
114
+ }
artifacts/codex-verify/sqlite-error-tests/result.json ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "job_id": "work-2026-05-04-codexdashboard-independent-verify",
3
+ "status": "passed",
4
+ "changed_files": [
5
+ "src/codex_dashboard/codex_data.py",
6
+ "src/codex_dashboard/app.py",
7
+ "tests/test_codex_data.py",
8
+ "tests/test_logs_data.py",
9
+ "tests/test_data_error_visibility.py",
10
+ "artifacts/codex-implement/sqlite-error-tests/result.json",
11
+ "artifacts/codex-implement/sqlite-error-tests/diff-summary.md"
12
+ ],
13
+ "checks": {
14
+ "repo_path": "/home/smlflg/Projekte/CodexDashBoard",
15
+ "git_diff_inspected": true,
16
+ "git_diff_note": "Repository has no HEAD commit; git status shows repository contents untracked, so changed_files were verified from implementation artifact and file inspection rather than a HEAD diff.",
17
+ "allowed_scope": true,
18
+ "pytest": {
19
+ "command": "python3 -m pytest",
20
+ "passed": true,
21
+ "result": "23 passed in 0.34s"
22
+ },
23
+ "compileall": {
24
+ "command": "python3 -m compileall -q src tests",
25
+ "passed": true
26
+ },
27
+ "implementation_artifacts_exist": true,
28
+ "error_visibility_covered": true,
29
+ "error_visibility_notes": [
30
+ "codex_data exposes DataResult/DataAccessError and preserves legacy empty-list behavior for missing databases.",
31
+ "tests cover state.sqlite schema drift, malformed token conversion, stats schema drift, logs_2.sqlite schema drift, normal log fixture/search behavior, and AST guards that session/log refresh status reports database errors separately from empty data.",
32
+ "app.py refresh_sessions and refresh_logs set explicit Session/Log database error status messages when result.error is present."
33
+ ]
34
+ },
35
+ "failed_checks": [],
36
+ "recommendation": "Accept implementation. Required verifier command passed, artifacts are present, and SQLite data-access errors are surfaced and tested for sessions/logs with data-layer coverage for state/log schema drift. Note: repository has no HEAD baseline, so independent scope verification is based on inspected files and implementation artifact rather than a git diff."
37
+ }
desktop/codex-dashboard.desktop ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [Desktop Entry]
2
+ Name=Codex Dashboard
3
+ Comment=Lightweight panel for Codex sessions, processes, and logs
4
+ Exec=env PYTHONPATH=/home/smlflg/Projekte/CodexDashBoard/src python3 -m codex_dashboard
5
+ Icon=utilities-system-monitor
6
+ Terminal=false
7
+ Type=Application
8
+ Categories=Development;Utility;
9
+ Keywords=codex;dashboard;panel;ai;
10
+ StartupWMClass=codex-dashboard
11
+
docs/PLAN.md ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CodexDashBoard Plan
2
+
3
+ ## Product Shape
4
+
5
+ Build a smaller, steadier dashboard than the current Claude Code panel.
6
+
7
+ CodexDashBoard should answer four questions quickly:
8
+
9
+ - What is Codex doing right now?
10
+ - What did I work on recently?
11
+ - Which processes are alive or stale?
12
+ - Where are the relevant logs?
13
+
14
+ ## v1 Scope
15
+
16
+ - Native GTK3 app.
17
+ - Sidebar navigation instead of many equal tabs.
18
+ - Read-only access to Codex SQLite databases.
19
+ - v0.2 ships Codex-first views only: Overview, Sessions, Processes, Logs.
20
+ - Unfinished Claude-specific views stay hidden or out of scope until they are complete.
21
+ - No WebKit embed.
22
+ - No Claude settings, Claude hooks, swarm views, brightness tools, or unrelated
23
+ panels.
24
+ - Process kill actions only with confirmation.
25
+
26
+ ## Stability Rules
27
+
28
+ - Data layer must work without GTK.
29
+ - Missing or locked Codex DBs must not crash the UI.
30
+ - Poll visible views only.
31
+ - Keep refreshes explicit and low-frequency.
32
+ - Log errors to `~/.cache/codex-dashboard.log`.
33
+
pyproject.toml ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "codex-dashboard"
3
+ version = "0.1.0"
4
+ description = "Lightweight GTK dashboard for Codex sessions and processes"
5
+ requires-python = ">=3.10"
6
+ dependencies = []
7
+
8
+ [project.optional-dependencies]
9
+ gui = [
10
+ "PyGObject>=3.42",
11
+ ]
12
+ dev = [
13
+ "pytest>=8",
14
+ ]
15
+
16
+ [tool.pytest.ini_options]
17
+ testpaths = ["tests"]
18
+ pythonpath = ["src"]
src/codex_dashboard/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """Lightweight Codex dashboard package."""
2
+
3
+ __version__ = "0.1.0"
4
+
src/codex_dashboard/__main__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from .bootstrap import run
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(run())
6
+
src/codex_dashboard/app.py ADDED
@@ -0,0 +1,737 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import fcntl
4
+ import logging
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import threading
9
+ import time
10
+ from pathlib import Path
11
+
12
+ import gi
13
+
14
+ gi.require_version("Gtk", "3.0")
15
+ gi.require_version("Gdk", "3.0")
16
+ from gi.repository import Gdk, GLib, Gtk, Pango
17
+
18
+ try:
19
+ gi.require_version("AyatanaAppIndicator3", "0.1")
20
+ from gi.repository import AyatanaAppIndicator3
21
+ except (ImportError, ValueError):
22
+ AyatanaAppIndicator3 = None
23
+
24
+ from .codex_data import (
25
+ DashboardStats,
26
+ ThreadSummary,
27
+ format_time,
28
+ format_tokens,
29
+ list_logs_result,
30
+ list_threads,
31
+ list_threads_result,
32
+ get_stats,
33
+ )
34
+ from .paths import CACHE_LOG, CONFIG_TOML, LOCK_PATH
35
+ from .processes import ProcessInfo, format_uptime, scan_processes, terminate_process
36
+
37
+
38
+ log = logging.getLogger("codex_dashboard")
39
+ _lock_file = None
40
+
41
+
42
+ CSS = b"""
43
+ window {
44
+ background: #0f172a;
45
+ color: #e2e8f0;
46
+ }
47
+ .topbar {
48
+ background: #111827;
49
+ border-bottom: 1px solid #1f2937;
50
+ padding: 10px 12px;
51
+ }
52
+ .app-title {
53
+ font-size: 18px;
54
+ font-weight: 700;
55
+ color: #f8fafc;
56
+ }
57
+ .muted {
58
+ color: #94a3b8;
59
+ }
60
+ .panel {
61
+ background: #0f172a;
62
+ }
63
+ .stat {
64
+ background: #111827;
65
+ border: 1px solid #1f2937;
66
+ border-radius: 10px;
67
+ padding: 12px;
68
+ }
69
+ .stat-value {
70
+ font-size: 22px;
71
+ font-weight: 700;
72
+ color: #f8fafc;
73
+ }
74
+ .badge,
75
+ .badge-ok,
76
+ .badge-warn,
77
+ .badge-idle {
78
+ border-radius: 999px;
79
+ padding: 4px 10px;
80
+ font-weight: 600;
81
+ }
82
+ .badge {
83
+ background: #1e293b;
84
+ color: #cbd5e1;
85
+ }
86
+ .badge-ok {
87
+ background: #123a2b;
88
+ color: #86efac;
89
+ }
90
+ .badge-warn {
91
+ background: #3f2a12;
92
+ color: #fcd34d;
93
+ }
94
+ .badge-idle {
95
+ background: #1f2937;
96
+ color: #cbd5e1;
97
+ }
98
+ .row {
99
+ border: 1px solid transparent;
100
+ border-bottom: 1px solid #1e293b;
101
+ border-radius: 8px;
102
+ padding: 10px 12px;
103
+ }
104
+ .row:hover {
105
+ background: #111827;
106
+ border-color: #334155;
107
+ }
108
+ button {
109
+ border-radius: 8px;
110
+ }
111
+ entry {
112
+ border-radius: 8px;
113
+ background: #111827;
114
+ color: #e2e8f0;
115
+ }
116
+ """
117
+
118
+
119
+ def _setup_logging() -> None:
120
+ CACHE_LOG.parent.mkdir(parents=True, exist_ok=True)
121
+ logging.basicConfig(
122
+ filename=str(CACHE_LOG),
123
+ level=logging.INFO,
124
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
125
+ )
126
+
127
+
128
+ def _acquire_singleton_lock() -> None:
129
+ global _lock_file
130
+ fd = os.open(LOCK_PATH, os.O_CREAT | os.O_WRONLY, 0o644)
131
+ _lock_file = os.fdopen(fd, "w")
132
+ try:
133
+ fcntl.flock(_lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
134
+ _lock_file.truncate(0)
135
+ _lock_file.write(str(os.getpid()))
136
+ _lock_file.flush()
137
+ except BlockingIOError:
138
+ log.warning("CodexDashBoard already running")
139
+ raise SystemExit(0)
140
+
141
+
142
+ def _label(text: str = "", *, xalign: float = 0.0, css: str | None = None) -> Gtk.Label:
143
+ label = Gtk.Label(label=text, xalign=xalign)
144
+ label.set_ellipsize(Pango.EllipsizeMode.END)
145
+ if css:
146
+ label.get_style_context().add_class(css)
147
+ return label
148
+
149
+
150
+ def _button(icon_name: str, tooltip: str) -> Gtk.Button:
151
+ button = Gtk.Button.new_from_icon_name(icon_name, Gtk.IconSize.BUTTON)
152
+ button.set_tooltip_text(tooltip)
153
+ return button
154
+
155
+
156
+ def _clear_list(list_box: Gtk.ListBox) -> None:
157
+ for child in list_box.get_children():
158
+ list_box.remove(child)
159
+
160
+
161
+ def _run_bg(work, done) -> None:
162
+ def worker():
163
+ try:
164
+ result = work()
165
+ except Exception as exc: # pragma: no cover - UI guard
166
+ log.exception("background task failed")
167
+ result = exc
168
+ GLib.idle_add(done, result)
169
+
170
+ threading.Thread(target=worker, daemon=True).start()
171
+
172
+
173
+ def _open_path(path: str | Path) -> None:
174
+ subprocess.Popen(["xdg-open", str(path)], start_new_session=True)
175
+
176
+
177
+ class CodexDashboard(Gtk.Window):
178
+ def __init__(self) -> None:
179
+ super().__init__(title="CodexDashBoard")
180
+ self.set_default_size(940, 640)
181
+ self.set_resizable(True)
182
+ self.set_wmclass("codex-dashboard", "CodexDashBoard")
183
+
184
+ self._tray_indicator = None
185
+ self._session_query = ""
186
+ self._log_query = ""
187
+ self._include_archived = False
188
+
189
+ self._stats_labels: dict[str, Gtk.Label] = {}
190
+ self._refresh_in_flight: set[str] = set()
191
+ self._overview_sessions = Gtk.ListBox()
192
+ self._overview_processes = Gtk.ListBox()
193
+ self._sessions_list = Gtk.ListBox()
194
+ self._processes_list = Gtk.ListBox()
195
+ self._logs_list = Gtk.ListBox()
196
+ self._status = _label("Ready", css="muted")
197
+
198
+ self._setup_css()
199
+ self._build_ui()
200
+ self._build_tray()
201
+ self.connect("delete-event", self._on_delete)
202
+
203
+ GLib.timeout_add_seconds(30, self._refresh_visible)
204
+ self._refresh_visible()
205
+
206
+ def _setup_css(self) -> None:
207
+ provider = Gtk.CssProvider()
208
+ provider.load_from_data(CSS)
209
+ screen = Gdk.Screen.get_default()
210
+ Gtk.StyleContext.add_provider_for_screen(
211
+ screen,
212
+ provider,
213
+ Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
214
+ )
215
+
216
+ def _build_ui(self) -> None:
217
+ root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
218
+ self.add(root)
219
+
220
+ topbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
221
+ topbar.get_style_context().add_class("topbar")
222
+ root.pack_start(topbar, False, False, 0)
223
+
224
+ title = _label("CodexDashBoard", css="app-title")
225
+ topbar.pack_start(title, False, False, 0)
226
+ topbar.pack_start(self._status, True, True, 0)
227
+
228
+ config_button = _button("emblem-system-symbolic", "Open Codex config")
229
+ config_button.connect("clicked", lambda *_: _open_path(CONFIG_TOML))
230
+ topbar.pack_end(config_button, False, False, 0)
231
+
232
+ refresh_button = _button("view-refresh-symbolic", "Refresh")
233
+ refresh_button.connect("clicked", lambda *_: self.refresh_all())
234
+ topbar.pack_end(refresh_button, False, False, 0)
235
+
236
+ body = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
237
+ root.pack_start(body, True, True, 0)
238
+
239
+ self.stack = Gtk.Stack()
240
+ self.stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
241
+ self.stack.set_transition_duration(120)
242
+ self.stack.connect("notify::visible-child-name", lambda *_: self._refresh_visible())
243
+
244
+ sidebar = Gtk.StackSidebar()
245
+ sidebar.set_stack(self.stack)
246
+ sidebar.set_size_request(150, -1)
247
+ body.pack_start(sidebar, False, False, 0)
248
+ body.pack_start(self.stack, True, True, 0)
249
+
250
+ self.stack.add_titled(self._build_overview(), "overview", "Overview")
251
+ self.stack.add_titled(self._build_sessions(), "sessions", "Sessions")
252
+ self.stack.add_titled(self._build_processes(), "processes", "Processes")
253
+ self.stack.add_titled(self._build_logs(), "logs", "Logs")
254
+
255
+ def _build_overview(self) -> Gtk.Widget:
256
+ scrolled = Gtk.ScrolledWindow()
257
+ scrolled.get_style_context().add_class("panel")
258
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14)
259
+ box.set_margin_top(14)
260
+ box.set_margin_bottom(14)
261
+ box.set_margin_start(14)
262
+ box.set_margin_end(14)
263
+ scrolled.add(box)
264
+
265
+ stats = Gtk.Grid(column_spacing=10, row_spacing=10)
266
+ box.pack_start(stats, False, False, 0)
267
+ for idx, (key, label_text) in enumerate(
268
+ [
269
+ ("threads", "Threads"),
270
+ ("recent", "Updated Last Hour"),
271
+ ("tokens", "Tokens"),
272
+ ("processes", "Agent Processes"),
273
+ ]
274
+ ):
275
+ card = self._stat_card(label_text)
276
+ stats.attach(card, idx % 3, idx // 3, 1, 1)
277
+ self._stats_labels[key] = card.value_label
278
+
279
+ box.pack_start(_label("Letzte 10 Codex Sessions", css="app-title"), False, False, 0)
280
+ box.pack_start(self._overview_sessions, False, False, 0)
281
+ box.pack_start(_label("Active Processes", css="app-title"), False, False, 0)
282
+ box.pack_start(self._overview_processes, False, False, 0)
283
+ return scrolled
284
+
285
+ def _stat_card(self, label_text: str) -> Gtk.Box:
286
+ card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
287
+ card.get_style_context().add_class("stat")
288
+ card.set_hexpand(True)
289
+ value = _label("-", css="stat-value")
290
+ label = _label(label_text, css="muted")
291
+ card.pack_start(value, False, False, 0)
292
+ card.pack_start(label, False, False, 0)
293
+ card.value_label = value # type: ignore[attr-defined]
294
+ return card
295
+
296
+ def _build_sessions(self) -> Gtk.Widget:
297
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
298
+ box.set_margin_top(10)
299
+ box.set_margin_bottom(10)
300
+ box.set_margin_start(10)
301
+ box.set_margin_end(10)
302
+
303
+ controls = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
304
+ search = Gtk.SearchEntry()
305
+ search.set_placeholder_text("Search title, cwd, branch")
306
+ search.connect("search-changed", self._on_session_search)
307
+ controls.pack_start(search, True, True, 0)
308
+
309
+ archived = Gtk.CheckButton(label="Archived")
310
+ archived.connect("toggled", self._on_archived_toggled)
311
+ controls.pack_end(archived, False, False, 0)
312
+ box.pack_start(controls, False, False, 0)
313
+
314
+ scrolled = Gtk.ScrolledWindow()
315
+ scrolled.add(self._sessions_list)
316
+ box.pack_start(scrolled, True, True, 0)
317
+ return box
318
+
319
+ def _build_processes(self) -> Gtk.Widget:
320
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
321
+ box.set_margin_top(10)
322
+ box.set_margin_bottom(10)
323
+ box.set_margin_start(10)
324
+ box.set_margin_end(10)
325
+ hint = _label(
326
+ "Codex-related processes. Stop actions ask first.",
327
+ css="muted",
328
+ )
329
+ box.pack_start(hint, False, False, 0)
330
+ scrolled = Gtk.ScrolledWindow()
331
+ scrolled.add(self._processes_list)
332
+ box.pack_start(scrolled, True, True, 0)
333
+ return box
334
+
335
+ def _build_logs(self) -> Gtk.Widget:
336
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
337
+ box.set_margin_top(10)
338
+ box.set_margin_bottom(10)
339
+ box.set_margin_start(10)
340
+ box.set_margin_end(10)
341
+ search = Gtk.SearchEntry()
342
+ search.set_placeholder_text("Search logs")
343
+ search.connect("search-changed", self._on_log_search)
344
+ box.pack_start(search, False, False, 0)
345
+ scrolled = Gtk.ScrolledWindow()
346
+ scrolled.add(self._logs_list)
347
+ box.pack_start(scrolled, True, True, 0)
348
+ return box
349
+
350
+ def _build_tray(self) -> None:
351
+ if AyatanaAppIndicator3 is None:
352
+ return
353
+ indicator = AyatanaAppIndicator3.Indicator.new(
354
+ "codex-dashboard",
355
+ "utilities-system-monitor",
356
+ AyatanaAppIndicator3.IndicatorCategory.APPLICATION_STATUS,
357
+ )
358
+ indicator.set_status(AyatanaAppIndicator3.IndicatorStatus.ACTIVE)
359
+ indicator.set_title("CodexDashBoard")
360
+
361
+ menu = Gtk.Menu()
362
+ show_item = Gtk.MenuItem(label="Show")
363
+ show_item.connect("activate", lambda *_: self._show_window())
364
+ menu.append(show_item)
365
+
366
+ refresh_item = Gtk.MenuItem(label="Refresh")
367
+ refresh_item.connect("activate", lambda *_: self.refresh_all())
368
+ menu.append(refresh_item)
369
+
370
+ menu.append(Gtk.SeparatorMenuItem())
371
+
372
+ quit_item = Gtk.MenuItem(label="Quit")
373
+ quit_item.connect("activate", lambda *_: Gtk.main_quit())
374
+ menu.append(quit_item)
375
+
376
+ menu.show_all()
377
+ indicator.set_menu(menu)
378
+ self._tray_indicator = indicator
379
+
380
+ def _show_window(self) -> None:
381
+ self.show_all()
382
+ self.present()
383
+
384
+ def _on_delete(self, *_args) -> bool:
385
+ if self._tray_indicator is not None:
386
+ self.hide()
387
+ return True
388
+ Gtk.main_quit()
389
+ return False
390
+
391
+ def _on_session_search(self, entry: Gtk.SearchEntry) -> None:
392
+ self._session_query = entry.get_text()
393
+ self.refresh_sessions()
394
+
395
+ def _on_archived_toggled(self, button: Gtk.CheckButton) -> None:
396
+ self._include_archived = button.get_active()
397
+ self.refresh_sessions()
398
+
399
+ def _on_log_search(self, entry: Gtk.SearchEntry) -> None:
400
+ self._log_query = entry.get_text()
401
+ self.refresh_logs()
402
+
403
+ def _refresh_visible(self) -> bool:
404
+ name = self.stack.get_visible_child_name()
405
+ if name == "sessions":
406
+ self.refresh_sessions()
407
+ elif name == "processes":
408
+ self.refresh_processes()
409
+ elif name == "logs":
410
+ self.refresh_logs()
411
+ else:
412
+ self.refresh_overview()
413
+ return True
414
+
415
+ def refresh_all(self) -> None:
416
+ self._refresh_visible()
417
+
418
+ def _run_refresh_once(self, key: str, work, done) -> None:
419
+ if key in self._refresh_in_flight:
420
+ self._set_status(f"{key.title()} refresh already running")
421
+ return
422
+ self._refresh_in_flight.add(key)
423
+
424
+ def wrapped_done(result):
425
+ try:
426
+ return done(result)
427
+ finally:
428
+ self._refresh_in_flight.discard(key)
429
+
430
+ _run_bg(work, wrapped_done)
431
+
432
+ def refresh_overview(self) -> None:
433
+ def work():
434
+ return get_stats(), list_threads(limit=10), scan_processes()
435
+
436
+ def done(result):
437
+ if isinstance(result, Exception):
438
+ self._set_status("Refresh failed")
439
+ return False
440
+ stats, threads, processes = result
441
+ self._render_overview(stats, threads, processes)
442
+ self._set_status("Overview refreshed")
443
+ return False
444
+
445
+ self._run_refresh_once("overview", work, done)
446
+
447
+ def refresh_sessions(self) -> None:
448
+ def work():
449
+ return list_threads_result(
450
+ query=self._session_query,
451
+ include_archived=self._include_archived,
452
+ limit=120,
453
+ )
454
+
455
+ def done(result):
456
+ if isinstance(result, Exception):
457
+ self._set_status("Session refresh failed")
458
+ return False
459
+ if result.error is not None:
460
+ self._render_threads(self._sessions_list, result.data)
461
+ self._set_status(f"Session database error: {result.error.message}")
462
+ return False
463
+ self._render_threads(self._sessions_list, result.data)
464
+ self._set_status(f"{len(result.data)} sessions")
465
+ return False
466
+
467
+ self._run_refresh_once("sessions", work, done)
468
+
469
+ def refresh_processes(self) -> None:
470
+ def done(result):
471
+ if isinstance(result, Exception):
472
+ self._set_status("Process refresh failed")
473
+ return False
474
+ self._render_processes(self._processes_list, result, full=True)
475
+ self._set_status(f"{len(result)} processes")
476
+ return False
477
+
478
+ self._run_refresh_once("processes", scan_processes, done)
479
+
480
+ def refresh_logs(self) -> None:
481
+ def work():
482
+ return list_logs_result(query=self._log_query, limit=150)
483
+
484
+ def done(result):
485
+ if isinstance(result, Exception):
486
+ self._set_status("Log refresh failed")
487
+ return False
488
+ if result.error is not None:
489
+ self._render_logs(result.data)
490
+ self._set_status(f"Log database error: {result.error.message}")
491
+ return False
492
+ self._render_logs(result.data)
493
+ self._set_status(f"{len(result.data)} log rows")
494
+ return False
495
+
496
+ self._run_refresh_once("logs", work, done)
497
+
498
+ def _render_overview(
499
+ self,
500
+ stats: DashboardStats,
501
+ threads: list[ThreadSummary],
502
+ processes: list[ProcessInfo],
503
+ ) -> None:
504
+ self._stats_labels["threads"].set_text(str(stats.thread_count))
505
+ self._stats_labels["recent"].set_text(str(stats.recent_hour_count))
506
+ self._stats_labels["tokens"].set_text(format_tokens(stats.total_tokens))
507
+ self._stats_labels["processes"].set_text(str(len(processes)))
508
+ active_cwds = {process.cwd for process in processes if process.cwd}
509
+ self._render_threads(
510
+ self._overview_sessions,
511
+ threads,
512
+ compact=True,
513
+ active_cwds=active_cwds,
514
+ )
515
+ self._render_processes(self._overview_processes, processes[:6], full=False)
516
+
517
+ def _render_threads(
518
+ self,
519
+ list_box: Gtk.ListBox,
520
+ threads: list[ThreadSummary],
521
+ *,
522
+ compact: bool = False,
523
+ active_cwds: set[str] | None = None,
524
+ ) -> None:
525
+ _clear_list(list_box)
526
+ if not threads:
527
+ list_box.add(self._empty_row("No Codex sessions found"))
528
+ list_box.show_all()
529
+ return
530
+ for thread in threads:
531
+ list_box.add(
532
+ self._thread_row(thread, compact=compact, active_cwds=active_cwds or set())
533
+ )
534
+ list_box.show_all()
535
+
536
+ def _thread_row(
537
+ self,
538
+ thread: ThreadSummary,
539
+ *,
540
+ compact: bool = False,
541
+ active_cwds: set[str] | None = None,
542
+ ) -> Gtk.ListBoxRow:
543
+ row = Gtk.ListBoxRow()
544
+ outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
545
+ outer.get_style_context().add_class("row")
546
+ row.add(outer)
547
+
548
+ text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
549
+ outer.pack_start(text_box, True, True, 0)
550
+
551
+ title_prefix = "Thema: " if compact else ""
552
+ title = _label(title_prefix + thread.title)
553
+ title.set_tooltip_text(thread.id)
554
+ text_box.pack_start(title, False, False, 0)
555
+
556
+ state_text, state_class = self._thread_state(thread, active_cwds or set())
557
+ meta_parts = [
558
+ "Stand: " + state_text,
559
+ "Update: " + format_time(thread.updated_ms),
560
+ "Projekt: " + (Path(thread.cwd).name if thread.cwd else "no cwd"),
561
+ thread.model or "model?",
562
+ ]
563
+ if thread.reasoning_effort:
564
+ meta_parts.append(thread.reasoning_effort)
565
+ if thread.git_branch:
566
+ meta_parts.append(thread.git_branch)
567
+ if not compact:
568
+ meta_parts.append(format_tokens(thread.tokens_used) + " tokens")
569
+ meta = _label(" | ".join(meta_parts), css="muted")
570
+ text_box.pack_start(meta, False, False, 0)
571
+
572
+ badge = _label(state_text, xalign=0.5, css=state_class)
573
+ outer.pack_end(badge, False, False, 0)
574
+
575
+ if not compact:
576
+ open_button = _button("folder-open-symbolic", "Open folder")
577
+ open_button.connect("clicked", lambda *_: self._open_thread_folder(thread))
578
+ outer.pack_end(open_button, False, False, 0)
579
+
580
+ resume_button = _button("media-playback-start-symbolic", "Resume in terminal")
581
+ resume_button.connect("clicked", lambda *_: self._resume_thread(thread))
582
+ outer.pack_end(resume_button, False, False, 0)
583
+
584
+ return row
585
+
586
+ def _thread_state(self, thread: ThreadSummary, active_cwds: set[str]) -> tuple[str, str]:
587
+ if thread.archived:
588
+ return "Archived", "badge"
589
+ if thread.cwd and thread.cwd in active_cwds:
590
+ return "Running", "badge-ok"
591
+ age_ms = int(time.time() * 1000) - thread.updated_ms
592
+ if age_ms <= 10 * 60 * 1000:
593
+ return "Active", "badge-ok"
594
+ if age_ms <= 60 * 60 * 1000:
595
+ return "Recent", "badge-warn"
596
+ return "Idle", "badge-idle"
597
+
598
+ def _render_processes(
599
+ self,
600
+ list_box: Gtk.ListBox,
601
+ processes: list[ProcessInfo],
602
+ *,
603
+ full: bool,
604
+ ) -> None:
605
+ _clear_list(list_box)
606
+ if not processes:
607
+ list_box.add(self._empty_row("No Codex-related processes found"))
608
+ list_box.show_all()
609
+ return
610
+ for process in processes:
611
+ list_box.add(self._process_row(process, full=full))
612
+ list_box.show_all()
613
+
614
+ def _process_row(self, process: ProcessInfo, *, full: bool) -> Gtk.ListBoxRow:
615
+ row = Gtk.ListBoxRow()
616
+ outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
617
+ outer.get_style_context().add_class("row")
618
+ row.add(outer)
619
+
620
+ text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
621
+ outer.pack_start(text_box, True, True, 0)
622
+
623
+ title = _label(f"{process.name} pid {process.pid}")
624
+ title.set_tooltip_text(process.command)
625
+ text_box.pack_start(title, False, False, 0)
626
+
627
+ cwd = Path(process.cwd).name if process.cwd else "-"
628
+ meta = (
629
+ f"{process.ram_mb:.1f} MB | {process.cpu:.1f}% CPU | "
630
+ f"{format_uptime(process.uptime_seconds)} | {cwd}"
631
+ )
632
+ text_box.pack_start(_label(meta, css="muted"), False, False, 0)
633
+
634
+ if process.stale:
635
+ badge = _label("Stale", xalign=0.5, css="badge-warn")
636
+ outer.pack_end(badge, False, False, 0)
637
+
638
+ if full:
639
+ stop_button = _button("process-stop-symbolic", "Stop process")
640
+ stop_button.connect("clicked", lambda *_: self._confirm_stop(process))
641
+ outer.pack_end(stop_button, False, False, 0)
642
+
643
+ return row
644
+
645
+ def _render_logs(self, logs) -> None:
646
+ _clear_list(self._logs_list)
647
+ if not logs:
648
+ self._logs_list.add(self._empty_row("No Codex logs found"))
649
+ self._logs_list.show_all()
650
+ return
651
+ for entry in logs:
652
+ row = Gtk.ListBoxRow()
653
+ outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
654
+ outer.get_style_context().add_class("row")
655
+ row.add(outer)
656
+
657
+ header = _label(
658
+ f"{format_time(entry.ts_ms)} | {entry.level} | {entry.target}"
659
+ )
660
+ outer.pack_start(header, False, False, 0)
661
+ if entry.message:
662
+ outer.pack_start(_label(entry.message, css="muted"), False, False, 0)
663
+ elif entry.thread_id:
664
+ outer.pack_start(_label(entry.thread_id, css="muted"), False, False, 0)
665
+ self._logs_list.add(row)
666
+ self._logs_list.show_all()
667
+
668
+ def _empty_row(self, text: str) -> Gtk.ListBoxRow:
669
+ row = Gtk.ListBoxRow()
670
+ label = _label(text, css="muted")
671
+ label.set_margin_top(12)
672
+ label.set_margin_bottom(12)
673
+ label.set_margin_start(12)
674
+ label.set_margin_end(12)
675
+ row.add(label)
676
+ return row
677
+
678
+ def _resume_thread(self, thread: ThreadSummary) -> None:
679
+ codex = shutil.which("codex")
680
+ kitty = shutil.which("kitty")
681
+ cwd = thread.cwd if thread.cwd and Path(thread.cwd).is_dir() else str(Path.home())
682
+ if codex is None:
683
+ self._set_status("codex command not found")
684
+ return
685
+ if kitty:
686
+ cmd = [kitty, "-e", codex, "resume", thread.id]
687
+ else:
688
+ cmd = [codex, "resume", thread.id]
689
+ try:
690
+ subprocess.Popen(cmd, cwd=cwd, start_new_session=True)
691
+ self._set_status("Resume launched")
692
+ except OSError:
693
+ log.exception("resume failed")
694
+ self._set_status("Resume failed")
695
+
696
+ def _open_thread_folder(self, thread: ThreadSummary) -> None:
697
+ path = Path(thread.cwd)
698
+ if path.is_dir():
699
+ _open_path(path)
700
+ self._set_status("Folder opened")
701
+ else:
702
+ self._set_status("Folder missing")
703
+
704
+ def _confirm_stop(self, process: ProcessInfo) -> None:
705
+ dialog = Gtk.MessageDialog(
706
+ transient_for=self,
707
+ flags=0,
708
+ message_type=Gtk.MessageType.WARNING,
709
+ buttons=Gtk.ButtonsType.CANCEL,
710
+ text=f"Stop process {process.pid}?",
711
+ )
712
+ dialog.format_secondary_text(process.command)
713
+ dialog.add_button("Stop", Gtk.ResponseType.OK)
714
+ response = dialog.run()
715
+ dialog.destroy()
716
+ if response != Gtk.ResponseType.OK:
717
+ return
718
+ try:
719
+ terminate_process(process.pid)
720
+ self._set_status(f"Sent SIGTERM to {process.pid}")
721
+ self.refresh_processes()
722
+ except OSError:
723
+ log.exception("terminate failed")
724
+ self._set_status("Stop failed")
725
+
726
+ def _set_status(self, text: str) -> None:
727
+ self._status.set_text(text)
728
+
729
+
730
+ def main() -> int:
731
+ _setup_logging()
732
+ _acquire_singleton_lock()
733
+ win = CodexDashboard()
734
+ win.connect("destroy", Gtk.main_quit)
735
+ win.show_all()
736
+ Gtk.main()
737
+ return 0
src/codex_dashboard/bootstrap.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from collections.abc import Callable
5
+
6
+
7
+ GUI_SYSTEM_PACKAGES = (
8
+ "libgirepository-2.0-dev",
9
+ "gobject-introspection",
10
+ "libgtk-3-dev",
11
+ "libcairo2-dev",
12
+ "pkg-config",
13
+ )
14
+
15
+
16
+ def format_missing_gi_message() -> str:
17
+ packages = " ".join(GUI_SYSTEM_PACKAGES)
18
+ return (
19
+ "CodexDashBoard needs PyGObject / GTK to start.\n"
20
+ "Install Ubuntu/Pop!_OS system packages:\n"
21
+ f" sudo apt-get install -y {packages}\n"
22
+ "Then install the Python GUI extra:\n"
23
+ " python3 -m pip install -e '.[gui]'"
24
+ )
25
+
26
+
27
+ def run(app_main_loader: Callable[[], Callable[[], int]] | None = None, *, err=None) -> int:
28
+ err = sys.stderr.write if err is None else err
29
+ app_main_loader = load_app_main if app_main_loader is None else app_main_loader
30
+ try:
31
+ app_main = app_main_loader()
32
+ except ImportError as exc:
33
+ text = str(exc)
34
+ if "gi" in text or "Gtk" in text or "Gdk" in text or "Ayatana" in text:
35
+ err(format_missing_gi_message() + "\n")
36
+ return 1
37
+ raise
38
+ return app_main()
39
+
40
+
41
+ def load_app_main() -> Callable[[], int]:
42
+ from .app import main
43
+
44
+ return main
src/codex_dashboard/claude_data.py ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .paths import CLAUDE_EVENTS_DIR, CLAUDE_PROJECTS_DIR, CLAUDE_TASKS_DIR
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ClaudeTask:
14
+ group_id: str
15
+ id: str
16
+ subject: str
17
+ description: str
18
+ status: str
19
+ blocks: tuple[str, ...]
20
+ blocked_by: tuple[str, ...]
21
+ path: Path
22
+ updated_ms: int
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ClaudeTaskGroup:
27
+ id: str
28
+ label: str
29
+ tasks: tuple[ClaudeTask, ...]
30
+ updated_ms: int
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ClaudeStats:
35
+ task_count: int
36
+ pending_count: int
37
+ active_count: int
38
+ completed_count: int
39
+ blocked_count: int
40
+ latest_task: ClaudeTask | None
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ClaudeEvent:
45
+ ts_ms: int
46
+ event_type: str
47
+ tool_name: str
48
+ cwd: str
49
+ session_id: str
50
+ summary: str
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class ClaudeSession:
55
+ id: str
56
+ project: str
57
+ path: Path
58
+ updated_ms: int
59
+
60
+
61
+ def _shorten(text: str | None, limit: int = 160) -> str:
62
+ value = (text or "").strip().replace("\n", " ")
63
+ if len(value) <= limit:
64
+ return value
65
+ return value[: limit - 3].rstrip() + "..."
66
+
67
+
68
+ def _as_str_list(value: Any) -> tuple[str, ...]:
69
+ if not isinstance(value, list):
70
+ return ()
71
+ return tuple(str(item) for item in value if str(item).strip())
72
+
73
+
74
+ def _load_json(path: Path) -> dict[str, Any] | None:
75
+ try:
76
+ data = json.loads(path.read_text(encoding="utf-8"))
77
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
78
+ return None
79
+ return data if isinstance(data, dict) else None
80
+
81
+
82
+ def _safe_mtime_ms(path: Path) -> int:
83
+ try:
84
+ return int(path.stat().st_mtime * 1000)
85
+ except OSError:
86
+ return 0
87
+
88
+
89
+ def _task_from_file(path: Path, group_id: str) -> ClaudeTask | None:
90
+ data = _load_json(path)
91
+ if data is None:
92
+ return None
93
+ task_id = str(data.get("id") or path.stem)
94
+ subject = _shorten(str(data.get("subject") or "(untitled task)"), 180)
95
+ description = _shorten(str(data.get("description") or ""), 260)
96
+ status = str(data.get("status") or "unknown").strip().lower() or "unknown"
97
+ return ClaudeTask(
98
+ group_id=group_id,
99
+ id=task_id,
100
+ subject=subject,
101
+ description=description,
102
+ status=status,
103
+ blocks=_as_str_list(data.get("blocks")),
104
+ blocked_by=_as_str_list(data.get("blockedBy")),
105
+ path=path,
106
+ updated_ms=_safe_mtime_ms(path),
107
+ )
108
+
109
+
110
+ def _project_label_from_dir(name: str) -> str:
111
+ label = name
112
+ for prefix in ("-home-smlflg-Projekte-", "-home-smlflg-"):
113
+ if label.startswith(prefix):
114
+ label = label[len(prefix) :]
115
+ break
116
+ label = label.replace("--", "/").replace("-", " / ")
117
+ return label or name
118
+
119
+
120
+ def _build_group_labels(projects_dir: Path) -> dict[str, str]:
121
+ if not projects_dir.is_dir():
122
+ return {}
123
+ labels: dict[str, str] = {}
124
+ try:
125
+ files = projects_dir.glob("*/*.jsonl")
126
+ for path in files:
127
+ labels.setdefault(path.stem, _project_label_from_dir(path.parent.name))
128
+ except OSError:
129
+ return {}
130
+ return labels
131
+
132
+
133
+ def list_claude_tasks(
134
+ *,
135
+ tasks_dir: Path = CLAUDE_TASKS_DIR,
136
+ projects_dir: Path = CLAUDE_PROJECTS_DIR,
137
+ query: str = "",
138
+ limit: int = 200,
139
+ ) -> list[ClaudeTask]:
140
+ if not tasks_dir.is_dir():
141
+ return []
142
+
143
+ needle = query.strip().lower()
144
+ tasks: list[ClaudeTask] = []
145
+ try:
146
+ group_dirs = [path for path in tasks_dir.iterdir() if path.is_dir()]
147
+ except OSError:
148
+ return []
149
+
150
+ for group_dir in group_dirs:
151
+ try:
152
+ files = sorted(group_dir.glob("*.json"))
153
+ except OSError:
154
+ continue
155
+ for path in files:
156
+ task = _task_from_file(path, group_dir.name)
157
+ if task is None:
158
+ continue
159
+ searchable = " ".join(
160
+ [
161
+ task.group_id,
162
+ task.id,
163
+ task.subject,
164
+ task.description,
165
+ task.status,
166
+ ]
167
+ ).lower()
168
+ if needle and needle not in searchable:
169
+ continue
170
+ tasks.append(task)
171
+
172
+ tasks.sort(key=lambda item: (-item.updated_ms, item.group_id, item.id))
173
+ return tasks[:limit]
174
+
175
+
176
+ def group_claude_tasks(
177
+ tasks: list[ClaudeTask],
178
+ *,
179
+ projects_dir: Path = CLAUDE_PROJECTS_DIR,
180
+ ) -> list[ClaudeTaskGroup]:
181
+ labels = _build_group_labels(projects_dir)
182
+ grouped: dict[str, list[ClaudeTask]] = {}
183
+ for task in tasks:
184
+ grouped.setdefault(task.group_id, []).append(task)
185
+
186
+ groups = [
187
+ ClaudeTaskGroup(
188
+ id=group_id,
189
+ label=labels.get(group_id, group_id),
190
+ tasks=tuple(sorted(items, key=lambda item: (item.status, item.id))),
191
+ updated_ms=max((item.updated_ms for item in items), default=0),
192
+ )
193
+ for group_id, items in grouped.items()
194
+ ]
195
+ groups.sort(key=lambda item: (-item.updated_ms, item.label))
196
+ return groups
197
+
198
+
199
+ def get_claude_stats(
200
+ *,
201
+ tasks_dir: Path = CLAUDE_TASKS_DIR,
202
+ projects_dir: Path = CLAUDE_PROJECTS_DIR,
203
+ ) -> ClaudeStats:
204
+ tasks = list_claude_tasks(tasks_dir=tasks_dir, projects_dir=projects_dir, limit=10_000)
205
+ pending = 0
206
+ active = 0
207
+ completed = 0
208
+ blocked = 0
209
+ for task in tasks:
210
+ status = task.status
211
+ if status in {"completed", "done", "closed"}:
212
+ completed += 1
213
+ elif status in {"active", "running", "in_progress", "in-progress"}:
214
+ active += 1
215
+ else:
216
+ pending += 1
217
+ if status == "blocked" or task.blocked_by:
218
+ blocked += 1
219
+ return ClaudeStats(
220
+ task_count=len(tasks),
221
+ pending_count=pending,
222
+ active_count=active,
223
+ completed_count=completed,
224
+ blocked_count=blocked,
225
+ latest_task=tasks[0] if tasks else None,
226
+ )
227
+
228
+
229
+ def list_claude_sessions(
230
+ *,
231
+ projects_dir: Path = CLAUDE_PROJECTS_DIR,
232
+ limit: int = 20,
233
+ ) -> list[ClaudeSession]:
234
+ if not projects_dir.is_dir():
235
+ return []
236
+ sessions: list[ClaudeSession] = []
237
+ try:
238
+ files = projects_dir.glob("*/*.jsonl")
239
+ for path in files:
240
+ sessions.append(
241
+ ClaudeSession(
242
+ id=path.stem,
243
+ project=_project_label_from_dir(path.parent.name),
244
+ path=path,
245
+ updated_ms=_safe_mtime_ms(path),
246
+ )
247
+ )
248
+ except OSError:
249
+ return []
250
+ sessions.sort(key=lambda item: -item.updated_ms)
251
+ return sessions[:limit]
252
+
253
+
254
+ def _event_summary(data: dict[str, Any]) -> str:
255
+ tool_input = data.get("tool_input")
256
+ if isinstance(tool_input, dict):
257
+ for key in ("description", "command", "file_path", "query"):
258
+ if tool_input.get(key):
259
+ return _shorten(str(tool_input[key]), 180)
260
+ tool_response = data.get("tool_response")
261
+ if isinstance(tool_response, dict):
262
+ for key in ("summary", "stdout", "stderr"):
263
+ if tool_response.get(key):
264
+ return _shorten(str(tool_response[key]), 180)
265
+ return ""
266
+
267
+
268
+ def list_recent_claude_events(
269
+ *,
270
+ events_dir: Path = CLAUDE_EVENTS_DIR,
271
+ limit: int = 20,
272
+ ) -> list[ClaudeEvent]:
273
+ if not events_dir.is_dir():
274
+ return []
275
+ try:
276
+ files = sorted(events_dir.glob("*.jsonl"), key=lambda path: path.name, reverse=True)
277
+ except OSError:
278
+ return []
279
+
280
+ events: list[ClaudeEvent] = []
281
+ for path in files:
282
+ if len(events) >= limit:
283
+ break
284
+ try:
285
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
286
+ except OSError:
287
+ continue
288
+ for line in reversed(lines):
289
+ if len(events) >= limit:
290
+ break
291
+ try:
292
+ data = json.loads(line)
293
+ except json.JSONDecodeError:
294
+ continue
295
+ if not isinstance(data, dict):
296
+ continue
297
+ logged_ts = data.get("logged_ts")
298
+ try:
299
+ ts_ms = int(float(logged_ts) * 1000)
300
+ except (TypeError, ValueError):
301
+ ts_ms = _safe_mtime_ms(path)
302
+ events.append(
303
+ ClaudeEvent(
304
+ ts_ms=ts_ms,
305
+ event_type=str(data.get("event_type") or data.get("hook_event_name") or ""),
306
+ tool_name=str(data.get("tool_name") or ""),
307
+ cwd=str(data.get("cwd") or ""),
308
+ session_id=str(data.get("session_id") or ""),
309
+ summary=_event_summary(data),
310
+ )
311
+ )
312
+
313
+ events.sort(key=lambda item: -item.ts_ms)
314
+ return events[:limit]
315
+
316
+
317
+ def format_claude_time(ms: int) -> str:
318
+ if not ms:
319
+ return "-"
320
+ return datetime.fromtimestamp(ms / 1000).strftime("%Y-%m-%d %H:%M")
src/codex_dashboard/codex_data.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ import sqlite3
7
+ from typing import Iterable
8
+
9
+ from .paths import LOGS_DB, STATE_DB, TUI_LOG
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ThreadSummary:
14
+ id: str
15
+ title: str
16
+ cwd: str
17
+ model: str
18
+ reasoning_effort: str
19
+ tokens_used: int
20
+ archived: bool
21
+ source: str
22
+ git_branch: str
23
+ created_ms: int
24
+ updated_ms: int
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class LogEntry:
29
+ id: int
30
+ ts_ms: int
31
+ level: str
32
+ target: str
33
+ thread_id: str
34
+ message: str
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class DataAccessError:
39
+ source: str
40
+ path: Path
41
+ message: str
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class DataResult:
46
+ data: object
47
+ error: DataAccessError | None = None
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class DashboardStats:
52
+ thread_count: int
53
+ archived_count: int
54
+ total_tokens: int
55
+ recent_hour_count: int
56
+ latest_thread: ThreadSummary | None
57
+ error: DataAccessError | None = None
58
+
59
+
60
+ def _data_error(source: str, path: Path, exc: BaseException) -> DataAccessError:
61
+ return DataAccessError(source=source, path=path, message=str(exc))
62
+
63
+
64
+ def _connect_readonly(path: Path) -> sqlite3.Connection:
65
+ if not path.exists():
66
+ raise FileNotFoundError(path)
67
+ uri = f"file:{path}?mode=ro"
68
+ conn = sqlite3.connect(uri, uri=True, timeout=0.05)
69
+ conn.row_factory = sqlite3.Row
70
+ return conn
71
+
72
+
73
+ def _shorten(text: str | None, limit: int = 120) -> str:
74
+ value = (text or "").strip().replace("\n", " ")
75
+ if len(value) <= limit:
76
+ return value
77
+ return value[: limit - 3].rstrip() + "..."
78
+
79
+
80
+ def _thread_from_row(row: sqlite3.Row) -> ThreadSummary:
81
+ title = row["title"] or row["first_user_message"] or "(untitled)"
82
+ return ThreadSummary(
83
+ id=row["id"],
84
+ title=_shorten(title),
85
+ cwd=row["cwd"] or "",
86
+ model=row["model"] or row["model_provider"] or "",
87
+ reasoning_effort=row["reasoning_effort"] or "",
88
+ tokens_used=int(row["tokens_used"] or 0),
89
+ archived=bool(row["archived"]),
90
+ source=row["source"] or "",
91
+ git_branch=row["git_branch"] or "",
92
+ created_ms=int(row["created_ms"] or 0),
93
+ updated_ms=int(row["updated_ms"] or 0),
94
+ )
95
+
96
+
97
+ def list_threads(
98
+ *,
99
+ db_path: Path = STATE_DB,
100
+ query: str = "",
101
+ include_archived: bool = False,
102
+ limit: int = 80,
103
+ ) -> list[ThreadSummary]:
104
+ return list_threads_result(
105
+ db_path=db_path,
106
+ query=query,
107
+ include_archived=include_archived,
108
+ limit=limit,
109
+ ).data
110
+
111
+
112
+ def list_threads_result(
113
+ *,
114
+ db_path: Path = STATE_DB,
115
+ query: str = "",
116
+ include_archived: bool = False,
117
+ limit: int = 80,
118
+ ) -> DataResult:
119
+ clauses = []
120
+ params: list[object] = []
121
+ if not include_archived:
122
+ clauses.append("archived = 0")
123
+ if query.strip():
124
+ needle = f"%{query.strip()}%"
125
+ clauses.append(
126
+ "(title LIKE ? OR first_user_message LIKE ? OR cwd LIKE ? OR git_branch LIKE ?)"
127
+ )
128
+ params.extend([needle, needle, needle, needle])
129
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
130
+ params.append(limit)
131
+
132
+ sql = f"""
133
+ SELECT
134
+ id,
135
+ title,
136
+ first_user_message,
137
+ cwd,
138
+ model,
139
+ model_provider,
140
+ reasoning_effort,
141
+ tokens_used,
142
+ archived,
143
+ source,
144
+ git_branch,
145
+ COALESCE(created_at_ms, created_at * 1000) AS created_ms,
146
+ COALESCE(updated_at_ms, updated_at * 1000) AS updated_ms
147
+ FROM threads
148
+ {where}
149
+ ORDER BY updated_ms DESC, id DESC
150
+ LIMIT ?
151
+ """
152
+ try:
153
+ with _connect_readonly(db_path) as conn:
154
+ return DataResult([_thread_from_row(row) for row in conn.execute(sql, params)])
155
+ except FileNotFoundError:
156
+ return DataResult([])
157
+ except (sqlite3.Error, TypeError, ValueError) as exc:
158
+ return DataResult([], _data_error("state.sqlite", db_path, exc))
159
+
160
+
161
+ def get_stats(*, db_path: Path = STATE_DB) -> DashboardStats:
162
+ threads_result = list_threads_result(db_path=db_path, include_archived=False, limit=1)
163
+ threads = threads_result.data
164
+ try:
165
+ with _connect_readonly(db_path) as conn:
166
+ row = conn.execute(
167
+ """
168
+ SELECT
169
+ COUNT(*) AS thread_count,
170
+ SUM(CASE WHEN archived = 1 THEN 1 ELSE 0 END) AS archived_count,
171
+ COALESCE(SUM(tokens_used), 0) AS total_tokens,
172
+ SUM(
173
+ CASE
174
+ WHEN COALESCE(updated_at_ms, updated_at * 1000)
175
+ >= (strftime('%s','now') * 1000 - 3600000)
176
+ THEN 1 ELSE 0
177
+ END
178
+ ) AS recent_hour_count
179
+ FROM threads
180
+ """
181
+ ).fetchone()
182
+ except FileNotFoundError:
183
+ return DashboardStats(0, 0, 0, 0, None)
184
+ except sqlite3.Error as exc:
185
+ return DashboardStats(0, 0, 0, 0, None, _data_error("state.sqlite", db_path, exc))
186
+
187
+ return DashboardStats(
188
+ thread_count=int(row["thread_count"] or 0),
189
+ archived_count=int(row["archived_count"] or 0),
190
+ total_tokens=int(row["total_tokens"] or 0),
191
+ recent_hour_count=int(row["recent_hour_count"] or 0),
192
+ latest_thread=threads[0] if threads else None,
193
+ error=threads_result.error,
194
+ )
195
+
196
+
197
+ def list_logs(
198
+ *,
199
+ db_path: Path = LOGS_DB,
200
+ query: str = "",
201
+ limit: int = 150,
202
+ ) -> list[LogEntry]:
203
+ return list_logs_result(db_path=db_path, query=query, limit=limit).data
204
+
205
+
206
+ def list_logs_result(
207
+ *,
208
+ db_path: Path = LOGS_DB,
209
+ query: str = "",
210
+ limit: int = 150,
211
+ ) -> DataResult:
212
+ clauses = []
213
+ params: list[object] = []
214
+ if query.strip():
215
+ needle = f"%{query.strip()}%"
216
+ clauses.append(
217
+ "(target LIKE ? OR module_path LIKE ? OR feedback_log_body LIKE ? OR thread_id LIKE ?)"
218
+ )
219
+ params.extend([needle, needle, needle, needle])
220
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
221
+ params.append(limit)
222
+ sql = f"""
223
+ SELECT
224
+ id,
225
+ ts,
226
+ ts_nanos,
227
+ level,
228
+ target,
229
+ thread_id,
230
+ feedback_log_body
231
+ FROM logs
232
+ {where}
233
+ ORDER BY ts DESC, ts_nanos DESC, id DESC
234
+ LIMIT ?
235
+ """
236
+ try:
237
+ with _connect_readonly(db_path) as conn:
238
+ rows = conn.execute(sql, params).fetchall()
239
+ except FileNotFoundError:
240
+ return DataResult([])
241
+ except sqlite3.Error as exc:
242
+ return DataResult([], _data_error("logs_2.sqlite", db_path, exc))
243
+ try:
244
+ return DataResult([
245
+ LogEntry(
246
+ id=int(row["id"]),
247
+ ts_ms=int(row["ts"] or 0) * 1000 + int((row["ts_nanos"] or 0) / 1_000_000),
248
+ level=row["level"] or "",
249
+ target=row["target"] or "",
250
+ thread_id=row["thread_id"] or "",
251
+ message=_shorten(row["feedback_log_body"], 240),
252
+ )
253
+ for row in rows
254
+ ])
255
+ except (TypeError, ValueError, sqlite3.Error) as exc:
256
+ return DataResult([], _data_error("logs_2.sqlite", db_path, exc))
257
+
258
+
259
+ def tail_tui_log(path: Path = TUI_LOG, lines: int = 80) -> list[str]:
260
+ if not path.exists():
261
+ return []
262
+ try:
263
+ data = path.read_text(encoding="utf-8", errors="replace").splitlines()
264
+ except OSError:
265
+ return []
266
+ return data[-lines:]
267
+
268
+
269
+ def format_time(ms: int) -> str:
270
+ if not ms:
271
+ return "-"
272
+ return datetime.fromtimestamp(ms / 1000).strftime("%Y-%m-%d %H:%M")
273
+
274
+
275
+ def format_tokens(value: int) -> str:
276
+ if value >= 1_000_000:
277
+ return f"{value / 1_000_000:.1f}M"
278
+ if value >= 1_000:
279
+ return f"{value / 1_000:.1f}k"
280
+ return str(value)
281
+
282
+
283
+ def first_existing(paths: Iterable[Path]) -> Path | None:
284
+ for path in paths:
285
+ if path.exists():
286
+ return path
287
+ return None
288
+
src/codex_dashboard/paths.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ HOME = Path.home()
8
+ CODEX_HOME = Path(os.environ.get("CODEX_HOME", HOME / ".codex")).expanduser()
9
+ CLAUDE_HOME = Path(os.environ.get("CLAUDE_HOME", HOME / ".claude")).expanduser()
10
+ STATE_DB = CODEX_HOME / "state_5.sqlite"
11
+ LOGS_DB = CODEX_HOME / "logs_2.sqlite"
12
+ HISTORY_JSONL = CODEX_HOME / "history.jsonl"
13
+ TUI_LOG = CODEX_HOME / "log" / "codex-tui.log"
14
+ CONFIG_TOML = CODEX_HOME / "config.toml"
15
+ CLAUDE_TASKS_DIR = CLAUDE_HOME / "tasks"
16
+ CLAUDE_EVENTS_DIR = CLAUDE_HOME / "events"
17
+ CLAUDE_PROJECTS_DIR = CLAUDE_HOME / "projects"
18
+ CLAUDE_SESSION_LOGS_DIR = CLAUDE_HOME / "session-logs"
19
+ CACHE_LOG = HOME / ".cache" / "codex-dashboard.log"
20
+ LOCK_PATH = Path("/tmp/codex-dashboard.lock")
src/codex_dashboard/processes.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import time
9
+
10
+
11
+ PROCESS_KEYWORDS = (
12
+ "claudecodepanel",
13
+ "claude-code",
14
+ "claude code",
15
+ "claude",
16
+ "codex",
17
+ "opencode",
18
+ "mcp-server",
19
+ "server-filesystem",
20
+ "server-memory",
21
+ "server-github",
22
+ "gemini-mcp",
23
+ "gemini",
24
+ )
25
+
26
+ STALE_SECONDS = 24 * 3600
27
+ CLK_TCK = os.sysconf(os.sysconf_names.get("SC_CLK_TCK", "SC_CLK_TCK"))
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ProcessInfo:
32
+ pid: int
33
+ name: str
34
+ command: str
35
+ cpu: float
36
+ ram_mb: float
37
+ tty: str
38
+ cwd: str
39
+ uptime_seconds: float
40
+ stale: bool
41
+ family: str = "Agent"
42
+
43
+
44
+ def _match_keyword(command: str) -> str | None:
45
+ lower = command.lower()
46
+ if "codex_dashboard" in lower or "codex-dashboard" in lower:
47
+ return None
48
+ for keyword in PROCESS_KEYWORDS:
49
+ if keyword in lower:
50
+ return keyword
51
+ return None
52
+
53
+
54
+ def _friendly_name(command: str, keyword: str) -> str:
55
+ first = Path(command.split()[0]).name if command.split() else keyword
56
+ if keyword == "claudecodepanel":
57
+ return "Claude Panel"
58
+ if keyword in {"claude", "claude-code", "claude code"}:
59
+ return "Claude Code"
60
+ if keyword == "codex":
61
+ return "Codex CLI"
62
+ if keyword == "opencode":
63
+ return "OpenCode"
64
+ if keyword in {"gemini", "gemini-mcp"}:
65
+ return "Gemini"
66
+ if keyword.startswith("server-"):
67
+ return keyword.replace("server-", "").title() + " MCP"
68
+ if keyword == "mcp-server":
69
+ return "MCP Server"
70
+ if first in {"node", "python", "python3"}:
71
+ return f"{first}/{keyword}"
72
+ return first or keyword
73
+
74
+
75
+ def _family(keyword: str) -> str:
76
+ if keyword in {"claude", "claude-code", "claude code", "claudecodepanel"}:
77
+ return "Claude"
78
+ if keyword == "codex":
79
+ return "Codex"
80
+ if keyword == "opencode":
81
+ return "OpenCode"
82
+ if "mcp" in keyword or keyword.startswith("server-"):
83
+ return "MCP"
84
+ if keyword.startswith("gemini"):
85
+ return "Gemini"
86
+ return "Agent"
87
+
88
+
89
+ def _read_cwd(pid: int) -> str:
90
+ try:
91
+ return os.readlink(f"/proc/{pid}/cwd")
92
+ except OSError:
93
+ return ""
94
+
95
+
96
+ def _read_uptime_seconds(pid: int, now: float | None = None) -> float:
97
+ now = time.time() if now is None else now
98
+ try:
99
+ stat_data = Path(f"/proc/{pid}/stat").read_text().split()
100
+ starttime_ticks = int(stat_data[21])
101
+ uptime = float(Path("/proc/uptime").read_text().split()[0])
102
+ except (OSError, ValueError, IndexError):
103
+ return 0.0
104
+ process_start = now - uptime + (starttime_ticks / CLK_TCK)
105
+ return max(0.0, now - process_start)
106
+
107
+
108
+ def parse_ps_aux_line(
109
+ line: str, *, now: float | None = None, read_proc_metadata: bool = True
110
+ ) -> ProcessInfo | None:
111
+ parts = line.split(None, 10)
112
+ if len(parts) < 11:
113
+ return None
114
+ try:
115
+ pid = int(parts[1])
116
+ cpu = float(parts[2])
117
+ rss_kb = int(parts[5])
118
+ except ValueError:
119
+ return None
120
+ command = parts[10]
121
+ keyword = _match_keyword(command)
122
+ if keyword is None or pid == os.getpid():
123
+ return None
124
+ uptime_seconds = _read_uptime_seconds(pid, now) if read_proc_metadata else 0.0
125
+ return ProcessInfo(
126
+ pid=pid,
127
+ name=_friendly_name(command, keyword),
128
+ command=command,
129
+ cpu=cpu,
130
+ ram_mb=rss_kb / 1024,
131
+ tty="" if parts[6] == "?" else parts[6],
132
+ cwd=_read_cwd(pid) if read_proc_metadata else "",
133
+ uptime_seconds=uptime_seconds,
134
+ stale=uptime_seconds >= STALE_SECONDS,
135
+ family=_family(keyword),
136
+ )
137
+
138
+
139
+ def scan_processes(*, ps_output: str | None = None) -> list[ProcessInfo]:
140
+ read_proc_metadata = ps_output is None
141
+ if ps_output is None:
142
+ try:
143
+ result = subprocess.run(
144
+ ["ps", "aux", "--no-headers"],
145
+ capture_output=True,
146
+ text=True,
147
+ timeout=5,
148
+ )
149
+ except (OSError, subprocess.TimeoutExpired):
150
+ return []
151
+ if result.returncode != 0:
152
+ return []
153
+ ps_output = result.stdout
154
+
155
+ processes = [
156
+ process
157
+ for line in ps_output.splitlines()
158
+ if (process := parse_ps_aux_line(
159
+ line, read_proc_metadata=read_proc_metadata
160
+ )) is not None
161
+ ]
162
+ processes.sort(key=lambda item: (-int(item.stale), -item.ram_mb, item.name))
163
+ return processes
164
+
165
+
166
+ def terminate_process(pid: int) -> None:
167
+ os.kill(pid, signal.SIGTERM)
168
+
169
+
170
+ def format_uptime(seconds: float) -> str:
171
+ if seconds < 60:
172
+ return "<1m"
173
+ minutes = int(seconds // 60)
174
+ if minutes < 60:
175
+ return f"{minutes}m"
176
+ hours = minutes // 60
177
+ if hours < 48:
178
+ return f"{hours}h"
179
+ return f"{hours // 24}d"
systemd/codex-dashboard.service ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [Unit]
2
+ Description=CodexDashBoard lightweight Codex panel
3
+ After=graphical-session.target
4
+
5
+ [Service]
6
+ Type=simple
7
+ WorkingDirectory=/home/smlflg/Projekte/CodexDashBoard
8
+ Environment=PYTHONPATH=/home/smlflg/Projekte/CodexDashBoard/src
9
+ ExecStart=/usr/bin/python3 -m codex_dashboard
10
+ Restart=on-failure
11
+ RestartSec=30
12
+ StartLimitIntervalSec=60
13
+ StartLimitBurst=3
14
+
15
+ [Install]
16
+ WantedBy=default.target
17
+
tests/test_codex_data.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import sqlite3
3
+
4
+ from codex_dashboard.codex_data import get_stats, list_threads, list_threads_result
5
+
6
+
7
+ def _make_state_db(path: Path) -> None:
8
+ conn = sqlite3.connect(path)
9
+ conn.execute(
10
+ """
11
+ CREATE TABLE threads (
12
+ id TEXT PRIMARY KEY,
13
+ rollout_path TEXT NOT NULL DEFAULT '',
14
+ created_at INTEGER NOT NULL,
15
+ updated_at INTEGER NOT NULL,
16
+ source TEXT NOT NULL,
17
+ model_provider TEXT NOT NULL,
18
+ cwd TEXT NOT NULL,
19
+ title TEXT NOT NULL,
20
+ sandbox_policy TEXT NOT NULL DEFAULT '',
21
+ approval_mode TEXT NOT NULL DEFAULT '',
22
+ tokens_used INTEGER NOT NULL DEFAULT 0,
23
+ has_user_event INTEGER NOT NULL DEFAULT 0,
24
+ archived INTEGER NOT NULL DEFAULT 0,
25
+ archived_at INTEGER,
26
+ git_sha TEXT,
27
+ git_branch TEXT,
28
+ git_origin_url TEXT,
29
+ cli_version TEXT NOT NULL DEFAULT '',
30
+ first_user_message TEXT NOT NULL DEFAULT '',
31
+ agent_nickname TEXT,
32
+ agent_role TEXT,
33
+ memory_mode TEXT NOT NULL DEFAULT 'enabled',
34
+ model TEXT,
35
+ reasoning_effort TEXT,
36
+ agent_path TEXT,
37
+ created_at_ms INTEGER,
38
+ updated_at_ms INTEGER
39
+ )
40
+ """
41
+ )
42
+ rows = [
43
+ (
44
+ "new",
45
+ 100,
46
+ 200,
47
+ "codex",
48
+ "openai",
49
+ "/work/new",
50
+ "Newest task",
51
+ 1200,
52
+ 0,
53
+ "main",
54
+ "gpt-5.4",
55
+ "high",
56
+ 100_000,
57
+ 200_000,
58
+ ),
59
+ (
60
+ "old",
61
+ 50,
62
+ 60,
63
+ "codex",
64
+ "openai",
65
+ "/work/old",
66
+ "Old task",
67
+ 300,
68
+ 1,
69
+ "archive",
70
+ "gpt-5.4",
71
+ "medium",
72
+ 50_000,
73
+ 60_000,
74
+ ),
75
+ ]
76
+ conn.executemany(
77
+ """
78
+ INSERT INTO threads (
79
+ id, created_at, updated_at, source, model_provider, cwd, title,
80
+ tokens_used, archived, git_branch, model, reasoning_effort,
81
+ created_at_ms, updated_at_ms
82
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
83
+ """,
84
+ rows,
85
+ )
86
+ conn.commit()
87
+ conn.close()
88
+
89
+
90
+ def test_list_threads_hides_archived_by_default(tmp_path):
91
+ db = tmp_path / "state.sqlite"
92
+ _make_state_db(db)
93
+
94
+ threads = list_threads(db_path=db)
95
+
96
+ assert [thread.id for thread in threads] == ["new"]
97
+ assert threads[0].tokens_used == 1200
98
+ assert threads[0].reasoning_effort == "high"
99
+
100
+
101
+ def test_list_threads_can_include_archived_and_search(tmp_path):
102
+ db = tmp_path / "state.sqlite"
103
+ _make_state_db(db)
104
+
105
+ threads = list_threads(db_path=db, include_archived=True, query="old")
106
+
107
+ assert [thread.id for thread in threads] == ["old"]
108
+
109
+
110
+ def test_get_stats_handles_missing_db(tmp_path):
111
+ stats = get_stats(db_path=tmp_path / "missing.sqlite")
112
+
113
+ assert stats.thread_count == 0
114
+ assert stats.latest_thread is None
115
+ assert stats.error is None
116
+
117
+
118
+ def test_list_threads_schema_drift_missing_timestamp_columns_reports_data_error(tmp_path):
119
+ db = tmp_path / "state.sqlite"
120
+ conn = sqlite3.connect(db)
121
+ conn.execute(
122
+ """
123
+ CREATE TABLE threads (
124
+ id TEXT PRIMARY KEY,
125
+ title TEXT,
126
+ first_user_message TEXT,
127
+ cwd TEXT,
128
+ model TEXT,
129
+ model_provider TEXT,
130
+ reasoning_effort TEXT,
131
+ tokens_used INTEGER,
132
+ archived INTEGER DEFAULT 0,
133
+ source TEXT,
134
+ git_branch TEXT
135
+ )
136
+ """
137
+ )
138
+ conn.execute("INSERT INTO threads (id, title, archived) VALUES ('t1', 'bad schema', 0)")
139
+ conn.commit()
140
+ conn.close()
141
+
142
+ result = list_threads_result(db_path=db)
143
+
144
+ assert result.data == []
145
+ assert result.error is not None
146
+ assert "created_at" in result.error.message or "updated_at" in result.error.message
147
+
148
+
149
+ def test_list_threads_malformed_tokens_are_reported_without_exception(tmp_path):
150
+ db = tmp_path / "state.sqlite"
151
+ _make_state_db(db)
152
+ conn = sqlite3.connect(db)
153
+ conn.execute("UPDATE threads SET tokens_used = 'not-an-int' WHERE id = 'new'")
154
+ conn.commit()
155
+ conn.close()
156
+
157
+ result = list_threads_result(db_path=db)
158
+
159
+ assert result.data == []
160
+ assert result.error is not None
161
+ assert "not-an-int" in result.error.message
162
+
163
+
164
+ def test_get_stats_schema_drift_reports_error_without_exception(tmp_path):
165
+ db = tmp_path / "state.sqlite"
166
+ conn = sqlite3.connect(db)
167
+ conn.execute("CREATE TABLE threads (id TEXT PRIMARY KEY, archived INTEGER DEFAULT 0)")
168
+ conn.commit()
169
+ conn.close()
170
+
171
+ stats = get_stats(db_path=db)
172
+
173
+ assert stats.thread_count == 0
174
+ assert stats.latest_thread is None
175
+ assert stats.error is not None
176
+ assert "tokens_used" in stats.error.message or "updated_at" in stats.error.message
177
+
tests/test_crash_safety.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import ast
3
+ import sqlite3
4
+ import time
5
+
6
+ from codex_dashboard.codex_data import get_stats, list_logs, list_threads
7
+
8
+
9
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
10
+ APP_PY = PROJECT_ROOT / "src" / "codex_dashboard" / "app.py"
11
+ SERVICE_FILE = PROJECT_ROOT / "systemd" / "codex-dashboard.service"
12
+ PYPROJECT = PROJECT_ROOT / "pyproject.toml"
13
+ SRC_DIR = PROJECT_ROOT / "src"
14
+
15
+
16
+ def _function_calls_in_method(source_path: Path, class_name: str, method_name: str) -> set[str]:
17
+ tree = ast.parse(source_path.read_text(encoding="utf-8"))
18
+ calls: set[str] = set()
19
+ for node in ast.walk(tree):
20
+ if isinstance(node, ast.ClassDef) and node.name == class_name:
21
+ for item in node.body:
22
+ if isinstance(item, ast.FunctionDef) and item.name == method_name:
23
+ for child in ast.walk(item):
24
+ if isinstance(child, ast.Call):
25
+ func = child.func
26
+ if isinstance(func, ast.Attribute):
27
+ calls.add(func.attr)
28
+ elif isinstance(func, ast.Name):
29
+ calls.add(func.id)
30
+ return calls
31
+
32
+
33
+ def test_locked_codex_db_returns_empty_fast_without_exception(tmp_path):
34
+ db = tmp_path / "state.sqlite"
35
+ owner = sqlite3.connect(db)
36
+ owner.execute(
37
+ "CREATE TABLE threads (id TEXT PRIMARY KEY, archived INTEGER DEFAULT 0, updated_at INTEGER DEFAULT 0, created_at INTEGER DEFAULT 0)"
38
+ )
39
+ owner.commit()
40
+ owner.execute("BEGIN EXCLUSIVE")
41
+
42
+ start = time.monotonic()
43
+ try:
44
+ threads = list_threads(db_path=db)
45
+ stats = get_stats(db_path=db)
46
+ logs = list_logs(db_path=db)
47
+ finally:
48
+ owner.rollback()
49
+ owner.close()
50
+ elapsed = time.monotonic() - start
51
+
52
+ assert threads == []
53
+ assert stats.thread_count == 0
54
+ assert stats.latest_thread is None
55
+ assert logs == []
56
+ assert elapsed < 0.75
57
+
58
+
59
+ def test_startup_constructor_does_not_call_refresh_all():
60
+ calls = _function_calls_in_method(APP_PY, "CodexDashboard", "__init__")
61
+
62
+ assert "refresh_all" not in calls
63
+
64
+
65
+ def test_refresh_all_does_not_refresh_every_view_at_once():
66
+ calls = _function_calls_in_method(APP_PY, "CodexDashboard", "refresh_all")
67
+ all_view_refreshes = {
68
+ "refresh_overview",
69
+ "refresh_sessions",
70
+ "refresh_processes",
71
+ "refresh_logs",
72
+ }
73
+
74
+ assert not all_view_refreshes.issubset(calls)
75
+
76
+
77
+ def test_service_policy_does_not_create_aggressive_restart_loop():
78
+ lines = SERVICE_FILE.read_text(encoding="utf-8").splitlines()
79
+ settings = dict(
80
+ line.split("=", 1)
81
+ for line in lines
82
+ if line and not line.startswith("#") and "=" in line
83
+ )
84
+
85
+ restart = settings.get("Restart", "")
86
+ restart_sec_raw = settings.get("RestartSec", "0").rstrip("s")
87
+ restart_sec = int(restart_sec_raw) if restart_sec_raw.isdigit() else 0
88
+ burst = int(settings.get("StartLimitBurst", "999"))
89
+
90
+ assert restart in {"", "no", "on-abnormal"} or restart_sec >= 30
91
+ assert burst <= 3
92
+
93
+
94
+ def test_refresh_methods_use_in_flight_guard_instead_of_raw_threads():
95
+ source = APP_PY.read_text(encoding="utf-8")
96
+ tree = ast.parse(source)
97
+ refresh_methods = {
98
+ "refresh_overview",
99
+ "refresh_sessions",
100
+ "refresh_processes",
101
+ "refresh_logs",
102
+ }
103
+ guarded_methods: set[str] = set()
104
+ raw_thread_methods: set[str] = set()
105
+
106
+ for node in ast.walk(tree):
107
+ if isinstance(node, ast.ClassDef) and node.name == "CodexDashboard":
108
+ for item in node.body:
109
+ if isinstance(item, ast.FunctionDef) and item.name in refresh_methods:
110
+ for child in ast.walk(item):
111
+ if isinstance(child, ast.Call):
112
+ func = child.func
113
+ if isinstance(func, ast.Attribute) and func.attr == "_run_refresh_once":
114
+ guarded_methods.add(item.name)
115
+ if isinstance(func, ast.Name) and func.id == "_run_bg":
116
+ raw_thread_methods.add(item.name)
117
+
118
+ assert guarded_methods == refresh_methods
119
+ assert raw_thread_methods == set()
120
+
121
+
122
+ def test_in_flight_guard_discards_key_when_refresh_done():
123
+ source = APP_PY.read_text(encoding="utf-8")
124
+ tree = ast.parse(source)
125
+ helper = None
126
+ for node in ast.walk(tree):
127
+ if isinstance(node, ast.FunctionDef) and node.name == "_run_refresh_once":
128
+ helper = node
129
+ break
130
+
131
+ assert helper is not None
132
+ helper_source = ast.get_source_segment(source, helper) or ""
133
+ assert "_refresh_in_flight" in helper_source
134
+ assert ".add(" in helper_source
135
+ assert ".discard(" in helper_source
136
+ assert "finally" in helper_source
137
+
138
+
139
+ def test_packaging_and_source_do_not_use_webkit():
140
+ checked = [PYPROJECT, SERVICE_FILE, *SRC_DIR.rglob("*.py")]
141
+ offenders = []
142
+ for path in checked:
143
+ text = path.read_text(encoding="utf-8", errors="replace").lower()
144
+ if "webkit" in text:
145
+ offenders.append(str(path.relative_to(PROJECT_ROOT)))
146
+
147
+ assert offenders == []
tests/test_data_error_visibility.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import ast
3
+
4
+
5
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
6
+ APP_PY = PROJECT_ROOT / "src" / "codex_dashboard" / "app.py"
7
+
8
+
9
+ def _method_source(class_name: str, method_name: str) -> str:
10
+ source = APP_PY.read_text(encoding="utf-8")
11
+ tree = ast.parse(source)
12
+ for node in ast.walk(tree):
13
+ if isinstance(node, ast.ClassDef) and node.name == class_name:
14
+ for item in node.body:
15
+ if isinstance(item, ast.FunctionDef) and item.name == method_name:
16
+ return ast.get_source_segment(source, item) or ""
17
+ raise AssertionError(f"missing {class_name}.{method_name}")
18
+
19
+
20
+ def test_session_refresh_status_distinguishes_database_error_from_no_sessions():
21
+ source = _method_source("CodexDashboard", "refresh_sessions")
22
+
23
+ assert "list_threads_result" in source
24
+ assert ".error" in source
25
+ assert "Session database error" in source
26
+ assert "No Codex sessions found" not in source
27
+
28
+
29
+ def test_log_refresh_status_distinguishes_database_error_from_no_logs():
30
+ source = _method_source("CodexDashboard", "refresh_logs")
31
+
32
+ assert "list_logs_result" in source
33
+ assert ".error" in source
34
+ assert "Log database error" in source
35
+ assert "No Codex logs found" not in source
tests/test_logs_data.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import sqlite3
3
+
4
+ from codex_dashboard.codex_data import list_logs, list_logs_result
5
+
6
+
7
+ def _make_logs_db(path: Path) -> None:
8
+ conn = sqlite3.connect(path)
9
+ conn.execute(
10
+ """
11
+ CREATE TABLE logs (
12
+ id INTEGER PRIMARY KEY,
13
+ ts INTEGER NOT NULL,
14
+ ts_nanos INTEGER NOT NULL DEFAULT 0,
15
+ level TEXT NOT NULL DEFAULT '',
16
+ target TEXT NOT NULL DEFAULT '',
17
+ thread_id TEXT NOT NULL DEFAULT '',
18
+ module_path TEXT NOT NULL DEFAULT '',
19
+ feedback_log_body TEXT
20
+ )
21
+ """
22
+ )
23
+ conn.executemany(
24
+ """
25
+ INSERT INTO logs (id, ts, ts_nanos, level, target, thread_id, module_path, feedback_log_body)
26
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
27
+ """,
28
+ [
29
+ (1, 100, 123_000_000, "INFO", "codex_core", "thread-a", "core", "first message"),
30
+ (2, 101, 500_000_000, "WARN", "codex_ui", "thread-b", "ui", "needle message"),
31
+ ],
32
+ )
33
+ conn.commit()
34
+ conn.close()
35
+
36
+
37
+ def test_list_logs_reads_codex_logs_fixture_and_searches(tmp_path):
38
+ db = tmp_path / "logs_2.sqlite"
39
+ _make_logs_db(db)
40
+
41
+ logs = list_logs(db_path=db, query="needle")
42
+
43
+ assert [entry.id for entry in logs] == [2]
44
+ assert logs[0].ts_ms == 101_500
45
+ assert logs[0].level == "WARN"
46
+ assert logs[0].thread_id == "thread-b"
47
+ assert logs[0].message == "needle message"
48
+
49
+
50
+ def test_list_logs_schema_drift_missing_expected_columns_reports_data_error(tmp_path):
51
+ db = tmp_path / "logs_2.sqlite"
52
+ conn = sqlite3.connect(db)
53
+ conn.execute(
54
+ """
55
+ CREATE TABLE logs (
56
+ id INTEGER PRIMARY KEY,
57
+ ts INTEGER NOT NULL,
58
+ level TEXT,
59
+ target TEXT,
60
+ thread_id TEXT
61
+ )
62
+ """
63
+ )
64
+ conn.execute("INSERT INTO logs (id, ts, level, target, thread_id) VALUES (1, 100, 'INFO', 'target', 't')")
65
+ conn.commit()
66
+ conn.close()
67
+
68
+ result = list_logs_result(db_path=db)
69
+
70
+ assert result.data == []
71
+ assert result.error is not None
72
+ assert "ts_nanos" in result.error.message or "feedback_log_body" in result.error.message
tests/test_packaging.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import tomllib
2
+ from pathlib import Path
3
+
4
+
5
+ def test_gui_dependency_is_optional_for_dev_installs():
6
+ data = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
7
+
8
+ project = data["project"]
9
+ dependencies = project.get("dependencies", [])
10
+ optional = project.get("optional-dependencies", {})
11
+
12
+ assert all(not dep.startswith("PyGObject") for dep in dependencies)
13
+ assert any(dep.startswith("PyGObject") for dep in optional["gui"])
14
+ assert any(dep.startswith("pytest") for dep in optional["dev"])
tests/test_processes.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from types import SimpleNamespace
2
+
3
+ from codex_dashboard.processes import format_uptime, scan_processes
4
+
5
+
6
+ def test_scan_processes_filters_codex_lines():
7
+ output = "\n".join(
8
+ [
9
+ "smlflg 101 0.0 0.1 1000 2048 ? S 10:00 0:00 codex",
10
+ "smlflg 102 0.0 0.1 1000 2048 ? S 10:00 0:00 bash",
11
+ "smlflg 103 1.5 0.2 1000 4096 pts/1 S 10:00 0:00 node mcp-server",
12
+ ]
13
+ )
14
+
15
+ processes = scan_processes(ps_output=output)
16
+
17
+ assert [process.pid for process in processes] == [103, 101]
18
+ assert processes[0].name in {"MCP Server", "node/mcp-server"}
19
+
20
+
21
+ def test_scan_processes_reads_proc_metadata_for_live_ps(monkeypatch):
22
+ import codex_dashboard.processes as processes_module
23
+
24
+ output = "smlflg 4242 0.0 0.1 1000 2048 ? S 10:00 0:00 codex"
25
+
26
+ monkeypatch.setattr(
27
+ processes_module.subprocess,
28
+ "run",
29
+ lambda *_, **__: SimpleNamespace(returncode=0, stdout=output),
30
+ )
31
+ monkeypatch.setattr(processes_module.os, "getpid", lambda: 9999)
32
+ monkeypatch.setattr(processes_module, "_read_cwd", lambda pid: f"/work/{pid}")
33
+ monkeypatch.setattr(processes_module, "_read_uptime_seconds", lambda pid, now=None: 25 * 3600)
34
+
35
+ result = scan_processes()
36
+
37
+ assert len(result) == 1
38
+ assert result[0].pid == 4242
39
+ assert result[0].cwd == "/work/4242"
40
+ assert result[0].uptime_seconds == 25 * 3600
41
+ assert result[0].stale is True
42
+
43
+
44
+ def test_format_uptime():
45
+ assert format_uptime(30) == "<1m"
46
+ assert format_uptime(600) == "10m"
47
+ assert format_uptime(7200) == "2h"
48
+ assert format_uptime(172800) == "2d"
49
+
tests/test_startup.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from codex_dashboard.bootstrap import format_missing_gi_message, run
2
+
3
+
4
+ def test_format_missing_gi_message_mentions_required_packages():
5
+ message = format_missing_gi_message()
6
+
7
+ assert "PyGObject / GTK" in message
8
+ assert "python3 -m pip install -e '.[gui]'" in message
9
+ assert "libgirepository-2.0-dev" in message
10
+ assert "libgtk-3-dev" in message
11
+
12
+
13
+ def test_run_returns_nonzero_when_gui_import_fails():
14
+ def _raise_import_error():
15
+ raise ImportError("No module named gi")
16
+
17
+ exit_code = run(_raise_import_error, err=lambda *_: None)
18
+
19
+ assert exit_code == 1
uv.lock ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.10"
4
+
5
+ [[package]]
6
+ name = "codex-dashboard"
7
+ version = "0.1.0"
8
+ source = { virtual = "." }
9
+ dependencies = [
10
+ { name = "pygobject" },
11
+ ]
12
+
13
+ [package.optional-dependencies]
14
+ dev = [
15
+ { name = "pytest" },
16
+ ]
17
+
18
+ [package.metadata]
19
+ requires-dist = [
20
+ { name = "pygobject", specifier = ">=3.42" },
21
+ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" },
22
+ ]
23
+ provides-extras = ["dev"]
24
+
25
+ [[package]]
26
+ name = "colorama"
27
+ version = "0.4.6"
28
+ source = { registry = "https://pypi.org/simple" }
29
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
30
+ wheels = [
31
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
32
+ ]
33
+
34
+ [[package]]
35
+ name = "exceptiongroup"
36
+ version = "1.3.1"
37
+ source = { registry = "https://pypi.org/simple" }
38
+ dependencies = [
39
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
40
+ ]
41
+ sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
42
+ wheels = [
43
+ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
44
+ ]
45
+
46
+ [[package]]
47
+ name = "iniconfig"
48
+ version = "2.3.0"
49
+ source = { registry = "https://pypi.org/simple" }
50
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
51
+ wheels = [
52
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
53
+ ]
54
+
55
+ [[package]]
56
+ name = "packaging"
57
+ version = "26.1"
58
+ source = { registry = "https://pypi.org/simple" }
59
+ sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" }
60
+ wheels = [
61
+ { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
62
+ ]
63
+
64
+ [[package]]
65
+ name = "pluggy"
66
+ version = "1.6.0"
67
+ source = { registry = "https://pypi.org/simple" }
68
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
69
+ wheels = [
70
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
71
+ ]
72
+
73
+ [[package]]
74
+ name = "pycairo"
75
+ version = "1.29.0"
76
+ source = { registry = "https://pypi.org/simple" }
77
+ sdist = { url = "https://files.pythonhosted.org/packages/22/d9/1728840a22a4ef8a8f479b9156aa2943cd98c3907accd3849fb0d5f82bfd/pycairo-1.29.0.tar.gz", hash = "sha256:f3f7fde97325cae80224c09f12564ef58d0d0f655da0e3b040f5807bd5bd3142", size = 665871, upload-time = "2025-11-11T19:13:01.584Z" }
78
+ wheels = [
79
+ { url = "https://files.pythonhosted.org/packages/23/e2/c08847af2a103517f7785830706b6d1d55274494d76ab605eb744404c22f/pycairo-1.29.0-cp310-cp310-win32.whl", hash = "sha256:96c67e6caba72afd285c2372806a0175b1aa2f4537aa88fb4d9802d726effcd1", size = 751339, upload-time = "2025-11-11T19:11:21.266Z" },
80
+ { url = "https://files.pythonhosted.org/packages/eb/36/2a934c6fd4f32d2011c4d9cc59a32e34e06a97dd9f4b138614078d39340b/pycairo-1.29.0-cp310-cp310-win_amd64.whl", hash = "sha256:65bddd944aee9f7d7d72821b1c87e97593856617c2820a78d589d66aa8afbd08", size = 845074, upload-time = "2025-11-11T19:11:27.111Z" },
81
+ { url = "https://files.pythonhosted.org/packages/1b/f0/ee0a887d8c8a6833940263b7234aaa63d8d95a27d6130a9a053867ff057c/pycairo-1.29.0-cp310-cp310-win_arm64.whl", hash = "sha256:15b36aea699e2ff215cb6a21501223246032e572a3a10858366acdd69c81a1c8", size = 694758, upload-time = "2025-11-11T19:11:32.635Z" },
82
+ { url = "https://files.pythonhosted.org/packages/31/92/1b904087e831806a449502786d47d3a468e5edb8f65755f6bd88e8038e53/pycairo-1.29.0-cp311-cp311-win32.whl", hash = "sha256:12757ebfb304b645861283c20585c9204c3430671fad925419cba04844d6dfed", size = 751342, upload-time = "2025-11-11T19:11:37.386Z" },
83
+ { url = "https://files.pythonhosted.org/packages/db/09/a0ab6a246a7ede89e817d749a941df34f27a74bedf15551da51e86ae105e/pycairo-1.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:3391532db03f9601c1cee9ebfa15b7d1db183c6020f3e75c1348cee16825934f", size = 845036, upload-time = "2025-11-11T19:11:43.408Z" },
84
+ { url = "https://files.pythonhosted.org/packages/3c/b2/bf455454bac50baef553e7356d36b9d16e482403bf132cfb12960d2dc2e7/pycairo-1.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:b69be8bb65c46b680771dc6a1a422b1cdd0cffb17be548f223e8cbbb6205567c", size = 694644, upload-time = "2025-11-11T19:11:48.599Z" },
85
+ { url = "https://files.pythonhosted.org/packages/f6/28/6363087b9e60af031398a6ee5c248639eefc6cc742884fa2789411b1f73b/pycairo-1.29.0-cp312-cp312-win32.whl", hash = "sha256:91bcd7b5835764c616a615d9948a9afea29237b34d2ed013526807c3d79bb1d0", size = 751486, upload-time = "2025-11-11T19:11:54.451Z" },
86
+ { url = "https://files.pythonhosted.org/packages/3a/d2/d146f1dd4ef81007686ac52231dd8f15ad54cf0aa432adaefc825475f286/pycairo-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f01c3b5e49ef9411fff6bc7db1e765f542dc1c9cfed4542958a5afa3a8b8e76", size = 845383, upload-time = "2025-11-11T19:12:01.551Z" },
87
+ { url = "https://files.pythonhosted.org/packages/01/16/6e6f33bb79ec4a527c9e633915c16dc55a60be26b31118dbd0d5859e8c51/pycairo-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:eafe3d2076f3533535ad4a361fa0754e0ee66b90e548a3a0f558fed00b1248f2", size = 694518, upload-time = "2025-11-11T19:12:06.561Z" },
88
+ { url = "https://files.pythonhosted.org/packages/f0/21/3f477dc318dd4e84a5ae6301e67284199d7e5a2384f3063714041086b65d/pycairo-1.29.0-cp313-cp313-win32.whl", hash = "sha256:3eb382a4141591807073274522f7aecab9e8fa2f14feafd11ac03a13a58141d7", size = 750949, upload-time = "2025-11-11T19:12:12.198Z" },
89
+ { url = "https://files.pythonhosted.org/packages/43/34/7d27a333c558d6ac16dbc12a35061d389735e99e494ee4effa4ec6d99bed/pycairo-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:91114e4b3fbf4287c2b0788f83e1f566ce031bda49cf1c3c3c19c3e986e95c38", size = 844149, upload-time = "2025-11-11T19:12:19.171Z" },
90
+ { url = "https://files.pythonhosted.org/packages/15/43/e782131e23df69e5c8e631a016ed84f94bbc4981bf6411079f57af730a23/pycairo-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:09b7f69a5ff6881e151354ea092137b97b0b1f0b2ab4eb81c92a02cc4a08e335", size = 693595, upload-time = "2025-11-11T19:12:23.445Z" },
91
+ { url = "https://files.pythonhosted.org/packages/2d/fa/87eaeeb9d53344c769839d7b2854db7ff2cd596211e00dd1b702eeb1838f/pycairo-1.29.0-cp314-cp314-win32.whl", hash = "sha256:69e2a7968a3fbb839736257bae153f547bca787113cc8d21e9e08ca4526e0b6b", size = 767198, upload-time = "2025-11-11T19:12:42.336Z" },
92
+ { url = "https://files.pythonhosted.org/packages/3c/90/3564d0f64d0a00926ab863dc3c4a129b1065133128e96900772e1c4421f8/pycairo-1.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:e91243437a21cc4c67c401eff4433eadc45745275fa3ade1a0d877e50ffb90da", size = 871579, upload-time = "2025-11-11T19:12:48.982Z" },
93
+ { url = "https://files.pythonhosted.org/packages/5e/91/93632b6ba12ad69c61991e3208bde88486fdfc152be8cfdd13444e9bc650/pycairo-1.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:b72200ea0e5f73ae4c788cd2028a750062221385eb0e6d8f1ecc714d0b4fdf82", size = 719537, upload-time = "2025-11-11T19:12:55.016Z" },
94
+ { url = "https://files.pythonhosted.org/packages/93/23/37053c039f8d3b9b5017af9bc64d27b680c48a898d48b72e6d6583cf0155/pycairo-1.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5e45fce6185f553e79e4ef1722b8e98e6cde9900dbc48cb2637a9ccba86f627a", size = 874015, upload-time = "2025-11-11T19:12:28.47Z" },
95
+ { url = "https://files.pythonhosted.org/packages/d7/54/123f6239685f5f3f2edc123f1e38d2eefacebee18cf3c532d2f4bd51d0ef/pycairo-1.29.0-cp314-cp314t-win_arm64.whl", hash = "sha256:caba0837a4b40d47c8dfb0f24cccc12c7831e3dd450837f2a356c75f21ce5a15", size = 721404, upload-time = "2025-11-11T19:12:36.919Z" },
96
+ ]
97
+
98
+ [[package]]
99
+ name = "pygments"
100
+ version = "2.20.0"
101
+ source = { registry = "https://pypi.org/simple" }
102
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
103
+ wheels = [
104
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
105
+ ]
106
+
107
+ [[package]]
108
+ name = "pygobject"
109
+ version = "3.56.2"
110
+ source = { registry = "https://pypi.org/simple" }
111
+ dependencies = [
112
+ { name = "pycairo" },
113
+ ]
114
+ sdist = { url = "https://files.pythonhosted.org/packages/a2/80/09247a2be28af2c2240132a0af6c1005a2b1d089242b13a2cd782d2de8d7/pygobject-3.56.2.tar.gz", hash = "sha256:b816098969544081de9eecedb94ad6ac59c77e4d571fe7051f18bebcec074313", size = 1409059, upload-time = "2026-03-25T16:14:04.008Z" }
115
+
116
+ [[package]]
117
+ name = "pytest"
118
+ version = "9.0.3"
119
+ source = { registry = "https://pypi.org/simple" }
120
+ dependencies = [
121
+ { name = "colorama", marker = "sys_platform == 'win32'" },
122
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
123
+ { name = "iniconfig" },
124
+ { name = "packaging" },
125
+ { name = "pluggy" },
126
+ { name = "pygments" },
127
+ { name = "tomli", marker = "python_full_version < '3.11'" },
128
+ ]
129
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
130
+ wheels = [
131
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
132
+ ]
133
+
134
+ [[package]]
135
+ name = "tomli"
136
+ version = "2.4.1"
137
+ source = { registry = "https://pypi.org/simple" }
138
+ sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
139
+ wheels = [
140
+ { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
141
+ { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
142
+ { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
143
+ { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
144
+ { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
145
+ { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
146
+ { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
147
+ { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
148
+ { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
149
+ { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
150
+ { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
151
+ { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
152
+ { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
153
+ { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
154
+ { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
155
+ { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
156
+ { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
157
+ { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
158
+ { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
159
+ { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
160
+ { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
161
+ { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
162
+ { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
163
+ { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
164
+ { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
165
+ { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
166
+ { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
167
+ { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
168
+ { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
169
+ { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
170
+ { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
171
+ { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
172
+ { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
173
+ { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
174
+ { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
175
+ { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
176
+ { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
177
+ { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
178
+ { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
179
+ { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
180
+ { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
181
+ { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
182
+ { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
183
+ { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
184
+ { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
185
+ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
186
+ ]
187
+
188
+ [[package]]
189
+ name = "typing-extensions"
190
+ version = "4.15.0"
191
+ source = { registry = "https://pypi.org/simple" }
192
+ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
193
+ wheels = [
194
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
195
+ ]