agent-decoder 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. agent_decoder-0.3.2.dist-info/METADATA +387 -0
  2. agent_decoder-0.3.2.dist-info/RECORD +89 -0
  3. agent_decoder-0.3.2.dist-info/WHEEL +4 -0
  4. agent_decoder-0.3.2.dist-info/entry_points.txt +2 -0
  5. agent_decoder-0.3.2.dist-info/licenses/LICENSE +111 -0
  6. clau_decode/__init__.py +8 -0
  7. clau_decode/__main__.py +11 -0
  8. clau_decode/_auth_env.py +118 -0
  9. clau_decode/_pty_preexec.py +42 -0
  10. clau_decode/analytics/__init__.py +0 -0
  11. clau_decode/analytics/aggregator.py +55 -0
  12. clau_decode/analytics/cost.py +76 -0
  13. clau_decode/analytics/extractor.py +32 -0
  14. clau_decode/analytics/fast.py +289 -0
  15. clau_decode/analytics/models.py +49 -0
  16. clau_decode/analytics/pricing.py +162 -0
  17. clau_decode/analytics/prompt.py +34 -0
  18. clau_decode/analytics/service.py +23 -0
  19. clau_decode/analytics/stats.py +117 -0
  20. clau_decode/analytics/tips.py +176 -0
  21. clau_decode/btw_capture.py +290 -0
  22. clau_decode/cli.py +504 -0
  23. clau_decode/config.py +144 -0
  24. clau_decode/db.py +2054 -0
  25. clau_decode/driver_manager.py +387 -0
  26. clau_decode/drivers/__init__.py +27 -0
  27. clau_decode/drivers/base.py +170 -0
  28. clau_decode/drivers/registry.py +113 -0
  29. clau_decode/drivers/tmux_driver.py +488 -0
  30. clau_decode/editor.py +182 -0
  31. clau_decode/events_bus.py +69 -0
  32. clau_decode/locks.py +388 -0
  33. clau_decode/migrate.py +1313 -0
  34. clau_decode/models.py +285 -0
  35. clau_decode/parser.py +466 -0
  36. clau_decode/providers/__init__.py +30 -0
  37. clau_decode/providers/base.py +132 -0
  38. clau_decode/providers/claude.py +81 -0
  39. clau_decode/providers/codex.py +441 -0
  40. clau_decode/providers/registry.py +89 -0
  41. clau_decode/pty_native.py +39 -0
  42. clau_decode/pty_runner.py +2406 -0
  43. clau_decode/pty_screen_state.py +65 -0
  44. clau_decode/recap_runner.py +230 -0
  45. clau_decode/reporter.py +305 -0
  46. clau_decode/scanner.py +125 -0
  47. clau_decode/server.py +2841 -0
  48. clau_decode/static/android-chrome-192x192.png +0 -0
  49. clau_decode/static/android-chrome-512x512.png +0 -0
  50. clau_decode/static/app-icon.png +0 -0
  51. clau_decode/static/apple-touch-icon.png +0 -0
  52. clau_decode/static/assets/ChatView-BBVXuITs.js +79 -0
  53. clau_decode/static/assets/ChatView-BrP-ENHg.css +1 -0
  54. clau_decode/static/assets/Dashboard-3CYO4HQw.js +1 -0
  55. clau_decode/static/assets/FileViewer-BwbDc3ky.js +1 -0
  56. clau_decode/static/assets/HelpPopup-CPtECjYw.js +1 -0
  57. clau_decode/static/assets/ScrollContainer-BT_Dzxah.js +1 -0
  58. clau_decode/static/assets/SearchOverlay-DqI7aZaC.js +1 -0
  59. clau_decode/static/assets/SessionItem-DlyKg80u.js +1 -0
  60. clau_decode/static/assets/SettingsModal-CZBfMqx7.js +1 -0
  61. clau_decode/static/assets/ShortcutsPopup-W7CPdCzW.js +1 -0
  62. clau_decode/static/assets/Sidebar-BBswhEua.js +1 -0
  63. clau_decode/static/assets/client-DTSXvAb8.js +1 -0
  64. clau_decode/static/assets/fira-code-DGosTW8U.woff2 +0 -0
  65. clau_decode/static/assets/formatRelative-j86webkr.js +1 -0
  66. clau_decode/static/assets/hljs-B3O74O7Z.js +35 -0
  67. clau_decode/static/assets/index-BHdCOIkq.css +10 -0
  68. clau_decode/static/assets/index-DfKltZ6z.js +5 -0
  69. clau_decode/static/assets/ioskeley-mono-C7VR2-sL.woff2 +0 -0
  70. clau_decode/static/assets/jetbrains-mono-V6pRDFza.woff2 +0 -0
  71. clau_decode/static/assets/julia-mono-Cbvba6sW.woff2 +0 -0
  72. clau_decode/static/assets/libertinus-mono-Coz17E-O.woff2 +0 -0
  73. clau_decode/static/assets/monaspace-argon-RS-2TGNg.woff2 +0 -0
  74. clau_decode/static/assets/nativePtyFonts-BaXKtUsn.js +1 -0
  75. clau_decode/static/assets/react-Bi_azaFt.js +9 -0
  76. clau_decode/static/assets/renderSnippet-CqpQQugH.js +1 -0
  77. clau_decode/static/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
  78. clau_decode/static/assets/shared-DdVGprvF.js +1 -0
  79. clau_decode/static/assets/source-code-pro-DEXWLoHG.woff2 +0 -0
  80. clau_decode/static/assets/spline-sans-mono-mUpA6Mve.woff2 +0 -0
  81. clau_decode/static/assets/ui-BcGPjpYn.js +1 -0
  82. clau_decode/static/assets/xanh-mono-Dsg19vWS.woff2 +0 -0
  83. clau_decode/static/favicon-16x16.png +0 -0
  84. clau_decode/static/favicon-32x32.png +0 -0
  85. clau_decode/static/favicon.ico +0 -0
  86. clau_decode/static/index.html +24 -0
  87. clau_decode/static/site.webmanifest +1 -0
  88. clau_decode/update_check.py +51 -0
  89. clau_decode/watcher.py +63 -0
clau_decode/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