remote-coder 0.5.0__tar.gz → 0.5.2__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.2}/PKG-INFO +3 -2
  2. {remote_coder-0.5.0 → remote_coder-0.5.2}/README.md +2 -1
  3. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/__init__.py +1 -1
  4. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/admin/router.py +2 -1
  5. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/admin/static/i18n.js +5 -0
  6. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/admin/templates/admin.html +4 -1
  7. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/ai/base.py +15 -0
  8. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/ai/claude.py +10 -3
  9. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/ai/codex.py +3 -3
  10. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/ai/gemini.py +2 -2
  11. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/cli.py +5 -1
  12. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/diagnostics.py +12 -1
  13. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/jobs/execution_pipeline.py +4 -5
  14. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/jobs/fix_pipeline.py +1 -0
  15. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/jobs/heartbeat.py +19 -10
  16. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/jobs/manager.py +32 -2
  17. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/jobs/schemas.py +8 -0
  18. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/jobs/worktree_planner.py +2 -2
  19. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/main.py +25 -7
  20. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/monitoring/git.py +34 -16
  21. remote_coder-0.5.2/app/monitoring/memory.py +30 -0
  22. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/commands/base.py +54 -28
  23. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/commands/branch.py +24 -4
  24. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/commands/registry.py +8 -1
  25. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/commands/status.py +2 -2
  26. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/commands/system.py +28 -30
  27. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/formatting.py +34 -4
  28. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/handlers/job_submission.py +2 -2
  29. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/handlers/natural_flow.py +6 -1
  30. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/handlers/presenters.py +9 -6
  31. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/handlers/update_handler.py +7 -2
  32. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/i18n.py +80 -91
  33. remote_coder-0.5.2/app/telegram/lists.py +20 -0
  34. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/messages.py +31 -7
  35. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/notifier.py +65 -20
  36. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/notifier_protocol.py +2 -0
  37. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/parser.py +14 -12
  38. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/plan_decisions_flow.py +1 -0
  39. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/webhook_registration.py +32 -0
  40. {remote_coder-0.5.0 → remote_coder-0.5.2}/pyproject.toml +1 -1
  41. {remote_coder-0.5.0 → remote_coder-0.5.2}/remote_coder.egg-info/PKG-INFO +3 -2
  42. {remote_coder-0.5.0 → remote_coder-0.5.2}/remote_coder.egg-info/SOURCES.txt +3 -0
  43. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_admin_router.py +2 -0
  44. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_ai_base.py +8 -0
  45. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_claude_runner.py +50 -0
  46. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_cli.py +2 -1
  47. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_codex_runner.py +23 -0
  48. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_command_parser.py +25 -0
  49. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_commands.py +99 -20
  50. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_diagnostics.py +14 -0
  51. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_gemini_runner.py +26 -0
  52. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_i18n.py +25 -0
  53. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_job_manager.py +156 -1
  54. remote_coder-0.5.2/tests/test_lists.py +26 -0
  55. remote_coder-0.5.2/tests/test_main_lifespan.py +67 -0
  56. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_monitoring.py +26 -0
  57. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_notifier.py +193 -5
  58. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_telegram_formatting.py +31 -1
  59. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_webhook.py +114 -10
  60. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_webhook_registration.py +35 -0
  61. remote_coder-0.5.0/app/monitoring/memory.py +0 -20
  62. {remote_coder-0.5.0 → remote_coder-0.5.2}/LICENSE +0 -0
  63. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/admin/__init__.py +0 -0
  64. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/admin/advanced_settings.py +0 -0
  65. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/admin/database_browser.py +0 -0
  66. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/admin/static/admin.css +0 -0
  67. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/admin/static/icons/advanced.svg +0 -0
  68. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/admin/static/icons/database.svg +0 -0
  69. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/admin/static/icons/download.svg +0 -0
  70. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/admin/static/icons/home.svg +0 -0
  71. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/admin/static/icons/logs.svg +0 -0
  72. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/admin/static/icons/projects.svg +0 -0
  73. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/admin/static/summary.js +0 -0
  74. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/admin/templates/advanced.html +0 -0
  75. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/admin/templates/database.html +0 -0
  76. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/admin/templates/logs.html +0 -0
  77. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/admin/templates/projects.html +0 -0
  78. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/ai/__init__.py +0 -0
  79. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/ai/factory.py +0 -0
  80. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/ai/model_catalog.py +0 -0
  81. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/ai/usage.py +0 -0
  82. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/config.py +0 -0
  83. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/git/__init__.py +0 -0
  84. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/git/ai_commit.py +0 -0
  85. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/git/branch_naming.py +0 -0
  86. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/git/commit_message.py +0 -0
  87. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/git/service.py +0 -0
  88. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/git/worktree_service.py +0 -0
  89. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/jobs/__init__.py +0 -0
  90. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/jobs/fix_support.py +0 -0
  91. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/jobs/plan_decisions.py +0 -0
  92. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/jobs/result_writer.py +0 -0
  93. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/jobs/store.py +0 -0
  94. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/models.py +0 -0
  95. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/monitoring/__init__.py +0 -0
  96. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/monitoring/code.py +0 -0
  97. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/monitoring/events.py +0 -0
  98. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/monitoring/log_buffer.py +0 -0
  99. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/monitoring/model.py +0 -0
  100. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/projects/__init__.py +0 -0
  101. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/projects/registry.py +0 -0
  102. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/security/__init__.py +0 -0
  103. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/security/auth.py +0 -0
  104. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/system_startup.py +0 -0
  105. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/__init__.py +0 -0
  106. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/bot_instances.py +0 -0
  107. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/commands/__init__.py +0 -0
  108. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/commands/clear_stop.py +0 -0
  109. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/commands/fix.py +0 -0
  110. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/commands/model.py +0 -0
  111. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/commands/monitor.py +0 -0
  112. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/confirmations.py +0 -0
  113. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/conversation/__init__.py +0 -0
  114. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/conversation/collaborators.py +0 -0
  115. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/conversation/context.py +0 -0
  116. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/conversation/models.py +0 -0
  117. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/conversation/protocols.py +0 -0
  118. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/conversation/sqlite_rows.py +0 -0
  119. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/conversation/sqlite_store.py +0 -0
  120. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/conversation/store.py +0 -0
  121. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/handlers/__init__.py +0 -0
  122. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/handlers/callback_dispatcher.py +0 -0
  123. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/handlers/command_flow.py +0 -0
  124. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/handlers/fix_flow.py +0 -0
  125. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/handlers/plan_flow.py +0 -0
  126. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/handlers/recent_updates.py +0 -0
  127. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/handlers/request.py +0 -0
  128. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/handlers/session_binding.py +0 -0
  129. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/model_preferences.py +0 -0
  130. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/telegram/webhook.py +0 -0
  131. {remote_coder-0.5.0 → remote_coder-0.5.2}/app/tunnel.py +0 -0
  132. {remote_coder-0.5.0 → remote_coder-0.5.2}/remote_coder.egg-info/dependency_links.txt +0 -0
  133. {remote_coder-0.5.0 → remote_coder-0.5.2}/remote_coder.egg-info/entry_points.txt +0 -0
  134. {remote_coder-0.5.0 → remote_coder-0.5.2}/remote_coder.egg-info/requires.txt +0 -0
  135. {remote_coder-0.5.0 → remote_coder-0.5.2}/remote_coder.egg-info/top_level.txt +0 -0
  136. {remote_coder-0.5.0 → remote_coder-0.5.2}/setup.cfg +0 -0
  137. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_ai_commit.py +0 -0
  138. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_ai_factory.py +0 -0
  139. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_auth.py +0 -0
  140. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_bot_instance_manager.py +0 -0
  141. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_branch_naming.py +0 -0
  142. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_commit_message_formatter.py +0 -0
  143. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_config.py +0 -0
  144. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_conversation_store.py +0 -0
  145. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_database_browser.py +0 -0
  146. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_event_logger.py +0 -0
  147. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_git_service.py +0 -0
  148. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_job_status.py +0 -0
  149. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_job_store.py +0 -0
  150. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_log_buffer.py +0 -0
  151. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_plan_decisions.py +0 -0
  152. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_project_registry.py +0 -0
  153. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_project_scoped_state.py +0 -0
  154. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_system_startup.py +0 -0
  155. {remote_coder-0.5.0 → remote_coder-0.5.2}/tests/test_tunnel.py +0 -0
  156. {remote_coder-0.5.0 → remote_coder-0.5.2}/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.2
