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/editor.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Session file editor — swap-based JSONL mutations.
|
|
2
|
+
|
|
3
|
+
The edit strategy:
|
|
4
|
+
1. Read the original file.
|
|
5
|
+
2. Write a backup copy at ``<stem>.bak.<ts>.jsonl`` that gets its own new UUID
|
|
6
|
+
as ``sessionId`` in content — so it shows up as a distinct session in the
|
|
7
|
+
sidebar labelled "[original]".
|
|
8
|
+
3. Write the edited content back to the **same path** as the original, keeping
|
|
9
|
+
the original session UUID. The session can resume as normal because it
|
|
10
|
+
finds the expected file at the expected path.
|
|
11
|
+
|
|
12
|
+
The backup file includes a ``clau-decode-backup`` metadata record so clau-decode
|
|
13
|
+
can mark it ``is_fork = True`` (disabling "Open in Terminal" since its new UUID
|
|
14
|
+
is unknown to the CLI's internal index).
|
|
15
|
+
|
|
16
|
+
Legacy in-place helpers are kept for the test suite only.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import uuid as _uuid_mod
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def swap_session(
|
|
30
|
+
original_path: Path,
|
|
31
|
+
session_id: str,
|
|
32
|
+
*,
|
|
33
|
+
delete_uuid: str | None = None,
|
|
34
|
+
edit_uuid: str | None = None,
|
|
35
|
+
new_content: list[dict] | None = None,
|
|
36
|
+
) -> tuple[Path, str, Path, str]:
|
|
37
|
+
"""Edit a session by swapping its file content in-place.
|
|
38
|
+
|
|
39
|
+
The original file is replaced with the edited version (keeping the same
|
|
40
|
+
filename / session UUID so the session can still be resumed). The original
|
|
41
|
+
content is preserved as a timestamped backup with a fresh UUID.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
``(edited_path, session_id, backup_path, backup_session_id)``
|
|
45
|
+
|
|
46
|
+
* ``edited_path`` — same as ``original_path``; the live, resumable session.
|
|
47
|
+
* ``session_id`` — unchanged original UUID.
|
|
48
|
+
* ``backup_path`` — the ``.bak.`` file containing the full original.
|
|
49
|
+
* ``backup_session_id`` — new UUID assigned to the backup session.
|
|
50
|
+
"""
|
|
51
|
+
lines = _read_lines(original_path)
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
# 1. Backup: original content with a new session UUID in metadata.
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
backup_id = str(_uuid_mod.uuid4())
|
|
57
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
58
|
+
backup_path = original_path.with_name(f"{original_path.stem}.bak.{ts}.jsonl")
|
|
59
|
+
|
|
60
|
+
backup_lines: list[str] = [
|
|
61
|
+
json.dumps({"type": "clau-decode-backup", "originalSessionId": session_id})
|
|
62
|
+
]
|
|
63
|
+
for line in lines:
|
|
64
|
+
try:
|
|
65
|
+
rec = json.loads(line)
|
|
66
|
+
# Restamp sessionId references so the backup has its own unique ID.
|
|
67
|
+
if "sessionId" in rec:
|
|
68
|
+
rec["sessionId"] = backup_id
|
|
69
|
+
# Prepend "[original] " to the title so the sidebar is readable.
|
|
70
|
+
if rec.get("type") == "custom-title" and rec.get("customTitle"):
|
|
71
|
+
rec["customTitle"] = f"[original] {rec['customTitle']}"
|
|
72
|
+
backup_lines.append(json.dumps(rec, ensure_ascii=False))
|
|
73
|
+
except json.JSONDecodeError:
|
|
74
|
+
backup_lines.append(line)
|
|
75
|
+
|
|
76
|
+
backup_path.write_text("\n".join(backup_lines) + "\n", encoding="utf-8")
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
# 2. Edited file: written back to the original path.
|
|
80
|
+
# No UUID remapping — same message UUIDs, same session UUID.
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
edited_lines: list[str] = []
|
|
83
|
+
for line in lines:
|
|
84
|
+
try:
|
|
85
|
+
record = json.loads(line)
|
|
86
|
+
except json.JSONDecodeError:
|
|
87
|
+
edited_lines.append(line)
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
old_uuid = record.get("uuid")
|
|
91
|
+
|
|
92
|
+
if delete_uuid and old_uuid == delete_uuid:
|
|
93
|
+
continue # omit
|
|
94
|
+
|
|
95
|
+
if edit_uuid and old_uuid == edit_uuid and new_content is not None:
|
|
96
|
+
record.setdefault("message", {})["content"] = new_content
|
|
97
|
+
|
|
98
|
+
edited_lines.append(json.dumps(record, ensure_ascii=False))
|
|
99
|
+
|
|
100
|
+
original_path.write_text("\n".join(edited_lines) + "\n", encoding="utf-8")
|
|
101
|
+
|
|
102
|
+
# Force-bump mtime to "now" so downstream consumers (watcher,
|
|
103
|
+
# _scan_one's stored_mtime == current_mtime fast-path, and any
|
|
104
|
+
# mtime-based dedupe) always observe the swap as a change, even on
|
|
105
|
+
# filesystems with coarse mtime resolution where two writes in the
|
|
106
|
+
# same second can collide. write_text already touches mtime on APFS,
|
|
107
|
+
# but this guarantees the contract regardless of FS.
|
|
108
|
+
os.utime(original_path, None)
|
|
109
|
+
|
|
110
|
+
return original_path, session_id, backup_path, backup_id
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# Legacy in-place helpers (kept for tests; not used by server routes)
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def backup_session(path: Path) -> Path:
|
|
119
|
+
"""Copy path to <path>.bak.<YYYYMMDD_HHMMSS>[_N].jsonl and return the backup path."""
|
|
120
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
121
|
+
backup = path.with_name(f"{path.stem}.bak.{ts}.jsonl")
|
|
122
|
+
n = 1
|
|
123
|
+
while backup.exists():
|
|
124
|
+
backup = path.with_name(f"{path.stem}.bak.{ts}_{n}.jsonl")
|
|
125
|
+
n += 1
|
|
126
|
+
shutil.copy2(path, backup)
|
|
127
|
+
return backup
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def delete_from_session(path: Path, message_uuid: str) -> None:
|
|
131
|
+
"""Remove the JSONL line whose uuid == message_uuid. Writes in-place atomically."""
|
|
132
|
+
lines = _read_lines(path)
|
|
133
|
+
kept = [line for line in lines if _get_uuid(line) != message_uuid]
|
|
134
|
+
if len(kept) == len(lines):
|
|
135
|
+
return
|
|
136
|
+
_write_lines(path, kept)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def edit_content_in_session(
|
|
140
|
+
path: Path, message_uuid: str, new_content: list[dict]
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Replace message.content for the line matching message_uuid. Writes atomically."""
|
|
143
|
+
lines = _read_lines(path)
|
|
144
|
+
result = []
|
|
145
|
+
changed = False
|
|
146
|
+
for line in lines:
|
|
147
|
+
if _get_uuid(line) == message_uuid:
|
|
148
|
+
try:
|
|
149
|
+
record = json.loads(line)
|
|
150
|
+
record.setdefault("message", {})["content"] = new_content
|
|
151
|
+
result.append(json.dumps(record, ensure_ascii=False))
|
|
152
|
+
changed = True
|
|
153
|
+
continue
|
|
154
|
+
except (json.JSONDecodeError, KeyError):
|
|
155
|
+
pass
|
|
156
|
+
result.append(line)
|
|
157
|
+
if changed:
|
|
158
|
+
_write_lines(path, result)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Internal helpers
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _read_lines(path: Path) -> list[str]:
|
|
167
|
+
return [
|
|
168
|
+
line for line in path.read_text(encoding="utf-8").splitlines() if line.strip()
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _write_lines(path: Path, lines: list[str]) -> None:
|
|
173
|
+
tmp = path.with_suffix(".tmp")
|
|
174
|
+
tmp.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
175
|
+
tmp.replace(path)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _get_uuid(line: str) -> str | None:
|
|
179
|
+
try:
|
|
180
|
+
return json.loads(line).get("uuid")
|
|
181
|
+
except (json.JSONDecodeError, AttributeError):
|
|
182
|
+
return None
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Per-connection SSE broadcaster.
|
|
2
|
+
|
|
3
|
+
The /api/events endpoint historically drained a *single* asyncio.Queue, which
|
|
4
|
+
meant two clients pointed at the same server would steal events from each
|
|
5
|
+
other (only one would receive any given refresh / rename / etc.). This module
|
|
6
|
+
provides a small SRP fan-out so every connected client gets every event.
|
|
7
|
+
|
|
8
|
+
Contract:
|
|
9
|
+
bus = EventBroadcaster()
|
|
10
|
+
q = bus.subscribe() # returns an asyncio.Queue
|
|
11
|
+
await bus.publish({...}) # delivers a SHALLOW-COPY of the event to every queue
|
|
12
|
+
bus.unsubscribe(q) # idempotent cleanup; safe to call from finally:
|
|
13
|
+
|
|
14
|
+
Notes:
|
|
15
|
+
- publish() never awaits on a slow consumer: if a queue fills up we drop
|
|
16
|
+
the event for that subscriber rather than block the publisher. Refresh
|
|
17
|
+
events are idempotent and renames have a server-authoritative cache, so
|
|
18
|
+
drop-on-overflow is the right tradeoff for keeping the bus non-blocking.
|
|
19
|
+
- The same broadcaster instance is shared by every emitter (file watcher,
|
|
20
|
+
/api/sessions/{id}/title endpoint, future producers). Keep it tiny.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EventBroadcaster:
|
|
30
|
+
"""Fan-out publisher with one bounded asyncio.Queue per subscriber."""
|
|
31
|
+
|
|
32
|
+
# Queue depth large enough to absorb a burst of file-watcher events while
|
|
33
|
+
# a slow client catches up, small enough that an unresponsive client
|
|
34
|
+
# doesn't pin a lot of memory.
|
|
35
|
+
_QUEUE_MAXSIZE = 64
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
self._subscribers: set[asyncio.Queue[Any]] = set()
|
|
39
|
+
|
|
40
|
+
def subscribe(self) -> asyncio.Queue[Any]:
|
|
41
|
+
"""Register a new subscriber and return its dedicated queue."""
|
|
42
|
+
q: asyncio.Queue[Any] = asyncio.Queue(maxsize=self._QUEUE_MAXSIZE)
|
|
43
|
+
self._subscribers.add(q)
|
|
44
|
+
return q
|
|
45
|
+
|
|
46
|
+
def unsubscribe(self, queue: asyncio.Queue[Any]) -> None:
|
|
47
|
+
"""Remove a subscriber. Idempotent — safe in a finally block."""
|
|
48
|
+
self._subscribers.discard(queue)
|
|
49
|
+
|
|
50
|
+
def publish(self, event: Any) -> None:
|
|
51
|
+
"""Deliver ``event`` to every subscriber, dropping on full queues.
|
|
52
|
+
|
|
53
|
+
Synchronous (not async) so producers from anywhere — even sync
|
|
54
|
+
callbacks — can publish without awaiting. asyncio.Queue.put_nowait
|
|
55
|
+
is safe from the running loop's thread.
|
|
56
|
+
"""
|
|
57
|
+
for q in list(self._subscribers):
|
|
58
|
+
try:
|
|
59
|
+
q.put_nowait(event)
|
|
60
|
+
except asyncio.QueueFull:
|
|
61
|
+
# Slow consumer — skip this event rather than block the bus.
|
|
62
|
+
# Refresh events are idempotent; rename events are reconciled
|
|
63
|
+
# against the server-authoritative response on next fetch.
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def subscriber_count(self) -> int:
|
|
68
|
+
"""Test/observability helper."""
|
|
69
|
+
return len(self._subscribers)
|
clau_decode/locks.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Phase-1 JSONL lock sidecar (pty-ownership-plan.md).
|
|
2
|
+
|
|
3
|
+
Promotes the Phase-0 hybrid pgrep+lsof detector from heuristic to
|
|
4
|
+
authoritative for clau-decode-managed sessions. Each ``PtyChannel``
|
|
5
|
+
acquires a ``<jsonl_path>.lock`` JSON sidecar on spawn and refreshes
|
|
6
|
+
its ``heartbeat_at`` every 30 s; ``release()`` runs on kill. A second
|
|
7
|
+
clau-decode (or — once Phase 2 ships — a wrapped terminal claude)
|
|
8
|
+
reading the sidecar can identify the existing owner by ``pid`` +
|
|
9
|
+
``hostname`` and decide whether to refuse the spawn or offer take-over.
|
|
10
|
+
|
|
11
|
+
Acquisition uses ``open(..., O_CREAT|O_EXCL)`` rather than the
|
|
12
|
+
plan's drafted "atomic rename + re-read" pattern — the rename pattern
|
|
13
|
+
can't detect a lost race because every writer's content wins on
|
|
14
|
+
``os.replace``. ``O_EXCL`` gives true exclusion at the filesystem
|
|
15
|
+
level. The findings doc records this deviation.
|
|
16
|
+
|
|
17
|
+
Sidecar schema (JSON, single line)::
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
"owner_kind": "clau-decode",
|
|
21
|
+
"pid": 12345,
|
|
22
|
+
"hostname": "example-host.local",
|
|
23
|
+
"heartbeat_at": "2026-05-26T14:30:01.234567+00:00",
|
|
24
|
+
"ui_endpoint": "http://127.0.0.1:4242"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Out of scope (documented in plan):
|
|
28
|
+
- NFS / network filesystems — O_EXCL semantics vary.
|
|
29
|
+
- Cross-host coordination — a remote sidecar's pid can't be
|
|
30
|
+
signalled from here; reader treats hostname-mismatch as "fresh
|
|
31
|
+
but un-takeoverable" and surfaces in the UI.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
import errno
|
|
38
|
+
import json
|
|
39
|
+
import logging
|
|
40
|
+
import os
|
|
41
|
+
import socket
|
|
42
|
+
from dataclasses import dataclass
|
|
43
|
+
from datetime import datetime, timedelta, timezone
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from typing import Optional
|
|
46
|
+
|
|
47
|
+
_log = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
# Heartbeat cadence + staleness threshold. The 5 min threshold tolerates
|
|
50
|
+
# brief event-loop stalls, swap pressure, etc., AND comfortably exceeds
|
|
51
|
+
# the runner's 5 min idle-kill window so an idle channel doesn't get
|
|
52
|
+
# flagged stale before it's killed naturally.
|
|
53
|
+
HEARTBEAT_INTERVAL_S = 30.0
|
|
54
|
+
STALE_AFTER_S = 5 * 60.0
|
|
55
|
+
|
|
56
|
+
# Lock file suffix appended to the JSONL path.
|
|
57
|
+
_SUFFIX = ".lock"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _lock_path_for(jsonl_path: Path) -> Path:
|
|
61
|
+
return Path(str(jsonl_path) + _SUFFIX)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _pid_alive(pid: int) -> bool:
|
|
65
|
+
"""``os.kill(pid, 0)`` probe. Returns False on ESRCH; True on EPERM
|
|
66
|
+
(process exists but we can't signal it — same machine, different
|
|
67
|
+
user, still a real owner)."""
|
|
68
|
+
if pid <= 0:
|
|
69
|
+
return False
|
|
70
|
+
try:
|
|
71
|
+
os.kill(pid, 0)
|
|
72
|
+
except OSError as exc:
|
|
73
|
+
if exc.errno == errno.ESRCH:
|
|
74
|
+
return False
|
|
75
|
+
# EPERM ⇒ the pid exists but is owned by another user.
|
|
76
|
+
return exc.errno == errno.EPERM
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class LockSidecar:
|
|
82
|
+
"""A live or read-back lock sidecar for a single session JSONL.
|
|
83
|
+
|
|
84
|
+
Instances returned by ``acquire`` own the on-disk file and an
|
|
85
|
+
asyncio heartbeat task started by ``start_heartbeat``. Instances
|
|
86
|
+
returned by ``read`` are read-only snapshots — call ``is_fresh``
|
|
87
|
+
/ ``is_self`` to interpret them.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
jsonl_path: Path
|
|
91
|
+
owner_kind: str
|
|
92
|
+
pid: int
|
|
93
|
+
hostname: str
|
|
94
|
+
heartbeat_at: datetime
|
|
95
|
+
ui_endpoint: Optional[str]
|
|
96
|
+
# Internal: True iff this instance was returned by acquire (i.e.
|
|
97
|
+
# we own the file and may refresh / release it). Read-back
|
|
98
|
+
# instances are inert.
|
|
99
|
+
_owned: bool = False
|
|
100
|
+
_heartbeat_task: Optional[asyncio.Task] = None
|
|
101
|
+
_released: bool = False
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
# Read / freshness
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def read(cls, jsonl_path: Path) -> Optional["LockSidecar"]:
|
|
109
|
+
"""Return the on-disk sidecar, or ``None`` if missing / malformed.
|
|
110
|
+
|
|
111
|
+
Tolerant of partial writes (the writer uses atomic rename, but
|
|
112
|
+
a Phase-2 wrapper crashing mid-write could leave a half-file).
|
|
113
|
+
Returns ``None`` rather than raising in that case.
|
|
114
|
+
"""
|
|
115
|
+
lp = _lock_path_for(jsonl_path)
|
|
116
|
+
try:
|
|
117
|
+
raw = lp.read_bytes()
|
|
118
|
+
except FileNotFoundError:
|
|
119
|
+
return None
|
|
120
|
+
except OSError as exc:
|
|
121
|
+
_log.debug("locks: read(%s) failed: %s", lp, exc)
|
|
122
|
+
return None
|
|
123
|
+
try:
|
|
124
|
+
data = json.loads(raw)
|
|
125
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
126
|
+
return None
|
|
127
|
+
if not isinstance(data, dict):
|
|
128
|
+
return None
|
|
129
|
+
try:
|
|
130
|
+
return cls(
|
|
131
|
+
jsonl_path=Path(jsonl_path),
|
|
132
|
+
owner_kind=str(data["owner_kind"]),
|
|
133
|
+
pid=int(data["pid"]),
|
|
134
|
+
hostname=str(data["hostname"]),
|
|
135
|
+
heartbeat_at=datetime.fromisoformat(data["heartbeat_at"]),
|
|
136
|
+
ui_endpoint=(
|
|
137
|
+
str(data["ui_endpoint"])
|
|
138
|
+
if data.get("ui_endpoint") is not None
|
|
139
|
+
else None
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
except (KeyError, TypeError, ValueError):
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
def is_fresh(self, *, now: Optional[datetime] = None) -> bool:
|
|
146
|
+
"""A lock is fresh when its heartbeat is recent AND its PID
|
|
147
|
+
is alive on the right host.
|
|
148
|
+
|
|
149
|
+
Same-host: PID-alive check is authoritative. A stale-ish
|
|
150
|
+
heartbeat (>5 min) with an alive PID is still fresh — the
|
|
151
|
+
owner is just busy. The threshold is a backstop for the
|
|
152
|
+
cross-host case where we can't probe the PID.
|
|
153
|
+
|
|
154
|
+
Cross-host: we can't probe the PID; rely on the heartbeat
|
|
155
|
+
threshold. A lock from a different hostname older than
|
|
156
|
+
``STALE_AFTER_S`` is considered stale.
|
|
157
|
+
"""
|
|
158
|
+
if now is None:
|
|
159
|
+
now = datetime.now(timezone.utc)
|
|
160
|
+
if self.hostname == socket.gethostname():
|
|
161
|
+
return _pid_alive(self.pid)
|
|
162
|
+
# Cross-host — heartbeat threshold only.
|
|
163
|
+
age = now - self.heartbeat_at
|
|
164
|
+
return age <= timedelta(seconds=STALE_AFTER_S)
|
|
165
|
+
|
|
166
|
+
def is_stale(self, *, now: Optional[datetime] = None) -> bool:
|
|
167
|
+
return not self.is_fresh(now=now)
|
|
168
|
+
|
|
169
|
+
def is_self(self) -> bool:
|
|
170
|
+
"""True iff this lock was written by THIS process on THIS host."""
|
|
171
|
+
return self.pid == os.getpid() and self.hostname == socket.gethostname()
|
|
172
|
+
|
|
173
|
+
# ------------------------------------------------------------------
|
|
174
|
+
# Acquire / release
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
@classmethod
|
|
178
|
+
def acquire(
|
|
179
|
+
cls,
|
|
180
|
+
jsonl_path: Path,
|
|
181
|
+
owner_kind: str,
|
|
182
|
+
*,
|
|
183
|
+
ui_endpoint: Optional[str] = None,
|
|
184
|
+
max_attempts: int = 3,
|
|
185
|
+
) -> "LockSidecar":
|
|
186
|
+
"""Atomically create the sidecar; raise ``LockAlreadyHeld`` on
|
|
187
|
+
a fresh non-self lock.
|
|
188
|
+
|
|
189
|
+
Algorithm:
|
|
190
|
+
1. ``open(O_CREAT|O_EXCL|O_WRONLY)``.
|
|
191
|
+
2. If success: write payload, ``os.close``, return owned instance.
|
|
192
|
+
3. If ``FileExistsError``: read the existing lock.
|
|
193
|
+
- missing on re-read (race against another acquirer's
|
|
194
|
+
release) → retry.
|
|
195
|
+
- self → refresh heartbeat in-place, return owned.
|
|
196
|
+
- fresh foreign → raise ``LockAlreadyHeld``.
|
|
197
|
+
- stale → ``os.unlink`` and retry.
|
|
198
|
+
"""
|
|
199
|
+
lp = _lock_path_for(jsonl_path)
|
|
200
|
+
own_pid = os.getpid()
|
|
201
|
+
own_host = socket.gethostname()
|
|
202
|
+
|
|
203
|
+
# Make sure the parent directory exists. claude itself doesn't
|
|
204
|
+
# create the projects/<cwd> dir until the first JSONL write, so
|
|
205
|
+
# a brand-new session's lock would otherwise hit ENOENT.
|
|
206
|
+
try:
|
|
207
|
+
lp.parent.mkdir(parents=True, exist_ok=True)
|
|
208
|
+
except OSError as exc:
|
|
209
|
+
_log.warning("locks: cannot mkdir %s: %s", lp.parent, exc)
|
|
210
|
+
raise
|
|
211
|
+
|
|
212
|
+
for _ in range(max_attempts):
|
|
213
|
+
now = datetime.now(timezone.utc)
|
|
214
|
+
payload = json.dumps(
|
|
215
|
+
{
|
|
216
|
+
"owner_kind": owner_kind,
|
|
217
|
+
"pid": own_pid,
|
|
218
|
+
"hostname": own_host,
|
|
219
|
+
"heartbeat_at": now.isoformat(),
|
|
220
|
+
"ui_endpoint": ui_endpoint,
|
|
221
|
+
}
|
|
222
|
+
).encode("utf-8")
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
fd = os.open(
|
|
226
|
+
str(lp),
|
|
227
|
+
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
|
|
228
|
+
0o644,
|
|
229
|
+
)
|
|
230
|
+
except FileExistsError:
|
|
231
|
+
existing = cls.read(jsonl_path)
|
|
232
|
+
if existing is None:
|
|
233
|
+
# Concurrent release between our O_EXCL and our read.
|
|
234
|
+
continue
|
|
235
|
+
if existing.is_self():
|
|
236
|
+
existing._owned = True
|
|
237
|
+
existing._refresh_to_disk(now=now, ui_endpoint=ui_endpoint)
|
|
238
|
+
return existing
|
|
239
|
+
if existing.is_fresh(now=now):
|
|
240
|
+
raise LockAlreadyHeld(existing)
|
|
241
|
+
# Stale: try to take it.
|
|
242
|
+
try:
|
|
243
|
+
os.unlink(str(lp))
|
|
244
|
+
except FileNotFoundError:
|
|
245
|
+
pass
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
os.write(fd, payload)
|
|
250
|
+
finally:
|
|
251
|
+
os.close(fd)
|
|
252
|
+
return cls(
|
|
253
|
+
jsonl_path=Path(jsonl_path),
|
|
254
|
+
owner_kind=owner_kind,
|
|
255
|
+
pid=own_pid,
|
|
256
|
+
hostname=own_host,
|
|
257
|
+
heartbeat_at=now,
|
|
258
|
+
ui_endpoint=ui_endpoint,
|
|
259
|
+
_owned=True,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
raise RuntimeError(
|
|
263
|
+
f"locks: could not acquire {lp} after {max_attempts} attempts "
|
|
264
|
+
f"(persistent contention or filesystem error)"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def release(self) -> None:
|
|
268
|
+
"""Best-effort ``os.remove``. Idempotent.
|
|
269
|
+
|
|
270
|
+
Cancels any running heartbeat task. Safe to call from any
|
|
271
|
+
teardown path (clean kill, idle-kill, shutdown, error).
|
|
272
|
+
"""
|
|
273
|
+
if self._released:
|
|
274
|
+
return
|
|
275
|
+
self._released = True
|
|
276
|
+
task = self._heartbeat_task
|
|
277
|
+
self._heartbeat_task = None
|
|
278
|
+
if task is not None and not task.done():
|
|
279
|
+
task.cancel()
|
|
280
|
+
if not self._owned:
|
|
281
|
+
return
|
|
282
|
+
lp = _lock_path_for(self.jsonl_path)
|
|
283
|
+
try:
|
|
284
|
+
os.unlink(str(lp))
|
|
285
|
+
except FileNotFoundError:
|
|
286
|
+
pass
|
|
287
|
+
except OSError as exc:
|
|
288
|
+
_log.warning("locks: release(%s) unlink failed: %s", lp, exc)
|
|
289
|
+
|
|
290
|
+
# ------------------------------------------------------------------
|
|
291
|
+
# Heartbeat
|
|
292
|
+
# ------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
def start_heartbeat(self, *, interval_s: float = HEARTBEAT_INTERVAL_S) -> None:
|
|
295
|
+
"""Spawn the heartbeat asyncio task. Idempotent — calling twice
|
|
296
|
+
on the same instance leaves the existing task in place."""
|
|
297
|
+
if not self._owned:
|
|
298
|
+
raise RuntimeError("locks: cannot heartbeat a read-only sidecar")
|
|
299
|
+
if self._released:
|
|
300
|
+
raise RuntimeError("locks: cannot heartbeat a released sidecar")
|
|
301
|
+
if self._heartbeat_task is not None and not self._heartbeat_task.done():
|
|
302
|
+
return
|
|
303
|
+
self._heartbeat_task = asyncio.create_task(
|
|
304
|
+
self._heartbeat_forever(interval_s),
|
|
305
|
+
name=f"locks.heartbeat[{self.jsonl_path.name}]",
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
async def _heartbeat_forever(self, interval_s: float) -> None:
|
|
309
|
+
try:
|
|
310
|
+
while not self._released:
|
|
311
|
+
try:
|
|
312
|
+
await asyncio.sleep(interval_s)
|
|
313
|
+
except asyncio.CancelledError:
|
|
314
|
+
return
|
|
315
|
+
if self._released:
|
|
316
|
+
return
|
|
317
|
+
try:
|
|
318
|
+
self._refresh_to_disk()
|
|
319
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
320
|
+
_log.warning(
|
|
321
|
+
"locks: heartbeat refresh failed for %s: %s",
|
|
322
|
+
self.jsonl_path,
|
|
323
|
+
exc,
|
|
324
|
+
)
|
|
325
|
+
except asyncio.CancelledError:
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
def _refresh_to_disk(
|
|
329
|
+
self,
|
|
330
|
+
*,
|
|
331
|
+
now: Optional[datetime] = None,
|
|
332
|
+
ui_endpoint: Optional[str] = ...,
|
|
333
|
+
) -> None:
|
|
334
|
+
"""Atomically overwrite the lock file with a fresh heartbeat.
|
|
335
|
+
|
|
336
|
+
Uses ``<lock>.tmp`` + ``os.replace`` — atomic on POSIX same-fs.
|
|
337
|
+
Caller guarantees ownership (only call from an instance whose
|
|
338
|
+
``_owned`` is True).
|
|
339
|
+
"""
|
|
340
|
+
if not self._owned:
|
|
341
|
+
return
|
|
342
|
+
if now is None:
|
|
343
|
+
now = datetime.now(timezone.utc)
|
|
344
|
+
if ui_endpoint is ...:
|
|
345
|
+
ui_endpoint = self.ui_endpoint
|
|
346
|
+
lp = _lock_path_for(self.jsonl_path)
|
|
347
|
+
tmp = Path(str(lp) + ".tmp")
|
|
348
|
+
payload = json.dumps(
|
|
349
|
+
{
|
|
350
|
+
"owner_kind": self.owner_kind,
|
|
351
|
+
"pid": self.pid,
|
|
352
|
+
"hostname": self.hostname,
|
|
353
|
+
"heartbeat_at": now.isoformat(),
|
|
354
|
+
"ui_endpoint": ui_endpoint,
|
|
355
|
+
}
|
|
356
|
+
).encode("utf-8")
|
|
357
|
+
# Open exclusive on the tmp path so two refreshes from this
|
|
358
|
+
# process don't clobber each other (defensive — heartbeat is
|
|
359
|
+
# single-task; this just makes the contract explicit).
|
|
360
|
+
try:
|
|
361
|
+
fd = os.open(
|
|
362
|
+
str(tmp),
|
|
363
|
+
os.O_CREAT | os.O_TRUNC | os.O_WRONLY,
|
|
364
|
+
0o644,
|
|
365
|
+
)
|
|
366
|
+
except OSError as exc:
|
|
367
|
+
_log.warning("locks: refresh tmp open(%s) failed: %s", tmp, exc)
|
|
368
|
+
return
|
|
369
|
+
try:
|
|
370
|
+
os.write(fd, payload)
|
|
371
|
+
finally:
|
|
372
|
+
os.close(fd)
|
|
373
|
+
os.replace(str(tmp), str(lp))
|
|
374
|
+
self.heartbeat_at = now
|
|
375
|
+
self.ui_endpoint = ui_endpoint
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class LockAlreadyHeld(RuntimeError):
|
|
379
|
+
"""Raised by ``LockSidecar.acquire`` when a fresh non-self lock
|
|
380
|
+
already exists. The caller can inspect ``.existing`` for owner
|
|
381
|
+
metadata before deciding whether to take over."""
|
|
382
|
+
|
|
383
|
+
def __init__(self, existing: LockSidecar) -> None:
|
|
384
|
+
super().__init__(
|
|
385
|
+
f"lock for {existing.jsonl_path.name} held by "
|
|
386
|
+
f"{existing.owner_kind}@{existing.hostname} pid {existing.pid}"
|
|
387
|
+
)
|
|
388
|
+
self.existing = existing
|