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/commands.py ADDED
@@ -0,0 +1,1398 @@
1
+ """Slash-command dispatcher for the gdm REPL.
2
+
3
+ Supported commands: /help /model /cost /tasks /btw /compact /clear /status
4
+ /sessions /allow-all /exit /quit /diff /undo /rollback /branch /pr /commit /browser /replay
5
+ /proxy
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import subprocess
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Callable
15
+ from urllib.parse import urlparse
16
+
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+
20
+ from src.git_workflow import GitWorkflow
21
+
22
+ if TYPE_CHECKING:
23
+ from src.cost_tracker import CostTracker
24
+ from src.memory.db import GdmDatabase
25
+
26
+ __all__ = ["CommandResult", "CommandDispatcher"]
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+ _VALID_TIERS: frozenset[str] = frozenset({"scout", "coder", "thinker", "reasoner", "debate"})
31
+
32
+
33
+ def _relative_time(dt_str: str | None, now: datetime) -> str:
34
+ """Return a human-readable relative time string (e.g. '5m ago', '3h ago')."""
35
+ if not dt_str:
36
+ return "unknown"
37
+ try:
38
+ dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
39
+ delta = now - dt.astimezone(timezone.utc)
40
+ secs = int(delta.total_seconds())
41
+ if secs < 0:
42
+ return "just now"
43
+ if secs < 60:
44
+ return f"{secs}s ago"
45
+ if secs < 3600:
46
+ return f"{secs // 60}m ago"
47
+ if secs < 86400:
48
+ return f"{secs // 3600}h ago"
49
+ return f"{secs // 86400}d ago"
50
+ except (ValueError, AttributeError):
51
+ return dt_str[:16] if dt_str else "unknown"
52
+
53
+ _COMMANDS: dict[str, str] = {
54
+ "/help": "Show this help message",
55
+ "/model [tier]": "Show or set model tier (scout|coder|thinker|reasoner|debate)",
56
+ "/cost": "Show session cost summary",
57
+ "/tasks": "List pending /btw notes and active tasks",
58
+ "/btw <message>": "Queue a background note for the agent",
59
+ "/compact": "Force context compression (evict old turns)",
60
+ "/clear": "Clear the terminal screen",
61
+ "/status": "Show session info (provider, model, cost, turns)",
62
+ "/sessions": "List recent sessions with ID, start time, turns, cost, status",
63
+ "/daemon [status]": "Show background daemon job queue",
64
+ "/resume [session_id]": "Restore a session transcript from checkpoint (defaults to most recent active)",
65
+ "/review": "Trigger a manual security review of recent writes",
66
+ "/allow-all": "Bypass permission prompts for this session",
67
+ "/diff": "Show git diff of pending gdm changes",
68
+ "/undo": "Undo last checkpoint commit (git reset HEAD~1)",
69
+ "/rollback": "Full rollback to pre-task state (confirms before executing)",
70
+ "/branch": "Show current task branch and checkpoint history",
71
+ "/pr": "Push task branch and generate a PR description",
72
+ "/commit [msg]": "Create a git checkpoint commit with optional message",
73
+ "/browser [start|stop|status]": "Manage the bridge server for browser automation",
74
+ "/remote [start|stop|status|qr]": "Manage remote tunnel (phone pairing via QR code)",
75
+ "/replay [session_id]": "Replay past session; compare/fork/export sub-commands available",
76
+ "/doctor": "Show resolved model IDs for every provider/tier",
77
+ "/reasoning [on|off|auto]": "Toggle per-turn reasoning tier (on=Reasoner, off=Scout, auto=classifier)",
78
+ "/artifacts [view|diff|search|export] ...": "List, view, diff, search, or export saved artifacts",
79
+ "/save [name]": "Save the last assistant response as a named artifact",
80
+ "/proxy [on|off|url <url>|token <tok>|status]": "Route LLM calls via proxy (for geo-blocked regions)",
81
+ "/exit, /quit": "Exit the REPL",
82
+ }
83
+
84
+
85
+ @dataclass
86
+ class CommandResult:
87
+ """Result returned by CommandDispatcher.handle()."""
88
+
89
+ handled: bool
90
+ output: str = ""
91
+ should_exit: bool = False
92
+ force_compact: bool = False
93
+ new_model_tier: str | None = None
94
+ allow_all: bool = False
95
+ force_resume: bool = False
96
+ resume_session_id: str | None = None
97
+ new_reasoning_mode: str | None = None
98
+ save_artifact_name: str | None = None # /save command: loop saves last assistant response
99
+ proxy_action: str | None = None # "enable" | "disable"
100
+ proxy_url: str | None = None
101
+ proxy_token: str | None = None
102
+ prompt_secret: str | None = None # if set, REPL prompts for hidden input then calls apply_proxy_token
103
+
104
+
105
+ class CommandDispatcher:
106
+ """Parses and executes /commands typed in the REPL."""
107
+
108
+ def __init__(
109
+ self,
110
+ session_id: str,
111
+ db: "GdmDatabase",
112
+ cost_tracker: "CostTracker",
113
+ *,
114
+ current_tier: str = "coder",
115
+ provider: str = "grok",
116
+ console: Console | None = None,
117
+ project_root: Path | None = None,
118
+ confirm_fn: "Callable[[], str] | None" = None,
119
+ cfg: "object | None" = None,
120
+ ) -> None:
121
+ self._session_id = session_id
122
+ self._db = db
123
+ self._cost_tracker = cost_tracker
124
+ self._current_tier = current_tier
125
+ self._provider = provider
126
+ self._console = console or Console()
127
+ self._project_root: Path = project_root or Path.cwd()
128
+ # Callable used for destructive-action confirmation (e.g. /rollback).
129
+ # None means fall back to bare input() at call time (patchable in tests).
130
+ self._confirm_fn: "Callable[[], str] | None" = confirm_fn
131
+ # Optional GdmConfig for model-assisted features (PR description, etc.)
132
+ self._cfg = cfg
133
+ # Reasoning mode — shared with AgentLoop; /reasoning command updates both
134
+ self._reasoning_mode: str = "auto"
135
+ # Proxy state — initialised from cfg; kept in sync with AgentLoop
136
+ from src.models.definitions import PROXY_DEFAULT_URL
137
+ self._proxy_enabled: bool = getattr(cfg, "proxy_enabled", False)
138
+ self._proxy_url: str = getattr(cfg, "proxy_url", None) or PROXY_DEFAULT_URL
139
+ self._proxy_token: str = getattr(cfg, "proxy_token", None) or ""
140
+
141
+ # ------------------------------------------------------------------
142
+ # Public API
143
+ # ------------------------------------------------------------------
144
+
145
+ def is_command(self, text: str) -> bool:
146
+ """Return True if *text* looks like a slash command."""
147
+ return text.strip().startswith("/")
148
+
149
+ def handle(self, text: str) -> CommandResult:
150
+ """Parse and dispatch *text* as a slash command.
151
+
152
+ Returns CommandResult(handled=False) for unknown commands so the
153
+ caller can decide what to do (e.g. show an error or pass to agent).
154
+ """
155
+ stripped = text.strip()
156
+ parts = stripped.split(None, 2)
157
+ if not parts:
158
+ return CommandResult(handled=False)
159
+ cmd = parts[0].lower()
160
+ match cmd:
161
+ case "/help":
162
+ return self._cmd_help()
163
+ case "/model":
164
+ return self._cmd_model(parts[1:])
165
+ case "/cost":
166
+ return self._cmd_cost()
167
+ case "/tasks":
168
+ return self._cmd_tasks()
169
+ case "/btw":
170
+ return self._cmd_btw(stripped[len("/btw"):].strip())
171
+ case "/compact":
172
+ return CommandResult(handled=True, force_compact=True, output="Compressing context...")
173
+ case "/clear":
174
+ self._console.clear()
175
+ return CommandResult(handled=True)
176
+ case "/status":
177
+ return self._cmd_status()
178
+ case "/daemon":
179
+ return self._cmd_daemon()
180
+ case "/sessions":
181
+ return self._cmd_sessions()
182
+ case "/resume":
183
+ return self._cmd_resume(parts[1] if len(parts) > 1 else None)
184
+ case "/review":
185
+ return CommandResult(handled=True, output="[dim]Review gate is triggered automatically after security-sensitive writes.[/dim]")
186
+ case "/allow-all":
187
+ return CommandResult(
188
+ handled=True,
189
+ allow_all=True,
190
+ output="[green]Permission checks bypassed for this session.[/green]",
191
+ )
192
+ case "/diff":
193
+ return self._cmd_diff()
194
+ case "/undo":
195
+ n = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 1
196
+ return self._cmd_undo(n=n)
197
+ case "/rollback":
198
+ checkpoint_id = parts[1] if len(parts) > 1 else None
199
+ return self._cmd_rollback(checkpoint_id=checkpoint_id)
200
+ case "/branch":
201
+ return self._cmd_branch()
202
+ case "/pr":
203
+ return self._cmd_pr()
204
+ case "/commit":
205
+ return self._cmd_commit(parts[1:])
206
+ case "/browser":
207
+ return self._cmd_browser(parts[1:])
208
+ case "/remote":
209
+ return self._cmd_remote(parts[1:])
210
+ case "/replay":
211
+ return self._cmd_replay(parts[1:])
212
+ case "/doctor":
213
+ return self._cmd_doctor()
214
+ case "/reasoning":
215
+ return self._cmd_reasoning(parts[1:])
216
+ case "/artifacts":
217
+ return self._cmd_artifacts(parts[1:])
218
+ case "/save":
219
+ return self._cmd_save(parts[1] if len(parts) > 1 else "")
220
+ case "/proxy":
221
+ return self._cmd_proxy(parts[1:])
222
+ case "/exit" | "/quit":
223
+ return CommandResult(handled=True, should_exit=True)
224
+ case _:
225
+ return CommandResult(
226
+ handled=False,
227
+ output=f"[yellow]Unknown command: {cmd!r}. Type /help for help.[/yellow]",
228
+ )
229
+
230
+ # ------------------------------------------------------------------
231
+ # Command implementations
232
+ # ------------------------------------------------------------------
233
+
234
+ def _cmd_help(self) -> CommandResult:
235
+ """Display a table of all available slash commands."""
236
+ tbl = Table(title="gdm slash commands", header_style="bold cyan", show_lines=False)
237
+ tbl.add_column("Command", style="cyan", no_wrap=True)
238
+ tbl.add_column("Description")
239
+ for cmd, desc in _COMMANDS.items():
240
+ tbl.add_row(cmd, desc)
241
+ self._console.print(tbl)
242
+ return CommandResult(handled=True)
243
+
244
+ def _cmd_model(self, args: list[str]) -> CommandResult:
245
+ """Show current model tier, or change it to *args[0]*."""
246
+ if not args:
247
+ return CommandResult(
248
+ handled=True,
249
+ output=f"Current model tier: [cyan]{self._current_tier}[/cyan]",
250
+ )
251
+ tier = args[0].lower()
252
+ if tier not in _VALID_TIERS:
253
+ valid = ", ".join(sorted(_VALID_TIERS))
254
+ return CommandResult(
255
+ handled=True,
256
+ output=f"[red]Invalid tier {tier!r}.[/red] Choose from: {valid}",
257
+ )
258
+ self._current_tier = tier
259
+ return CommandResult(handled=True, new_model_tier=tier, output=f"Model tier set to [cyan]{tier}[/cyan]")
260
+
261
+ def _cmd_cost(self) -> CommandResult:
262
+ """Display a rich table of the session cost summary."""
263
+ summary = self._cost_tracker.summary()
264
+ tbl = Table(title="Session Cost", show_header=False, min_width=38)
265
+ tbl.add_column("Key", style="dim")
266
+ tbl.add_column("Value", style="bold")
267
+ for key, val in summary.items():
268
+ display = f"${val:.6f}" if key == "total_usd" else str(val)
269
+ tbl.add_row(key.replace("_", " "), display)
270
+ self._console.print(tbl)
271
+ return CommandResult(handled=True)
272
+
273
+ def _cmd_tasks(self) -> CommandResult:
274
+ """Show unread /btw notes and pending / in-progress tasks."""
275
+ self._show_btw_notes()
276
+ self._show_active_tasks()
277
+ return CommandResult(handled=True)
278
+
279
+ def _show_btw_notes(self) -> None:
280
+ """Print unread btw_queue rows for this session."""
281
+ try:
282
+ rows = self._db.execute_all(
283
+ "SELECT id, message, created_at FROM btw_queue "
284
+ "WHERE session_id = ? AND read_at IS NULL ORDER BY created_at",
285
+ (self._session_id,),
286
+ )
287
+ except Exception as exc: # noqa: BLE001
288
+ log.warning("btw_queue read failed: %s", exc)
289
+ return
290
+ if not rows:
291
+ return
292
+ tbl = Table(title="Queued Notes (/btw)", header_style="bold yellow", show_lines=True)
293
+ tbl.add_column("#", style="dim", width=5)
294
+ tbl.add_column("Message")
295
+ tbl.add_column("Queued at", style="dim")
296
+ for row in rows:
297
+ tbl.add_row(str(row["id"]), row["message"], row["created_at"])
298
+ self._console.print(tbl)
299
+
300
+ def _show_active_tasks(self) -> None:
301
+ """Print pending/in-progress tasks from the tasks table for this session."""
302
+ try:
303
+ rows = self._db.execute_all(
304
+ "SELECT task_id, title, status FROM tasks "
305
+ "WHERE session_id = ? AND status IN ('pending','in_progress') ORDER BY status",
306
+ (self._session_id,),
307
+ )
308
+ except Exception as exc: # noqa: BLE001
309
+ log.warning("tasks read failed: %s", exc)
310
+ return
311
+ if not rows:
312
+ self._console.print("[dim]No pending tasks.[/dim]")
313
+ return
314
+ tbl = Table(title="Active Tasks", header_style="bold cyan", show_lines=True)
315
+ tbl.add_column("Status", width=12)
316
+ tbl.add_column("ID", style="dim")
317
+ tbl.add_column("Title")
318
+ for row in rows:
319
+ colour = "yellow" if row["status"] == "in_progress" else "white"
320
+ tbl.add_row(f"[{colour}]{row['status']}[/{colour}]", row["task_id"], row["title"])
321
+ self._console.print(tbl)
322
+
323
+ def _cmd_btw(self, message: str) -> CommandResult:
324
+ """Insert *message* into btw_queue for the agent to pick up later."""
325
+ if not message:
326
+ return CommandResult(handled=True, output="[yellow]Usage: /btw <message>[/yellow]")
327
+
328
+ # Length guard — prevent transcript bloat / abuse
329
+ _BTW_MAX_LEN = 500
330
+ if len(message) > _BTW_MAX_LEN:
331
+ return CommandResult(
332
+ handled=True,
333
+ output=f"[red]Message too long[/red] ({len(message)} chars, max {_BTW_MAX_LEN}).",
334
+ )
335
+
336
+ # Injection check — warn but still enqueue (user is trusted; just advisory)
337
+ from src.security import check_user_injection
338
+ inj = check_user_injection(message)
339
+ if inj.is_injected:
340
+ log.warning("btw message looks like injection attempt: %s", inj.patterns)
341
+
342
+ ts = datetime.now(timezone.utc).isoformat()
343
+ try:
344
+ self._db.execute(
345
+ "INSERT INTO btw_queue (session_id, message, created_at) VALUES (?, ?, ?)",
346
+ (self._session_id, message, ts),
347
+ )
348
+ except Exception as exc: # noqa: BLE001
349
+ log.warning("btw_queue insert failed: %s", exc)
350
+ return CommandResult(handled=True, output=f"[red]Queue failed:[/red] {exc}")
351
+ return CommandResult(handled=True, output=f"[green]Queued:[/green] {message}")
352
+
353
+ def _cmd_daemon(self) -> CommandResult:
354
+ """Show background daemon job queue status."""
355
+ try:
356
+ from src.agent.daemon import BackgroundDaemon
357
+ daemon = BackgroundDaemon(db=self._db)
358
+ pending = daemon.pending_count()
359
+ running = daemon.is_running
360
+ state = "[green]running[/green]" if running else "[dim]stopped[/dim]"
361
+ msg = f"Daemon: {state} | Pending jobs: [bold]{pending}[/bold]"
362
+ except Exception as exc: # noqa: BLE001
363
+ msg = f"[yellow]Daemon unavailable:[/yellow] {exc}"
364
+ return CommandResult(handled=True, output=msg)
365
+
366
+ def _cmd_status(self) -> CommandResult:
367
+ """Display a session status table (provider, tier, cost, turns, tokens)."""
368
+ summary = self._cost_tracker.summary()
369
+ total_tokens = (
370
+ int(summary.get("input_tokens", 0)) + int(summary.get("output_tokens", 0))
371
+ )
372
+ tbl = Table(title="Session Status", show_header=False, min_width=38)
373
+ tbl.add_column("Field", style="dim")
374
+ tbl.add_column("Value", style="bold")
375
+ tbl.add_row("Provider", self._provider)
376
+ tbl.add_row("Model tier", self._current_tier)
377
+ tbl.add_row("Session ID", self._session_id[:8] + "...")
378
+ tbl.add_row("Turns used", str(summary.get("turns", 0)))
379
+ tbl.add_row("Cost so far", f"${summary.get('total_usd', 0.0):.6f}")
380
+ tbl.add_row("Total tokens", f"{total_tokens:,}")
381
+ self._console.print(tbl)
382
+ return CommandResult(handled=True)
383
+
384
+ def _cmd_sessions(self) -> CommandResult:
385
+ """List recent sessions with ID, start time, turns, cost, status."""
386
+ try:
387
+ rows = self._db.execute_all(
388
+ "SELECT session_id, created_at, updated_at, turn_count, cost_usd, status "
389
+ "FROM sessions ORDER BY updated_at DESC LIMIT 20"
390
+ )
391
+ except Exception as exc: # noqa: BLE001
392
+ return CommandResult(handled=True, output=f"[red]Could not load sessions: {exc}[/red]")
393
+
394
+ if not rows:
395
+ return CommandResult(handled=True, output="[dim]No sessions found.[/dim]")
396
+
397
+ tbl = Table(
398
+ title="gdm sessions",
399
+ header_style="bold cyan",
400
+ show_lines=False,
401
+ )
402
+ tbl.add_column("#", style="dim", no_wrap=True)
403
+ tbl.add_column("Session ID", no_wrap=True)
404
+ tbl.add_column("Started", no_wrap=True)
405
+ tbl.add_column("Turns", justify="right")
406
+ tbl.add_column("Cost", justify="right")
407
+ tbl.add_column("Status")
408
+
409
+ now = datetime.now(timezone.utc)
410
+ for i, row in enumerate(rows, 1):
411
+ sid = row["session_id"]
412
+ short_id = sid[:8] + "..."
413
+ started = _relative_time(row["created_at"], now)
414
+ turns = str(row["turn_count"] or 0)
415
+ cost = f"${(row['cost_usd'] or 0.0):.4f}"
416
+ status = row["status"] or "active"
417
+ status_style = (
418
+ "[green]active ← resumable[/green]" if status == "active"
419
+ else f"[dim]{status}[/dim]"
420
+ )
421
+ tbl.add_row(str(i), short_id, started, turns, cost, status_style)
422
+
423
+ self._console.print(tbl)
424
+ return CommandResult(handled=True)
425
+
426
+ def _cmd_resume(self, session_id: str | None = None) -> CommandResult:
427
+ """Signal the REPL to restore a session transcript from checkpoint.
428
+
429
+ With no args, resumes the most recent active session.
430
+ With a session_id arg, resumes that specific session.
431
+ """
432
+ if session_id:
433
+ # Validate the session exists before committing to restore
434
+ try:
435
+ row = self._db.execute_one(
436
+ "SELECT session_id FROM sessions WHERE session_id = ?",
437
+ (session_id,),
438
+ )
439
+ if not row:
440
+ # Try prefix match
441
+ rows = self._db.execute_all(
442
+ "SELECT session_id FROM sessions WHERE session_id LIKE ? LIMIT 2",
443
+ (session_id + "%",),
444
+ )
445
+ if not rows:
446
+ return CommandResult(
447
+ handled=True,
448
+ output=f"[red]Session {session_id!r} not found.[/red]",
449
+ )
450
+ if len(rows) > 1:
451
+ return CommandResult(
452
+ handled=True,
453
+ output=f"[red]Ambiguous prefix {session_id!r} — matches multiple sessions. Use more characters.[/red]",
454
+ )
455
+ session_id = rows[0]["session_id"]
456
+ except Exception as exc: # noqa: BLE001
457
+ return CommandResult(
458
+ handled=True,
459
+ output=f"[red]Could not look up session: {exc}[/red]",
460
+ )
461
+ return CommandResult(
462
+ handled=True,
463
+ force_resume=True,
464
+ resume_session_id=session_id,
465
+ output=f"[cyan]Restoring session {session_id[:8]}...[/cyan]",
466
+ )
467
+
468
+ # No session_id — find most recent active session
469
+ try:
470
+ sessions = self._db.list_incomplete_sessions(limit=1)
471
+ except Exception as exc: # noqa: BLE001
472
+ sessions = []
473
+ log.warning("list_incomplete_sessions failed: %s", exc)
474
+
475
+ if not sessions:
476
+ return CommandResult(
477
+ handled=True,
478
+ output="[yellow]No active sessions found. Start a new conversation.[/yellow]",
479
+ )
480
+ target = sessions[0]["session_id"]
481
+ return CommandResult(
482
+ handled=True,
483
+ force_resume=True,
484
+ resume_session_id=target,
485
+ output=f"[cyan]Restoring session {target[:8]}...[/cyan]",
486
+ )
487
+
488
+ # ------------------------------------------------------------------
489
+ # Git commands
490
+ # ------------------------------------------------------------------
491
+
492
+ def _git_run(self, args: list[str]) -> tuple[int, str]:
493
+ """Run a git command in project_root. Returns (returncode, output)."""
494
+ try:
495
+ result = subprocess.run(
496
+ ["git"] + args,
497
+ cwd=str(self._project_root),
498
+ capture_output=True,
499
+ text=True,
500
+ timeout=15,
501
+ )
502
+ output = (result.stdout + result.stderr).strip()
503
+ return result.returncode, output
504
+ except FileNotFoundError:
505
+ return -1, "git not found in PATH"
506
+ except subprocess.TimeoutExpired:
507
+ return 1, "git command timed out"
508
+ except Exception as exc: # noqa: BLE001
509
+ return 1, str(exc)
510
+
511
+ def _cmd_diff(self) -> CommandResult:
512
+ """Show git diff of staged + unstaged changes."""
513
+ rc, out = self._git_run(["diff", "HEAD"])
514
+ if rc == -1:
515
+ return CommandResult(handled=True, output=f"[red]{out}[/red]")
516
+ if not out:
517
+ rc2, stat = self._git_run(["status", "--short"])
518
+ if not stat:
519
+ return CommandResult(handled=True, output="[dim]No changes since last commit.[/dim]")
520
+ out = stat
521
+ # Truncate long diffs
522
+ if len(out) > 4000:
523
+ out = out[:4000] + "\n…(truncated)"
524
+ return CommandResult(handled=True, output=f"[dim]{out}[/dim]")
525
+
526
+ def _cmd_undo(self, n: int = 1) -> CommandResult:
527
+ """Soft-undo the last *n* [gdm-checkpoint] commits (keeps working tree).
528
+
529
+ Only operates on commits tagged [gdm-checkpoint] so it never undoes
530
+ user commits or moves HEAD past non-gdm history.
531
+ """
532
+ if n < 1:
533
+ n = 1
534
+ # Find the most recent N gdm-checkpoint commit SHAs
535
+ # --fixed-strings avoids BRE treating [...] as a character class
536
+ rc, log_out = self._git_run(
537
+ ["log", "--oneline", "--fixed-strings", "--grep=[gdm-checkpoint]", f"-{n}"]
538
+ )
539
+ if rc == -1:
540
+ return CommandResult(handled=True, output=f"[red]{log_out}[/red]")
541
+ if rc != 0 or not log_out.strip():
542
+ return CommandResult(
543
+ handled=True,
544
+ output="[yellow]No gdm checkpoint commits found. Nothing to undo.[/yellow]",
545
+ )
546
+ lines = [ln for ln in log_out.strip().splitlines() if ln]
547
+ if len(lines) < n:
548
+ return CommandResult(
549
+ handled=True,
550
+ output=f"[yellow]Only {len(lines)} checkpoint(s) found; cannot undo {n}.[/yellow]",
551
+ )
552
+ # Oldest among the N checkpoints is the last line; reset to its parent
553
+ oldest_sha = lines[-1].split()[0]
554
+ rc2, out2 = self._git_run(["reset", "--soft", f"{oldest_sha}~1"])
555
+ if rc2 != 0:
556
+ return CommandResult(handled=True, output=f"[red]undo failed:[/red] {out2[:300]}")
557
+ return CommandResult(
558
+ handled=True,
559
+ output=(
560
+ f"[green]✓ Undid {n} checkpoint commit(s).[/green] "
561
+ "Changes preserved in working tree."
562
+ ),
563
+ )
564
+
565
+ def _cmd_rollback(self, checkpoint_id: str | None = None) -> CommandResult:
566
+ """Hard rollback. Without arg: pre-task state. With arg: named checkpoint SHA."""
567
+ # Safety gate: refuse if working tree is dirty
568
+ wf = GitWorkflow(self._project_root)
569
+ if not wf.is_clean():
570
+ return CommandResult(
571
+ handled=True,
572
+ output=(
573
+ "[red]⚠ Uncommitted changes detected. "
574
+ "Commit ([bold]/commit[/bold]) or stash before rollback.[/red]"
575
+ ),
576
+ )
577
+
578
+ if checkpoint_id:
579
+ # Validate the SHA exists
580
+ rc_v, _ = self._git_run(["cat-file", "-t", checkpoint_id])
581
+ if rc_v != 0:
582
+ return CommandResult(
583
+ handled=True,
584
+ output=f"[red]Unknown checkpoint: {checkpoint_id!r}[/red]",
585
+ )
586
+ target_ref = checkpoint_id
587
+ description = f"checkpoint {checkpoint_id[:8]}"
588
+ else:
589
+ # Existing logic: find parent of first gdm-checkpoint commit
590
+ rc, log_out = self._git_run(
591
+ ["log", "--oneline", "--fixed-strings", "--grep=[gdm-checkpoint]", "--reverse"]
592
+ )
593
+ if rc == -1:
594
+ return CommandResult(handled=True, output=f"[red]{log_out}[/red]")
595
+ if rc != 0 or not log_out.strip():
596
+ return CommandResult(
597
+ handled=True,
598
+ output="[yellow]No gdm checkpoints found. Nothing to roll back to.[/yellow]",
599
+ )
600
+ first_sha = log_out.strip().splitlines()[0].split()[0]
601
+ target_ref = f"{first_sha}~1"
602
+ rc_p, pre_sha = self._git_run(["rev-parse", "--short", target_ref])
603
+ if rc_p != 0:
604
+ return CommandResult(
605
+ handled=True,
606
+ output=f"[red]Cannot find pre-task commit (is this the first commit?)[/red] {pre_sha}",
607
+ )
608
+ description = f"pre-task state ({pre_sha.strip()})"
609
+
610
+ # Confirmation prompt before destructive hard reset
611
+ self._console.print(
612
+ f"[bold red]⚠ Hard rollback[/bold red] to {description}.\n"
613
+ "[red]This DISCARDS all uncommitted and committed changes since that point.[/red]\n"
614
+ "Type [bold]YES[/bold] to confirm, anything else to cancel: ",
615
+ end="",
616
+ )
617
+ try:
618
+ confirm = (self._confirm_fn() if self._confirm_fn is not None else input()).strip()
619
+ except (EOFError, KeyboardInterrupt):
620
+ confirm = ""
621
+ if confirm != "YES":
622
+ return CommandResult(handled=True, output="[dim]Rollback cancelled.[/dim]")
623
+
624
+ rc2, out2 = self._git_run(["reset", "--hard", target_ref])
625
+ if rc2 != 0:
626
+ return CommandResult(handled=True, output=f"[red]rollback failed:[/red] {out2[:300]}")
627
+ return CommandResult(
628
+ handled=True,
629
+ output=f"[green]✓ Rolled back to {description}.[/green] All subsequent changes discarded.",
630
+ )
631
+
632
+ def _cmd_branch(self) -> CommandResult:
633
+ """Show current branch and gdm checkpoint history."""
634
+ rc1, branch = self._git_run(["branch", "--show-current"])
635
+ rc2, log_out = self._git_run(["log", "--oneline", "-10"])
636
+ tbl = Table(title="git branch", show_header=False, min_width=50)
637
+ tbl.add_column("Field", style="dim")
638
+ tbl.add_column("Value")
639
+ tbl.add_row("Branch", branch or "(unknown)")
640
+ tbl.add_row("Recent commits", log_out[:500] if log_out else "(no commits)")
641
+ self._console.print(tbl)
642
+ return CommandResult(handled=True)
643
+
644
+ def _cmd_pr(self) -> CommandResult:
645
+ """Push the current branch and generate a model-written PR description."""
646
+ rc1, branch = self._git_run(["branch", "--show-current"])
647
+ if not branch or not branch.startswith("gdm/"):
648
+ return CommandResult(
649
+ handled=True,
650
+ output="[yellow]Not on a gdm task branch. Run a task first.[/yellow]",
651
+ )
652
+ rc2, out2 = self._git_run(["push", "-u", "origin", branch])
653
+ if rc2 not in (0, 1):
654
+ return CommandResult(handled=True, output=f"[red]push failed:[/red] {out2[:400]}")
655
+
656
+ rc3, log_out = self._git_run(["log", "--oneline", f"origin/HEAD...{branch}"])
657
+ commit_log = log_out[:800] if log_out else "(no commits ahead of origin)"
658
+
659
+ rc4, diff_text = self._git_run(["diff", f"origin/HEAD...{branch}"])
660
+ description = self._generate_pr_description(branch, diff_text or "", commit_log)
661
+
662
+ # Copy to clipboard if pyperclip is available (soft dependency)
663
+ try:
664
+ import pyperclip # type: ignore[import-untyped]
665
+ pyperclip.copy(description)
666
+ clipboard_note = "\n[dim]✓ PR description copied to clipboard.[/dim]"
667
+ except (ImportError, Exception): # noqa: BLE001
668
+ clipboard_note = ""
669
+
670
+ msg = (
671
+ f"[green]✓ Pushed[/green] [cyan]{branch}[/cyan]\n\n"
672
+ f"{description}"
673
+ f"{clipboard_note}"
674
+ )
675
+ return CommandResult(handled=True, output=msg)
676
+
677
+ def _generate_pr_description(
678
+ self, branch: str, diff_text: str, commit_log: str
679
+ ) -> str:
680
+ """Call the Coder model to generate a structured PR description.
681
+
682
+ Prompt asks for ## Summary, ## Changes, ## Testing, ## Notes sections.
683
+ Falls back to the raw commit log on any exception.
684
+ """
685
+ if not self._cfg:
686
+ return f"Commits:\n{commit_log}\n\n[dim]Open a PR at your git hosting provider.[/dim]"
687
+
688
+ try:
689
+ from src.models.client import GdmClient
690
+ from src.models.definitions import ModelTier, get_model
691
+
692
+ model_def = get_model(ModelTier.CODER, getattr(self._cfg, "provider", "grok"))
693
+ model_id = model_def.id
694
+ client = GdmClient(self._cfg) # type: ignore[arg-type]
695
+
696
+ prompt = (
697
+ "You are writing a GitHub pull request description.\n"
698
+ f"Branch: {branch}\n"
699
+ f"Commits:\n{commit_log}\n\n"
700
+ f"Diff summary (first 4000 chars):\n{diff_text[:4000]}\n\n"
701
+ "Write a PR description with these sections:\n"
702
+ "## Summary (2-3 sentences)\n"
703
+ "## Changes\n"
704
+ "## Testing\n"
705
+ "## Notes (optional)\n"
706
+ "Be precise and factual. Do not invent changes not in the diff."
707
+ )
708
+ response = client.complete(
709
+ messages=[{"role": "user", "content": prompt}],
710
+ model=model_id,
711
+ max_tokens=600,
712
+ temperature=0.2,
713
+ )
714
+ return response.choices[0].message.content.strip()
715
+ except Exception as exc: # noqa: BLE001
716
+ log.warning("_generate_pr_description failed: %s", exc)
717
+ return f"Commits:\n{commit_log}\n\n[dim]Open a PR at your git hosting provider.[/dim]"
718
+
719
+ def _cmd_commit(self, args: list[str]) -> CommandResult:
720
+ """Create a manual checkpoint commit."""
721
+ msg = " ".join(args).strip() or "manual checkpoint"
722
+ rc1, _ = self._git_run(["add", "-A"])
723
+ rc2, out2 = self._git_run(["commit", "--allow-empty", "-m", f"[gdm-checkpoint] {msg}"])
724
+ if rc2 != 0:
725
+ return CommandResult(handled=True, output=f"[red]commit failed:[/red] {out2[:400]}")
726
+ rc3, sha = self._git_run(["rev-parse", "--short", "HEAD"])
727
+ return CommandResult(
728
+ handled=True,
729
+ output=f"[green]✓ Checkpoint commit {sha}:[/green] {msg}",
730
+ )
731
+
732
+ def _cmd_browser(self, args: list[str]) -> CommandResult:
733
+ """Manage the gdm bridge server for browser automation."""
734
+ import os
735
+ import platform
736
+ import subprocess
737
+ import sys
738
+
739
+ action = args[0].lower() if args else "status"
740
+ pid_file = self._project_root / ".gdm_bridge.pid"
741
+
742
+ match action:
743
+ case "start":
744
+ if pid_file.exists():
745
+ try:
746
+ pid = int(pid_file.read_text().strip())
747
+ # Check if process is alive
748
+ if platform.system() == "Windows":
749
+ r = subprocess.run(
750
+ ["tasklist", "/FI", f"PID eq {pid}", "/NH"],
751
+ capture_output=True, text=True,
752
+ )
753
+ alive = str(pid) in r.stdout
754
+ else:
755
+ try:
756
+ os.kill(pid, 0)
757
+ alive = True
758
+ except ProcessLookupError:
759
+ alive = False
760
+ if alive:
761
+ return CommandResult(
762
+ handled=True,
763
+ output=f"[yellow]Bridge already running (PID {pid}).[/yellow]",
764
+ )
765
+ except Exception: # noqa: BLE001
766
+ pass
767
+
768
+ proc = subprocess.Popen(
769
+ [sys.executable, "-m", "src.server.bridge_cli"],
770
+ stdout=subprocess.DEVNULL,
771
+ stderr=subprocess.DEVNULL,
772
+ )
773
+ pid_file.write_text(str(proc.pid))
774
+ return CommandResult(
775
+ handled=True,
776
+ output=(
777
+ f"[green]✓ Bridge server started (PID {proc.pid})[/green]\n"
778
+ f"[dim]Load the gdm-chrome extension to connect. "
779
+ f"Token at: ~/.config/gdm/browser.token[/dim]"
780
+ ),
781
+ )
782
+
783
+ case "stop":
784
+ if not pid_file.exists():
785
+ return CommandResult(handled=True, output="[yellow]Bridge not running.[/yellow]")
786
+ try:
787
+ pid = int(pid_file.read_text().strip())
788
+ if platform.system() == "Windows":
789
+ subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True)
790
+ else:
791
+ os.kill(pid, 15) # SIGTERM
792
+ pid_file.unlink(missing_ok=True)
793
+ return CommandResult(
794
+ handled=True,
795
+ output=f"[green]✓ Bridge stopped (PID {pid}).[/green]",
796
+ )
797
+ except Exception as exc: # noqa: BLE001
798
+ pid_file.unlink(missing_ok=True)
799
+ return CommandResult(handled=True, output=f"[red]Stop failed:[/red] {exc}")
800
+
801
+ case "status":
802
+ if not pid_file.exists():
803
+ return CommandResult(
804
+ handled=True,
805
+ output="[dim]Bridge not running. Use /browser start to launch it.[/dim]",
806
+ )
807
+ try:
808
+ pid = int(pid_file.read_text().strip())
809
+ try:
810
+ import httpx
811
+ r = httpx.get("http://127.0.0.1:9321/health", timeout=2.0)
812
+ data = r.json()
813
+ clients = data.get("clients", {})
814
+ queue = data.get("queue_depth", 0)
815
+ return CommandResult(
816
+ handled=True,
817
+ output=(
818
+ f"[green]✓ Bridge running[/green] (PID {pid})\n"
819
+ f" CLI clients: {clients.get('cli', 0)} "
820
+ f"Extension clients: {clients.get('extension', 0)} "
821
+ f"Queue depth: {queue}"
822
+ ),
823
+ )
824
+ except Exception: # noqa: BLE001
825
+ return CommandResult(
826
+ handled=True,
827
+ output=f"[yellow]Bridge process running (PID {pid}) but health check failed.[/yellow]",
828
+ )
829
+ except Exception as exc: # noqa: BLE001
830
+ return CommandResult(handled=True, output=f"[red]Status check failed:[/red] {exc}")
831
+
832
+ case _:
833
+ return CommandResult(
834
+ handled=True,
835
+ output="[yellow]Usage: /browser [start|stop|status][/yellow]",
836
+ )
837
+
838
+ def _cmd_remote(self, args: list[str]) -> CommandResult:
839
+ """Manage the gdm remote tunnel.
840
+
841
+ Usage:
842
+ /remote start [--port PORT] - start tunnel, print URL and QR
843
+ /remote stop - tear down tunnel
844
+ /remote status - show tunnel state
845
+ /remote qr - display QR code for active tunnel
846
+ """
847
+ import json
848
+ from pathlib import Path as _Path
849
+
850
+ action = (args[0] if args else "status").lower()
851
+ state_file = _Path.cwd() / ".gdm_remote.json"
852
+
853
+ match action:
854
+ case "start":
855
+ port = 8765
856
+ for i, a in enumerate(args[1:]):
857
+ if a in ("--port", "-p") and i + 1 < len(args) - 1:
858
+ try:
859
+ port = int(args[i + 2])
860
+ except ValueError:
861
+ pass
862
+ try:
863
+ from src.remote.tunnel import TunnelManager
864
+ from src.remote.qr import make_pairing_url, render_qr
865
+ from src.remote.token_manager import PairingTokenService
866
+
867
+ mgr = TunnelManager(port=port)
868
+ url = mgr.start()
869
+ token_svc = PairingTokenService()
870
+ token = token_svc.issue()
871
+ pairing_url = make_pairing_url(url, token)
872
+ st = mgr.status()
873
+ state_file.write_text(
874
+ json.dumps({
875
+ "url": url,
876
+ "pairing_url": pairing_url,
877
+ "provider": st["provider"],
878
+ "port": port,
879
+ }),
880
+ encoding="utf-8",
881
+ )
882
+ qr_text = render_qr(pairing_url)
883
+ lines = [
884
+ f"[green]Tunnel started:[/green] [cyan]{url}[/cyan]",
885
+ f" Provider: [bold]{st['provider']}[/bold]",
886
+ "",
887
+ qr_text,
888
+ f"\n[dim]Pairing URL: {pairing_url}[/dim]",
889
+ ]
890
+ mgr.stop()
891
+ return CommandResult(handled=True, output="\n".join(lines))
892
+ except Exception as exc: # noqa: BLE001
893
+ return CommandResult(handled=True, output=f"[red]Remote start failed:[/red] {exc}")
894
+
895
+ case "stop":
896
+ if not state_file.exists():
897
+ return CommandResult(handled=True, output="[yellow]Remote tunnel not running.[/yellow]")
898
+ state_file.unlink(missing_ok=True)
899
+ return CommandResult(handled=True, output="[green]Remote tunnel stopped.[/green]")
900
+
901
+ case "status":
902
+ if not state_file.exists():
903
+ return CommandResult(
904
+ handled=True,
905
+ output="[dim]Remote tunnel not running. Run: /remote start[/dim]",
906
+ )
907
+ try:
908
+ data = json.loads(state_file.read_text(encoding="utf-8"))
909
+ url = data.get("url", "unknown")
910
+ provider = data.get("provider", "unknown")
911
+ return CommandResult(
912
+ handled=True,
913
+ output=(
914
+ f"[green]Remote tunnel active[/green]\n"
915
+ f" URL: [cyan]{url}[/cyan]\n"
916
+ f" Provider: [bold]{provider}[/bold]"
917
+ ),
918
+ )
919
+ except Exception as exc: # noqa: BLE001
920
+ return CommandResult(
921
+ handled=True, output=f"[yellow]Could not read tunnel state:[/yellow] {exc}"
922
+ )
923
+
924
+ case "qr":
925
+ if not state_file.exists():
926
+ return CommandResult(
927
+ handled=True, output="[yellow]No active tunnel. Run: /remote start[/yellow]"
928
+ )
929
+ try:
930
+ from src.remote.qr import render_qr
931
+
932
+ data = json.loads(state_file.read_text(encoding="utf-8"))
933
+ pairing_url = data.get("pairing_url") or data.get("url", "")
934
+ if not pairing_url:
935
+ return CommandResult(handled=True, output="[red]No URL found in tunnel state.[/red]")
936
+ qr_text = render_qr(pairing_url)
937
+ return CommandResult(handled=True, output=f"{qr_text}\n[dim]{pairing_url}[/dim]")
938
+ except Exception as exc: # noqa: BLE001
939
+ return CommandResult(handled=True, output=f"[red]QR render failed:[/red] {exc}")
940
+
941
+ case _:
942
+ return CommandResult(
943
+ handled=True,
944
+ output="[yellow]Usage: /remote [start|stop|status|qr][/yellow]",
945
+ )
946
+
947
+ def _cmd_replay(self, args: list[str]) -> CommandResult:
948
+ """Replay, compare, fork, or export past sessions.
949
+
950
+ Usage:
951
+ /replay {session_id} — interactive step-through
952
+ /replay {session_id} --json — machine-readable summary
953
+ /replay compare {id1} {id2} — divergence summary
954
+ /replay fork {session_id} {turn_index} — fork from turn N
955
+ /replay export {session_id} [--format fmt] — export (openai|anthropic|raw)
956
+ """
957
+ from src.runtime.replay import ReplayError, SessionReplay
958
+
959
+ if not args:
960
+ return CommandResult(
961
+ handled=True,
962
+ output="[yellow]Usage: /replay {session_id} | compare {id1} {id2} | "
963
+ "fork {id} {n} | export {id} [--format openai|anthropic|raw][/yellow]",
964
+ )
965
+
966
+ # handle() splits the input with maxsplit=2 so later tokens may be merged;
967
+ # re-split to get individual tokens.
968
+ args_flat: list[str] = []
969
+ for a in args:
970
+ args_flat.extend(a.split())
971
+ args = args_flat
972
+
973
+ sub = args[0].lower()
974
+
975
+ # --- /replay compare {id1} {id2} ---
976
+ if sub == "compare":
977
+ if len(args) < 3:
978
+ return CommandResult(
979
+ handled=True,
980
+ output="[yellow]Usage: /replay compare {session_id_a} {session_id_b}[/yellow]",
981
+ )
982
+ id_a, id_b = args[1], args[2]
983
+ try:
984
+ replay_a = SessionReplay(self._db, id_a)
985
+ replay_a.load_session()
986
+ diff = replay_a.compare(id_b)
987
+ summary = diff.divergence_summary()
988
+ lines = [
989
+ f"[bold]Session A:[/bold] {id_a} ({len(diff.frames_a)} turns, ${summary['cost_a_usd']:.6f})",
990
+ f"[bold]Session B:[/bold] {id_b} ({len(diff.frames_b)} turns, ${summary['cost_b_usd']:.6f})",
991
+ f"[bold]Common prefix:[/bold] {summary['common_turns']} turns",
992
+ f"[bold]Unique to A:[/bold] {summary['session_a_unique_turns']} turns",
993
+ f"[bold]Unique to B:[/bold] {summary['session_b_unique_turns']} turns",
994
+ ]
995
+ return CommandResult(handled=True, output="\n".join(lines))
996
+ except ReplayError as exc:
997
+ return CommandResult(handled=True, output=f"[red]Replay error:[/red] {exc}")
998
+ except Exception as exc: # noqa: BLE001
999
+ return CommandResult(handled=True, output=f"[red]Error:[/red] {exc}")
1000
+
1001
+ # --- /replay fork {session_id} {turn_index} ---
1002
+ if sub == "fork":
1003
+ if len(args) < 3:
1004
+ return CommandResult(
1005
+ handled=True,
1006
+ output="[yellow]Usage: /replay fork {session_id} {turn_index}[/yellow]",
1007
+ )
1008
+ session_id, turn_str = args[1], args[2]
1009
+ try:
1010
+ turn_n = int(turn_str)
1011
+ except ValueError:
1012
+ return CommandResult(
1013
+ handled=True, output=f"[red]turn_index must be an integer, got {turn_str!r}[/red]"
1014
+ )
1015
+ try:
1016
+ import uuid as _uuid
1017
+ new_sid = str(_uuid.uuid4())
1018
+ replay = SessionReplay(self._db, session_id)
1019
+ replay.load_session()
1020
+ replay.fork_from(turn_n, new_sid)
1021
+ return CommandResult(
1022
+ handled=True,
1023
+ output=(
1024
+ f"[green]✓ Forked session {session_id[:8]}… at turn {turn_n}[/green]\n"
1025
+ f"New session: [cyan]{new_sid}[/cyan]"
1026
+ ),
1027
+ )
1028
+ except ReplayError as exc:
1029
+ return CommandResult(handled=True, output=f"[red]Replay error:[/red] {exc}")
1030
+ except Exception as exc: # noqa: BLE001
1031
+ return CommandResult(handled=True, output=f"[red]Error:[/red] {exc}")
1032
+
1033
+ # --- /replay export {session_id} [--format fmt] ---
1034
+ if sub == "export":
1035
+ if len(args) < 2:
1036
+ return CommandResult(
1037
+ handled=True,
1038
+ output="[yellow]Usage: /replay export {session_id} [--format openai|anthropic|raw][/yellow]",
1039
+ )
1040
+ session_id = args[1]
1041
+ fmt = "openai"
1042
+ if "--format" in args:
1043
+ idx = args.index("--format")
1044
+ if idx + 1 < len(args):
1045
+ fmt = args[idx + 1]
1046
+ try:
1047
+ replay = SessionReplay(self._db, session_id)
1048
+ replay.load_session()
1049
+ records = replay.export(fmt)
1050
+ import json as _json
1051
+ lines_out = "\n".join(_json.dumps(r) for r in records)
1052
+ return CommandResult(
1053
+ handled=True,
1054
+ output=f"[green]{len(records)} records exported ({fmt}):[/green]\n{lines_out[:2000]}",
1055
+ )
1056
+ except (ReplayError, ValueError) as exc:
1057
+ return CommandResult(handled=True, output=f"[red]Export error:[/red] {exc}")
1058
+ except Exception as exc: # noqa: BLE001
1059
+ return CommandResult(handled=True, output=f"[red]Error:[/red] {exc}")
1060
+
1061
+ # --- /replay {session_id} [--json] --- (default: interactive display)
1062
+ session_id = args[0]
1063
+ as_json = "--json" in args
1064
+ try:
1065
+ replay = SessionReplay(self._db, session_id)
1066
+ replay.load_session()
1067
+ except ReplayError as exc:
1068
+ return CommandResult(handled=True, output=f"[red]Replay error:[/red] {exc}")
1069
+ except Exception as exc: # noqa: BLE001
1070
+ return CommandResult(handled=True, output=f"[red]Error loading session:[/red] {exc}")
1071
+
1072
+ if as_json:
1073
+ import json as _json
1074
+ records = replay.export("raw")
1075
+ return CommandResult(handled=True, output=_json.dumps(records, indent=2)[:4000])
1076
+
1077
+ # Non-blocking textual display: show all frames, then navigation hint
1078
+ lines = [f"[bold]Session {session_id[:8]}…[/bold] — {replay.turn_count} turn(s)\n"]
1079
+ for frame in replay._frames:
1080
+ lines.append(
1081
+ f"[cyan]Turn {frame.turn_index}[/cyan] "
1082
+ f"[dim]{frame.model} ${frame.cost_usd:.6f}[/dim]"
1083
+ )
1084
+ if frame.user_message:
1085
+ snippet = frame.user_message[:120].replace("\n", " ")
1086
+ lines.append(f" User: {snippet}")
1087
+ if frame.assistant_text:
1088
+ snippet = frame.assistant_text[:120].replace("\n", " ")
1089
+ lines.append(f" Asst: {snippet}")
1090
+ if frame.tool_calls:
1091
+ names = ", ".join(tc.get("tool_name", "?") for tc in frame.tool_calls)
1092
+ lines.append(f" Tools: {names}")
1093
+ if frame.annotation:
1094
+ lines.append(f" [yellow]Annotation: {frame.annotation}[/yellow]")
1095
+ lines.append("")
1096
+
1097
+ lines.append(
1098
+ "[dim]Tip: /replay compare {id1} {id2} | /replay fork {id} {n} | "
1099
+ "/replay export {id} --format openai[/dim]"
1100
+ )
1101
+ return CommandResult(handled=True, output="\n".join(lines))
1102
+
1103
+ def _cmd_doctor(self) -> CommandResult:
1104
+ """Show resolved model IDs for all providers and tiers."""
1105
+ from src.models.definitions import (
1106
+ _CODEX_BY_TIER,
1107
+ _GEMINI_BY_TIER,
1108
+ _GROK_BY_TIER,
1109
+ )
1110
+
1111
+ tbl = Table(title="Resolved Model IDs", header_style="bold cyan", show_lines=True)
1112
+ tbl.add_column("Provider", style="bold")
1113
+ tbl.add_column("Tier")
1114
+ tbl.add_column("Model ID")
1115
+ tbl.add_column("Context (K)")
1116
+ tbl.add_column("$/1M in")
1117
+ tbl.add_column("$/1M out")
1118
+
1119
+ for provider_label, tier_map in (
1120
+ ("grok", _GROK_BY_TIER),
1121
+ ("gemini", _GEMINI_BY_TIER),
1122
+ ("codex", _CODEX_BY_TIER),
1123
+ ):
1124
+ for tier_name, model_def in sorted(tier_map.items()):
1125
+ tbl.add_row(
1126
+ provider_label,
1127
+ tier_name,
1128
+ model_def.id,
1129
+ str(model_def.context_window // 1000),
1130
+ f"{model_def.input_per_m:.4f}",
1131
+ f"{model_def.output_per_m:.4f}",
1132
+ )
1133
+ self._console.print(tbl)
1134
+ return CommandResult(handled=True)
1135
+
1136
+ def _cmd_reasoning(self, args: list[str]) -> CommandResult:
1137
+ """Show or set the per-turn reasoning mode.
1138
+
1139
+ Usage:
1140
+ /reasoning — show current mode
1141
+ /reasoning on — force Reasoner tier every turn
1142
+ /reasoning off — force Scout tier every turn
1143
+ /reasoning auto — use the deterministic scoring classifier
1144
+ """
1145
+ _VALID_MODES: frozenset[str] = frozenset({"on", "off", "auto"})
1146
+ if not args:
1147
+ return CommandResult(
1148
+ handled=True,
1149
+ output=(
1150
+ f"[cyan]Reasoning mode:[/cyan] [bold]{self._reasoning_mode}[/bold]\n"
1151
+ "[dim]Use /reasoning on|off|auto to change.[/dim]"
1152
+ ),
1153
+ )
1154
+ mode = args[0].lower().strip()
1155
+ if mode not in _VALID_MODES:
1156
+ return CommandResult(
1157
+ handled=True,
1158
+ output=f"[yellow]Unknown mode {mode!r}. Valid: on | off | auto[/yellow]",
1159
+ )
1160
+ self._reasoning_mode = mode
1161
+ _DESCRIPTIONS = {
1162
+ "on": "Force [bold]Reasoner[/bold] tier — highest quality, highest cost",
1163
+ "off": "Force [bold]Scout[/bold] tier — cheapest, fastest",
1164
+ "auto": "Use [bold]auto-classifier[/bold] — selects tier per turn by score",
1165
+ }
1166
+ return CommandResult(
1167
+ handled=True,
1168
+ new_reasoning_mode=mode,
1169
+ output=f"[green]✓ Reasoning mode → {mode}[/green] {_DESCRIPTIONS[mode]}",
1170
+ )
1171
+
1172
+ # ------------------------------------------------------------------
1173
+ # /artifacts and /save
1174
+ # ------------------------------------------------------------------
1175
+
1176
+ def _cmd_artifacts(self, args: list[str]) -> CommandResult:
1177
+ """Handle /artifacts [sub] [args]."""
1178
+ from src.artifacts.artifact_store import ArtifactNotFoundError, ArtifactStore
1179
+ store = ArtifactStore(self._db)
1180
+
1181
+ sub = args[0].lower() if args else ""
1182
+ rest = args[1].split() if len(args) > 1 else []
1183
+
1184
+ if sub == "view":
1185
+ name_or_id = rest[0] if rest else ""
1186
+ if not name_or_id:
1187
+ return CommandResult(handled=True, output="[red]Usage: /artifacts view {name|id}[/red]")
1188
+ try:
1189
+ art = store.get(name_or_id)
1190
+ except ArtifactNotFoundError as exc:
1191
+ return CommandResult(handled=True, output=f"[red]{exc}[/red]")
1192
+ header = (
1193
+ f"[bold]{art.name}[/bold] [dim]{art.type} v{art.version_num} {art.updated_at[:16]}[/dim]\n"
1194
+ )
1195
+ return CommandResult(handled=True, output=header + art.content)
1196
+
1197
+ if sub == "diff":
1198
+ ids = args[1].split() if len(args) > 1 else []
1199
+ if len(ids) < 2:
1200
+ return CommandResult(handled=True, output="[red]Usage: /artifacts diff {name1} {name2}[/red]")
1201
+ try:
1202
+ diff = store.diff(ids[0], ids[1])
1203
+ except ArtifactNotFoundError as exc:
1204
+ return CommandResult(handled=True, output=f"[red]{exc}[/red]")
1205
+ except TypeError as exc:
1206
+ return CommandResult(handled=True, output=f"[red]{exc}[/red]")
1207
+ if not diff.unified_diff:
1208
+ return CommandResult(handled=True, output="[dim]No differences.[/dim]")
1209
+ return CommandResult(handled=True, output=diff.unified_diff)
1210
+
1211
+ if sub == "search":
1212
+ query = " ".join(rest) if rest else (args[1] if len(args) > 1 else "")
1213
+ if not query:
1214
+ return CommandResult(handled=True, output="[red]Usage: /artifacts search {query}[/red]")
1215
+ results = store.search(query)
1216
+ if not results:
1217
+ return CommandResult(handled=True, output=f"[dim]No artifacts matching {query!r}.[/dim]")
1218
+ tbl = Table(title=f"Search: {query}", header_style="bold cyan")
1219
+ tbl.add_column("Name", style="cyan")
1220
+ tbl.add_column("Type")
1221
+ tbl.add_column("v#")
1222
+ tbl.add_column("Updated")
1223
+ for art in results:
1224
+ tbl.add_row(art.name, art.type, str(art.version_num), art.updated_at[:16])
1225
+ self._console.print(tbl)
1226
+ return CommandResult(handled=True)
1227
+
1228
+ if sub == "export":
1229
+ export_args = args[1].split() if len(args) > 1 else []
1230
+ if not export_args:
1231
+ return CommandResult(handled=True, output="[red]Usage: /artifacts export {name|id} [--format raw|html][/red]")
1232
+ name_or_id = export_args[0]
1233
+ fmt = "raw"
1234
+ if "--format" in export_args:
1235
+ idx = export_args.index("--format")
1236
+ if idx + 1 < len(export_args):
1237
+ fmt = export_args[idx + 1]
1238
+ try:
1239
+ content = store.export(name_or_id, fmt=fmt)
1240
+ except ArtifactNotFoundError as exc:
1241
+ return CommandResult(handled=True, output=f"[red]{exc}[/red]")
1242
+ except ValueError as exc:
1243
+ return CommandResult(handled=True, output=f"[red]{exc}[/red]")
1244
+ return CommandResult(handled=True, output=content)
1245
+
1246
+ # Default: list all artifacts
1247
+ artifacts = store.list()
1248
+ if not artifacts:
1249
+ return CommandResult(handled=True, output="[dim]No artifacts saved yet. Use /save [name] to save one.[/dim]")
1250
+ tbl = Table(title="Saved Artifacts", header_style="bold cyan", show_lines=False)
1251
+ tbl.add_column("Name", style="cyan", no_wrap=True)
1252
+ tbl.add_column("Type")
1253
+ tbl.add_column("Ver")
1254
+ tbl.add_column("Session", style="dim")
1255
+ tbl.add_column("Updated")
1256
+ for art in artifacts:
1257
+ session_abbrev = (art.session_id or "")[:8]
1258
+ tbl.add_row(art.name, art.type, str(art.version_num), session_abbrev, art.updated_at[:16])
1259
+ self._console.print(tbl)
1260
+ return CommandResult(handled=True)
1261
+
1262
+ def _cmd_save(self, name_arg: str) -> CommandResult:
1263
+ """Handle /save [name] — signals loop to save last assistant response."""
1264
+ name = name_arg.strip() or "unnamed"
1265
+ return CommandResult(
1266
+ handled=True,
1267
+ save_artifact_name=name,
1268
+ output=f"[dim]Saving last response as artifact [cyan]{name!r}[/cyan]...[/dim]",
1269
+ )
1270
+
1271
+ def _cmd_proxy(self, args: list[str]) -> CommandResult:
1272
+ """Handle /proxy [on|off|url <url>|token <tok>|status]."""
1273
+ from src.auth import CredentialStore
1274
+ from src.models.definitions import PROXY_DEFAULT_URL
1275
+
1276
+ sub = args[0].lower() if args else "status"
1277
+
1278
+ if sub == "on":
1279
+ if not self._proxy_token:
1280
+ return CommandResult(
1281
+ handled=True,
1282
+ output=(
1283
+ "[red]No proxy token stored. Set one first:[/red]\n"
1284
+ " /proxy token <your-token>"
1285
+ ),
1286
+ )
1287
+ if not self._proxy_url:
1288
+ self._proxy_url = PROXY_DEFAULT_URL
1289
+ self._proxy_enabled = True
1290
+ return CommandResult(
1291
+ handled=True,
1292
+ output=f"[green]Proxy enabled:[/green] [cyan]{self._proxy_url}[/cyan]",
1293
+ proxy_action="enable",
1294
+ proxy_url=self._proxy_url,
1295
+ proxy_token=self._proxy_token,
1296
+ )
1297
+
1298
+ if sub == "off":
1299
+ self._proxy_enabled = False
1300
+ return CommandResult(
1301
+ handled=True,
1302
+ output="[yellow]Proxy disabled.[/yellow] Calls go directly to provider.",
1303
+ proxy_action="disable",
1304
+ )
1305
+
1306
+ if sub == "url":
1307
+ if len(args) < 2:
1308
+ return CommandResult(
1309
+ handled=True,
1310
+ output="[red]Usage:[/red] /proxy url <https://your-proxy/v1>",
1311
+ )
1312
+ url = args[1]
1313
+ _parsed = urlparse(url)
1314
+ _loopback = (
1315
+ _parsed.scheme == "http"
1316
+ and _parsed.hostname in ("localhost", "127.0.0.1", "::1")
1317
+ and not _parsed.username # reject http://localhost@evil.com/
1318
+ )
1319
+ if _parsed.scheme != "https" and not _loopback:
1320
+ return CommandResult(
1321
+ handled=True,
1322
+ output=(
1323
+ "[red]Proxy URL must use HTTPS to protect your token in transit.[/red]\n"
1324
+ "Exception: http://localhost (exact loopback) is allowed for local testing."
1325
+ ),
1326
+ )
1327
+ self._proxy_url = url
1328
+ return CommandResult(
1329
+ handled=True,
1330
+ output=f"Proxy URL set to [cyan]{url}[/cyan]. Run [cyan]/proxy on[/cyan] to activate.",
1331
+ )
1332
+
1333
+ if sub == "token":
1334
+ if len(args) < 2:
1335
+ # Prompt for token via hidden input (never stored in history)
1336
+ return CommandResult(
1337
+ handled=True,
1338
+ prompt_secret="Proxy token: ",
1339
+ )
1340
+ token = args[1]
1341
+ self._proxy_token = token
1342
+ try:
1343
+ CredentialStore().set("proxy", token)
1344
+ masked = "****" + token[-4:] if len(token) >= 4 else "****"
1345
+ return CommandResult(
1346
+ handled=True,
1347
+ output=f"Proxy token saved to OS keychain ([cyan]{masked}[/cyan]).",
1348
+ )
1349
+ except Exception as exc:
1350
+ log.warning("Could not save proxy token to keychain: %s", exc)
1351
+ masked = "****" + token[-4:] if len(token) >= 4 else "****"
1352
+ return CommandResult(
1353
+ handled=True,
1354
+ output=(
1355
+ f"Proxy token set for this session ([cyan]{masked}[/cyan]). "
1356
+ "[yellow]Keychain save failed — token will not persist.[/yellow]"
1357
+ ),
1358
+ )
1359
+
1360
+ # /proxy status (default)
1361
+ status = "[green]enabled[/green]" if self._proxy_enabled else "[dim]disabled[/dim]"
1362
+ url = self._proxy_url or PROXY_DEFAULT_URL
1363
+ masked = ("****" + self._proxy_token[-4:]) if len(self._proxy_token) >= 4 else ("[dim]not set[/dim]" if not self._proxy_token else "****")
1364
+ lines = [
1365
+ f" Status: {status}",
1366
+ f" URL: [cyan]{url}[/cyan]",
1367
+ f" Token: {masked}",
1368
+ "",
1369
+ "Commands: [cyan]/proxy on[/cyan] [cyan]/proxy off[/cyan] "
1370
+ "[cyan]/proxy url <url>[/cyan] [cyan]/proxy token[/cyan] (prompts for token)",
1371
+ ]
1372
+ return CommandResult(handled=True, output="\n".join(lines))
1373
+
1374
+ def apply_proxy_token(self, token: str) -> CommandResult:
1375
+ """Store proxy token received via hidden prompt (never written to history)."""
1376
+ token = token.strip()
1377
+ if not token:
1378
+ return CommandResult(handled=True, output="[yellow]No token entered.[/yellow]")
1379
+ self._proxy_token = token
1380
+ try:
1381
+ CredentialStore().set("proxy", token)
1382
+ masked = "****" + token[-4:] if len(token) >= 4 else "****"
1383
+ return CommandResult(
1384
+ handled=True,
1385
+ output=f"Proxy token saved to OS keychain ([cyan]{masked}[/cyan]).",
1386
+ proxy_token=token,
1387
+ )
1388
+ except Exception as exc:
1389
+ log.warning("Could not save proxy token to keychain: %s", exc)
1390
+ masked = "****" + token[-4:] if len(token) >= 4 else "****"
1391
+ return CommandResult(
1392
+ handled=True,
1393
+ output=(
1394
+ f"Proxy token set for this session ([cyan]{masked}[/cyan]). "
1395
+ "[yellow]Keychain save failed — token will not persist.[/yellow]"
1396
+ ),
1397
+ proxy_token=token,
1398
+ )