agent-decoder 0.3.2__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 (89) hide show
  1. agent_decoder-0.3.2.dist-info/METADATA +387 -0
  2. agent_decoder-0.3.2.dist-info/RECORD +89 -0
  3. agent_decoder-0.3.2.dist-info/WHEEL +4 -0
  4. agent_decoder-0.3.2.dist-info/entry_points.txt +2 -0
  5. agent_decoder-0.3.2.dist-info/licenses/LICENSE +111 -0
  6. clau_decode/__init__.py +8 -0
  7. clau_decode/__main__.py +11 -0
  8. clau_decode/_auth_env.py +118 -0
  9. clau_decode/_pty_preexec.py +42 -0
  10. clau_decode/analytics/__init__.py +0 -0
  11. clau_decode/analytics/aggregator.py +55 -0
  12. clau_decode/analytics/cost.py +76 -0
  13. clau_decode/analytics/extractor.py +32 -0
  14. clau_decode/analytics/fast.py +289 -0
  15. clau_decode/analytics/models.py +49 -0
  16. clau_decode/analytics/pricing.py +162 -0
  17. clau_decode/analytics/prompt.py +34 -0
  18. clau_decode/analytics/service.py +23 -0
  19. clau_decode/analytics/stats.py +117 -0
  20. clau_decode/analytics/tips.py +176 -0
  21. clau_decode/btw_capture.py +290 -0
  22. clau_decode/cli.py +504 -0
  23. clau_decode/config.py +144 -0
  24. clau_decode/db.py +2054 -0
  25. clau_decode/driver_manager.py +387 -0
  26. clau_decode/drivers/__init__.py +27 -0
  27. clau_decode/drivers/base.py +170 -0
  28. clau_decode/drivers/registry.py +113 -0
  29. clau_decode/drivers/tmux_driver.py +488 -0
  30. clau_decode/editor.py +182 -0
  31. clau_decode/events_bus.py +69 -0
  32. clau_decode/locks.py +388 -0
  33. clau_decode/migrate.py +1313 -0
  34. clau_decode/models.py +285 -0
  35. clau_decode/parser.py +466 -0
  36. clau_decode/providers/__init__.py +30 -0
  37. clau_decode/providers/base.py +132 -0
  38. clau_decode/providers/claude.py +81 -0
  39. clau_decode/providers/codex.py +441 -0
  40. clau_decode/providers/registry.py +89 -0
  41. clau_decode/pty_native.py +39 -0
  42. clau_decode/pty_runner.py +2406 -0
  43. clau_decode/pty_screen_state.py +65 -0
  44. clau_decode/recap_runner.py +230 -0
  45. clau_decode/reporter.py +305 -0
  46. clau_decode/scanner.py +125 -0
  47. clau_decode/server.py +2841 -0
  48. clau_decode/static/android-chrome-192x192.png +0 -0
  49. clau_decode/static/android-chrome-512x512.png +0 -0
  50. clau_decode/static/app-icon.png +0 -0
  51. clau_decode/static/apple-touch-icon.png +0 -0
  52. clau_decode/static/assets/ChatView-BBVXuITs.js +79 -0
  53. clau_decode/static/assets/ChatView-BrP-ENHg.css +1 -0
  54. clau_decode/static/assets/Dashboard-3CYO4HQw.js +1 -0
  55. clau_decode/static/assets/FileViewer-BwbDc3ky.js +1 -0
  56. clau_decode/static/assets/HelpPopup-CPtECjYw.js +1 -0
  57. clau_decode/static/assets/ScrollContainer-BT_Dzxah.js +1 -0
  58. clau_decode/static/assets/SearchOverlay-DqI7aZaC.js +1 -0
  59. clau_decode/static/assets/SessionItem-DlyKg80u.js +1 -0
  60. clau_decode/static/assets/SettingsModal-CZBfMqx7.js +1 -0
  61. clau_decode/static/assets/ShortcutsPopup-W7CPdCzW.js +1 -0
  62. clau_decode/static/assets/Sidebar-BBswhEua.js +1 -0
  63. clau_decode/static/assets/client-DTSXvAb8.js +1 -0
  64. clau_decode/static/assets/fira-code-DGosTW8U.woff2 +0 -0
  65. clau_decode/static/assets/formatRelative-j86webkr.js +1 -0
  66. clau_decode/static/assets/hljs-B3O74O7Z.js +35 -0
  67. clau_decode/static/assets/index-BHdCOIkq.css +10 -0
  68. clau_decode/static/assets/index-DfKltZ6z.js +5 -0
  69. clau_decode/static/assets/ioskeley-mono-C7VR2-sL.woff2 +0 -0
  70. clau_decode/static/assets/jetbrains-mono-V6pRDFza.woff2 +0 -0
  71. clau_decode/static/assets/julia-mono-Cbvba6sW.woff2 +0 -0
  72. clau_decode/static/assets/libertinus-mono-Coz17E-O.woff2 +0 -0
  73. clau_decode/static/assets/monaspace-argon-RS-2TGNg.woff2 +0 -0
  74. clau_decode/static/assets/nativePtyFonts-BaXKtUsn.js +1 -0
  75. clau_decode/static/assets/react-Bi_azaFt.js +9 -0
  76. clau_decode/static/assets/renderSnippet-CqpQQugH.js +1 -0
  77. clau_decode/static/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
  78. clau_decode/static/assets/shared-DdVGprvF.js +1 -0
  79. clau_decode/static/assets/source-code-pro-DEXWLoHG.woff2 +0 -0
  80. clau_decode/static/assets/spline-sans-mono-mUpA6Mve.woff2 +0 -0
  81. clau_decode/static/assets/ui-BcGPjpYn.js +1 -0
  82. clau_decode/static/assets/xanh-mono-Dsg19vWS.woff2 +0 -0
  83. clau_decode/static/favicon-16x16.png +0 -0
  84. clau_decode/static/favicon-32x32.png +0 -0
  85. clau_decode/static/favicon.ico +0 -0
  86. clau_decode/static/index.html +24 -0
  87. clau_decode/static/site.webmanifest +1 -0
  88. clau_decode/update_check.py +51 -0
  89. clau_decode/watcher.py +63 -0
