-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgui.py
More file actions
executable file
·686 lines (617 loc) · 27.8 KB
/
Copy pathgui.py
File metadata and controls
executable file
·686 lines (617 loc) · 27.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
#!/usr/bin/env python3
"""
claudegram tray app.
Runs in the system tray, supervises bot.py as a child process, and streams its
live log into a console window so you can see what the bot is doing. The bot
itself is unchanged — this is a supervisor + log viewer.
- Tray icon: left-click toggles the console window.
- Tray menu: Show console / Restart bot / Quit.
- Closing the window hides it to the tray (the bot keeps running).
- If the bot crashes, it is restarted automatically (with backoff).
- Single-instance PER DIRECTORY: a second launch of the SAME install just focuses the
first, but a copy in ANOTHER directory runs its own independent tray (its own token =
its own Telegram bot). Named copies get a distinct title + colored tray badge so the
windows are tell-apart-able; see instance_id.py and the optional `instance.txt`.
Launched at login via ~/.config/autostart/<install>.desktop (see install-autostart.sh).
"""
import sys
import time
from pathlib import Path
import instance_id
from PySide6.QtCore import Qt, QProcess, QProcessEnvironment, QTimer, QElapsedTimer
from PySide6.QtGui import QAction, QColor, QFont, QIcon, QPainter, QPixmap, QTextCursor
from PySide6.QtNetwork import QLocalServer, QLocalSocket
from PySide6.QtWidgets import (
QApplication,
QHBoxLayout,
QLabel,
QMainWindow,
QMenu,
QMessageBox,
QPlainTextEdit,
QPushButton,
QSizePolicy,
QStatusBar,
QSystemTrayIcon,
QToolBar,
QVBoxLayout,
QWidget,
)
HERE = Path(__file__).resolve().parent
PYTHON = str(HERE / ".venv" / "bin" / "python")
BOT = str(HERE / "bot.py")
LOG_FILE = HERE / "claudegram.log" # persistent copy of the bot's output
LOG_MAX_BYTES = 25_000_000 # rotate claudegram.log past ~25 MB (keeps one .1 backup)
LOG_CHECK_INTERVAL = 86_400 # seconds between rotation checks that touch disk (once a day)
import re as _re
# Matches the bot's periodic line, e.g.:
# usage refreshed: session=35% (1:39pm) week=79% (Jul 14, 6:59pm)
_USAGE_RE = _re.compile(r"usage refreshed: session=(\d+)% \(([^)]+)\) week=(\d+)% \(([^)]+)\)")
BLOCK_FILE = HERE / "BLOCKED.flag" # presence = bridge is locked (firewall trip)
SLEEP_FILE = HERE / "SLEEP.flag" # presence = sleep mode (Telegram input paused)
INTRUSION_OFF_FILE = HERE / "INTRUSION_OFF.flag" # presence = paranoid intrusion gate OFF (default ON)
REGRESSIONS_FILE = HERE / "HACKING_REGRESSIONS.md" # false-positive list to append to
INSTANCE_JSON = HERE / "instance.json" # declared per-install identity {name, color, glyph}
INSTANCE_FILE = HERE / "instance.txt" # legacy identity fallback (name / #color / glyph lines)
def _read_text(path):
try:
return path.read_text(encoding="utf-8") if path.exists() else None
except OSError:
return None
# --- per-install identity ---------------------------------------------------------
# Run several copies of claudegram (each its OWN directory + token = its own Telegram bot),
# and each tray must be launchable and tell-apart-able. The single-instance key is keyed to THIS
# directory (not global), so copies never collide. A named copy gets a distinct title + tray
# badge; identity is DECLARED in instance.json (name/color/glyph), with instance.txt and finally
# the directory name as fallbacks. The canonical lone `claudegram` install is left as before.
APP_ID = instance_id.instance_key(str(HERE)) # single-instance key, unique per directory
_INST_JSON = _read_text(INSTANCE_JSON)
_INST_TXT = _read_text(INSTANCE_FILE) if _INST_JSON is None else None
_HAS_IDENTITY = _INST_JSON is not None or _INST_TXT is not None
LABEL, _INST_COLOR, _INST_GLYPH, IS_DEFAULT = instance_id.resolve(HERE.name, _INST_JSON, _INST_TXT)
TITLE = "claudegram" if IS_DEFAULT else f"claudegram · {LABEL}"
DESKTOP_NAME = instance_id.desktop_name(HERE.name, LABEL, _HAS_IDENTITY)
# Toolbar buttons get real chrome (border, hover highlight, pressed feedback) instead of
# flat text. Explicit colors so it looks deliberate under any system theme; blue accent
# matches the tray icon.
TOOLBAR_CSS = """
QToolBar { spacing: 6px; padding: 5px; }
QToolButton {
background: #f0f1f3; color: #1f2937;
border: 1px solid #c4c8cf; border-radius: 6px;
padding: 6px 14px; font-weight: 600;
}
QToolButton:hover { background: #e3effb; border-color: #2b6cb0; }
QToolButton:pressed { background: #cfe0f4; border-color: #2b6cb0; }
"""
# Intrusion toggle: a physical switch — green & sunken when ON, grey & raised when OFF.
INTRUSION_ON_CSS = """
QPushButton {
background: #2e9e4f; color: white;
border: 3px inset #1c6b34; border-radius: 6px;
padding: 6px 18px; font-weight: 700;
}
QPushButton:hover { background: #34b259; }
"""
INTRUSION_OFF_CSS = """
QPushButton {
background: #e0a106; color: #3a2c00;
border: 3px outset #b5820a; border-radius: 6px;
padding: 6px 18px; font-weight: 700;
}
QPushButton:hover { background: #efb422; }
"""
MAX_FAST_FAILS = 6 # give up auto-restart after this many quick crashes
FAST_FAIL_SECS = 10_000 # an exit sooner than this (ms) counts as a "fast fail"
def make_icon() -> QIcon:
"""A microphone from the theme, or a drawn 'C' badge as fallback."""
themed = QIcon.fromTheme("audio-input-microphone")
if not themed.isNull():
return themed
return make_badge_icon(QColor("#2b6cb0"), "C")
def make_badge_icon(color: QColor, glyph: str) -> QIcon:
"""A filled circle in `color` with `glyph` (a letter or emoji) centered — the tray badge
that tells one running install apart from another at a glance."""
pm = QPixmap(64, 64)
pm.fill(Qt.transparent)
p = QPainter(pm)
p.setRenderHint(QPainter.Antialiasing)
p.setBrush(color)
p.setPen(Qt.NoPen)
p.drawEllipse(4, 4, 56, 56)
p.setPen(QColor("white"))
f = QFont()
f.setPointSize(30 if len(glyph) <= 1 else 22)
f.setBold(True)
p.setFont(f)
p.drawText(pm.rect(), Qt.AlignCenter, glyph[:2])
p.end()
return QIcon(pm)
def instance_icon() -> QIcon:
"""The default microphone for the canonical install, or a per-install colored badge for a
named copy (declared color/glyph from the identity file, else auto-derived from the label)."""
if IS_DEFAULT:
return make_icon()
color = QColor(_INST_COLOR) if _INST_COLOR else QColor()
if not color.isValid(): # unset OR a bad hex in instance.json -> auto
color = QColor.fromHsv(*instance_id.accent_hsv(LABEL))
return make_badge_icon(color, instance_id.badge_glyph(LABEL, _INST_GLYPH))
class _InputBox(QPlainTextEdit):
"""Multi-line paste box for the console; Ctrl+Enter submits (the Send button also does)."""
def __init__(self, on_submit):
super().__init__()
self._on_submit = on_submit
def keyPressEvent(self, e):
if e.key() in (Qt.Key_Return, Qt.Key_Enter) and (e.modifiers() & Qt.ControlModifier):
self._on_submit()
return
super().keyPressEvent(e)
class Supervisor(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.icon = instance_icon()
self._quitting = False
self._fast_fails = 0
self._uptime = QElapsedTimer()
self.proc: QProcess | None = None
self._log_fh = self._open_log()
self._last_log_check = time.time() # gates the once-a-day log rotation (see _maybe_rotate_log)
# --- console window ---------------------------------------------------
self.setWindowTitle(TITLE)
self.setWindowIcon(self.icon)
self.resize(860, 500)
self.view = QPlainTextEdit()
self.view.setReadOnly(True)
self.view.setMaximumBlockCount(8000) # cap memory on long sessions
mono = QFont("monospace")
mono.setStyleHint(QFont.Monospace)
mono.setPointSize(10)
self.view.setFont(mono)
# Bottom pane: paste a message and Send it into the running bot. It is dropped into
# wake-inbox/ (the same path cg-wake/cron use), so the bot injects it as a turn and
# echoes it into the chat. Ctrl+Enter sends too.
self.input = _InputBox(self.send_to_bot)
self.input.setPlaceholderText("Message to the bot — paste, then Send (or Ctrl+Enter)")
self.input.setFont(mono)
self.input.setFixedHeight(self.input.fontMetrics().lineSpacing() * 5 + 14) # ~5 lines
send_btn = QPushButton("Send")
send_btn.setFixedWidth(80)
send_btn.clicked.connect(self.send_to_bot)
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.addWidget(self.input, 1)
row.addWidget(send_btn, 0, Qt.AlignBottom)
central = QWidget()
col = QVBoxLayout(central)
col.setContentsMargins(0, 0, 0, 0)
col.setSpacing(2)
# Readings bar: below the toolbar buttons, above the log. Live subscription budget,
# parsed from the bot's periodic "usage refreshed" log line (_update_usage below).
self.usage_label = QLabel("📊 session — · week —")
self.usage_label.setStyleSheet(
"QLabel { background:#10263b; color:#8fd0ff; padding:5px 10px;"
" font-family:monospace; font-size:11px; border-bottom:1px solid #24425f; }")
self.usage_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
col.addWidget(self.usage_label, 0)
col.addWidget(self.view, 1) # log pane takes the slack
col.addLayout(row) # input pane stays ~fixed height
self.setCentralWidget(central)
self._seed_usage_from_log()
tb = QToolBar()
tb.setMovable(False)
tb.setStyleSheet(TOOLBAR_CSS)
self.addToolBar(tb)
act_restart = QAction("Restart bot", self)
act_restart.triggered.connect(self.restart_bot)
tb.addAction(act_restart)
self.act_unblock = QAction("🔓 UNBLOCK", self)
self.act_unblock.triggered.connect(self.unblock)
self.act_unblock.setVisible(False)
tb.addAction(self.act_unblock)
self.act_regress = QAction("🔓 Unlock & add regression", self)
self.act_regress.triggered.connect(self.unblock_and_regress)
self.act_regress.setVisible(False)
tb.addAction(self.act_regress)
self.act_wake = QAction("☀️ WAKE UP", self)
self.act_wake.triggered.connect(self.wake)
self.act_wake.setVisible(False)
tb.addAction(self.act_wake)
act_clear = QAction("Clear logs", self)
act_clear.triggered.connect(self.clear_logs)
tb.addAction(act_clear)
act_hide = QAction("Hide to tray", self)
act_hide.triggered.connect(self.hide)
tb.addAction(act_hide)
# Intrusion lock: a real checkable on/off switch, pushed to the far right and
# visually distinct from the action buttons.
spacer = QWidget()
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
tb.addWidget(spacer)
self.intrusion_btn = QPushButton("🛡 Intrusion Lock")
self.intrusion_btn.setCheckable(True)
self.intrusion_btn.setCursor(Qt.PointingHandCursor)
self.intrusion_btn.setToolTip(
"ON: a message from any non-allowed Telegram user hard-locks the bridge "
"(kills Claude + locks) and alerts you. Toggle here only — never remotely.")
self.intrusion_btn.clicked.connect(self.toggle_intrusion)
tb.addWidget(self.intrusion_btn)
self.setStatusBar(QStatusBar())
self.status_label = QLabel("starting…")
self.statusBar().addPermanentWidget(self.status_label)
# --- tray icon --------------------------------------------------------
self.tray = QSystemTrayIcon(self.icon, self)
self.tray.setToolTip(TITLE)
menu = QMenu()
menu.addAction("Show console", self.show_console)
menu.addAction("Restart bot", self.restart_bot)
self.tray_unblock = menu.addAction("🔓 Unblock bridge", self.unblock)
self.tray_unblock.setVisible(False)
self.tray_regress = menu.addAction("🔓 Unlock & add regression", self.unblock_and_regress)
self.tray_regress.setVisible(False)
self.tray_wake = menu.addAction("☀️ Wake up (exit sleep)", self.wake)
self.tray_wake.setVisible(False)
self.tray_intrusion = menu.addAction("🛡 Intrusion Lock", self.toggle_intrusion)
self.tray_intrusion.setCheckable(True)
menu.addSeparator()
menu.addAction("Quit", self.quit_app)
self.tray.setContextMenu(menu)
self.tray.activated.connect(self._on_tray_activated)
self.tray.show()
# Watch the firewall BLOCK flag and the SLEEP flag; reflect them in the tray.
self._was_blocked = None
self._was_sleeping = None
self._block_timer = QTimer(self)
self._block_timer.timeout.connect(self._check_block)
self._block_timer.timeout.connect(self._check_sleep)
self._block_timer.timeout.connect(self._refresh_intrusion)
self._block_timer.timeout.connect(self._maybe_rotate_log)
self._block_timer.start(2000)
self._check_block()
self._check_sleep()
self._refresh_intrusion()
self.start_bot()
# --- firewall lock state -------------------------------------------------
def _check_block(self) -> None:
blocked = BLOCK_FILE.exists()
for act in (self.act_unblock, self.tray_unblock, self.act_regress, self.tray_regress):
act.setVisible(blocked)
if blocked == self._was_blocked:
return
self._was_blocked = blocked
if blocked:
self.set_status("🔒 BLOCKED — hacking attempt flagged")
self.append(
"\n[supervisor] 🔒 BLOCKED — a request was flagged as a hacking attempt.\n"
f" Offending prompt: {self._block_reason()!r}\n"
" 'UNBLOCK' = just resume · 'Unlock & add regression' = resume AND "
"record this as a false positive so it never blocks again.\n"
)
self.tray.showMessage(
f"{TITLE} LOCKED 🔒",
"A request was flagged as a hacking attempt. Open the console and "
"Unblock (or Unlock & add regression) when you're at the machine.",
QSystemTrayIcon.Critical,
10000,
)
else:
self.set_status("running")
def _block_reason(self) -> str:
try:
for line in BLOCK_FILE.read_text(encoding="utf-8").splitlines():
if line.startswith("reason:"):
return line[len("reason:"):].strip()
except OSError:
pass
return ""
def unblock(self) -> None:
try:
BLOCK_FILE.unlink(missing_ok=True)
except OSError:
pass
self.append("\n[supervisor] 🔓 Unblocked at the machine — bridge will resume.\n")
self._check_block()
self.tray.showMessage(
TITLE, "Unblocked — the bridge will resume.", self.icon, 4000
)
def unblock_and_regress(self) -> None:
reason = self._block_reason()
if reason:
try:
with open(REGRESSIONS_FILE, "a", encoding="utf-8") as f:
f.write(f'\n- "{reason}"\n')
except OSError:
self.append("\n[supervisor] ⚠️ Could not write to the regressions file.\n")
try:
BLOCK_FILE.unlink(missing_ok=True)
except OSError:
pass
self.append(
f"\n[supervisor] 🔓 Unblocked + recorded as a false positive: {reason[:100]!r}\n"
)
self._check_block()
self.tray.showMessage(
TITLE,
"Unblocked and added to the regressions list — it won't block this again.",
self.icon,
5000,
)
# --- paranoid intrusion gate (toggle; default ON, disabled here only) -----
def toggle_intrusion(self, *args) -> None:
on = not INTRUSION_OFF_FILE.exists() # current state
try:
if on: # ON -> turn OFF
INTRUSION_OFF_FILE.write_text("off\n", encoding="utf-8")
else: # OFF -> turn ON
INTRUSION_OFF_FILE.unlink(missing_ok=True)
except OSError:
self.append("\n[supervisor] ⚠️ Could not toggle the intrusion-lock flag.\n")
self._refresh_intrusion()
now_on = not INTRUSION_OFF_FILE.exists()
self.append(f"\n[supervisor] 🛡 Intrusion lock {'ON' if now_on else 'OFF'} "
"(set at the machine).\n")
self.tray.showMessage(
TITLE, f"Intrusion lock {'ON' if now_on else 'OFF'}.", self.icon, 4000)
def _refresh_intrusion(self) -> None:
on = not INTRUSION_OFF_FILE.exists()
self.intrusion_btn.setChecked(on)
self.intrusion_btn.setText(f"🛡 Intrusion Lock: {'ON' if on else 'OFF'}")
self.intrusion_btn.setStyleSheet(INTRUSION_ON_CSS if on else INTRUSION_OFF_CSS)
self.tray_intrusion.setChecked(on)
self.tray_intrusion.setText(f"🛡 Intrusion lock: {'ON' if on else 'OFF'}")
# --- sleep state (Telegram input paused; Claude keeps running) ------------
def _check_sleep(self) -> None:
sleeping = SLEEP_FILE.exists()
for act in (self.act_wake, self.tray_wake):
act.setVisible(sleeping)
if sleeping == self._was_sleeping:
return
self._was_sleeping = sleeping
if sleeping:
self.set_status("😴 SLEEP — Telegram input paused")
self.append(
"\n[supervisor] 😴 SLEEP mode — Telegram input is paused (Claude keeps "
"running). Click 'WAKE UP' to resume accepting input.\n"
)
self.tray.showMessage(
f"{TITLE} — sleeping 😴",
"Telegram input is paused. Click the tray icon → 'Wake up' to resume.",
self.icon,
6000,
)
elif not BLOCK_FILE.exists():
self.set_status("running")
def wake(self) -> None:
try:
SLEEP_FILE.unlink(missing_ok=True)
except OSError:
pass
self.append("\n[supervisor] ☀️ Woken at the machine — Telegram input resumes.\n")
self._check_sleep()
self.tray.showMessage(
TITLE, "Awake — Telegram input resumes.", self.icon, 4000
)
# --- bot process management ----------------------------------------------
def start_bot(self) -> None:
if self.proc is not None and self.proc.state() != QProcess.NotRunning:
return
self.set_status("launching bot…")
self.proc = QProcess(self)
self.proc.setProgram(PYTHON)
self.proc.setArguments([BOT])
self.proc.setWorkingDirectory(str(HERE))
self.proc.setProcessChannelMode(QProcess.MergedChannels)
env = QProcessEnvironment.systemEnvironment()
env.insert("PYTHONUNBUFFERED", "1") # don't buffer the bot's logs
# Tells `bot restart` a supervisor is watching: it may just exit (we respawn it).
# Headless (./run.sh) lacks this, so the bot re-execs itself instead of dying.
env.insert("CLAUDEGRAM_SUPERVISED", "1")
self.proc.setProcessEnvironment(env)
self.proc.readyReadStandardOutput.connect(self._on_output)
self.proc.started.connect(lambda: self.set_status("running"))
self.proc.finished.connect(self._on_finished)
self.proc.errorOccurred.connect(self._on_proc_error)
self._uptime.start()
self.proc.start()
def restart_bot(self) -> None:
self._fast_fails = 0
self.append("\n[supervisor] restart requested\n")
if self.proc is not None and self.proc.state() != QProcess.NotRunning:
self.proc.terminate() # _on_finished will relaunch it
if not self.proc.waitForFinished(5000):
self.proc.kill()
else:
self.start_bot()
def _maybe_rotate_log(self) -> None:
"""Bound the on-disk log without thrashing the disk. Wired to the 2s block timer but
only compares a timestamp on each tick (cheap); at most once per LOG_CHECK_INTERVAL it
actually stats the file and, if it grew past LOG_MAX_BYTES, rotates it (keeps one .1
backup). Trims, never clears: recent history stays in the new file, older in .log.1."""
now = time.time()
if now - self._last_log_check < LOG_CHECK_INTERVAL:
return
self._last_log_check = now
try:
if LOG_FILE.exists() and LOG_FILE.stat().st_size > LOG_MAX_BYTES:
if self._log_fh:
self._log_fh.close()
LOG_FILE.replace(LOG_FILE.with_name(LOG_FILE.name + ".1"))
self._log_fh = self._open_log()
self.append("[supervisor] log rotated (previous kept as claudegram.log.1)\n")
except OSError:
pass
@staticmethod
def _open_log():
try:
# Boot-time backstop; ongoing rotation is _maybe_rotate_log on the timer.
if LOG_FILE.exists() and LOG_FILE.stat().st_size > LOG_MAX_BYTES:
LOG_FILE.replace(LOG_FILE.with_name(LOG_FILE.name + ".1"))
return open(LOG_FILE, "a", encoding="utf-8", buffering=1)
except OSError:
return None
def clear_logs(self) -> None:
"""Clear the console view AND truncate the persistent log file."""
self.view.clear()
try:
if self._log_fh:
self._log_fh.close()
LOG_FILE.write_text("", encoding="utf-8")
self._log_fh = self._open_log()
except OSError:
pass
self.append("[supervisor] logs cleared.\n")
def _on_output(self) -> None:
data = bytes(self.proc.readAllStandardOutput()).decode("utf-8", "replace")
self.append(data)
self._update_usage(data)
if self._log_fh is not None:
try:
self._log_fh.write(data)
self._log_fh.flush()
except OSError:
pass
def _update_usage(self, text: str) -> None:
"""Show the bot's latest 'usage refreshed' reading in the bar (subscription budget)."""
m = None
for m in _USAGE_RE.finditer(text):
pass # keep the last match in this chunk
if m is not None:
s, sr, w, wr = m.groups()
self.usage_label.setText(
f"📊 session {s}% (resets {sr}) · week {w}% (resets {wr})")
def _seed_usage_from_log(self) -> None:
"""On startup, fill the bar from the last usage line already in the log (tail only)."""
try:
with open(LOG_FILE, "rb") as f:
f.seek(0, 2)
size = f.tell()
f.seek(max(0, size - 65536))
tail = f.read().decode("utf-8", "replace")
except OSError:
return
self._update_usage(tail)
def _on_finished(self, code: int, _status) -> None:
self.set_status(f"stopped (exit {code})")
if self._quitting:
return
if self._uptime.elapsed() < FAST_FAIL_SECS:
self._fast_fails += 1
else:
self._fast_fails = 0
if self._fast_fails > MAX_FAST_FAILS:
self.append(
"\n[supervisor] bot keeps exiting quickly — auto-restart paused. "
"Fix the issue and click 'Restart bot'.\n"
)
self.set_status("crash loop — auto-restart paused")
return
delay = min(30, 3 * max(1, self._fast_fails)) * 1000
self.append(f"\n[supervisor] bot exited; restarting in {delay // 1000}s…\n")
QTimer.singleShot(delay, self._restart_if_running_session)
def _restart_if_running_session(self) -> None:
if not self._quitting:
self.start_bot()
def _on_proc_error(self, err) -> None:
if err == QProcess.FailedToStart:
self.append(
f"\n[supervisor] FAILED TO START: {PYTHON} {BOT}\n"
"Check that the virtualenv exists (run ./run.sh once).\n"
)
self.set_status("failed to start")
# --- window / tray behaviour ---------------------------------------------
def send_to_bot(self) -> None:
"""Inject the input-box text into the running bot as a turn by dropping it into
wake-inbox/ (atomic: temp then rename). The bot's wake watcher picks it up on its
next poll and echoes it into the chat; nothing else to do here."""
text = self.input.toPlainText().strip()
if not text:
return
inbox = HERE / "wake-inbox"
try:
inbox.mkdir(parents=True, exist_ok=True)
stamp = str(time.time_ns())
tmp = inbox / f".{stamp}.tmp"
tmp.write_text(text, encoding="utf-8")
tmp.rename(inbox / f"{stamp}.msg") # atomic: the bot never reads a partial drop
self.input.clear()
except OSError:
self.append("\n[gui] could not queue message (wake-inbox write failed)\n")
def append(self, text: str) -> None:
sb = self.view.verticalScrollBar()
at_bottom = sb.value() >= sb.maximum() - 4
cur = self.view.textCursor()
cur.movePosition(QTextCursor.End)
self.view.setTextCursor(cur)
self.view.insertPlainText(text)
if at_bottom:
sb.setValue(sb.maximum())
def set_status(self, text: str) -> None:
self.status_label.setText(text)
self.tray.setToolTip(f"{TITLE} — {text}")
def show_console(self) -> None:
self.showNormal()
self.raise_()
self.activateWindow()
def _on_tray_activated(self, reason) -> None:
if reason in (QSystemTrayIcon.Trigger, QSystemTrayIcon.DoubleClick):
if self.isVisible():
self.hide()
else:
self.show_console()
def closeEvent(self, event) -> None:
# Closing the window hides to tray instead of quitting.
if self._quitting:
event.accept()
return
event.ignore()
self.hide()
self.tray.showMessage(
TITLE,
"Still running in the tray. Right-click the icon to quit.",
self.icon,
3000,
)
def quit_app(self) -> None:
self._quitting = True
if self.proc is not None and self.proc.state() != QProcess.NotRunning:
self.proc.terminate()
if not self.proc.waitForFinished(5000):
self.proc.kill()
self.tray.hide()
if self._log_fh is not None:
self._log_fh.close()
QApplication.instance().quit()
def main() -> None:
app = QApplication(sys.argv)
app.setApplicationName(DESKTOP_NAME) # distinct WM class -> separate taskbar entry
app.setApplicationDisplayName(TITLE)
app.setDesktopFileName(DESKTOP_NAME) # matches this install's autostart .desktop
app.setQuitOnLastWindowClosed(False) # live in the tray
# Single instance: if one is already running, just ask it to show, then exit.
probe = QLocalSocket()
probe.connectToServer(APP_ID)
if probe.waitForConnected(200):
probe.write(b"show")
probe.flush()
probe.waitForBytesWritten(200)
probe.disconnectFromServer()
return
QLocalServer.removeServer(APP_ID)
server = QLocalServer()
server.listen(APP_ID)
win = Supervisor()
def on_new_connection() -> None:
conn = server.nextPendingConnection()
if conn:
conn.readyRead.connect(lambda: (conn.readAll(), win.show_console()))
server.newConnection.connect(on_new_connection)
if not QSystemTrayIcon.isSystemTrayAvailable():
# No tray (rare here) — fall back to just showing the window.
win.show_console()
QMessageBox.information(
win,
TITLE,
"No system tray detected; showing the console window directly.",
)
sys.exit(app.exec())
if __name__ == "__main__":
main()