session-saloon 0.1.0__tar.gz
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-0.1.0/LICENSE +24 -0
- session_saloon-0.1.0/PKG-INFO +189 -0
- session_saloon-0.1.0/README.md +172 -0
- session_saloon-0.1.0/pyproject.toml +35 -0
- session_saloon-0.1.0/setup.cfg +4 -0
- session_saloon-0.1.0/src/session_saloon/__init__.py +3 -0
- session_saloon-0.1.0/src/session_saloon/__main__.py +103 -0
- session_saloon-0.1.0/src/session_saloon/codex.py +130 -0
- session_saloon-0.1.0/src/session_saloon/config.py +277 -0
- session_saloon-0.1.0/src/session_saloon/demo.py +115 -0
- session_saloon-0.1.0/src/session_saloon/projects.py +77 -0
- session_saloon-0.1.0/src/session_saloon/server.py +226 -0
- session_saloon-0.1.0/src/session_saloon/session.py +193 -0
- session_saloon-0.1.0/src/session_saloon/sources.py +152 -0
- session_saloon-0.1.0/src/session_saloon/static/fonts/OFL-PressStart2P.txt +93 -0
- session_saloon-0.1.0/src/session_saloon/static/fonts/OFL-VT323.txt +93 -0
- session_saloon-0.1.0/src/session_saloon/static/fonts/PressStart2P.woff2 +0 -0
- session_saloon-0.1.0/src/session_saloon/static/fonts/VT323.woff2 +0 -0
- session_saloon-0.1.0/src/session_saloon/static/index.html +1197 -0
- session_saloon-0.1.0/src/session_saloon/tracker.py +171 -0
- session_saloon-0.1.0/src/session_saloon/transcript.py +176 -0
- session_saloon-0.1.0/src/session_saloon.egg-info/PKG-INFO +189 -0
- session_saloon-0.1.0/src/session_saloon.egg-info/SOURCES.txt +33 -0
- session_saloon-0.1.0/src/session_saloon.egg-info/dependency_links.txt +1 -0
- session_saloon-0.1.0/src/session_saloon.egg-info/entry_points.txt +2 -0
- session_saloon-0.1.0/src/session_saloon.egg-info/requires.txt +3 -0
- session_saloon-0.1.0/src/session_saloon.egg-info/top_level.txt +1 -0
- session_saloon-0.1.0/tests/test_cli.py +57 -0
- session_saloon-0.1.0/tests/test_codex.py +221 -0
- session_saloon-0.1.0/tests/test_projects_config.py +129 -0
- session_saloon-0.1.0/tests/test_server.py +207 -0
- session_saloon-0.1.0/tests/test_state.py +186 -0
- session_saloon-0.1.0/tests/test_static_page.py +64 -0
- session_saloon-0.1.0/tests/test_tracker.py +170 -0
- session_saloon-0.1.0/tests/test_transcript.py +171 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Session Saloon contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
The bundled fonts (Press Start 2P, VT323) are licensed under the SIL Open Font
|
|
24
|
+
License 1.1; see src/session_saloon/static/fonts/OFL-*.txt.
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: session-saloon
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A local, live dashboard that shows your Claude Code sessions as customers at a saloon bar.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: claude,claude-code,dashboard,sessions
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Topic :: Utilities
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest; extra == "dev"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# Session Saloon
|
|
19
|
+
|
|
20
|
+
A local, live dashboard for people who run many Claude Code (and Codex CLI) sessions at once. Every session is a
|
|
21
|
+
pixel-art customer at a saloon bar. Customers whose turn is running stand near the door, drinking.
|
|
22
|
+
Customers waiting for **you** walk down the bar toward the barman (that is you), and the longer they
|
|
23
|
+
wait, the closer they get. Below the bar, **The Tab** lists the same sessions with numbers and a
|
|
24
|
+
copy-to-clipboard resume command.
|
|
25
|
+
|
|
26
|
+
It answers one question at a glance: **which of my Claude sessions is waiting on me, and for how long?**
|
|
27
|
+
|
|
28
|
+

