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.
Files changed (35) hide show
  1. session_saloon-0.1.0/LICENSE +24 -0
  2. session_saloon-0.1.0/PKG-INFO +189 -0
  3. session_saloon-0.1.0/README.md +172 -0
  4. session_saloon-0.1.0/pyproject.toml +35 -0
  5. session_saloon-0.1.0/setup.cfg +4 -0
  6. session_saloon-0.1.0/src/session_saloon/__init__.py +3 -0
  7. session_saloon-0.1.0/src/session_saloon/__main__.py +103 -0
  8. session_saloon-0.1.0/src/session_saloon/codex.py +130 -0
  9. session_saloon-0.1.0/src/session_saloon/config.py +277 -0
  10. session_saloon-0.1.0/src/session_saloon/demo.py +115 -0
  11. session_saloon-0.1.0/src/session_saloon/projects.py +77 -0
  12. session_saloon-0.1.0/src/session_saloon/server.py +226 -0
  13. session_saloon-0.1.0/src/session_saloon/session.py +193 -0
  14. session_saloon-0.1.0/src/session_saloon/sources.py +152 -0
  15. session_saloon-0.1.0/src/session_saloon/static/fonts/OFL-PressStart2P.txt +93 -0
  16. session_saloon-0.1.0/src/session_saloon/static/fonts/OFL-VT323.txt +93 -0
  17. session_saloon-0.1.0/src/session_saloon/static/fonts/PressStart2P.woff2 +0 -0
  18. session_saloon-0.1.0/src/session_saloon/static/fonts/VT323.woff2 +0 -0
  19. session_saloon-0.1.0/src/session_saloon/static/index.html +1197 -0
  20. session_saloon-0.1.0/src/session_saloon/tracker.py +171 -0
  21. session_saloon-0.1.0/src/session_saloon/transcript.py +176 -0
  22. session_saloon-0.1.0/src/session_saloon.egg-info/PKG-INFO +189 -0
  23. session_saloon-0.1.0/src/session_saloon.egg-info/SOURCES.txt +33 -0
  24. session_saloon-0.1.0/src/session_saloon.egg-info/dependency_links.txt +1 -0
  25. session_saloon-0.1.0/src/session_saloon.egg-info/entry_points.txt +2 -0
  26. session_saloon-0.1.0/src/session_saloon.egg-info/requires.txt +3 -0
  27. session_saloon-0.1.0/src/session_saloon.egg-info/top_level.txt +1 -0
  28. session_saloon-0.1.0/tests/test_cli.py +57 -0
  29. session_saloon-0.1.0/tests/test_codex.py +221 -0
  30. session_saloon-0.1.0/tests/test_projects_config.py +129 -0
  31. session_saloon-0.1.0/tests/test_server.py +207 -0
  32. session_saloon-0.1.0/tests/test_state.py +186 -0
  33. session_saloon-0.1.0/tests/test_static_page.py +64 -0
  34. session_saloon-0.1.0/tests/test_tracker.py +170 -0
  35. 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
+ ![Session Saloon running on the built-in demo data](docs/screenshots/demo.jpg)
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
+ ![Session Saloon running on the built-in demo data](docs/screenshots/demo.jpg)
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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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