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.
@@ -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