|
|
29
|
+
|
|
30
|
+
*The screenshot is `saloon --demo`: synthetic sessions, nothing real.*
|
|
31
|
+
|
|
32
|
+
## Install and run
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pipx install session-saloon # or: uvx session-saloon
|
|
36
|
+
saloon # serves on http://127.0.0.1:7317 and opens your browser
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Python 3.9 or newer, no runtime dependencies. Running `saloon` a second time just opens the browser on
|
|
40
|
+
the instance that is already running.
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
saloon --hours 24 # time window (default 72, 0 = no limit)
|
|
44
|
+
saloon --port 7400
|
|
45
|
+
saloon --no-open
|
|
46
|
+
saloon --dump # print the snapshot as JSON and exit
|
|
47
|
+
saloon --projects-dir PATH # Claude Code: default ~/.claude/projects (or $CLAUDE_CONFIG_DIR/projects)
|
|
48
|
+
saloon --codex-dir PATH # Codex CLI home: default $CODEX_HOME or ~/.codex (reads its sessions/ folder)
|
|
49
|
+
saloon --only claude # or --only codex: show one agent's sessions only
|
|
50
|
+
saloon --demo # synthetic, animated data that shows every state
|
|
51
|
+
saloon --version
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Double-click launchers.** Windows: `launchers/saloon.cmd` (put a shortcut to it in
|
|
55
|
+
`%APPDATA%\Microsoft\Windows\Start Menu\Programs`, or pin it to the taskbar). macOS: run
|
|
56
|
+
`pipx install session-saloon`, then drag a small Automator "Run Shell Script" app that runs `saloon`
|
|
57
|
+
into the Dock. Linux: add a `.desktop` file whose `Exec=` is `saloon`.
|
|
58
|
+
|
|
59
|
+
**Start at login.** Windows: put a shortcut to `saloon.cmd` (or to `saloon --no-open`) in
|
|
60
|
+
`shell:startup`. macOS: add a Login Item that runs `saloon --no-open`, or a LaunchAgent with
|
|
61
|
+
`ProgramArguments = [saloon, --no-open]`. Linux: a systemd user service with
|
|
62
|
+
`ExecStart=%h/.local/bin/saloon --no-open`. Then pin `http://127.0.0.1:7317/` as a browser tab; the tab
|
|
63
|
+
title shows `(3) Session Saloon` when three sessions wait on you.
|
|
64
|
+
|
|
65
|
+
## Reading the bar
|
|
66
|
+
|
|
67
|
+
| You see | It means |
|
|
68
|
+
|---|---|
|
|
69
|
+
| Near the door, drinking | Claude is working on that turn |
|
|
70
|
+
| Walking toward the barman | The turn ended and the session waits on you; the closer, the longer |
|
|
71
|
+
| Red `!` bubble, frown | Waiting 20 minutes or more (*late*) |
|
|
72
|
+
| `z z z`, dimmed | Waiting more than 2 hours (*dozing*); not counted in "waiting on you" |
|
|
73
|
+
| Head on the bar, `...18m` | Mid-turn but no transcript writes for 15 minutes (*stalled*): process gone, or stuck on a permission prompt |
|
|
74
|
+
| Top hat / crown / cap / beanie | Opus / Fable / Sonnet / Haiku |
|
|
75
|
+
| Robot | A headless (`sdk-cli`) session; it never waits on a human |
|
|
76
|
+
| Gold coins on the counter | Pull requests the session opened |
|
|
77
|
+
| Small mates | Its subagents (bright and bouncing while live) |
|
|
78
|
+
|
|
79
|
+
Hover a customer for details; click to pin it (its row in The Tab lights up); **double-click to serve**
|
|
80
|
+
it, which closes the session on the bar until you prompt it again. The same actions are in The Tab
|
|
81
|
+
(`SERVE` / `REOPEN`, `COPY`), reachable by keyboard.
|
|
82
|
+
|
|
83
|
+
## Claude Code and Codex
|
|
84
|
+
|
|
85
|
+
Both agents drink at the same bar. Codex sessions are tagged `codex` on The Tab, show the agent in the
|
|
86
|
+
hover card, and `COPY` gives `codex resume <id>` instead of `claude --resume <id>`.
|
|
87
|
+
|
|
88
|
+
| | Claude Code | Codex CLI |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| Transcripts | `~/.claude/projects/<project>/<id>.jsonl` | `~/.codex/sessions/YYYY/MM/DD/rollout-<time>-<id>.jsonl` |
|
|
91
|
+
| Turn finished | `turn_duration` marker (sometimes late, so a quiet text-only reply also counts) | `task_complete` (explicit, no quiet rule needed) |
|
|
92
|
+
| Interrupted | `[Request interrupted` text | `turn_aborted` |
|
|
93
|
+
| Your prompt | `user` line, minus injected reminders | completed `UserMessage` item |
|
|
94
|
+
| Subagents | files under `<id>/subagents/` | separate rollouts naming their parent; shown as small mates of the parent |
|
|
95
|
+
| Headless | `entrypoint: sdk-cli` | `codex exec`, MCP server and internal reviewers (the guardian) |
|
|
96
|
+
|
|
97
|
+
If a source folder does not exist it is skipped silently, so having only one of the two is fine.
|
|
98
|
+
`sources = ["claude"]` in the config file turns one off permanently.
|
|
99
|
+
|
|
100
|
+
## Configuration
|
|
101
|
+
|
|
102
|
+
Click the gear in the page, or edit the file:
|
|
103
|
+
|
|
104
|
+
* Linux/macOS: `~/.config/session-saloon/config.toml`
|
|
105
|
+
* Windows: `%APPDATA%\session-saloon\config.toml`
|
|
106
|
+
|
|
107
|
+
On Python 3.11+ this file is TOML, read with the standard library's `tomllib`. On Python 3.9
|
|
108
|
+
or 3.10 (no `tomllib`), write the same settings as JSON instead — the keys and nesting are
|
|
109
|
+
identical, just JSON syntax (`{"hours": 72, "thresholds": {...}, ...}`); a TOML-formatted file
|
|
110
|
+
is silently ignored on those versions rather than applied. The settings panel writes whichever
|
|
111
|
+
format your interpreter can read, so this only matters if you edit the file by hand.
|
|
112
|
+
|
|
113
|
+
```toml
|
|
114
|
+
hours = 72
|
|
115
|
+
shell = "auto" # resume command flavour: auto | powershell | posix
|
|
116
|
+
sources = ["claude", "codex"]
|
|
117
|
+
|
|
118
|
+
[thresholds] # seconds
|
|
119
|
+
quiet_end = 180 # text-only reply + this much quiet = waiting
|
|
120
|
+
stall_after = 900 # mid-turn + this much quiet = stalled
|
|
121
|
+
late_after = 1200
|
|
122
|
+
doze_after = 7200
|
|
123
|
+
subagent_live = 120
|
|
124
|
+
|
|
125
|
+
[projects.alias]
|
|
126
|
+
"pillar2-main" = "Pillar2" # rename, or map several folders to one lane
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Command-line flags override the file. Served marks live in your data directory
|
|
130
|
+
(`%LOCALAPPDATA%\session-saloon\served.json`, `~/.local/share/session-saloon/served.json`,
|
|
131
|
+
`~/Library/Application Support/session-saloon/served.json`).
|
|
132
|
+
|
|
133
|
+
## How states are decided
|
|
134
|
+
|
|
135
|
+
| State | Rule |
|
|
136
|
+
|---|---|
|
|
137
|
+
| `waiting` | The last event is a finished turn or an interrupt, or it is a text-only reply and the transcript has been quiet for more than `quiet_end` |
|
|
138
|
+
| `working` | Otherwise |
|
|
139
|
+
| `stalled` | Mid-turn and quiet for more than `stall_after` (a live subagent counts as activity) |
|
|
140
|
+
| `done` | Served by you and not prompted since; or a headless session that finished or went quiet |
|
|
141
|
+
|
|
142
|
+
The transcript formats are not public contracts. All knowledge of them lives in
|
|
143
|
+
`src/session_saloon/transcript.py` (Claude Code) and `codex.py` (Codex); unknown fields and malformed
|
|
144
|
+
lines are tolerated. The page also has a size setting (small by default, or normal) in the gear panel.
|
|
145
|
+
|
|
146
|
+
## Themes
|
|
147
|
+
|
|
148
|
+
The stage is skinnable: **Saloon** (default) and **Tiki Isle**, picked in the gear panel. A theme is
|
|
149
|
+
one self-contained object in `static/index.html` (palette + copy + a handful of drawing hooks) — see
|
|
150
|
+
`docs/decisions.md` for the interface and, notably, the reasoning for why "Tiki Isle" is its own
|
|
151
|
+
original design rather than a licensed-character reskin. See
|
|
152
|
+
[docs/decisions.md](docs/decisions.md) for the interpretations made along the way.
|
|
153
|
+
|
|
154
|
+
## Privacy
|
|
155
|
+
|
|
156
|
+
* Runs on your machine only. The server binds to `127.0.0.1`, never sends anything off the machine,
|
|
157
|
+
and has no telemetry. The page loads no third-party resources (fonts are bundled).
|
|
158
|
+
* Read-only toward Claude Code: it reads the transcripts Claude Code already writes and never modifies
|
|
159
|
+
them. The only thing it writes is the list of sessions you marked as served.
|
|
160
|
+
* `POST` endpoints only accept `application/json` (so browsers must preflight, which the server never
|
|
161
|
+
answers), a loopback `Host` header (DNS-rebinding protection), a 4 KB body and strict ids.
|
|
162
|
+
* Session titles and prompts are rendered with `textContent`, never `innerHTML`.
|
|
163
|
+
|
|
164
|
+
## Development
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
pip install -e .[dev]
|
|
168
|
+
pytest
|
|
169
|
+
python tests/fixture_builders.py # regenerate tests/fixtures (synthetic transcripts)
|
|
170
|
+
saloon --dump --projects-dir tests/fixtures/projects --hours 0 --now 1790596800
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
MIT licensed. Fonts: Press Start 2P and VT323, SIL Open Font License.
|
|
174
|
+
|
|
175
|
+
### Releasing (maintainers)
|
|
176
|
+
|
|
177
|
+
Publishing to PyPI uses [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC) —
|
|
178
|
+
no API token is stored as a repo secret. One-time setup on the PyPI project's *Publishing* page:
|
|
179
|
+
add a GitHub publisher for `wasigh/session-saloon`, workflow `release.yml`, environment `pypi`.
|
|
180
|
+
|
|
181
|
+
To ship a release: bump `version` in `pyproject.toml`, then
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
git tag v0.1.0
|
|
185
|
+
git push origin v0.1.0
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`.github/workflows/release.yml` builds the package, runs the test matrix, and publishes on a
|
|
189
|
+
green run. `.github/workflows/ci.yml` runs the same tests on every push and pull request.
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Session Saloon
|
|
2
|
+
|
|
3
|
+
A local, live dashboard for people who run many Claude Code (and Codex CLI) sessions at once. Every session is a
|
|
4
|
+
pixel-art customer at a saloon bar. Customers whose turn is running stand near the door, drinking.
|
|
5
|
+
Customers waiting for **you** walk down the bar toward the barman (that is you), and the longer they
|
|
6
|
+
wait, the closer they get. Below the bar, **The Tab** lists the same sessions with numbers and a
|
|
7
|
+
copy-to-clipboard resume command.
|
|
8
|
+
|
|
9
|
+
It answers one question at a glance: **which of my Claude sessions is waiting on me, and for how long?**
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
*The screenshot is `saloon --demo`: synthetic sessions, nothing real.*
|
|
14
|
+
|
|
15
|
+
## Install and run
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
pipx install session-saloon # or: uvx session-saloon
|
|
19
|
+
saloon # serves on http://127.0.0.1:7317 and opens your browser
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Python 3.9 or newer, no runtime dependencies. Running `saloon` a second time just opens the browser on
|
|
23
|
+
the instance that is already running.
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
saloon --hours 24 # time window (default 72, 0 = no limit)
|
|
27
|
+
saloon --port 7400
|
|
28
|
+
saloon --no-open
|
|
29
|
+
saloon --dump # print the snapshot as JSON and exit
|
|
30
|
+
saloon --projects-dir PATH # Claude Code: default ~/.claude/projects (or $CLAUDE_CONFIG_DIR/projects)
|
|
31
|
+
saloon --codex-dir PATH # Codex CLI home: default $CODEX_HOME or ~/.codex (reads its sessions/ folder)
|
|
32
|
+
saloon --only claude # or --only codex: show one agent's sessions only
|
|
33
|
+
saloon --demo # synthetic, animated data that shows every state
|
|
34
|
+
saloon --version
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**Double-click launchers.** Windows: `launchers/saloon.cmd` (put a shortcut to it in
|
|
38
|
+
`%APPDATA%\Microsoft\Windows\Start Menu\Programs`, or pin it to the taskbar). macOS: run
|
|
39
|
+
`pipx install session-saloon`, then drag a small Automator "Run Shell Script" app that runs `saloon`
|
|
40
|
+
into the Dock. Linux: add a `.desktop` file whose `Exec=` is `saloon`.
|
|
41
|
+
|
|
42
|
+
**Start at login.** Windows: put a shortcut to `saloon.cmd` (or to `saloon --no-open`) in
|
|
43
|
+
`shell:startup`. macOS: add a Login Item that runs `saloon --no-open`, or a LaunchAgent with
|
|
44
|
+
`ProgramArguments = [saloon, --no-open]`. Linux: a systemd user service with
|
|
45
|
+
`ExecStart=%h/.local/bin/saloon --no-open`. Then pin `http://127.0.0.1:7317/` as a browser tab; the tab
|
|
46
|
+
title shows `(3) Session Saloon` when three sessions wait on you.
|
|
47
|
+
|
|
48
|
+
## Reading the bar
|
|
49
|
+
|
|
50
|
+
| You see | It means |
|
|
51
|
+
|---|---|
|
|
52
|
+
| Near the door, drinking | Claude is working on that turn |
|
|
53
|
+
| Walking toward the barman | The turn ended and the session waits on you; the closer, the longer |
|
|
54
|
+
| Red `!` bubble, frown | Waiting 20 minutes or more (*late*) |
|
|
55
|
+
| `z z z`, dimmed | Waiting more than 2 hours (*dozing*); not counted in "waiting on you" |
|
|
56
|
+
| Head on the bar, `...18m` | Mid-turn but no transcript writes for 15 minutes (*stalled*): process gone, or stuck on a permission prompt |
|
|
57
|
+
| Top hat / crown / cap / beanie | Opus / Fable / Sonnet / Haiku |
|
|
58
|
+
| Robot | A headless (`sdk-cli`) session; it never waits on a human |
|
|
59
|
+
| Gold coins on the counter | Pull requests the session opened |
|
|
60
|
+
| Small mates | Its subagents (bright and bouncing while live) |
|
|
61
|
+
|
|
62
|
+
Hover a customer for details; click to pin it (its row in The Tab lights up); **double-click to serve**
|
|
63
|
+
it, which closes the session on the bar until you prompt it again. The same actions are in The Tab
|
|
64
|
+
(`SERVE` / `REOPEN`, `COPY`), reachable by keyboard.
|
|
65
|
+
|
|
66
|
+
## Claude Code and Codex
|
|
67
|
+
|
|
68
|
+
Both agents drink at the same bar. Codex sessions are tagged `codex` on The Tab, show the agent in the
|
|
69
|
+
hover card, and `COPY` gives `codex resume <id>` instead of `claude --resume <id>`.
|
|
70
|
+
|
|
71
|
+
| | Claude Code | Codex CLI |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| Transcripts | `~/.claude/projects/<project>/<id>.jsonl` | `~/.codex/sessions/YYYY/MM/DD/rollout-<time>-<id>.jsonl` |
|
|
74
|
+
| Turn finished | `turn_duration` marker (sometimes late, so a quiet text-only reply also counts) | `task_complete` (explicit, no quiet rule needed) |
|
|
75
|
+
| Interrupted | `[Request interrupted` text | `turn_aborted` |
|
|
76
|
+
| Your prompt | `user` line, minus injected reminders | completed `UserMessage` item |
|
|
77
|
+
| Subagents | files under `<id>/subagents/` | separate rollouts naming their parent; shown as small mates of the parent |
|
|
78
|
+
| Headless | `entrypoint: sdk-cli` | `codex exec`, MCP server and internal reviewers (the guardian) |
|
|
79
|
+
|
|
80
|
+
If a source folder does not exist it is skipped silently, so having only one of the two is fine.
|
|
81
|
+
`sources = ["claude"]` in the config file turns one off permanently.
|
|
82
|
+
|
|
83
|
+
## Configuration
|
|
84
|
+
|
|
85
|
+
Click the gear in the page, or edit the file:
|
|
86
|
+
|
|
87
|
+
* Linux/macOS: `~/.config/session-saloon/config.toml`
|
|
88
|
+
* Windows: `%APPDATA%\session-saloon\config.toml`
|
|
89
|
+
|
|
90
|
+
On Python 3.11+ this file is TOML, read with the standard library's `tomllib`. On Python 3.9
|
|
91
|
+
or 3.10 (no `tomllib`), write the same settings as JSON instead — the keys and nesting are
|
|
92
|
+
identical, just JSON syntax (`{"hours": 72, "thresholds": {...}, ...}`); a TOML-formatted file
|
|
93
|
+
is silently ignored on those versions rather than applied. The settings panel writes whichever
|
|
94
|
+
format your interpreter can read, so this only matters if you edit the file by hand.
|
|
95
|
+
|
|
96
|
+
```toml
|
|
97
|
+
hours = 72
|
|
98
|
+
shell = "auto" # resume command flavour: auto | powershell | posix
|
|
99
|
+
sources = ["claude", "codex"]
|
|
100
|
+
|
|
101
|
+
[thresholds] # seconds
|
|
102
|
+
quiet_end = 180 # text-only reply + this much quiet = waiting
|
|
103
|
+
stall_after = 900 # mid-turn + this much quiet = stalled
|
|
104
|
+
late_after = 1200
|
|
105
|
+
doze_after = 7200
|
|
106
|
+
subagent_live = 120
|
|
107
|
+
|
|
108
|
+
[projects.alias]
|
|
109
|
+
"pillar2-main" = "Pillar2" # rename, or map several folders to one lane
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Command-line flags override the file. Served marks live in your data directory
|
|
113
|
+
(`%LOCALAPPDATA%\session-saloon\served.json`, `~/.local/share/session-saloon/served.json`,
|
|
114
|
+
`~/Library/Application Support/session-saloon/served.json`).
|
|
115
|
+
|
|
116
|
+
## How states are decided
|
|
117
|
+
|
|
118
|
+
| State | Rule |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `waiting` | The last event is a finished turn or an interrupt, or it is a text-only reply and the transcript has been quiet for more than `quiet_end` |
|
|
121
|
+
| `working` | Otherwise |
|
|
122
|
+
| `stalled` | Mid-turn and quiet for more than `stall_after` (a live subagent counts as activity) |
|
|
123
|
+
| `done` | Served by you and not prompted since; or a headless session that finished or went quiet |
|
|
124
|
+
|
|
125
|
+
The transcript formats are not public contracts. All knowledge of them lives in
|
|
126
|
+
`src/session_saloon/transcript.py` (Claude Code) and `codex.py` (Codex); unknown fields and malformed
|
|
127
|
+
lines are tolerated. The page also has a size setting (small by default, or normal) in the gear panel.
|
|
128
|
+
|
|
129
|
+
## Themes
|
|
130
|
+
|
|
131
|
+
The stage is skinnable: **Saloon** (default) and **Tiki Isle**, picked in the gear panel. A theme is
|
|
132
|
+
one self-contained object in `static/index.html` (palette + copy + a handful of drawing hooks) — see
|
|
133
|
+
`docs/decisions.md` for the interface and, notably, the reasoning for why "Tiki Isle" is its own
|
|
134
|
+
original design rather than a licensed-character reskin. See
|
|
135
|
+
[docs/decisions.md](docs/decisions.md) for the interpretations made along the way.
|
|
136
|
+
|
|
137
|
+
## Privacy
|
|
138
|
+
|
|
139
|
+
* Runs on your machine only. The server binds to `127.0.0.1`, never sends anything off the machine,
|
|
140
|
+
and has no telemetry. The page loads no third-party resources (fonts are bundled).
|
|
141
|
+
* Read-only toward Claude Code: it reads the transcripts Claude Code already writes and never modifies
|
|
142
|
+
them. The only thing it writes is the list of sessions you marked as served.
|
|
143
|
+
* `POST` endpoints only accept `application/json` (so browsers must preflight, which the server never
|
|
144
|
+
answers), a loopback `Host` header (DNS-rebinding protection), a 4 KB body and strict ids.
|
|
145
|
+
* Session titles and prompts are rendered with `textContent`, never `innerHTML`.
|
|
146
|
+
|
|
147
|
+
## Development
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
pip install -e .[dev]
|
|
151
|
+
pytest
|
|
152
|
+
python tests/fixture_builders.py # regenerate tests/fixtures (synthetic transcripts)
|
|
153
|
+
saloon --dump --projects-dir tests/fixtures/projects --hours 0 --now 1790596800
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
MIT licensed. Fonts: Press Start 2P and VT323, SIL Open Font License.
|
|
157
|
+
|
|
158
|
+
### Releasing (maintainers)
|
|
159
|
+
|
|
160
|
+
Publishing to PyPI uses [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC) —
|
|
161
|
+
no API token is stored as a repo secret. One-time setup on the PyPI project's *Publishing* page:
|
|
162
|
+
add a GitHub publisher for `wasigh/session-saloon`, workflow `release.yml`, environment `pypi`.
|
|
163
|
+
|
|
164
|
+
To ship a release: bump `version` in `pyproject.toml`, then
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
git tag v0.1.0
|
|
168
|
+
git push origin v0.1.0
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`.github/workflows/release.yml` builds the package, runs the test matrix, and publishes on a
|
|
172
|
+
green run. `.github/workflows/ci.yml` runs the same tests on every push and pull request.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "session-saloon"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A local, live dashboard that shows your Claude Code sessions as customers at a saloon bar."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = []
|
|
13
|
+
keywords = ["claude", "claude-code", "dashboard", "sessions"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Topic :: Utilities",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = ["pytest"]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
saloon = "session_saloon.__main__:main"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["src"]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.package-data]
|
|
31
|
+
session_saloon = ["static/*.html", "static/fonts/*"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
35
|
+
pythonpath = ["src"]
|
|
@@ -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
|