caura-bus-cli 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- caura_bus_cli/__init__.py +5 -0
- caura_bus_cli/broker.py +140 -0
- caura_bus_cli/hooks.py +115 -0
- caura_bus_cli/join.py +438 -0
- caura_bus_cli/main.py +530 -0
- caura_bus_cli/peer_instructions.md +243 -0
- caura_bus_cli/py.typed +0 -0
- caura_bus_cli/runtime.py +555 -0
- caura_bus_cli/setup.py +819 -0
- caura_bus_cli-0.3.0.dist-info/METADATA +16 -0
- caura_bus_cli-0.3.0.dist-info/RECORD +16 -0
- caura_bus_cli-0.3.0.dist-info/WHEEL +5 -0
- caura_bus_cli-0.3.0.dist-info/entry_points.txt +2 -0
- caura_bus_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
- caura_bus_cli-0.3.0.dist-info/licenses/NOTICE +7 -0
- caura_bus_cli-0.3.0.dist-info/top_level.txt +1 -0
caura_bus_cli/runtime.py
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
"""Native wake prompts; consumption and lease ownership remain exclusively in MCP."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import fcntl
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from caura_bus_core import RESYNC_EVENT, Bus, PlatformError
|
|
17
|
+
from caura_bus_core.collaboration import Presence
|
|
18
|
+
|
|
19
|
+
WAKE_TEXT = (
|
|
20
|
+
"Caura: check inbox. Call peer wait, handle each delivery, and repeat until delivery is null. "
|
|
21
|
+
"Read notices too. Stop if paused. Acknowledge with peer progress; send one reply with the result."
|
|
22
|
+
)
|
|
23
|
+
OVERDUE_TEXT = "Caura: a request you sent is overdue. Call peer wait."
|
|
24
|
+
WAKE_EVENTS = {
|
|
25
|
+
"request.overdue",
|
|
26
|
+
"request.unanswered",
|
|
27
|
+
"request.nudge",
|
|
28
|
+
"request.cancelled",
|
|
29
|
+
"message.available",
|
|
30
|
+
"delivery.available",
|
|
31
|
+
# Retention removed history after our cursor: reconcile from REST state.
|
|
32
|
+
RESYNC_EVENT,
|
|
33
|
+
"human.decided",
|
|
34
|
+
"delivery.interrupt",
|
|
35
|
+
"delivery.acked",
|
|
36
|
+
"delivery.leased",
|
|
37
|
+
"delivery.resumed",
|
|
38
|
+
"delivery.cancelled",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class WakeNotStarted(RuntimeError):
|
|
43
|
+
"""The runtime process was never created, so retry cannot duplicate a prompt."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def state_path(config):
|
|
47
|
+
identity = json.dumps([config.api_url, config.agent.tenant_id, config.agent.agent_id])
|
|
48
|
+
digest = hashlib.sha256(identity.encode()).hexdigest()[:24]
|
|
49
|
+
root = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local/state")))
|
|
50
|
+
return root / "caura-bus" / (digest + ".json")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@contextlib.contextmanager
|
|
54
|
+
def lock(path):
|
|
55
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
56
|
+
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
57
|
+
try:
|
|
58
|
+
try:
|
|
59
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
60
|
+
except BlockingIOError:
|
|
61
|
+
raise RuntimeError("another Caura waker or hook is using this agent") from None
|
|
62
|
+
yield
|
|
63
|
+
finally:
|
|
64
|
+
os.close(fd)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# A failed or interrupted native queue is retried on a later snapshot, spaced
|
|
68
|
+
# by a capped exponential backoff that survives restarts (wall-clock based).
|
|
69
|
+
WAKE_RETRY_BASE_SECONDS = 5.0
|
|
70
|
+
WAKE_RETRY_MAX_SECONDS = 300.0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def wake_retry_delay(attempts):
|
|
74
|
+
return min(WAKE_RETRY_BASE_SECONDS * 2 ** min(attempts - 1, 16), WAKE_RETRY_MAX_SECONDS)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# A Codex queue succeeds once the prompt is stored, not once a turn runs it: a
|
|
78
|
+
# prompt queued on a thread no Codex process has open waits there forever. When
|
|
79
|
+
# the agent has not called wait since the wake, wake it again (re-resolving the
|
|
80
|
+
# thread), spaced by a doubling interval so a long busy turn is not flooded.
|
|
81
|
+
WAKE_RECHECK_SECONDS = 120.0
|
|
82
|
+
WAKE_RECHECK_MAX_SECONDS = 1800.0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def wake_recheck_delay(rewakes, base):
|
|
86
|
+
return min(base * 2 ** min(rewakes, 16), WAKE_RECHECK_MAX_SECONDS)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class WakeState:
|
|
90
|
+
def __init__(self, path, clock=time.time, recheck_seconds=None):
|
|
91
|
+
self.path = Path(path)
|
|
92
|
+
self.clock = clock
|
|
93
|
+
# Only for runtimes whose wake is unconfirmed (Codex queue); a hook
|
|
94
|
+
# wake starts the turn itself.
|
|
95
|
+
self.recheck_seconds = recheck_seconds
|
|
96
|
+
|
|
97
|
+
def load(self):
|
|
98
|
+
return json.loads(self.path.read_text()) if self.path.exists() else {}
|
|
99
|
+
|
|
100
|
+
def save(self, data):
|
|
101
|
+
temporary = self.path.with_suffix(".tmp")
|
|
102
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
103
|
+
with os.fdopen(fd, "w") as stream:
|
|
104
|
+
json.dump(data, stream)
|
|
105
|
+
stream.flush()
|
|
106
|
+
os.fsync(stream.fileno())
|
|
107
|
+
temporary.replace(self.path)
|
|
108
|
+
|
|
109
|
+
def health(self, status):
|
|
110
|
+
with lock(self.path.with_suffix(".lock")):
|
|
111
|
+
self.save({**self.load(), "health": status, "health_at": datetime.now(UTC).isoformat()})
|
|
112
|
+
|
|
113
|
+
async def notify(self, snapshot, emit):
|
|
114
|
+
with lock(self.path.with_suffix(".lock")):
|
|
115
|
+
saved = self.load()
|
|
116
|
+
# Repeated waits on a lease and additional notices belong to the
|
|
117
|
+
# same burst. Only a drained inbox rearms its ordinary wake hint.
|
|
118
|
+
# Keep the old-server path for rolling upgrades.
|
|
119
|
+
durable = "drain_generation" in snapshot
|
|
120
|
+
generation = snapshot.get("drain_generation", snapshot["wait_generation"])
|
|
121
|
+
marker = "drain_generation" if durable else "outstanding"
|
|
122
|
+
recovery = snapshot.get("recovery_key")
|
|
123
|
+
if not (snapshot["pending"] or snapshot.get("notices_pending")):
|
|
124
|
+
return False
|
|
125
|
+
if durable and marker not in saved and saved.get("outstanding") == snapshot["wait_generation"]:
|
|
126
|
+
# An older waker may already have queued a prompt. Adopt it,
|
|
127
|
+
# rather than queueing another merely because we upgraded.
|
|
128
|
+
saved = {**saved, marker: generation, "recovery_key": recovery}
|
|
129
|
+
self.save(saved)
|
|
130
|
+
return False
|
|
131
|
+
# A delivery that arrived after the last hint is new work even when the
|
|
132
|
+
# inbox never drained: an agent can end its turn with a collected reply
|
|
133
|
+
# or an ignored request still open. Old work alone never re-arms.
|
|
134
|
+
newest = snapshot.get("newest_open")
|
|
135
|
+
arrived = newest is not None and newest > saved.get("newest_open", 0)
|
|
136
|
+
same_burst = saved.get(marker) == generation and not arrived
|
|
137
|
+
recovering = recovery is not None and recovery != saved.get("recovery_key")
|
|
138
|
+
now = self.clock()
|
|
139
|
+
# Any wait since the wake proves a turn picked it up; a held lease
|
|
140
|
+
# means the agent is working.
|
|
141
|
+
unanswered = (
|
|
142
|
+
self.recheck_seconds is not None
|
|
143
|
+
and not snapshot.get("active")
|
|
144
|
+
and saved.get("woken_wait") == snapshot["wait_generation"]
|
|
145
|
+
and now >= saved.get("recheck_at", float("inf"))
|
|
146
|
+
)
|
|
147
|
+
if same_burst and not recovering and not unanswered:
|
|
148
|
+
return False
|
|
149
|
+
# The burst is recorded as woken only after the runtime confirms the
|
|
150
|
+
# queue. Before handing control to it, persist the attempt with its
|
|
151
|
+
# backoff deadline: a failed, timed-out or crashed queue is retried
|
|
152
|
+
# on a later snapshot (also after a restart), but never in a storm.
|
|
153
|
+
attempt = saved.get("wake_attempt") or {}
|
|
154
|
+
if now < attempt.get("retry_at", 0):
|
|
155
|
+
return False
|
|
156
|
+
attempts = attempt.get("attempts", 0) + 1
|
|
157
|
+
self.save(
|
|
158
|
+
{
|
|
159
|
+
**saved,
|
|
160
|
+
"wake_attempt": {"attempts": attempts, "retry_at": now + wake_retry_delay(attempts)},
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
if snapshot.get("wake_reason") == "request_overdue":
|
|
164
|
+
await emit(OVERDUE_TEXT)
|
|
165
|
+
else:
|
|
166
|
+
await emit()
|
|
167
|
+
# Inventory records only confirmed queue delivery or emitted hook
|
|
168
|
+
# output; a runtime failure never becomes a successful wake.
|
|
169
|
+
current = {k: v for k, v in saved.items() if k != "wake_attempt"}
|
|
170
|
+
recheck = {}
|
|
171
|
+
if self.recheck_seconds is not None:
|
|
172
|
+
rewakes = saved.get("rewakes", 0) + 1 if same_burst and not recovering else 0
|
|
173
|
+
recheck = {
|
|
174
|
+
"woken_wait": snapshot["wait_generation"],
|
|
175
|
+
"recheck_at": now + wake_recheck_delay(rewakes, self.recheck_seconds),
|
|
176
|
+
"rewakes": rewakes,
|
|
177
|
+
}
|
|
178
|
+
self.save(
|
|
179
|
+
{
|
|
180
|
+
**current,
|
|
181
|
+
**recheck,
|
|
182
|
+
marker: generation,
|
|
183
|
+
"recovery_key": recovery or saved.get("recovery_key"),
|
|
184
|
+
"newest_open": max(newest or 0, saved.get("newest_open", 0)),
|
|
185
|
+
"last_wake_at": datetime.now(UTC).isoformat(),
|
|
186
|
+
}
|
|
187
|
+
)
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
LATEST_THREAD = "latest"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def codex_home():
|
|
195
|
+
base = os.environ.get("CODEX_HOME", "").strip()
|
|
196
|
+
return Path(base) if base else Path.home() / ".codex"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def latest_codex_thread(directory, home=None):
|
|
200
|
+
"""The newest Codex session started in ``directory``, from its rollout metadata.
|
|
201
|
+
|
|
202
|
+
Codex writes a session's rollout once its first turn starts, so a session
|
|
203
|
+
that has not yet received a message has no thread to queue into.
|
|
204
|
+
|
|
205
|
+
Newest means most recently started: rollout names begin with the start
|
|
206
|
+
time. Not the most recently written: a queued wake runs a turn on its
|
|
207
|
+
thread, so ordering by write time kept waking a closed session after Codex
|
|
208
|
+
was restarted, while the open one never saw the request.
|
|
209
|
+
"""
|
|
210
|
+
directory = Path(directory).resolve()
|
|
211
|
+
sessions = (Path(home) if home else codex_home()) / "sessions"
|
|
212
|
+
rollouts = sorted(sessions.rglob("rollout-*.jsonl"), key=lambda p: p.name, reverse=True)
|
|
213
|
+
for rollout in rollouts:
|
|
214
|
+
try:
|
|
215
|
+
with rollout.open() as stream:
|
|
216
|
+
meta = json.loads(stream.readline())
|
|
217
|
+
except (OSError, ValueError):
|
|
218
|
+
continue
|
|
219
|
+
if not isinstance(meta, dict) or meta.get("type") != "session_meta":
|
|
220
|
+
continue
|
|
221
|
+
payload = meta.get("payload") or {}
|
|
222
|
+
if not payload.get("id") or not payload.get("cwd"):
|
|
223
|
+
continue
|
|
224
|
+
if Path(payload["cwd"]).resolve() == directory:
|
|
225
|
+
return payload["id"]
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class CodexQueue:
|
|
230
|
+
def __init__(self, thread, executable="codex", directory=None):
|
|
231
|
+
# ``latest`` follows the newest session in ``directory`` and is resolved
|
|
232
|
+
# at every wake, so the waker can start before (or outlive) a session.
|
|
233
|
+
self.thread = thread
|
|
234
|
+
self.executable = executable
|
|
235
|
+
self.directory = Path(directory or Path.cwd())
|
|
236
|
+
|
|
237
|
+
def resolve(self):
|
|
238
|
+
if self.thread != LATEST_THREAD:
|
|
239
|
+
return self.thread
|
|
240
|
+
thread = latest_codex_thread(self.directory)
|
|
241
|
+
if thread is None:
|
|
242
|
+
raise WakeNotStarted(
|
|
243
|
+
f"no Codex session found for {self.directory}; start Codex there and send it a first message"
|
|
244
|
+
)
|
|
245
|
+
return thread
|
|
246
|
+
|
|
247
|
+
async def __call__(self, message=WAKE_TEXT):
|
|
248
|
+
env = {k: v for k, v in os.environ.items() if k not in {"CAURA_API_KEY", "CAURA_BUS_AGENT_CONFIG"}}
|
|
249
|
+
thread = self.resolve()
|
|
250
|
+
try:
|
|
251
|
+
process = await asyncio.create_subprocess_exec(
|
|
252
|
+
self.executable,
|
|
253
|
+
"queue",
|
|
254
|
+
"--thread",
|
|
255
|
+
thread,
|
|
256
|
+
"--message",
|
|
257
|
+
message,
|
|
258
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
259
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
260
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
261
|
+
env=env,
|
|
262
|
+
)
|
|
263
|
+
except OSError as exc:
|
|
264
|
+
raise WakeNotStarted("Codex queue could not start; fix the runtime and retry") from exc
|
|
265
|
+
try:
|
|
266
|
+
await asyncio.wait_for(process.wait(), 15)
|
|
267
|
+
except BaseException:
|
|
268
|
+
if process.returncode is None:
|
|
269
|
+
process.kill()
|
|
270
|
+
await process.wait()
|
|
271
|
+
raise
|
|
272
|
+
if process.returncode:
|
|
273
|
+
raise RuntimeError("Codex queue failed; inspect the runtime before clearing wake state")
|
|
274
|
+
print(f"Caura: native Codex wake queued for thread {thread}.", file=sys.stderr, flush=True)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def transient(exc):
|
|
278
|
+
return isinstance(exc, httpx.TransportError) or (
|
|
279
|
+
isinstance(exc, PlatformError) and (exc.status >= 500 or exc.status == 429)
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
async def retry(operation, sleep=asyncio.sleep):
|
|
284
|
+
failures = 0
|
|
285
|
+
while True:
|
|
286
|
+
try:
|
|
287
|
+
return await operation()
|
|
288
|
+
except (PlatformError, httpx.TransportError) as exc:
|
|
289
|
+
if not transient(exc):
|
|
290
|
+
raise
|
|
291
|
+
await sleep(min(0.5 * 2 ** min(failures, 5), 15))
|
|
292
|
+
failures += 1
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
async def run_waker(config, runtime, state, emit=None):
|
|
296
|
+
"""Events trigger prompt checks; periodic reconciliation handles lease expiry."""
|
|
297
|
+
bus = Bus(config)
|
|
298
|
+
profile = Presence(
|
|
299
|
+
session_id="waker-" + runtime,
|
|
300
|
+
display_name=config.agent.agent_id,
|
|
301
|
+
description=(
|
|
302
|
+
"Native Codex queue; delivery at a turn boundary"
|
|
303
|
+
if runtime == "codex"
|
|
304
|
+
else "Claude Code background hook; wakes the open session between turns"
|
|
305
|
+
),
|
|
306
|
+
capabilities=["pull-delivery"],
|
|
307
|
+
supports_interrupt=False,
|
|
308
|
+
)
|
|
309
|
+
changed = asyncio.Event()
|
|
310
|
+
revoked = False
|
|
311
|
+
|
|
312
|
+
async def consume(cursor):
|
|
313
|
+
async for event in bus.events(after=cursor):
|
|
314
|
+
if event["event_type"] in WAKE_EVENTS:
|
|
315
|
+
changed.set()
|
|
316
|
+
|
|
317
|
+
async def checked(operation):
|
|
318
|
+
try:
|
|
319
|
+
return await operation()
|
|
320
|
+
except (PlatformError, httpx.TransportError):
|
|
321
|
+
state.health("error")
|
|
322
|
+
raise
|
|
323
|
+
|
|
324
|
+
async def reconcile():
|
|
325
|
+
snapshot = await retry(lambda: checked(bus.inbox_state))
|
|
326
|
+
await retry(
|
|
327
|
+
lambda: checked(
|
|
328
|
+
lambda: bus.advertise(
|
|
329
|
+
profile.model_copy(update={"status": "busy" if snapshot["active"] else "ready"})
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
if emit is not None:
|
|
334
|
+
try:
|
|
335
|
+
await state.notify(snapshot, emit)
|
|
336
|
+
except Exception as exc:
|
|
337
|
+
# The attempt and its backoff are persisted; a later snapshot
|
|
338
|
+
# retries. Losing the waker here would strand the wake.
|
|
339
|
+
print(f"Caura: native wake failed, will retry: {exc!r}", file=sys.stderr, flush=True)
|
|
340
|
+
state.health("healthy")
|
|
341
|
+
return snapshot
|
|
342
|
+
|
|
343
|
+
events_task = None
|
|
344
|
+
try:
|
|
345
|
+
state.health("starting")
|
|
346
|
+
await retry(lambda: checked(bus.connect))
|
|
347
|
+
snapshot = await reconcile()
|
|
348
|
+
events_task = asyncio.create_task(consume(snapshot["cursor"]))
|
|
349
|
+
while True:
|
|
350
|
+
changed_task = asyncio.create_task(changed.wait())
|
|
351
|
+
try:
|
|
352
|
+
done, _ = await asyncio.wait(
|
|
353
|
+
{events_task, changed_task},
|
|
354
|
+
timeout=10,
|
|
355
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
356
|
+
)
|
|
357
|
+
if events_task in done:
|
|
358
|
+
await events_task
|
|
359
|
+
raise RuntimeError("Caura event stream stopped")
|
|
360
|
+
finally:
|
|
361
|
+
changed_task.cancel()
|
|
362
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
363
|
+
await changed_task
|
|
364
|
+
changed.clear()
|
|
365
|
+
await reconcile()
|
|
366
|
+
except PlatformError as exc:
|
|
367
|
+
revoked = exc.status in {401, 403}
|
|
368
|
+
state.health("error")
|
|
369
|
+
raise
|
|
370
|
+
finally:
|
|
371
|
+
if events_task is not None:
|
|
372
|
+
events_task.cancel()
|
|
373
|
+
with contextlib.suppress(asyncio.CancelledError, PlatformError):
|
|
374
|
+
await events_task
|
|
375
|
+
if not revoked:
|
|
376
|
+
with contextlib.suppress(Exception):
|
|
377
|
+
async with asyncio.timeout(5):
|
|
378
|
+
await bus.advertise(profile.model_copy(update={"status": "offline"}))
|
|
379
|
+
await bus.close()
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
async def supervise(config, runtime, state, emit=None):
|
|
383
|
+
# Only one event subscriber per agent on this host. Hint checks also use
|
|
384
|
+
# a short-lived lock so concurrent callers cannot queue duplicate prompts.
|
|
385
|
+
with lock(state.path.with_suffix(".process.lock")):
|
|
386
|
+
task = asyncio.current_task()
|
|
387
|
+
loop = asyncio.get_running_loop()
|
|
388
|
+
loop.add_signal_handler(signal.SIGTERM, task.cancel)
|
|
389
|
+
try:
|
|
390
|
+
await run_waker(config, runtime, state, emit)
|
|
391
|
+
finally:
|
|
392
|
+
loop.remove_signal_handler(signal.SIGTERM)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def process_alive(pid):
|
|
396
|
+
try:
|
|
397
|
+
os.kill(pid, 0)
|
|
398
|
+
except ProcessLookupError:
|
|
399
|
+
return False
|
|
400
|
+
except PermissionError:
|
|
401
|
+
return True
|
|
402
|
+
return True
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
async def listen(config, state, wait, watch_pid=None, poll=5.0):
|
|
406
|
+
"""Background Claude Code listener (an ``asyncRewake`` hook).
|
|
407
|
+
|
|
408
|
+
Runs the event-driven waker until the inbox has a new burst to announce,
|
|
409
|
+
then returns the wake text; the hook exits 2 and Claude Code starts a turn
|
|
410
|
+
with it, even when the session is idle. Returns ``None`` without waking when
|
|
411
|
+
another listener already covers this agent on this host, when ``wait``
|
|
412
|
+
expires, or when the watched Claude Code process has exited.
|
|
413
|
+
"""
|
|
414
|
+
message = None
|
|
415
|
+
woke = asyncio.Event()
|
|
416
|
+
|
|
417
|
+
async def emit(text=WAKE_TEXT):
|
|
418
|
+
nonlocal message
|
|
419
|
+
message = text
|
|
420
|
+
woke.set()
|
|
421
|
+
|
|
422
|
+
guard = contextlib.ExitStack()
|
|
423
|
+
try:
|
|
424
|
+
guard.enter_context(lock(state.path.with_suffix(".process.lock")))
|
|
425
|
+
except RuntimeError:
|
|
426
|
+
return None # one listener per agent and host; the armed one wakes us
|
|
427
|
+
loop = asyncio.get_running_loop()
|
|
428
|
+
parent = os.getppid()
|
|
429
|
+
waker = asyncio.create_task(run_waker(config, "claude-code", state, emit))
|
|
430
|
+
try:
|
|
431
|
+
deadline = loop.time() + wait
|
|
432
|
+
while not woke.is_set() and not waker.done():
|
|
433
|
+
if watch_pid is not None and not process_alive(watch_pid):
|
|
434
|
+
break
|
|
435
|
+
if watch_pid is None and os.getppid() != parent:
|
|
436
|
+
break # reparented: the session that armed us is gone
|
|
437
|
+
remaining = deadline - loop.time()
|
|
438
|
+
if remaining <= 0:
|
|
439
|
+
break
|
|
440
|
+
with contextlib.suppress(TimeoutError):
|
|
441
|
+
async with asyncio.timeout(min(poll, remaining)):
|
|
442
|
+
await woke.wait()
|
|
443
|
+
if waker.done() and not woke.is_set():
|
|
444
|
+
waker.result() # surface revocation or a stopped event stream
|
|
445
|
+
finally:
|
|
446
|
+
if not waker.done():
|
|
447
|
+
waker.cancel()
|
|
448
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
449
|
+
await waker
|
|
450
|
+
guard.close()
|
|
451
|
+
return message
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
async def receive(config, state, hook=None, wait=0, idle_listen_seconds=5):
|
|
455
|
+
output = []
|
|
456
|
+
|
|
457
|
+
async def emit(message=WAKE_TEXT):
|
|
458
|
+
if hook == "Stop":
|
|
459
|
+
output.append(json.dumps({"decision": "block", "reason": message}))
|
|
460
|
+
elif hook == "UserPromptSubmit":
|
|
461
|
+
output.append(
|
|
462
|
+
json.dumps({"hookSpecificOutput": {"hookEventName": hook, "additionalContext": message}})
|
|
463
|
+
)
|
|
464
|
+
else:
|
|
465
|
+
output.append(message)
|
|
466
|
+
|
|
467
|
+
started = time.monotonic()
|
|
468
|
+
deadline = started + wait
|
|
469
|
+
next_status = 0
|
|
470
|
+
failures = 0
|
|
471
|
+
bus = Bus(config)
|
|
472
|
+
profile = Presence(
|
|
473
|
+
session_id="claude-stop-listener",
|
|
474
|
+
display_name=config.agent.agent_id,
|
|
475
|
+
description="Claude Stop hook is listening for a bounded window",
|
|
476
|
+
capabilities=["pull-delivery"],
|
|
477
|
+
supports_interrupt=False,
|
|
478
|
+
)
|
|
479
|
+
try:
|
|
480
|
+
initial_budget = min(wait, idle_listen_seconds) if wait and idle_listen_seconds else 5
|
|
481
|
+
async with asyncio.timeout(initial_budget) as budget:
|
|
482
|
+
while True:
|
|
483
|
+
try:
|
|
484
|
+
await bus.connect()
|
|
485
|
+
snapshot = await bus.inbox_state()
|
|
486
|
+
state.health("healthy")
|
|
487
|
+
if wait:
|
|
488
|
+
window = (
|
|
489
|
+
wait if snapshot.get("awaiting_reply", False) else min(wait, idle_listen_seconds)
|
|
490
|
+
)
|
|
491
|
+
deadline = started + window
|
|
492
|
+
# Shrinking the budget also bounds a slow gateway call
|
|
493
|
+
# after learning that this is an ordinary idle session.
|
|
494
|
+
budget.reschedule(
|
|
495
|
+
asyncio.get_running_loop().time() + max(0, deadline - time.monotonic())
|
|
496
|
+
)
|
|
497
|
+
if await state.notify(snapshot, emit):
|
|
498
|
+
break
|
|
499
|
+
if snapshot["pending"]:
|
|
500
|
+
# A previous hook already asked for wait; do not create
|
|
501
|
+
# an unbounded Stop continuation loop if it was ignored.
|
|
502
|
+
break
|
|
503
|
+
remaining = max(0, deadline - time.monotonic())
|
|
504
|
+
if not wait or remaining <= 0:
|
|
505
|
+
break
|
|
506
|
+
if time.monotonic() >= next_status:
|
|
507
|
+
print(
|
|
508
|
+
f"Caura: listening, {remaining:.0f}s remaining",
|
|
509
|
+
file=sys.stderr,
|
|
510
|
+
flush=True,
|
|
511
|
+
)
|
|
512
|
+
next_status = time.monotonic() + 10
|
|
513
|
+
if hook == "Stop":
|
|
514
|
+
# Match the waker: a held lease is busy, not ready.
|
|
515
|
+
status = "busy" if snapshot.get("active") else "ready"
|
|
516
|
+
await bus.advertise(profile.model_copy(update={"status": status}))
|
|
517
|
+
failures = 0
|
|
518
|
+
await asyncio.sleep(min(1, remaining))
|
|
519
|
+
except (PlatformError, httpx.TransportError) as exc:
|
|
520
|
+
state.health("error")
|
|
521
|
+
if not transient(exc) or not wait:
|
|
522
|
+
raise
|
|
523
|
+
await asyncio.sleep(min(0.5 * 2 ** min(failures, 5), 15))
|
|
524
|
+
failures += 1
|
|
525
|
+
except TimeoutError:
|
|
526
|
+
if not wait:
|
|
527
|
+
raise
|
|
528
|
+
finally:
|
|
529
|
+
if hook == "Stop" and wait:
|
|
530
|
+
with contextlib.suppress(Exception):
|
|
531
|
+
async with asyncio.timeout(2):
|
|
532
|
+
await bus.advertise(profile.model_copy(update={"status": "offline"}))
|
|
533
|
+
await bus.close()
|
|
534
|
+
return output[0] if output else ""
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def load_key(key_file=None):
|
|
538
|
+
"""Make the agent key available: the environment wins, then a private key file.
|
|
539
|
+
|
|
540
|
+
Hooks and wakers are started by the runtime, not by the shell that holds the
|
|
541
|
+
key, so `setup --hooks` stores it in a 0600 file next to the agent config.
|
|
542
|
+
Returns False when no key is available (the caller treats that as opt-out).
|
|
543
|
+
"""
|
|
544
|
+
if os.environ.get("CAURA_API_KEY", "").strip():
|
|
545
|
+
return True
|
|
546
|
+
if key_file is None or not Path(key_file).is_file():
|
|
547
|
+
return False
|
|
548
|
+
path = Path(key_file)
|
|
549
|
+
if path.stat().st_mode & 0o077:
|
|
550
|
+
raise ValueError(f"{path} must be private to you (chmod 600 {path})")
|
|
551
|
+
key = path.read_text().strip()
|
|
552
|
+
if not key:
|
|
553
|
+
return False
|
|
554
|
+
os.environ["CAURA_API_KEY"] = key
|
|
555
|
+
return True
|