remote-coder 0.4.1__py3-none-any.whl

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 (78) hide show
  1. app/__init__.py +3 -0
  2. app/admin/__init__.py +0 -0
  3. app/admin/advanced_settings.py +88 -0
  4. app/admin/database_browser.py +301 -0
  5. app/admin/router.py +528 -0
  6. app/admin/static/i18n.js +401 -0
  7. app/admin/static/icons/advanced.svg +8 -0
  8. app/admin/static/icons/database.svg +5 -0
  9. app/admin/static/icons/download.svg +3 -0
  10. app/admin/static/icons/home.svg +4 -0
  11. app/admin/static/icons/logs.svg +3 -0
  12. app/admin/static/icons/projects.svg +5 -0
  13. app/admin/static/summary.js +73 -0
  14. app/admin/templates/admin.html +511 -0
  15. app/admin/templates/advanced.html +635 -0
  16. app/admin/templates/database.html +880 -0
  17. app/admin/templates/logs.html +686 -0
  18. app/admin/templates/projects.html +878 -0
  19. app/ai/__init__.py +0 -0
  20. app/ai/base.py +129 -0
  21. app/ai/claude.py +20 -0
  22. app/ai/codex.py +34 -0
  23. app/ai/factory.py +27 -0
  24. app/ai/gemini.py +20 -0
  25. app/ai/model_catalog.py +47 -0
  26. app/ai/usage.py +134 -0
  27. app/cli.py +238 -0
  28. app/config.py +130 -0
  29. app/git/__init__.py +0 -0
  30. app/git/ai_commit.py +88 -0
  31. app/git/branch_naming.py +21 -0
  32. app/git/commit_message.py +279 -0
  33. app/git/service.py +669 -0
  34. app/jobs/__init__.py +0 -0
  35. app/jobs/manager.py +770 -0
  36. app/jobs/schemas.py +116 -0
  37. app/jobs/store.py +334 -0
  38. app/main.py +265 -0
  39. app/models.py +20 -0
  40. app/monitoring/__init__.py +10 -0
  41. app/monitoring/code.py +161 -0
  42. app/monitoring/events.py +33 -0
  43. app/monitoring/git.py +103 -0
  44. app/monitoring/log_buffer.py +245 -0
  45. app/monitoring/memory.py +19 -0
  46. app/monitoring/model.py +598 -0
  47. app/projects/__init__.py +19 -0
  48. app/projects/registry.py +384 -0
  49. app/security/__init__.py +0 -0
  50. app/security/auth.py +19 -0
  51. app/system_startup.py +34 -0
  52. app/telegram/__init__.py +0 -0
  53. app/telegram/bot_instances.py +67 -0
  54. app/telegram/commands/__init__.py +64 -0
  55. app/telegram/commands/base.py +222 -0
  56. app/telegram/commands/branch.py +366 -0
  57. app/telegram/commands/clear_stop.py +221 -0
  58. app/telegram/commands/fix.py +219 -0
  59. app/telegram/commands/model.py +93 -0
  60. app/telegram/commands/monitor.py +185 -0
  61. app/telegram/commands/registry.py +110 -0
  62. app/telegram/commands/status.py +243 -0
  63. app/telegram/commands/system.py +201 -0
  64. app/telegram/confirmations.py +36 -0
  65. app/telegram/conversation.py +789 -0
  66. app/telegram/i18n.py +742 -0
  67. app/telegram/model_preferences.py +53 -0
  68. app/telegram/notifier.py +387 -0
  69. app/telegram/parser.py +267 -0
  70. app/telegram/webhook.py +988 -0
  71. app/telegram/webhook_registration.py +172 -0
  72. app/tunnel.py +104 -0
  73. remote_coder-0.4.1.dist-info/METADATA +520 -0
  74. remote_coder-0.4.1.dist-info/RECORD +78 -0
  75. remote_coder-0.4.1.dist-info/WHEEL +5 -0
  76. remote_coder-0.4.1.dist-info/entry_points.txt +2 -0
  77. remote_coder-0.4.1.dist-info/licenses/LICENSE +201 -0
  78. remote_coder-0.4.1.dist-info/top_level.txt +1 -0
