remote-coder 0.4.3__tar.gz → 0.4.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 (119) hide show
  1. {remote_coder-0.4.3 → remote_coder-0.4.4}/PKG-INFO +1 -1
  2. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/__init__.py +1 -1
  3. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/cli.py +15 -12
  4. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/jobs/manager.py +82 -99
  5. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/jobs/schemas.py +0 -1
  6. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/monitoring/model.py +58 -1
  7. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/telegram/commands/__init__.py +0 -2
  8. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/telegram/commands/base.py +19 -2
  9. remote_coder-0.4.4/app/telegram/commands/fix.py +19 -0
  10. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/telegram/commands/registry.py +1 -1
  11. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/telegram/commands/system.py +14 -5
  12. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/telegram/confirmations.py +1 -0
  13. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/telegram/i18n.py +35 -6
  14. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/telegram/parser.py +44 -11
  15. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/telegram/webhook.py +208 -83
  16. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/tunnel.py +5 -5
  17. {remote_coder-0.4.3 → remote_coder-0.4.4}/pyproject.toml +1 -1
  18. {remote_coder-0.4.3 → remote_coder-0.4.4}/remote_coder.egg-info/PKG-INFO +1 -1
  19. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_cli.py +1 -1
  20. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_command_parser.py +26 -0
  21. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_commands.py +18 -219
  22. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_commit_message_formatter.py +2 -3
  23. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_job_manager.py +26 -41
  24. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_monitoring.py +45 -2
  25. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_webhook.py +286 -1
  26. remote_coder-0.4.3/app/telegram/commands/fix.py +0 -219
  27. {remote_coder-0.4.3 → remote_coder-0.4.4}/LICENSE +0 -0
  28. {remote_coder-0.4.3 → remote_coder-0.4.4}/README.md +0 -0
  29. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/admin/__init__.py +0 -0
  30. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/admin/advanced_settings.py +0 -0
  31. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/admin/database_browser.py +0 -0
  32. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/admin/router.py +0 -0
  33. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/admin/static/i18n.js +0 -0
  34. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/admin/static/icons/advanced.svg +0 -0
  35. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/admin/static/icons/database.svg +0 -0
  36. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/admin/static/icons/download.svg +0 -0
  37. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/admin/static/icons/home.svg +0 -0
  38. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/admin/static/icons/logs.svg +0 -0
  39. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/admin/static/icons/projects.svg +0 -0
  40. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/admin/static/summary.js +0 -0
  41. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/admin/templates/admin.html +0 -0
  42. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/admin/templates/advanced.html +0 -0
  43. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/admin/templates/database.html +0 -0
  44. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/admin/templates/logs.html +0 -0
  45. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/admin/templates/projects.html +0 -0
  46. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/ai/__init__.py +0 -0
  47. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/ai/base.py +0 -0
  48. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/ai/claude.py +0 -0
  49. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/ai/codex.py +0 -0
  50. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/ai/factory.py +0 -0
  51. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/ai/gemini.py +0 -0
  52. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/ai/model_catalog.py +1 -1
  53. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/ai/usage.py +0 -0
  54. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/config.py +0 -0
  55. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/diagnostics.py +0 -0
  56. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/git/__init__.py +0 -0
  57. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/git/ai_commit.py +0 -0
  58. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/git/branch_naming.py +0 -0
  59. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/git/commit_message.py +0 -0
  60. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/git/service.py +0 -0
  61. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/jobs/__init__.py +0 -0
  62. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/jobs/store.py +0 -0
  63. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/main.py +0 -0
  64. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/models.py +0 -0
  65. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/monitoring/__init__.py +0 -0
  66. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/monitoring/code.py +0 -0
  67. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/monitoring/events.py +0 -0
  68. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/monitoring/git.py +0 -0
  69. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/monitoring/log_buffer.py +0 -0
  70. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/monitoring/memory.py +0 -0
  71. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/projects/__init__.py +0 -0
  72. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/projects/registry.py +0 -0
  73. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/security/__init__.py +0 -0
  74. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/security/auth.py +0 -0
  75. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/system_startup.py +0 -0
  76. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/telegram/__init__.py +0 -0
  77. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/telegram/bot_instances.py +0 -0
  78. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/telegram/commands/branch.py +0 -0
  79. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/telegram/commands/clear_stop.py +0 -0
  80. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/telegram/commands/model.py +0 -0
  81. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/telegram/commands/monitor.py +0 -0
  82. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/telegram/commands/status.py +0 -0
  83. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/telegram/conversation.py +0 -0
  84. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/telegram/model_preferences.py +0 -0
  85. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/telegram/notifier.py +0 -0
  86. {remote_coder-0.4.3 → remote_coder-0.4.4}/app/telegram/webhook_registration.py +0 -0
  87. {remote_coder-0.4.3 → remote_coder-0.4.4}/remote_coder.egg-info/SOURCES.txt +0 -0
  88. {remote_coder-0.4.3 → remote_coder-0.4.4}/remote_coder.egg-info/dependency_links.txt +0 -0
  89. {remote_coder-0.4.3 → remote_coder-0.4.4}/remote_coder.egg-info/entry_points.txt +0 -0
  90. {remote_coder-0.4.3 → remote_coder-0.4.4}/remote_coder.egg-info/requires.txt +0 -0
  91. {remote_coder-0.4.3 → remote_coder-0.4.4}/remote_coder.egg-info/top_level.txt +0 -0
  92. {remote_coder-0.4.3 → remote_coder-0.4.4}/setup.cfg +0 -0
  93. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_admin_router.py +0 -0
  94. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_ai_base.py +0 -0
  95. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_ai_commit.py +0 -0
  96. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_ai_factory.py +0 -0
  97. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_auth.py +0 -0
  98. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_bot_instance_manager.py +0 -0
  99. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_branch_naming.py +0 -0
  100. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_claude_runner.py +0 -0
  101. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_codex_runner.py +0 -0
  102. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_config.py +0 -0
  103. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_conversation_store.py +0 -0
  104. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_database_browser.py +0 -0
  105. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_diagnostics.py +0 -0
  106. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_event_logger.py +0 -0
  107. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_gemini_runner.py +0 -0
  108. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_git_service.py +0 -0
  109. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_i18n.py +0 -0
  110. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_job_status.py +0 -0
  111. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_job_store.py +0 -0
  112. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_log_buffer.py +0 -0
  113. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_notifier.py +0 -0
  114. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_project_registry.py +0 -0
  115. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_project_scoped_state.py +0 -0
  116. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_system_startup.py +0 -0
  117. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_tunnel.py +0 -0
  118. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_webhook_multibot.py +0 -0
  119. {remote_coder-0.4.3 → remote_coder-0.4.4}/tests/test_webhook_registration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remote-coder