4
4
  Summary: Telegram-based remote AI coding automation server
5
5
  Author: Remote AI Coder contributors
6
6
  License: Apache License
@@ -236,7 +236,7 @@ Run Claude Code, Codex, or Gemini on your local development machine by sending a
236
236
  - Claude, Codex, and Gemini runners behind the same job flow.
237
237
  - Reply-linked jobs continue the same AI CLI session, so follow-ups build on prior context.
238
238
  - Local admin UI for project setup, advanced settings, logs, and conversation memory.
239
- - Read-only `plan:` and `ask:` modes when you want analysis without commits. PLAN mode asks open decisions through inline buttons first, then finalizes the plan from your answers.
239
+ - Read-only `plan:`, `ask:`, and `research:` modes when you want analysis without commits. PLAN mode asks open decisions through inline buttons first, then finalizes the plan from your answers; RESEARCH mode asks the selected AI CLI to use internet search when useful.
240
240
 
241
241
  ## Quick Start
242
242
 
@@ -311,6 +311,7 @@ Natural-language examples:
311
311
  Fix the login validation bug with model: codex
312
312
  plan: outline the migration before changing code
313
313
  /ask what test command does this repo use?
314
+ /research compare current Telegram webhook security guidance
314
315
  수정: 방금 작업에서 README 문구만 더 간결하게 바꿔줘
315
316
  ```
316
317
 
@@ -15,7 +15,7 @@ Run Claude Code, Codex, or Gemini on your local development machine by sending a
15
15
  - Claude, Codex, and Gemini runners behind the same job flow.
16
16
  - Reply-linked jobs continue the same AI CLI session, so follow-ups build on prior context.
17
17
  - Local admin UI for project setup, advanced settings, logs, and conversation memory.
18
- - Read-only `plan:` and `ask:` modes when you want analysis without commits. PLAN mode asks open decisions through inline buttons first, then finalizes the plan from your answers.
18
+ - Read-only `plan:`, `ask:`, and `research:` modes when you want analysis without commits. PLAN mode asks open decisions through inline buttons first, then finalizes the plan from your answers; RESEARCH mode asks the selected AI CLI to use internet search when useful.
19
19
 
20
20
  ## Quick Start
21
21
 
@@ -90,6 +90,7 @@ Natural-language examples:
90
90
  Fix the login validation bug with model: codex
91
91
  plan: outline the migration before changing code
92
92
  /ask what test command does this repo use?
93
+ /research compare current Telegram webhook security guidance
93
94
  수정: 방금 작업에서 README 문구만 더 간결하게 바꿔줘
94
95
  ```
95
96
 
@@ -1,3 +1,3 @@
1
1
  """Remote AI Coder application package."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.5.2"
@@ -378,9 +378,10 @@ def create_admin_router(
378
378
  report = check_prerequisites()
379
379
  installed = [cli.name for cli in report.ai_clis if cli.installed]
380
380
  _adminlog.info(
381
- "prerequisites queried ngrok_ok=%s ai_clis=%s",
381
+ "prerequisites queried ngrok_ok=%s ai_clis=%s github_cli=%s",
382
382
  report.ngrok_ok,
383
383
  ",".join(installed) or "-",
384
+ report.github_cli.installed,
384
385
  )
385
386
  return report.model_dump()
386
387
 
@@ -146,6 +146,11 @@
146
146
  en: "None found (install at least one: claude / codex / gemini)",
147
147
  ko: "설치된 것 없음 (claude / codex / gemini 중 최소 1개 설치)",
148
148
  },
149
+ "setup.githubCli": { en: "GitHub CLI", ko: "GitHub CLI" },
150
+ "setup.githubCliNone": {
151
+ en: "Not found (install gh before using /pr)",
152
+ ko: "설치되지 않음 (/pr 사용 전 gh 설치 필요)",
153
+ },
149
154
  "setup.checking": { en: "Checking…", ko: "확인 중…" },
150
155
  "setup.recheck": { en: "Re-check", ko: "다시 확인" },
151
156
  "setup.cta": { en: "Add your first project", ko: "첫 프로젝트 추가" },
@@ -248,9 +248,12 @@
248
248
  const installed = (report.ai_clis || []).filter((c) => c.installed).map((c) => c.name);
249
249
  const aiOk = installed.length > 0;
250
250
  const aiDetail = aiOk ? installed.join(", ") : i18n.t("setup.aiCliNone");
251
+ const ghOk = !!(report.github_cli && report.github_cli.installed);
252
+ const ghDetail = ghOk ? "gh" : i18n.t("setup.githubCliNone");
251
253
  $("#setup-prereq").innerHTML =
252
254
  renderPrereqRow(!!report.ngrok_ok, i18n.t("setup.ngrok"), ngrokDetail) +
253
- renderPrereqRow(aiOk, i18n.t("setup.aiCli"), aiDetail);
255
+ renderPrereqRow(aiOk, i18n.t("setup.aiCli"), aiDetail) +
256
+ renderPrereqRow(ghOk, i18n.t("setup.githubCli"), ghDetail);
254
257
  }
255
258
 
256
259
  async function loadPrerequisites() {
@@ -57,6 +57,18 @@ _PLAN_DECISIONS_INSTRUCTION = (
57
57
  )
58
58
 
59
59
 
60
+ _RESEARCH_INSTRUCTION = (
61
+ "You are in RESEARCH mode. Read the repository context and answer the user's research "
62
+ "question. Do not modify files.\n\n"
63
+ "Use internet search when it is useful or necessary for the question, similar to a deep "
64
+ "research workflow. Compare multiple perspectives or sources when possible, and clearly "
65
+ "separate repository-derived facts from external findings. Include citations or source "
66
+ "links for external claims, call out uncertainty or limitations, and finish with a direct "
67
+ "answer to the user's problem.\n\n"
68
+ "User research request:\n"
69
+ )
70
+
71
+
60
72
  def instruction_for_runner_mode(instruction: str, mode: JobMode) -> str:
61
73
  if mode == JobMode.PLAN:
62
74
  return f"{_PLAN_DECISIONS_INSTRUCTION}{instruction}"
@@ -66,6 +78,8 @@ def instruction_for_runner_mode(instruction: str, mode: JobMode) -> str:
66
78
  "Do not modify files.\n\n"
67
79
  f"User question:\n{instruction}"
68
80
  )
81
+ if mode == JobMode.RESEARCH:
82
+ return f"{_RESEARCH_INSTRUCTION}{instruction}"
69
83
  return instruction
70
84
 
71
85
 
@@ -80,6 +94,7 @@ class RunnerInput:
80
94
  mode: JobMode = JobMode.AGENT
81
95
  session_id: str | None = None
82
96
  resume_token: str | None = None
97
+ native_resume_cwd_stable: bool = True
83
98
 
84
99
 
85
100
  @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:
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
 
5
5
  from app.ai.base import BaseCliRunner, RunnerInput, instruction_for_runner_mode
6
- from app.jobs.schemas import JobMode
6
+ from app.jobs.schemas import is_read_only_job_mode
7
7
  from app.models import CodexSandboxMode
8
8
  from app.monitoring.events import EventLogger
9
9
 
@@ -16,7 +16,7 @@ class CodexRunner(BaseCliRunner):
16
16
  self._sandbox = sandbox
17
17
 
18
18
  def _resolve_sandbox(self, runner_input: RunnerInput) -> CodexSandboxMode:
19
- if runner_input.mode in (JobMode.PLAN, JobMode.ASK):
19
+ if is_read_only_job_mode(runner_input.mode):
20
20
  return CodexSandboxMode.READ_ONLY
21
21
  return self._sandbox
22
22
 
@@ -29,7 +29,7 @@ class CodexRunner(BaseCliRunner):
29
29
 
30
30
  def build_argv(self, runner_input: RunnerInput) -> list[str]:
31
31
  sandbox = self._resolve_sandbox(runner_input)
32
- if runner_input.mode in (JobMode.PLAN, JobMode.ASK):
32
+ if is_read_only_job_mode(runner_input.mode):
33
33
  instruction = instruction_for_runner_mode(runner_input.instruction, runner_input.mode)
34
34
  else:
35
35
  instruction = runner_input.instruction
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
 
5
5
  from app.ai.base import BaseCliRunner, RunnerInput, instruction_for_runner_mode
6
- from app.jobs.schemas import JobMode
6
+ from app.jobs.schemas import is_read_only_job_mode
7
7
  from app.monitoring.events import EventLogger
8
8
 
9
9
 
@@ -17,7 +17,7 @@ class GeminiRunner(BaseCliRunner):
17
17
  return Path.home() / ".gemini" / "sessions"
18
18
 
19
19
  def build_argv(self, runner_input: RunnerInput) -> list[str]:
20
- if runner_input.mode in (JobMode.PLAN, JobMode.ASK):
20
+ if is_read_only_job_mode(runner_input.mode):
21
21
  prompt = instruction_for_runner_mode(runner_input.instruction, runner_input.mode)
22
22
  argv = ["gemini", "--skip-trust", "-p", prompt]
23
23
  else:
@@ -32,7 +32,7 @@ def build_parser() -> argparse.ArgumentParser:
32
32
  help="Run the server only (skip ngrok and webhook registration)",
33
33
  )
34
34
 
35
- subparsers.add_parser("doctor", help="Check prerequisites (ngrok, AI CLIs)")
35
+ subparsers.add_parser("doctor", help="Check prerequisites (ngrok, AI CLIs, GitHub CLI)")
36
36
  return parser
37
37
 
38
38
 
@@ -128,6 +128,10 @@ def run_doctor() -> None:
128
128
  " ⚠️ AI CLI (claude/codex/gemini) not found. Install at least one. "
129
129
  "(e.g. npm install -g @anthropic-ai/claude-code)"
130
130
  )
131
+ if report.github_cli.installed:
132
+ print(" ✅ GitHub CLI: gh")
133
+ else:
134
+ print(" ⚠️ GitHub CLI (gh) not found. Install it before using /pr.")
131
135
 
132
136
 
133
137
  if __name__ == "__main__":
@@ -7,6 +7,7 @@ from pydantic import BaseModel
7
7
  from app import tunnel
8
8
 
9
9
  AI_CLI_TOOLS = ("claude", "codex", "gemini")
10
+ GITHUB_CLI_TOOL = "gh"
10
11
 
11
12
 
12
13
  class AiCliStatus(BaseModel):
@@ -18,6 +19,7 @@ class PrerequisitesReport(BaseModel):
18
19
  ngrok_ok: bool
19
20
  ngrok_detail: str
20
21
  ai_clis: list[AiCliStatus]
22
+ github_cli: AiCliStatus
21
23
 
22
24
 
23
25
  def check_prerequisites() -> PrerequisitesReport:
@@ -34,4 +36,13 @@ def check_prerequisites() -> PrerequisitesReport:
34
36
  AiCliStatus(name=tool, installed=shutil.which(tool) is not None)
35
37
  for tool in AI_CLI_TOOLS
36
38
  ]
37
- return PrerequisitesReport(ngrok_ok=ngrok_ok, ngrok_detail=ngrok_detail, ai_clis=ai_clis)
39
+ github_cli = AiCliStatus(
40
+ name=GITHUB_CLI_TOOL,
41
+ installed=shutil.which(GITHUB_CLI_TOOL) is not None,
42
+ )
43
+ return PrerequisitesReport(
44
+ ngrok_ok=ngrok_ok,
45
+ ngrok_detail=ngrok_detail,
46
+ ai_clis=ai_clis,
47
+ github_cli=github_cli,
48
+ )
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
  from app.ai.base import RunnerInput
7
7
  from app.git.commit_message import CommitMessageFormatter
8
8
  from app.jobs.plan_decisions import PlanDecisionQuestion, parse_plan_decisions
9
- from app.jobs.schemas import Job, JobMode
9
+ from app.jobs.schemas import Job, JobMode, is_read_only_job_mode
10
10
  from app.monitoring.events import EventLogger
11
11
 
12
12
  _joblog = EventLogger("app.jobs.lifecycle", "job.lifecycle")
@@ -50,7 +50,7 @@ def run_job(manager, job_id: str) -> Job:
50
50
  created_worktree_for_job = False
51
51
  failed_stage: str | None = None
52
52
  remote = manager._effective_git_remote_name()
53
- read_only_job = job.request.mode in (JobMode.PLAN, JobMode.ASK)
53
+ read_only_job = is_read_only_job_mode(job.request.mode)
54
54
  plan_decision_questions: list[PlanDecisionQuestion] | None = None
55
55
  try:
56
56
  job.mark_running()
@@ -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:
@@ -237,9 +238,7 @@ def run_job(manager, job_id: str) -> Job:
237
238
  finally:
238
239
  manager._cancel_events.pop(job_id, None)
239
240
  manager._cancelled_job_ids.discard(job_id)
240
- read_only_succeeded = (
241
- job.request.mode in (JobMode.PLAN, JobMode.ASK) and job.status.value == "succeeded"
242
- )
241
+ read_only_succeeded = is_read_only_job_mode(job.request.mode) and job.status.value == "succeeded"
243
242
  cleanup_on_success = read_only_succeeded or not manager._effective_keep_worktree_on_success()
244
243
  _joblog.info(
245
244
  "job finalizing status=%s created_worktree=%s cleanup_on_success=%s",
@@ -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
 
@@ -26,9 +26,17 @@ class JobMode(StrEnum):
26
26
  AGENT = "agent"
27
27
  PLAN = "plan"
28
28
  ASK = "ask"
29
+ RESEARCH = "research"
29
30
  AGENT_FIX = "agent_fix"
30
31
 
31
32
 
33
+ READ_ONLY_JOB_MODES = frozenset({JobMode.PLAN, JobMode.ASK, JobMode.RESEARCH})
34
+
35
+
36
+ def is_read_only_job_mode(mode: JobMode) -> bool:
37
+ return mode in READ_ONLY_JOB_MODES
38
+
39
+
32
40
  class FixKind(StrEnum):
33
41
  SOURCE = "source"
34
42
 
@@ -4,7 +4,7 @@ from dataclasses import dataclass
4
4
  from pathlib import Path
5
5
 
6
6
  from app.git.service import GitWorktreeService
7
- from app.jobs.schemas import Job, JobMode
7
+ from app.jobs.schemas import Job, is_read_only_job_mode
8
8
  from app.monitoring.events import EventLogger
9
9
 
10
10
  _joblog = EventLogger("app.jobs.lifecycle", "job.lifecycle")
@@ -27,7 +27,7 @@ def prepare_worktree_plan(
27
27
  job_ctx: dict[str, object],
28
28
  ) -> WorktreePlan:
29
29
  requested_branch = job.request.branch
30
- if job.request.mode in (JobMode.PLAN, JobMode.ASK):
30
+ if is_read_only_job_mode(job.request.mode):
31
31
  path = git_service.prepare_detached_worktree(
32
32
  project_path, job.id, worktree_base_dir=worktree_base
33
33
  )
@@ -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
+ )