remote-coder 0.5.0__tar.gz → 0.5.1__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 (156) hide show
  1. {remote_coder-0.5.0 → remote_coder-0.5.1}/PKG-INFO +1 -1
  2. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/__init__.py +1 -1
  3. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/ai/base.py +1 -0
  4. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/ai/claude.py +10 -3
  5. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/jobs/execution_pipeline.py +1 -0
  6. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/jobs/fix_pipeline.py +1 -0
  7. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/jobs/heartbeat.py +19 -10
  8. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/jobs/manager.py +32 -2
  9. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/main.py +25 -7
  10. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/monitoring/git.py +34 -16
  11. remote_coder-0.5.1/app/monitoring/memory.py +30 -0
  12. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/commands/base.py +33 -24
  13. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/commands/status.py +2 -2
  14. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/commands/system.py +15 -29
  15. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/formatting.py +34 -4
  16. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/i18n.py +59 -81
  17. remote_coder-0.5.1/app/telegram/lists.py +20 -0
  18. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/messages.py +24 -3
  19. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/notifier.py +65 -20
  20. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/notifier_protocol.py +2 -0
  21. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/plan_decisions_flow.py +1 -0
  22. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/webhook_registration.py +32 -0
  23. {remote_coder-0.5.0 → remote_coder-0.5.1}/pyproject.toml +1 -1
  24. {remote_coder-0.5.0 → remote_coder-0.5.1}/remote_coder.egg-info/PKG-INFO +1 -1
  25. {remote_coder-0.5.0 → remote_coder-0.5.1}/remote_coder.egg-info/SOURCES.txt +3 -0
  26. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_claude_runner.py +26 -0
  27. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_cli.py +1 -1
  28. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_commands.py +40 -16
  29. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_i18n.py +25 -0
  30. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_job_manager.py +109 -1
  31. remote_coder-0.5.1/tests/test_lists.py +26 -0
  32. remote_coder-0.5.1/tests/test_main_lifespan.py +67 -0
  33. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_monitoring.py +26 -0
  34. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_notifier.py +157 -0
  35. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_telegram_formatting.py +31 -1
  36. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_webhook.py +6 -9
  37. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_webhook_registration.py +35 -0
  38. remote_coder-0.5.0/app/monitoring/memory.py +0 -20
  39. {remote_coder-0.5.0 → remote_coder-0.5.1}/LICENSE +0 -0
  40. {remote_coder-0.5.0 → remote_coder-0.5.1}/README.md +0 -0
  41. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/admin/__init__.py +0 -0
  42. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/admin/advanced_settings.py +0 -0
  43. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/admin/database_browser.py +0 -0
  44. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/admin/router.py +0 -0
  45. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/admin/static/admin.css +0 -0
  46. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/admin/static/i18n.js +0 -0
  47. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/admin/static/icons/advanced.svg +0 -0
  48. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/admin/static/icons/database.svg +0 -0
  49. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/admin/static/icons/download.svg +0 -0
  50. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/admin/static/icons/home.svg +0 -0
  51. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/admin/static/icons/logs.svg +0 -0
  52. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/admin/static/icons/projects.svg +0 -0
  53. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/admin/static/summary.js +0 -0
  54. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/admin/templates/admin.html +0 -0
  55. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/admin/templates/advanced.html +0 -0
  56. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/admin/templates/database.html +0 -0
  57. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/admin/templates/logs.html +0 -0
  58. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/admin/templates/projects.html +0 -0
  59. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/ai/__init__.py +0 -0
  60. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/ai/codex.py +0 -0
  61. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/ai/factory.py +0 -0
  62. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/ai/gemini.py +0 -0
  63. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/ai/model_catalog.py +0 -0
  64. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/ai/usage.py +0 -0
  65. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/cli.py +0 -0
  66. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/config.py +0 -0
  67. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/diagnostics.py +0 -0
  68. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/git/__init__.py +0 -0
  69. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/git/ai_commit.py +0 -0
  70. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/git/branch_naming.py +0 -0
  71. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/git/commit_message.py +0 -0
  72. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/git/service.py +0 -0
  73. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/git/worktree_service.py +0 -0
  74. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/jobs/__init__.py +0 -0
  75. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/jobs/fix_support.py +0 -0
  76. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/jobs/plan_decisions.py +0 -0
  77. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/jobs/result_writer.py +0 -0
  78. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/jobs/schemas.py +0 -0
  79. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/jobs/store.py +0 -0
  80. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/jobs/worktree_planner.py +0 -0
  81. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/models.py +0 -0
  82. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/monitoring/__init__.py +0 -0
  83. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/monitoring/code.py +0 -0
  84. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/monitoring/events.py +0 -0
  85. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/monitoring/log_buffer.py +0 -0
  86. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/monitoring/model.py +0 -0
  87. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/projects/__init__.py +0 -0
  88. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/projects/registry.py +0 -0
  89. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/security/__init__.py +0 -0
  90. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/security/auth.py +0 -0
  91. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/system_startup.py +0 -0
  92. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/__init__.py +0 -0
  93. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/bot_instances.py +0 -0
  94. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/commands/__init__.py +0 -0
  95. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/commands/branch.py +0 -0
  96. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/commands/clear_stop.py +0 -0
  97. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/commands/fix.py +0 -0
  98. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/commands/model.py +0 -0
  99. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/commands/monitor.py +0 -0
  100. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/commands/registry.py +0 -0
  101. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/confirmations.py +0 -0
  102. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/conversation/__init__.py +0 -0
  103. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/conversation/collaborators.py +0 -0
  104. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/conversation/context.py +0 -0
  105. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/conversation/models.py +0 -0
  106. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/conversation/protocols.py +0 -0
  107. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/conversation/sqlite_rows.py +0 -0
  108. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/conversation/sqlite_store.py +0 -0
  109. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/conversation/store.py +0 -0
  110. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/handlers/__init__.py +0 -0
  111. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/handlers/callback_dispatcher.py +0 -0
  112. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/handlers/command_flow.py +0 -0
  113. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/handlers/fix_flow.py +0 -0
  114. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/handlers/job_submission.py +0 -0
  115. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/handlers/natural_flow.py +0 -0
  116. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/handlers/plan_flow.py +0 -0
  117. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/handlers/presenters.py +0 -0
  118. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/handlers/recent_updates.py +0 -0
  119. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/handlers/request.py +0 -0
  120. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/handlers/session_binding.py +0 -0
  121. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/handlers/update_handler.py +0 -0
  122. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/model_preferences.py +0 -0
  123. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/parser.py +0 -0
  124. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/telegram/webhook.py +0 -0
  125. {remote_coder-0.5.0 → remote_coder-0.5.1}/app/tunnel.py +0 -0
  126. {remote_coder-0.5.0 → remote_coder-0.5.1}/remote_coder.egg-info/dependency_links.txt +0 -0
  127. {remote_coder-0.5.0 → remote_coder-0.5.1}/remote_coder.egg-info/entry_points.txt +0 -0
  128. {remote_coder-0.5.0 → remote_coder-0.5.1}/remote_coder.egg-info/requires.txt +0 -0
  129. {remote_coder-0.5.0 → remote_coder-0.5.1}/remote_coder.egg-info/top_level.txt +0 -0
  130. {remote_coder-0.5.0 → remote_coder-0.5.1}/setup.cfg +0 -0
  131. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_admin_router.py +0 -0
  132. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_ai_base.py +0 -0
  133. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_ai_commit.py +0 -0
  134. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_ai_factory.py +0 -0
  135. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_auth.py +0 -0
  136. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_bot_instance_manager.py +0 -0
  137. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_branch_naming.py +0 -0
  138. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_codex_runner.py +0 -0
  139. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_command_parser.py +0 -0
  140. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_commit_message_formatter.py +0 -0
  141. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_config.py +0 -0
  142. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_conversation_store.py +0 -0
  143. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_database_browser.py +0 -0
  144. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_diagnostics.py +0 -0
  145. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_event_logger.py +0 -0
  146. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_gemini_runner.py +0 -0
  147. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_git_service.py +0 -0
  148. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_job_status.py +0 -0
  149. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_job_store.py +0 -0
  150. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_log_buffer.py +0 -0
  151. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_plan_decisions.py +0 -0
  152. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_project_registry.py +0 -0
  153. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_project_scoped_state.py +0 -0
  154. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_system_startup.py +0 -0
  155. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_tunnel.py +0 -0
  156. {remote_coder-0.5.0 → remote_coder-0.5.1}/tests/test_webhook_multibot.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remote-coder
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: Telegram-based remote AI coding automation server
5
5
  Author: Remote AI Coder contributors
6
6
  License: Apache License
@@ -1,3 +1,3 @@
1
1
  """Remote AI Coder application package."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.5.1"
@@ -80,6 +80,7 @@ class RunnerInput:
80
80
  mode: JobMode = JobMode.AGENT
81
81
  session_id: str | None = None
82
82
  resume_token: str | None = None
83
+ native_resume_cwd_stable: bool = True
83
84
 
84
85
 
85
86
  @dataclass
@@ -12,13 +12,20 @@ class ClaudeRunner(BaseCliRunner):
12
12
  self, runner_input: RunnerInput, before: dict[str, float]
13
13
  ) -> str | None:
14
14
  # Claude owns its session id deterministically (we pass --session-id/--resume).
15
- return runner_input.resume_token or runner_input.session_id
15
+ return self._effective_resume_token(runner_input) or runner_input.session_id
16
+
17
+ @staticmethod
18
+ def _effective_resume_token(runner_input: RunnerInput) -> str | None:
19
+ if not runner_input.native_resume_cwd_stable:
20
+ return None
21
+ return runner_input.resume_token
16
22
 
17
23
  def build_argv(self, runner_input: RunnerInput) -> list[str]:
18
24
  prompt = instruction_for_runner_mode(runner_input.instruction, runner_input.mode)
19
25
  argv = ["claude", "-p", prompt, "--dangerously-skip-permissions"]
20
- if runner_input.resume_token:
21
- argv.extend(["--resume", runner_input.resume_token])
26
+ resume_token = self._effective_resume_token(runner_input)
27
+ if resume_token:
28
+ argv.extend(["--resume", resume_token])
22
29
  elif runner_input.session_id:
23
30
  argv.extend(["--session-id", runner_input.session_id])
24
31
  if runner_input.model_id:
@@ -96,6 +96,7 @@ def run_job(manager, job_id: str) -> Job:
96
96
  mode=job.request.mode,
97
97
  session_id=job.request.session_id,
98
98
  resume_token=job.request.resume_session_token,
99
+ native_resume_cwd_stable=not created_worktree_for_job,
99
100
  )
100
101
  )
101
102
  finally:
@@ -92,6 +92,7 @@ def run_fix_job(manager, job_id: str) -> Job:
92
92
  mode=JobMode.AGENT,
93
93
  session_id=job.request.session_id,
94
94
  resume_token=job.request.resume_session_token,
95
+ native_resume_cwd_stable=not created_worktree_for_job,
95
96
  )
96
97
  )
97
98
  finally:
@@ -8,33 +8,42 @@ from app.jobs.schemas import Job
8
8
  from app.telegram.notifier import Notifier, build_job_accepted_message, build_job_heartbeat_message
9
9
 
10
10
 
11
+ class HeartbeatHandle:
12
+ def __init__(self, stop_event: threading.Event, thread: threading.Thread | None) -> None:
13
+ self._stop = stop_event
14
+ self._thread = thread
15
+
16
+ def set(self) -> None:
17
+ # Stop the heartbeat and wait for any in-flight edit so the caller's next
18
+ # edit (send_job_result) wins the race for the same message_id.
19
+ self._stop.set()
20
+ if self._thread is not None:
21
+ self._thread.join(timeout=15)
22
+
23
+
11
24
  def start_heartbeat(
12
25
  *,
13
26
  job: Job,
14
27
  notifier_resolver: Callable[[str], Notifier],
15
28
  interval_seconds: float,
16
- ) -> threading.Event:
29
+ ) -> HeartbeatHandle:
17
30
  # Periodically edit the "Job accepted" message so a long run shows live progress.
18
31
  # No-op when there is no message to edit (e.g. notifier returned no id).
19
32
  stop = threading.Event()
20
33
  if job.accepted_message_id is None:
21
- return stop
34
+ return HeartbeatHandle(stop, None)
22
35
  notifier = notifier_resolver(job.request.project)
23
36
  chat_id = job.request.chat_id
24
37
  message_id = job.accepted_message_id
25
38
  started = datetime.now(UTC)
26
- accepted_text, stop_buttons = build_job_accepted_message(job)
27
- edited = threading.Event()
39
+ _, stop_buttons = build_job_accepted_message(job)
28
40
 
29
41
  def _beat() -> None:
30
42
  while not stop.wait(interval_seconds):
31
43
  elapsed_minutes = int((datetime.now(UTC) - started).total_seconds() // 60)
32
44
  text = build_job_heartbeat_message(job, elapsed_minutes)
33
45
  notifier.edit_message(chat_id, message_id, text, stop_buttons)
34
- edited.set()
35
- if edited.is_set():
36
- # Restore the original accepted body without the now-useless Stop button.
37
- notifier.edit_message(chat_id, message_id, accepted_text, [])
38
46
 
39
- threading.Thread(target=_beat, daemon=True).start()
40
- return stop
47
+ thread = threading.Thread(target=_beat, daemon=True)
48
+ thread.start()
49
+ return HeartbeatHandle(stop, thread)
@@ -20,7 +20,7 @@ from app.jobs.fix_support import (
20
20
  list_fix_candidates,
21
21
  resolve_fix_target_job,
22
22
  )
23
- from app.jobs.heartbeat import start_heartbeat
23
+ from app.jobs.heartbeat import HeartbeatHandle, start_heartbeat
24
24
  from app.jobs.plan_decisions import PlanDecisionQuestion
25
25
  from app.jobs.result_writer import (
26
26
  make_output_summary,
@@ -37,6 +37,19 @@ from app.telegram.notifier import Notifier
37
37
 
38
38
  _joblog = EventLogger("app.jobs.lifecycle", "job.lifecycle")
39
39
 
40
+ # Telegram only allows reactions from a fixed allow-list of emoji, so map the job
41
+ # lifecycle onto values from https://core.telegram.org/bots/api#reactiontypeemoji.
42
+ _REACTION_QUEUED = "👀"
43
+ _REACTION_SUCCEEDED = "🎉"
44
+ _REACTION_FAILED = "💔"
45
+ _REACTION_CANCELLED = "🤝"
46
+
47
+ _TERMINAL_REACTION_BY_STATUS = {
48
+ "succeeded": _REACTION_SUCCEEDED,
49
+ "failed": _REACTION_FAILED,
50
+ "cancelled": _REACTION_CANCELLED,
51
+ }
52
+
40
53
 
41
54
  class JobManager:
42
55
  def __init__(
@@ -73,7 +86,7 @@ class JobManager:
73
86
  def _notifier_for(self, project: str) -> Notifier:
74
87
  return self._notifier_resolver(project)
75
88
 
76
- def _start_heartbeat(self, job: Job) -> threading.Event:
89
+ def _start_heartbeat(self, job: Job) -> HeartbeatHandle:
77
90
  return start_heartbeat(
78
91
  job=job,
79
92
  notifier_resolver=self._notifier_for,
@@ -104,6 +117,21 @@ class JobManager:
104
117
  self._notifier_for(job.request.project).send_job_result(job)
105
118
  )
106
119
  self._job_store.update(job)
120
+ self._react(job.request, _TERMINAL_REACTION_BY_STATUS.get(job.status.value))
121
+
122
+ def _react(self, request: JobRequest, emoji: str | None) -> None:
123
+ if request.message_id is None or emoji is None:
124
+ return
125
+ try:
126
+ self._notifier_for(request.project).set_reaction(
127
+ request.chat_id, request.message_id, emoji
128
+ )
129
+ except Exception: # pylint: disable=broad-except
130
+ _joblog.exception(
131
+ "set_reaction failed",
132
+ chat_id=request.chat_id,
133
+ project=request.project,
134
+ )
107
135
 
108
136
  def submit(self, request: JobRequest) -> Job:
109
137
  job = Job(id=request.job_id or self._make_job_id(), request=request)
@@ -120,6 +148,7 @@ class JobManager:
120
148
  if accepted_message_id is not None:
121
149
  job.accepted_message_id = accepted_message_id
122
150
  self._job_store.update(job)
151
+ self._react(request, _REACTION_QUEUED)
123
152
  return job
124
153
 
125
154
  def cancel(self, job_id: str) -> bool:
@@ -218,6 +247,7 @@ class JobManager:
218
247
  if accepted_message_id is not None:
219
248
  job.accepted_message_id = accepted_message_id
220
249
  self._job_store.update(job)
250
+ self._react(request, _REACTION_QUEUED)
221
251
  with self._project_lock(request.project):
222
252
  return self._run_fix(job.id)
223
253
 
@@ -1,7 +1,7 @@
1
1
  import asyncio
2
2
  import logging
3
3
  import time
4
- from contextlib import asynccontextmanager
4
+ from contextlib import asynccontextmanager, suppress
5
5
  from dataclasses import replace
6
6
 
7
7
  from fastapi import FastAPI, Request
@@ -137,9 +137,8 @@ webhook_registrar = (
137
137
  else None
138
138
  )
139
139
 
140
- @asynccontextmanager
141
- async def lifespan(_app: FastAPI):
142
- instances = bot_instance_manager.list_all()
140
+
141
+ def _run_startup_side_effects(instances: list[BotInstance], adv: AdvancedSettings) -> None:
143
142
  startup_chat_total = sum(len(inst.auth_service.allowed_chat_ids) for inst in instances)
144
143
  _systemlog.info(
145
144
  "lifespan startup notifying allowed chats count=%d projects=%d default_model=%s",
@@ -147,9 +146,7 @@ async def lifespan(_app: FastAPI):
147
146
  len(project_registry.list_projects()),
148
147
  ModelName.CLAUDE.value,
149
148
  )
150
- adv = advanced_settings_store.get()
151
- await asyncio.to_thread(
152
- run_startup_project_pulls,
149
+ run_startup_project_pulls(
153
150
  pull_projects_on_server_startup_enabled=adv.pull_projects_on_server_startup_enabled,
154
151
  project_registry=project_registry,
155
152
  git_service=git_service,
@@ -178,7 +175,28 @@ async def lifespan(_app: FastAPI):
178
175
  _systemlog.info("startup notification sent", chat_id=chat_id)
179
176
  except Exception:
180
177
  _systemlog.exception("startup notification failed", chat_id=chat_id)
178
+
179
+
180
+ async def _run_startup_side_effects_in_background(
181
+ instances: list[BotInstance], adv: AdvancedSettings
182
+ ) -> None:
183
+ try:
184
+ await asyncio.to_thread(_run_startup_side_effects, instances, adv)
185
+ except Exception:
186
+ _systemlog.exception("startup side effects failed")
187
+
188
+
189
+ @asynccontextmanager
190
+ async def lifespan(_app: FastAPI):
191
+ instances = bot_instance_manager.list_all()
192
+ startup_task = asyncio.create_task(
193
+ _run_startup_side_effects_in_background(instances, advanced_settings_store.get())
194
+ )
181
195
  yield
196
+ if not startup_task.done():
197
+ startup_task.cancel()
198
+ with suppress(asyncio.CancelledError):
199
+ await startup_task
182
200
  shutdown_instances = bot_instance_manager.list_all()
183
201
  shutdown_chat_total = sum(len(inst.auth_service.allowed_chat_ids) for inst in shutdown_instances)
184
202
  _systemlog.info("lifespan shutdown notifying allowed chats count=%d", shutdown_chat_total)
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
 
5
5
  from app.git.service import GitWorktreeService
6
+ from app.telegram.lists import render_labeled_list
6
7
 
7
8
 
8
9
  TELEGRAM_SAFE_LEN = 3800
@@ -24,16 +25,19 @@ def format_branch_monitor(
24
25
  except RuntimeError as exc:
25
26
  return f"/monitor branch failed: {exc}"
26
27
 
27
- header = (
28
- f"Branch monitor\n"
29
- f"Project: {project_name}\n"
30
- f"root: {root}\n"
31
- f"Remote: {remote}\n"
32
- f"Current checkout: {current}\n"
33
- f"Local branches: {local_n}\n"
34
- f"{remote} remote-tracking branches: {remote_n}\n\n"
28
+ summary_rows = [
29
+ ("Project", project_name),
30
+ ("root", str(root)),
31
+ ("Remote", remote),
32
+ ("Current checkout", current),
33
+ ("Local branches", str(local_n)),
34
+ ("Remote-tracking branches", str(remote_n)),
35
+ ]
36
+ header = "Branch monitor\n" + render_labeled_list(summary_rows) + "\n\n"
37
+ body = (
38
+ f"Local branches\n{_render_text_list(local_block)}\n\n"
39
+ f"Remote branches\n{_render_text_list(remote_block)}"
35
40
  )
36
- body = f"[Local]\n{local_block}\n\n[{remote} remote]\n{remote_block}"
37
41
  text = header + body
38
42
  if len(text) > max_len:
39
43
  text = text[:max_len].rstrip() + "\n\n...(truncated for message length)"
@@ -87,17 +91,31 @@ def format_worktree_monitor(
87
91
  if len(entries) > max_detail:
88
92
  extra = f"\n...({len(entries) - max_detail} more omitted)"
89
93
 
94
+ summary_rows = [
95
+ ("Project", project_name),
96
+ ("root", str(root)),
97
+ ("Managed base directory", str(managed_base)),
98
+ ("Total worktrees", str(len(entries))),
99
+ ("Detached worktrees", str(detached_n)),
100
+ ("Managed candidates", str(managed_n)),
101
+ ]
90
102
  lines = [
91
103
  "Worktree monitor",
92
- f"Project: {project_name}",
93
- f"root: {root}",
94
- f"Managed base directory (worktree_base): {managed_base}",
95
- f"Total worktrees: {len(entries)}",
96
- f"Detached worktrees: {detached_n}",
97
- f"Managed candidates (remote-*, base, _rebase_ops): {managed_n}",
104
+ render_labeled_list(summary_rows),
98
105
  "",
99
- "[Entries]",
106
+ "Worktree entries",
100
107
  *detail_lines,
101
108
  extra,
102
109
  ]
103
110
  return "\n".join(lines).strip()
111
+
112
+
113
+ def _render_text_list(text: str) -> str:
114
+ items: list[str] = []
115
+ for raw in text.splitlines():
116
+ item = raw.strip()
117
+ if item.startswith("* "):
118
+ item = item[2:]
119
+ if item:
120
+ items.append(f"- {item}")
121
+ return "\n".join(items)
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from app.telegram.conversation import ConversationDbChatStats
4
+ from app.telegram.lists import render_labeled_list
5
+
6
+
7
+ def format_memory_monitor(stats: ConversationDbChatStats, project: str, chat_id: int) -> str:
8
+ size_kb = stats.db_size_bytes / 1024.0 if stats.db_size_bytes else 0.0
9
+ summary_rows: list[tuple[str, str]] = [
10
+ ("Project", project),
11
+ ("chat_id", str(chat_id)),
12
+ ("DB path", str(stats.db_path)),
13
+ ("DB exists", "yes" if stats.db_exists else "no"),
14
+ ("DB size", f"{size_kb:.2f} KiB ({stats.db_size_bytes} bytes)"),
15
+ ("Rows for this chat", str(stats.total_rows)),
16
+ ("Sessions", str(stats.session_count)),
17
+ ]
18
+ if stats.rows_by_role:
19
+ role_rows = [(role, str(count)) for role, count in sorted(stats.rows_by_role.items())]
20
+ else:
21
+ role_rows = [("(none)", "0")]
22
+ return "\n".join(
23
+ [
24
+ "Memory (SQLite)",
25
+ render_labeled_list(summary_rows),
26
+ "",
27
+ "Rows by role",
28
+ render_labeled_list(role_rows),
29
+ ]
30
+ )
@@ -12,6 +12,7 @@ from app.projects.registry import ProjectRegistry
12
12
  from app.telegram.confirmations import InMemoryConfirmationStore, PendingConfirmation
13
13
  from app.telegram.conversation import SQLiteConversationStore
14
14
  from app.telegram.model_preferences import InMemoryModelPreferenceStore, ModelPreference
15
+ from app.telegram.lists import render_command_list
15
16
 
16
17
  if TYPE_CHECKING:
17
18
  from app.admin.advanced_settings import FileAdvancedSettingsStore
@@ -54,6 +55,7 @@ MODEL_USAGE = "<claude|codex|gemini>"
54
55
  class InlineButton:
55
56
  label: str
56
57
  callback_data: str
58
+ style: str | None = None
57
59
 
58
60
 
59
61
  @dataclass
@@ -69,13 +71,30 @@ def _help_response_skips_notifier_body_i18n(message_text: str) -> bool:
69
71
  return False
70
72
 
71
73
 
74
+ _HELP_COMMAND_ROWS: tuple[tuple[str, str, str], ...] = (
75
+ ("/model", "<claude|codex|gemini>", "Change the default model"),
76
+ ("/status", "<job_id>", "Check job status"),
77
+ ("/branch", "[name]", "Show or switch branches"),
78
+ ("/pull", "", "Pull remote branch updates"),
79
+ ("/rebase", "[branch]", "Rebase a branch"),
80
+ ("/pr", "[branch]", "Open a GitHub PR"),
81
+ ("/monitor", "<scope>", "Monitoring"),
82
+ ("/clear", "<branch|worktrees|memory>", "Cleanup (confirmation)"),
83
+ ("/reports", "[count]", "Conversation memory report"),
84
+ ("/init", "", "Reset this chat's settings"),
85
+ ("/stop", "<job_id>", "Stop a running job"),
86
+ ("/fix", "", "Fix linked job commit"),
87
+ ("/start", "", "Inline menu"),
88
+ )
89
+
90
+
72
91
  HELP_TEXT = "\n".join(
73
92
  [
74
- "Help",
93
+ "🧭 Help",
75
94
  "",
76
95
  "Send work requests as regular messages.",
77
96
  "",
78
- "Options",
97
+ "⚙️ Options",
79
98
  "- model:",
80
99
  "- branch:",
81
100
  "- no commit",
@@ -84,31 +103,21 @@ HELP_TEXT = "\n".join(
84
103
  "- fix: <natural language> or /fix - fix mode (reply to a job result; amends that commit)",
85
104
  "- Korean aliases 계획:, 질문:, and 수정: instead of plan:/ask:/fix: (colons `:` or full-width `:` allowed)",
86
105
  "",
87
- "Commands:",
88
- "- /model <claude|codex|gemini>: Change the default model",
89
- "- /status <job_id>: Check job status",
90
- "- /branch [name]: Show or switch branches",
91
- "- /pull: Pull all remote branch updates",
92
- "- /rebase [branch]: Rebase a branch",
93
- "- /pr [branch]: Open a GitHub PR for a branch",
94
- "- /monitor <model|memory|branch|worktrees|code|project>: Monitoring",
95
- "- /clear <branch|worktrees|memory>: Cleanup (confirmation required)",
96
- "- /reports [count]: Conversation memory report",
97
- "- /init: Reset this chat's settings",
98
- "- /stop <job_id>: Stop a running job",
99
- "- /fix: Fix the linked job commit (reply to a job result first)",
100
- "- /start: Inline menu",
106
+ "📋 Commands",
107
+ render_command_list(_HELP_COMMAND_ROWS),
108
+ "",
109
+ "💡 Tip: Reply to a job result and send `fix: ...` to amend that commit.",
101
110
  ]
102
111
  )
103
112
 
104
113
  HELP_AGENT_TOPIC = "\n".join(
105
114
  [
106
- "AGENTS mode (agent)",
115
+ "🤖 AGENTS mode (agent)",
107
116
  "",
108
117
  "Natural-language coding tasks. The agent can modify code in the current project; when there are "
109
118
  "changes it can create or update a branch, commit, and push.",
110
119
  "",
111
- "Examples",
120
+ "💡 Examples",
112
121
  "- fix the login validation bug",
113
122
  "- model: codex branch: remote-auth strengthen tests",
114
123
  "- no commit just verify the doc wording",
@@ -119,12 +128,12 @@ HELP_AGENT_TOPIC = "\n".join(
119
128
 
120
129
  HELP_PLAN_TOPIC = "\n".join(
121
130
  [
122
- "Plan mode (plan)",
131
+ "📐 Plan mode (plan)",
123
132
  "",
124
133
  "Receive change plans only; no code edits. Like agent mode, a job is accepted after confirmation "
125
134
  "(`y`/`Y` or inline buttons).",
126
135
  "",
127
- "Examples",
136
+ "💡 Examples",
128
137
  "- plan: summarize the login validation flow",
129
138
  "- /plan model: codex list only API boundary risks",
130
139
  "- 계획:refactor steps (full-width colon)",
@@ -135,12 +144,12 @@ HELP_PLAN_TOPIC = "\n".join(
135
144
 
136
145
  HELP_ASK_TOPIC = "\n".join(
137
146
  [
138
- "Ask mode (ask)",
147
+ "Ask mode (ask)",
139
148
  "",
140
149
  "Answer questions using the repository; no code edits, commits, or pushes. Jobs are accepted like "
141
150
  "agent mode after confirmation (`y`/`Y` or inline buttons).",
142
151
  "",
143
- "Examples",
152
+ "💡 Examples",
144
153
  "- ask: how do I run pytest in this project?",
145
154
  "- /ask explain JobManager.run stages",
146
155
  "- 질문:what this error line means",
@@ -151,12 +160,12 @@ HELP_ASK_TOPIC = "\n".join(
151
160
 
152
161
  HELP_FIX_TOPIC = "\n".join(
153
162
  [
154
- "Fix mode (fix)",
163
+ "🔧 Fix mode (fix)",
155
164
  "",
156
165
  "Apply follow-up fixes on top of a previous succeeded job. Reply to a job result, then use "
157
166
  "fix: or /fix. The agent amends the existing commit and pushes with --force-with-lease.",
158
167
  "",
159
- "Examples",
168
+ "💡 Examples",
160
169
  "- (reply to job result) fix: add missing tests",
161
170
  "- (reply to job result) /fix then send the fix instruction",
162
171
  "- (reply to job result) 수정:로그인 검증 버그도 고쳐줘",
@@ -188,11 +188,11 @@ class StatusCommand(TelegramCommand):
188
188
  rows: list[list[InlineButton]] = []
189
189
  status = job.status.value
190
190
  if status in ("running", "queued"):
191
- rows.append([InlineButton("Stop", f"/stop {job.id}")])
191
+ rows.append([InlineButton("Stop", f"/stop {job.id}", style="danger")])
192
192
  elif status == "succeeded" and job.branch:
193
193
  actions: list[InlineButton] = []
194
194
  if job.commit_hash:
195
- actions.append(InlineButton("Open PR", f"/pr {job.branch}"))
195
+ actions.append(InlineButton("Open PR", f"/pr {job.branch}", style="primary"))
196
196
  actions.append(InlineButton("Rebase", f"/rebase {job.branch}"))
197
197
  rows.append(actions)
198
198
  return with_nav_row(rows, back_to="/status")
@@ -11,7 +11,6 @@ from app.telegram.commands.base import (
11
11
  InlineButton,
12
12
  TelegramCommand,
13
13
  TelegramMessage,
14
- _button_rows,
15
14
  _cmd_evt,
16
15
  effective_model_for_chat,
17
16
  effective_project_name_for_chat,
@@ -42,30 +41,35 @@ class StartCommand(TelegramCommand):
42
41
  return topic_text
43
42
  project_name = effective_project_name_for_chat(ctx, message.chat_id)
44
43
  if not project_name:
45
- return f"{self._ready_line()}\n\nWelcome to Remote AI Coder."
44
+ return (
45
+ f"{self._ready_line()}\n\n"
46
+ "Welcome to Remote AI Coder.\n"
47
+ "Send a coding request or tap Help to start."
48
+ )
46
49
  entry = ctx.project_registry.get(project_name)
47
50
  if not entry:
48
51
  return (
49
52
  f"{self._ready_line()}\n\n"
50
- "Welcome to Remote AI Coder.\n"
51
53
  f"- Project: {project_name} (not registered)"
52
54
  )
55
+ if not entry.enabled:
56
+ return (
57
+ f"{self._ready_line()}\n\n"
58
+ f"- Project: {entry.name} (disabled)"
59
+ )
53
60
  try:
54
61
  current_branch = ctx.git_service.get_current_branch(entry.root_path)
55
62
  except RuntimeError:
56
63
  current_branch = "(check failed)"
57
- state = "enabled" if entry.enabled else "disabled"
58
64
  return "\n".join(
59
65
  [
60
66
  self._ready_line(),
61
67
  "",
62
- "Welcome to Remote AI Coder.",
63
68
  f"- Project: {entry.name}",
64
- f"- root_path: {entry.root_path}",
65
- f"- default_model: {entry.default_model.value}",
66
- f"- current_branch: {current_branch}",
67
- f"- worktree_base_dir: {entry.worktree_base_dir}",
68
- f"- enabled: {state}",
69
+ f"- Model: {entry.default_model.value}",
70
+ f"- Branch: {current_branch}",
71
+ "",
72
+ "Send a coding request or tap Help to start.",
69
73
  ]
70
74
  )
71
75
 
@@ -138,25 +142,7 @@ class HelpCommand(TelegramCommand):
138
142
  message: TelegramMessage | None = None,
139
143
  ctx: CommandContext | None = None,
140
144
  ) -> list[list[InlineButton]] | None:
141
- if self._registry is None:
142
- return None
143
- tokens = message.text.strip().split() if message else []
144
- if len(tokens) >= 2:
145
- topic = tokens[1].lower()
146
- if topic in ("agent", "agents", "plan", "ask", "fix"):
147
- return with_nav_row(None, back_to="/help")
148
- subcmd = self._registry.get("/" + tokens[1])
149
- if subcmd is not None:
150
- sub_buttons = subcmd.get_inline_buttons(None, ctx) or []
151
- return with_nav_row(sub_buttons, back_to="/help")
152
- menu_cmds = [
153
- cmd for name, cmd in self._registry.items()
154
- if name not in ("/help", "/start") and cmd.menu_text
155
- ]
156
- if not menu_cmds:
157
- return None
158
- buttons = [InlineButton(cmd.name[1:], f"/help {cmd.name[1:]}") for cmd in menu_cmds]
159
- return with_nav_row(_button_rows(buttons, per_row=2))
145
+ return None
160
146
 
161
147
 
162
148
  class InitCommand(TelegramCommand):
@@ -13,12 +13,26 @@ _SECTION_HEADINGS = frozenset(
13
13
  {
14
14
  "Options",
15
15
  "옵션",
16
+ "⚙️ Options",
17
+ "⚙️ 옵션",
16
18
  "Commands:",
17
19
  "명령어 목록:",
20
+ "📋 Commands",
21
+ "📋 명령어 목록",
22
+ "Rows by role",
23
+ "역할별 행 수",
24
+ "Local branches",
25
+ "로컬 브랜치",
26
+ "Remote branches",
27
+ "원격 브랜치",
28
+ "Worktree entries",
29
+ "Worktree 항목",
18
30
  "Usage",
19
31
  "사용법",
20
32
  "Examples",
21
33
  "예시",
34
+ "💡 Examples",
35
+ "💡 예시",
22
36
  "Prerequisites",
23
37
  "전제조건",
24
38
  "AI response:",
@@ -46,12 +60,17 @@ _BODY_MARKERS = frozenset(
46
60
  _CODE_VALUE_LINE = re.compile(
47
61
  r"^(?P<prefix>\s*-\s*(?:Job ID|Session ID|Branch|Commit|Log path|브랜치|커밋|로그 경로)\s*:\s*)(?P<value>\S.*?)\s*$"
48
62
  )
63
+ _COMMAND_LIST_LINE = re.compile(r"^\s*-\s+(?P<command>/\S+(?:\s+\S.*)?)\s*$")
49
64
 
50
65
 
51
66
  def _utf16_units(text: str) -> int:
52
67
  return len(text.encode("utf-16-le")) // 2
53
68
 
54
69
 
70
+ def prepare_outgoing(text: str) -> tuple[str, list[dict[str, int | str]]]:
71
+ return text, build_message_entities(text)
72
+
73
+
55
74
  def build_message_entities(text: str) -> list[dict[str, int | str]]:
56
75
  """Compute BotFather-like entities (bold title/headings, code values)."""
57
76
  lines = text.split("\n")
@@ -77,14 +96,25 @@ def build_message_entities(text: str) -> list[dict[str, int | str]]:
77
96
  if stripped in _BODY_MARKERS:
78
97
  body_started = True
79
98
  else:
80
- match = _CODE_VALUE_LINE.match(line)
81
- if match is not None and not match.group("value").startswith("("):
99
+ command_match = _COMMAND_LIST_LINE.match(line)
100
+ if command_match is not None:
101
+ command = command_match.group("command")
82
102
  entities.append(
83
103
  {
84
104
  "type": "code",
85
- "offset": offset + _utf16_units(match.group("prefix")),
86
- "length": _utf16_units(match.group("value")),
105
+ "offset": offset + _utf16_units(line[: command_match.start("command")]),
106
+ "length": _utf16_units(command),
87
107
  }
88
108
  )
109
+ else:
110
+ value_match = _CODE_VALUE_LINE.match(line)
111
+ if value_match is not None and not value_match.group("value").startswith("("):
112
+ entities.append(
113
+ {
114
+ "type": "code",
115
+ "offset": offset + _utf16_units(value_match.group("prefix")),
116
+ "length": _utf16_units(value_match.group("value")),
117
+ }
118
+ )
89
119
  offset += line_units + 1
90
120
  return entities