bitacora 1.0.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.
bitacora/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Bitácora: a live dashboard for coding agents working in the background."""
2
+
3
+ __version__ = "1.0.0"
bitacora/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from bitacora.cli import main
2
+
3
+ main()
bitacora/cli.py ADDED
@@ -0,0 +1,78 @@
1
+ """Command line entry point."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ from bitacora import __version__, i18n
12
+ from bitacora.store import State, config_path, load_config, state_path
13
+ from bitacora.views import BUILTIN
14
+
15
+
16
+ def build_parser() -> argparse.ArgumentParser:
17
+ ap = argparse.ArgumentParser(
18
+ prog="bitacora",
19
+ description="Live dashboard for coding agents (Codex, Claude Code) working in the background.")
20
+ ap.add_argument("--view", help="start in this view: " + ", ".join(v.id for v in BUILTIN)
21
+ + " or one from config.toml (skips the first-run picker)")
22
+ ap.add_argument("--live", action="store_true", help="show live sessions only")
23
+ ap.add_argument("--hours", type=float, help="history window in hours (default 24)")
24
+ ap.add_argument("--lang", choices=i18n.LANGS, help="interface language (default: system)")
25
+ ap.add_argument("--demo", action="store_true", help="run on generated sample sessions")
26
+ ap.add_argument("--paths", action="store_true", help="print where config and state live, and exit")
27
+ ap.add_argument("--version", action="version", version=f"bitacora {__version__}")
28
+ return ap
29
+
30
+
31
+ def main(argv: list[str] | None = None) -> None:
32
+ args = build_parser().parse_args(argv)
33
+ if args.paths:
34
+ print(json.dumps({"config": str(config_path()), "state": str(state_path())}, indent=1))
35
+ return
36
+
37
+ animator = None
38
+ if args.demo:
39
+ from bitacora import demo
40
+ root = Path(tempfile.mkdtemp(prefix="bitacora-demo-"))
41
+ data = demo.generate(root)
42
+ os.environ["CODEX_HOME"] = str(data["codex_home"])
43
+ os.environ["CLAUDE_CONFIG_DIR"] = str(data["claude_home"])
44
+ os.environ["BITACORA_HOME"] = str(root / "home")
45
+ seed = State()
46
+ for key, entry in data["state"].items():
47
+ seed.remember(key, entry)
48
+ seed.save()
49
+ animator = demo.Animator(data["live"])
50
+
51
+ from bitacora.providers import all_providers
52
+ from bitacora.ui.app import create_app
53
+
54
+ if animator:
55
+ animator.start()
56
+ view, collector = args.view, None
57
+ try:
58
+ while True:
59
+ # Precedence: command line, then what you saved in setup, then config.toml.
60
+ config, state = load_config(), State()
61
+ lang = args.lang or state.get("lang") or config.lang or "auto"
62
+ config.hours = args.hours or state.get("hours") or config.hours
63
+ config.notify = state.get("notify", config.notify)
64
+ i18n.set_lang(None if lang == "auto" else lang)
65
+ app = create_app(all_providers(), config, state, view=view, live_only=args.live,
66
+ scan_procs=not args.demo, lang=args.lang or state.get("lang") or "auto",
67
+ collector=collector)
68
+ if app.run() != "relaunch":
69
+ break
70
+ collector = app.collector # keep what was already read; the new app only relabels
71
+ view = None # a relaunch after setup starts in the view just saved
72
+ finally:
73
+ if animator:
74
+ animator.stop.set()
75
+
76
+
77
+ if __name__ == "__main__":
78
+ sys.exit(main())
bitacora/collector.py ADDED
@@ -0,0 +1,95 @@
1
+ """Joins live processes with their session files, plus recent history, across providers."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from pathlib import Path
6
+
7
+ from bitacora import procs
8
+ from bitacora.model import ProcInfo, Run, Session
9
+ from bitacora.providers import Provider
10
+ from bitacora.store import State
11
+ from bitacora.util import label_from_out, label_from_prompt
12
+
13
+
14
+ class Collector:
15
+ def __init__(self, providers: list[Provider], state: State, hours: float, scan_procs: bool = True):
16
+ self.providers = providers
17
+ self.state = state
18
+ self.hours = hours
19
+ self.scan_procs = scan_procs
20
+ self.sessions: dict[Path, Session] = {}
21
+ self.pid_map: dict[tuple[str, int], Path] = {}
22
+ self.log_seen: dict[tuple[str, int], str | None] = {}
23
+ self.quotas: dict[str, dict] = {}
24
+
25
+ def _session(self, prov: Provider, path: Path) -> Session:
26
+ s = self.sessions.get(path)
27
+ if s is None:
28
+ s = self.sessions[path] = prov.session_cls(path)
29
+ s.update()
30
+ title = prov.title(s)
31
+ if title:
32
+ s.title = title
33
+ return s
34
+
35
+ def _live_run(self, prov: Provider, pi: ProcInfo, claimed: set[Path]) -> Run:
36
+ key = (prov.name, pi.pid)
37
+ path = self.pid_map.get(key)
38
+ if path is None or not path.exists():
39
+ path = prov.match(pi, claimed)
40
+ s = None
41
+ if path:
42
+ self.pid_map[key] = path
43
+ claimed.add(path)
44
+ s = self._session(prov, path)
45
+ sid = s.session_id if s else None
46
+ skey = f"{prov.name}:{sid}" if sid else ""
47
+ cached = self.state.session(skey) if skey else {}
48
+ out = pi.out or cached.get("out")
49
+ if pi.log is None:
50
+ # The redirect is fixed at launch and walking parent processes is slow: look once per process.
51
+ if key not in self.log_seen:
52
+ self.log_seen[key] = cached.get("log") or procs.find_log(pi.pid, out, prov.marker)
53
+ pi.log = self.log_seen[key]
54
+ label = (label_from_out(out) or (s and s.title) or cached.get("label")
55
+ or (s and label_from_prompt(s.first_prompt)) or f"pid {pi.pid}")
56
+ if skey:
57
+ self.state.remember(skey, {"label": label, "out": out, "log": pi.log, "cmdline": pi.cmdline})
58
+ return Run(skey or f"{prov.name}:pid{pi.pid}", prov.name, s, pi, label, out, pi.log)
59
+
60
+ def collect(self) -> list[Run]:
61
+ snap = procs.snapshot() if self.scan_procs else []
62
+ runs: dict[str, Run] = {}
63
+ since = time.time() - self.hours * 3600
64
+ seen: set[Path] = set()
65
+ alive: set[tuple[str, int]] = set()
66
+ for prov in self.providers:
67
+ if not prov.available():
68
+ continue
69
+ claimed: set[Path] = set()
70
+ for pi in sorted(prov.procs(snap), key=lambda x: x.create):
71
+ alive.add((prov.name, pi.pid))
72
+ run = self._live_run(prov, pi, claimed)
73
+ runs[run.key] = run
74
+ seen |= claimed
75
+ for f in prov.history(since):
76
+ if f in claimed:
77
+ continue
78
+ s = self._session(prov, f)
79
+ seen.add(f)
80
+ key = f"{prov.name}:{s.session_id or f}"
81
+ if key in runs:
82
+ continue
83
+ cached = self.state.session(key)
84
+ label = (label_from_out(cached.get("out")) or s.title or cached.get("label")
85
+ or label_from_prompt(s.first_prompt) or f.stem[-12:])
86
+ runs[key] = Run(key, prov.name, s, None, label, cached.get("out"), cached.get("log"))
87
+ mine = [s for p, s in self.sessions.items() if s.provider == prov.name and p in seen]
88
+ quota = prov.quota(mine)
89
+ if quota:
90
+ self.quotas[prov.name] = quota
91
+ self.pid_map = {k: v for k, v in self.pid_map.items() if k in alive}
92
+ self.log_seen = {k: v for k, v in self.log_seen.items() if k in alive}
93
+ self.sessions = {p: s for p, s in self.sessions.items() if p in seen}
94
+ self.state.save()
95
+ return sorted(runs.values(), key=lambda r: (not r.live, -(r.start or 0)))
bitacora/demo.py ADDED
@@ -0,0 +1,323 @@
1
+ """Synthetic Codex and Claude Code sessions, written relative to "now".
2
+
3
+ Used by `bitacora --demo`, the tests and the README screenshots. Nothing here touches real data.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import re
10
+ import threading
11
+ import time
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+
15
+ CODEX_MODEL = "gpt-6-luna"
16
+ CLAUDE_MODEL = "claude-opus-5-5"
17
+
18
+
19
+ def iso(ts: float) -> str:
20
+ return datetime.fromtimestamp(ts, UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
21
+
22
+
23
+ def demo_cwd(name: str) -> str:
24
+ return f"/home/dev/{name}"
25
+
26
+
27
+ # ----------------------------------------------------------------------------- codex
28
+
29
+ class CodexWriter:
30
+ def __init__(self, home: Path, sid: str, start: float, cwd: str, source: str = "exec",
31
+ originator: str = "codex_exec", effort: str = "high", sandbox: str = "workspace-write"):
32
+ day = datetime.fromtimestamp(start)
33
+ folder = home / "sessions" / f"{day:%Y}" / f"{day:%m}" / f"{day:%d}"
34
+ folder.mkdir(parents=True, exist_ok=True)
35
+ self.path = folder / f"rollout-{day:%Y-%m-%dT%H-%M-%S}-{sid}.jsonl"
36
+ self.path.write_text("", encoding="utf-8")
37
+ self.cwd = cwd
38
+ self.t = start
39
+ self.rec("session_meta", {"id": sid, "timestamp": iso(start), "cwd": cwd, "source": source,
40
+ "originator": originator})
41
+ self.rec("turn_context", {"model": CODEX_MODEL, "effort": effort, "sandbox_policy": {"type": sandbox}})
42
+ self.tokens = 0
43
+
44
+ def rec(self, typ: str, payload: dict, dt: float = 0.0) -> None:
45
+ self.t += dt
46
+ with open(self.path, "a", encoding="utf-8", newline="\n") as fh:
47
+ fh.write(json.dumps({"timestamp": iso(self.t), "type": typ, "payload": payload}) + "\n")
48
+
49
+ def ev(self, payload: dict, dt: float = 0.0) -> None:
50
+ self.rec("event_msg", payload, dt)
51
+
52
+ def item(self, item: dict, dt: float = 0.0) -> None:
53
+ self.ev({"type": "item_completed", "item": item}, dt)
54
+
55
+ def prompt(self, text: str, dt: float = 0.0) -> None:
56
+ self.ev({"type": "task_started"}, dt)
57
+ self.item({"type": "UserMessage", "content": [{"type": "text", "text": text}]}, 0.2)
58
+
59
+ def think(self, dt: float, summary: str = "") -> None:
60
+ self.item({"type": "Reasoning", "summary_text": [summary] if summary else []}, dt)
61
+
62
+ def cmd(self, cmd: str, out: str = "", code: int = 0, secs: float = 0.4, kind: str = "", dt: float = 1.0) -> None:
63
+ parsed = [{"type": kind, "cmd": cmd}] if kind else []
64
+ self.item({"type": "CommandExecution", "command": ["bash", "-lc", cmd], "parsed_cmd": parsed,
65
+ "exit_code": code, "duration": {"secs": int(secs), "nanos": int((secs % 1) * 1e9)},
66
+ "aggregated_output": out}, dt)
67
+
68
+ def edit(self, rel: str, diff: str, kind: str = "update", dt: float = 1.0) -> None:
69
+ sep = "\\" if "\\" in self.cwd else "/"
70
+ change = {"type": kind}
71
+ if kind == "add":
72
+ change["content"] = diff
73
+ else:
74
+ change["unified_diff"] = diff
75
+ self.item({"type": "FileChange", "changes": {self.cwd + sep + rel.replace("/", sep): change}}, dt)
76
+
77
+ def say(self, text: str, dt: float = 1.0) -> None:
78
+ self.item({"type": "AgentMessage", "content": [{"type": "text", "text": text}]}, dt)
79
+
80
+ def usage(self, add: int, ctx: int, dt: float = 0.1, rate: dict | None = None) -> None:
81
+ self.tokens += add
82
+ payload = {"type": "token_count", "info": {"total_token_usage": {"total_tokens": self.tokens},
83
+ "last_token_usage": {"input_tokens": ctx},
84
+ "model_context_window": 272_000}}
85
+ if rate:
86
+ payload["rate_limits"] = rate
87
+ self.ev(payload, dt)
88
+
89
+ def ask(self, question: str, dt: float = 1.0) -> None:
90
+ self.rec("response_item", {"type": "function_call", "name": "request_user_input", "call_id": "q1",
91
+ "arguments": json.dumps({"questions": [{"question": question}]})}, dt)
92
+
93
+ def complete(self, message: str, dt: float = 1.0) -> None:
94
+ self.say(message, dt)
95
+ self.ev({"type": "task_complete", "last_agent_message": message}, 0.2)
96
+
97
+ def abort(self, reason: str = "interrupted", dt: float = 1.0) -> None:
98
+ self.ev({"type": "turn_aborted", "reason": reason}, dt)
99
+
100
+
101
+ # ----------------------------------------------------------------------------- claude
102
+
103
+ class ClaudeWriter:
104
+ def __init__(self, home: Path, sid: str, start: float, cwd: str, entrypoint: str = "sdk-cli",
105
+ title: str = ""):
106
+ folder = home / "projects" / re.sub(r"[^A-Za-z0-9-]", "-", cwd)
107
+ folder.mkdir(parents=True, exist_ok=True)
108
+ self.path = folder / f"{sid}.jsonl"
109
+ self.path.write_text("", encoding="utf-8")
110
+ self.sid, self.cwd, self.entry, self.t = sid, cwd, entrypoint, start
111
+ self.n = 0
112
+ if title:
113
+ self.line({"type": "custom-title", "customTitle": title, "sessionId": sid})
114
+
115
+ def line(self, obj: dict) -> None:
116
+ with open(self.path, "a", encoding="utf-8", newline="\n") as fh:
117
+ fh.write(json.dumps(obj) + "\n")
118
+
119
+ def _base(self, typ: str, dt: float) -> dict:
120
+ self.t += dt
121
+ self.n += 1
122
+ return {"type": typ, "timestamp": iso(self.t), "sessionId": self.sid, "cwd": self.cwd,
123
+ "entrypoint": self.entry, "uuid": f"u{self.n}", "isSidechain": False}
124
+
125
+ def user(self, content, dt: float = 0.0) -> None:
126
+ o = self._base("user", dt)
127
+ o["message"] = {"role": "user", "content": content}
128
+ self.line(o)
129
+
130
+ def assistant(self, block: dict, stop: str | None = "tool_use", dt: float = 1.0, out_tokens: int = 300,
131
+ ctx: int = 40_000) -> None:
132
+ o = self._base("assistant", dt)
133
+ o["message"] = {"id": f"msg_{self.n}", "role": "assistant", "model": CLAUDE_MODEL, "content": [block],
134
+ "stop_reason": stop, "usage": {"input_tokens": 4, "cache_read_input_tokens": ctx,
135
+ "cache_creation_input_tokens": 600,
136
+ "output_tokens": out_tokens}}
137
+ self.line(o)
138
+
139
+ def think(self, dt: float) -> None:
140
+ self.assistant({"type": "thinking", "thinking": "", "signature": "x"}, dt=dt)
141
+
142
+ def tool(self, name: str, inp: dict, result: str = "", ok: bool = True, dt: float = 1.0,
143
+ secs: float = 1.0, extra: dict | None = None) -> None:
144
+ tid = f"toolu_{self.n}"
145
+ self.assistant({"type": "tool_use", "id": tid, "name": name, "input": inp}, dt=dt)
146
+ o = self._base("user", secs)
147
+ o["message"] = {"role": "user", "content": [{"type": "tool_result", "tool_use_id": tid,
148
+ "content": result, "is_error": not ok}]}
149
+ if extra:
150
+ o["toolUseResult"] = extra
151
+ self.line(o)
152
+
153
+ def say(self, text: str, end: bool = False, dt: float = 1.0) -> None:
154
+ self.assistant({"type": "text", "text": text}, stop="end_turn" if end else None, dt=dt)
155
+
156
+
157
+ # ----------------------------------------------------------------------------- scenario
158
+
159
+ RATE = {"primary": {"used_percent": 38.0, "window_minutes": 300},
160
+ "secondary": {"used_percent": 61.0, "window_minutes": 10080}}
161
+
162
+
163
+ def generate(root: Path, now: float | None = None) -> dict:
164
+ """Write the demo homes under `root`. Returns paths and the state entries to seed."""
165
+ now = now or time.time()
166
+ codex_home, claude_home, runs = root / "codex", root / "claude", root / "runs"
167
+ for d in (codex_home, claude_home, runs):
168
+ d.mkdir(parents=True, exist_ok=True)
169
+ rate = {k: dict(v, resets_at=int(now + (2 * 3600 if k == "primary" else 3 * 86400)))
170
+ for k, v in RATE.items()}
171
+ state: dict = {}
172
+ api, web, infra = demo_cwd("shop-api"), demo_cwd("shop-web"), demo_cwd("infra")
173
+
174
+ # 1. A background run that is working right now.
175
+ sid = "0199a1b2-0000-7000-8000-000000000001"
176
+ w = CodexWriter(codex_home, sid, now - 7 * 60, api)
177
+ w.prompt("Task: move session auth to signed cookies. Keep the public API unchanged and add tests.")
178
+ w.think(6)
179
+ w.cmd("rg -n \"session_token\" src", "src/auth/session.py:12\nsrc/auth/middleware.py:40", kind="search", dt=2)
180
+ w.cmd("sed -n 1,80p src/auth/session.py", "...", kind="read")
181
+ w.usage(48_000, 41_000, rate=rate)
182
+ w.think(14)
183
+ w.edit("src/auth/session.py",
184
+ "@@ -10,7 +10,9 @@\n- token = request.headers.get('X-Session')\n+ raw = request.cookies.get(COOKIE)\n"
185
+ "+ token = unsign(raw) if raw else None\n+ if token is None:\n+ return None", dt=3)
186
+ w.edit("tests/test_session_cookies.py", "def test_signed_cookie_roundtrip():\n ...\n", kind="add")
187
+ w.cmd("pytest -q tests/test_session_cookies.py", "F.\nFAILED test_expired_cookie - assert 401 == 200",
188
+ code=1, secs=3.2)
189
+ w.think(9)
190
+ w.edit("src/auth/session.py", "@@ -31,3 +31,5 @@\n+ if expired(token):\n+ raise Unauthorized()", dt=2)
191
+ w.cmd("pytest -q tests/test_session_cookies.py", "..\n2 passed in 1.84s", secs=2.4)
192
+ w.usage(61_000, 58_500, rate=rate)
193
+ w.t = now - 25
194
+ w.say("Cookie signing is in place and the new tests pass. Now checking the middleware for the old header.")
195
+ w.t = now - 4
196
+ w.think(0)
197
+ (runs / "auth-cookies.log").write_text(
198
+ "exec codex --model gpt-6-luna -s workspace-write\n$ rg -n session_token src\n"
199
+ "$ pytest -q tests/test_session_cookies.py\nFAILED test_expired_cookie\n$ pytest -q\n2 passed\n",
200
+ encoding="utf-8")
201
+ state[f"codex:{sid}"] = {"label": "auth-cookies", "out": str(runs / "auth-cookies.result.md"),
202
+ "log": str(runs / "auth-cookies.log")}
203
+ live_codex = w
204
+
205
+ # 2. A background run waiting for an answer.
206
+ sid = "0199a1b2-0000-7000-8000-000000000002"
207
+ w = CodexWriter(codex_home, sid, now - 12 * 60, infra, sandbox="read-only")
208
+ w.prompt("Plan the Postgres 17 upgrade for the staging cluster.")
209
+ w.think(20)
210
+ w.cmd("cat terraform/db.tf", "engine_version = \"15.6\"", kind="read")
211
+ w.usage(30_000, 28_000)
212
+ w.ask("Staging has 2 replicas. Upgrade in place, or blue/green with a new cluster?", dt=30)
213
+ w.t = now - 50
214
+ w.ev({"type": "token_count", "info": None})
215
+ state[f"codex:{sid}"] = {"label": "pg17-plan", "out": None, "log": None}
216
+
217
+ # 3. A finished background run with its result file.
218
+ sid = "0199a1b2-0000-7000-8000-000000000003"
219
+ w = CodexWriter(codex_home, sid, now - 95 * 60, web, sandbox="read-only")
220
+ w.prompt("Read-only audit: list accessibility issues on the checkout flow.")
221
+ w.think(30)
222
+ w.cmd("rg -n \"<img\" src/checkout", "src/checkout/Summary.tsx:22", kind="search")
223
+ w.cmd("rg -n \"onClick\" src/checkout", "4 matches", kind="search")
224
+ w.usage(210_000, 96_000)
225
+ result = "# Checkout a11y audit\n\n1. `Summary.tsx`: product images have no alt text.\n" \
226
+ "2. The coupon toggle is a `div` with onClick and no keyboard handler.\n" \
227
+ "3. Error messages are not announced (missing `aria-live`).\n"
228
+ w.complete(result, dt=240)
229
+ (runs / "a11y-audit.result.md").write_text(result, encoding="utf-8")
230
+ state[f"codex:{sid}"] = {"label": "a11y-audit", "out": str(runs / "a11y-audit.result.md"), "log": None}
231
+
232
+ # 4. A background run that was cut off.
233
+ sid = "0199a1b2-0000-7000-8000-000000000004"
234
+ w = CodexWriter(codex_home, sid, now - 5 * 3600, api)
235
+ w.prompt("Find why test_orders is flaky on CI.")
236
+ w.think(12)
237
+ w.cmd("pytest tests/test_orders.py -x --count 20", "19 passed, 1 failed", code=1, secs=41)
238
+ w.abort("interrupted", dt=60)
239
+ state[f"codex:{sid}"] = {"label": "flaky-orders", "out": None, "log": None}
240
+
241
+ # 5. An interactive Codex thread from the desktop app.
242
+ sid = "0199a1b2-0000-7000-8000-000000000005"
243
+ w = CodexWriter(codex_home, sid, now - 3 * 3600, web, source="vscode", originator="Codex Desktop",
244
+ effort="medium")
245
+ w.prompt("Rewrite the landing hero copy, shorter and less salesy.")
246
+ w.think(8)
247
+ w.complete("Here are three options for the hero, from most to least direct…", dt=20)
248
+ with open(codex_home / "session_index.jsonl", "a", encoding="utf-8", newline="\n") as fh:
249
+ fh.write(json.dumps({"id": sid, "thread_name": "Landing hero copy", "updated_at": iso(now)}) + "\n")
250
+
251
+ # 6. A Claude Code run in print mode, working right now.
252
+ c = ClaudeWriter(claude_home, "5f0c2a1e-0000-4000-8000-000000000006", now - 4 * 60, web)
253
+ c.user("Update CHANGELOG.md for the 2.4.0 release from the merged PRs since 2.3.0.")
254
+ c.think(4)
255
+ c.tool("Bash", {"command": "git log --oneline v2.3.0..HEAD"}, "a1f3 Add dark mode\n9c2e Fix cart rounding")
256
+ c.tool("Read", {"file_path": web + "/CHANGELOG.md"}, "# Changelog")
257
+ c.think(11)
258
+ c.tool("Edit", {"file_path": web + "/CHANGELOG.md",
259
+ "old_string": "# Changelog\n",
260
+ "new_string": "# Changelog\n\n## 2.4.0\n- Dark mode\n- Cart rounding fix\n"})
261
+ c.t = now - 8
262
+ c.say("Added the 2.4.0 section. Checking that every PR title is user facing.")
263
+ live_claude = c
264
+
265
+ # 7. An interactive Claude Code session from the desktop app.
266
+ c = ClaudeWriter(claude_home, "5f0c2a1e-0000-4000-8000-000000000007", now - 2 * 3600, api,
267
+ entrypoint="claude-desktop", title="Review PR 42: rate limiter")
268
+ c.user("Review PR 42, focus on the rate limiter edge cases.")
269
+ c.think(9)
270
+ c.tool("Bash", {"command": "gh pr diff 42"}, "+ bucket.refill(now)")
271
+ c.tool("Grep", {"pattern": "refill", "path": "src"}, "src/limits/bucket.py")
272
+ c.say("Two issues: refill can overflow capacity after a long idle period, and the lock is not held "
273
+ "while reading `tokens`. Details below.", end=True, dt=30)
274
+
275
+ # 8. An older terminal session.
276
+ c = ClaudeWriter(claude_home, "5f0c2a1e-0000-4000-8000-000000000008", now - 7 * 3600, infra,
277
+ entrypoint="cli", title="Fix login redirect loop")
278
+ c.user("Users get a redirect loop after login on Safari.")
279
+ c.think(15)
280
+ c.tool("Read", {"file_path": infra + "/nginx.conf"}, "...")
281
+ c.tool("Bash", {"command": "curl -I https://staging.example.test/login"}, "HTTP/2 302", ok=True)
282
+ c.say("The cookie is set with SameSite=Strict on a cross-site redirect. Switched it to Lax.", end=True)
283
+
284
+ # File times follow the last record, as they would on a real machine.
285
+ for f in list(codex_home.rglob("*.jsonl")) + list(claude_home.rglob("*.jsonl")):
286
+ last = json.loads(f.read_text(encoding="utf-8").splitlines()[-1]).get("timestamp")
287
+ if last:
288
+ ts = datetime.fromisoformat(last.replace("Z", "+00:00")).timestamp()
289
+ os.utime(f, (ts, ts))
290
+ return {"codex_home": codex_home, "claude_home": claude_home, "state": state,
291
+ "live": (live_codex, live_claude)}
292
+
293
+
294
+ class Animator(threading.Thread):
295
+ """Keeps the two live demo sessions moving so the dashboard looks like a real day."""
296
+
297
+ def __init__(self, live: tuple, every: float = 3.0):
298
+ super().__init__(daemon=True)
299
+ self.codex, self.claude = live
300
+ self.every = every
301
+ self.stop = threading.Event()
302
+
303
+ def run(self) -> None:
304
+ i = 0
305
+ cfiles = ["src/auth/middleware.py", "src/auth/cookies.py", "docs/auth.md"]
306
+ while not self.stop.wait(self.every):
307
+ now = time.time()
308
+ self.codex.t = now - 1
309
+ self.claude.t = now - 1
310
+ step = i % 4
311
+ if step == 0:
312
+ self.codex.think(0.5)
313
+ self.claude.think(0.5)
314
+ elif step == 1:
315
+ self.codex.cmd(f"rg -n \"X-Session\" {cfiles[i // 4 % 3]}", "1 match", kind="search", dt=0.5)
316
+ self.claude.tool("Bash", {"command": "gh pr view --json title"}, "{\"title\": \"...\"}", dt=0.5)
317
+ elif step == 2:
318
+ self.codex.edit(cfiles[i // 4 % 3], "@@ -1 +1 @@\n-old\n+new", dt=0.5)
319
+ self.codex.usage(9_000, 60_000 + 1_500 * (i % 40), dt=0.1)
320
+ else:
321
+ self.codex.say(f"Updated {cfiles[i // 4 % 3]}; running the suite again.", dt=0.5)
322
+ self.claude.say("Reworded two entries so they describe the change for users.", dt=0.5)
323
+ i += 1