sessionpulse 0.1.2__tar.gz → 0.2.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.
- {sessionpulse-0.1.2/src/sessionpulse.egg-info → sessionpulse-0.2.0}/PKG-INFO +27 -1
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/README.md +26 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/pyproject.toml +1 -1
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse/__init__.py +1 -1
- sessionpulse-0.2.0/src/sessionpulse/cli.py +351 -0
- sessionpulse-0.2.0/src/sessionpulse/followups.py +600 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse/install.py +3 -2
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse/server.py +36 -1
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse/static/dashboard.html +178 -27
- sessionpulse-0.2.0/src/sessionpulse/status.py +96 -0
- sessionpulse-0.2.0/src/sessionpulse/todos.py +463 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse/tray.py +2 -2
- {sessionpulse-0.1.2 → sessionpulse-0.2.0/src/sessionpulse.egg-info}/PKG-INFO +27 -1
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse.egg-info/SOURCES.txt +1 -0
- sessionpulse-0.2.0/tests/test_sessionpulse.py +1010 -0
- sessionpulse-0.1.2/src/sessionpulse/cli.py +0 -187
- sessionpulse-0.1.2/src/sessionpulse/followups.py +0 -273
- sessionpulse-0.1.2/src/sessionpulse/todos.py +0 -163
- sessionpulse-0.1.2/tests/test_sessionpulse.py +0 -492
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/LICENSE +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/setup.cfg +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse/__main__.py +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse/config.py +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse/hook.py +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse/icons/sessionpulse-busy.svg +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse/icons/sessionpulse-idle.svg +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse/icons/sessionpulse-none.svg +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse/learn.py +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse/reader.py +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse/static/brand/favicon.svg +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse/static/brand/session-pulse-idle-animated.svg +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse/static/brand/session-pulse-idle-static.svg +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse/static/brand/session-pulse-none-animated.svg +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse/static/brand/session-pulse-none-static.svg +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse/static/brand/session-pulse-working-animated.svg +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse/static/brand/session-pulse-working-static.svg +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse.egg-info/dependency_links.txt +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse.egg-info/entry_points.txt +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse.egg-info/requires.txt +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.2.0}/src/sessionpulse.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sessionpulse
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A quiet instrument panel for Claude Code: live sessions, usage, and a to-do board that fills itself from your sessions. Local only.
|
|
5
5
|
Author: Vijay Shukla
|
|
6
6
|
License-Expression: MIT
|
|
@@ -85,6 +85,7 @@ It reads the files Claude Code already writes under `~/.claude`. **Nothing leave
|
|
|
85
85
|
|
|
86
86
|
- **Live sessions:** working or idle, running time, last change, tokens, model. The panel icon shows the count and turns orange while Claude is working.
|
|
87
87
|
- **Follow-ups (optional):** Claude reads each session after 20 quiet minutes and adds what was left open to your to-do list, linked back to the session, with a one-click resume command. A later session that finishes an item closes it automatically.
|
|
88
|
+
- **Why is this here?** Each follow-up keeps a short quote from the session, checked against the transcript; **Why?** shows it in context. The same work showing up in another project is flagged, with **Merge** and **Not the same**.
|
|
88
89
|
- **Board:** every to-do as a card, colour-coded by kind (Decide, Outside work, Waiting on, Checklist, Later), or by status (To do, Doing, Done). Drag and drop, project filter, search.
|
|
89
90
|
- **Your own to-dos too:** add them on the dashboard or with `sessionpulse todo add "…"`.
|
|
90
91
|
- **Usage:** input, output and cache tokens per day, split by model; active hours per day; busiest projects.
|
|
@@ -154,6 +155,27 @@ sessionpulse followups # read the last 30 days now instead of waiting
|
|
|
154
155
|
|
|
155
156
|
Check everything with `sessionpulse doctor`. Remove it with `sessionpulse uninstall` (your data stays) and `pipx uninstall sessionpulse`.
|
|
156
157
|
|
|
158
|
+
## GNOME extension
|
|
159
|
+
|
|
160
|
+
A native top-bar indicator for GNOME Shell 45 to 48, as an alternative to the AppIndicator panel icon. It shows the same thing: the number of open sessions, orange while one is working, plus a menu with each session, today's totals, and links to the dashboard and the to-do board.
|
|
161
|
+
|
|
162
|
+
The extension only reads `~/.local/share/sessionpulse/status.json` ([format](https://github.com/vijayshuklaHL/sessionpulse/blob/main/docs/status-json.md)), which `sessionpulse daemon` writes, so the app has to be installed too:
|
|
163
|
+
|
|
164
|
+
```sh
|
|
165
|
+
pipx install sessionpulse
|
|
166
|
+
sessionpulse install --daemon # hooks, plus the daemon (no icon) at login
|
|
167
|
+
sessionpulse daemon & # start it now
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Then install the extension. It isn't on extensions.gnome.org yet, so install it from a clone of this repo:
|
|
171
|
+
|
|
172
|
+
```sh
|
|
173
|
+
gnome-extension/pack.sh
|
|
174
|
+
gnome-extensions install --force gnome-extension/dist/sessionpulse@vijayshuklaHL.github.io.shell-extension.zip
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Log out and back in (on X11 you can press Alt+F2 and type `r` instead), then run `gnome-extensions enable sessionpulse@vijayshuklaHL.github.io`. If you used the panel icon before, stop it: run `sessionpulse install --daemon` again so login starts the daemon instead of the tray.
|
|
178
|
+
|
|
157
179
|
## Commands
|
|
158
180
|
|
|
159
181
|
| Command | What it does |
|
|
@@ -162,10 +184,14 @@ Check everything with `sessionpulse doctor`. Remove it with `sessionpulse uninst
|
|
|
162
184
|
| `sessionpulse uninstall` | Removes exactly those hooks and the autostart entry. Your data stays. |
|
|
163
185
|
| `sessionpulse tray` | Panel icon and dashboard server |
|
|
164
186
|
| `sessionpulse serve` | Dashboard server only |
|
|
187
|
+
| `sessionpulse daemon` | Dashboard server plus `status.json` for panel widgets such as the GNOME extension, no icon (`install --daemon` starts it at login) |
|
|
165
188
|
| `sessionpulse open` | Opens the dashboard in your browser, signed in (add `#board` to the address for the board) |
|
|
166
189
|
| `sessionpulse doctor` | Checks the setup and warns if Claude Code's file formats look different |
|
|
167
190
|
| `sessionpulse todo [add TEXT -p PROJECT \| done ID \| undo ID \| delete ID \| list-done]` | Your to-do list from the terminal |
|
|
191
|
+
| `sessionpulse todo export [--format md\|csv\|json] [-p PROJECT] [--status open\|all]` | Prints the list as Markdown (grouped by project), CSV or JSON; the board's Export menu downloads the same ([fields](https://github.com/vijayshuklaHL/sessionpulse/blob/main/docs/todos-json.md)) |
|
|
168
192
|
| `sessionpulse followups [--days N] [--session ID] [--now] [--again]` | Read recent sessions now and add their open follow-ups |
|
|
193
|
+
| `sessionpulse followups status` | What follow-ups used (calls, tokens, cost), results per kind, and sessions that failed |
|
|
194
|
+
| `sessionpulse dedupe [-p PROJECT] [--across] [--apply]` | Finds open to-dos that are the same work reworded (`--across`: in different projects); `--apply` archives the copies |
|
|
169
195
|
| `sessionpulse snapshot` | Prints the dashboard data as JSON |
|
|
170
196
|
|
|
171
197
|
## Settings
|
|
@@ -57,6 +57,7 @@ It reads the files Claude Code already writes under `~/.claude`. **Nothing leave
|
|
|
57
57
|
|
|
58
58
|
- **Live sessions:** working or idle, running time, last change, tokens, model. The panel icon shows the count and turns orange while Claude is working.
|
|
59
59
|
- **Follow-ups (optional):** Claude reads each session after 20 quiet minutes and adds what was left open to your to-do list, linked back to the session, with a one-click resume command. A later session that finishes an item closes it automatically.
|
|
60
|
+
- **Why is this here?** Each follow-up keeps a short quote from the session, checked against the transcript; **Why?** shows it in context. The same work showing up in another project is flagged, with **Merge** and **Not the same**.
|
|
60
61
|
- **Board:** every to-do as a card, colour-coded by kind (Decide, Outside work, Waiting on, Checklist, Later), or by status (To do, Doing, Done). Drag and drop, project filter, search.
|
|
61
62
|
- **Your own to-dos too:** add them on the dashboard or with `sessionpulse todo add "…"`.
|
|
62
63
|
- **Usage:** input, output and cache tokens per day, split by model; active hours per day; busiest projects.
|
|
@@ -126,6 +127,27 @@ sessionpulse followups # read the last 30 days now instead of waiting
|
|
|
126
127
|
|
|
127
128
|
Check everything with `sessionpulse doctor`. Remove it with `sessionpulse uninstall` (your data stays) and `pipx uninstall sessionpulse`.
|
|
128
129
|
|
|
130
|
+
## GNOME extension
|
|
131
|
+
|
|
132
|
+
A native top-bar indicator for GNOME Shell 45 to 48, as an alternative to the AppIndicator panel icon. It shows the same thing: the number of open sessions, orange while one is working, plus a menu with each session, today's totals, and links to the dashboard and the to-do board.
|
|
133
|
+
|
|
134
|
+
The extension only reads `~/.local/share/sessionpulse/status.json` ([format](https://github.com/vijayshuklaHL/sessionpulse/blob/main/docs/status-json.md)), which `sessionpulse daemon` writes, so the app has to be installed too:
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
pipx install sessionpulse
|
|
138
|
+
sessionpulse install --daemon # hooks, plus the daemon (no icon) at login
|
|
139
|
+
sessionpulse daemon & # start it now
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Then install the extension. It isn't on extensions.gnome.org yet, so install it from a clone of this repo:
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
gnome-extension/pack.sh
|
|
146
|
+
gnome-extensions install --force gnome-extension/dist/sessionpulse@vijayshuklaHL.github.io.shell-extension.zip
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Log out and back in (on X11 you can press Alt+F2 and type `r` instead), then run `gnome-extensions enable sessionpulse@vijayshuklaHL.github.io`. If you used the panel icon before, stop it: run `sessionpulse install --daemon` again so login starts the daemon instead of the tray.
|
|
150
|
+
|
|
129
151
|
## Commands
|
|
130
152
|
|
|
131
153
|
| Command | What it does |
|
|
@@ -134,10 +156,14 @@ Check everything with `sessionpulse doctor`. Remove it with `sessionpulse uninst
|
|
|
134
156
|
| `sessionpulse uninstall` | Removes exactly those hooks and the autostart entry. Your data stays. |
|
|
135
157
|
| `sessionpulse tray` | Panel icon and dashboard server |
|
|
136
158
|
| `sessionpulse serve` | Dashboard server only |
|
|
159
|
+
| `sessionpulse daemon` | Dashboard server plus `status.json` for panel widgets such as the GNOME extension, no icon (`install --daemon` starts it at login) |
|
|
137
160
|
| `sessionpulse open` | Opens the dashboard in your browser, signed in (add `#board` to the address for the board) |
|
|
138
161
|
| `sessionpulse doctor` | Checks the setup and warns if Claude Code's file formats look different |
|
|
139
162
|
| `sessionpulse todo [add TEXT -p PROJECT \| done ID \| undo ID \| delete ID \| list-done]` | Your to-do list from the terminal |
|
|
163
|
+
| `sessionpulse todo export [--format md\|csv\|json] [-p PROJECT] [--status open\|all]` | Prints the list as Markdown (grouped by project), CSV or JSON; the board's Export menu downloads the same ([fields](https://github.com/vijayshuklaHL/sessionpulse/blob/main/docs/todos-json.md)) |
|
|
140
164
|
| `sessionpulse followups [--days N] [--session ID] [--now] [--again]` | Read recent sessions now and add their open follow-ups |
|
|
165
|
+
| `sessionpulse followups status` | What follow-ups used (calls, tokens, cost), results per kind, and sessions that failed |
|
|
166
|
+
| `sessionpulse dedupe [-p PROJECT] [--across] [--apply]` | Finds open to-dos that are the same work reworded (`--across`: in different projects); `--apply` archives the copies |
|
|
141
167
|
| `sessionpulse snapshot` | Prints the dashboard data as JSON |
|
|
142
168
|
|
|
143
169
|
## Settings
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sessionpulse"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "A quiet instrument panel for Claude Code: live sessions, usage, and a to-do board that fills itself from your sessions. Local only."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Command line: sessionpulse <command>."""
|
|
2
|
+
import argparse
|
|
3
|
+
import getpass
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import socket
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import webbrowser
|
|
12
|
+
from collections import Counter
|
|
13
|
+
|
|
14
|
+
from . import __version__, config, followups, hook, install, reader, server, status, todos, tray
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def headless():
|
|
18
|
+
"""True on a Linux/BSD box with no desktop session, e.g. a server you reach over SSH."""
|
|
19
|
+
return (sys.platform not in ("darwin", "win32")
|
|
20
|
+
and not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def remote_hint(cfg):
|
|
24
|
+
"""How to reach the dashboard from another computer through an SSH tunnel."""
|
|
25
|
+
host = f"{getpass.getuser()}@{socket.gethostname()}"
|
|
26
|
+
return (f"No desktop here, so open it from your own computer through an SSH tunnel:\n"
|
|
27
|
+
f" ssh -N -L {cfg.port}:127.0.0.1:{cfg.port} {host}\n"
|
|
28
|
+
f"then browse to:\n {server.open_url(cfg)}\n"
|
|
29
|
+
f"(Use the address you normally ssh to. If port {cfg.port} is busy on your computer, "
|
|
30
|
+
f"use another local port, e.g. -L 18765:127.0.0.1:{cfg.port}, and change it in the link.)")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def cmd_serve(cfg, args):
|
|
34
|
+
try:
|
|
35
|
+
srv = server.make_server(cfg)
|
|
36
|
+
except OSError as exc:
|
|
37
|
+
raise SystemExit(f"Can't serve on port {cfg.port}: {exc.strerror or exc}. Something else is using it "
|
|
38
|
+
f"(another `sessionpulse serve`/`tray`?); stop that, or set `port` in {config.CONFIG_FILE}.")
|
|
39
|
+
followups.start_worker(cfg)
|
|
40
|
+
print(f"Session Pulse on {cfg.url}\n" + (remote_hint(cfg) if headless() else "Open it with: sessionpulse open"),
|
|
41
|
+
flush=True)
|
|
42
|
+
try:
|
|
43
|
+
srv.serve_forever()
|
|
44
|
+
except KeyboardInterrupt:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_daemon(cfg, args):
|
|
49
|
+
"""Dashboard server + follow-ups + status.json, no icon: for the GNOME extension."""
|
|
50
|
+
try:
|
|
51
|
+
srv = server.make_server(cfg)
|
|
52
|
+
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
53
|
+
followups.start_worker(cfg)
|
|
54
|
+
print(f"Session Pulse on {cfg.url}", flush=True)
|
|
55
|
+
except OSError:
|
|
56
|
+
print(f"Port {cfg.port} is already in use (another `sessionpulse serve`/`tray`?); "
|
|
57
|
+
"writing status.json only.", flush=True)
|
|
58
|
+
print(f"Writing {status.path(cfg)} every {status.INTERVAL}s", flush=True)
|
|
59
|
+
stop = threading.Event()
|
|
60
|
+
for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
|
|
61
|
+
signal.signal(sig, lambda *_: stop.set())
|
|
62
|
+
status.run(cfg, stop, on_error=lambda exc: print(f"status.json not written: {exc}",
|
|
63
|
+
file=sys.stderr, flush=True))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_tray(cfg, args):
|
|
67
|
+
tray.run(cfg)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cmd_open(cfg, args):
|
|
71
|
+
if headless() or not webbrowser.open(server.open_url(cfg)):
|
|
72
|
+
print(remote_hint(cfg) if headless() else f"Couldn't start a browser. Open:\n {server.open_url(cfg)}")
|
|
73
|
+
with socket.socket() as s:
|
|
74
|
+
if s.connect_ex(("127.0.0.1", cfg.port)) != 0:
|
|
75
|
+
print("Note: the dashboard isn't running. Start it with `sessionpulse serve` (or `sessionpulse tray`).")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def cmd_hook(cfg, args):
|
|
79
|
+
return hook.run()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_install(cfg, args):
|
|
83
|
+
try:
|
|
84
|
+
changed = install.install_hooks(cfg)
|
|
85
|
+
except install.SettingsError as exc:
|
|
86
|
+
sys.exit(f"error: {exc}")
|
|
87
|
+
print(("Added" if changed else "Already present:") +
|
|
88
|
+
f" SessionStart/SessionEnd hooks in {cfg.claude / 'settings.json'}")
|
|
89
|
+
if args.no_autostart:
|
|
90
|
+
return
|
|
91
|
+
if args.daemon:
|
|
92
|
+
print(f"Background daemon will start at login ({install.enable_autostart('daemon')}).")
|
|
93
|
+
print("Start it now with: sessionpulse daemon &")
|
|
94
|
+
return
|
|
95
|
+
missing = tray.available()
|
|
96
|
+
if missing:
|
|
97
|
+
print(f"Skipped login autostart: {missing}\nYou can still run: sessionpulse serve")
|
|
98
|
+
return
|
|
99
|
+
print(f"Panel icon will start at login ({install.enable_autostart()}).")
|
|
100
|
+
print("Start it now with: sessionpulse tray &")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def cmd_uninstall(cfg, args):
|
|
104
|
+
try:
|
|
105
|
+
changed = install.uninstall_hooks(cfg)
|
|
106
|
+
except install.SettingsError as exc:
|
|
107
|
+
sys.exit(f"error: {exc}")
|
|
108
|
+
print("Removed Session Pulse hooks." if changed else "No Session Pulse hooks were installed.")
|
|
109
|
+
if install.disable_autostart():
|
|
110
|
+
print("Removed login autostart.")
|
|
111
|
+
print(f"Your data is kept in {cfg.data}; delete that folder to remove it.")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_doctor(cfg, args):
|
|
115
|
+
ok = True
|
|
116
|
+
|
|
117
|
+
def line(good, text):
|
|
118
|
+
nonlocal ok
|
|
119
|
+
ok &= bool(good)
|
|
120
|
+
print(f" {'ok ' if good else 'FIX'} {text}")
|
|
121
|
+
|
|
122
|
+
print(f"Session Pulse {__version__}")
|
|
123
|
+
line(cfg.claude.is_dir(), f"Claude Code folder: {cfg.claude}")
|
|
124
|
+
line((cfg.claude / "sessions").is_dir(), "live-session files (sessions/)")
|
|
125
|
+
snap = reader.snapshot(cfg)
|
|
126
|
+
h = snap["health"]
|
|
127
|
+
line(h["files"] > 0, f"{h['files']} transcripts, {len(snap['sessions'])} sessions, "
|
|
128
|
+
f"newest Claude Code {h['newest_cli'] or '?'}")
|
|
129
|
+
try:
|
|
130
|
+
inst = install.hooks_installed(cfg)
|
|
131
|
+
line(inst == set(install.EVENTS), "start/exit hooks installed" if inst == set(install.EVENTS)
|
|
132
|
+
else "start/exit hooks missing: run `sessionpulse install`")
|
|
133
|
+
except install.SettingsError as exc:
|
|
134
|
+
line(False, str(exc))
|
|
135
|
+
if headless():
|
|
136
|
+
print(" -- no desktop session: the panel icon and login autostart don't apply here. "
|
|
137
|
+
"Run `sessionpulse serve` (see \"On a server\" in the README) and use `sessionpulse open` "
|
|
138
|
+
"for the SSH tunnel link.")
|
|
139
|
+
else:
|
|
140
|
+
missing = tray.available()
|
|
141
|
+
print(f" {'ok ' if not missing else '-- '} {'panel icon available' if not missing else missing}")
|
|
142
|
+
with socket.socket() as s:
|
|
143
|
+
busy = s.connect_ex(("127.0.0.1", cfg.port)) == 0
|
|
144
|
+
print(f" {'ok ' if busy else '-- '} dashboard {'running' if busy else 'not running'} on {cfg.url}"
|
|
145
|
+
+ ("" if busy else ": start it with `sessionpulse serve`"))
|
|
146
|
+
a = status.age(cfg)
|
|
147
|
+
if a is not None:
|
|
148
|
+
fresh = a < status.INTERVAL * 3
|
|
149
|
+
print(f" {'ok ' if fresh else '-- '} status.json {'updated' if fresh else 'stale, last updated'} "
|
|
150
|
+
f"{int(a)}s ago{'' if fresh else ': is `sessionpulse daemon` running?'}")
|
|
151
|
+
bad = followups.failing(cfg)
|
|
152
|
+
if bad:
|
|
153
|
+
sid, e = bad[0]
|
|
154
|
+
print(f" -- {len(bad)} session(s) failed when read for follow-ups; the latest error: "
|
|
155
|
+
f"{e.get('last_error')}. See `sessionpulse followups status`.")
|
|
156
|
+
week = followups.usage_totals(cfg, 7)
|
|
157
|
+
if week["calls"]:
|
|
158
|
+
print(f" ok follow-ups used {week['calls']} model calls, {tokens(week['tokens'])} tokens in the last 7 days")
|
|
159
|
+
for w in h["warnings"]:
|
|
160
|
+
line(False, w)
|
|
161
|
+
return 0 if ok else 1
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def tokens(n):
|
|
165
|
+
return f"{n / 1e6:.1f}M" if n >= 1e6 else f"{n / 1e3:.0f}k" if n >= 1e3 else str(n)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def cmd_snapshot(cfg, args):
|
|
169
|
+
json.dump(reader.snapshot(cfg), sys.stdout, indent=1)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def cmd_todo(cfg, args):
|
|
173
|
+
if args.action == "export":
|
|
174
|
+
try:
|
|
175
|
+
sys.stdout.write(todos.export(todos.load(cfg), args.format, args.project, args.status))
|
|
176
|
+
except ValueError as exc:
|
|
177
|
+
sys.exit(f"error: {exc}")
|
|
178
|
+
return
|
|
179
|
+
try:
|
|
180
|
+
if args.action == "add":
|
|
181
|
+
todos.apply(cfg, {"op": "add", "text": " ".join(args.text), "project": args.project})
|
|
182
|
+
elif args.action in ("done", "undo", "delete"):
|
|
183
|
+
for i in args.text:
|
|
184
|
+
todos.apply(cfg, {"op": args.action, "id": i})
|
|
185
|
+
except ValueError as exc:
|
|
186
|
+
sys.exit(f"error: {exc}")
|
|
187
|
+
items = todos.load(cfg)
|
|
188
|
+
want = (lambda i: i["done_at"]) if args.action == "list-done" else todos.is_open
|
|
189
|
+
show = [i for i in items if want(i)]
|
|
190
|
+
for i in show:
|
|
191
|
+
tag = f" [{i['project']}]" if i.get("project") else ""
|
|
192
|
+
when = f" done {i['done_at'][:10]}" if i["done_at"] else ""
|
|
193
|
+
print(f"{i['id']} {i['text']}{tag}{when}")
|
|
194
|
+
parked = [i for i in items if not i["done_at"] and i.get("status") in todos.STATUSES]
|
|
195
|
+
if parked and args.action != "list-done":
|
|
196
|
+
held = sum(i["status"] == "hold" for i in parked)
|
|
197
|
+
print(f"({held} on hold, {len(parked) - held} archived; see the dashboard board)")
|
|
198
|
+
if not show:
|
|
199
|
+
print("Nothing here." if args.action == "list-done" else "No open to-dos.")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def followups_status(cfg):
|
|
203
|
+
print(f"Follow-ups are {'on' if cfg.followups else 'off (add `followups = true` to config.toml)'}; "
|
|
204
|
+
f"model {cfg.followups_model}.")
|
|
205
|
+
st = followups.load_state(cfg)
|
|
206
|
+
read = [e for e in st.values() if isinstance(e, dict) and e.get("at")]
|
|
207
|
+
print(f"Sessions read: {len(read)}, adding {sum(e.get('added') or 0 for e in read)} "
|
|
208
|
+
f"and closing {sum(e.get('closed') or 0 for e in read)} to-dos.")
|
|
209
|
+
print("Model use (the cost is what the API would charge; on a subscription it counts toward your limits):")
|
|
210
|
+
for label, days in (("today", 1), ("7 days", 7), ("30 days", 30)):
|
|
211
|
+
u = followups.usage_totals(cfg, days)
|
|
212
|
+
print(f" {label:8} {u['calls']:4} calls {tokens(u['tokens']):>6} tokens "
|
|
213
|
+
f"({tokens(u['output_tokens'])} written) ${u['cost_usd']:.2f}")
|
|
214
|
+
kinds = todos.kind_stats(cfg)
|
|
215
|
+
if kinds:
|
|
216
|
+
print("By kind (dismissed items count as added; \"noise\" = dismissed as not useful):")
|
|
217
|
+
print(f" {'':10} {'added':>6} {'open':>6} {'done':>6} {'parked':>6} {'noise':>6} {'other':>6}")
|
|
218
|
+
for k in (*todos.KINDS, "unknown"):
|
|
219
|
+
if k in kinds:
|
|
220
|
+
r = kinds[k]
|
|
221
|
+
print(f" {k:10} " + " ".join(f"{r[c]:>6}" for c in ("added", "open", "done", "parked",
|
|
222
|
+
"not_useful", "dismissed")))
|
|
223
|
+
bad = followups.failing(cfg)
|
|
224
|
+
if not bad:
|
|
225
|
+
print("No session is failing.")
|
|
226
|
+
return
|
|
227
|
+
print(f"{len(bad)} session(s) failed when last read:")
|
|
228
|
+
for sid, e in bad:
|
|
229
|
+
when = f"next try after {e['next_try'][:16].replace('T', ' ')}" if e.get("next_try") else \
|
|
230
|
+
"waiting until the session grows"
|
|
231
|
+
print(f" {sid[:8]} {e.get('project') or '?'}: {e['fails']}x, {when}\n {e.get('last_error')}")
|
|
232
|
+
print("Retry one now with `sessionpulse followups --session <id>`.")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def cmd_followups(cfg, args):
|
|
236
|
+
if args.action == "status":
|
|
237
|
+
return followups_status(cfg)
|
|
238
|
+
if not followups.claude_bin(cfg):
|
|
239
|
+
sys.exit("error: the `claude` command was not found; set claude_command in config.toml")
|
|
240
|
+
try:
|
|
241
|
+
n, added, closed = followups.scan(cfg, days=args.days, idle_minutes=0 if args.now else None,
|
|
242
|
+
session=args.session, force=args.again,
|
|
243
|
+
log=lambda m: print(m, flush=True))
|
|
244
|
+
except (RuntimeError, OSError, ValueError) as exc:
|
|
245
|
+
sys.exit(f"error: {exc}")
|
|
246
|
+
print(f"Read {n} session(s): {added} follow-up(s) added, {closed} closed.")
|
|
247
|
+
if not cfg.followups:
|
|
248
|
+
print("Tip: add `followups = true` to ~/.config/sessionpulse/config.toml to do this automatically.")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def cmd_dedupe(cfg, args):
|
|
252
|
+
if not followups.claude_bin(cfg):
|
|
253
|
+
sys.exit("error: the `claude` command was not found; set claude_command in config.toml")
|
|
254
|
+
if args.across:
|
|
255
|
+
return dedupe_across(cfg, args)
|
|
256
|
+
items = todos.load(cfg)
|
|
257
|
+
counts = Counter(i.get("project") or "" for i in items if todos.is_open(i))
|
|
258
|
+
projects = [args.project] if args.project is not None else [p for p, n in counts.items() if n > 1]
|
|
259
|
+
text = {i["id"]: i["text"] for i in items}
|
|
260
|
+
total = 0
|
|
261
|
+
for project in projects:
|
|
262
|
+
try:
|
|
263
|
+
groups = followups.find_duplicates(cfg, project)
|
|
264
|
+
except (RuntimeError, OSError, ValueError, subprocess.TimeoutExpired) as exc:
|
|
265
|
+
print(f"{project or '(no project)'}: skipped, {exc}", flush=True)
|
|
266
|
+
continue
|
|
267
|
+
if not groups:
|
|
268
|
+
continue
|
|
269
|
+
print(f"\n{project or '(no project)'}", flush=True)
|
|
270
|
+
for g in groups:
|
|
271
|
+
print(" same:")
|
|
272
|
+
for x in g:
|
|
273
|
+
print(f" {x} {text.get(x, '')}")
|
|
274
|
+
if args.apply:
|
|
275
|
+
n = todos.merge_duplicates(cfg, groups)
|
|
276
|
+
else:
|
|
277
|
+
n = sum(len(g) - 1 for g in groups)
|
|
278
|
+
total += n
|
|
279
|
+
if args.apply:
|
|
280
|
+
print(f"\nArchived {total} duplicate(s); each keeps `duplicate_of` pointing at the one kept.")
|
|
281
|
+
else:
|
|
282
|
+
print(f"\n{total} duplicate(s) found. Run again with --apply to archive them, keeping the newest.")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def dedupe_across(cfg, args):
|
|
286
|
+
pairs = followups.pairs_across(cfg)
|
|
287
|
+
print(f"{len(pairs)} pair(s) across projects look alike; asking Claude which are the same work…", flush=True)
|
|
288
|
+
if not pairs:
|
|
289
|
+
return
|
|
290
|
+
try:
|
|
291
|
+
groups = followups.find_duplicates_across(cfg, pairs)
|
|
292
|
+
except (RuntimeError, OSError, ValueError, subprocess.TimeoutExpired) as exc:
|
|
293
|
+
sys.exit(f"error: {exc}")
|
|
294
|
+
by_id = {i["id"]: i for i in todos.load(cfg)}
|
|
295
|
+
for g in groups:
|
|
296
|
+
print("\n same:")
|
|
297
|
+
for x in g:
|
|
298
|
+
i = by_id.get(x, {})
|
|
299
|
+
print(f" {x} {i.get('text', '')} ({i.get('project') or 'no project'})")
|
|
300
|
+
if args.apply:
|
|
301
|
+
n = todos.merge_duplicates(cfg, groups, link=True)
|
|
302
|
+
print(f"\nArchived {n} duplicate(s), keeping the newest (or the one in progress). Their projects now count "
|
|
303
|
+
"as related, so follow-ups in one can close work listed in the other.")
|
|
304
|
+
else:
|
|
305
|
+
print(f"\n{sum(len(g) - 1 for g in groups)} duplicate(s) found. Run again with --apply to archive them, "
|
|
306
|
+
"keeping the newest, and link their projects.")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def main(argv=None):
|
|
310
|
+
p = argparse.ArgumentParser(prog="sessionpulse", description=__doc__)
|
|
311
|
+
p.add_argument("--version", action="version", version=f"sessionpulse {__version__}")
|
|
312
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
313
|
+
i = sub.add_parser("install", help="add Claude Code hooks and login autostart")
|
|
314
|
+
i.add_argument("--no-autostart", action="store_true", help="only add the hooks")
|
|
315
|
+
i.add_argument("--daemon", action="store_true",
|
|
316
|
+
help="autostart the background daemon (for the GNOME extension) instead of the panel icon")
|
|
317
|
+
sub.add_parser("uninstall", help="remove the hooks and autostart (keeps your data)")
|
|
318
|
+
sub.add_parser("tray", help="panel icon + dashboard server")
|
|
319
|
+
sub.add_parser("serve", help="dashboard server only")
|
|
320
|
+
sub.add_parser("daemon", help="dashboard server + status.json for the GNOME extension, no icon")
|
|
321
|
+
sub.add_parser("open", help="open the dashboard in your browser")
|
|
322
|
+
sub.add_parser("doctor", help="check the setup and data formats")
|
|
323
|
+
sub.add_parser("snapshot", help="print the dashboard data as JSON")
|
|
324
|
+
t = sub.add_parser("todo", help="your to-do list: add, done, undo, delete, list, list-done, export")
|
|
325
|
+
t.add_argument("action", nargs="?", default="list",
|
|
326
|
+
choices=["list", "list-done", "add", "done", "undo", "delete", "export"])
|
|
327
|
+
t.add_argument("text", nargs="*", help="the to-do text (add) or item ids (done/undo/delete)")
|
|
328
|
+
t.add_argument("-p", "--project", help="tag the new to-do with a project (export: only this project)")
|
|
329
|
+
t.add_argument("--format", choices=todos.FORMATS, default="md", help="export: md (default), csv or json")
|
|
330
|
+
t.add_argument("--status", choices=["open", "all"], default="open",
|
|
331
|
+
help="export: open to-dos (default) or all, including done, on hold and archived")
|
|
332
|
+
f = sub.add_parser("followups", help="pick open follow-ups out of recent sessions with Claude")
|
|
333
|
+
f.add_argument("action", nargs="?", default="run", choices=["run", "status"],
|
|
334
|
+
help="run (default), or status: model use and sessions that failed")
|
|
335
|
+
f.add_argument("--days", type=int, help="look back this many days (default: followups_days)")
|
|
336
|
+
f.add_argument("--session", help="only this session id (or its first characters)")
|
|
337
|
+
f.add_argument("--now", action="store_true", help="include sessions that are still active")
|
|
338
|
+
f.add_argument("--again", action="store_true", help="re-read sessions already read")
|
|
339
|
+
d = sub.add_parser("dedupe", help="find open to-dos that are the same work reworded, with Claude")
|
|
340
|
+
d.add_argument("-p", "--project", help="only this project (default: every project)")
|
|
341
|
+
d.add_argument("--apply", action="store_true", help="archive the copies (default: only list them)")
|
|
342
|
+
d.add_argument("--across", action="store_true",
|
|
343
|
+
help="look for the same work listed in different projects (one model call per 40 likely pairs)")
|
|
344
|
+
sub.add_parser("hook", help=argparse.SUPPRESS)
|
|
345
|
+
args = p.parse_args(argv)
|
|
346
|
+
cfg = config.load()
|
|
347
|
+
return globals()[f"cmd_{args.cmd}"](cfg, args) or 0
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
if __name__ == "__main__":
|
|
351
|
+
sys.exit(main())
|