session-saloon 0.1.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,3 @@
1
+ """Session Saloon: a local, live dashboard for Claude Code sessions."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,103 @@
1
+ """Command-line entry point: ``saloon``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import List, Optional
10
+
11
+ from . import __version__
12
+ from .config import Config, config_path, load_config, resolve_codex_dir, resolve_projects_dir, served_path
13
+ from .server import App, SaloonServer, open_browser, probe_existing
14
+ from .tracker import ServedStore, Tracker
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ p = argparse.ArgumentParser(
19
+ prog="saloon",
20
+ description="A local, live dashboard that shows your Claude Code sessions as customers at a saloon bar.",
21
+ )
22
+ p.add_argument("--hours", type=float, help="time window in hours (default 72; 0 = no limit)")
23
+ p.add_argument("--port", type=int, help="port to serve on (default 7317)")
24
+ p.add_argument("--no-open", action="store_true", help="do not open the browser")
25
+ p.add_argument("--dump", action="store_true", help="print the snapshot as JSON and exit")
26
+ p.add_argument("--projects-dir", metavar="PATH", help="transcript root (default: ~/.claude/projects, or $CLAUDE_CONFIG_DIR/projects)")
27
+ p.add_argument("--codex-dir", metavar="PATH", help="Codex home (default: $CODEX_HOME or ~/.codex); sessions live in its sessions/ folder")
28
+ p.add_argument("--only", choices=["claude", "codex"], help="show only one agent's sessions")
29
+ p.add_argument("--demo", action="store_true", help="serve a synthetic, animated snapshot instead of real sessions")
30
+ p.add_argument("--config", metavar="FILE", help=f"config file (default: {config_path()})")
31
+ p.add_argument("--now", type=float, help=argparse.SUPPRESS) # debugging aid: pretend it is this unix time (with --dump)
32
+ p.add_argument("--version", action="version", version=f"session-saloon {__version__}")
33
+ return p
34
+
35
+
36
+ def config_from_args(args: argparse.Namespace) -> Config:
37
+ cfg = load_config(Path(args.config) if args.config else None)
38
+ if args.hours is not None:
39
+ cfg.hours = args.hours
40
+ if args.port is not None:
41
+ cfg.port = args.port
42
+ if args.projects_dir:
43
+ cfg.projects_dir = args.projects_dir
44
+ if args.codex_dir:
45
+ cfg.codex_dir = args.codex_dir
46
+ if args.only:
47
+ cfg.sources = [args.only]
48
+ return cfg
49
+
50
+
51
+ def main(argv: Optional[List[str]] = None) -> int:
52
+ args = build_parser().parse_args(argv)
53
+ cfg = config_from_args(args)
54
+ if not 1 <= cfg.port <= 65535:
55
+ print(f"saloon: invalid port {cfg.port}", file=sys.stderr)
56
+ return 2
57
+
58
+ if args.dump:
59
+ tracker = Tracker(cfg, ServedStore(served_path()))
60
+ print(json.dumps(tracker.snapshot(args.now), indent=2))
61
+ return 0
62
+
63
+ if args.demo:
64
+ from .demo import make_demo
65
+
66
+ app = App(cfg, demo=make_demo(), served=ServedStore(None), persist_config=False)
67
+ else:
68
+ roots = []
69
+ if "claude" in cfg.sources:
70
+ roots.append(resolve_projects_dir(cfg))
71
+ if "codex" in cfg.sources:
72
+ roots.append(resolve_codex_dir(cfg) / "sessions")
73
+ if not any(r.is_dir() for r in roots):
74
+ where = " or ".join(str(r) for r in roots)
75
+ print(f"saloon: no transcripts found at {where} (use --projects-dir / --codex-dir, or start a session first)", file=sys.stderr)
76
+ app = App(cfg, tracker=Tracker(cfg, ServedStore(served_path())))
77
+
78
+ try:
79
+ server = SaloonServer(cfg.port, app)
80
+ except OSError:
81
+ if probe_existing(cfg.port):
82
+ print(f"Session Saloon is already open on http://127.0.0.1:{cfg.port}/")
83
+ if not args.no_open:
84
+ open_browser(cfg.port)
85
+ return 0
86
+ print(f"saloon: port {cfg.port} is in use by something else (try --port)", file=sys.stderr)
87
+ return 1
88
+
89
+ url = f"http://127.0.0.1:{cfg.port}/"
90
+ print(f"Session Saloon is open at {url} (Ctrl+C to close the bar)")
91
+ if not args.no_open:
92
+ open_browser(cfg.port)
93
+ try:
94
+ server.serve_forever()
95
+ except KeyboardInterrupt:
96
+ pass
97
+ finally:
98
+ server.server_close()
99
+ return 0
100
+
101
+
102
+ if __name__ == "__main__":
103
+ sys.exit(main())
@@ -0,0 +1,130 @@
1
+ """All knowledge of the Codex CLI rollout format lives here.
2
+
3
+ Codex writes ``~/.codex/sessions/YYYY/MM/DD/rollout-<time>-<uuid>.jsonl``. Each line is
4
+ ``{"timestamp", "type", "payload"}``. Unlike Claude Code, turn boundaries are explicit
5
+ (``task_started`` / ``task_complete`` / ``turn_aborted``), so no "quiet" heuristic is needed
6
+ to detect a finished turn. Like the Claude side, this is not a public contract: unknown
7
+ fields and lines are ignored.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from typing import Any, Dict, Optional, Sequence
14
+
15
+ from . import transcript as tr
16
+
17
+ WORK_ITEMS = {
18
+ "function_call", "function_call_output", "custom_tool_call", "custom_tool_call_output",
19
+ "local_shell_call", "web_search_call", "tool_search_call", "reasoning",
20
+ }
21
+ TOOL_CALLS = {"function_call", "custom_tool_call", "local_shell_call", "web_search_call", "tool_search_call"}
22
+ HEADLESS_SOURCES = {"exec", "mcp"} # `codex exec` and the MCP server run without a person at the keyboard
23
+
24
+
25
+ def clean_cwd(cwd: Any) -> Optional[str]:
26
+ if not isinstance(cwd, str) or not cwd:
27
+ return None
28
+ return cwd[4:] if cwd.startswith("\\\\?\\") else cwd # Windows extended-length prefix
29
+
30
+
31
+ def parse_line(raw: str, markers: Sequence[str] = ()) -> Optional[tr.Record]:
32
+ raw = raw.strip()
33
+ if not raw:
34
+ return None
35
+ try:
36
+ obj = json.loads(raw)
37
+ except ValueError:
38
+ return None
39
+ if not isinstance(obj, dict):
40
+ return None
41
+ return parse_object(obj)
42
+
43
+
44
+ def parse_object(obj: Dict[str, Any]) -> tr.Record:
45
+ rec = tr.Record()
46
+ rec.ts = tr.parse_timestamp(obj.get("timestamp"))
47
+ payload = obj.get("payload")
48
+ p: Dict[str, Any] = payload if isinstance(payload, dict) else {}
49
+ kind = obj.get("type")
50
+ ptype = p.get("type")
51
+
52
+ if kind == "session_meta":
53
+ rec.cwd = clean_cwd(p.get("cwd"))
54
+ git = p.get("git")
55
+ if isinstance(git, dict) and isinstance(git.get("branch"), str):
56
+ rec.branch = git["branch"]
57
+ _parse_source(p, rec)
58
+ elif kind == "turn_context":
59
+ rec.cwd = clean_cwd(p.get("cwd"))
60
+ model = p.get("model")
61
+ if isinstance(model, str) and model:
62
+ rec.model = model
63
+ elif kind == "compacted":
64
+ rec.kind = tr.COMPACT
65
+ elif kind == "event_msg":
66
+ _parse_event(p, ptype, rec)
67
+ elif kind == "response_item":
68
+ _parse_item(p, ptype, rec)
69
+ return rec
70
+
71
+
72
+ def _parse_source(p: Dict[str, Any], rec: tr.Record) -> None:
73
+ source = p.get("source")
74
+ if isinstance(source, str):
75
+ if source in HEADLESS_SOURCES or p.get("originator") == "codex_exec":
76
+ rec.headless = True
77
+ return
78
+ if isinstance(source, dict) and isinstance(source.get("subagent"), dict):
79
+ spawn = source["subagent"].get("thread_spawn")
80
+ parent = spawn.get("parent_thread_id") if isinstance(spawn, dict) else None
81
+ if isinstance(parent, str) and parent:
82
+ rec.parent = parent # a helper thread of another session: shown as a "mate" of it
83
+ else:
84
+ rec.headless = True # e.g. the guardian reviewer: an automation
85
+
86
+
87
+ def _parse_event(p: Dict[str, Any], ptype: Any, rec: tr.Record) -> None:
88
+ if ptype == "task_started":
89
+ rec.kind = tr.WORK
90
+ elif ptype == "user_message":
91
+ text = p.get("message")
92
+ if isinstance(text, str) and text.strip():
93
+ rec.kind = tr.PROMPT
94
+ rec.text = text.strip()[: tr.PROMPT_TEXT_LIMIT]
95
+ elif ptype == "task_complete":
96
+ rec.kind = tr.END
97
+ elif ptype == "turn_aborted":
98
+ rec.kind = tr.INTERRUPT
99
+ elif ptype == "token_count":
100
+ info = p.get("info")
101
+ usage = info.get("total_token_usage") if isinstance(info, dict) else None
102
+ out = usage.get("output_tokens") if isinstance(usage, dict) else None
103
+ if isinstance(out, (int, float)) and not isinstance(out, bool) and out >= 0:
104
+ rec.out_tokens_total = int(out)
105
+ elif ptype == "item_completed":
106
+ # Interactive sessions record what the person typed as a completed UserMessage item.
107
+ item = p.get("item")
108
+ if isinstance(item, dict) and item.get("type") == "UserMessage":
109
+ text = _item_text(item.get("content"))
110
+ if text:
111
+ rec.kind = tr.PROMPT
112
+ rec.text = text[: tr.PROMPT_TEXT_LIMIT]
113
+ # agent_message, thread_settings_applied, other items: they trail the turn markers, so
114
+ # they must not flip a finished turn back to "working".
115
+
116
+
117
+ def _item_text(content: Any) -> str:
118
+ if isinstance(content, str):
119
+ return content.strip()
120
+ if not isinstance(content, list):
121
+ return ""
122
+ parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text" and isinstance(b.get("text"), str)]
123
+ return "\n".join(parts).strip()
124
+
125
+
126
+ def _parse_item(p: Dict[str, Any], ptype: Any, rec: tr.Record) -> None:
127
+ if ptype in WORK_ITEMS or (ptype == "message" and p.get("role") == "assistant"):
128
+ rec.kind = tr.WORK
129
+ if ptype in TOOL_CALLS:
130
+ rec.tool_calls = 1
@@ -0,0 +1,277 @@
1
+ """Defaults, config file and command-line overrides.
2
+
3
+ Every heuristic threshold lives in :class:`Config`. Precedence, lowest to
4
+ highest: built-in defaults, config file, command-line flags.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import sys
12
+ from dataclasses import dataclass, field, fields
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ APP = "session-saloon"
17
+
18
+ # A user line starting with one of these is not something the human typed.
19
+ DEFAULT_PROMPT_MARKERS: List[str] = [
20
+ "<system-reminder",
21
+ "<command-name",
22
+ "<command-message",
23
+ "<command-args",
24
+ "<local-command",
25
+ "<task-notification",
26
+ "<agent-message",
27
+ "Stop hook feedback",
28
+ "This session is being continued",
29
+ "Caveat:",
30
+ "Base directory for this skill",
31
+ "## Instructions",
32
+ "[Image:",
33
+ ]
34
+
35
+ SHELLS = ("auto", "powershell", "posix")
36
+
37
+
38
+ @dataclass
39
+ class Config:
40
+ # Window and server
41
+ hours: float = 72.0
42
+ port: int = 7317
43
+ projects_dir: Optional[str] = None # Claude Code transcripts
44
+ codex_dir: Optional[str] = None # Codex home (contains sessions/)
45
+ sources: List[str] = field(default_factory=lambda: ["claude", "codex"])
46
+ # State-machine thresholds, all in seconds
47
+ quiet_end: float = 180.0 # text-only assistant message + quiet => waiting
48
+ stall_after: float = 900.0 # mid-turn + quiet => stalled
49
+ late_after: float = 1200.0 # waiting this long => late (display mood)
50
+ doze_after: float = 7200.0 # waiting this long => dozing (display mood)
51
+ subagent_live: float = 120.0 # subagent file changed this recently => live
52
+ # Parsing
53
+ prompt_markers: List[str] = field(default_factory=lambda: list(DEFAULT_PROMPT_MARKERS))
54
+ # Presentation
55
+ shell: str = "auto" # resume command flavour: auto | powershell | posix
56
+ aliases: Dict[str, str] = field(default_factory=dict) # project name -> shown name
57
+
58
+ def public(self) -> Dict[str, Any]:
59
+ """The subset the page needs, and the settings panel edits."""
60
+ return {
61
+ "hours": self.hours,
62
+ "quietEnd": self.quiet_end,
63
+ "stallAfter": self.stall_after,
64
+ "lateAfter": self.late_after,
65
+ "dozeAfter": self.doze_after,
66
+ "subagentLive": self.subagent_live,
67
+ "shell": self.shell,
68
+ "aliases": dict(self.aliases),
69
+ }
70
+
71
+
72
+ # ---------------------------------------------------------------- locations
73
+
74
+ def config_dir() -> Path:
75
+ if sys.platform == "win32":
76
+ base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
77
+ return Path(base) / APP
78
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
79
+ return Path(base) / APP
80
+
81
+
82
+ def data_dir() -> Path:
83
+ if sys.platform == "win32":
84
+ base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
85
+ return Path(base) / APP
86
+ if sys.platform == "darwin":
87
+ return Path.home() / "Library" / "Application Support" / APP
88
+ base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
89
+ return Path(base) / APP
90
+
91
+
92
+ def config_path() -> Path:
93
+ return config_dir() / "config.toml"
94
+
95
+
96
+ def served_path() -> Path:
97
+ return data_dir() / "served.json"
98
+
99
+
100
+ def default_projects_dir() -> Path:
101
+ root = os.environ.get("CLAUDE_CONFIG_DIR")
102
+ base = Path(root).expanduser() if root else Path.home() / ".claude"
103
+ return base / "projects"
104
+
105
+
106
+ def resolve_projects_dir(cfg: Config) -> Path:
107
+ return Path(cfg.projects_dir).expanduser() if cfg.projects_dir else default_projects_dir()
108
+
109
+
110
+ def resolve_codex_dir(cfg: Config) -> Path:
111
+ if cfg.codex_dir:
112
+ return Path(cfg.codex_dir).expanduser()
113
+ root = os.environ.get("CODEX_HOME")
114
+ return Path(root).expanduser() if root else Path.home() / ".codex"
115
+
116
+
117
+ def detect_shell() -> str:
118
+ return "powershell" if sys.platform == "win32" else "posix"
119
+
120
+
121
+ # --------------------------------------------------------------- file format
122
+
123
+ def _load_file(path: Path) -> Dict[str, Any]:
124
+ """Read TOML (3.11+) or, on older Pythons, JSON. Bad files are ignored."""
125
+ try:
126
+ text = path.read_text(encoding="utf-8")
127
+ except OSError:
128
+ return {}
129
+ try:
130
+ import tomllib # type: ignore[import-not-found]
131
+
132
+ return tomllib.loads(text)
133
+ except ImportError:
134
+ pass
135
+ except Exception:
136
+ return {}
137
+ try:
138
+ data = json.loads(text)
139
+ return data if isinstance(data, dict) else {}
140
+ except ValueError:
141
+ return {}
142
+
143
+
144
+ def _apply(cfg: Config, data: Dict[str, Any]) -> None:
145
+ """Copy known keys from a parsed file into cfg, ignoring bad types."""
146
+ flat: Dict[str, Any] = {}
147
+ for key, value in data.items():
148
+ if key == "projects" and isinstance(value, dict):
149
+ alias = value.get("alias")
150
+ if isinstance(alias, dict):
151
+ flat["aliases"] = alias
152
+ elif key == "thresholds" and isinstance(value, dict):
153
+ flat.update(value)
154
+ else:
155
+ flat[key] = value
156
+ for f in fields(Config):
157
+ if f.name not in flat:
158
+ continue
159
+ value = flat[f.name]
160
+ current = getattr(cfg, f.name)
161
+ if isinstance(current, bool):
162
+ continue
163
+ if isinstance(current, (int, float)) and isinstance(value, (int, float)) and not isinstance(value, bool):
164
+ setattr(cfg, f.name, type(current)(value))
165
+ elif isinstance(current, str) and isinstance(value, str):
166
+ setattr(cfg, f.name, value)
167
+ elif f.name in ("projects_dir", "codex_dir") and isinstance(value, str):
168
+ setattr(cfg, f.name, value)
169
+ elif f.name == "sources" and isinstance(value, list) and all(v in ("claude", "codex") for v in value):
170
+ cfg.sources = list(value)
171
+ elif f.name == "prompt_markers" and isinstance(value, list) and all(isinstance(x, str) for x in value):
172
+ cfg.prompt_markers = list(value)
173
+ elif f.name == "aliases" and isinstance(value, dict):
174
+ cfg.aliases = {str(k): str(v) for k, v in value.items()}
175
+ if cfg.shell not in SHELLS:
176
+ cfg.shell = "auto"
177
+
178
+
179
+ def load_config(path: Optional[Path] = None) -> Config:
180
+ cfg = Config()
181
+ _apply(cfg, _load_file(path or config_path()))
182
+ return cfg
183
+
184
+
185
+ def _toml_str(s: str) -> str:
186
+ return json.dumps(s, ensure_ascii=False) # JSON strings are valid TOML basic strings
187
+
188
+
189
+ def dump_toml(cfg: Config) -> str:
190
+ """Write the user-editable part of the config as TOML (a JSON file on old Pythons)."""
191
+ if sys.version_info < (3, 11):
192
+ return json.dumps(
193
+ {
194
+ "hours": cfg.hours,
195
+ "thresholds": {
196
+ "quiet_end": cfg.quiet_end,
197
+ "stall_after": cfg.stall_after,
198
+ "late_after": cfg.late_after,
199
+ "doze_after": cfg.doze_after,
200
+ "subagent_live": cfg.subagent_live,
201
+ },
202
+ "shell": cfg.shell,
203
+ "projects": {"alias": cfg.aliases},
204
+ },
205
+ indent=2,
206
+ )
207
+ lines = [
208
+ "# Session Saloon settings. Thresholds are in seconds.",
209
+ f"hours = {cfg.hours:g}",
210
+ f"shell = {_toml_str(cfg.shell)}",
211
+ "",
212
+ "[thresholds]",
213
+ f"quiet_end = {cfg.quiet_end:g}",
214
+ f"stall_after = {cfg.stall_after:g}",
215
+ f"late_after = {cfg.late_after:g}",
216
+ f"doze_after = {cfg.doze_after:g}",
217
+ f"subagent_live = {cfg.subagent_live:g}",
218
+ "",
219
+ "[projects.alias]",
220
+ ]
221
+ for k, v in cfg.aliases.items():
222
+ lines.append(f"{_toml_str(k)} = {_toml_str(v)}")
223
+ return "\n".join(lines) + "\n"
224
+
225
+
226
+ def save_config(cfg: Config, path: Optional[Path] = None) -> None:
227
+ path = path or config_path()
228
+ path.parent.mkdir(parents=True, exist_ok=True)
229
+ tmp = path.with_suffix(path.suffix + ".tmp")
230
+ tmp.write_text(dump_toml(cfg), encoding="utf-8")
231
+ os.replace(tmp, path)
232
+
233
+
234
+ # ------------------------------------------------------------ settings edits
235
+
236
+ _NUMERIC_LIMITS = {
237
+ # key: (attribute, min, max)
238
+ "hours": ("hours", 0, 24 * 365), # 0 = no limit
239
+ "quietEnd": ("quiet_end", 10, 3600),
240
+ "stallAfter": ("stall_after", 60, 24 * 3600),
241
+ "lateAfter": ("late_after", 60, 24 * 3600),
242
+ "dozeAfter": ("doze_after", 300, 30 * 24 * 3600),
243
+ "subagentLive": ("subagent_live", 10, 3600),
244
+ }
245
+
246
+
247
+ def update_from_request(cfg: Config, body: Dict[str, Any]) -> List[str]:
248
+ """Apply a validated settings change to cfg. Returns a list of errors (empty = ok)."""
249
+ errors: List[str] = []
250
+ staged: Dict[str, Any] = {}
251
+ for key, (attr, lo, hi) in _NUMERIC_LIMITS.items():
252
+ if key not in body:
253
+ continue
254
+ v = body[key]
255
+ if isinstance(v, bool) or not isinstance(v, (int, float)) or not (lo <= v <= hi):
256
+ errors.append(f"{key} must be a number between {lo} and {hi}")
257
+ else:
258
+ staged[attr] = float(v)
259
+ if "shell" in body:
260
+ if body["shell"] in SHELLS:
261
+ staged["shell"] = body["shell"]
262
+ else:
263
+ errors.append("shell must be one of " + ", ".join(SHELLS))
264
+ if "aliases" in body:
265
+ a = body["aliases"]
266
+ if (
267
+ isinstance(a, dict)
268
+ and len(a) <= 100
269
+ and all(isinstance(k, str) and isinstance(v, str) and 0 < len(k) <= 200 and 0 < len(v) <= 100 for k, v in a.items())
270
+ ):
271
+ staged["aliases"] = dict(a)
272
+ else:
273
+ errors.append("aliases must map project names to display names")
274
+ if not errors:
275
+ for attr, v in staged.items():
276
+ setattr(cfg, attr, v)
277
+ return errors
session_saloon/demo.py ADDED
@@ -0,0 +1,115 @@
1
+ """A synthetic, animated snapshot (`saloon --demo`) that shows every state.
2
+
3
+ Nothing here reads real sessions, so it is safe for screenshots and the README.
4
+ One session cycles forever (prompt, work, finish) so the pour, mug and ROUND!
5
+ animations can be seen without running Claude.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from typing import Any, Dict, List
12
+
13
+ from .session import local_day
14
+
15
+ CYCLE = 40.0 # seconds per prompt/finish cycle
16
+ WORK_FOR = 12.0 # seconds of "working" in each cycle
17
+
18
+ MIN = 60.0
19
+ HOUR = 3600.0
20
+
21
+
22
+ def _session(now: float, n: int, project: str, title: str, state: str, age: float, **kw: Any) -> Dict[str, Any]:
23
+ prompts = kw.pop("prompts", 4)
24
+ sess: Dict[str, Any] = {
25
+ "id": f"demo-{n:04d}",
26
+ "agent": kw.pop("agent", "claude"),
27
+ "project": project,
28
+ "cwd": f"/home/demo/{project.lower()}",
29
+ "title": title,
30
+ "branch": kw.pop("branch", "main"),
31
+ "model": kw.pop("model", "claude-opus-5-5"),
32
+ "headless": False,
33
+ "state": state,
34
+ "age": round(age, 1),
35
+ "firstTs": now - age - 2 * HOUR,
36
+ "lastTs": now - age,
37
+ "prompts": prompts,
38
+ "promptsToday": kw.pop("promptsToday", prompts),
39
+ "turns": kw.pop("turns", max(prompts - 1, 0)),
40
+ "tools": kw.pop("tools", 24),
41
+ "tokens": kw.pop("tokens", 31000),
42
+ "tokensToday": kw.pop("tokensToday", 12000),
43
+ "lastPrompt": kw.pop("lastPrompt", "Please look into this and tell me what you find."),
44
+ "prs": kw.pop("prs", []),
45
+ "compactions": kw.pop("compactions", 0),
46
+ "subagents": kw.pop("subagents", 0),
47
+ "subagentsLive": kw.pop("subagentsLive", 0),
48
+ "served": None,
49
+ "lastPromptTs": now - age,
50
+ }
51
+ sess.update(kw)
52
+ return sess
53
+
54
+
55
+ def _pr(n: int) -> Dict[str, Any]:
56
+ return {"n": n, "url": f"https://example.invalid/demo/pull/{n}", "repo": "demo/repo"}
57
+
58
+
59
+ def snapshot(now: float, t0: float) -> Dict[str, Any]:
60
+ elapsed = max(0.0, now - t0)
61
+ cycle, phase = int(elapsed // CYCLE), elapsed % CYCLE
62
+ working = phase < WORK_FOR
63
+
64
+ cycler = _session(
65
+ now, 1, "Orchard", "Refactor the checkout flow", "working" if working else "waiting",
66
+ phase if working else phase - WORK_FOR,
67
+ model="claude-opus-5-5", branch="feature/checkout", prompts=6 + cycle + 1, turns=6 + cycle + (0 if working else 1),
68
+ tools=40 + cycle * 9 + (int(phase * 0.7) if working else 8), tokens=52000 + cycle * 900 + int(phase * 60),
69
+ tokensToday=20000 + cycle * 900, prs=[_pr(101)], subagents=2, subagentsLive=2 if working else 0,
70
+ lastPrompt="Split the payment step into its own component and keep the tests green.",
71
+ )
72
+ cycler["lastPromptTs"] = now - phase # the prompt that started this cycle
73
+
74
+ sessions: List[Dict[str, Any]] = [
75
+ cycler,
76
+ _session(now, 2, "Orchard", "Fix flaky invoice test", "waiting", 25 * MIN + elapsed, model="claude-sonnet-5",
77
+ branch="fix/invoice-flake", prs=[_pr(98), _pr(99)], prompts=9, turns=9, tools=71, tokens=88000,
78
+ lastPrompt="Run it ten times in a loop and tell me if it still fails."),
79
+ _session(now, 3, "Orchard", "Plan the database migration", "waiting", 8 * MIN + elapsed, model="claude-fable-5-1",
80
+ branch="chore/migration", subagents=3, subagentsLive=1, prompts=3, turns=3, tools=18, tokens=19000,
81
+ lastPrompt="Draft the plan first, no changes yet."),
82
+ _session(now, 4, "Orchard", "Old spike: caching layer", "waiting", 3 * HOUR + elapsed, model="claude-haiku-4-5",
83
+ prompts=2, turns=2, tools=6, tokens=4200),
84
+ _session(now, 5, "Orchard", "Release notes draft", "waiting", 30 * HOUR + elapsed, model="claude-opus-5-5",
85
+ prompts=5, turns=5, tools=12, tokens=15000, promptsToday=0, tokensToday=0, compactions=1),
86
+ _session(now, 6, "Orchard", "Upgrade the linter", "stalled", 22 * MIN + elapsed, model="claude-sonnet-5",
87
+ branch="chore/lint", prompts=2, turns=1, tools=9, tokens=6100),
88
+ _session(now, 7, "Website", "Landing page copy", "waiting", 3 * MIN + elapsed, model="claude-haiku-4-5",
89
+ branch="content/landing", prompts=4, turns=4, tools=10, tokens=8800),
90
+ _session(now, 8, "Website", "Fix the mobile menu", "working", 4 + (elapsed % 6), model="claude-sonnet-5",
91
+ branch="fix/menu", prompts=2, turns=1, tools=14 + int(elapsed / 2) % 40, tokens=7300,
92
+ subagents=1, subagentsLive=1),
93
+ _session(now, 9, "Website", "Accessibility audit", "waiting", 50 * MIN + elapsed, model="",
94
+ prompts=3, turns=3, tools=33, tokens=27000, prs=[_pr(12), _pr(13), _pr(14)]),
95
+ _session(now, 14, "Orchard", "Port the importer to async", "waiting", 14 * MIN + elapsed, model="gpt-5.6-sol", agent="codex",
96
+ branch="feature/async-import", prompts=5, turns=5, tools=48, tokens=41000, subagents=2, subagentsLive=1,
97
+ lastPrompt="Port the importer to asyncio and keep the CLI flags the same."),
98
+ _session(now, 15, "Website", "Compress the hero images", "working", 6 + (elapsed % 8), model="gpt-6-astra", agent="codex",
99
+ branch="perf/images", prompts=2, turns=1, tools=9 + int(elapsed / 3) % 30, tokens=6200),
100
+ _session(now, 10, "home", "Tidy the dotfiles", "waiting", 5 * MIN + elapsed, model="claude-opus-5-5",
101
+ branch="", prompts=2, turns=2, tools=5, tokens=2400),
102
+ _session(now, 11, "home", "Session Saloon demo", "working", 2 + (elapsed % 5), model="claude-opus-5-5",
103
+ branch="", prompts=1, turns=0, tools=3 + int(elapsed / 3) % 30, tokens=1900),
104
+ _session(now, 12, "automations", "nightly-triage (headless)", "working", 20 + (elapsed % 30), model="claude-haiku-4-5",
105
+ headless=True, prompts=0, turns=0, tools=8 + int(elapsed / 4) % 20, tokens=3000, promptsToday=0),
106
+ _session(now, 13, "automations", "changelog-bot (headless)", "done", 2 * HOUR, model="claude-sonnet-5",
107
+ headless=True, prompts=1, turns=1, tools=17, tokens=23000, subagents=1),
108
+ ]
109
+ sessions.sort(key=lambda d: d["lastTs"], reverse=True)
110
+ return {"now": now, "hours": 72, "today": local_day(now), "sessions": sessions}
111
+
112
+
113
+ def make_demo():
114
+ t0 = time.time()
115
+ return lambda now: snapshot(now, t0)