clau_decode/server.py ADDED
@@ -0,0 +1,2841 @@
1
+ """FastAPI application — HTTP API + static file serving.
2
+
3
+ Contract (for Agent 2 to implement):
4
+ create_app(config: AppConfig, db_path: Path) -> FastAPI
5
+ - Mount /api routes
6
+ - Mount static files at / (serves the built React app)
7
+ - On startup: init DB schema, trigger first scan, start file watcher task
8
+
9
+ Routes to implement:
10
+ GET /api/health → {"ok": true}
11
+ GET /api/config → AppConfig
12
+ PUT /api/config (body: AppConfig) → AppConfig (saves + returns updated)
13
+ GET /api/projects → list[Project]
14
+ GET /api/projects/{project_id}/sessions → list[Session]
15
+ GET /api/sessions/{session_id} → SessionDetail
16
+ GET /api/search?q=&project=&limit= → list[SearchHit]
17
+ GET /api/stats → StatsResponse
18
+ POST /api/refresh → {"ok": true} (re-scans all paths)
19
+ GET /api/events → SSE stream (text/event-stream)
20
+ — emits {"type": "refresh"} when a JSONL file changes
21
+
22
+ SOLID notes:
23
+ - Dependency Inversion: routes receive Database as a FastAPI Depends, not import
24
+ - Open/Closed: add routes without touching existing ones
25
+ - server.py does not contain business logic — it delegates to db/scanner/parser
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import ipaddress
32
+ import json
33
+ import logging
34
+ import os
35
+ import shlex
36
+ import shutil
37
+ import signal
38
+ import subprocess
39
+ import sys
40
+ import time
41
+ from collections import OrderedDict
42
+ from contextlib import asynccontextmanager
43
+ from dataclasses import dataclass
44
+ from pathlib import Path
45
+
46
+ from fastapi import FastAPI, HTTPException, Query, Request
47
+ from fastapi.middleware.gzip import GZipMiddleware
48
+ from fastapi.responses import Response, StreamingResponse
49
+
50
+ from pydantic import BaseModel, Field
51
+
52
+ from .analytics.cost import CostEngine
53
+ from .recap_runner import generate_recap as _generate_recap
54
+ from . import __version__
55
+ from .pty_runner import (
56
+ DEFAULT_ROWS,
57
+ PtyManager,
58
+ PtyOwnershipConflict,
59
+ PtySubmitInFlight,
60
+ _session_conflict_pids,
61
+ _unlink_fresh_foreign_sidecar,
62
+ )
63
+ from .pty_native import decode_terminal_input
64
+ from .driver_manager import DriverManager
65
+ from .drivers import availability_for as _driver_availability
66
+ from .drivers import supports_driving as _driver_supports
67
+ from .analytics import fast as analytics_fast
68
+ from .analytics.pricing import CachedPricingStrategy, _HARDCODED_RATES
69
+ from .update_check import fetch_latest_version, is_newer
70
+ from .analytics.service import TokenAnalyticsService as _AnalyticsSvc
71
+ from .analytics.stats import (
72
+ ModelUsageScanner,
73
+ )
74
+ from .analytics.tips import (
75
+ LowCacheHitRule,
76
+ OversizedToolResultRule,
77
+ RepeatedFileReadRule,
78
+ TipRegistry,
79
+ )
80
+ from .config import save_config
81
+ from .db import Database
82
+ from .editor import swap_session
83
+ from .events_bus import EventBroadcaster
84
+ from .models import AppConfig, Profile
85
+ from .providers import register_builtins, registry
86
+ from .reporter import export_json, export_markdown
87
+ from .scanner import build_project_from_dir
88
+ from .watcher import watch_paths
89
+
90
+
91
+ _log = logging.getLogger(__name__)
92
+
93
+
94
+ def _sse_event_data(path: Path | str) -> str:
95
+ """Serialize a file-change path to an SSE JSON payload.
96
+
97
+ Extracted so tests can pin this exact contract: if the field names or types
98
+ change here, the frontend's ``data.type === "refresh"`` check silently breaks.
99
+ """
100
+ return json.dumps({"type": "refresh", "path": str(path)})
101
+
102
+
103
+ def _derive_bin_name(file_path: str) -> str:
104
+ """Walk up from a session's JSONL file_path to find the claude binary name.
105
+
106
+ ~/.claude/projects/... → .claude → claude
107
+ ~/.cc-mirror/zai/config/projects/... → zai
108
+
109
+ If the inferred name isn't on PATH (e.g. demo data living under a
110
+ `demo-data/projects/` tree where the parent isn't a real CLI), fall back
111
+ to the plain `claude` binary.
112
+ """
113
+ import shutil as _shutil
114
+
115
+ parts = Path(file_path).parts
116
+ for i, p in enumerate(parts):
117
+ if p == "projects":
118
+ j = i - 1
119
+ while j >= 0 and parts[j] == "config":
120
+ j -= 1
121
+ if j >= 0:
122
+ candidate = parts[j].lstrip(".")
123
+ if _shutil.which(candidate) is not None:
124
+ return candidate
125
+ break
126
+ return "claude"
127
+
128
+
129
+ def _derive_bin_from_data_path(data_path: str) -> str:
130
+ """Derive a CLI bin_name from a profile's data_path directory.
131
+
132
+ Same conventions as ``_derive_bin_name`` but accepts the data-root dir
133
+ (e.g. ``~/.cc-mirror/zai/config`` or ``~/.claude``) instead of a JSONL
134
+ file path. Implemented by synthesizing a fake ``<path>/projects/_.jsonl``
135
+ so a single resolver covers both call sites.
136
+ """
137
+ expanded = str(Path(data_path).expanduser())
138
+ return _derive_bin_name(str(Path(expanded) / "projects" / "_.jsonl"))
139
+
140
+
141
+ def _active_profile_bin_name(config: AppConfig) -> str:
142
+ """Resolve the CLI bin_name to use for a brand-new session.
143
+
144
+ Inspects ``config.active_profile_id`` and the matching profile's first
145
+ ``data_paths`` entry (cc-mirror profiles have a single canonical data
146
+ root per CLI install). Falls back to plain ``claude`` when no profile is
147
+ active or no data_paths are configured.
148
+
149
+ This mirrors the per-profile binary selection that ``_derive_bin_name``
150
+ performs for EXISTING sessions (whose JSONL path encodes the writer
151
+ binary). For new sessions there is no JSONL yet, so the active profile
152
+ is the only signal available.
153
+ """
154
+ if config.active_profile_id and config.profiles:
155
+ for p in config.profiles:
156
+ if p.id == config.active_profile_id and p.data_paths:
157
+ return _derive_bin_from_data_path(p.data_paths[0])
158
+ # No active profile (or legacy config with only top-level data_paths).
159
+ if config.data_paths:
160
+ return _derive_bin_from_data_path(config.data_paths[0])
161
+ return "claude"
162
+
163
+
164
+ def _config_dir_for_bin(config: AppConfig, bin_name: str) -> Path:
165
+ """Resolve the ``CLAUDE_CONFIG_DIR``-equivalent for a CLI bin_name.
166
+
167
+ The directory holds the ``.claude.json`` claude reads at startup
168
+ (containing per-project trust flags etc.). For cc-mirror profiles
169
+ this is the profile's first ``data_paths`` entry; for vanilla
170
+ ``claude`` it's ``~/.claude``.
171
+
172
+ Strategy: find the profile whose first data_path resolves to the
173
+ same bin_name. If none matches, fall back to the active profile.
174
+ Last resort: ``~/.claude``.
175
+ """
176
+ candidates: list[Path] = []
177
+ if config.profiles:
178
+ for p in config.profiles:
179
+ if not p.data_paths:
180
+ continue
181
+ if _derive_bin_from_data_path(p.data_paths[0]) == bin_name:
182
+ return Path(p.data_paths[0]).expanduser()
183
+ # No bin match — fall back to active profile.
184
+ for p in config.profiles:
185
+ if p.id == config.active_profile_id and p.data_paths:
186
+ candidates.append(Path(p.data_paths[0]).expanduser())
187
+ if candidates:
188
+ return candidates[0]
189
+ if config.data_paths:
190
+ return Path(config.data_paths[0]).expanduser()
191
+ return Path("~/.claude").expanduser()
192
+
193
+
194
+ def _ensure_trust(config_dir: Path, cwd: str) -> bool:
195
+ """Mark ``cwd`` as trusted in ``<config_dir>/.claude.json``.
196
+
197
+ Returns True if a write happened, False if the cwd was already
198
+ trusted (or trust state was already True). claude's TUI shows a
199
+ "trust this folder" dialog on first launch in an untrusted cwd; if
200
+ we don't pre-empt it, our first user message gets consumed by the
201
+ dialog's Enter binding and never reaches the chat input.
202
+
203
+ clau-decode owns the chat-spawn gesture (click ``+``, choose/inherit
204
+ a cwd, hit send) so the trust signal is implicit in the UI flow —
205
+ this just plumbs that intent through to claude's config.
206
+
207
+ Atomic via temp-file + rename. Tolerates a missing file (creates
208
+ a minimal one). Re-raises on malformed JSON (caller should let it
209
+ surface; we don't want to silently overwrite a corrupted user file).
210
+ """
211
+ import json as _json
212
+ import os as _os
213
+
214
+ path = config_dir / ".claude.json"
215
+ if path.exists():
216
+ with path.open("r") as f:
217
+ data = _json.load(f)
218
+ if not isinstance(data, dict):
219
+ raise RuntimeError(f"{path}: expected top-level object")
220
+ else:
221
+ data = {}
222
+
223
+ projects = data.setdefault("projects", {})
224
+ if not isinstance(projects, dict):
225
+ raise RuntimeError(f"{path}: 'projects' is not an object")
226
+ proj = projects.setdefault(cwd, {})
227
+ if not isinstance(proj, dict):
228
+ raise RuntimeError(f"{path}: projects[{cwd!r}] is not an object")
229
+ if proj.get("hasTrustDialogAccepted") is True:
230
+ return False
231
+ proj["hasTrustDialogAccepted"] = True
232
+
233
+ tmp = path.with_suffix(path.suffix + ".tmp")
234
+ with tmp.open("w") as f:
235
+ _json.dump(data, f, indent=2)
236
+ _os.replace(tmp, path)
237
+ return True
238
+
239
+
240
+ def _extract_worktree_name(file_path: str, cwd: str | None) -> str | None:
241
+ """Extract the worktree name (e.g. 'pr-160-review') from a worktree session."""
242
+ # Try cwd first — the actual filesystem path is more reliable.
243
+ if cwd:
244
+ marker = "/.claude/worktrees/"
245
+ idx = cwd.find(marker)
246
+ if idx >= 0:
247
+ return cwd[idx + len(marker) :]
248
+ # Fall back to the mangled project directory name in the file_path.
249
+ parts = Path(file_path).parts
250
+ for i, p in enumerate(parts):
251
+ if p == "projects" and i + 1 < len(parts):
252
+ mangled = parts[i + 1]
253
+ marker = "worktrees-"
254
+ idx = mangled.find(marker)
255
+ if idx >= 0:
256
+ return mangled[idx + len(marker) :]
257
+ break
258
+ return None
259
+
260
+
261
+ def _reject_root_cwd(cwd: str, action: str) -> None:
262
+ """Raise HTTP 400 if ``cwd`` resolves to the filesystem root.
263
+
264
+ A session with cwd "/" is almost always degenerate (no working dir
265
+ recorded); spawning a CLI there triggers claude's "trust this folder?"
266
+ prompt for "/" and is never what the user intended.
267
+ """
268
+ if os.path.realpath(cwd) == os.sep:
269
+ raise HTTPException(
270
+ status_code=400,
271
+ detail=f"Refusing to {action} at the filesystem root (/)",
272
+ )
273
+
274
+
275
+ class _MessageContentUpdate(BaseModel):
276
+ content_blocks: list[dict]
277
+
278
+
279
+ class _CreateProfileRequest(BaseModel):
280
+ name: str
281
+ data_paths: list[str] = Field(default_factory=list)
282
+ color: str = "#e9733a"
283
+
284
+
285
+ class _UpdateProfileRequest(BaseModel):
286
+ name: str | None = None
287
+ data_paths: list[str] | None = None
288
+ color: str | None = None
289
+
290
+
291
+ class _SetActiveProfileRequest(BaseModel):
292
+ active_profile_id: str | None = None
293
+
294
+
295
+ class _NewSessionRequest(BaseModel):
296
+ # All optional — defaults are "last-used cwd" + AppConfig permission mode.
297
+ # No initial_message: brand-new sessions stay empty until the user types
298
+ # their first message (see issue #9 fix — auto-greeting was wrong).
299
+ cwd: str | None = None
300
+ permission_mode: str | None = None
301
+ # Provider for the new chat. Defaults to the active profile (Claude). When
302
+ # the user hits "+" from inside a Codex chat the FE passes "codex" so the
303
+ # new chat stays Codex instead of falling back to the active profile.
304
+ provider: str | None = None
305
+
306
+
307
+ @dataclass(frozen=True)
308
+ class _PendingSession:
309
+ """Metadata for a session that's been minted but not yet materialised.
310
+
311
+ Lives in a small in-memory map (``app.state.pending_sessions``) keyed by
312
+ session id. Entries are created by ``POST /api/sessions/new`` and consumed
313
+ by the first PTY submit; nothing else touches them, so we don't need a TTL
314
+ — server restarts wipe the map.
315
+ """
316
+
317
+ cwd: str
318
+ permission_mode: str
319
+ bin_name: str
320
+
321
+
322
+ class _SessionTitleRequest(BaseModel):
323
+ # Pydantic enforces "must be string or null"; non-string/non-null bodies
324
+ # (e.g. ``{"title": 123}``) produce 422 automatically. Empty / whitespace
325
+ # strings are accepted and treated as a clear by the DB helper.
326
+ title: str | None
327
+
328
+
329
+ class _SessionArchivedRequest(BaseModel):
330
+ archived: bool
331
+
332
+
333
+ class _SessionStarredRequest(BaseModel):
334
+ starred: bool
335
+
336
+
337
+ class _SessionViewedRequest(BaseModel):
338
+ # Explicit ISO timestamp lets the FE record "viewed at message_updated_at"
339
+ # rather than "viewed at now"; None clears.
340
+ viewed_at: str | None
341
+
342
+
343
+ class _LocalStorageMigrationRequest(BaseModel):
344
+ """One-time payload uploaded by the FE the first time it loads with the
345
+ server-backed meta. Maps existing localStorage flags into session_meta."""
346
+
347
+ archived: list[str] = []
348
+ starred: list[str] = []
349
+ viewed_at: dict[str, str] = {}
350
+
351
+
352
+ class _SessionDeleteRequest(BaseModel):
353
+ session_ids: list[str]
354
+
355
+
356
+ class _FsWriteBody(BaseModel):
357
+ # Module-scope: BaseModels declared inside create_app() are not picked up
358
+ # by FastAPI as request-body params (they get treated as query params and
359
+ # 422 with `loc: ["query", "body"]`).
360
+ path: str
361
+ content: str
362
+
363
+
364
+ class _PtyFocusRequest(BaseModel):
365
+ session_id: str
366
+ cwd: str | None = None
367
+ bin_name: str | None = None
368
+ model: str | None = None
369
+ permission_mode: str | None = None
370
+ new_chat: bool = False
371
+ # Native view fits the terminal to the pane before spawning and sends the
372
+ # fitted row count here, so the PTY spawns at its final height (no
373
+ # spawn-then-grow that smears the revealed rows / strands the footer).
374
+ # Omitted by non-native callers → PtyManager.focus() uses its default.
375
+ rows: int | None = Field(default=None, gt=0, le=200)
376
+
377
+
378
+ class _PtyBlurRequest(BaseModel):
379
+ session_id: str
380
+
381
+
382
+ class _PtySubmitRequest(BaseModel):
383
+ session_id: str
384
+ content: str
385
+ model: str | None = None
386
+
387
+
388
+ class _PtyKillRequest(BaseModel):
389
+ session_id: str
390
+
391
+
392
+ class _PtyNativeInputRequest(BaseModel):
393
+ session_id: str
394
+ data: str
395
+
396
+
397
+ class _PtyResizeRequest(BaseModel):
398
+ session_id: str
399
+ rows: int = Field(gt=0, le=200)
400
+ cols: int = Field(gt=0, le=400)
401
+
402
+
403
+ def create_app(config: AppConfig, db_path: Path) -> FastAPI:
404
+ """Build and return the FastAPI application instance.
405
+
406
+ Wires together all subsystems:
407
+ - Database initialisation and schema migration on startup.
408
+ - Initial filesystem scan on startup.
409
+ - Background file-watcher task that feeds the SSE event queue.
410
+ - All /api/* routes.
411
+ - Optional static file serving from ``src/clau_decode/static/`` (the built
412
+ React frontend, if present).
413
+
414
+ Args:
415
+ config: The runtime ``AppConfig`` (port, data_paths, theme, …).
416
+ db_path: Absolute path to the SQLite database file.
417
+
418
+ Returns:
419
+ A fully configured ``FastAPI`` application ready to hand to uvicorn.
420
+ """
421
+ # Ensure all built-in provider adapters are registered before any scan
422
+ # or lifespan task uses registry.all_adapters().
423
+ register_builtins()
424
+
425
+ # Mutable shared state captured by closures below.
426
+ _state: dict = {"config": config, "watch_task": None, "fanout_task": None}
427
+ _analytics = _AnalyticsSvc()
428
+ _pricing_strat = CachedPricingStrategy()
429
+ _cost_engine = CostEngine(_pricing_strat)
430
+ # Intermediate queue: the watcher writes WatchEvent objects here; a small
431
+ # background fan-out task scans the file and republishes the (now indexed)
432
+ # change to every connected SSE client via the broadcaster.
433
+ _watch_queue: asyncio.Queue = asyncio.Queue()
434
+ _bus = EventBroadcaster()
435
+ _pty_manager: PtyManager | None = None # populated in lifespan
436
+ # DriverManager owns live ProviderDriver sessions (Codex via tmux). Claude
437
+ # never enters it — it keeps its direct-PTY path. Populated in lifespan.
438
+ _driver_manager: DriverManager | None = None
439
+ # Tombstone set: session ids that have been hard-deleted are recorded here
440
+ # so that concurrent/queued scan tasks never re-upsert a deleted session.
441
+ # Scoped to the create_app() closure — one set per app instance.
442
+ _deleted_tombstones: set[str] = set()
443
+
444
+ # ------------------------------------------------------------------
445
+ # Internal helpers
446
+ # ------------------------------------------------------------------
447
+
448
+ def _all_scan_roots() -> list[Path]:
449
+ """User-configured paths."""
450
+ return [Path(p).expanduser() for p in _state["config"].get_all_scan_paths()]
451
+
452
+ async def do_scan(db: Database) -> int:
453
+ """Scan all configured paths and upsert new / changed sessions into DB.
454
+
455
+ Returns the number of sessions (re)indexed this pass, so callers — e.g.
456
+ the periodic safety-net rescan — can skip publishing a refresh when
457
+ nothing changed.
458
+ """
459
+ MAX_SCAN_SIZE = 20 * 1024 * 1024 # skip files > 20 MB; loaded on demand instead
460
+ changed = 0
461
+ for adapter in registry.all_adapters():
462
+ roots = adapter.configured_roots(_state["config"])
463
+ async for project, session_path in adapter.discover(roots):
464
+ if session_path.stem in _deleted_tombstones:
465
+ continue
466
+ try:
467
+ current_mtime = session_path.stat().st_mtime
468
+ stored_mtime = await db.get_session_mtime(session_path.stem)
469
+ if stored_mtime == current_mtime:
470
+ continue
471
+ file_size = session_path.stat().st_size
472
+ if file_size > MAX_SCAN_SIZE:
473
+ # Store just the session metadata so it appears in the list;
474
+ # messages are loaded on demand via _scan_one when clicked.
475
+ if stored_mtime is None:
476
+ session, _ = await asyncio.to_thread(
477
+ adapter.parse, session_path
478
+ )
479
+ session.project_id = project.id
480
+ session.message_count = 0
481
+ project.session_count += 1
482
+ await db.upsert_project(project)
483
+ await db.upsert_session(session, file_mtime=current_mtime)
484
+ changed += 1
485
+ continue
486
+ session, messages = await asyncio.to_thread(
487
+ adapter.parse, session_path
488
+ )
489
+ session.project_id = project.id
490
+ project.session_count += 1
491
+ await db.upsert_project(project)
492
+ await db.upsert_session(session, file_mtime=current_mtime)
493
+ await db.upsert_messages(messages)
494
+ changed += 1
495
+ await asyncio.sleep(0)
496
+ except Exception as exc:
497
+ print(f"Warning: skipping {session_path}: {exc}")
498
+ return changed
499
+
500
+ async def _scan_one(db: Database, session_path: Path) -> None:
501
+ """Re-parse a single changed JSONL file and upsert it into the DB.
502
+
503
+ Much faster than do_scan for live updates: one stat + one DB lookup
504
+ instead of iterating every session file to find what changed.
505
+ Path structure assumed: <root>/projects/<project-dir>/<session>.jsonl
506
+ """
507
+ if not session_path.exists() or session_path.suffix != ".jsonl":
508
+ return
509
+ if session_path.stem in _deleted_tombstones:
510
+ return
511
+ try:
512
+ project_dir = session_path.parent
513
+ root_path = project_dir.parent.parent # <root>/projects/<proj>/<session>
514
+ project = build_project_from_dir(project_dir.name, str(root_path))
515
+ current_mtime = session_path.stat().st_mtime
516
+ stored_mtime = await db.get_session_mtime(session_path.stem)
517
+ if stored_mtime == current_mtime:
518
+ return
519
+ adapter = registry.adapter_for_path(session_path) or registry.get("claude")
520
+ session, messages = await asyncio.to_thread(adapter.parse, session_path)
521
+ session.project_id = project.id
522
+ project.session_count += 1
523
+ await db.upsert_project(project)
524
+ await db.upsert_session(session, file_mtime=current_mtime)
525
+ await db.upsert_messages(messages)
526
+ except Exception as exc:
527
+ print(f"Warning: skipping {session_path}: {exc}")
528
+
529
+ async def _rescan_and_rewatch() -> None:
530
+ """Rescan all paths and restart the watcher with updated paths."""
531
+ # Cancel existing watcher
532
+ old_task = _state.get("watch_task")
533
+ if old_task and not old_task.done():
534
+ old_task.cancel()
535
+ # Rescan
536
+ async with Database(db_path) as db:
537
+ await do_scan(db)
538
+ # Restart watcher with current paths.
539
+ _state["watch_task"] = asyncio.create_task(
540
+ watch_paths(_all_scan_roots(), _watch_queue)
541
+ )
542
+
543
+ # ------------------------------------------------------------------
544
+ # Lifecycle
545
+ # ------------------------------------------------------------------
546
+
547
+ @asynccontextmanager
548
+ async def lifespan(app: FastAPI):
549
+ async with Database(db_path) as db:
550
+ await db.init_schema()
551
+ await db.reset_xml_title_mtimes()
552
+ await db.reset_truncated_titles()
553
+ await db.migrate_project_id_v2()
554
+ await db.migrate_materialize_v1()
555
+ await db.migrate_fts_rowid_v1()
556
+ # No long-lived readers are open yet at startup, so this is the
557
+ # cheapest moment to TRUNCATE any WAL bloat left by a prior run.
558
+ await db.checkpoint()
559
+
560
+ async def _background_scan() -> None:
561
+ async with Database(db_path) as db:
562
+ await do_scan(db)
563
+
564
+ asyncio.create_task(_background_scan())
565
+ _state["watch_task"] = asyncio.create_task(
566
+ watch_paths(_all_scan_roots(), _watch_queue)
567
+ )
568
+
569
+ async def _fanout_watcher_events() -> None:
570
+ """Drain the watcher queue and republish indexed events to all clients.
571
+
572
+ One task per app (not per client) so the DB re-index runs once per
573
+ file change regardless of how many viewers are connected.
574
+
575
+ Burst coalescing: a streaming turn (claude is spawned with
576
+ ``--include-partial-messages``) rewrites its JSONL many times per
577
+ second. Re-parsing the whole file on every watch event pinned the
578
+ event loop near 80% CPU. Instead we block for the first event, wait
579
+ a short window for the burst to accumulate, dedupe by path, and
580
+ re-index each distinct path once — collapsing ~20 reparses/sec into
581
+ a handful while keeping the reader's view live. Emitting a single
582
+ refresh per path (rather than per event) also throttles the FE's
583
+ on-demand refetch, which itself reparses via GET /api/sessions/{id}.
584
+
585
+ A fresh DB connection is opened per burst (not one held for the
586
+ life of the server) so we don't pin a read snapshot between bursts,
587
+ which would block wal_checkpoint(TRUNCATE) and let index.db-wal
588
+ grow without bound.
589
+ """
590
+ COALESCE_WINDOW_S = 0.2
591
+ while True:
592
+ first = await _watch_queue.get()
593
+ paths = {first.path}
594
+ # Let the rest of the burst land, then coalesce by path.
595
+ await asyncio.sleep(COALESCE_WINDOW_S)
596
+ while True:
597
+ try:
598
+ nxt = _watch_queue.get_nowait()
599
+ except asyncio.QueueEmpty:
600
+ break
601
+ paths.add(nxt.path)
602
+ async with Database(db_path) as db:
603
+ for path in paths:
604
+ try:
605
+ await _scan_one(db, path)
606
+ except Exception as exc:
607
+ print(f"Warning: scan_one failed for {path}: {exc}")
608
+ _bus.publish({"type": "refresh", "path": str(path)})
609
+
610
+ _state["fanout_task"] = asyncio.create_task(_fanout_watcher_events())
611
+
612
+ # Safety net behind the live watcher. The watcher only catches changes
613
+ # while the server is running AND only if the OS delivers the event;
614
+ # anything that changed while clau-decode was down, or any watch event
615
+ # the kernel coalesced/dropped, would otherwise stay stale until the
616
+ # next restart (the "session shows no/old history even though it
617
+ # resumes fine in the terminal" symptom). This periodic pass re-stats
618
+ # every session file across ALL profile data paths and re-parses only
619
+ # those whose mtime moved, so the index self-heals without a restart.
620
+ # do_scan returns the changed count, so connected clients are nudged to
621
+ # refetch only when something actually changed.
622
+ PERIODIC_RESCAN_S = 60.0
623
+
624
+ async def _periodic_rescan() -> None:
625
+ while True:
626
+ await asyncio.sleep(PERIODIC_RESCAN_S)
627
+ changed = 0
628
+ try:
629
+ # Fresh connection per pass so we don't hold a read
630
+ # snapshot across the sleep, which would block
631
+ # wal_checkpoint(TRUNCATE) and let index.db-wal grow.
632
+ async with Database(db_path) as db:
633
+ changed = await do_scan(db)
634
+ except Exception as exc:
635
+ print(f"Warning: periodic rescan failed: {exc}")
636
+ continue
637
+ if changed:
638
+ _bus.publish({"type": "refresh", "path": "periodic-rescan"})
639
+
640
+ _state["periodic_rescan_task"] = asyncio.create_task(_periodic_rescan())
641
+
642
+ # Fold the WAL back into index.db and truncate index.db-wal on a timer.
643
+ # SQLite's default wal_autocheckpoint is passive and only fires on
644
+ # commit, so it is easily pinned by a held read snapshot — and the
645
+ # PtyManager/DriverManager connection below lives for the whole server
646
+ # lifetime. This dedicated short-lived connection owns no snapshot, so
647
+ # TRUNCATE can shrink the WAL that autocheckpoint can't. Without it
648
+ # index.db-wal grew to ~22 GB.
649
+ PERIODIC_CHECKPOINT_S = 600.0
650
+
651
+ async def _periodic_checkpoint() -> None:
652
+ while True:
653
+ await asyncio.sleep(PERIODIC_CHECKPOINT_S)
654
+ try:
655
+ async with Database(db_path) as db:
656
+ await db.checkpoint()
657
+ except Exception as exc:
658
+ print(f"Warning: periodic checkpoint failed: {exc}")
659
+
660
+ _state["periodic_checkpoint_task"] = asyncio.create_task(_periodic_checkpoint())
661
+
662
+ async def _refresh_pricing() -> None:
663
+ await _pricing_strat.refresh()
664
+
665
+ asyncio.create_task(_refresh_pricing())
666
+ nonlocal _pty_manager, _driver_manager
667
+ # ui_endpoint is written into Phase-1 lock sidecars so a peer
668
+ # clau-decode reading the lock can render "open in UI at ..."
669
+ # in its take-over banner. Best-effort — config.host of
670
+ # "0.0.0.0" gets normalised to "127.0.0.1" for the URL.
671
+ _ui_host = "127.0.0.1" if config.host in ("0.0.0.0", "::") else config.host
672
+ # PtyManager holds a long-lived Database reference for Phase 2 /btw
673
+ # capture (`record_ephemeral_input` / `record_ephemeral_response`).
674
+ # The async-context-manager MUST be entered here so ``_conn`` is
675
+ # opened — passing a bare ``Database(db_path)`` would leave the
676
+ # connection None and silently fail every ephemeral write. Routes
677
+ # continue to use their own per-request ``async with Database(...)``
678
+ # contexts; SQLite WAL mode keeps the long-lived + per-request
679
+ # connections from contending.
680
+ async with Database(db_path) as _pty_db:
681
+ _pty_manager = PtyManager(
682
+ _pty_db,
683
+ _bus,
684
+ ui_endpoint=f"http://{_ui_host}:{config.port}",
685
+ )
686
+ app.state.pty_manager = _pty_manager
687
+ _pty_manager.set_native_cols(_state["config"].native_pty_cols)
688
+ # DriverManager shares the bus + a long-lived DB handle. No idle
689
+ # reaper: tmux-backed sessions survive disconnect/idle by design,
690
+ # so the "5-min reaper kills long tasks" bug cannot recur here.
691
+ _driver_manager = DriverManager(
692
+ _pty_db,
693
+ _bus,
694
+ ui_endpoint=f"http://{_ui_host}:{config.port}",
695
+ )
696
+ app.state.driver_manager = _driver_manager
697
+ _driver_manager.set_native_cols(_state["config"].native_pty_cols)
698
+ try:
699
+ yield
700
+ finally:
701
+ if _driver_manager is not None:
702
+ await _driver_manager.shutdown()
703
+ if _pty_manager is not None:
704
+ await _pty_manager.shutdown()
705
+
706
+ app = FastAPI(title="Clau-Decode", version="0.1.0", lifespan=lifespan)
707
+ # Session-detail responses are megabytes of JSON for old chats; gzip cuts
708
+ # transfer time by ~10x. minimum_size avoids overhead for tiny payloads.
709
+ app.add_middleware(GZipMiddleware, minimum_size=1024)
710
+
711
+ # Pending-session map (issue #9 fix): /api/sessions/new mints metadata into
712
+ # this dict; the first PTY submit for that id materializes the session with
713
+ # --session-id. Guarded by an asyncio lock so two concurrent submits for the
714
+ # same fresh id can't both claim it.
715
+ app.state.pending_sessions = {}
716
+ app.state.pending_sessions_lock = asyncio.Lock()
717
+
718
+ # Session ids currently routed to the DriverManager (driver-backed, e.g.
719
+ # Codex). Populated on a successful driver focus; cleared on kill. Hot-path
720
+ # native endpoints (input/resize/snapshot/status/kill) consult this O(1)
721
+ # set to pick the manager without a per-call DB lookup.
722
+ app.state.driver_sessions = set()
723
+
724
+ # LRU cache of pre-serialized SessionDetail responses, keyed by session id
725
+ # with file mtime as the validation token. A hit skips the SQL fetch,
726
+ # JSON parse, Pydantic round-trip, and re-serialization entirely.
727
+ _detail_cache: "OrderedDict[str, tuple[float, bytes]]" = OrderedDict()
728
+ _DETAIL_CACHE_MAX = 4
729
+
730
+ def _invalidate_detail_cache(session_id: str) -> None:
731
+ _detail_cache.pop(session_id, None)
732
+
733
+ # Whole-corpus analytics cache. Six endpoints (daily/stats/models/tools/
734
+ # files/tips) each used to run `SELECT * FROM messages` and scan all
735
+ # ~145k rows in Python — independently, uncached, on every dashboard view
736
+ # (~6-12s each). We now load the corpus once and compute the whole bundle
737
+ # behind a lock, keyed on a cheap sessions-table signature. Cache hits are
738
+ # the common case (dashboard re-opened, data unchanged → instant); the
739
+ # bundle recomputes only when a session is added/removed/re-indexed.
740
+ _analytics_cache: dict = {}
741
+ _analytics_cache_sig: list = [None] # boxed so the closure can rebind it
742
+ _analytics_lock = asyncio.Lock()
743
+
744
+ async def _analytics_bundle(db: Database) -> dict:
745
+ sig = await db.analytics_signature()
746
+ if _analytics_cache_sig[0] == sig and _analytics_cache:
747
+ return _analytics_cache
748
+ async with _analytics_lock:
749
+ # Re-check: a concurrent request may have filled the cache while
750
+ # we waited for the lock (browsers fetch all six in parallel).
751
+ if _analytics_cache_sig[0] == sig and _analytics_cache:
752
+ return _analytics_cache
753
+ # SQL-backed aggregation: two corpus reads (usage + content) in
754
+ # the engine instead of loading 145k pydantic Message objects.
755
+ # Verified byte-for-byte against the scanners in
756
+ # tests/analytics/test_fast_parity.py.
757
+ bundle = await analytics_fast.compute_bundle(db._conn)
758
+ _analytics_cache.clear()
759
+ _analytics_cache.update(bundle)
760
+ _analytics_cache_sig[0] = sig
761
+ return _analytics_cache
762
+
763
+ # ------------------------------------------------------------------
764
+ # Routes
765
+ # ------------------------------------------------------------------
766
+
767
+ @app.get("/api/health")
768
+ async def health():
769
+ return {"ok": True}
770
+
771
+ @app.get("/api/config")
772
+ async def get_config():
773
+ return _state["config"]
774
+
775
+ @app.put("/api/config")
776
+ async def update_config(new_config: AppConfig):
777
+ _state["config"] = new_config
778
+ save_config(new_config)
779
+ # Refresh the authoritative native PTY width so new spawns pick it up.
780
+ if _pty_manager is not None:
781
+ _pty_manager.set_native_cols(new_config.native_pty_cols)
782
+ return new_config
783
+
784
+ # ------------------------------------------------------------------
785
+ # Profiles
786
+ # ------------------------------------------------------------------
787
+
788
+ @app.get("/api/profiles")
789
+ async def get_profiles():
790
+ return {
791
+ "profiles": [p.model_dump() for p in _state["config"].profiles],
792
+ "active_profile_id": _state["config"].active_profile_id,
793
+ }
794
+
795
+ @app.post("/api/profiles")
796
+ async def create_profile(req: _CreateProfileRequest):
797
+ cfg = _state["config"]
798
+ if not cfg.profiles:
799
+ cfg.profiles.insert(
800
+ 0,
801
+ Profile(
802
+ name="Default",
803
+ data_paths=cfg.data_paths,
804
+ color="#6b7280",
805
+ ),
806
+ )
807
+ profile = Profile(
808
+ name=req.name, data_paths=req.data_paths or ["~/.claude"], color=req.color
809
+ )
810
+ cfg.profiles.append(profile)
811
+ save_config(cfg)
812
+ asyncio.create_task(_rescan_and_rewatch())
813
+ return profile.model_dump()
814
+
815
+ @app.put("/api/profiles/active")
816
+ async def set_active_profile(req: _SetActiveProfileRequest):
817
+ _state["config"].active_profile_id = req.active_profile_id
818
+ save_config(_state["config"])
819
+ return {"active_profile_id": req.active_profile_id}
820
+
821
+ @app.put("/api/profiles/{profile_id}")
822
+ async def update_profile(profile_id: str, req: _UpdateProfileRequest):
823
+ cfg = _state["config"]
824
+ for p in cfg.profiles:
825
+ if p.id == profile_id:
826
+ if req.name is not None:
827
+ p.name = req.name
828
+ if req.data_paths is not None:
829
+ p.data_paths = req.data_paths
830
+ if req.color is not None:
831
+ p.color = req.color
832
+ save_config(cfg)
833
+ asyncio.create_task(_rescan_and_rewatch())
834
+ return p.model_dump()
835
+ raise HTTPException(404, "Profile not found")
836
+
837
+ @app.delete("/api/profiles/{profile_id}")
838
+ async def delete_profile(profile_id: str):
839
+ cfg = _state["config"]
840
+ cfg.profiles = [p for p in cfg.profiles if p.id != profile_id]
841
+ if cfg.active_profile_id == profile_id:
842
+ cfg.active_profile_id = None
843
+ save_config(cfg)
844
+ return {"ok": True}
845
+
846
+ @app.get("/api/projects")
847
+ async def get_projects():
848
+ data_sources = _state["config"].get_active_data_sources()
849
+ async with Database(db_path) as db:
850
+ return await db.get_projects(data_sources=data_sources)
851
+
852
+ @app.get("/api/projects/{project_id}/sessions")
853
+ async def get_project_sessions(project_id: str):
854
+ data_sources = _state["config"].get_active_data_sources()
855
+ async with Database(db_path) as db:
856
+ return await db.get_sessions(
857
+ project_id=project_id, data_sources=data_sources
858
+ )
859
+
860
+ @app.get("/api/sessions")
861
+ async def get_all_sessions():
862
+ data_sources = _state["config"].get_active_data_sources()
863
+ async with Database(db_path) as db:
864
+ return await db.get_sessions(data_sources=data_sources)
865
+
866
+ def _pending_session_placeholder(session_id: str, pending: _PendingSession):
867
+ """Synthesize a minimal SessionDetail for a not-yet-materialised session.
868
+
869
+ Returned when the caller asks for a session that's been minted via
870
+ ``/api/sessions/new`` but whose JSONL hasn't appeared on disk yet
871
+ (lazy-spawn PTY path: the user has focused the chat input and may
872
+ even have hit Submit, but ``claude`` is still booting). Without this
873
+ the UI 404s for the brief window between submit and the first JSONL
874
+ flush. The placeholder keeps the route deterministic during that
875
+ bootstrap window instead of relying on post-submit polling.
876
+ """
877
+ # Synthesize a plausible file_path so ``_derive_bin_name`` and the
878
+ # frontend's worktree-extraction logic stay consistent once the
879
+ # real JSONL replaces the placeholder via the SSE refresh event.
880
+ file_path = str(Path(pending.cwd) / ".pending" / f"{session_id}.jsonl")
881
+ return {
882
+ "id": session_id,
883
+ "project_id": "",
884
+ "file_path": file_path,
885
+ "title": None,
886
+ "custom_title": None,
887
+ "model": None,
888
+ "started_at": None,
889
+ "updated_at": None,
890
+ "message_count": 0,
891
+ "user_message_count": 0,
892
+ "cwd": pending.cwd,
893
+ "git_branch": None,
894
+ "is_worktree": False,
895
+ "is_fork": False,
896
+ "permission_mode": pending.permission_mode,
897
+ "last_message_role": None,
898
+ # Provider drives the FE skin + caps for the empty chat. A pending
899
+ # Codex chat must report "codex" so the composer/toggle/skin match
900
+ # before any rollout exists; everything else is Claude.
901
+ "provider": "codex" if pending.bin_name == "codex" else "claude",
902
+ "messages": [],
903
+ }
904
+
905
+ @app.get("/api/sessions/{session_id}")
906
+ async def get_session(
907
+ session_id: str, limit: int | None = Query(None, ge=1, le=5000)
908
+ ):
909
+ async with Database(db_path) as db:
910
+ # On-demand re-parse: if the JSONL file changed since last index
911
+ # (e.g. server restarted before background scan reached this file),
912
+ # re-parse now so the caller always sees fresh content.
913
+ file_path_str = await db.get_session_file_path(session_id)
914
+ current_mtime: float | None = None
915
+ if file_path_str:
916
+ session_path = Path(file_path_str)
917
+ await _scan_one(db, session_path)
918
+ try:
919
+ current_mtime = session_path.stat().st_mtime
920
+ except OSError:
921
+ current_mtime = None
922
+
923
+ # Cache hit: skip the SQL+parse+serialize path entirely.
924
+ if limit is None and current_mtime is not None:
925
+ hit = _detail_cache.get(session_id)
926
+ if hit is not None and hit[0] == current_mtime:
927
+ _detail_cache.move_to_end(session_id)
928
+ return Response(content=hit[1], media_type="application/json")
929
+
930
+ # Hot path (no limit): bypass Pydantic — embed stored content_json /
931
+ # usage_json strings directly into the response.
932
+ if limit is None:
933
+ body = await db.get_session_detail_json_bytes(session_id)
934
+ if body is None:
935
+ # Session not on disk — check the pending-session map
936
+ # before 404'ing. A session minted by /api/sessions/new
937
+ # and submitted via /api/pty/submit has no JSONL until
938
+ # claude finishes booting + flushes its first turn;
939
+ # return a placeholder so the UI can render an empty
940
+ # chat instead of treating it as a deleted session.
941
+ async with app.state.pending_sessions_lock:
942
+ pending = app.state.pending_sessions.get(session_id)
943
+ if pending is not None:
944
+ return _pending_session_placeholder(session_id, pending)
945
+ raise HTTPException(status_code=404, detail="Session not found")
946
+ # Session is materialised on disk — drop any stale pending
947
+ # entry so the placeholder branch above never wins after
948
+ # the real JSONL arrives. The PTY flow leaves the entry in
949
+ # place until materialization confirms the session is on disk.
950
+ async with app.state.pending_sessions_lock:
951
+ app.state.pending_sessions.pop(session_id, None)
952
+ if current_mtime is not None:
953
+ _detail_cache[session_id] = (current_mtime, body)
954
+ _detail_cache.move_to_end(session_id)
955
+ while len(_detail_cache) > _DETAIL_CACHE_MAX:
956
+ _detail_cache.popitem(last=False)
957
+ return Response(content=body, media_type="application/json")
958
+
959
+ # Slow path (limited): Pydantic round-trip is fine for small payloads.
960
+ detail = await db.get_session_detail(session_id, message_limit=limit)
961
+
962
+ if detail is None:
963
+ async with app.state.pending_sessions_lock:
964
+ pending = app.state.pending_sessions.get(session_id)
965
+ if pending is not None:
966
+ return _pending_session_placeholder(session_id, pending)
967
+ raise HTTPException(status_code=404, detail="Session not found")
968
+ async with app.state.pending_sessions_lock:
969
+ app.state.pending_sessions.pop(session_id, None)
970
+ return detail
971
+
972
+ @app.get("/api/search")
973
+ async def search(
974
+ q: str = Query(..., min_length=1),
975
+ project: str | None = Query(None),
976
+ limit: int = Query(50, ge=1, le=200),
977
+ offset: int = Query(0, ge=0),
978
+ ):
979
+ async with Database(db_path) as db:
980
+ return await db.search(q, project_id=project, limit=limit, offset=offset)
981
+
982
+ @app.get("/api/stats")
983
+ async def get_stats():
984
+ async with Database(db_path) as db:
985
+ stats = await db.get_stats()
986
+ stats.data_paths = _state["config"].data_paths
987
+ return stats
988
+
989
+ @app.get("/api/dashboard")
990
+ async def get_dashboard():
991
+ data_sources = _state["config"].get_active_data_sources()
992
+ async with Database(db_path) as db:
993
+ projects = await db.get_projects(data_sources)
994
+ sessions = await db.get_sessions(data_sources=data_sources)
995
+ stats = await db.get_stats()
996
+
997
+ # Only load messages for the 10 most recent sessions
998
+ recent = sessions[:10]
999
+ recent_data: list[dict] = []
1000
+ all_recent_prompts: list = []
1001
+ all_recent_msgs: list = []
1002
+ for s in recent:
1003
+ detail = await db.get_session_detail(s.id)
1004
+ session_msgs = detail.messages if detail else []
1005
+ models_used = list(
1006
+ dict.fromkeys(
1007
+ m.model
1008
+ for m in session_msgs
1009
+ if m.model and m.role == "assistant"
1010
+ )
1011
+ )
1012
+ prompts = _analytics.prompt_breakdown(session_msgs)
1013
+ multi = _cost_engine.compute_multi(prompts)
1014
+ all_recent_prompts.extend(prompts)
1015
+ all_recent_msgs.extend(session_msgs)
1016
+ recent_data.append(
1017
+ {
1018
+ "id": s.id,
1019
+ "title": s.title,
1020
+ "project_id": s.project_id,
1021
+ "models": models_used,
1022
+ "message_count": s.message_count,
1023
+ "total_usd": float(multi.total_usd),
1024
+ "updated_at": s.updated_at.isoformat()
1025
+ if s.updated_at
1026
+ else None,
1027
+ "last_message_role": s.last_message_role,
1028
+ }
1029
+ )
1030
+
1031
+ # Global stats from recent sessions only (fast)
1032
+ model_usage = ModelUsageScanner().scan(all_recent_msgs)
1033
+ tip_registry = TipRegistry()
1034
+ tip_registry.register(RepeatedFileReadRule())
1035
+ tip_registry.register(OversizedToolResultRule())
1036
+ tip_registry.register(LowCacheHitRule())
1037
+ tips = tip_registry.run(all_recent_msgs)[:3]
1038
+
1039
+ recent_cost = _cost_engine.compute_multi(all_recent_prompts)
1040
+
1041
+ project_data = [
1042
+ {
1043
+ "id": p.id,
1044
+ "display_name": p.display_name,
1045
+ "session_count": p.session_count,
1046
+ "last_activity_at": p.last_activity_at.isoformat()
1047
+ if p.last_activity_at
1048
+ else None,
1049
+ }
1050
+ for p in projects
1051
+ ]
1052
+
1053
+ return {
1054
+ "recent_sessions": recent_data,
1055
+ "projects": project_data,
1056
+ "model_usage": model_usage,
1057
+ "total_cost_usd": float(recent_cost.total_usd),
1058
+ "total_sessions": stats.total_sessions,
1059
+ "total_messages": stats.total_messages,
1060
+ "tips": [
1061
+ {
1062
+ "rule_id": t.rule_id,
1063
+ "severity": t.severity,
1064
+ "title": t.title,
1065
+ "detail": t.detail,
1066
+ }
1067
+ for t in tips
1068
+ ],
1069
+ }
1070
+
1071
+ @app.get("/api/analytics/sessions/{session_id}/tokens")
1072
+ async def get_session_tokens(session_id: str):
1073
+ async with Database(db_path) as db:
1074
+ detail = await db.get_session_detail(session_id)
1075
+ if detail is None:
1076
+ raise HTTPException(status_code=404, detail="Session not found")
1077
+ bd = _analytics.session_totals(detail.messages)
1078
+ return {
1079
+ "session_id": session_id,
1080
+ "input_tokens": bd.input_tokens,
1081
+ "output_tokens": bd.output_tokens,
1082
+ "cache_creation_tokens": bd.cache_creation_tokens,
1083
+ "cache_read_tokens": bd.cache_read_tokens,
1084
+ "total": bd.total,
1085
+ }
1086
+
1087
+ @app.get("/api/analytics/sessions/{session_id}/prompts")
1088
+ async def get_session_prompts(session_id: str):
1089
+ async with Database(db_path) as db:
1090
+ detail = await db.get_session_detail(session_id)
1091
+ if detail is None:
1092
+ raise HTTPException(status_code=404, detail="Session not found")
1093
+ prompts = _analytics.prompt_breakdown(detail.messages)
1094
+ return [
1095
+ {
1096
+ "user_message_id": p.user_message_id,
1097
+ "assistant_message_id": p.assistant_message_id,
1098
+ "input_tokens": p.breakdown.input_tokens,
1099
+ "output_tokens": p.breakdown.output_tokens,
1100
+ "cache_creation_tokens": p.breakdown.cache_creation_tokens,
1101
+ "cache_read_tokens": p.breakdown.cache_read_tokens,
1102
+ "total": p.breakdown.total,
1103
+ }
1104
+ for p in prompts
1105
+ ]
1106
+
1107
+ @app.get("/api/analytics/daily")
1108
+ async def get_daily_analytics():
1109
+ async with Database(db_path) as db:
1110
+ return (await _analytics_bundle(db))["daily"]
1111
+
1112
+ @app.get("/api/analytics/sessions/{session_id}/cost")
1113
+ async def get_session_cost(session_id: str):
1114
+ async with Database(db_path) as db:
1115
+ detail = await db.get_session_detail(session_id)
1116
+ if detail is None:
1117
+ raise HTTPException(status_code=404, detail="Session not found")
1118
+ prompts = _analytics.prompt_breakdown(detail.messages)
1119
+ multi = _cost_engine.compute_multi(prompts)
1120
+ pricing_source = "live" if _pricing_strat._cached_data else "hardcoded"
1121
+ return {
1122
+ "session_id": session_id,
1123
+ "models": [
1124
+ {
1125
+ "model": c.model,
1126
+ "input_usd": float(c.input_usd),
1127
+ "output_usd": float(c.output_usd),
1128
+ "cache_write_usd": float(c.cache_write_usd),
1129
+ "cache_read_usd": float(c.cache_read_usd),
1130
+ "total_usd": float(c.total_usd),
1131
+ "pricing_known": c.pricing is not None,
1132
+ }
1133
+ for c in multi.models
1134
+ ],
1135
+ "total_usd": float(multi.total_usd),
1136
+ "pricing_known": any(c.pricing is not None for c in multi.models),
1137
+ "pricing_source": pricing_source,
1138
+ }
1139
+
1140
+ @app.get("/api/analytics/stats")
1141
+ async def get_prompt_stats():
1142
+ async with Database(db_path) as db:
1143
+ return (await _analytics_bundle(db))["stats"]
1144
+
1145
+ @app.get("/api/analytics/models")
1146
+ async def get_model_usage():
1147
+ async with Database(db_path) as db:
1148
+ return (await _analytics_bundle(db))["models"]
1149
+
1150
+ @app.get("/api/analytics/tools")
1151
+ async def get_tool_usage():
1152
+ async with Database(db_path) as db:
1153
+ return (await _analytics_bundle(db))["tools"]
1154
+
1155
+ @app.get("/api/analytics/files")
1156
+ async def get_file_touches():
1157
+ async with Database(db_path) as db:
1158
+ return (await _analytics_bundle(db))["files"]
1159
+
1160
+ @app.get("/api/analytics/tips")
1161
+ async def get_tips():
1162
+ async with Database(db_path) as db:
1163
+ return (await _analytics_bundle(db))["tips"]
1164
+
1165
+ @app.get("/api/pricing")
1166
+ async def get_pricing_table():
1167
+ source = "live" if _pricing_strat._cached_data else "hardcoded"
1168
+ data = _pricing_strat._cached_data or _HARDCODED_RATES
1169
+ return {
1170
+ "source": source,
1171
+ "models": [
1172
+ {
1173
+ "model": model,
1174
+ "input_per_mtok": float(p.input_per_mtok),
1175
+ "output_per_mtok": float(p.output_per_mtok),
1176
+ "cache_write_per_mtok": float(p.cache_write_per_mtok),
1177
+ "cache_read_per_mtok": float(p.cache_read_per_mtok),
1178
+ }
1179
+ for model, p in sorted(data.items())
1180
+ ],
1181
+ }
1182
+
1183
+ # ------------------------------------------------------------------
1184
+ # Export routes (Phase 7)
1185
+ # ------------------------------------------------------------------
1186
+
1187
+ @app.get("/api/sessions/{session_id}/export")
1188
+ async def export_session(session_id: str, format: str = Query("json")):
1189
+ if format not in ("json", "md"):
1190
+ raise HTTPException(status_code=400, detail="format must be 'json' or 'md'")
1191
+ async with Database(db_path) as db:
1192
+ detail = await db.get_session_detail(session_id)
1193
+ ephemerals = await db.get_ephemeral_messages(session_id)
1194
+ if detail is None:
1195
+ raise HTTPException(status_code=404, detail="Session not found")
1196
+
1197
+ bd = _analytics.session_totals(detail.messages)
1198
+ model = detail.model or "unknown"
1199
+ cost = _cost_engine.compute(model, bd)
1200
+ prompts = _analytics.prompt_breakdown(detail.messages)
1201
+ prompts_dicts = [
1202
+ {
1203
+ "user_message_id": p.user_message_id,
1204
+ "assistant_message_id": p.assistant_message_id,
1205
+ "breakdown": {
1206
+ "input_tokens": p.breakdown.input_tokens,
1207
+ "output_tokens": p.breakdown.output_tokens,
1208
+ "cache_creation_tokens": p.breakdown.cache_creation_tokens,
1209
+ "cache_read_tokens": p.breakdown.cache_read_tokens,
1210
+ "total": p.breakdown.total,
1211
+ },
1212
+ }
1213
+ for p in prompts
1214
+ ]
1215
+
1216
+ if format == "json":
1217
+ data = export_json(
1218
+ detail, cost=cost, prompts=prompts_dicts, ephemerals=ephemerals
1219
+ )
1220
+ import json as _json
1221
+
1222
+ slug = (detail.title or detail.id).replace(" ", "_").lower()
1223
+ return Response(
1224
+ content=_json.dumps(data, indent=2),
1225
+ media_type="application/json",
1226
+ headers={"Content-Disposition": f'attachment; filename="{slug}.json"'},
1227
+ )
1228
+ else:
1229
+ pricing = _pricing_strat.get_pricing(model)
1230
+ md = export_markdown(
1231
+ detail,
1232
+ cost=cost,
1233
+ prompts=prompts_dicts,
1234
+ pricing=pricing,
1235
+ ephemerals=ephemerals,
1236
+ )
1237
+ slug = (detail.title or detail.id).replace(" ", "_").lower()
1238
+ return Response(
1239
+ content=md,
1240
+ media_type="text/markdown",
1241
+ headers={"Content-Disposition": f'attachment; filename="{slug}.md"'},
1242
+ )
1243
+
1244
+ # -----------------------------------------------------------------------
1245
+ # Mutation guard (Phase 6)
1246
+ # -----------------------------------------------------------------------
1247
+
1248
+ def _require_edit() -> None:
1249
+ if not _state["config"].edit_enabled:
1250
+ raise HTTPException(
1251
+ status_code=403,
1252
+ detail="Editing is disabled. Set edit_enabled: true in settings to enable.",
1253
+ )
1254
+
1255
+ # -----------------------------------------------------------------------
1256
+ # Provider capability gating (Phase 4b) — effective caps = static
1257
+ # ProviderCaps AND runtime drivability. Driving-gated caps (send/resume)
1258
+ # are ANDed with the backend availability ONLY for driver-backed providers
1259
+ # (Codex). Claude is not driver-backed — it sends over its own direct-PTY
1260
+ # path, so its caps stand on their own and stay fully available on POSIX.
1261
+ # -----------------------------------------------------------------------
1262
+
1263
+ def _provider_availability(provider: str) -> dict:
1264
+ """Runtime drivability for *provider* as a serialisable dict."""
1265
+ if _driver_supports(provider):
1266
+ a = _driver_availability(provider)
1267
+ return {"available": a.available, "reason": a.reason}
1268
+ # Native-path provider (claude): driven over its own POSIX PTY.
1269
+ return {"available": True, "reason": None}
1270
+
1271
+ def _effective_caps(provider: str) -> dict:
1272
+ """Static caps reconciled with runtime drivability."""
1273
+ try:
1274
+ caps = registry.get(provider).capabilities
1275
+ except KeyError:
1276
+ return {
1277
+ "can_send": False,
1278
+ "can_resume": False,
1279
+ "can_fork": False,
1280
+ "can_edit": False,
1281
+ }
1282
+ if _driver_supports(provider):
1283
+ drivable = _driver_availability(provider).available
1284
+ send = caps.can_send and drivable
1285
+ resume = caps.can_resume and drivable
1286
+ else:
1287
+ send, resume = caps.can_send, caps.can_resume
1288
+ # fork/edit are file-level operations, not gated on the live transport.
1289
+ return {
1290
+ "can_send": send,
1291
+ "can_resume": resume,
1292
+ "can_fork": caps.can_fork,
1293
+ "can_edit": caps.can_edit,
1294
+ }
1295
+
1296
+ def _require_capability(provider: str, attr: str) -> None:
1297
+ """Raise 409 if *provider* does not effectively support *attr*."""
1298
+ if not _effective_caps(provider).get(attr, False):
1299
+ raise HTTPException(
1300
+ status_code=409,
1301
+ detail={
1302
+ "kind": "capability_unsupported",
1303
+ "provider": provider,
1304
+ "capability": attr,
1305
+ "availability": _provider_availability(provider),
1306
+ },
1307
+ )
1308
+
1309
+ def _find_new_codex_rollout(cwd: str, after_ts: float) -> str | None:
1310
+ """Newest codex rollout written after ``after_ts`` whose session_meta
1311
+ cwd matches ``cwd`` — i.e. the rollout a freshly-spawned codex just
1312
+ created on its first message. Returns its session id, or None.
1313
+
1314
+ Cheap: stat-filters on mtime first and only reads the first line
1315
+ (session_meta) of files newer than ``after_ts``, of which there are
1316
+ ~none until the new rollout appears.
1317
+ """
1318
+ cwd_resolved = os.path.realpath(os.path.expanduser(cwd))
1319
+ best_id: str | None = None
1320
+ best_mtime = after_ts
1321
+ for root in _state["config"].codex_data_paths:
1322
+ root_p = Path(root).expanduser()
1323
+ if not root_p.is_dir():
1324
+ continue
1325
+ for path in root_p.rglob("rollout-*.jsonl"):
1326
+ try:
1327
+ mt = path.stat().st_mtime
1328
+ except OSError:
1329
+ continue
1330
+ if mt <= best_mtime:
1331
+ continue
1332
+ try:
1333
+ with path.open("r", encoding="utf-8", errors="replace") as fh:
1334
+ rec = json.loads(fh.readline())
1335
+ except (OSError, ValueError):
1336
+ continue
1337
+ if rec.get("type") != "session_meta":
1338
+ continue
1339
+ payload = rec.get("payload", {})
1340
+ rid = payload.get("id")
1341
+ rec_cwd = payload.get("cwd")
1342
+ if not rid or not rec_cwd:
1343
+ continue
1344
+ if os.path.realpath(os.path.expanduser(rec_cwd)) != cwd_resolved:
1345
+ continue
1346
+ best_id, best_mtime = rid, mt
1347
+ return best_id
1348
+
1349
+ async def _adopt_codex_session(
1350
+ placeholder_id: str, cwd: str, after_ts: float
1351
+ ) -> None:
1352
+ """Adopt the real Codex rollout UUID for a brand-new Codex chat.
1353
+
1354
+ A fresh ``codex`` invents its own rollout id on the first message (codex
1355
+ has no ``--session-id``), so the chat the user is driving under the
1356
+ placeholder id must be re-keyed to that real id once it appears — else
1357
+ the live session and the on-disk rollout are two different identities.
1358
+ Polls for the new rollout, re-keys the live driver in place (tmux rename,
1359
+ no respawn), drops the placeholder, and signals the FE to navigate.
1360
+ Gives up after 10 min (user never sent a first message) or if the
1361
+ session dies.
1362
+ """
1363
+ deadline = time.time() + 600.0
1364
+ while time.time() < deadline:
1365
+ await asyncio.sleep(1.5)
1366
+ if placeholder_id not in app.state.driver_sessions:
1367
+ return # session closed / died before a rollout appeared
1368
+ try:
1369
+ real_id = await asyncio.to_thread(
1370
+ _find_new_codex_rollout, cwd, after_ts
1371
+ )
1372
+ except Exception as exc: # pragma: no cover — defensive
1373
+ _log.warning("codex adopt: scan raised: %s", exc)
1374
+ continue
1375
+ if not real_id or real_id == placeholder_id:
1376
+ continue
1377
+ if _driver_manager is None:
1378
+ return
1379
+ try:
1380
+ rekeyed = await _driver_manager.rekey(placeholder_id, real_id)
1381
+ except Exception as exc: # pragma: no cover — defensive
1382
+ _log.warning("codex adopt: rekey raised: %s", exc)
1383
+ return
1384
+ if not rekeyed:
1385
+ return
1386
+ app.state.driver_sessions.discard(placeholder_id)
1387
+ app.state.driver_sessions.add(real_id)
1388
+ async with app.state.pending_sessions_lock:
1389
+ app.state.pending_sessions.pop(placeholder_id, None)
1390
+ _log.info("codex adopt: %s -> %s", placeholder_id, real_id)
1391
+ # Navigate the FE placeholder → real id, and refresh the list so the
1392
+ # newly-materialised rollout shows up.
1393
+ _bus.publish(
1394
+ {"type": "session_adopted", "old": placeholder_id, "new": real_id}
1395
+ )
1396
+ _bus.publish({"type": "refresh", "path": "codex-adopt"})
1397
+ return
1398
+
1399
+ async def _resolve_provider(session_id: str) -> str:
1400
+ """Provider for *session_id* — on-disk detail, else a pending mint.
1401
+
1402
+ A brand-new Codex chat has no rollout yet, so its provider lives only
1403
+ in the pending-session map (bin_name="codex"). Consult it so focus/submit
1404
+ route to the DriverManager and spawn fresh codex instead of treating the
1405
+ empty chat as Claude.
1406
+ """
1407
+ async with Database(db_path) as db:
1408
+ detail = await db.get_session_detail(session_id)
1409
+ if detail is not None:
1410
+ return detail.provider
1411
+ async with app.state.pending_sessions_lock:
1412
+ pending = app.state.pending_sessions.get(session_id)
1413
+ if pending is not None and _driver_supports(pending.bin_name):
1414
+ return pending.bin_name
1415
+ return "claude"
1416
+
1417
+ @app.get("/api/providers")
1418
+ async def get_providers():
1419
+ """Per-provider static caps + runtime availability + effective caps.
1420
+
1421
+ The FE drives every affordance off ``effective`` so a read-only or
1422
+ non-drivable provider never shows a send/Native/fork button that would
1423
+ misfire (the read-only-honesty gap).
1424
+ """
1425
+ out = []
1426
+ for adapter in registry.all_adapters():
1427
+ name = adapter.name
1428
+ out.append(
1429
+ {
1430
+ "name": name,
1431
+ "caps": adapter.capabilities.model_dump(),
1432
+ "availability": _provider_availability(name),
1433
+ "effective": _effective_caps(name),
1434
+ "driver_backed": _driver_supports(name),
1435
+ }
1436
+ )
1437
+ return out
1438
+
1439
+ # -----------------------------------------------------------------------
1440
+ # Message mutation routes (Phase 6)
1441
+ # -----------------------------------------------------------------------
1442
+
1443
+ @app.delete("/api/messages/{message_id}")
1444
+ async def delete_message_route(message_id: str):
1445
+ _require_edit()
1446
+ async with Database(db_path) as db:
1447
+ info = await db.get_session_info_for_message(message_id)
1448
+ if info is None:
1449
+ raise HTTPException(status_code=404, detail="Message not found")
1450
+ session_id, file_path = info
1451
+ # The editor rewrites the on-disk file in the Claude JSONL format;
1452
+ # applying it to a Codex rollout would corrupt it. Gate on the
1453
+ # provider's effective can_edit (defense in depth behind the FE).
1454
+ _require_capability(await _resolve_provider(session_id), "can_edit")
1455
+ path = Path(file_path)
1456
+ if not path.exists():
1457
+ raise HTTPException(
1458
+ status_code=404, detail="Session file not found on disk"
1459
+ )
1460
+ edited_path, _, backup_path, _ = swap_session(
1461
+ path, session_id, delete_uuid=message_id
1462
+ )
1463
+ await db.delete_session_messages(session_id)
1464
+ await _scan_one(db, edited_path)
1465
+ await _scan_one(db, backup_path)
1466
+ # Parsed updated_at = max(remaining message timestamps); deleting
1467
+ # the latest message regresses it. Force-bump so SSE/dedupe see
1468
+ # the mutation. See db.touch_session_updated_at docstring.
1469
+ await db.touch_session_updated_at(session_id)
1470
+ return {"ok": True, "session_id": session_id}
1471
+
1472
+ @app.patch("/api/messages/{message_id}")
1473
+ async def patch_message_route(message_id: str, body: _MessageContentUpdate):
1474
+ _require_edit()
1475
+ async with Database(db_path) as db:
1476
+ info = await db.get_session_info_for_message(message_id)
1477
+ if info is None:
1478
+ raise HTTPException(status_code=404, detail="Message not found")
1479
+ session_id, file_path = info
1480
+ # See delete_message_route: provider-format-specific editor, so gate
1481
+ # on effective can_edit to never corrupt a non-Claude session file.
1482
+ _require_capability(await _resolve_provider(session_id), "can_edit")
1483
+ path = Path(file_path)
1484
+ if not path.exists():
1485
+ raise HTTPException(
1486
+ status_code=404, detail="Session file not found on disk"
1487
+ )
1488
+ edited_path, _, backup_path, _ = swap_session(
1489
+ path, session_id, edit_uuid=message_id, new_content=body.content_blocks
1490
+ )
1491
+ await db.delete_session_messages(session_id)
1492
+ await _scan_one(db, edited_path)
1493
+ await _scan_one(db, backup_path)
1494
+ # Content edits leave message timestamps untouched, so the
1495
+ # parsed updated_at doesn't move. Force-bump so SSE/dedupe see
1496
+ # the mutation. See db.touch_session_updated_at docstring.
1497
+ await db.touch_session_updated_at(session_id)
1498
+ return {"ok": True, "session_id": session_id}
1499
+
1500
+ @app.post("/api/sessions/{session_id}/reveal")
1501
+ async def reveal_session(session_id: str):
1502
+ """Reveal the session JSONL file in the OS file manager."""
1503
+ async with Database(db_path) as db:
1504
+ detail = await db.get_session_detail(session_id)
1505
+ if detail is None:
1506
+ raise HTTPException(status_code=404, detail="Session not found")
1507
+ file_path = Path(detail.file_path)
1508
+ if not file_path.exists():
1509
+ raise HTTPException(status_code=404, detail="File not found on disk")
1510
+ if sys.platform == "darwin":
1511
+ subprocess.Popen(["open", "-R", str(file_path)])
1512
+ elif sys.platform.startswith("linux"):
1513
+ subprocess.Popen(["xdg-open", str(file_path.parent)])
1514
+ else:
1515
+ subprocess.Popen(["explorer", "/select,", str(file_path)])
1516
+ return {"ok": True}
1517
+
1518
+ @app.post("/api/sessions/{session_id}/open-terminal")
1519
+ async def open_terminal(session_id: str):
1520
+ """Open a new Terminal.app window at the session's cwd and run <binary> -r."""
1521
+ async with Database(db_path) as db:
1522
+ detail = await db.get_session_detail(session_id)
1523
+ if detail is None:
1524
+ raise HTTPException(status_code=404, detail="Session not found")
1525
+ cwd = detail.cwd
1526
+ if not cwd:
1527
+ cwd = str(Path(detail.file_path).parent)
1528
+ if not Path(cwd).is_dir():
1529
+ raise HTTPException(status_code=404, detail=f"Directory not found: {cwd}")
1530
+ _reject_root_cwd(cwd, "open a terminal")
1531
+ # Build a provider-correct resume command. Codex resumes with
1532
+ # ``codex resume <uuid>`` (the uuid IS our session id); Claude uses
1533
+ # ``claude -r <id>`` (with ``-w <worktree>`` for worktree sessions).
1534
+ # Without this branch a Codex "open in terminal" would run
1535
+ # ``claude -r <codex-uuid>`` — the wrong binary against a foreign id.
1536
+ if detail.provider == "codex":
1537
+ cmd = f"codex resume {shlex.quote(session_id)}"
1538
+ else:
1539
+ bin_name = _derive_bin_name(detail.file_path)
1540
+ wt = (
1541
+ _extract_worktree_name(detail.file_path, detail.cwd)
1542
+ if detail.is_worktree
1543
+ else None
1544
+ )
1545
+ quoted_bin = shlex.quote(bin_name)
1546
+ cmd = (
1547
+ f"{quoted_bin} -w {shlex.quote(wt)} -r {session_id}"
1548
+ if wt
1549
+ else f"{quoted_bin} -r {session_id}"
1550
+ )
1551
+ # Apply the same API-key cleanup for every terminal launch. Subscription
1552
+ # CLIs avoid unexpected-key prompts; API-key CLIs that need a key can
1553
+ # re-read it from their own shell/keychain setup.
1554
+ unset_key = "unset ANTHROPIC_API_KEY && "
1555
+ if sys.platform == "darwin":
1556
+ subprocess.Popen(
1557
+ [
1558
+ "osascript",
1559
+ "-e",
1560
+ f'tell application "Terminal"\n'
1561
+ f" activate\n"
1562
+ f' do script "{unset_key}cd {shlex.quote(cwd)} && {cmd}"\n'
1563
+ f"end tell",
1564
+ ]
1565
+ )
1566
+ elif sys.platform.startswith("linux"):
1567
+ for term in ["gnome-terminal", "konsole", "xfce4-terminal"]:
1568
+ if shutil.which(term):
1569
+ subprocess.Popen(
1570
+ [
1571
+ term,
1572
+ "--",
1573
+ "bash",
1574
+ "-c",
1575
+ f"{unset_key}cd {shlex.quote(cwd)} && {cmd}; exec bash",
1576
+ ]
1577
+ )
1578
+ break
1579
+ else:
1580
+ subprocess.Popen(
1581
+ [
1582
+ "x-terminal-emulator",
1583
+ "-e",
1584
+ "bash",
1585
+ "-c",
1586
+ f"{unset_key}cd {shlex.quote(cwd)} && {cmd}; exec bash",
1587
+ ]
1588
+ )
1589
+ else:
1590
+ subprocess.Popen(
1591
+ [
1592
+ "cmd",
1593
+ "/c",
1594
+ "start",
1595
+ "cmd",
1596
+ "/k",
1597
+ f'cd /d "{cwd}" && {cmd}',
1598
+ ]
1599
+ )
1600
+ return {"ok": True}
1601
+
1602
+ # -----------------------------------------------------------------------
1603
+ # Headless runner — send/stop/status
1604
+ # -----------------------------------------------------------------------
1605
+
1606
+ @app.post("/api/sessions/new")
1607
+ async def new_session(req: _NewSessionRequest):
1608
+ """Mint metadata for a brand-new Claude session (issue #9).
1609
+
1610
+ This endpoint is a PURE METADATA MINT — it does NOT spawn the CLI and
1611
+ does NOT write any JSONL on disk. It stashes a ``_PendingSession`` in
1612
+ ``app.state.pending_sessions`` keyed by the new uuid. The user's first
1613
+ real PTY submit for that id is what materialises the session via
1614
+ ``claude --session-id``.
1615
+
1616
+ Why no spawn here: an auto-greeting that materialises the JSONL would
1617
+ appear as the user's first turn in the conversation, which is wrong.
1618
+ A new session must land empty.
1619
+
1620
+ Defaults (all overridable):
1621
+ * ``cwd`` — last-used cwd from the most-recent indexed session.
1622
+ * ``permission_mode`` — ``AppConfig.claude_default_permission_mode``.
1623
+ """
1624
+ import uuid
1625
+
1626
+ # Resolve cwd default → last-used session's cwd. Query directly
1627
+ # (not via get_sessions) because we want to fall back to any session
1628
+ # with a cwd on disk, including untitled ones.
1629
+ cwd = req.cwd
1630
+ if not cwd:
1631
+ async with Database(db_path) as db:
1632
+ assert db._conn is not None
1633
+ async with db._conn.execute(
1634
+ "SELECT cwd FROM sessions WHERE cwd IS NOT NULL "
1635
+ "ORDER BY updated_at DESC"
1636
+ ) as cursor:
1637
+ rows = await cursor.fetchall()
1638
+ for row in rows:
1639
+ candidate = row["cwd"]
1640
+ if candidate and Path(candidate).is_dir():
1641
+ cwd = candidate
1642
+ break
1643
+ if not cwd:
1644
+ raise HTTPException(
1645
+ status_code=400,
1646
+ detail="cwd is required and no prior session cwd is available",
1647
+ )
1648
+ if not Path(cwd).is_dir():
1649
+ raise HTTPException(status_code=404, detail=f"Directory not found: {cwd}")
1650
+
1651
+ # The binary that writes the JSONL determines which data_path the
1652
+ # session shows up under, so a brand-new session must use the CLI
1653
+ # whose data root is the active profile's data_path. Otherwise a
1654
+ # ``crad``-profile user clicking "New Task" while the work profile
1655
+ # is active would silently spawn vanilla ``claude`` and the session
1656
+ # would never appear in their active-profile sidebar. We validate
1657
+ # the resolved bin up front so the caller learns about a missing
1658
+ # CLI now rather than only on their first message.
1659
+ # Provider override (e.g. "+" from inside a Codex chat) wins over the
1660
+ # active-profile default so the new chat stays on the same harness. A
1661
+ # driver-backed provider maps to its CLI bin (codex → "codex"); anything
1662
+ # else falls back to the active profile's binary.
1663
+ if req.provider and _driver_supports(req.provider):
1664
+ bin_name = req.provider
1665
+ else:
1666
+ bin_name = _active_profile_bin_name(_state["config"])
1667
+ if shutil.which(bin_name) is None:
1668
+ raise HTTPException(status_code=503, detail=f"{bin_name} not found on PATH")
1669
+
1670
+ permission_mode = (
1671
+ req.permission_mode
1672
+ or _state["config"].claude_default_permission_mode
1673
+ or "default"
1674
+ )
1675
+ new_id = str(uuid.uuid4())
1676
+ async with app.state.pending_sessions_lock:
1677
+ app.state.pending_sessions[new_id] = _PendingSession(
1678
+ cwd=cwd,
1679
+ permission_mode=permission_mode,
1680
+ bin_name=bin_name,
1681
+ )
1682
+ return {
1683
+ "session_id": new_id,
1684
+ "cwd": cwd,
1685
+ "permission_mode": permission_mode,
1686
+ }
1687
+
1688
+ def _pty_busy_snapshot(session_id: str) -> dict:
1689
+ """Return the sidebar busy-badge shape for ``session_id``.
1690
+
1691
+ Busy state is derived from PtyManager. The FE polls
1692
+ ``/api/runner-status?ids=...`` (kept by name for API compatibility);
1693
+ Phase 7+ may rename the endpoint to ``/api/pty/status-batch``.
1694
+
1695
+ Busy heuristic: PTY alive, the channel has seen input within
1696
+ ~2 minutes, and it's emitted output within the last 2 seconds.
1697
+ The output window catches streaming (TUI redraws on every chunk);
1698
+ the input bound prevents long-idle channels from being flagged as
1699
+ busy after the user walks away.
1700
+ """
1701
+ if _pty_manager is None:
1702
+ return {
1703
+ "busy": False,
1704
+ "last_error": None,
1705
+ "permission_mode": None,
1706
+ }
1707
+ pty = _pty_manager.status(session_id)
1708
+ now_ms = int(time.time() * 1000)
1709
+ last_in = int(pty.get("last_input_ms") or 0)
1710
+ last_out = int(pty.get("last_pty_output_ms") or 0)
1711
+ pty_busy = (
1712
+ bool(pty.get("alive"))
1713
+ and last_in > 0
1714
+ and (now_ms - last_in) < 120_000
1715
+ and (now_ms - last_out) < 2_000
1716
+ )
1717
+ return {
1718
+ "busy": pty_busy,
1719
+ "last_error": None,
1720
+ "permission_mode": pty.get("permission_mode") or None,
1721
+ }
1722
+
1723
+ @app.get("/api/runner-status")
1724
+ async def runner_status_batch(ids: str = Query("")):
1725
+ """Batch busy-status for a comma-separated id list → ``{id: status}``.
1726
+
1727
+ Drives the sidebar busy-indicator. One shared poll for the whole
1728
+ visible session list, capped to bound a pathological query string.
1729
+ """
1730
+ session_ids = [s for s in ids.split(",") if s][:500]
1731
+ return {sid: _pty_busy_snapshot(sid) for sid in session_ids}
1732
+
1733
+ # -----------------------------------------------------------------------
1734
+ # Recaps
1735
+ # -----------------------------------------------------------------------
1736
+
1737
+ _RECAP_PROMPT = (
1738
+ "In 1-2 short sentences, recap where this conversation left off. "
1739
+ "No bullets, no preamble. Just the sentences. This is a glance-and-go "
1740
+ "context-restore inline in the chat view, not a summary document."
1741
+ )
1742
+
1743
+ @app.post("/api/sessions/{session_id}/recap")
1744
+ async def create_recap(session_id: str):
1745
+ async with Database(db_path) as db:
1746
+ detail = await db.get_session_detail(session_id)
1747
+ if detail is None:
1748
+ raise HTTPException(status_code=404, detail="Session not found")
1749
+ if detail.is_fork:
1750
+ raise HTTPException(
1751
+ status_code=422,
1752
+ detail="fork sessions are not valid --resume targets",
1753
+ )
1754
+ cwd = detail.cwd or str(Path(detail.file_path).parent)
1755
+ if not Path(cwd).is_dir():
1756
+ raise HTTPException(
1757
+ status_code=404, detail=f"Directory not found: {cwd}"
1758
+ )
1759
+ bin_name = _derive_bin_name(detail.file_path)
1760
+ if shutil.which(bin_name) is None:
1761
+ raise HTTPException(
1762
+ status_code=503, detail=f"{bin_name} not found on PATH"
1763
+ )
1764
+ latest_uuid: str | None = (
1765
+ detail.messages[-1].id if detail.messages else None
1766
+ )
1767
+
1768
+ # Phase 8: recap runs through a hidden PTY against a forked
1769
+ # session. Pre-mint the fork id and tombstone it so the
1770
+ # watcher doesn't briefly surface the fork JSONL in the
1771
+ # sidebar between file creation and the recap runner's
1772
+ # cleanup unlink.
1773
+ import uuid
1774
+
1775
+ fork_id = str(uuid.uuid4())
1776
+ _deleted_tombstones.add(fork_id)
1777
+ # Pre-empt the trust-this-folder TUI dialog (same as the
1778
+ # main chat PTY does) — without this the fork's first
1779
+ # spawn into a fresh cwd would eat the recap prompt while
1780
+ # waiting for the user to confirm trust.
1781
+ try:
1782
+ _ensure_trust(_config_dir_for_bin(_state["config"], bin_name), cwd)
1783
+ except Exception as exc:
1784
+ _log.warning(
1785
+ "recap: trust pre-flight failed for %s in %s: %s",
1786
+ bin_name,
1787
+ cwd,
1788
+ exc,
1789
+ )
1790
+ text = await _generate_recap(
1791
+ session_id,
1792
+ cwd=cwd,
1793
+ bin_name=bin_name,
1794
+ prompt=_RECAP_PROMPT,
1795
+ source_jsonl_path=Path(detail.file_path),
1796
+ fork_id=fork_id,
1797
+ )
1798
+ if text is None:
1799
+ raise HTTPException(status_code=502, detail="recap generation failed")
1800
+ new_id = await db.insert_recap(session_id, text, latest_uuid)
1801
+ recaps = await db.list_recaps(session_id, include_dismissed=True)
1802
+ row = next((r for r in recaps if r["id"] == new_id), None)
1803
+ if row is None:
1804
+ raise HTTPException(
1805
+ status_code=500, detail="recap row missing after insert"
1806
+ )
1807
+ return row
1808
+
1809
+ @app.get("/api/sessions/{session_id}/recaps")
1810
+ async def list_recaps_route(
1811
+ session_id: str,
1812
+ include_dismissed: bool = Query(False),
1813
+ ):
1814
+ async with Database(db_path) as db:
1815
+ return await db.list_recaps(session_id, include_dismissed=include_dismissed)
1816
+
1817
+ @app.post("/api/sessions/{session_id}/recaps/{recap_id}/dismiss")
1818
+ async def dismiss_recap_route(session_id: str, recap_id: int):
1819
+ async with Database(db_path) as db:
1820
+ ok = await db.dismiss_recap(recap_id)
1821
+ if not ok:
1822
+ raise HTTPException(status_code=404, detail="Recap not found")
1823
+ return {"ok": True, "dismissed": True}
1824
+
1825
+ @app.get("/api/sessions/{session_id}/ephemerals")
1826
+ async def list_ephemerals_route(session_id: str):
1827
+ """Return ordered ephemeral_messages for *session_id* (e.g. /btw pairs).
1828
+
1829
+ Both user and assistant rows are returned; the FE pairs them via
1830
+ ``responds_to`` and interleaves with regular messages by timestamp.
1831
+ """
1832
+ async with Database(db_path) as db:
1833
+ return await db.get_ephemeral_messages(session_id)
1834
+
1835
+ @app.put("/api/sessions/{session_id}/title")
1836
+ async def set_session_title(session_id: str, req: _SessionTitleRequest):
1837
+ """Persist a user-supplied rename and broadcast to other clients.
1838
+
1839
+ Body: ``{"title": str | null}`` — null/blank clears the override.
1840
+ Returns ``{ok, id, custom_title}`` so the caller can reconcile.
1841
+ """
1842
+ async with Database(db_path) as db:
1843
+ existing = await db.get_session_file_path(session_id)
1844
+ if existing is None:
1845
+ raise HTTPException(status_code=404, detail="Session not found")
1846
+ stored = await db.set_custom_title(session_id, req.title)
1847
+ # The detail cache embeds custom_title — drop it so the next read
1848
+ # rebuilds with the override.
1849
+ _invalidate_detail_cache(session_id)
1850
+ _bus.publish({"type": "session-meta", "id": session_id, "title": stored})
1851
+ return {"ok": True, "id": session_id, "custom_title": stored}
1852
+
1853
+ @app.put("/api/sessions/{session_id}/archived")
1854
+ async def set_session_archived(session_id: str, req: _SessionArchivedRequest):
1855
+ """Persist the archived flag server-side and broadcast.
1856
+
1857
+ Body: ``{"archived": bool}``. Returns ``{ok, id, archived_at}``.
1858
+ Replaces the previous localStorage-only state (LS.ARCHIVED), which
1859
+ meant a second browser saw stale state.
1860
+ """
1861
+ async with Database(db_path) as db:
1862
+ if await db.get_session_file_path(session_id) is None:
1863
+ raise HTTPException(status_code=404, detail="Session not found")
1864
+ stored = await db.set_archived(session_id, req.archived)
1865
+ _invalidate_detail_cache(session_id)
1866
+ _bus.publish({"type": "session-meta", "id": session_id, "archived_at": stored})
1867
+ return {"ok": True, "id": session_id, "archived_at": stored}
1868
+
1869
+ @app.put("/api/sessions/{session_id}/starred")
1870
+ async def set_session_starred(session_id: str, req: _SessionStarredRequest):
1871
+ """Persist the starred flag server-side. Same shape as ``archived``."""
1872
+ async with Database(db_path) as db:
1873
+ if await db.get_session_file_path(session_id) is None:
1874
+ raise HTTPException(status_code=404, detail="Session not found")
1875
+ stored = await db.set_starred(session_id, req.starred)
1876
+ _invalidate_detail_cache(session_id)
1877
+ _bus.publish({"type": "session-meta", "id": session_id, "starred_at": stored})
1878
+ return {"ok": True, "id": session_id, "starred_at": stored}
1879
+
1880
+ @app.put("/api/sessions/{session_id}/viewed")
1881
+ async def set_session_viewed(session_id: str, req: _SessionViewedRequest):
1882
+ """Record the wall-clock the session was last viewed by the user.
1883
+
1884
+ Body: ``{"viewed_at": str | null}``. Pass ``null`` to mark unread.
1885
+ The FE typically passes the session's current ``updated_at`` so the
1886
+ bell stays dismissed only as long as no new messages have arrived.
1887
+ """
1888
+ async with Database(db_path) as db:
1889
+ if await db.get_session_file_path(session_id) is None:
1890
+ raise HTTPException(status_code=404, detail="Session not found")
1891
+ stored = await db.set_viewed_at(session_id, req.viewed_at)
1892
+ _invalidate_detail_cache(session_id)
1893
+ _bus.publish({"type": "session-meta", "id": session_id, "viewed_at": stored})
1894
+ return {"ok": True, "id": session_id, "viewed_at": stored}
1895
+
1896
+ @app.post("/api/sessions/migrate-localstorage")
1897
+ async def migrate_session_localstorage(req: _LocalStorageMigrationRequest):
1898
+ """One-time migration: import localStorage-only meta into the DB.
1899
+
1900
+ Called by the FE on first load after the upgrade so existing users
1901
+ don't lose their archive / star / read-receipt state. Idempotent —
1902
+ the FE should clear the corresponding localStorage keys only after
1903
+ a 200 response, but calling this twice with overlapping ids just
1904
+ rewrites the same timestamps.
1905
+
1906
+ Body shape:
1907
+ { "archived": [sid, ...],
1908
+ "starred": [sid, ...],
1909
+ "viewed_at": { sid: iso_timestamp, ... } }
1910
+ Returns counts of rows actually written. Missing sessions are
1911
+ silently skipped (deleted-since-archive is fine — no row needed).
1912
+ """
1913
+ applied = {"archived": 0, "starred": 0, "viewed_at": 0}
1914
+ async with Database(db_path) as db:
1915
+ for sid in req.archived:
1916
+ if await db.get_session_file_path(sid) is not None:
1917
+ await db.set_archived(sid, True)
1918
+ applied["archived"] += 1
1919
+ for sid in req.starred:
1920
+ if await db.get_session_file_path(sid) is not None:
1921
+ await db.set_starred(sid, True)
1922
+ applied["starred"] += 1
1923
+ for sid, ts in req.viewed_at.items():
1924
+ if await db.get_session_file_path(sid) is not None:
1925
+ await db.set_viewed_at(sid, ts)
1926
+ applied["viewed_at"] += 1
1927
+ # No per-session SSE — the FE is expected to refetch its session
1928
+ # list once after migration. Single broadcast tells other tabs to
1929
+ # do the same.
1930
+ _bus.publish({"type": "session-meta-bulk-migration", "applied": applied})
1931
+ return {"ok": True, "applied": applied}
1932
+
1933
+ @app.post("/api/sessions/delete")
1934
+ async def delete_sessions(req: _SessionDeleteRequest):
1935
+ """Hard-delete sessions: unlinks the on-disk .jsonl file and removes
1936
+ all DB rows (messages, FTS, recaps, session_meta, session).
1937
+
1938
+ Race-free design:
1939
+ 1. Every session id is added to ``_deleted_tombstones`` BEFORE any
1940
+ unlink or DB delete, so concurrent ``do_scan`` / ``_scan_one``
1941
+ iterations that are already running skip re-inserting the row.
1942
+ 2. All DB work for the whole batch runs inside ONE ``Database``
1943
+ context, eliminating the per-id connection-open/close churn
1944
+ (busy-timeout lock contention).
1945
+
1946
+ Path safety: each file_path is resolved with os.path.realpath and
1947
+ verified to live under one of the configured scan roots AND to end
1948
+ with '.jsonl'. Any path that fails this check is recorded in
1949
+ ``failed`` and skipped — the file is never touched.
1950
+
1951
+ Returns {"ok": true, "deleted": [...ids], "failed": [...{id, error}]}.
1952
+ """
1953
+ deleted: list[str] = []
1954
+ failed: list[dict] = []
1955
+
1956
+ # Compute the set of allowed root prefixes once for the whole batch.
1957
+ # Sessions live at <root>/projects/<proj>/<session>.jsonl so the file
1958
+ # must resolve under one of the configured scan roots. Claude sessions
1959
+ # live under the profile data_paths (get_all_scan_paths); Codex rollouts
1960
+ # live under codex_data_paths (~/.codex/sessions by default). Those are
1961
+ # deliberately NOT in get_all_scan_paths (that one scopes the Claude
1962
+ # watcher/scanner), so they must be added here — otherwise every Codex
1963
+ # session fails this safety check and can't be deleted.
1964
+ scan_roots = [
1965
+ os.path.realpath(str(Path(p).expanduser()))
1966
+ for p in (
1967
+ list(_state["config"].get_all_scan_paths())
1968
+ + list(_state["config"].codex_data_paths)
1969
+ )
1970
+ ]
1971
+
1972
+ # Phase 0: tombstone ALL requested ids BEFORE touching disk or DB so
1973
+ # that any in-flight do_scan / _scan_one iteration sees the guard
1974
+ # before we do the unlink + row delete.
1975
+ for session_id in req.session_ids:
1976
+ _deleted_tombstones.add(session_id)
1977
+
1978
+ # Kill live PTY channels for all requested sessions (idempotent when
1979
+ # no channel exists) before opening the DB, so an attached claude can't
1980
+ # keep writing to the JSONL we're about to unlink.
1981
+ if _pty_manager is not None:
1982
+ for session_id in req.session_ids:
1983
+ try:
1984
+ await _pty_manager.kill(session_id)
1985
+ except Exception:
1986
+ pass
1987
+ # Also tear down any driver-backed (Codex/tmux) sessions for these ids.
1988
+ if _driver_manager is not None:
1989
+ for session_id in req.session_ids:
1990
+ if session_id in app.state.driver_sessions:
1991
+ try:
1992
+ await _driver_manager.kill(session_id)
1993
+ except Exception:
1994
+ pass
1995
+ app.state.driver_sessions.discard(session_id)
1996
+
1997
+ # Map of session_id -> resolved file path for files that pass safety
1998
+ # checks and should be unlinked AFTER the DB transaction commits.
1999
+ to_unlink: dict[str, str] = {}
2000
+
2001
+ # Single DB connection for the whole batch — no per-id lock churn.
2002
+ try:
2003
+ async with Database(db_path) as db:
2004
+ for session_id in req.session_ids:
2005
+ try:
2006
+ fp = await db.get_session_file_path(session_id)
2007
+
2008
+ if fp is not None:
2009
+ # --- Path safety check ---
2010
+ resolved = os.path.realpath(fp)
2011
+ if not resolved.endswith(".jsonl"):
2012
+ failed.append(
2013
+ {
2014
+ "id": session_id,
2015
+ "error": "path outside sessions root / not .jsonl",
2016
+ }
2017
+ )
2018
+ # Do NOT delete row, do NOT unlink.
2019
+ _deleted_tombstones.discard(session_id)
2020
+ continue
2021
+ if not any(
2022
+ resolved == r or resolved.startswith(r + os.sep)
2023
+ for r in scan_roots
2024
+ ):
2025
+ failed.append(
2026
+ {
2027
+ "id": session_id,
2028
+ "error": "path outside sessions root / not .jsonl",
2029
+ }
2030
+ )
2031
+ _deleted_tombstones.discard(session_id)
2032
+ continue
2033
+ to_unlink[session_id] = resolved
2034
+
2035
+ await db.delete_session(session_id)
2036
+ deleted.append(session_id)
2037
+
2038
+ except Exception as exc:
2039
+ failed.append({"id": session_id, "error": str(exc)})
2040
+ _deleted_tombstones.discard(session_id)
2041
+ except Exception as exc:
2042
+ # Whole-batch DB open failure — mark everything not yet handled.
2043
+ handled = {e["id"] for e in failed} | set(deleted)
2044
+ for session_id in req.session_ids:
2045
+ if session_id not in handled:
2046
+ failed.append({"id": session_id, "error": str(exc)})
2047
+ _deleted_tombstones.discard(session_id)
2048
+
2049
+ # Unlink on-disk files for successfully deleted sessions.
2050
+ for session_id in deleted:
2051
+ resolved = to_unlink.get(session_id)
2052
+ if resolved:
2053
+ try:
2054
+ os.unlink(resolved)
2055
+ except FileNotFoundError:
2056
+ pass # already gone — still counts as success
2057
+ _invalidate_detail_cache(session_id)
2058
+
2059
+ # Broadcast a single refresh event so all connected clients reload
2060
+ # their session list (same event type the file-watcher uses).
2061
+ if deleted:
2062
+ _bus.publish({"type": "refresh", "path": ""})
2063
+
2064
+ return {"ok": True, "deleted": deleted, "failed": failed}
2065
+
2066
+ @app.post("/api/refresh")
2067
+ async def refresh():
2068
+ async with Database(db_path) as db:
2069
+ await do_scan(db)
2070
+ return {"ok": True}
2071
+
2072
+ @app.get("/api/events")
2073
+ async def events(request: Request):
2074
+ # Each /api/events connection gets its own queue off the shared
2075
+ # broadcaster. Without this, multiple clients on the same server
2076
+ # would steal events from each other (issue #11).
2077
+ client_queue = _bus.subscribe()
2078
+
2079
+ async def _wait_disconnect() -> None:
2080
+ # request.is_disconnected() is a NON-BLOCKING peek (uses a pre-cancelled
2081
+ # anyio scope) — it returns False immediately. To actually block until the
2082
+ # client disconnects we have to consume from request.receive() ourselves.
2083
+ while True:
2084
+ message = await request.receive()
2085
+ if message["type"] == "http.disconnect":
2086
+ return
2087
+
2088
+ async def generate():
2089
+ disconnect_task = asyncio.ensure_future(_wait_disconnect())
2090
+ try:
2091
+ while True:
2092
+ queue_get = asyncio.ensure_future(client_queue.get())
2093
+ try:
2094
+ done, _ = await asyncio.wait(
2095
+ {queue_get, disconnect_task},
2096
+ timeout=30.0,
2097
+ return_when=asyncio.FIRST_COMPLETED,
2098
+ )
2099
+ except Exception:
2100
+ queue_get.cancel()
2101
+ return
2102
+ if disconnect_task in done:
2103
+ queue_get.cancel()
2104
+ return
2105
+ if queue_get in done:
2106
+ event = queue_get.result()
2107
+ yield f"data: {json.dumps(event)}\n\n"
2108
+ else:
2109
+ # Timeout — emit keepalive and poll again.
2110
+ queue_get.cancel()
2111
+ yield ": keepalive\n\n"
2112
+ finally:
2113
+ disconnect_task.cancel()
2114
+ _bus.unsubscribe(client_queue)
2115
+
2116
+ return StreamingResponse(generate(), media_type="text/event-stream")
2117
+
2118
+ # ------------------------------------------------------------------
2119
+ # File system browser (read-only, sandboxed)
2120
+ # ------------------------------------------------------------------
2121
+
2122
+ _EXTENSION_LANGUAGE: dict[str, str] = {
2123
+ ".py": "python",
2124
+ ".js": "javascript",
2125
+ ".ts": "typescript",
2126
+ ".tsx": "typescript",
2127
+ ".jsx": "javascript",
2128
+ ".rs": "rust",
2129
+ ".go": "go",
2130
+ ".java": "java",
2131
+ ".kt": "kotlin",
2132
+ ".rb": "ruby",
2133
+ ".php": "php",
2134
+ ".c": "c",
2135
+ ".cpp": "cpp",
2136
+ ".h": "c",
2137
+ ".hpp": "cpp",
2138
+ ".cs": "csharp",
2139
+ ".swift": "swift",
2140
+ ".scala": "scala",
2141
+ ".sh": "bash",
2142
+ ".bash": "bash",
2143
+ ".zsh": "bash",
2144
+ ".sql": "sql",
2145
+ ".html": "xml",
2146
+ ".xml": "xml",
2147
+ ".css": "css",
2148
+ ".json": "json",
2149
+ ".yaml": "yaml",
2150
+ ".yml": "yaml",
2151
+ ".toml": "ini",
2152
+ ".md": "markdown",
2153
+ ".lua": "lua",
2154
+ ".r": "r",
2155
+ ".pl": "perl",
2156
+ ".ex": "elixir",
2157
+ ".exs": "elixir",
2158
+ ".erl": "erlang",
2159
+ ".hs": "haskell",
2160
+ ".ml": "ocaml",
2161
+ ".vim": "vim",
2162
+ ".dockerfile": "dockerfile",
2163
+ ".tf": "hcl",
2164
+ ".dart": "dart",
2165
+ }
2166
+
2167
+ async def _allowed_prefixes() -> set[str]:
2168
+ """Collect all filesystem paths the FS browser may access."""
2169
+ prefixes: set[str] = set()
2170
+ for p in _state["config"].get_all_scan_paths():
2171
+ prefixes.add(str(Path(p).expanduser().resolve()))
2172
+ async with Database(db_path) as db:
2173
+ data_sources = _state["config"].get_active_data_sources()
2174
+ projects = await db.get_projects(data_sources=data_sources)
2175
+ for proj in projects:
2176
+ if proj.resolved_path:
2177
+ prefixes.add(str(Path(proj.resolved_path).resolve()))
2178
+ for session in await db.get_sessions(data_sources=data_sources):
2179
+ if session.cwd:
2180
+ prefixes.add(str(Path(session.cwd).resolve()))
2181
+ return prefixes
2182
+
2183
+ def _validate_fs_path(requested: str, prefixes: set[str]) -> Path:
2184
+ resolved = Path(requested).resolve()
2185
+ if not any(str(resolved).startswith(p) for p in prefixes):
2186
+ raise HTTPException(
2187
+ status_code=403, detail="Path outside allowed directories"
2188
+ )
2189
+ return resolved
2190
+
2191
+ @app.get("/api/host-info")
2192
+ async def host_info(request: Request):
2193
+ """Per-connection metadata for the UI to gate host-side actions.
2194
+
2195
+ ``is_remote_client`` is True when the request did NOT originate from a
2196
+ loopback address on the server box. Used by the frontend to disable
2197
+ actions that run osascript / xdg-open on the SERVER's host (e.g.
2198
+ "Open in terminal", "Reveal in Finder") — those would silently fire
2199
+ on the host machine and confuse a remote viewer.
2200
+
2201
+ Caveat: this can't see through ssh -L / Tailscale forwards. A user
2202
+ tunneling to 127.0.0.1 reads as local to the server, which is the
2203
+ correct behavior — the actions DO run somewhere they can see.
2204
+ """
2205
+ host = request.client.host if request.client else None
2206
+ is_local = False
2207
+ if host:
2208
+ if host == "localhost":
2209
+ is_local = True
2210
+ else:
2211
+ try:
2212
+ is_local = ipaddress.ip_address(host).is_loopback
2213
+ except ValueError:
2214
+ is_local = False
2215
+ return {
2216
+ "is_remote_client": not is_local,
2217
+ "platform": sys.platform,
2218
+ "client_host": host,
2219
+ "version": __version__,
2220
+ }
2221
+
2222
+ UPDATE_CHECK_INTERVAL_S = 6 * 60 * 60 # cache: PyPI-friendly, still same-day fresh
2223
+
2224
+ @app.get("/api/update-check")
2225
+ async def update_check():
2226
+ """Best-effort PyPI version check backing the About panel's update
2227
+ banner. In-process cached so re-opening Settings doesn't hit PyPI on
2228
+ every click; a failed/offline check just reports no update available
2229
+ rather than erroring the UI (see update_check.fetch_latest_version).
2230
+ """
2231
+ cache = _state.get("update_check_cache")
2232
+ now = time.monotonic()
2233
+ if cache is None or now - cache["checked_at"] > UPDATE_CHECK_INTERVAL_S:
2234
+ latest = await fetch_latest_version()
2235
+ cache = {"checked_at": now, "latest": latest}
2236
+ _state["update_check_cache"] = cache
2237
+ latest = cache["latest"]
2238
+ return {
2239
+ "current_version": __version__,
2240
+ "latest_version": latest,
2241
+ "update_available": bool(latest) and is_newer(latest, __version__),
2242
+ }
2243
+
2244
+ @app.get("/api/fs/list")
2245
+ async def fs_list(
2246
+ path: str = Query(...),
2247
+ show_hidden: bool = Query(False),
2248
+ ):
2249
+ prefixes = await _allowed_prefixes()
2250
+ resolved = _validate_fs_path(path, prefixes)
2251
+ if not resolved.is_dir():
2252
+ raise HTTPException(status_code=404, detail="Not a directory")
2253
+
2254
+ entries: list[dict] = []
2255
+ for child in resolved.iterdir():
2256
+ if not show_hidden and child.name.startswith("."):
2257
+ continue
2258
+ try:
2259
+ stat = child.stat()
2260
+ except OSError:
2261
+ continue
2262
+ entries.append(
2263
+ {
2264
+ "name": child.name,
2265
+ "type": "dir" if child.is_dir() else "file",
2266
+ "size": stat.st_size if child.is_file() else None,
2267
+ "modified": stat.st_mtime,
2268
+ }
2269
+ )
2270
+
2271
+ dirs = sorted(
2272
+ [e for e in entries if e["type"] == "dir"], key=lambda e: e["name"].lower()
2273
+ )
2274
+ files = sorted(
2275
+ [e for e in entries if e["type"] == "file"], key=lambda e: e["name"].lower()
2276
+ )
2277
+ return {"path": str(resolved), "entries": dirs + files}
2278
+
2279
+ @app.put("/api/fs/write")
2280
+ async def fs_write(body: _FsWriteBody):
2281
+ # File-preview editing is always available; edit_enabled gates only
2282
+ # message edit/delete on the chat side.
2283
+ prefixes = await _allowed_prefixes()
2284
+ resolved = _validate_fs_path(body.path, prefixes)
2285
+ if resolved.is_dir():
2286
+ raise HTTPException(status_code=400, detail="Path is a directory")
2287
+ # Refuse to create new files for now — only edit existing.
2288
+ if not resolved.exists():
2289
+ raise HTTPException(status_code=404, detail="File does not exist")
2290
+ encoded = body.content.encode("utf-8")
2291
+ if len(encoded) > 2 * 1024 * 1024:
2292
+ raise HTTPException(status_code=413, detail="File too large (max 2MB)")
2293
+ if b"\x00" in encoded:
2294
+ raise HTTPException(status_code=415, detail="Binary content not allowed")
2295
+ try:
2296
+ with open(resolved, "w", encoding="utf-8") as f:
2297
+ f.write(body.content)
2298
+ except OSError as e:
2299
+ raise HTTPException(status_code=500, detail=f"Write failed: {e}")
2300
+ return {"ok": True, "path": str(resolved), "size": len(encoded)}
2301
+
2302
+ @app.get("/api/fs/read")
2303
+ async def fs_read(path: str = Query(...)):
2304
+ prefixes = await _allowed_prefixes()
2305
+ resolved = _validate_fs_path(path, prefixes)
2306
+ if not resolved.is_file():
2307
+ raise HTTPException(status_code=404, detail="Not a file")
2308
+
2309
+ size = resolved.stat().st_size
2310
+ if size > 2 * 1024 * 1024:
2311
+ raise HTTPException(status_code=413, detail="File too large (max 2MB)")
2312
+
2313
+ with open(resolved, "rb") as f:
2314
+ head = f.read(8192)
2315
+ if b"\x00" in head:
2316
+ raise HTTPException(status_code=415, detail="Binary file")
2317
+
2318
+ content = head.decode("utf-8", errors="replace")
2319
+ if size > 8192:
2320
+ with open(resolved, "r", encoding="utf-8", errors="replace") as f:
2321
+ content = f.read()
2322
+
2323
+ ext = resolved.suffix.lower()
2324
+ language = _EXTENSION_LANGUAGE.get(ext)
2325
+ if not language and resolved.name == "Dockerfile":
2326
+ language = "dockerfile"
2327
+ if not language and resolved.name == "Makefile":
2328
+ language = "makefile"
2329
+
2330
+ return {
2331
+ "path": str(resolved),
2332
+ "name": resolved.name,
2333
+ "content": content,
2334
+ "size": size,
2335
+ "language": language,
2336
+ }
2337
+
2338
+ @app.get("/api/fs/blob")
2339
+ async def fs_blob(path: str = Query(...)):
2340
+ import mimetypes
2341
+
2342
+ prefixes = await _allowed_prefixes()
2343
+ resolved = _validate_fs_path(path, prefixes)
2344
+ if not resolved.is_file():
2345
+ raise HTTPException(status_code=404, detail="Not a file")
2346
+
2347
+ size = resolved.stat().st_size
2348
+ if size > 50 * 1024 * 1024:
2349
+ raise HTTPException(status_code=413, detail="File too large (max 50MB)")
2350
+
2351
+ mime_type, _ = mimetypes.guess_type(str(resolved))
2352
+ if not mime_type:
2353
+ mime_type = "application/octet-stream"
2354
+
2355
+ from starlette.responses import FileResponse as _BlobFileResponse
2356
+
2357
+ return _BlobFileResponse(
2358
+ str(resolved),
2359
+ media_type=mime_type,
2360
+ headers={"Cache-Control": "private, max-age=60"},
2361
+ )
2362
+
2363
+ # ------------------------------------------------------------------
2364
+ # PTY runner endpoints (Phase 1)
2365
+ # ------------------------------------------------------------------
2366
+
2367
+ async def _resolve_pty_focus_params(
2368
+ session_id: str,
2369
+ *,
2370
+ bin_override: str | None = None,
2371
+ cwd_override: str | None = None,
2372
+ permission_mode_override: str | None = None,
2373
+ ) -> tuple[str, str, str, bool, Path | None]:
2374
+ """Resolve (bin_name, cwd, permission_mode, new_chat, jsonl_path) for PTY focus/submit.
2375
+
2376
+ Priority for PTY focus/submit:
2377
+ 1. Explicit override (caller knows best).
2378
+ 2. Existing on-disk session detail (resume case → new_chat=False).
2379
+ 3. ``app.state.pending_sessions`` entry minted by ``/api/sessions/new``
2380
+ (no JSONL yet → new_chat=True).
2381
+ 4. Active profile fallback for ad-hoc calls (treated as resume).
2382
+
2383
+ ``jsonl_path`` is ``None`` when no JSONL exists yet (new_chat=True
2384
+ + pending session). For resumes we hand back the on-disk JSONL
2385
+ path so the PTY runner can run its Phase-0 ownership check.
2386
+ """
2387
+ cfg = _state["config"]
2388
+ bin_name: str | None = bin_override
2389
+ cwd: str | None = cwd_override
2390
+ permission_mode: str | None = permission_mode_override
2391
+ new_chat = False
2392
+ jsonl_path: Path | None = None
2393
+
2394
+ async with Database(db_path) as db:
2395
+ detail = await db.get_session_detail(session_id)
2396
+ if detail is not None:
2397
+ if bin_name is None:
2398
+ bin_name = _derive_bin_name(detail.file_path)
2399
+ if cwd is None:
2400
+ cwd = detail.cwd or str(Path(detail.file_path).parent)
2401
+ if permission_mode is None and detail.permission_mode:
2402
+ permission_mode = detail.permission_mode
2403
+ jsonl_path = Path(detail.file_path)
2404
+ else:
2405
+ async with app.state.pending_sessions_lock:
2406
+ pending = app.state.pending_sessions.get(session_id)
2407
+ if pending is not None:
2408
+ new_chat = True
2409
+ if bin_name is None:
2410
+ bin_name = pending.bin_name
2411
+ if cwd is None:
2412
+ cwd = pending.cwd
2413
+ if permission_mode is None:
2414
+ permission_mode = pending.permission_mode
2415
+ else:
2416
+ if bin_name is None:
2417
+ bin_name = _active_profile_bin_name(cfg)
2418
+
2419
+ if cwd is None:
2420
+ cwd = os.getcwd()
2421
+ if permission_mode is None:
2422
+ permission_mode = cfg.claude_default_permission_mode or "default"
2423
+ if bin_name is None:
2424
+ bin_name = "claude"
2425
+
2426
+ return bin_name, cwd, permission_mode, new_chat, jsonl_path
2427
+
2428
+ @app.post("/api/pty/focus")
2429
+ async def pty_focus(req: _PtyFocusRequest):
2430
+ (
2431
+ bin_name,
2432
+ cwd,
2433
+ permission_mode,
2434
+ resolved_new_chat,
2435
+ jsonl_path,
2436
+ ) = await _resolve_pty_focus_params(
2437
+ req.session_id,
2438
+ bin_override=req.bin_name,
2439
+ cwd_override=req.cwd,
2440
+ permission_mode_override=req.permission_mode,
2441
+ )
2442
+ # Driver-backed providers (Codex) route to the DriverManager instead of
2443
+ # the Claude direct-PTY path. Gate on effective can_send so a read-only
2444
+ # / non-drivable Codex session 409s here rather than bringing a live
2445
+ # process up (until caps flip in 4e this is always a 409 for Codex).
2446
+ provider = await _resolve_provider(req.session_id)
2447
+ driver_new_chat = req.new_chat or resolved_new_chat
2448
+ if _driver_supports(provider):
2449
+ _require_capability(provider, "can_send")
2450
+ if _driver_manager is None:
2451
+ raise HTTPException(status_code=503, detail="driver manager not ready")
2452
+ # A brand-new driver chat has no rollout to resume — spawn fresh
2453
+ # (resume_uuid=None). The CLI (codex) mints its OWN rollout UUID on
2454
+ # the first message; we adopt it below so the placeholder id is
2455
+ # replaced by the real, persisted session.
2456
+ adopt_after_ts = time.time() - 2.0
2457
+ await _driver_manager.focus(
2458
+ req.session_id,
2459
+ provider=provider,
2460
+ cwd=cwd,
2461
+ model=req.model,
2462
+ resume_uuid=req.session_id,
2463
+ new_chat=driver_new_chat,
2464
+ rows=req.rows,
2465
+ )
2466
+ app.state.driver_sessions.add(req.session_id)
2467
+ if driver_new_chat:
2468
+ # Watch for the real rollout codex writes on the first message,
2469
+ # then re-key placeholder → real id and tell the FE to navigate.
2470
+ asyncio.ensure_future(
2471
+ _adopt_codex_session(req.session_id, cwd, adopt_after_ts)
2472
+ )
2473
+ return {"ok": True}
2474
+ # Caller's explicit req.new_chat wins; otherwise use the resolver's
2475
+ # inferred value (True iff session lives in pending_sessions only).
2476
+ new_chat = req.new_chat or resolved_new_chat
2477
+ # Pre-empt claude's trust-this-folder TUI dialog. Without this the
2478
+ # dialog eats the first user message (Enter selects "Yes, I trust"
2479
+ # and the typed content goes nowhere). See _ensure_trust docstring.
2480
+ try:
2481
+ _ensure_trust(_config_dir_for_bin(_state["config"], bin_name), cwd)
2482
+ except Exception as exc:
2483
+ _log.warning(
2484
+ "pty: trust pre-flight failed for %s in %s: %s",
2485
+ bin_name,
2486
+ cwd,
2487
+ exc,
2488
+ )
2489
+ try:
2490
+ await _pty_manager.focus(
2491
+ req.session_id,
2492
+ cwd=cwd,
2493
+ bin_name=bin_name,
2494
+ model=req.model or "",
2495
+ permission_mode=permission_mode,
2496
+ new_chat=new_chat,
2497
+ rows=req.rows if req.rows is not None else DEFAULT_ROWS,
2498
+ jsonl_path=jsonl_path,
2499
+ )
2500
+ except PtyOwnershipConflict as exc:
2501
+ # Foreign claude already owns the JSONL — surface as 409 so
2502
+ # the FE can render its Take-over banner without surprising
2503
+ # the user with a spinner that never finishes.
2504
+ raise HTTPException(
2505
+ status_code=409,
2506
+ detail={
2507
+ "kind": "pty_ownership_conflict",
2508
+ "session_id": req.session_id,
2509
+ "foreign_pids": exc.foreign_pids,
2510
+ "jsonl_path": str(jsonl_path) if jsonl_path else None,
2511
+ },
2512
+ )
2513
+ return {"ok": True}
2514
+
2515
+ @app.post("/api/pty/blur")
2516
+ async def pty_blur(req: _PtyBlurRequest):
2517
+ # Driver-backed sessions have no idle reaper — blur is a no-op so the
2518
+ # tmux session survives navigation/disconnect (the persistence point).
2519
+ if _is_driver_session(req.session_id):
2520
+ return {"ok": True}
2521
+ await _pty_manager.unfocus(req.session_id)
2522
+ return {"ok": True}
2523
+
2524
+ @app.post("/api/pty/submit")
2525
+ async def pty_submit(req: _PtySubmitRequest):
2526
+ # Defensive: if the session's cwd directory was deleted since the
2527
+ # FE last loaded the detail, refuse the submit with a clear 410.
2528
+ # The FE banner + handleSend short-circuit cover the typical path;
2529
+ # this protects against the cwd disappearing mid-session.
2530
+ async with Database(db_path) as db:
2531
+ _detail = await db.get_session_detail(req.session_id)
2532
+ if _detail is not None and _detail.cwd and not _detail.cwd_exists:
2533
+ raise HTTPException(
2534
+ status_code=410,
2535
+ detail=(
2536
+ f"Session's working directory no longer exists: "
2537
+ f"{_detail.cwd}. Cannot deliver the message."
2538
+ ),
2539
+ )
2540
+ # Driver-backed providers (Codex) submit through the DriverManager.
2541
+ # Gate on effective can_send: a read-only / no-tmux Codex session 409s
2542
+ # here instead of falling through and wrongly spawning a *claude* PTY
2543
+ # against the Codex cwd (the read-only-honesty bug). Until caps flip in
2544
+ # 4e this is always a 409 for Codex.
2545
+ # Resolve provider via the pending-aware helper so a brand-new Codex
2546
+ # chat (no on-disk detail yet) still routes to the DriverManager instead
2547
+ # of falling through to the Claude PTY path.
2548
+ provider = await _resolve_provider(req.session_id)
2549
+ if _driver_supports(provider):
2550
+ _require_capability(provider, "can_send")
2551
+ if _driver_manager is None:
2552
+ raise HTTPException(status_code=503, detail="driver manager not ready")
2553
+ if _detail is not None:
2554
+ cwd = _detail.cwd or str(Path(_detail.file_path).parent)
2555
+ driver_new_chat = False
2556
+ else:
2557
+ async with app.state.pending_sessions_lock:
2558
+ pending = app.state.pending_sessions.get(req.session_id)
2559
+ cwd = pending.cwd if pending is not None else os.getcwd()
2560
+ driver_new_chat = pending is not None
2561
+ adopt_after_ts = time.time() - 2.0
2562
+ await _driver_manager.focus(
2563
+ req.session_id,
2564
+ provider=provider,
2565
+ cwd=cwd,
2566
+ model=req.model,
2567
+ resume_uuid=req.session_id,
2568
+ new_chat=driver_new_chat,
2569
+ )
2570
+ app.state.driver_sessions.add(req.session_id)
2571
+ if driver_new_chat:
2572
+ asyncio.ensure_future(
2573
+ _adopt_codex_session(req.session_id, cwd, adopt_after_ts)
2574
+ )
2575
+ await _driver_manager.submit(req.session_id, req.content)
2576
+ return {"ok": True}
2577
+ # Lazily ensure focus. The frontend wires onFocus on the chat input
2578
+ # but that doesn't always fire (autofocus on mount, programmatic
2579
+ # focus, navigation between sessions with cursor still in input).
2580
+ # Auto-focusing on submit keeps the user-facing contract simple:
2581
+ # POST /api/pty/submit just works for any valid session_id.
2582
+ #
2583
+ # Model handling: ``req.model`` reflects the model picker's current
2584
+ # selection at submit time. If the live channel was spawned with a
2585
+ # different ``--model``, kill it so the auto-focus block below
2586
+ # respawns with the user's intended model — claude's TUI is locked
2587
+ # to its spawn-time ``--model``, so a mid-session change requires
2588
+ # a respawn. This costs the next message ~1-3s of spawn time but
2589
+ # honours the user's expectation that the picker matches the reply.
2590
+ requested_model = req.model or ""
2591
+ status = _pty_manager.status(req.session_id)
2592
+ # Heuristic for "turn currently in flight": user input has been
2593
+ # written more recently than the last drained PTY byte. We skip
2594
+ # the model switch during a live turn — sending /model mid-stream
2595
+ # would mean the in-flight reply finishes on the old model and the
2596
+ # *next* turn uses the new one, but the slash command itself shows
2597
+ # up in JSONL out of order. The picker change takes effect on the
2598
+ # next idle submit instead. UX cost: one "wrong-model" turn after
2599
+ # switching mid-stream.
2600
+ last_in = int(status.get("last_input_ms") or 0)
2601
+ last_out = int(status.get("last_pty_output_ms") or 0)
2602
+ turn_in_flight = last_in > 0 and last_in > last_out
2603
+ if (
2604
+ status.get("alive")
2605
+ and requested_model
2606
+ and status.get("model") != requested_model
2607
+ and not turn_in_flight
2608
+ ):
2609
+ # Use claude's /model slash command to switch the live TUI's
2610
+ # model in place. Keeps the TUI alive, conversation context
2611
+ # intact, near-instant — vs the old kill+respawn path that
2612
+ # blocked the submit for 10-15s while the new TUI bootstrapped.
2613
+ await _pty_manager.switch_model(req.session_id, requested_model)
2614
+ if not status.get("alive"):
2615
+ (
2616
+ bin_name,
2617
+ cwd,
2618
+ permission_mode,
2619
+ new_chat,
2620
+ jsonl_path,
2621
+ ) = await _resolve_pty_focus_params(req.session_id)
2622
+ try:
2623
+ _ensure_trust(_config_dir_for_bin(_state["config"], bin_name), cwd)
2624
+ except Exception as exc:
2625
+ _log.warning(
2626
+ "pty: trust pre-flight failed for %s in %s: %s", bin_name, cwd, exc
2627
+ )
2628
+ try:
2629
+ await _pty_manager.focus(
2630
+ req.session_id,
2631
+ cwd=cwd,
2632
+ bin_name=bin_name,
2633
+ model=requested_model,
2634
+ permission_mode=permission_mode,
2635
+ new_chat=new_chat,
2636
+ jsonl_path=jsonl_path,
2637
+ )
2638
+ except PtyOwnershipConflict as exc:
2639
+ raise HTTPException(
2640
+ status_code=409,
2641
+ detail={
2642
+ "kind": "pty_ownership_conflict",
2643
+ "session_id": req.session_id,
2644
+ "foreign_pids": exc.foreign_pids,
2645
+ "jsonl_path": str(jsonl_path) if jsonl_path else None,
2646
+ },
2647
+ )
2648
+ try:
2649
+ await _pty_manager.submit(req.session_id, req.content)
2650
+ except PtySubmitInFlight as exc:
2651
+ raise HTTPException(status_code=409, detail=str(exc))
2652
+ except PtyOwnershipConflict as exc:
2653
+ # Auto-respawn inside submit() can hit the same conflict if
2654
+ # a foreign claude attached during our idle-kill window.
2655
+ raise HTTPException(
2656
+ status_code=409,
2657
+ detail={
2658
+ "kind": "pty_ownership_conflict",
2659
+ "session_id": req.session_id,
2660
+ "foreign_pids": exc.foreign_pids,
2661
+ },
2662
+ )
2663
+ return {"ok": True}
2664
+
2665
+ def _is_driver_session(session_id: str) -> bool:
2666
+ """True if this session is currently routed to the DriverManager."""
2667
+ return session_id in app.state.driver_sessions
2668
+
2669
+ @app.post("/api/pty/kill")
2670
+ async def pty_kill(req: _PtyKillRequest):
2671
+ if _is_driver_session(req.session_id) and _driver_manager is not None:
2672
+ await _driver_manager.kill(req.session_id)
2673
+ app.state.driver_sessions.discard(req.session_id)
2674
+ return {"ok": True}
2675
+ await _pty_manager.kill(req.session_id)
2676
+ return {"ok": True}
2677
+
2678
+ @app.get("/api/pty/native-snapshot")
2679
+ async def pty_native_snapshot(session_id: str = Query(...)):
2680
+ try:
2681
+ if _is_driver_session(session_id) and _driver_manager is not None:
2682
+ return await _driver_manager.native_snapshot(session_id)
2683
+ return _pty_manager.native_snapshot(session_id)
2684
+ except RuntimeError as exc:
2685
+ raise HTTPException(status_code=404, detail=str(exc))
2686
+
2687
+ @app.post("/api/pty/input")
2688
+ async def pty_native_input(req: _PtyNativeInputRequest):
2689
+ try:
2690
+ if _is_driver_session(req.session_id) and _driver_manager is not None:
2691
+ await _driver_manager.write_raw_input(
2692
+ req.session_id, decode_terminal_input(req.data)
2693
+ )
2694
+ else:
2695
+ await _pty_manager.write_raw_input(
2696
+ req.session_id,
2697
+ decode_terminal_input(req.data),
2698
+ )
2699
+ except RuntimeError as exc:
2700
+ raise HTTPException(status_code=404, detail=str(exc))
2701
+ return {"ok": True}
2702
+
2703
+ @app.post("/api/pty/resize")
2704
+ async def pty_resize(req: _PtyResizeRequest):
2705
+ try:
2706
+ if _is_driver_session(req.session_id) and _driver_manager is not None:
2707
+ await _driver_manager.resize(req.session_id, req.rows, req.cols)
2708
+ else:
2709
+ await _pty_manager.resize(req.session_id, req.rows, req.cols)
2710
+ except RuntimeError as exc:
2711
+ raise HTTPException(status_code=404, detail=str(exc))
2712
+ return {"ok": True}
2713
+
2714
+ @app.get("/api/pty/status")
2715
+ async def pty_status(session_id: str = Query(...)):
2716
+ if _is_driver_session(session_id) and _driver_manager is not None:
2717
+ return _driver_manager.status(session_id)
2718
+ return _pty_manager.status(session_id)
2719
+
2720
+ @app.get("/api/pty/ownership/{session_id}")
2721
+ async def pty_ownership(session_id: str):
2722
+ """Phase-0 ownership snapshot. Pure read — never spawns.
2723
+
2724
+ Returns ``{status, foreign_pids, jsonl_path}`` for the FE badge.
2725
+ For sessions whose JSONL doesn't exist yet (brand-new pending
2726
+ sessions, or unknown ids), reports ``status="idle"``.
2727
+ """
2728
+ async with Database(db_path) as db:
2729
+ detail = await db.get_session_detail(session_id)
2730
+ jsonl_path = Path(detail.file_path) if detail is not None else None
2731
+ return _pty_manager.ownership(session_id, jsonl_path)
2732
+
2733
+ @app.post("/api/pty/takeover/{session_id}")
2734
+ async def pty_takeover(session_id: str):
2735
+ """Phase-0 take-over. SIGINTs each foreign claude attached to the
2736
+ session and polls until they release the JSONL fd (or ~3 s
2737
+ elapses).
2738
+
2739
+ Returns 200 on success (the FE follows up with the regular focus
2740
+ call). Returns 409 if any pid is still attached after the poll
2741
+ window — matches the plan's "no SIGTERM/SIGKILL fallback"
2742
+ decision: we don't escalate.
2743
+ """
2744
+ async with Database(db_path) as db:
2745
+ detail = await db.get_session_detail(session_id)
2746
+ if detail is None:
2747
+ raise HTTPException(
2748
+ status_code=404,
2749
+ detail=f"Unknown session: {session_id}",
2750
+ )
2751
+ jsonl_path = Path(detail.file_path)
2752
+ pids = _session_conflict_pids(session_id, jsonl_path)
2753
+ if not pids:
2754
+ _unlink_fresh_foreign_sidecar(jsonl_path)
2755
+ return {"ok": True, "released_pids": [], "still_held_by": []}
2756
+ released: list[int] = []
2757
+ for pid in pids:
2758
+ try:
2759
+ os.kill(pid, signal.SIGINT)
2760
+ released.append(pid)
2761
+ except ProcessLookupError:
2762
+ # Already gone — counts as a release.
2763
+ released.append(pid)
2764
+ except PermissionError as exc:
2765
+ raise HTTPException(
2766
+ status_code=403,
2767
+ detail=(
2768
+ f"Cannot signal pid {pid}: {exc}. "
2769
+ "Take over manually from the owning terminal."
2770
+ ),
2771
+ )
2772
+ # Poll: claude exits in ~300 ms once SIGINT is processed (see
2773
+ # docs/pty-ownership-phase0-findings.md). Budget 3 s.
2774
+ end = time.monotonic() + 3.0
2775
+ still: list[int] = list(pids)
2776
+ while time.monotonic() < end and still:
2777
+ await asyncio.sleep(0.1)
2778
+ still = _session_conflict_pids(session_id, jsonl_path)
2779
+ if still:
2780
+ raise HTTPException(
2781
+ status_code=409,
2782
+ detail={
2783
+ "kind": "pty_takeover_timeout",
2784
+ "session_id": session_id,
2785
+ "still_held_by": still,
2786
+ },
2787
+ )
2788
+ _unlink_fresh_foreign_sidecar(jsonl_path)
2789
+ return {"ok": True, "released_pids": released, "still_held_by": []}
2790
+
2791
+ # ------------------------------------------------------------------
2792
+ # Static frontend — must be registered LAST (catch-all route)
2793
+ # ------------------------------------------------------------------
2794
+ static_dir = Path(__file__).parent / "static"
2795
+ if static_dir.exists():
2796
+ # Hashed JS/CSS assets can be cached aggressively (immutable).
2797
+ # index.html must never be cached — its name has no content hash,
2798
+ # so browsers would serve stale bundles after a rebuild.
2799
+ from fastapi.responses import FileResponse as _FileResponse
2800
+
2801
+ @app.get("/", include_in_schema=False)
2802
+ @app.get("/{full_path:path}", include_in_schema=False)
2803
+ async def _spa(full_path: str = ""):
2804
+ candidate = static_dir / full_path
2805
+ if full_path and candidate.exists() and candidate.is_file():
2806
+ return _FileResponse(
2807
+ str(candidate),
2808
+ headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
2809
+ )
2810
+ # Hashed asset misses must 404, NOT fall through to index.html.
2811
+ # Otherwise a browser holding a stale chunk hash (e.g. after a
2812
+ # rebuild) gets text/html for an /assets/<old-hash>.js request
2813
+ # and throws "Failed to fetch dynamically imported module" with
2814
+ # no actionable error — see frontend lazyWithRetry which only
2815
+ # triggers its reload prompt on a real fetch failure.
2816
+ if full_path.startswith("assets/"):
2817
+ raise HTTPException(status_code=404)
2818
+ index = static_dir / "index.html"
2819
+ return _FileResponse(
2820
+ str(index),
2821
+ headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
2822
+ )
2823
+
2824
+ return app
2825
+
2826
+
2827
+ # ---------------------------------------------------------------------------
2828
+ # Module-level app for `uvicorn clau_decode.server:app --reload`
2829
+ # ---------------------------------------------------------------------------
2830
+
2831
+
2832
+ def _build_default_app() -> FastAPI:
2833
+ from .config import get_db_path, load_config
2834
+
2835
+ _config = load_config()
2836
+ _db_path = get_db_path()
2837
+ _db_path.parent.mkdir(parents=True, exist_ok=True)
2838
+ return create_app(_config, _db_path)
2839
+
2840
+
2841
+ app = _build_default_app()