gdmcode 0.1.0__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 (131) hide show
  1. gdmcode-0.1.0.dist-info/METADATA +240 -0
  2. gdmcode-0.1.0.dist-info/RECORD +131 -0
  3. gdmcode-0.1.0.dist-info/WHEEL +4 -0
  4. gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. src/__init__.py +1 -0
  6. src/_internal/__init__.py +0 -0
  7. src/_internal/constants.py +244 -0
  8. src/_internal/domain_skills.py +339 -0
  9. src/agent/__init__.py +0 -0
  10. src/agent/commit_classifier.py +91 -0
  11. src/agent/context_budget.py +391 -0
  12. src/agent/daemon.py +681 -0
  13. src/agent/dag_validator.py +153 -0
  14. src/agent/debug_loop.py +473 -0
  15. src/agent/impact_analyzer.py +149 -0
  16. src/agent/impact_graph.py +117 -0
  17. src/agent/loop.py +1410 -0
  18. src/agent/orchestrator.py +141 -0
  19. src/agent/regression_guard.py +251 -0
  20. src/agent/review_gate.py +648 -0
  21. src/agent/risk_scorer.py +169 -0
  22. src/agent/self_healing.py +145 -0
  23. src/agent/smart_test_selector.py +89 -0
  24. src/agent/system_prompt.py +226 -0
  25. src/agent/task_tracker.py +320 -0
  26. src/agent/test_validator.py +210 -0
  27. src/agent/tool_orchestrator.py +402 -0
  28. src/agent/transcript.py +230 -0
  29. src/agent/verification_loop.py +133 -0
  30. src/agent/work_director.py +136 -0
  31. src/agent/worktree_manager.py +53 -0
  32. src/artifacts/__init__.py +16 -0
  33. src/artifacts/artifact_store.py +456 -0
  34. src/artifacts/verification_graph.py +75 -0
  35. src/auth.py +411 -0
  36. src/cli.py +1290 -0
  37. src/commands.py +1398 -0
  38. src/config.py +762 -0
  39. src/cost_tracker.py +348 -0
  40. src/db/__init__.py +4 -0
  41. src/db/migrations.py +337 -0
  42. src/enterprise/__init__.py +3 -0
  43. src/enterprise/audit_log.py +182 -0
  44. src/enterprise/identity.py +90 -0
  45. src/enterprise/rbac.py +100 -0
  46. src/enterprise/team_config.py +125 -0
  47. src/enterprise/usage_analytics.py +261 -0
  48. src/exceptions.py +207 -0
  49. src/git_workflow.py +651 -0
  50. src/integrations/__init__.py +6 -0
  51. src/integrations/github_actions.py +106 -0
  52. src/integrations/mcp_server.py +333 -0
  53. src/integrations/sentry_integration.py +100 -0
  54. src/integrations/sentry_server.py +82 -0
  55. src/integrations/webhook_security.py +19 -0
  56. src/main.py +27 -0
  57. src/memory/__init__.py +0 -0
  58. src/memory/code_index.py +376 -0
  59. src/memory/compressor.py +378 -0
  60. src/memory/context_memory.py +135 -0
  61. src/memory/continuous_memory.py +234 -0
  62. src/memory/conventions.py +495 -0
  63. src/memory/db.py +1119 -0
  64. src/memory/document_index.py +205 -0
  65. src/memory/file_cache.py +128 -0
  66. src/memory/project_scanner.py +178 -0
  67. src/memory/session_store.py +201 -0
  68. src/models/__init__.py +0 -0
  69. src/models/client.py +715 -0
  70. src/models/definitions.py +459 -0
  71. src/models/router.py +418 -0
  72. src/models/schemas.py +389 -0
  73. src/permissions.py +294 -0
  74. src/remote/__init__.py +5 -0
  75. src/remote/command_filter.py +33 -0
  76. src/remote/models.py +31 -0
  77. src/remote/permission_handler.py +79 -0
  78. src/remote/phone_ui.py +48 -0
  79. src/remote/protocol.py +59 -0
  80. src/remote/qr.py +65 -0
  81. src/remote/server.py +586 -0
  82. src/remote/token_manager.py +61 -0
  83. src/remote/tunnel.py +212 -0
  84. src/repl.py +475 -0
  85. src/runtime/__init__.py +1 -0
  86. src/runtime/branch_farm.py +372 -0
  87. src/runtime/replay.py +351 -0
  88. src/sandbox/__init__.py +2 -0
  89. src/sandbox/hermetic.py +214 -0
  90. src/sandbox/policy.py +44 -0
  91. src/sdk/__init__.py +3 -0
  92. src/sdk/plugin_base.py +39 -0
  93. src/sdk/plugin_host.py +100 -0
  94. src/sdk/plugin_loader.py +101 -0
  95. src/security.py +409 -0
  96. src/server/__init__.py +7 -0
  97. src/server/bridge.py +427 -0
  98. src/server/bridge_cli.py +103 -0
  99. src/server/bridge_client.py +170 -0
  100. src/server/protocol_version.py +103 -0
  101. src/session/__init__.py +10 -0
  102. src/session/event_fanout.py +46 -0
  103. src/session/input_broker.py +38 -0
  104. src/session/permission_bridge.py +100 -0
  105. src/tools/__init__.py +160 -0
  106. src/tools/_atomic.py +72 -0
  107. src/tools/agent_tools.py +423 -0
  108. src/tools/ask_user_tool.py +83 -0
  109. src/tools/bash_tool.py +384 -0
  110. src/tools/browser_tool.py +352 -0
  111. src/tools/browser_tools.py +179 -0
  112. src/tools/dep_tools.py +210 -0
  113. src/tools/document_reader.py +167 -0
  114. src/tools/document_tool.py +240 -0
  115. src/tools/document_writer.py +171 -0
  116. src/tools/impact_tools.py +240 -0
  117. src/tools/playwright_tool.py +172 -0
  118. src/tools/quality_tools.py +366 -0
  119. src/tools/read_tools.py +318 -0
  120. src/tools/result_cache.py +157 -0
  121. src/tools/search_tools.py +310 -0
  122. src/tools/shell_tools.py +311 -0
  123. src/tools/write_tools.py +337 -0
  124. src/voice/__init__.py +25 -0
  125. src/voice/audio_capture.py +92 -0
  126. src/voice/audio_playback.py +68 -0
  127. src/voice/errors.py +14 -0
  128. src/voice/models.py +35 -0
  129. src/voice/providers.py +143 -0
  130. src/voice/vad.py +55 -0
  131. src/voice/voice_loop.py +156 -0
src/remote/tunnel.py ADDED
@@ -0,0 +1,212 @@
1
+ """TunnelManager — multi-provider tunnel support with crash recovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import socket
7
+ import subprocess
8
+ import threading
9
+ import time
10
+ from typing import Callable
11
+
12
+
13
+ _DEFAULT_BACKOFF = [5.0, 15.0, 30.0]
14
+
15
+ _PROVIDERS: list[dict] = [
16
+ {
17
+ "name": "cloudflared",
18
+ "cmd": lambda port: ["cloudflared", "tunnel", "--url", f"http://localhost:{port}"],
19
+ "url_marker": "trycloudflare.com",
20
+ },
21
+ {
22
+ "name": "ngrok",
23
+ "cmd": lambda port: ["ngrok", "http", str(port), "--log=stdout", "--log-format=json"],
24
+ "url_marker": "ngrok",
25
+ },
26
+ {
27
+ "name": "localtunnel",
28
+ "cmd": lambda port: ["lt", "--port", str(port)],
29
+ "url_marker": "loca.lt",
30
+ },
31
+ ]
32
+
33
+
34
+ def _lan_ip() -> str:
35
+ """Return best-guess LAN IP without sending actual traffic."""
36
+ try:
37
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
38
+ s.connect(("8.8.8.8", 80))
39
+ ip = s.getsockname()[0]
40
+ s.close()
41
+ return ip
42
+ except OSError:
43
+ return "127.0.0.1"
44
+
45
+
46
+ class TunnelError(RuntimeError):
47
+ """Raised when no tunnel provider is available."""
48
+
49
+
50
+ class TunnelManager:
51
+ """Start/stop/monitor a public tunnel to *port*.
52
+
53
+ Provider priority: cloudflared → ngrok → localtunnel → LAN.
54
+ Crash recovery is implemented by a background monitor thread that
55
+ restarts the tunnel with exponential back-off on unexpected exit.
56
+
57
+ Parameters
58
+ ----------
59
+ port:
60
+ Local port to expose.
61
+ providers:
62
+ Override the provider list (for testing).
63
+ backoff_delays:
64
+ Seconds to wait between retry attempts (injectable for tests).
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ port: int = 8765,
70
+ providers: list[dict] | None = None,
71
+ backoff_delays: list[float] | None = None,
72
+ ) -> None:
73
+ self._port = port
74
+ self._providers = providers if providers is not None else list(_PROVIDERS)
75
+ self._backoff_delays: list[float] = (
76
+ backoff_delays if backoff_delays is not None else _DEFAULT_BACKOFF
77
+ )
78
+ self._url: str | None = None
79
+ self._provider_name: str | None = None
80
+ self._proc: subprocess.Popen | None = None
81
+ self._lock = threading.Lock()
82
+ self._stop_event = threading.Event()
83
+ self._monitor_thread: threading.Thread | None = None
84
+
85
+ # ------------------------------------------------------------------
86
+ # Public API
87
+ # ------------------------------------------------------------------
88
+
89
+ def start(self) -> str:
90
+ """Start the tunnel and return the public URL."""
91
+ with self._lock:
92
+ if self._url:
93
+ return self._url
94
+ url, name = self._try_start_with_retry()
95
+ self._url = url
96
+ self._provider_name = name
97
+ self._stop_event.clear()
98
+ self._monitor_thread = threading.Thread(
99
+ target=self._monitor_loop, daemon=True, name="gdm-tunnel-monitor"
100
+ )
101
+ self._monitor_thread.start()
102
+ return url
103
+
104
+ def stop(self) -> None:
105
+ """Stop the tunnel and the monitor thread."""
106
+ self._stop_event.set()
107
+ with self._lock:
108
+ self._kill_proc()
109
+ self._url = None
110
+ self._provider_name = None
111
+
112
+ def status(self) -> dict:
113
+ """Return current tunnel status dict."""
114
+ with self._lock:
115
+ return {
116
+ "running": self._url is not None,
117
+ "url": self._url,
118
+ "provider": self._provider_name,
119
+ "port": self._port,
120
+ }
121
+
122
+ # ------------------------------------------------------------------
123
+ # Internal helpers
124
+ # ------------------------------------------------------------------
125
+
126
+ def _lan_fallback(self) -> tuple[str, str]:
127
+ ip = _lan_ip()
128
+ return f"http://{ip}:{self._port}", "lan"
129
+
130
+ def _try_provider(self, provider: dict) -> tuple[str, str] | None:
131
+ """Try to start *provider*; return (url, name) or None."""
132
+ try:
133
+ cmd = provider["cmd"](self._port)
134
+ proc = subprocess.Popen(
135
+ cmd,
136
+ stdout=subprocess.PIPE,
137
+ stderr=subprocess.STDOUT,
138
+ text=True,
139
+ )
140
+ marker = provider["url_marker"]
141
+ # Read stdout until we see the URL or the process exits
142
+ for _ in range(60):
143
+ if proc.poll() is not None:
144
+ return None
145
+ assert proc.stdout is not None
146
+ line = proc.stdout.readline()
147
+ if not line:
148
+ time.sleep(0.5)
149
+ continue
150
+ if marker in line:
151
+ # Extract the URL from the line
152
+ for token in line.split():
153
+ if marker in token:
154
+ url = token.strip().rstrip(",")
155
+ if not url.startswith("http"):
156
+ url = "https://" + url
157
+ self._proc = proc
158
+ return url, provider["name"]
159
+ proc.kill()
160
+ return None
161
+ except FileNotFoundError:
162
+ return None
163
+ except Exception:
164
+ return None
165
+
166
+ def _try_start_with_retry(self) -> tuple[str, str]:
167
+ """Try all providers; retry with back-off; fall back to LAN."""
168
+ delays = list(self._backoff_delays)
169
+
170
+ for delay in delays:
171
+ for provider in self._providers:
172
+ result = self._try_provider(provider)
173
+ if result:
174
+ return result
175
+ time.sleep(delay)
176
+
177
+ # Final attempt before LAN fallback
178
+ for provider in self._providers:
179
+ result = self._try_provider(provider)
180
+ if result:
181
+ return result
182
+
183
+ return self._lan_fallback()
184
+
185
+ def _kill_proc(self) -> None:
186
+ if self._proc and self._proc.poll() is None:
187
+ try:
188
+ self._proc.kill()
189
+ self._proc.wait(timeout=5)
190
+ except Exception:
191
+ pass
192
+ self._proc = None
193
+
194
+ def _monitor_loop(self) -> None:
195
+ """Watch the subprocess; restart if it dies unexpectedly."""
196
+ while not self._stop_event.is_set():
197
+ time.sleep(1)
198
+ with self._lock:
199
+ if self._stop_event.is_set():
200
+ break
201
+ if self._proc is None:
202
+ continue
203
+ if self._proc.poll() is not None:
204
+ # Process died unexpectedly — restart
205
+ self._proc = None
206
+ try:
207
+ url, name = self._try_start_with_retry()
208
+ self._url = url
209
+ self._provider_name = name
210
+ except Exception:
211
+ self._url = None
212
+ self._provider_name = None
src/repl.py ADDED
@@ -0,0 +1,475 @@
1
+ """gdm REPL -- interactive coding agent loop.
2
+
3
+ Uses prompt_toolkit for rich input (history, editing) with a plain
4
+ input() fallback when prompt_toolkit is unavailable.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import importlib
9
+ import importlib.util
10
+ import logging
11
+ import sys
12
+ import uuid
13
+ from datetime import date, datetime, timezone
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING
16
+
17
+ from rich.console import Console
18
+ from rich.status import Status
19
+
20
+ if TYPE_CHECKING:
21
+ from src.config import GdmConfig
22
+ from src.memory.db import GdmDatabase
23
+
24
+ __all__ = ["start_repl"]
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+ _HISTORY_FILENAME = ".context-memory/gdm_history"
29
+ _PROMPT_HTML = "<ansiCyan>gdm</ansiCyan><ansiwhite> > </ansiwhite>"
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Session bootstrap
34
+ # ---------------------------------------------------------------------------
35
+
36
+ def _ensure_session(db: "GdmDatabase", project_root: Path) -> str:
37
+ """Upsert project + session rows so FK constraints are satisfied.
38
+
39
+ Uses a UUID-5 of the project root as a stable project_id, then creates
40
+ a fresh session row. Errors are logged but not re-raised.
41
+ """
42
+ project_id = str(uuid.uuid5(uuid.NAMESPACE_URL, str(project_root)))
43
+ session_id = str(uuid.uuid4())
44
+ now = datetime.now(timezone.utc).isoformat()
45
+ try:
46
+ db.execute(
47
+ "INSERT INTO projects (project_id, root_path, name) VALUES (?, ?, ?)"
48
+ ' ON CONFLICT(project_id) DO UPDATE SET last_seen = datetime("now")',
49
+ (project_id, str(project_root), project_root.name),
50
+ )
51
+ db.execute(
52
+ "INSERT INTO sessions (session_id, project_id, created_at, updated_at)"
53
+ " VALUES (?, ?, ?, ?)",
54
+ (session_id, project_id, now, now),
55
+ )
56
+ except Exception as exc: # noqa: BLE001
57
+ log.warning("Session DB init failed (%s) -- btw_queue will be unavailable", exc)
58
+ return session_id
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Pending /btw notes display
63
+ # ---------------------------------------------------------------------------
64
+
65
+ def _show_pending_btw(db: "GdmDatabase", session_id: str, console: Console) -> list[str]:
66
+ """Print and mark-read pending /btw notes. Returns message strings."""
67
+ try:
68
+ rows = db.execute_all(
69
+ "SELECT id, message FROM btw_queue"
70
+ " WHERE session_id = ? AND read_at IS NULL ORDER BY created_at",
71
+ (session_id,),
72
+ )
73
+ except Exception: # noqa: BLE001
74
+ return []
75
+ if not rows:
76
+ return []
77
+ messages: list[str] = []
78
+ now = datetime.now(timezone.utc).isoformat()
79
+ for row in rows:
80
+ console.print(f"[dim yellow]note:[/dim yellow] {row['message']}")
81
+ messages.append(row["message"])
82
+ try:
83
+ db.execute("UPDATE btw_queue SET read_at = ? WHERE id = ?", (now, row["id"]))
84
+ except Exception: # noqa: BLE001
85
+ pass
86
+ return messages
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Spinner verb selection
91
+ # ---------------------------------------------------------------------------
92
+
93
+ def _pick_spinner_verb(db: "GdmDatabase", session_id: str) -> str: # noqa: ARG001
94
+ """Return a weighted-random spinner verb."""
95
+ import random
96
+ from src._internal.constants import _ALL_SPINNER_VERBS, _PRIORITY_VERBS
97
+
98
+ today = date.today().isoformat()
99
+ seen_today = db.spinner_state_get(today)
100
+
101
+ weights: list[int] = []
102
+ for verb in _ALL_SPINNER_VERBS:
103
+ if verb in _PRIORITY_VERBS:
104
+ weights.append(30 if verb not in seen_today else 15)
105
+ else:
106
+ weights.append(1)
107
+
108
+ chosen = random.choices(list(_ALL_SPINNER_VERBS), weights=weights, k=1)[0]
109
+ if chosen in _PRIORITY_VERBS:
110
+ db.spinner_state_mark_seen(today, chosen)
111
+ return chosen
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Event rendering
116
+ # ---------------------------------------------------------------------------
117
+
118
+ def _fmt_args(args: dict) -> str: # type: ignore[type-arg]
119
+ """Format a tool-args dict for single-line display, truncating long values."""
120
+ parts = []
121
+ for k, v in list(args.items())[:3]:
122
+ sv = str(v)
123
+ sv = sv[:40] + "..." if len(sv) > 40 else sv
124
+ parts.append(f"{k}={sv!r}")
125
+ return ", ".join(parts)
126
+
127
+
128
+ def _render_event(event: object, status: Status, console: Console) -> None:
129
+ """Render one AgentEvent to the terminal while the spinner is live."""
130
+ from src.agent.loop import EventType # lazy
131
+
132
+ ev = event # type: ignore[assignment]
133
+ match ev.type: # type: ignore[union-attr]
134
+ case EventType.THINKING:
135
+ snippet = str(ev.content or "")[:100] # type: ignore[union-attr]
136
+ label = f"[dim cyan]Thinking... {snippet}[/dim cyan]"
137
+ status.update(label)
138
+ case EventType.TOOL_CALL:
139
+ console.print(f"[yellow][tool] {ev.tool_name}({_fmt_args(ev.args)})[/yellow]") # type: ignore[union-attr]
140
+ case EventType.TOOL_RESULT:
141
+ result_str = str(ev.result or "")[:80] # type: ignore[union-attr]
142
+ console.print(f"[dim green] -> {result_str}[/dim green]")
143
+ case EventType.RESPONSE:
144
+ status.stop()
145
+ console.print(str(ev.content or "")) # type: ignore[union-attr]
146
+ case EventType.ERROR:
147
+ status.stop()
148
+ console.print(f"[red]Error: {ev.content}[/red]") # type: ignore[union-attr]
149
+ case EventType.COST_UPDATE:
150
+ console.print(f"[dim] [${ev.cost_usd:.5f} | turn {ev.turn}][/dim]") # type: ignore[union-attr]
151
+ case EventType.DONE:
152
+ status.stop()
153
+ console.print("[green]Done[/green]")
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Agent turn runner
158
+ # ---------------------------------------------------------------------------
159
+
160
+ def _run_agent_turn(
161
+ loop: object,
162
+ user_message: str,
163
+ console: Console,
164
+ db: "GdmDatabase",
165
+ session_id: str,
166
+ ) -> None:
167
+ """Run one agent turn, streaming events with a spinner."""
168
+ try:
169
+ from src.agent.loop import AgentLoop # noqa: F401
170
+ except (ImportError, AttributeError):
171
+ console.print("[yellow]Agent loop not yet implemented.[/yellow]")
172
+ return
173
+
174
+ verb = _pick_spinner_verb(db, session_id)
175
+ status = Status(f"[cyan]{verb}...[/cyan]", console=console, spinner="dots")
176
+ status.start()
177
+ try:
178
+ for event in loop.run(user_message): # type: ignore[union-attr]
179
+ _render_event(event, status, console)
180
+ except Exception as exc: # noqa: BLE001
181
+ status.stop()
182
+ console.print(f"[red]Agent error: {exc}[/red]")
183
+ log.exception("Agent turn failed")
184
+ finally:
185
+ status.stop()
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Input helper
190
+ # ---------------------------------------------------------------------------
191
+
192
+ def _build_input_fn(history_path: Path) -> object:
193
+ """Return a callable() -> str for reading user input.
194
+
195
+ Uses prompt_toolkit when available, falls back to built-in input().
196
+ """
197
+ if importlib.util.find_spec("prompt_toolkit") is not None:
198
+ from prompt_toolkit import PromptSession
199
+ from prompt_toolkit.formatted_text import HTML
200
+ from prompt_toolkit.history import FileHistory
201
+ import re as _re
202
+
203
+ _SENSITIVE_RE = _re.compile(r"^\s*/proxy\s+token\s+\S", _re.IGNORECASE)
204
+
205
+ class _SafeHistory(FileHistory):
206
+ """FileHistory that never persists sensitive /proxy token <secret> entries."""
207
+ def append_string(self, string: str) -> None:
208
+ if _SENSITIVE_RE.match(string):
209
+ return # drop silently — token must not reach disk
210
+ super().append_string(string)
211
+
212
+ pt_session: PromptSession[str] = PromptSession(
213
+ history=_SafeHistory(str(history_path)),
214
+ )
215
+
216
+ def _pt_input() -> str:
217
+ return pt_session.prompt(HTML(_PROMPT_HTML))
218
+
219
+ return _pt_input
220
+
221
+ def _builtin_input() -> str:
222
+ return input("gdm > ")
223
+
224
+ return _builtin_input
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # Public entry point
229
+ # ---------------------------------------------------------------------------
230
+
231
+ def start_repl(cfg: "GdmConfig", db: "GdmDatabase", *, yes: bool = False, model_override: str | None = None) -> None:
232
+ """Start the interactive gdm REPL.
233
+
234
+ Bootstraps session, builds the agent loop (or falls back gracefully),
235
+ then enters the prompt/dispatch loop until the user exits.
236
+ """
237
+ from src.commands import CommandDispatcher
238
+ from src.cost_tracker import CostTracker
239
+
240
+ console = Console()
241
+ project_root = cfg.project_root.resolve()
242
+
243
+ session_id = _ensure_session(db, project_root)
244
+
245
+ history_path = project_root / _HISTORY_FILENAME
246
+ history_path.parent.mkdir(parents=True, exist_ok=True)
247
+ read_input = _build_input_fn(history_path)
248
+
249
+ cost_tracker = CostTracker(session_id=session_id, provider=cfg.provider)
250
+ dispatcher = CommandDispatcher(
251
+ session_id,
252
+ db,
253
+ cost_tracker,
254
+ current_tier="coder",
255
+ provider=cfg.provider,
256
+ console=console,
257
+ project_root=project_root,
258
+ confirm_fn=read_input,
259
+ cfg=cfg,
260
+ )
261
+
262
+ loop: object = None
263
+ permissions: object = None
264
+ try:
265
+ from src.agent.context_budget import ContextBudget
266
+ from src.agent.loop import AgentLoop
267
+ from src.agent.tool_orchestrator import ToolOrchestrator
268
+ from src.agent.transcript import TranscriptStore
269
+ from src.models.definitions import ModelTier, get_model
270
+ from src.models.router import ModelRouter
271
+ from src.permissions import PermissionContext
272
+
273
+ tier = model_override or ModelTier.CODER
274
+ model_def = get_model(tier, cfg.provider)
275
+ _project_id = str(uuid.uuid5(uuid.NAMESPACE_URL, str(project_root)))
276
+ transcript = TranscriptStore(max_tokens=model_def.context_window)
277
+ budget = ContextBudget(model_context_window=model_def.context_window)
278
+ permissions = PermissionContext(db=db, session_id=session_id, non_interactive=False)
279
+ if yes:
280
+ permissions.allow_all_session()
281
+ router = ModelRouter()
282
+ orchestrator = ToolOrchestrator(
283
+ permissions=permissions, db=db, session_id=session_id, project_id=_project_id
284
+ )
285
+ loop = AgentLoop(
286
+ cfg=cfg,
287
+ orchestrator=orchestrator,
288
+ transcript=transcript,
289
+ budget=budget,
290
+ cost_tracker=cost_tracker,
291
+ model_tier=tier,
292
+ router=router,
293
+ db=db,
294
+ session_id=session_id,
295
+ project_id=_project_id,
296
+ )
297
+ except (ImportError, AttributeError, TypeError) as exc:
298
+ log.info("AgentLoop unavailable: %s", exc)
299
+
300
+ # Graceful shutdown: threading.Event flag pattern — no I/O in signal handlers.
301
+ import atexit
302
+ import signal as _sig
303
+ import threading
304
+
305
+ _shutdown_event: threading.Event = threading.Event()
306
+ _flushed: threading.Event = threading.Event()
307
+
308
+ def _flush() -> None:
309
+ if _flushed.is_set():
310
+ return
311
+ _flushed.set()
312
+ if loop is not None:
313
+ try:
314
+ loop._flush_checkpoint_sync() # type: ignore[union-attr]
315
+ except Exception: # noqa: BLE001
316
+ pass
317
+
318
+ def _handle_sigterm(*_: object) -> None:
319
+ """SIGTERM handler — sets shutdown flag only. No I/O here."""
320
+ _shutdown_event.set()
321
+
322
+ def _handle_sigint(sig: int, frame: object) -> None:
323
+ """SIGINT handler — first Ctrl+C requests shutdown; second forces exit."""
324
+ if not _shutdown_event.is_set():
325
+ _shutdown_event.set()
326
+ else:
327
+ _flush()
328
+ sys.exit(130)
329
+
330
+ atexit.register(_flush)
331
+ try:
332
+ _sig.signal(_sig.SIGTERM, _handle_sigterm)
333
+ _sig.signal(_sig.SIGINT, _handle_sigint)
334
+ if sys.platform == "win32":
335
+ _sig.signal(_sig.SIGBREAK, _handle_sigterm) # type: ignore[attr-defined]
336
+ except (OSError, ValueError):
337
+ pass # Not in main thread or platform limitation
338
+
339
+ # Startup: check for resumable active sessions from previous runs
340
+ if loop is not None:
341
+ try:
342
+ incomplete = db.list_incomplete_sessions()
343
+ # Exclude the session we just created (it has no turns yet)
344
+ incomplete = [s for s in incomplete if s["session_id"] != session_id]
345
+ if incomplete:
346
+ sess = incomplete[0]
347
+ sid_short = sess["session_id"][:8]
348
+ turns = sess["turn_count"] or 0
349
+ started = sess["updated_at"] or sess["created_at"] or ""
350
+ console.print(
351
+ f"[yellow]⚡ Session {sid_short}... was in progress "
352
+ f"({turns} turn(s), last active: {started[:16].replace('T', ' ')})."
353
+ " Resume? [Y/n][/yellow]"
354
+ )
355
+ try:
356
+ ans = input("").strip().lower()
357
+ if not ans or ans.startswith("y"):
358
+ n = loop.restore_from_db(sess["session_id"]) # type: ignore[union-attr]
359
+ if n:
360
+ console.print(f"[green]✓ Restored {n} turns from previous session.[/green]")
361
+ else:
362
+ console.print("[dim]No checkpoint data found; starting fresh.[/dim]")
363
+ except (EOFError, KeyboardInterrupt):
364
+ pass
365
+ except Exception: # noqa: BLE001
366
+ pass
367
+
368
+ # Startup hints from ContinuousMemory (non-blocking, max 3 seconds)
369
+ if not getattr(cfg, "gdm_quiet", False):
370
+ try:
371
+ from src.memory.continuous_memory import ContinuousMemory as _CM
372
+ _cm_path = str(project_root / ".gdm" / "memory.db")
373
+ _cm = _CM(db_path=_cm_path)
374
+ _cm_project_id = str(uuid.uuid5(uuid.NAMESPACE_URL, str(project_root)))
375
+ _hints = _cm.get_session_hints([], _cm_project_id)
376
+ if _hints:
377
+ console.print("[dim]hints (press Enter to skip):[/dim]")
378
+ for _h in _hints:
379
+ console.print(f"[dim] * {_h}[/dim]")
380
+ _skip_event: threading.Event = threading.Event()
381
+
382
+ def _wait_skip() -> None:
383
+ try:
384
+ input()
385
+ _skip_event.set()
386
+ except Exception: # noqa: BLE001
387
+ pass
388
+
389
+ threading.Thread(target=_wait_skip, daemon=True).start()
390
+ _skip_event.wait(3.0)
391
+ except Exception: # noqa: BLE001
392
+ pass
393
+
394
+ console.print("[bold green]gdm[/bold green] [dim]ready -- type /help for commands, /exit to quit[/dim]")
395
+
396
+ while True:
397
+ try:
398
+ raw = read_input() # type: ignore[call-arg]
399
+ except (EOFError, KeyboardInterrupt):
400
+ console.print("\n[dim]Bye.[/dim]")
401
+ break
402
+
403
+ text = raw.strip()
404
+ if not text:
405
+ continue
406
+
407
+ if dispatcher.is_command(text):
408
+ result = dispatcher.handle(text)
409
+ if result.output:
410
+ console.print(result.output)
411
+ if result.prompt_secret:
412
+ # Token must be collected via hidden input — never stored in history
413
+ try:
414
+ if importlib.util.find_spec("prompt_toolkit") is not None:
415
+ from prompt_toolkit.shortcuts import prompt as _pt_prompt
416
+ _secret = _pt_prompt(result.prompt_secret, is_password=True)
417
+ else:
418
+ import getpass as _gp
419
+ _secret = _gp.getpass(result.prompt_secret)
420
+ except (EOFError, KeyboardInterrupt):
421
+ _secret = ""
422
+ sub = dispatcher.apply_proxy_token(_secret)
423
+ if sub.output:
424
+ console.print(sub.output)
425
+ if result.should_exit:
426
+ console.print("[dim]Bye.[/dim]")
427
+ break
428
+ if result.allow_all and permissions is not None:
429
+ try:
430
+ permissions.allow_all_session() # type: ignore[union-attr]
431
+ except AttributeError:
432
+ pass
433
+ if result.force_compact and loop is not None:
434
+ try:
435
+ n = loop._maybe_compress() # type: ignore[union-attr]
436
+ console.print(f"[dim]Compacted {n} turn(s).[/dim]")
437
+ except AttributeError:
438
+ pass
439
+ if result.new_model_tier and loop is not None:
440
+ try:
441
+ loop.set_tier(result.new_model_tier) # type: ignore[union-attr]
442
+ except AttributeError:
443
+ pass
444
+ if result.proxy_action == "enable" and loop is not None and result.proxy_url and result.proxy_token:
445
+ try:
446
+ loop.set_proxy(result.proxy_url, result.proxy_token) # type: ignore[union-attr]
447
+ except AttributeError:
448
+ pass
449
+ elif result.proxy_action == "disable" and loop is not None:
450
+ try:
451
+ loop.clear_proxy() # type: ignore[union-attr]
452
+ except AttributeError:
453
+ pass
454
+ if result.force_resume and loop is not None:
455
+ try:
456
+ restore_id = result.resume_session_id # may be None
457
+ n = loop.restore_from_db(restore_id) # type: ignore[union-attr]
458
+ if n:
459
+ console.print(f"[green]✓ Restored {n} turns from checkpoint.[/green]")
460
+ else:
461
+ console.print("[yellow]No checkpoint found for this session.[/yellow]")
462
+ except AttributeError:
463
+ console.print("[yellow]Restore not supported by current agent.[/yellow]")
464
+ continue
465
+
466
+ pending = _show_pending_btw(db, session_id, console)
467
+ full_message = text
468
+ if pending:
469
+ notes = "\n".join(f"[context note: {m}]" for m in pending)
470
+ full_message = f"{notes}\n{text}"
471
+
472
+ if loop is None:
473
+ console.print("[yellow]Agent loop not yet implemented.[/yellow]")
474
+ else:
475
+ _run_agent_turn(loop, full_message, console, db, session_id)
@@ -0,0 +1 @@
1
+ """gdm runtime — branch farming, replay engine, and parallel evaluation."""