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.
- agent_decoder-0.3.2.dist-info/METADATA +387 -0
- agent_decoder-0.3.2.dist-info/RECORD +89 -0
- agent_decoder-0.3.2.dist-info/WHEEL +4 -0
- agent_decoder-0.3.2.dist-info/entry_points.txt +2 -0
- agent_decoder-0.3.2.dist-info/licenses/LICENSE +111 -0
- clau_decode/__init__.py +8 -0
- clau_decode/__main__.py +11 -0
- clau_decode/_auth_env.py +118 -0
- clau_decode/_pty_preexec.py +42 -0
- clau_decode/analytics/__init__.py +0 -0
- clau_decode/analytics/aggregator.py +55 -0
- clau_decode/analytics/cost.py +76 -0
- clau_decode/analytics/extractor.py +32 -0
- clau_decode/analytics/fast.py +289 -0
- clau_decode/analytics/models.py +49 -0
- clau_decode/analytics/pricing.py +162 -0
- clau_decode/analytics/prompt.py +34 -0
- clau_decode/analytics/service.py +23 -0
- clau_decode/analytics/stats.py +117 -0
- clau_decode/analytics/tips.py +176 -0
- clau_decode/btw_capture.py +290 -0
- clau_decode/cli.py +504 -0
- clau_decode/config.py +144 -0
- clau_decode/db.py +2054 -0
- clau_decode/driver_manager.py +387 -0
- clau_decode/drivers/__init__.py +27 -0
- clau_decode/drivers/base.py +170 -0
- clau_decode/drivers/registry.py +113 -0
- clau_decode/drivers/tmux_driver.py +488 -0
- clau_decode/editor.py +182 -0
- clau_decode/events_bus.py +69 -0
- clau_decode/locks.py +388 -0
- clau_decode/migrate.py +1313 -0
- clau_decode/models.py +285 -0
- clau_decode/parser.py +466 -0
- clau_decode/providers/__init__.py +30 -0
- clau_decode/providers/base.py +132 -0
- clau_decode/providers/claude.py +81 -0
- clau_decode/providers/codex.py +441 -0
- clau_decode/providers/registry.py +89 -0
- clau_decode/pty_native.py +39 -0
- clau_decode/pty_runner.py +2406 -0
- clau_decode/pty_screen_state.py +65 -0
- clau_decode/recap_runner.py +230 -0
- clau_decode/reporter.py +305 -0
- clau_decode/scanner.py +125 -0
- clau_decode/server.py +2841 -0
- clau_decode/static/android-chrome-192x192.png +0 -0
- clau_decode/static/android-chrome-512x512.png +0 -0
- clau_decode/static/app-icon.png +0 -0
- clau_decode/static/apple-touch-icon.png +0 -0
- clau_decode/static/assets/ChatView-BBVXuITs.js +79 -0
- clau_decode/static/assets/ChatView-BrP-ENHg.css +1 -0
- clau_decode/static/assets/Dashboard-3CYO4HQw.js +1 -0
- clau_decode/static/assets/FileViewer-BwbDc3ky.js +1 -0
- clau_decode/static/assets/HelpPopup-CPtECjYw.js +1 -0
- clau_decode/static/assets/ScrollContainer-BT_Dzxah.js +1 -0
- clau_decode/static/assets/SearchOverlay-DqI7aZaC.js +1 -0
- clau_decode/static/assets/SessionItem-DlyKg80u.js +1 -0
- clau_decode/static/assets/SettingsModal-CZBfMqx7.js +1 -0
- clau_decode/static/assets/ShortcutsPopup-W7CPdCzW.js +1 -0
- clau_decode/static/assets/Sidebar-BBswhEua.js +1 -0
- clau_decode/static/assets/client-DTSXvAb8.js +1 -0
- clau_decode/static/assets/fira-code-DGosTW8U.woff2 +0 -0
- clau_decode/static/assets/formatRelative-j86webkr.js +1 -0
- clau_decode/static/assets/hljs-B3O74O7Z.js +35 -0
- clau_decode/static/assets/index-BHdCOIkq.css +10 -0
- clau_decode/static/assets/index-DfKltZ6z.js +5 -0
- clau_decode/static/assets/ioskeley-mono-C7VR2-sL.woff2 +0 -0
- clau_decode/static/assets/jetbrains-mono-V6pRDFza.woff2 +0 -0
- clau_decode/static/assets/julia-mono-Cbvba6sW.woff2 +0 -0
- clau_decode/static/assets/libertinus-mono-Coz17E-O.woff2 +0 -0
- clau_decode/static/assets/monaspace-argon-RS-2TGNg.woff2 +0 -0
- clau_decode/static/assets/nativePtyFonts-BaXKtUsn.js +1 -0
- clau_decode/static/assets/react-Bi_azaFt.js +9 -0
- clau_decode/static/assets/renderSnippet-CqpQQugH.js +1 -0
- clau_decode/static/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
- clau_decode/static/assets/shared-DdVGprvF.js +1 -0
- clau_decode/static/assets/source-code-pro-DEXWLoHG.woff2 +0 -0
- clau_decode/static/assets/spline-sans-mono-mUpA6Mve.woff2 +0 -0
- clau_decode/static/assets/ui-BcGPjpYn.js +1 -0
- clau_decode/static/assets/xanh-mono-Dsg19vWS.woff2 +0 -0
- clau_decode/static/favicon-16x16.png +0 -0
- clau_decode/static/favicon-32x32.png +0 -0
- clau_decode/static/favicon.ico +0 -0
- clau_decode/static/index.html +24 -0
- clau_decode/static/site.webmanifest +1 -0
- clau_decode/update_check.py +51 -0
- 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()
|