3
- Version: 0.4.3
3
+ Version: 0.4.4
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.4.3"
3
+ __version__ = "0.4.4"
@@ -55,7 +55,7 @@ def main(argv: Sequence[str] | None = None) -> None:
55
55
 
56
56
 
57
57
  def _run_server(*, host: str, port: int, reload: bool, log_level: str) -> None:
58
- print("🚀 서버를 시작합니다... (종료: Ctrl+C)")
58
+ print("🚀 Starting server... (press Ctrl+C to stop)")
59
59
  uvicorn.run("app.main:app", host=host, port=port, reload=reload, log_level=log_level)
60
60
 
61
61
 
@@ -70,32 +70,35 @@ def run_up(*, host: str, port: int, reload: bool, log_level: str, tunnel: bool =
70
70
 
71
71
  ngrok = NgrokTunnel(port)
72
72
  try:
73
- print("🌐 ngrok 터널을 시작합니다...")
73
+ print("🌐 Starting ngrok tunnel...")
74
74
  public_url = ngrok.start()
75
75
  except TunnelError as exc:
76
76
  print(f"❌ {exc}")
77
77
  raise SystemExit(1) from exc
78
78
 
79
- print(f"🔗 공개 HTTPS 주소: {public_url}")
79
+ print(f"🔗 Public HTTPS URL: {public_url}")
80
80
  os.environ["TELEGRAM_WEBHOOK_PUBLIC_BASE_URL"] = public_url
81
81
  get_settings.cache_clear()
82
82
  settings = get_settings()
83
83
 
84
- print("📨 Telegram webhook/명령어 메뉴를 등록합니다...")
84
+ print("📨 Registering Telegram webhooks and command menu...")
85
85
  if not register_all_enabled_projects(public_url, settings):
86
86
  if _has_enabled_projects(settings):
87
- print("⚠️ 일부 프로젝트 webhook 등록에 실패했습니다. 기존 등록이 유효하면 계속 동작할 수 있습니다.")
87
+ print(
88
+ "⚠️ Some project webhook registrations failed. "
89
+ "If existing registrations are valid, the service may still work."
90
+ )
88
91
  else:
89
92
  print(
90
- f"🛠️ 아직 등록된 프로젝트가 없습니다. 브라우저에서 http://127.0.0.1:{port}/ 열어 "
91
- " 프로젝트( 토큰·저장소·허용 Chat ID)를 등록하세요."
93
+ f"🛠️ No projects registered yet. Open http://127.0.0.1:{port}/ in a browser and "
94
+ "register your first project (bot token, repository, allowed Chat IDs)."
92
95
  )
93
96
 
94
97
  try:
95
98
  _run_server(host=host, port=port, reload=reload, log_level=log_level)
96
99
  finally:
97
100
  ngrok.stop()
98
- print("🛑 ngrok 터널을 종료했습니다.")
101
+ print("🛑 ngrok tunnel stopped.")
99
102
 
100
103
 
101
104
  def _has_enabled_projects(settings) -> bool:
@@ -111,9 +114,9 @@ def run_doctor() -> None:
111
114
  from app.diagnostics import check_prerequisites
112
115
 
113
116
  report = check_prerequisites()
114
- print("전제조건 점검:")
117
+ print("Prerequisite checks:")
115
118
  if report.ngrok_ok:
116
- print(" ✅ ngrok: 설치 AuthToken 설정 완료")
119
+ print(" ✅ ngrok: installed and AuthToken configured")
117
120
  else:
118
121
  print(f" ⚠️ ngrok: {report.ngrok_detail}")
119
122
 
@@ -122,8 +125,8 @@ def run_doctor() -> None:
122
125
  print(f" ✅ AI CLI: {', '.join(installed)}")
123
126
  else:
124
127
  print(
125
- " ⚠️ AI CLI(claude/codex/gemini) 찾지 못했습니다. 최소 1개를 설치하세요. "
126
- "(예: npm install -g @anthropic-ai/claude-code)"
128
+ " ⚠️ AI CLI (claude/codex/gemini) not found. Install at least one. "
129
+ "(e.g. npm install -g @anthropic-ai/claude-code)"
127
130
  )
128
131
 
129
132
 
@@ -460,22 +460,24 @@ class JobManager:
460
460
  if self.is_fix_candidate(job, project, chat_id)
461
461
  ][:limit]
462
462
 
463
- def build_fix_commit_preview(self, parent_job: Job) -> str:
464
- ai_title = None
465
- ai_body = None
466
- if self._ai_commit_body_generator is not None:
467
- ai_title, ai_body = self._ai_commit_body_generator.generate(
468
- instruction=parent_job.request.instruction,
469
- changed_files=parent_job.changed_files,
470
- model_name=parent_job.request.model,
471
- )
472
- return CommitMessageFormatter.format(
473
- job_id=parent_job.id,
474
- instruction=parent_job.request.instruction,
475
- changed_files=parent_job.changed_files,
476
- ai_body=ai_body,
477
- ai_title=ai_title,
478
- )
463
+ def resolve_fix_target_job(self, job_id: str, project: str, chat_id: int) -> Job | None:
464
+ job = self._job_store.get(job_id)
465
+ if job is None:
466
+ return None
467
+ visited: set[str] = set()
468
+ while job is not None and job.id not in visited:
469
+ visited.add(job.id)
470
+ if self.is_fix_candidate(job, project, chat_id):
471
+ if job.request.mode is JobMode.AGENT_FIX and job.request.parent_job_id:
472
+ parent = self._job_store.get(job.request.parent_job_id)
473
+ if parent is not None and self.is_fix_candidate(parent, project, chat_id):
474
+ return parent
475
+ return job
476
+ if job.request.parent_job_id:
477
+ job = self._job_store.get(job.request.parent_job_id)
478
+ else:
479
+ break
480
+ return None
479
481
 
480
482
  @staticmethod
481
483
  def compose_fix_source_prompt(parent_job: Job, fix_instruction: str) -> str:
@@ -495,15 +497,11 @@ class JobManager:
495
497
  "Do not add new files or unrelated changes."
496
498
  )
497
499
 
498
- def execute_fix_job(
499
- self,
500
- request: JobRequest,
501
- prepared_message: str | None = None,
502
- ) -> Job:
500
+ def execute_fix_job(self, request: JobRequest) -> Job:
503
501
  if request.mode is not JobMode.AGENT_FIX:
504
502
  raise ValueError("execute_fix_job requires JobMode.AGENT_FIX")
505
- if request.fix_kind is None:
506
- raise ValueError("execute_fix_job requires fix_kind")
503
+ if request.fix_kind is not FixKind.SOURCE:
504
+ raise ValueError("execute_fix_job requires FixKind.SOURCE")
507
505
  if not request.parent_job_id:
508
506
  raise ValueError("execute_fix_job requires parent_job_id")
509
507
 
@@ -521,9 +519,9 @@ class JobManager:
521
519
  if accepted_message_id is not None:
522
520
  job.accepted_message_id = accepted_message_id
523
521
  self._job_store.update(job)
524
- return self._run_fix(job.id, prepared_message=prepared_message)
522
+ return self._run_fix(job.id)
525
523
 
526
- def _run_fix(self, job_id: str, prepared_message: str | None = None) -> Job:
524
+ def _run_fix(self, job_id: str) -> Job:
527
525
  job = self._job_store.get(job_id)
528
526
  if job is None:
529
527
  _joblog.warning("run_fix requested for missing job job_id=%s", job_id)
@@ -553,10 +551,12 @@ class JobManager:
553
551
  self._job_store.update(job)
554
552
 
555
553
  failed_stage = "fix_resolve_target"
556
- parent_job = self._job_store.get(job.request.parent_job_id or "")
557
- if parent_job is None or not self.is_fix_candidate(
558
- parent_job, job.request.project, job.request.chat_id
559
- ):
554
+ parent_job = self.resolve_fix_target_job(
555
+ job.request.parent_job_id or "",
556
+ job.request.project,
557
+ job.request.chat_id,
558
+ )
559
+ if parent_job is None:
560
560
  raise RuntimeError("Fix target job was not found or can no longer be fixed.")
561
561
  assert parent_job.branch is not None
562
562
  assert parent_job.commit_hash is not None
@@ -577,90 +577,73 @@ class JobManager:
577
577
  created_worktree_for_job = True
578
578
  self._git_service.ensure_worktree_writable(worktree_path)
579
579
 
580
- if job.request.fix_kind is FixKind.SOURCE:
581
- failed_stage = "fix_runner"
582
- runner = self._runner_factory.create(job.request.model)
583
- timeout_seconds = self._effective_job_timeout_seconds()
584
- fix_prompt = self.compose_fix_source_prompt(parent_job, job.request.instruction)
585
- runner_result = runner.run(
586
- RunnerInput(
587
- instruction=fix_prompt,
588
- cwd=worktree_path,
589
- timeout_seconds=timeout_seconds,
590
- model_id=job.request.model_id,
591
- env=None,
592
- cancel_event=cancel_event,
593
- mode=JobMode.AGENT,
594
- )
580
+ failed_stage = "fix_runner"
581
+ runner = self._runner_factory.create(job.request.model)
582
+ timeout_seconds = self._effective_job_timeout_seconds()
583
+ fix_prompt = self.compose_fix_source_prompt(parent_job, job.request.instruction)
584
+ runner_result = runner.run(
585
+ RunnerInput(
586
+ instruction=fix_prompt,
587
+ cwd=worktree_path,
588
+ timeout_seconds=timeout_seconds,
589
+ model_id=job.request.model_id,
590
+ env=None,
591
+ cancel_event=cancel_event,
592
+ mode=JobMode.AGENT,
595
593
  )
596
- self._save_runner_log(job, runner_result, worktree_base)
597
- if runner_result.exit_code != 0:
598
- raise RuntimeError(runner_result.stderr.strip() or "runner failed")
599
-
600
- failed_stage = "fix_collect_changes"
601
- new_changed = self._git_service.collect_changes(worktree_path)
602
- merged = list(dict.fromkeys([*parent_job.changed_files, *new_changed]))
603
- job.changed_files = merged
604
-
605
- if not new_changed:
606
- job.branch = parent_job.branch
607
- job.commit_hash = parent_job.commit_hash
608
- job.mark_succeeded()
609
- self._job_store.update(job)
610
- _joblog.info(
611
- "fix source produced no changes parent=%s",
612
- parent_job.id,
613
- **self._job_ctx(job),
614
- )
615
- else:
616
- failed_stage = "fix_message"
617
- ai_title = None
618
- ai_body = None
619
- if self._ai_commit_body_generator is not None:
620
- ai_title, ai_body = self._ai_commit_body_generator.generate(
621
- instruction=self.compose_fix_source_prompt(
622
- parent_job, job.request.instruction
623
- ),
624
- changed_files=merged,
625
- model_name=job.request.model,
626
- )
627
- commit_message = CommitMessageFormatter.format(
628
- job_id=parent_job.id,
629
- instruction=parent_job.request.instruction,
630
- changed_files=merged,
631
- ai_body=ai_body,
632
- ai_title=ai_title,
633
- )
594
+ )
595
+ self._save_runner_log(job, runner_result, worktree_base)
596
+ if runner_result.exit_code != 0:
597
+ raise RuntimeError(runner_result.stderr.strip() or "runner failed")
634
598
 
635
- failed_stage = "fix_amend"
636
- job.commit_hash = self._git_service.amend_commit(worktree_path, commit_message)
637
- job.branch = parent_job.branch
599
+ failed_stage = "fix_collect_changes"
600
+ new_changed = self._git_service.collect_changes(worktree_path)
601
+ merged = list(dict.fromkeys([*parent_job.changed_files, *new_changed]))
602
+ job.changed_files = merged
638
603
 
639
- failed_stage = "fix_push"
640
- self._git_service.push_branch_force_with_lease(
641
- project_path, remote, parent_job.branch
604
+ if not new_changed:
605
+ job.branch = parent_job.branch
606
+ job.commit_hash = parent_job.commit_hash
607
+ job.mark_succeeded()
608
+ self._job_store.update(job)
609
+ _joblog.info(
610
+ "fix source produced no changes parent=%s",
611
+ parent_job.id,
612
+ **self._job_ctx(job),
613
+ )
614
+ else:
615
+ failed_stage = "fix_message"
616
+ ai_title = None
617
+ ai_body = None
618
+ if self._ai_commit_body_generator is not None:
619
+ ai_title, ai_body = self._ai_commit_body_generator.generate(
620
+ instruction=self.compose_fix_source_prompt(
621
+ parent_job, job.request.instruction
622
+ ),
623
+ changed_files=merged,
624
+ model_name=job.request.model,
642
625
  )
626
+ commit_message = CommitMessageFormatter.format(
627
+ job_id=parent_job.id,
628
+ instruction=parent_job.request.instruction,
629
+ changed_files=merged,
630
+ ai_body=ai_body,
631
+ ai_title=ai_title,
632
+ )
643
633
 
644
- parent_job.commit_hash = job.commit_hash
645
- parent_job.changed_files = merged
646
- self._job_store.update(parent_job)
647
-
648
- job.mark_succeeded()
649
- self._job_store.update(job)
650
- else:
651
634
  failed_stage = "fix_amend"
652
- if prepared_message is None:
653
- prepared_message = self.build_fix_commit_preview(parent_job)
654
- job.changed_files = list(parent_job.changed_files)
655
- job.commit_hash = self._git_service.amend_commit(worktree_path, prepared_message)
635
+ job.commit_hash = self._git_service.amend_commit(worktree_path, commit_message)
656
636
  job.branch = parent_job.branch
657
637
 
658
638
  failed_stage = "fix_push"
659
639
  self._git_service.push_branch_force_with_lease(
660
640
  project_path, remote, parent_job.branch
661
641
  )
642
+
662
643
  parent_job.commit_hash = job.commit_hash
644
+ parent_job.changed_files = merged
663
645
  self._job_store.update(parent_job)
646
+
664
647
  job.mark_succeeded()
665
648
  self._job_store.update(job)
666
649
  except Exception as exc: # pylint: disable=broad-except
@@ -29,7 +29,6 @@ class JobMode(StrEnum):
29
29
 
30
30
 
31
31
  class FixKind(StrEnum):
32
- COMMIT = "commit"
33
32
  SOURCE = "source"
34
33
 
35
34
 
@@ -234,6 +234,7 @@ def _normalize_token_dict(raw: object) -> dict[str, int]:
234
234
  "output_tokens": "output",
235
235
  "reasoning_output_tokens": "reasoning",
236
236
  "total_tokens": "total",
237
+ "candidates": "output",
237
238
  "input": "input",
238
239
  "output": "output",
239
240
  "cached": "cached",
@@ -553,7 +554,7 @@ class GeminiUsageProvider(ModelUsageProvider):
553
554
  else:
554
555
  lines.append(f"Version check failed (exit {proc.returncode}).")
555
556
 
556
- lines.extend(self._footer())
557
+ lines.extend(self._model_probe(timeout_seconds))
557
558
  return "\n".join(lines)
558
559
 
559
560
  @staticmethod
@@ -563,6 +564,49 @@ class GeminiUsageProvider(ModelUsageProvider):
563
564
  "Install: npm install -g @google/gemini-cli",
564
565
  ]
565
566
 
567
+ @staticmethod
568
+ def _model_probe(timeout_seconds: int) -> list[str]:
569
+ lines = ["", "Live model probe (--output-format json):"]
570
+ try:
571
+ proc = subprocess.run(
572
+ [
573
+ "gemini",
574
+ "--skip-trust",
575
+ "--output-format",
576
+ "json",
577
+ "-p",
578
+ "Reply with ok only.",
579
+ ],
580
+ capture_output=True,
581
+ text=True,
582
+ timeout=timeout_seconds,
583
+ shell=False,
584
+ )
585
+ except subprocess.TimeoutExpired:
586
+ return [*lines, "- Probe timed out before Gemini returned JSON stats."]
587
+ except FileNotFoundError:
588
+ return [*lines, "- Probe skipped because `gemini` is not available on PATH."]
589
+
590
+ raw = (proc.stdout or "").strip()
591
+ if proc.returncode != 0:
592
+ message = (proc.stderr or raw or "no output").strip()
593
+ return [*lines, f"- Probe failed (exit {proc.returncode}): {message[:400]}"]
594
+ try:
595
+ data = json.loads(raw)
596
+ except json.JSONDecodeError:
597
+ return [*lines, "- Probe returned non-JSON output.", raw[:400]]
598
+
599
+ model, tokens = _gemini_model_stats(data)
600
+ if model:
601
+ lines.append(f"- Observed detailed model: {model}")
602
+ if tokens:
603
+ formatted = format_token_usage(tokens)
604
+ if formatted:
605
+ lines.append(f"- Observed tokens: {formatted}")
606
+ if len(lines) == 2:
607
+ lines.append("- No model stats found in Gemini JSON output.")
608
+ return lines
609
+
566
610
  def read_local_usage(self) -> LocalUsageSnapshot | None:
567
611
  root = Path(os.environ.get("GEMINI_HOME", Path.home() / ".gemini"))
568
612
  newest: tuple[Path, dict[str, Any], datetime | None] | None = None
@@ -591,6 +635,19 @@ class GeminiUsageProvider(ModelUsageProvider):
591
635
  )
592
636
 
593
637
 
638
+ def _gemini_model_stats(data: dict[str, Any]) -> tuple[str | None, dict[str, int]]:
639
+ stats = data.get("stats") if isinstance(data.get("stats"), dict) else {}
640
+ models = stats.get("models") if isinstance(stats.get("models"), dict) else {}
641
+ if not models:
642
+ return None, {}
643
+
644
+ model_name = next(iter(models))
645
+ model_stats = models.get(model_name)
646
+ if not isinstance(model_stats, dict):
647
+ return str(model_name), {}
648
+ return str(model_name), _normalize_token_dict(model_stats.get("tokens"))
649
+
650
+
594
651
  _USAGE_PROVIDERS: dict[ModelName, ModelUsageProvider] = {
595
652
  ModelName.CLAUDE: ClaudeUsageProvider(),
596
653
  ModelName.CODEX: CodexUsageProvider(),
@@ -14,7 +14,6 @@ from app.telegram.commands.base import (
14
14
  from app.telegram.commands.branch import BranchCommand, PrCommand, PullCommand, RebaseCommand
15
15
  from app.telegram.commands.clear_stop import ClearCommand, StopCommand
16
16
  from app.telegram.commands.fix import (
17
- FIX_COMMIT_PENDING_ACTION,
18
17
  FIX_SOURCE_AWAIT_ACTION,
19
18
  FIX_SOURCE_PENDING_ACTION,
20
19
  FixCommand,
@@ -37,7 +36,6 @@ __all__ = [
37
36
  "CommandRegistry",
38
37
  "CommandResponse",
39
38
  "ConfirmableCommand",
40
- "FIX_COMMIT_PENDING_ACTION",
41
39
  "FIX_SOURCE_AWAIT_ACTION",
42
40
  "FIX_SOURCE_PENDING_ACTION",
43
41
  "FixCommand",
@@ -80,7 +80,8 @@ HELP_TEXT = "\n".join(
80
80
  "- no commit",
81
81
  "- plan: <natural language> or /plan <natural language> - plan mode (plan only; no code changes)",
82
82
  "- ask: <natural language> or /ask <natural language> - ask mode (analysis and answers; no code edits)",
83
- "- Korean aliases 계획: and 질문: instead of plan:/ask: (colons `:` or full-width `:` allowed)",
83
+ "- fix: <natural language> or /fix - fix mode (reply to a job result; amends that commit)",
84
+ "- Korean aliases 계획:, 질문:, and 수정: instead of plan:/ask:/fix: (colons `:` or full-width `:` allowed)",
84
85
  "",
85
86
  "Commands:",
86
87
  "- /model <claude|codex|gemini>: Change the default model",
@@ -94,7 +95,7 @@ HELP_TEXT = "\n".join(
94
95
  "- /reports [count]: Conversation memory report",
95
96
  "- /init: Reset this chat's settings",
96
97
  "- /stop <job_id>: Stop a running job",
97
- "- /fix <commit|source> [job_id]: Re-do a job's commit/source (amend + force-with-lease push)",
98
+ "- /fix: Fix the linked job commit (reply to a job result first)",
98
99
  "- /start: Inline menu",
99
100
  ]
100
101
  )
@@ -147,6 +148,22 @@ HELP_ASK_TOPIC = "\n".join(
147
148
  ]
148
149
  )
149
150
 
151
+ HELP_FIX_TOPIC = "\n".join(
152
+ [
153
+ "Fix mode (fix)",
154
+ "",
155
+ "Apply follow-up fixes on top of a previous succeeded job. Reply to a job result, then use "
156
+ "fix: or /fix. The agent amends the existing commit and pushes with --force-with-lease.",
157
+ "",
158
+ "Examples",
159
+ "- (reply to job result) fix: add missing tests",
160
+ "- (reply to job result) /fix then send the fix instruction",
161
+ "- (reply to job result) 수정:로그인 검증 버그도 고쳐줘",
162
+ "",
163
+ "See /help for more options.",
164
+ ]
165
+ )
166
+
150
167
 
151
168
  def _button_rows(buttons: list[InlineButton], per_row: int = 2) -> list[list[InlineButton]]:
152
169
  return [buttons[i : i + per_row] for i in range(0, len(buttons), per_row)]
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from app.telegram.commands.base import TelegramCommand, TelegramMessage, CommandContext
4
+
5
+ FIX_SOURCE_AWAIT_ACTION = "fix_source_await_instruction"
6
+ FIX_SOURCE_PENDING_ACTION = "fix_source"
7
+
8
+
9
+ class FixCommand(TelegramCommand):
10
+ name = "/fix"
11
+ menu_text = "Fix mode amends the linked job commit (reply to a job result first)."
12
+ description = "Fix the linked job commit (reply to a job result, then /fix or fix:)"
13
+
14
+ def execute(self, message: TelegramMessage, ctx: CommandContext) -> str:
15
+ _ = (message, ctx)
16
+ return (
17
+ "Fix mode requires replying to a job result message.\n\n"
18
+ "Example: reply to a job result, then send /fix or fix: add missing tests"
19
+ )
@@ -37,7 +37,7 @@ class CommandRegistry:
37
37
  ctx.confirmation_store.pop(scope_project, message.chat_id)
38
38
  return init_cmd.execute(message, ctx)
39
39
 
40
- if head in {"/plan", "/ask"}:
40
+ if head in {"/plan", "/ask", "/fix"}:
41
41
  return None
42
42
 
43
43
  pending = ctx.confirmation_store.get(scope_project, message.chat_id)
@@ -1,8 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from app import __version__
3
4
  from app.telegram.commands.base import (
4
5
  HELP_AGENT_TOPIC,
5
6
  HELP_ASK_TOPIC,
7
+ HELP_FIX_TOPIC,
6
8
  HELP_PLAN_TOPIC,
7
9
  HELP_TEXT,
8
10
  CommandContext,
@@ -26,6 +28,10 @@ class StartCommand(TelegramCommand):
26
28
  "modes": "Choose a mode guide.",
27
29
  }
28
30
 
31
+ @staticmethod
32
+ def _ready_line() -> str:
33
+ return f"✅ Remote AI Coder v{__version__} is ready."
34
+
29
35
  def execute(self, message: TelegramMessage, ctx: CommandContext) -> str:
30
36
  tokens = message.text.strip().split()
31
37
  if len(tokens) == 2:
@@ -35,11 +41,11 @@ class StartCommand(TelegramCommand):
35
41
  return topic_text
36
42
  project_name = effective_project_name_for_chat(ctx, message.chat_id)
37
43
  if not project_name:
38
- return "✅ Remote AI Coder is ready.\n\nWelcome to Remote AI Coder."
44
+ return f"{self._ready_line()}\n\nWelcome to Remote AI Coder."
39
45
  entry = ctx.project_registry.get(project_name)
40
46
  if not entry:
41
47
  return (
42
- "✅ Remote AI Coder is ready.\n\n"
48
+ f"{self._ready_line()}\n\n"
43
49
  "Welcome to Remote AI Coder.\n"
44
50
  f"- Project: {project_name} (not registered)"
45
51
  )
@@ -50,7 +56,7 @@ class StartCommand(TelegramCommand):
50
56
  state = "enabled" if entry.enabled else "disabled"
51
57
  return "\n".join(
52
58
  [
53
- "✅ Remote AI Coder is ready.",
59
+ self._ready_line(),
54
60
  "",
55
61
  "Welcome to Remote AI Coder.",
56
62
  f"- Project: {entry.name}",
@@ -98,6 +104,7 @@ class StartCommand(TelegramCommand):
98
104
  InlineButton("AGENTS mode", "/help agent"),
99
105
  InlineButton("PLAN mode", "/help plan"),
100
106
  InlineButton("ASK mode", "/help ask"),
107
+ InlineButton("FIX mode", "/help fix"),
101
108
  ],
102
109
  [InlineButton("Back", "/start")],
103
110
  ]
@@ -117,7 +124,7 @@ class HelpCommand(TelegramCommand):
117
124
  tokens = message.text.strip().split()
118
125
  if len(tokens) >= 2:
119
126
  raw = tokens[1]
120
- topic_aliases = {"에이전트": "agent", "계획": "plan", "질문": "ask"}
127
+ topic_aliases = {"에이전트": "agent", "계획": "plan", "질문": "ask", "수정": "fix"}
121
128
  topic = topic_aliases.get(raw, raw.lower())
122
129
  if topic in ("agent", "agents"):
123
130
  return HELP_AGENT_TOPIC
@@ -125,6 +132,8 @@ class HelpCommand(TelegramCommand):
125
132
  return HELP_PLAN_TOPIC
126
133
  if topic == "ask":
127
134
  return HELP_ASK_TOPIC
135
+ if topic == "fix":
136
+ return HELP_FIX_TOPIC
128
137
  if len(tokens) >= 2 and self._registry is not None:
129
138
  subcmd = self._registry.get("/" + tokens[1])
130
139
  if subcmd is not None and subcmd.menu_text:
@@ -141,7 +150,7 @@ class HelpCommand(TelegramCommand):
141
150
  tokens = message.text.strip().split() if message else []
142
151
  if len(tokens) >= 2:
143
152
  topic = tokens[1].lower()
144
- if topic in ("agent", "agents", "plan", "ask"):
153
+ if topic in ("agent", "agents", "plan", "ask", "fix"):
145
154
  return [[InlineButton("← Back", "/help")]]
146
155
  subcmd = self._registry.get("/" + tokens[1])
147
156
  if subcmd is not None:
@@ -16,6 +16,7 @@ class PendingConfirmation:
16
16
  original_text: str | None = None
17
17
  target_job_id: str | None = None
18
18
  prepared_payload: str | None = None
19
+ reply_to_message_id: int | None = None
19
20
 
20
21
 
21
22
  class InMemoryConfirmationStore: