codex-telegram-bridge 0.6.2__tar.gz → 0.6.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {codex_telegram_bridge-0.6.2/codex_telegram_bridge.egg-info → codex_telegram_bridge-0.6.4}/PKG-INFO +13 -9
  2. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/README.md +12 -8
  3. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/bridge_setup.py +153 -52
  4. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/bridge_watchdog.py +128 -0
  5. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4/codex_telegram_bridge.egg-info}/PKG-INFO +13 -9
  6. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/pyproject.toml +1 -1
  7. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/tests/test_setup_wizard.py +60 -16
  8. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/LICENSE +0 -0
  9. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/agent_runtime/__init__.py +0 -0
  10. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/agent_runtime/adapters/__init__.py +0 -0
  11. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/agent_runtime/adapters/base.py +0 -0
  12. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/agent_runtime/adapters/codex_repl.py +0 -0
  13. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/agent_runtime/approvals.py +0 -0
  14. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/agent_runtime/capabilities.py +0 -0
  15. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/agent_runtime/locks.py +0 -0
  16. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/agent_runtime/transport.py +0 -0
  17. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/agent_runtime/types.py +0 -0
  18. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/codex_audio_transcribe.py +0 -0
  19. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/codex_repl_bridge.py +0 -0
  20. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/codex_telegram_bridge.egg-info/SOURCES.txt +0 -0
  21. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/codex_telegram_bridge.egg-info/dependency_links.txt +0 -0
  22. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/codex_telegram_bridge.egg-info/entry_points.txt +0 -0
  23. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/codex_telegram_bridge.egg-info/requires.txt +0 -0
  24. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/codex_telegram_bridge.egg-info/top_level.txt +0 -0
  25. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/setup.cfg +0 -0
  26. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/telegram_agent_bridge.py +0 -0
  27. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/tests/test_agent_runtime.py +0 -0
  28. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/tests/test_bridge.py +0 -0
  29. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/tests/test_public_export.py +0 -0
  30. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/tests/test_repl_bridge.py +0 -0
  31. {codex_telegram_bridge-0.6.2 → codex_telegram_bridge-0.6.4}/tests/test_watchdog.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-telegram-bridge
3
- Version: 0.6.2
3
+ Version: 0.6.4
4
4
  Summary: Control a live Codex CLI session from Telegram.
5
5
  Author: Kang Daejong
6
6
  License: MIT
@@ -132,7 +132,7 @@ Answer media attachments
132
132
  -> send the actual media with sendPhoto/sendVideo/sendVoice/sendAudio
133
133
 
134
134
  Service restart
135
- -> run a local watchdog every 60 seconds on Linux/WSL and macOS
135
+ -> run a local watchdog every 60 seconds
136
136
  -> restart or kickstart the bridge if the user service is inactive
137
137
  -> load a persistent JSONL cursor and final-answer dedup ring
138
138
  -> resume from the cursor when it still matches the current session file
@@ -219,8 +219,8 @@ The wizard will:
219
219
  - install `~/.local/bin/telegram-agent-bridge-run`
220
220
  - install and start a user service with systemd on Linux/WSL, launchd on macOS,
221
221
  or a per-user Windows Startup launcher
222
- - install a watchdog timer/LaunchAgent on Linux/WSL and macOS that recovers an
223
- inactive bridge service
222
+ - install a watchdog timer/LaunchAgent/Scheduled Task that recovers an inactive
223
+ bridge service
224
224
  - send a setup-complete test message
225
225
 
226
226
  Default setup mode is `repl`, which supports the visible Codex CLI transcript,
@@ -504,8 +504,8 @@ service was restarting.
504
504
 
505
505
  ## Service Watchdog
506
506
 
507
- When the setup wizard installs a background service on Linux/WSL or macOS, it
508
- also installs a small watchdog.
507
+ When the setup wizard installs a background service, it also installs a small
508
+ watchdog.
509
509
 
510
510
  On Windows, the wizard writes a per-user Startup launcher at
511
511
  `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\telegram-agent-bridge.bat`.
@@ -513,9 +513,13 @@ This does not require administrator rights. Setup also starts that launcher once
513
513
  immediately so you can send `/ping` without logging out and back in. If an older
514
514
  per-user `telegram-agent-bridge` Scheduled Task exists, `doctor` still reports
515
515
  its `schtasks /query` status; otherwise it reports whether the Startup launcher
516
- is installed. Windows watchdog install is not automated; `doctor` reports that
517
- Startup autostart is the supported Windows path instead of recommending another
518
- setup run for watchdog.
516
+ is installed.
517
+
518
+ The Windows watchdog is a separate per-user Scheduled Task named
519
+ `telegram-agent-bridge-watchdog`. It runs `bridge_watchdog.py` every minute
520
+ without administrator rights. If the bridge process is missing, it first restarts
521
+ an older service Scheduled Task when present, otherwise it re-runs the Startup
522
+ launcher and writes the same status file.
519
523
 
520
524
  On Linux and WSL, the wizard writes:
521
525
 
@@ -105,7 +105,7 @@ Answer media attachments
105
105
  -> send the actual media with sendPhoto/sendVideo/sendVoice/sendAudio
106
106
 
107
107
  Service restart
108
- -> run a local watchdog every 60 seconds on Linux/WSL and macOS
108
+ -> run a local watchdog every 60 seconds
109
109
  -> restart or kickstart the bridge if the user service is inactive
