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.
- session_saloon/__init__.py +3 -0
- session_saloon/__main__.py +103 -0
- session_saloon/codex.py +130 -0
- session_saloon/config.py +277 -0
- session_saloon/demo.py +115 -0
- session_saloon/projects.py +77 -0
- session_saloon/server.py +226 -0
- session_saloon/session.py +193 -0
- session_saloon/sources.py +152 -0
- session_saloon/static/fonts/OFL-PressStart2P.txt +93 -0
- session_saloon/static/fonts/OFL-VT323.txt +93 -0
- session_saloon/static/fonts/PressStart2P.woff2 +0 -0
- session_saloon/static/fonts/VT323.woff2 +0 -0
- session_saloon/static/index.html +1197 -0
- session_saloon/tracker.py +171 -0
- session_saloon/transcript.py +176 -0
- session_saloon-0.1.0.dist-info/METADATA +189 -0
- session_saloon-0.1.0.dist-info/RECORD +22 -0
- session_saloon-0.1.0.dist-info/WHEEL +5 -0
- session_saloon-0.1.0.dist-info/entry_points.txt +2 -0
- session_saloon-0.1.0.dist-info/licenses/LICENSE +24 -0
- session_saloon-0.1.0.dist-info/top_level.txt +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())
|
session_saloon/codex.py
ADDED
|
@@ -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
|
session_saloon/config.py
ADDED
|
@@ -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)
|