app/main.py ADDED
@@ -0,0 +1,265 @@
1
+ import asyncio
2
+ import logging
3
+ import time
4
+ from contextlib import asynccontextmanager
5
+ from dataclasses import replace
6
+
7
+ from fastapi import FastAPI, Request
8
+
9
+ from app.admin.advanced_settings import FileAdvancedSettingsStore, advanced_settings_path_for_project_root
10
+ from app.admin.router import create_admin_router
11
+ from app.ai.factory import AiRunnerFactory
12
+ from app.config import get_settings
13
+ from app.git.ai_commit import AiCommitBodyGenerator
14
+ from app.git.branch_naming import TimestampSlugStrategy
15
+ from app.git.service import GitWorktreeService
16
+ from app.jobs.manager import JobManager
17
+ from app.jobs.store import SQLiteJobStore
18
+ from app.monitoring.log_buffer import InMemoryLogBuffer, attach_app_memory_log_handler
19
+ from app.monitoring.events import EventLogger
20
+ from app.projects.registry import (
21
+ ProjectRegistry,
22
+ compute_token_hash_prefix,
23
+ projects_config_path_for_settings,
24
+ )
25
+ from app.security.auth import AllowlistAuthService
26
+ from app.system_startup import run_startup_project_pulls
27
+ from app.telegram.commands import (
28
+ CommandContext,
29
+ CommandRegistry,
30
+ TelegramMessage,
31
+ build_default_commands,
32
+ )
33
+ from app.telegram.bot_instances import BotInstance, BotInstanceManager
34
+ from app.telegram.confirmations import InMemoryConfirmationStore
35
+ from app.telegram.conversation import SQLiteConversationStore
36
+ from app.telegram.notifier import Notifier, TelegramNotifier
37
+ from app.telegram.i18n import ui_message
38
+ from app.telegram.parser import CommandParser
39
+ from app.telegram.model_preferences import InMemoryModelPreferenceStore
40
+ from app.telegram.webhook import create_webhook_router
41
+ from app.telegram.webhook_registration import TelegramWebhookRegistrar
42
+
43
+ settings = get_settings()
44
+ _projects_path = projects_config_path_for_settings(settings.project_root, settings.projects_config_path)
45
+ project_registry = ProjectRegistry(_projects_path)
46
+ project_registry.ensure_seeded_from_settings(settings)
47
+ _advanced_settings_path = advanced_settings_path_for_project_root(settings.project_root)
48
+ advanced_settings_store = FileAdvancedSettingsStore(_advanced_settings_path)
49
+ advanced_settings_store.load()
50
+
51
+ log_buffer = InMemoryLogBuffer(max_entries=2000)
52
+ attach_app_memory_log_handler(log_buffer)
53
+ logging.getLogger("app").info(
54
+ "Remote AI Coder server (re)loaded — log buffer ready"
55
+ )
56
+ _systemlog = EventLogger("app.system", "system.lifecycle")
57
+ _httplog = EventLogger("app.http", "http.request")
58
+
59
+ job_store = SQLiteJobStore(settings.job_db_path)
60
+ model_preferences = InMemoryModelPreferenceStore(default_model=settings.default_model)
61
+ confirmation_store = InMemoryConfirmationStore()
62
+ conversation_store = SQLiteConversationStore(
63
+ settings.conversation_db_path,
64
+ advanced_settings_store=advanced_settings_store,
65
+ )
66
+ parser = CommandParser(
67
+ project_registry=project_registry,
68
+ default_model=settings.default_model,
69
+ model_preferences=model_preferences,
70
+ conversation_store=conversation_store,
71
+ conversation_recent_limit=settings.conversation_recent_limit,
72
+ advanced_settings_store=advanced_settings_store,
73
+ )
74
+ command_registry = CommandRegistry(commands=build_default_commands())
75
+ git_service = GitWorktreeService(base_dir=settings.worktree_base_dir)
76
+ command_context = CommandContext(
77
+ job_store=job_store,
78
+ default_model=settings.default_model,
79
+ project_registry=project_registry,
80
+ model_preferences=model_preferences,
81
+ project_name=None,
82
+ git_service=git_service,
83
+ git_remote_name=settings.git_remote_name,
84
+ conversation_store=conversation_store,
85
+ confirmation_store=confirmation_store,
86
+ advanced_settings_store=advanced_settings_store,
87
+ )
88
+ runner_factory = AiRunnerFactory(codex_sandbox=settings.codex_sandbox)
89
+ branch_strategy = TimestampSlugStrategy()
90
+
91
+
92
+ def _build_bot_instance(record):
93
+ return BotInstance(
94
+ project_name=record.name,
95
+ token_hash=compute_token_hash_prefix(record.bot_token.get_secret_value()),
96
+ notifier=TelegramNotifier(record.bot_token.get_secret_value(), advanced_settings_store),
97
+ auth_service=AllowlistAuthService(set(record.allowed_chat_ids), set(record.allowed_user_ids)),
98
+ command_context=command_context,
99
+ webhook_secret=record.webhook_secret.get_secret_value() if record.webhook_secret else None,
100
+ )
101
+
102
+
103
+ bot_instance_manager = BotInstanceManager(_build_bot_instance)
104
+ for project in project_registry.list_projects():
105
+ if project.enabled:
106
+ bot_instance_manager.register(project)
107
+
108
+
109
+ def _notifier_for_project(project_name: str) -> Notifier:
110
+ instance = bot_instance_manager.get_by_name(project_name)
111
+ if instance is None:
112
+ raise RuntimeError(
113
+ "No Telegram notifier bot instance found for project. "
114
+ f"project={project_name!r}"
115
+ )
116
+ return instance.notifier
117
+
118
+
119
+ job_manager = JobManager(
120
+ settings=settings,
121
+ job_store=job_store,
122
+ git_service=git_service,
123
+ runner_factory=runner_factory,
124
+ branch_strategy=branch_strategy,
125
+ notifier_resolver=_notifier_for_project,
126
+ project_registry=project_registry,
127
+ advanced_settings_store=advanced_settings_store,
128
+ ai_commit_body_generator=AiCommitBodyGenerator(),
129
+ )
130
+ command_context.job_manager = job_manager
131
+ webhook_registrar = (
132
+ TelegramWebhookRegistrar(
133
+ settings.telegram_webhook_public_base_url,
134
+ bot_commands=command_registry.bot_commands(advanced_settings_store.get().ui_language),
135
+ )
136
+ if settings.telegram_webhook_public_base_url
137
+ else None
138
+ )
139
+
140
+ @asynccontextmanager
141
+ async def lifespan(_app: FastAPI):
142
+ instances = bot_instance_manager.list_all()
143
+ startup_chat_total = sum(len(inst.auth_service.allowed_chat_ids) for inst in instances)
144
+ _systemlog.info(
145
+ "lifespan startup notifying allowed chats count=%d projects=%d default_model=%s",
146
+ startup_chat_total,
147
+ len(project_registry.list_projects()),
148
+ settings.default_model.value,
149
+ )
150
+ adv = advanced_settings_store.get()
151
+ await asyncio.to_thread(
152
+ run_startup_project_pulls,
153
+ pull_projects_on_server_startup_enabled=adv.pull_projects_on_server_startup_enabled,
154
+ project_registry=project_registry,
155
+ git_service=git_service,
156
+ remote=settings.git_remote_name,
157
+ system_log=_systemlog,
158
+ )
159
+ if adv.server_lifecycle_notify_enabled:
160
+ for instance in instances:
161
+ ctx = replace(instance.command_context, project_name=instance.project_name)
162
+ bot_notifier = instance.notifier
163
+ for chat_id in instance.auth_service.allowed_chat_ids:
164
+ try:
165
+ response = command_registry.dispatch_rich(
166
+ TelegramMessage(chat_id=chat_id, user_id=None, text="/start"),
167
+ ctx,
168
+ )
169
+ if response:
170
+ text = ui_message(
171
+ "server.started",
172
+ "✅ Remote AI Coder server started.\n{body}",
173
+ body=response.text,
174
+ )
175
+ if response.inline_buttons:
176
+ bot_notifier.send_with_buttons(chat_id, text, response.inline_buttons)
177
+ else:
178
+ bot_notifier.send_text(chat_id, text)
179
+ _systemlog.info("startup notification sent", chat_id=chat_id)
180
+ except Exception:
181
+ _systemlog.exception("startup notification failed", chat_id=chat_id)
182
+ else:
183
+ _systemlog.info("lifespan startup notify disabled by settings")
184
+ yield
185
+ shutdown_instances = bot_instance_manager.list_all()
186
+ shutdown_chat_total = sum(len(inst.auth_service.allowed_chat_ids) for inst in shutdown_instances)
187
+ _systemlog.info("lifespan shutdown notifying allowed chats count=%d", shutdown_chat_total)
188
+ if advanced_settings_store.get().server_lifecycle_notify_enabled:
189
+ for instance in shutdown_instances:
190
+ bot_notifier = instance.notifier
191
+ for chat_id in instance.auth_service.allowed_chat_ids:
192
+ try:
193
+ bot_notifier.send_text(
194
+ chat_id,
195
+ ui_message(
196
+ "server.shutdown",
197
+ "🔴 Remote AI Coder server connection closed.",
198
+ ),
199
+ )
200
+ _systemlog.info("shutdown notification sent", chat_id=chat_id)
201
+ except Exception:
202
+ _systemlog.exception("shutdown notification failed", chat_id=chat_id)
203
+ else:
204
+ _systemlog.info("lifespan shutdown notify disabled by settings")
205
+
206
+
207
+ app = FastAPI(title="Remote AI Coder", lifespan=lifespan)
208
+
209
+
210
+ @app.middleware("http")
211
+ async def log_http_request(request: Request, call_next):
212
+ start = time.perf_counter()
213
+ path = request.url.path
214
+ method = request.method
215
+ client_host = request.client.host if request.client else "-"
216
+ _httplog.info("request start method=%s path=%s client=%s", method, path, client_host)
217
+ try:
218
+ response = await call_next(request)
219
+ except Exception:
220
+ dur_ms = int((time.perf_counter() - start) * 1000)
221
+ _httplog.exception(
222
+ "request failed method=%s path=%s client=%s dur_ms=%d",
223
+ method,
224
+ path,
225
+ client_host,
226
+ dur_ms,
227
+ )
228
+ raise
229
+ dur_ms = int((time.perf_counter() - start) * 1000)
230
+ _httplog.info(
231
+ "request done method=%s path=%s status=%d dur_ms=%d client=%s",
232
+ method,
233
+ path,
234
+ response.status_code,
235
+ dur_ms,
236
+ client_host,
237
+ )
238
+ return response
239
+ app.include_router(
240
+ create_admin_router(
241
+ settings,
242
+ project_registry,
243
+ advanced_settings_store,
244
+ log_buffer,
245
+ conversation_store,
246
+ bot_instance_manager=bot_instance_manager,
247
+ webhook_registrar=webhook_registrar,
248
+ bot_commands_builder=command_registry.bot_commands,
249
+ )
250
+ )
251
+ app.include_router(
252
+ create_webhook_router(
253
+ bot_instance_manager=bot_instance_manager,
254
+ parser=parser,
255
+ command_registry=command_registry,
256
+ job_manager=job_manager,
257
+ job_store=job_store,
258
+ conversation_store=conversation_store,
259
+ )
260
+ )
261
+
262
+
263
+ @app.get("/health")
264
+ def health() -> dict[str, str]:
265
+ return {"status": "ok"}
app/models.py ADDED
@@ -0,0 +1,20 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class UiLanguage(StrEnum):
5
+ ENGLISH = "en"
6
+ KOREAN = "ko"
7
+
8
+
9
+ class ModelName(StrEnum):
10
+ CLAUDE = "claude"
11
+ CODEX = "codex"
12
+ GEMINI = "gemini"
13
+
14
+
15
+ class CodexSandboxMode(StrEnum):
16
+ """Matches Codex CLI `--sandbox`; remote-coder defaults to workspace-write."""
17
+
18
+ READ_ONLY = "read-only"
19
+ WORKSPACE_WRITE = "workspace-write"
20
+ DANGER_FULL_ACCESS = "danger-full-access"
@@ -0,0 +1,10 @@
1
+ from app.monitoring.git import format_branch_monitor, format_worktree_monitor
2
+ from app.monitoring.memory import format_memory_monitor
3
+ from app.monitoring.model import format_model_monitor
4
+
5
+ __all__ = [
6
+ "format_branch_monitor",
7
+ "format_memory_monitor",
8
+ "format_model_monitor",
9
+ "format_worktree_monitor",
10
+ ]
app/monitoring/code.py ADDED
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+
7
+ _CODE_SUFFIXES: frozenset[str] = frozenset(
8
+ {
9
+ ".py",
10
+ ".pyi",
11
+ ".pyx",
12
+ ".md",
13
+ ".toml",
14
+ ".yaml",
15
+ ".yml",
16
+ ".json",
17
+ ".rs",
18
+ ".go",
19
+ ".c",
20
+ ".h",
21
+ ".cpp",
22
+ ".hpp",
23
+ ".java",
24
+ ".kt",
25
+ ".swift",
26
+ ".ts",
27
+ ".tsx",
28
+ ".js",
29
+ ".jsx",
30
+ ".mjs",
31
+ ".cjs",
32
+ ".css",
33
+ ".scss",
34
+ ".html",
35
+ ".htm",
36
+ ".vue",
37
+ ".svelte",
38
+ ".sql",
39
+ ".sh",
40
+ ".bash",
41
+ ".zsh",
42
+ ".dockerfile",
43
+ }
44
+ )
45
+
46
+ _SKIP_DIR_NAMES: frozenset[str] = frozenset(
47
+ {
48
+ ".git",
49
+ ".remote-coder",
50
+ "__pycache__",
51
+ ".venv",
52
+ "venv",
53
+ ".tox",
54
+ "node_modules",
55
+ ".mypy_cache",
56
+ ".pytest_cache",
57
+ ".ruff_cache",
58
+ ".idea",
59
+ ".vscode",
60
+ "dist",
61
+ "build",
62
+ ".eggs",
63
+ }
64
+ )
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ProjectCodeStats:
69
+ files_scanned: int
70
+ total_lines: int
71
+ skipped_binary_or_error: int
72
+
73
+
74
+ def count_project_code(
75
+ project_root: Path,
76
+ *,
77
+ worktree_base_dir: Path | None = None,
78
+ max_files: int = 50_000,
79
+ ) -> ProjectCodeStats:
80
+ root = project_root.resolve()
81
+ wt_base = worktree_base_dir.resolve() if worktree_base_dir is not None else None
82
+
83
+ files_scanned = 0
84
+ total_lines = 0
85
+ skipped = 0
86
+
87
+ for path in root.rglob("*"):
88
+ if files_scanned >= max_files:
89
+ break
90
+ if not path.is_file():
91
+ continue
92
+ try:
93
+ rel = path.relative_to(root)
94
+ except ValueError:
95
+ continue
96
+ if _should_skip_relative(rel, root, wt_base):
97
+ continue
98
+
99
+ suf = path.suffix.lower()
100
+ if suf == "" and path.name in {"Dockerfile", "Makefile", "Justfile"}:
101
+ pass
102
+ elif suf not in _CODE_SUFFIXES:
103
+ continue
104
+
105
+ try:
106
+ data = path.read_bytes()
107
+ except OSError:
108
+ skipped += 1
109
+ continue
110
+ if b"\x00" in data[:8192]:
111
+ skipped += 1
112
+ continue
113
+ try:
114
+ text = data.decode("utf-8")
115
+ except UnicodeDecodeError:
116
+ try:
117
+ text = data.decode("utf-8", errors="replace")
118
+ except OSError:
119
+ skipped += 1
120
+ continue
121
+
122
+ line_count = 1 + text.count("\n") if text else 0
123
+ total_lines += line_count
124
+ files_scanned += 1
125
+
126
+ return ProjectCodeStats(
127
+ files_scanned=files_scanned,
128
+ total_lines=total_lines,
129
+ skipped_binary_or_error=skipped,
130
+ )
131
+
132
+
133
+ def format_code_monitor(stats: ProjectCodeStats, project_name: str, root: Path) -> str:
134
+ return "\n".join(
135
+ [
136
+ "Code size (estimated)",
137
+ f"Project: {project_name}",
138
+ f"root: {root}",
139
+ f"Code files scanned: {stats.files_scanned}",
140
+ f"Total lines (approx): {stats.total_lines}",
141
+ f"Skipped (binary/read errors): {stats.skipped_binary_or_error}",
142
+ "",
143
+ "Note: only extension-based text files are included. Large repositories may be partially counted when limits are reached.",
144
+ ]
145
+ )
146
+
147
+
148
+ def _should_skip_relative(rel: Path, root: Path, wt_base: Path | None) -> bool:
149
+ for p in rel.parts[:-1]:
150
+ if p in _SKIP_DIR_NAMES:
151
+ return True
152
+ if p.endswith(".egg-info"):
153
+ return True
154
+ if wt_base is None:
155
+ return False
156
+ try:
157
+ candidate = (root / rel).resolve()
158
+ candidate.relative_to(wt_base)
159
+ return True
160
+ except ValueError:
161
+ return False
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from app.monitoring.log_buffer import LOG_RECORD_CONTEXT_KEYS
7
+
8
+
9
+ class EventLogger:
10
+ def __init__(self, logger_name: str, category: str) -> None:
11
+ self._logger = logging.getLogger(logger_name)
12
+ self._category = category
13
+
14
+ def _extra(self, context: dict[str, Any]) -> dict[str, Any]:
15
+ extra: dict[str, Any] = {"category": self._category}
16
+ for k in LOG_RECORD_CONTEXT_KEYS:
17
+ if k == "category":
18
+ continue
19
+ if k in context and context[k] is not None:
20
+ extra[k] = context[k]
21
+ return extra
22
+
23
+ def info(self, message: str, *args: Any, **context: Any) -> None:
24
+ self._logger.info(message, *args, extra=self._extra(context))
25
+
26
+ def warning(self, message: str, *args: Any, **context: Any) -> None:
27
+ self._logger.warning(message, *args, extra=self._extra(context))
28
+
29
+ def error(self, message: str, *args: Any, **context: Any) -> None:
30
+ self._logger.error(message, *args, extra=self._extra(context))
31
+
32
+ def exception(self, message: str, *args: Any, **context: Any) -> None:
33
+ self._logger.exception(message, *args, extra=self._extra(context))
app/monitoring/git.py ADDED
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from app.git.service import GitWorktreeService
6
+
7
+
8
+ TELEGRAM_SAFE_LEN = 3800
9
+
10
+
11
+ def format_branch_monitor(
12
+ git: GitWorktreeService,
13
+ root: Path,
14
+ remote: str,
15
+ project_name: str,
16
+ max_len: int = TELEGRAM_SAFE_LEN,
17
+ ) -> str:
18
+ try:
19
+ current = git.get_current_branch(root)
20
+ local_n = git.count_local_branches(root)
21
+ remote_n = git.count_remote_branches_for_remote(root, remote)
22
+ local_block = git.format_local_branches(root)
23
+ remote_block = git.format_remote_branches_for_remote(root, remote)
24
+ except RuntimeError as exc:
25
+ return f"/monitor branch failed: {exc}"
26
+
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"
35
+ )
36
+ body = f"[Local]\n{local_block}\n\n[{remote} remote]\n{remote_block}"
37
+ text = header + body
38
+ if len(text) > max_len:
39
+ text = text[:max_len].rstrip() + "\n\n...(truncated for message length)"
40
+ return text
41
+
42
+
43
+ def format_worktree_monitor(
44
+ git: GitWorktreeService,
45
+ project_path: Path,
46
+ worktree_base_dir: Path,
47
+ project_name: str,
48
+ branch_prefix: str = "remote-",
49
+ max_detail: int = 40,
50
+ ) -> str:
51
+ try:
52
+ entries = git.list_worktree_entries(project_path)
53
+ except RuntimeError as exc:
54
+ return f"/monitor worktrees failed: {exc}"
55
+
56
+ root = project_path.resolve()
57
+ managed_base = worktree_base_dir.resolve()
58
+ rebase_ops_base = (worktree_base_dir / "_rebase_ops").resolve()
59
+
60
+ detached_n = 0
61
+ managed_n = 0
62
+ detail_lines: list[str] = []
63
+
64
+ for wt_path, branch in entries:
65
+ resolved = wt_path.resolve()
66
+ is_root = resolved == root
67
+ branch_matches = branch is not None and branch.startswith(branch_prefix)
68
+ under_managed = GitWorktreeService._is_within(resolved, managed_base)
69
+ under_rebase = GitWorktreeService._is_within(resolved, rebase_ops_base)
70
+ is_managed = (not is_root) and (branch_matches or under_managed or under_rebase)
71
+ if branch is None:
72
+ detached_n += 1
73
+ if is_managed:
74
+ managed_n += 1
75
+
76
+ if len(detail_lines) < max_detail:
77
+ b_label = branch if branch is not None else "(detached)"
78
+ tags: list[str] = []
79
+ if is_root:
80
+ tags.append("main worktree")
81
+ if is_managed:
82
+ tags.append("managed")
83
+ tag_s = f" [{' '.join(tags)}]" if tags else ""
84
+ detail_lines.append(f"- {wt_path} → {b_label}{tag_s}")
85
+
86
+ extra = ""
87
+ if len(entries) > max_detail:
88
+ extra = f"\n...({len(entries) - max_detail} more omitted)"
89
+
90
+ lines = [
91
+ "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}",
98
+ "",
99
+ "[Entries]",
100
+ *detail_lines,
101
+ extra,
102
+ ]
103
+ return "\n".join(lines).strip()