110
110
  -> load a persistent JSONL cursor and final-answer dedup ring
111
111
  -> resume from the cursor when it still matches the current session file
@@ -192,8 +192,8 @@ The wizard will:
192
192
  - install `~/.local/bin/telegram-agent-bridge-run`
193
193
  - install and start a user service with systemd on Linux/WSL, launchd on macOS,
194
194
  or a per-user Windows Startup launcher
195
- - install a watchdog timer/LaunchAgent on Linux/WSL and macOS that recovers an
196
- inactive bridge service
195
+ - install a watchdog timer/LaunchAgent/Scheduled Task that recovers an inactive
196
+ bridge service
197
197
  - send a setup-complete test message
198
198
 
199
199
  Default setup mode is `repl`, which supports the visible Codex CLI transcript,
@@ -477,8 +477,8 @@ service was restarting.
477
477
 
478
478
  ## Service Watchdog
479
479
 
480
- When the setup wizard installs a background service on Linux/WSL or macOS, it
481
- also installs a small watchdog.
480
+ When the setup wizard installs a background service, it also installs a small
481
+ watchdog.
482
482
 
483
483
  On Windows, the wizard writes a per-user Startup launcher at
484
484
  `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\telegram-agent-bridge.bat`.
@@ -486,9 +486,13 @@ This does not require administrator rights. Setup also starts that launcher once
486
486
  immediately so you can send `/ping` without logging out and back in. If an older
487
487
  per-user `telegram-agent-bridge` Scheduled Task exists, `doctor` still reports
488
488
  its `schtasks /query` status; otherwise it reports whether the Startup launcher
489
- is installed. Windows watchdog install is not automated; `doctor` reports that
490
- Startup autostart is the supported Windows path instead of recommending another
491
- setup run for watchdog.
489
+ is installed.
490
+
491
+ The Windows watchdog is a separate per-user Scheduled Task named
492
+ `telegram-agent-bridge-watchdog`. It runs `bridge_watchdog.py` every minute
493
+ without administrator rights. If the bridge process is missing, it first restarts
494
+ an older service Scheduled Task when present, otherwise it re-runs the Startup
495
+ launcher and writes the same status file.
492
496
 
493
497
  On Linux and WSL, the wizard writes:
494
498
 
@@ -27,6 +27,7 @@ APP_NAME = "telegram-agent-bridge"
27
27
  SERVICE_NAME = "telegram-agent-bridge.service"
28
28
  LAUNCHD_LABEL = "com.user.telegram-agent-bridge"
29
29
  WINDOWS_TASK_NAME = APP_NAME
30
+ WINDOWS_WATCHDOG_TASK_NAME = f"{APP_NAME}-watchdog"
30
31
  WATCHDOG_SERVICE_NAME = "telegram-agent-bridge-watchdog.service"
31
32
  WATCHDOG_TIMER_NAME = "telegram-agent-bridge-watchdog.timer"
32
33
  WATCHDOG_LAUNCHD_LABEL = "com.user.telegram-agent-bridge-watchdog"
@@ -148,7 +149,7 @@ def setup_command(command: str) -> None:
148
149
  def doctor_command_hint() -> str:
149
150
  invoked = Path(sys.argv[0]).name
150
151
  if invoked == "bridge_setup.py":
151
- python_cmd = "python" if platform.system() == "Windows" else "python3"
152
+ python_cmd = windows_command_quote(sys.executable or "python") if platform.system() == "Windows" else "python3"
152
153
  return f"{python_cmd} bridge_setup.py doctor"
153
154
  if invoked in {"codex-telegram-bridge", "telegram-agent-bridge"}:
154
155
  return f"{invoked} doctor"
@@ -159,6 +160,8 @@ def setup_command_hint(*, mode: str | None = None) -> str:
159
160
  invoked = Path(sys.argv[0]).name
160
161
  if invoked in {"codex-telegram-bridge", "telegram-agent-bridge"}:
161
162
  command = f"{invoked} setup"
163
+ elif platform.system() == "Windows":
164
+ command = f"{windows_command_quote(sys.executable or 'python')} bridge_setup.py setup"
162
165
  else:
163
166
  command = "python bridge_setup.py setup"
164
167
  if mode:
@@ -187,7 +190,7 @@ def chmod_private(path: Path) -> None:
187
190
  def write_text_atomic(path: Path, value: str, mode: int | None = None) -> None:
188
191
  path.parent.mkdir(parents=True, exist_ok=True)
189
192
  tmp = path.with_name(path.name + ".tmp")
190
- tmp.write_text(value, encoding="utf-8")
193
+ tmp.write_text(value, encoding="utf-8", newline="\n")
191
194
  if mode is not None:
192
195
  tmp.chmod(mode)
193
196
  tmp.replace(path)
@@ -320,7 +323,7 @@ def wait_for_chat_id(
320
323
 
321
324
 
322
325
  def service_is_running(status: str | None) -> bool:
323
- return (status or "").strip().lower() in {"active", "running", "startup-installed"}
326
+ return (status or "").strip().lower() in {"active", "running"}
324
327
 
325
328
 
326
329
  def send_test_message(
@@ -331,12 +334,7 @@ def send_test_message(
331
334
  service_status_text: str | None = None,
332
335
  start_command: str | None = None,
333
336
  ) -> bool:
334
- if (service_status_text or "").strip().lower() == "startup-installed":
335
- text = (
336
- "telegram-agent-bridge setup complete. Windows Startup autostart is installed "
337
- "and was launched. Send /ping to test the bridge."
338
- )
339
- elif service_status_text and service_is_running(service_status_text):
337
+ if service_status_text and service_is_running(service_status_text):
340
338
  text = "telegram-agent-bridge setup complete. Background service is running. Send /ping to test the bridge."
341
339
  elif start_command:
342
340
  status = service_status_text or "unknown"
@@ -420,7 +418,61 @@ def write_env_config(
420
418
  write_text_atomic(path, content, mode=0o600)
421
419
 
422
420
 
423
- def install_runner(runner_file: Path, env_file: Path) -> None:
421
+ def python_runner_content(env_file: Path) -> str:
422
+ return "\n".join(
423
+ [
424
+ "#!/usr/bin/env python3",
425
+ "import os",
426
+ "import shlex",
427
+ "import subprocess",
428
+ "import sys",
429
+ "from pathlib import Path",
430
+ "",
431
+ f"ENV_FILE = Path({str(env_file)!r})",
432
+ f"REPL_SCRIPT = Path({str(REPL_BRIDGE_SCRIPT)!r})",
433
+ f"EXEC_SCRIPT = Path({str(EXEC_BRIDGE_SCRIPT)!r})",
434
+ "",
435
+ "if not ENV_FILE.exists():",
436
+ " print(f\"config file not found: {ENV_FILE}\", file=sys.stderr)",
437
+ " raise SystemExit(2)",
438
+ "",
439
+ "for raw_line in ENV_FILE.read_text(encoding='utf-8').splitlines():",
440
+ " line = raw_line.strip()",
441
+ " if not line or line.startswith('#') or '=' not in line:",
442
+ " continue",
443
+ " key, raw_value = line.split('=', 1)",
444
+ " key = key.strip()",
445
+ " raw_value = raw_value.strip()",
446
+ " try:",
447
+ " parts = shlex.split(raw_value)",
448
+ " value = parts[0] if parts else ''",
449
+ " except ValueError:",
450
+ " value = raw_value.strip(\"'\\\"\")",
451
+ " if key:",
452
+ " os.environ[key] = value",
453
+ "",
454
+ "mode = os.environ.get('TAB_BRIDGE_MODE', 'repl')",
455
+ "if mode == 'repl':",
456
+ " script = REPL_SCRIPT",
457
+ "elif mode == 'exec':",
458
+ " script = EXEC_SCRIPT",
459
+ "else:",
460
+ " print(f\"unknown TAB_BRIDGE_MODE: {mode}\", file=sys.stderr)",
461
+ " raise SystemExit(2)",
462
+ "",
463
+ "os.environ.setdefault('PYTHONUNBUFFERED', '1')",
464
+ "raise SystemExit(subprocess.call([sys.executable, str(script)]))",
465
+ "",
466
+ ]
467
+ )
468
+
469
+
470
+ def install_runner(runner_file: Path, env_file: Path, *, os_name: str | None = None) -> None:
471
+ os_name = os_name or platform.system()
472
+ if os_name == "Windows":
473
+ write_text_atomic(runner_file, python_runner_content(env_file), mode=0o755)
474
+ return
475
+
424
476
  runner = "\n".join(
425
477
  [
426
478
  "#!/usr/bin/env bash",
@@ -768,6 +820,14 @@ def windows_command_quote(value: str) -> str:
768
820
  return value
769
821
 
770
822
 
823
+ def windows_python_executable() -> str:
824
+ return sys.executable or shutil.which("python") or "python"
825
+
826
+
827
+ def windows_python_runner_command(runner_file: Path) -> tuple[str, list[str]]:
828
+ return windows_python_executable(), [str(runner_file)]
829
+
830
+
771
831
  def windows_manual_start_command(host: WindowsBashHost, runner_file: Path) -> str:
772
832
  if host.kind == "wsl":
773
833
  args: list[str] = []
@@ -789,10 +849,8 @@ def manual_start_command_for_runner(
789
849
  ) -> str:
790
850
  os_name = os_name or platform.system()
791
851
  if os_name == "Windows":
792
- try:
793
- return windows_manual_start_command(detect_windows_bash_host(run=run), runner_file)
794
- except SetupError:
795
- return str(runner_file)
852
+ executable, args = windows_python_runner_command(runner_file)
853
+ return " ".join(windows_command_quote(part) for part in [executable, *args])
796
854
  return str(runner_file)
797
855
 
798
856
 
@@ -834,32 +892,55 @@ def install_windows_scheduled_task(
834
892
  return f"Scheduled Task: {task_name}"
835
893
 
836
894
 
895
+ def windows_watchdog_task_action() -> str:
896
+ python_bin = sys.executable or shutil.which("python") or shutil.which("python3") or "python"
897
+ return " ".join(windows_command_quote(part) for part in [str(python_bin), str(WATCHDOG_SCRIPT)])
898
+
899
+
900
+ def install_windows_watchdog_task(
901
+ *,
902
+ start: bool,
903
+ task_name: str,
904
+ run: Callable[..., subprocess.CompletedProcess[str]],
905
+ ) -> str | None:
906
+ create = run(
907
+ [
908
+ "schtasks",
909
+ "/Create",
910
+ "/TN",
911
+ task_name,
912
+ "/SC",
913
+ "MINUTE",
914
+ "/MO",
915
+ "1",
916
+ "/TR",
917
+ windows_watchdog_task_action(),
918
+ "/F",
919
+ ]
920
+ )
921
+ if create.returncode != 0:
922
+ detail = command_output(create) or "unknown error"
923
+ warn(f"failed to create Windows watchdog Scheduled Task {task_name}: {detail}")
924
+ return None
925
+
926
+ if start:
927
+ started = run(["schtasks", "/Run", "/TN", task_name])
928
+ if started.returncode != 0:
929
+ warn(f"could not start Windows watchdog Scheduled Task {task_name}: {command_output(started)}")
930
+ return f"Scheduled Task: {task_name}"
931
+
932
+
837
933
  def powershell_invoke_command(executable: str, args: list[str]) -> str:
838
934
  return " ".join(["&", powershell_single_quote(executable), *[powershell_single_quote(arg) for arg in args]])
839
935
 
840
936
 
841
- def windows_startup_launcher_command(host: WindowsBashHost, runner_file: Path) -> tuple[str, str]:
842
- executable, args = windows_bash_host_runner_command(host, runner_file)
843
- inner = powershell_invoke_command(executable, args)
844
- nested_args = [
845
- "-NoProfile",
846
- "-ExecutionPolicy",
847
- "Bypass",
848
- "-Command",
849
- inner,
850
- ]
851
- quoted_args = ", ".join(powershell_single_quote(arg) for arg in nested_args)
852
- script = (
853
- "Start-Process -WindowStyle Hidden "
854
- f"-FilePath {powershell_single_quote('powershell.exe')} "
855
- f"-ArgumentList @({quoted_args})"
856
- )
857
- script_arg = '"' + script.replace('"', '`"') + '"'
858
- return "powershell.exe", f"-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -Command {script_arg}"
937
+ def windows_startup_launcher_command(runner_file: Path) -> tuple[str, str]:
938
+ executable, args = windows_python_runner_command(runner_file)
939
+ return windows_scheduled_task_exec(executable, args)
859
940
 
860
941
 
861
- def windows_startup_launcher_content(host: WindowsBashHost, runner_file: Path) -> str:
862
- command, arguments = windows_startup_launcher_command(host, runner_file)
942
+ def windows_startup_launcher_content(runner_file: Path) -> str:
943
+ command, arguments = windows_startup_launcher_command(runner_file)
863
944
  return "\r\n".join(
864
945
  [
865
946
  "@echo off",
@@ -879,16 +960,12 @@ def install_windows_startup_launcher(
879
960
  startup_dir: Path | None = None,
880
961
  app_name: str = APP_NAME,
881
962
  ) -> Path | None:
882
- try:
883
- host = bash_host or detect_windows_bash_host(run=run)
884
- except SetupError as exc:
885
- warn(str(exc))
886
- return None
963
+ _ = bash_host
887
964
 
888
965
  launcher_file = default_windows_startup_launcher_file(startup_dir, app_name=app_name)
889
966
  write_text_atomic(
890
967
  launcher_file,
891
- windows_startup_launcher_content(host, runner_file),
968
+ windows_startup_launcher_content(runner_file),
892
969
  mode=0o644,
893
970
  )
894
971
  if start:
@@ -919,6 +996,19 @@ def windows_scheduled_task_status(
919
996
  return parse_windows_scheduled_task_status(proc.stdout or proc.stderr or "")
920
997
 
921
998
 
999
+ def windows_bridge_process_running(run: Callable[..., subprocess.CompletedProcess[str]]) -> bool:
1000
+ script = (
1001
+ "$ErrorActionPreference='SilentlyContinue'; "
1002
+ "$p = Get-CimInstance Win32_Process | Where-Object { "
1003
+ "$_.ProcessId -ne $PID -and $_.CommandLine -and "
1004
+ "$_.CommandLine -match 'telegram_agent_bridge\\.py|codex_repl_bridge\\.py|telegram-agent-bridge-run' "
1005
+ "} | Select-Object -First 1; "
1006
+ "if ($p) { 'running'; exit 0 } else { 'missing'; exit 1 }"
1007
+ )
1008
+ proc = run(["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script])
1009
+ return proc.returncode == 0
1010
+
1011
+
922
1012
  def windows_startup_launcher_status(
923
1013
  startup_dir: Path | None = None,
924
1014
  *,
@@ -1035,11 +1125,11 @@ def install_watchdog(
1035
1125
  return plist_file
1036
1126
 
1037
1127
  if os_name == "Windows":
1038
- warn(
1039
- "Windows watchdog install is not automated; "
1040
- "setup installs the Startup launcher autostart only"
1128
+ return install_windows_watchdog_task(
1129
+ start=start,
1130
+ task_name=WINDOWS_WATCHDOG_TASK_NAME,
1131
+ run=run,
1041
1132
  )
1042
- return None
1043
1133
 
1044
1134
  warn(f"watchdog install is not automated for {os_name}")
1045
1135
  return None
@@ -1107,6 +1197,11 @@ def uninstall_watchdog(
1107
1197
  pass
1108
1198
  return
1109
1199
 
1200
+ if os_name == "Windows":
1201
+ run(["schtasks", "/End", "/TN", WINDOWS_WATCHDOG_TASK_NAME])
1202
+ run(["schtasks", "/Delete", "/TN", WINDOWS_WATCHDOG_TASK_NAME, "/F"])
1203
+ return
1204
+
1110
1205
  warn(f"watchdog uninstall is not automated for {os_name}")
1111
1206
 
1112
1207
 
@@ -1133,6 +1228,8 @@ def service_status(
1133
1228
  task_status = windows_scheduled_task_status(task_name=task_name, run=run)
1134
1229
  if task_status != "not-installed":
1135
1230
  return task_status
1231
+ if windows_bridge_process_running(run):
1232
+ return "running"
1136
1233
  return windows_startup_launcher_status(windows_startup_dir, app_name=task_name)
1137
1234
  return "manual"
1138
1235
 
@@ -1141,7 +1238,7 @@ def watchdog_status(
1141
1238
  *,
1142
1239
  os_name: str | None = None,
1143
1240
  run: Callable[..., subprocess.CompletedProcess[str]] = run_command,
1144
- task_name: str = WINDOWS_TASK_NAME,
1241
+ task_name: str = WINDOWS_WATCHDOG_TASK_NAME,
1145
1242
  ) -> str:
1146
1243
  os_name = os_name or platform.system()
1147
1244
  if os_name == "Linux":
@@ -1323,13 +1420,13 @@ def setup_bridge(options: SetupOptions, api_call: ApiCall = telegram_call) -> in
1323
1420
  ok(f"wrote private config: {options.config_file}")
1324
1421
  setup_note("The config file is chmod 600 and should stay out of git.")
1325
1422
 
1326
- install_runner(options.runner_file, options.config_file)
1423
+ current_os = platform.system()
1424
+ install_runner(options.runner_file, options.config_file, os_name=current_os)
1327
1425
  ok(f"installed runner: {options.runner_file}")
1328
1426
 
1329
1427
  setup_step(5, "Install the background service")
1330
1428
  service_state: str | None = None
1331
1429
  start_command: str | None = None
1332
- current_os = platform.system()
1333
1430
  if options.install_service:
1334
1431
  installed = install_service(options.runner_file, start=options.start_service)
1335
1432
  if installed:
@@ -1337,7 +1434,12 @@ def setup_bridge(options: SetupOptions, api_call: ApiCall = telegram_call) -> in
1337
1434
  ok(f"installed service: {installed}")
1338
1435
  service_state = service_status()
1339
1436
  if options.start_service:
1340
- ok(f"service status: {service_state}")
1437
+ if service_is_running(service_state):
1438
+ ok(f"service status: {service_state}")
1439
+ else:
1440
+ warn(f"service status: {service_state}")
1441
+ if current_os == "Windows" and service_state == "startup-installed":
1442
+ setup_note("Windows Startup autostart exists, but the bridge process is not confirmed running.")
1341
1443
  else:
1342
1444
  start_command = manual_start_command_for_runner(options.runner_file, os_name=current_os)
1343
1445
  warn("service install did not complete; run the bridge manually")
@@ -1500,6 +1602,8 @@ def doctor(config_file: Path, runner_file: Path, api_call: ApiCall = telegram_ca
1500
1602
  add_next_step(f"Install Windows Startup autostart: {setup_command_hint()}")
1501
1603
  else:
1502
1604
  add_next_step(f"Run setup again to install service/watchdog: {setup_command_hint()}")
1605
+ elif current_os == "Windows" and status == "startup-installed":
1606
+ add_next_step(f"Start Windows Startup launcher: {service_start_command(os_name=current_os, runner_file=runner_file)}")
1503
1607
  warnings += 1
1504
1608
 
1505
1609
  wd_status = watchdog_status()
@@ -1509,10 +1613,7 @@ def doctor(config_file: Path, runner_file: Path, api_call: ApiCall = telegram_ca
1509
1613
  warn(f"watchdog status: {wd_status}")
1510
1614
  if wd_status == "not-installed":
1511
1615
  if current_os == "Windows":
1512
- setup_note(
1513
- "Windows watchdog install is not automated; "
1514
- "the Startup launcher covers logon autostart but not periodic recovery."
1515
- )
1616
+ add_next_step(f"Run setup again to install Windows watchdog: {setup_command_hint()}")
1516
1617
  else:
1517
1618
  add_next_step(f"Run setup again to install service/watchdog: {setup_command_hint()}")
1518
1619
  warnings += 1
@@ -15,6 +15,7 @@ from typing import Callable
15
15
  APP_NAME = "telegram-agent-bridge"
16
16
  SERVICE_NAME = "telegram-agent-bridge.service"
17
17
  LAUNCHD_LABEL = "com.user.telegram-agent-bridge"
18
+ WINDOWS_TASK_NAME = APP_NAME
18
19
 
19
20
  RunCommand = Callable[[list[str]], subprocess.CompletedProcess[str]]
20
21
 
@@ -41,6 +42,83 @@ def status_text(proc: subprocess.CompletedProcess[str]) -> str:
41
42
  return (proc.stdout or proc.stderr or "").strip()
42
43
 
43
44
 
45
+ def default_windows_startup_dir() -> Path:
46
+ appdata = os.environ.get("APPDATA")
47
+ if appdata:
48
+ return (
49
+ Path(appdata)
50
+ / "Microsoft"
51
+ / "Windows"
52
+ / "Start Menu"
53
+ / "Programs"
54
+ / "Startup"
55
+ )
56
+ return (
57
+ Path.home()
58
+ / "AppData"
59
+ / "Roaming"
60
+ / "Microsoft"
61
+ / "Windows"
62
+ / "Start Menu"
63
+ / "Programs"
64
+ / "Startup"
65
+ )
66
+
67
+
68
+ def default_windows_startup_launcher_file(
69
+ startup_dir: Path | None = None,
70
+ *,
71
+ app_name: str = APP_NAME,
72
+ ) -> Path:
73
+ return (startup_dir or default_windows_startup_dir()) / f"{app_name}.bat"
74
+
75
+
76
+ def powershell_single_quote(value: str | Path) -> str:
77
+ return "'" + str(value).replace("'", "''") + "'"
78
+
79
+
80
+ def windows_startup_launcher_start_command(launcher_file: Path) -> list[str]:
81
+ script = f"Start-Process -WindowStyle Hidden -FilePath {powershell_single_quote(launcher_file)}"
82
+ return [
83
+ "powershell.exe",
84
+ "-NoProfile",
85
+ "-ExecutionPolicy",
86
+ "Bypass",
87
+ "-Command",
88
+ script,
89
+ ]
90
+
91
+
92
+ def parse_windows_scheduled_task_status(output: str) -> str:
93
+ for line in output.replace("\r\n", "\n").splitlines():
94
+ if ":" not in line:
95
+ continue
96
+ key, value = line.split(":", 1)
97
+ if key.strip().lower() == "status":
98
+ return value.strip() or "unknown"
99
+ return "unknown"
100
+
101
+
102
+ def windows_scheduled_task_status(*, task_name: str, run: RunCommand) -> str:
103
+ proc = run(["schtasks", "/Query", "/TN", task_name, "/FO", "LIST", "/V"])
104
+ if proc.returncode != 0:
105
+ return "not-installed"
106
+ return parse_windows_scheduled_task_status(proc.stdout or proc.stderr or "")
107
+
108
+
109
+ def windows_bridge_process_running(run: RunCommand) -> bool:
110
+ script = (
111
+ "$ErrorActionPreference='SilentlyContinue'; "
112
+ "$p = Get-CimInstance Win32_Process | Where-Object { "
113
+ "$_.ProcessId -ne $PID -and $_.CommandLine -and "
114
+ "$_.CommandLine -match 'telegram_agent_bridge\\.py|codex_repl_bridge\\.py|telegram-agent-bridge-run' "
115
+ "}; "
116
+ "if ($p) { 'running'; exit 0 } else { 'missing'; exit 1 }"
117
+ )
118
+ proc = run(["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script])
119
+ return proc.returncode == 0
120
+
121
+
44
122
  def launchd_state(output: str) -> str:
45
123
  for line in output.splitlines():
46
124
  stripped = line.strip()
@@ -130,12 +208,54 @@ def watch_macos(
130
208
  return 1
131
209
 
132
210
 
211
+ def watch_windows(
212
+ *,
213
+ service_task_name: str,
214
+ startup_launcher: Path | None,
215
+ status_file: Path,
216
+ run: RunCommand,
217
+ settle_seconds: float,
218
+ ) -> int:
219
+ if windows_bridge_process_running(run):
220
+ write_status(status_file, "active", "windows-process")
221
+ return 0
222
+
223
+ task_status = windows_scheduled_task_status(task_name=service_task_name, run=run)
224
+ if task_status != "not-installed":
225
+ start = run(["schtasks", "/Run", "/TN", service_task_name])
226
+ if settle_seconds > 0:
227
+ time.sleep(settle_seconds)
228
+ if windows_bridge_process_running(run):
229
+ write_status(status_file, "recovered", f"windows-task:{service_task_name}")
230
+ return 0
231
+ detail = status_text(start) or f"windows-task:{service_task_name}:{task_status}:start-failed"
232
+ write_status(status_file, "failed", detail)
233
+ return 1
234
+
235
+ launcher = startup_launcher or default_windows_startup_launcher_file()
236
+ if launcher.exists():
237
+ start = run(windows_startup_launcher_start_command(launcher))
238
+ if settle_seconds > 0:
239
+ time.sleep(settle_seconds)
240
+ if windows_bridge_process_running(run):
241
+ write_status(status_file, "recovered", f"windows-startup:{launcher}")
242
+ return 0
243
+ detail = status_text(start) or f"windows-startup:{launcher}:process-not-detected"
244
+ write_status(status_file, "failed", detail)
245
+ return 1
246
+
247
+ write_status(status_file, "failed", "windows:not-installed")
248
+ return 1
249
+
250
+
133
251
  def watch_once(
134
252
  *,
135
253
  os_name: str | None = None,
136
254
  service_name: str = SERVICE_NAME,
137
255
  launchd_label: str = LAUNCHD_LABEL,
138
256
  launchd_plist: Path | None = None,
257
+ windows_service_task_name: str = WINDOWS_TASK_NAME,
258
+ windows_startup_launcher: Path | None = None,
139
259
  status_file: Path | None = None,
140
260
  run: RunCommand = run_command,
141
261
  settle_seconds: float = 2.0,
@@ -158,6 +278,14 @@ def watch_once(
158
278
  run=run,
159
279
  settle_seconds=settle_seconds,
160
280
  )
281
+ if os_name == "Windows":
282
+ return watch_windows(
283
+ service_task_name=windows_service_task_name,
284
+ startup_launcher=windows_startup_launcher,
285
+ status_file=status_file,
286
+ run=run,
287
+ settle_seconds=settle_seconds,
288
+ )
161
289
 
162
290
  write_status(status_file, "unsupported", os_name)
163
291
  return 0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-telegram-bridge
3
- Version: 0.6.2
3
+ Version: 0.6.4
4
4
  Summary: Control a live Codex CLI session from Telegram.
5
5
  Author: Kang Daejong
6
6
  License: MIT
@@ -132,7 +132,7 @@ Answer media attachments
132
132
  -> send the actual media with sendPhoto/sendVideo/sendVoice/sendAudio
133
133
 
134
134
  Service restart
135
- -> run a local watchdog every 60 seconds on Linux/WSL and macOS
135
+ -> run a local watchdog every 60 seconds
136
136
  -> restart or kickstart the bridge if the user service is inactive
137
137
  -> load a persistent JSONL cursor and final-answer dedup ring
138
138
  -> resume from the cursor when it still matches the current session file
@@ -219,8 +219,8 @@ The wizard will:
219
219
  - install `~/.local/bin/telegram-agent-bridge-run`
220
220
  - install and start a user service with systemd on Linux/WSL, launchd on macOS,
221
221
  or a per-user Windows Startup launcher
222
- - install a watchdog timer/LaunchAgent on Linux/WSL and macOS that recovers an
223
- inactive bridge service
222
+ - install a watchdog timer/LaunchAgent/Scheduled Task that recovers an inactive
223
+ bridge service
224
224
  - send a setup-complete test message
225
225
 
226
226
  Default setup mode is `repl`, which supports the visible Codex CLI transcript,
@@ -504,8 +504,8 @@ service was restarting.
504
504
 
505
505
  ## Service Watchdog
506
506
 
507
- When the setup wizard installs a background service on Linux/WSL or macOS, it
508
- also installs a small watchdog.
507
+ When the setup wizard installs a background service, it also installs a small
508
+ watchdog.
509
509
 
510
510
  On Windows, the wizard writes a per-user Startup launcher at
511
511
  `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\telegram-agent-bridge.bat`.
@@ -513,9 +513,13 @@ This does not require administrator rights. Setup also starts that launcher once
513
513
  immediately so you can send `/ping` without logging out and back in. If an older
514
514
  per-user `telegram-agent-bridge` Scheduled Task exists, `doctor` still reports
515
515
  its `schtasks /query` status; otherwise it reports whether the Startup launcher
516
- is installed. Windows watchdog install is not automated; `doctor` reports that
517
- Startup autostart is the supported Windows path instead of recommending another
518
- setup run for watchdog.
516
+ is installed.
517
+
518
+ The Windows watchdog is a separate per-user Scheduled Task named
519
+ `telegram-agent-bridge-watchdog`. It runs `bridge_watchdog.py` every minute
520
+ without administrator rights. If the bridge process is missing, it first restarts
521
+ an older service Scheduled Task when present, otherwise it re-runs the Startup
522
+ launcher and writes the same status file.
519
523
 
520
524
  On Linux and WSL, the wizard writes:
521
525
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codex-telegram-bridge"
7
- version = "0.6.2"
7
+ version = "0.6.4"
8
8
  description = "Control a live Codex CLI session from Telegram."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -5,6 +5,7 @@ import subprocess
5
5
  import tempfile
6
6
  import unittest
7
7
  from pathlib import Path
8
+ from unittest import mock
8
9
 
9
10
  import bridge_setup as setup
10
11
 
@@ -174,7 +175,23 @@ class SetupWizardTests(unittest.TestCase):
174
175
  self.assertIn("/mnt/c/Users/Ada/very-long-profile-segment", xml_text)
175
176
  self.assertIn(("schtasks", "/Run", "/TN", "tab-test-autostart"), commands)
176
177
 
177
- def test_install_service_windows_installs_startup_launcher_without_schtasks(self):
178
+ def test_install_runner_windows_writes_python_runner_with_lf(self):
179
+ with tempfile.TemporaryDirectory() as tmpdir:
180
+ runner = Path(tmpdir) / "telegram-agent-bridge-run"
181
+ env_file = Path(r"C:\Users\gb\.config\telegram-agent-bridge.env")
182
+
183
+ setup.install_runner(runner, env_file, os_name="Windows")
184
+
185
+ raw = runner.read_bytes()
186
+ text = raw.decode("utf-8")
187
+ self.assertNotIn(b"\r", raw)
188
+ self.assertIn("import os", text)
189
+ self.assertIn("shlex.split", text)
190
+ self.assertIn(repr(str(env_file)), text)
191
+ self.assertIn("telegram_agent_bridge.py", text)
192
+ self.assertNotIn("set -euo pipefail", text)
193
+
194
+ def test_install_service_windows_installs_native_startup_launcher_without_bash(self):
178
195
  commands = []
179
196
 
180
197
  def run(cmd, check=False):
@@ -183,22 +200,26 @@ class SetupWizardTests(unittest.TestCase):
183
200
 
184
201
  with tempfile.TemporaryDirectory() as tmpdir:
185
202
  startup_dir = Path(tmpdir) / "Startup"
186
- installed = setup.install_service(
187
- Path(r"C:\Users\gb\.local\bin\telegram-agent-bridge-run"),
188
- os_name="Windows",
189
- run=run,
190
- task_name="telegram-agent-bridge",
191
- bash_host=setup.WindowsBashHost(kind="git-bash", executable=r"C:\Program Files\Git\bin\bash.exe"),
192
- windows_startup_dir=startup_dir,
193
- )
203
+ with mock.patch.object(setup.sys, "executable", r"C:\Python312\python.exe"):
204
+ installed = setup.install_service(
205
+ Path(r"C:\Users\gb\.local\bin\telegram-agent-bridge-run"),
206
+ os_name="Windows",
207
+ run=run,
208
+ task_name="telegram-agent-bridge",
209
+ bash_host=setup.WindowsBashHost(kind="git-bash", executable=r"C:\Program Files\Git\bin\bash.exe"),
210
+ windows_startup_dir=startup_dir,
211
+ )
194
212
 
195
213
  launcher = startup_dir / "telegram-agent-bridge.bat"
196
214
  self.assertEqual(installed, launcher)
197
215
  self.assertTrue(launcher.exists())
198
216
  launcher_text = launcher.read_text(encoding="utf-8")
199
217
  self.assertIn("Start-Process", launcher_text)
200
- self.assertIn("C:\\Program Files\\Git\\bin\\bash.exe", launcher_text)
201
- self.assertIn("/c/Users/gb/.local/bin/telegram-agent-bridge-run", launcher_text)
218
+ self.assertIn(r"C:\Python312\python.exe", launcher_text)
219
+ self.assertIn(r"C:\Users\gb\.local\bin\telegram-agent-bridge-run", launcher_text)
220
+ self.assertNotIn("bash.exe", launcher_text)
221
+ self.assertNotIn("wsl.exe", launcher_text)
222
+ self.assertNotIn("/c/Users/gb", launcher_text)
202
223
  self.assertIn(">NUL 2>NUL", launcher_text)
203
224
 
204
225
  self.assertNotIn(("schtasks", "/Create"), [cmd[:2] for cmd in commands])
@@ -215,8 +236,11 @@ class SetupWizardTests(unittest.TestCase):
215
236
  launcher.write_text("@echo off\n", encoding="utf-8")
216
237
 
217
238
  def missing_task(cmd, check=False):
218
- self.assertEqual(cmd[:4], ["schtasks", "/Query", "/TN", "telegram-agent-bridge"])
219
- return completed(cmd, returncode=1, stderr="ERROR: Access is denied.\r\n")
239
+ if cmd[:4] == ["schtasks", "/Query", "/TN", "telegram-agent-bridge"]:
240
+ return completed(cmd, returncode=1, stderr="ERROR: Access is denied.\r\n")
241
+ if cmd[0] == "powershell.exe" and "Get-CimInstance" in cmd[-1]:
242
+ return completed(cmd, returncode=1, stdout="missing")
243
+ self.fail(f"unexpected command: {cmd}")
220
244
 
221
245
  self.assertEqual(
222
246
  setup.service_status(
@@ -228,6 +252,26 @@ class SetupWizardTests(unittest.TestCase):
228
252
  "startup-installed",
229
253
  )
230
254
 
255
+ def running_process(cmd, check=False):
256
+ if cmd[:4] == ["schtasks", "/Query", "/TN", "telegram-agent-bridge"]:
257
+ return completed(cmd, returncode=1, stderr="ERROR: Access is denied.\r\n")
258
+ if cmd[0] == "powershell.exe" and "Get-CimInstance" in cmd[-1]:
259
+ return completed(cmd, returncode=0, stdout="running")
260
+ self.fail(f"unexpected command: {cmd}")
261
+
262
+ self.assertEqual(
263
+ setup.service_status(
264
+ os_name="Windows",
265
+ run=running_process,
266
+ task_name="telegram-agent-bridge",
267
+ windows_startup_dir=startup_dir,
268
+ ),
269
+ "running",
270
+ )
271
+
272
+ def test_windows_startup_installed_is_not_running(self):
273
+ self.assertFalse(setup.service_is_running("startup-installed"))
274
+
231
275
  def test_windows_service_status_parses_schtasks_query(self):
232
276
  def running(cmd, check=False):
233
277
  self.assertEqual(cmd[:4], ["schtasks", "/Query", "/TN", "tab-test-autostart"])
@@ -276,7 +320,7 @@ class SetupWizardTests(unittest.TestCase):
276
320
  self.assertIn("/ping", params["text"])
277
321
  self.assertIn("schtasks /Run /TN telegram-agent-bridge", params["text"])
278
322
 
279
- def test_setup_complete_message_treats_windows_startup_launcher_as_ready(self):
323
+ def test_setup_complete_message_treats_windows_startup_launcher_as_not_confirmed_running(self):
280
324
  api = FakeApi()
281
325
 
282
326
  self.assertTrue(
@@ -291,9 +335,9 @@ class SetupWizardTests(unittest.TestCase):
291
335
 
292
336
  _token, method, _timeout, params = api.calls[-1]
293
337
  self.assertEqual(method, "sendMessage")
294
- self.assertIn("Windows Startup autostart is installed", params["text"])
338
+ self.assertIn("Background service is not running (startup-installed)", params["text"])
295
339
  self.assertIn("/ping", params["text"])
296
- self.assertNotIn("Start it with", params["text"])
340
+ self.assertIn("Start it with", params["text"])
297
341
 
298
342
  def test_noninteractive_setup_writes_config_and_runner(self):
299
343
  with tempfile.TemporaryDirectory() as tmpdir: