sessionpulse 0.1.2__tar.gz → 0.1.3__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 (36) hide show
  1. {sessionpulse-0.1.2/src/sessionpulse.egg-info → sessionpulse-0.1.3}/PKG-INFO +24 -1
  2. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/README.md +23 -0
  3. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/pyproject.toml +1 -1
  4. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/__init__.py +1 -1
  5. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/cli.py +78 -4
  6. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/followups.py +44 -6
  7. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/install.py +3 -2
  8. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/static/dashboard.html +26 -17
  9. sessionpulse-0.1.3/src/sessionpulse/status.py +96 -0
  10. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/todos.py +86 -5
  11. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/tray.py +2 -2
  12. {sessionpulse-0.1.2 → sessionpulse-0.1.3/src/sessionpulse.egg-info}/PKG-INFO +24 -1
  13. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse.egg-info/SOURCES.txt +1 -0
  14. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/tests/test_sessionpulse.py +109 -1
  15. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/LICENSE +0 -0
  16. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/setup.cfg +0 -0
  17. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/__main__.py +0 -0
  18. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/config.py +0 -0
  19. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/hook.py +0 -0
  20. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/icons/sessionpulse-busy.svg +0 -0
  21. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/icons/sessionpulse-idle.svg +0 -0
  22. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/icons/sessionpulse-none.svg +0 -0
  23. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/learn.py +0 -0
  24. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/reader.py +0 -0
  25. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/server.py +0 -0
  26. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/static/brand/favicon.svg +0 -0
  27. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/static/brand/session-pulse-idle-animated.svg +0 -0
  28. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/static/brand/session-pulse-idle-static.svg +0 -0
  29. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/static/brand/session-pulse-none-animated.svg +0 -0
  30. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/static/brand/session-pulse-none-static.svg +0 -0
  31. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/static/brand/session-pulse-working-animated.svg +0 -0
  32. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/static/brand/session-pulse-working-static.svg +0 -0
  33. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse.egg-info/dependency_links.txt +0 -0
  34. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse.egg-info/entry_points.txt +0 -0
  35. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse.egg-info/requires.txt +0 -0
  36. {sessionpulse-0.1.2 → sessionpulse-0.1.3}/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.1.2
3
+ Version: 0.1.3
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
@@ -154,6 +154,27 @@ sessionpulse followups # read the last 30 days now instead of waiting
154
154
 
155
155
  Check everything with `sessionpulse doctor`. Remove it with `sessionpulse uninstall` (your data stays) and `pipx uninstall sessionpulse`.
156
156
 
157
+ ## GNOME extension
158
+
159
+ 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.
160
+
161
+ 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:
162
+
163
+ ```sh
164
+ pipx install sessionpulse
165
+ sessionpulse install --daemon # hooks, plus the daemon (no icon) at login
166
+ sessionpulse daemon & # start it now
167
+ ```
168
+
169
+ Then install the extension. It isn't on extensions.gnome.org yet, so install it from a clone of this repo:
170
+
171
+ ```sh
172
+ gnome-extension/pack.sh
173
+ gnome-extensions install --force gnome-extension/dist/sessionpulse@vijayshuklaHL.github.io.shell-extension.zip
174
+ ```
175
+
176
+ 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.
177
+
157
178
  ## Commands
158
179
 
159
180
  | Command | What it does |
@@ -162,10 +183,12 @@ Check everything with `sessionpulse doctor`. Remove it with `sessionpulse uninst
162
183
  | `sessionpulse uninstall` | Removes exactly those hooks and the autostart entry. Your data stays. |
163
184
  | `sessionpulse tray` | Panel icon and dashboard server |
164
185
  | `sessionpulse serve` | Dashboard server only |
186
+ | `sessionpulse daemon` | Dashboard server plus `status.json` for panel widgets such as the GNOME extension, no icon (`install --daemon` starts it at login) |
165
187
  | `sessionpulse open` | Opens the dashboard in your browser, signed in (add `#board` to the address for the board) |
166
188
  | `sessionpulse doctor` | Checks the setup and warns if Claude Code's file formats look different |
167
189
  | `sessionpulse todo [add TEXT -p PROJECT \| done ID \| undo ID \| delete ID \| list-done]` | Your to-do list from the terminal |
168
190
  | `sessionpulse followups [--days N] [--session ID] [--now] [--again]` | Read recent sessions now and add their open follow-ups |
191
+ | `sessionpulse dedupe [-p PROJECT] [--apply]` | Finds open to-dos that are the same work reworded; `--apply` archives the copies |
169
192
  | `sessionpulse snapshot` | Prints the dashboard data as JSON |
170
193
 
171
194
  ## Settings
@@ -126,6 +126,27 @@ sessionpulse followups # read the last 30 days now instead of waiting
126
126
 
127
127
  Check everything with `sessionpulse doctor`. Remove it with `sessionpulse uninstall` (your data stays) and `pipx uninstall sessionpulse`.
128
128
 
129
+ ## GNOME extension
130
+
131
+ 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.
132
+
133
+ 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:
134
+
135
+ ```sh
136
+ pipx install sessionpulse
137
+ sessionpulse install --daemon # hooks, plus the daemon (no icon) at login
138
+ sessionpulse daemon & # start it now
139
+ ```
140
+
141
+ Then install the extension. It isn't on extensions.gnome.org yet, so install it from a clone of this repo:
142
+
143
+ ```sh
144
+ gnome-extension/pack.sh
145
+ gnome-extensions install --force gnome-extension/dist/sessionpulse@vijayshuklaHL.github.io.shell-extension.zip
146
+ ```
147
+
148
+ 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.
149
+
129
150
  ## Commands
130
151
 
131
152
  | Command | What it does |
@@ -134,10 +155,12 @@ Check everything with `sessionpulse doctor`. Remove it with `sessionpulse uninst
134
155
  | `sessionpulse uninstall` | Removes exactly those hooks and the autostart entry. Your data stays. |
135
156
  | `sessionpulse tray` | Panel icon and dashboard server |
136
157
  | `sessionpulse serve` | Dashboard server only |
158
+ | `sessionpulse daemon` | Dashboard server plus `status.json` for panel widgets such as the GNOME extension, no icon (`install --daemon` starts it at login) |
137
159
  | `sessionpulse open` | Opens the dashboard in your browser, signed in (add `#board` to the address for the board) |
138
160
  | `sessionpulse doctor` | Checks the setup and warns if Claude Code's file formats look different |
139
161
  | `sessionpulse todo [add TEXT -p PROJECT \| done ID \| undo ID \| delete ID \| list-done]` | Your to-do list from the terminal |
140
162
  | `sessionpulse followups [--days N] [--session ID] [--now] [--again]` | Read recent sessions now and add their open follow-ups |
163
+ | `sessionpulse dedupe [-p PROJECT] [--apply]` | Finds open to-dos that are the same work reworded; `--apply` archives the copies |
141
164
  | `sessionpulse snapshot` | Prints the dashboard data as JSON |
142
165
 
143
166
  ## Settings
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sessionpulse"
7
- version = "0.1.2"
7
+ version = "0.1.3"
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"
@@ -1,3 +1,3 @@
1
1
  """Session Pulse: a local dashboard and panel icon for Claude Code sessions."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.1.3"
@@ -3,11 +3,15 @@ import argparse
3
3
  import getpass
4
4
  import json
5
5
  import os
6
+ import signal
6
7
  import socket
8
+ import subprocess
7
9
  import sys
10
+ import threading
8
11
  import webbrowser
12
+ from collections import Counter
9
13
 
10
- from . import __version__, config, followups, hook, install, reader, server, todos, tray
14
+ from . import __version__, config, followups, hook, install, reader, server, status, todos, tray
11
15
 
12
16
 
13
17
  def headless():
@@ -37,6 +41,24 @@ def cmd_serve(cfg, args):
37
41
  pass
38
42
 
39
43
 
44
+ def cmd_daemon(cfg, args):
45
+ """Dashboard server + follow-ups + status.json, no icon: for the GNOME extension."""
46
+ try:
47
+ srv = server.make_server(cfg)
48
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
49
+ followups.start_worker(cfg)
50
+ print(f"Session Pulse on {cfg.url}", flush=True)
51
+ except OSError:
52
+ print(f"Port {cfg.port} is already in use (another `sessionpulse serve`/`tray`?); "
53
+ "writing status.json only.", flush=True)
54
+ print(f"Writing {status.path(cfg)} every {status.INTERVAL}s", flush=True)
55
+ stop = threading.Event()
56
+ for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
57
+ signal.signal(sig, lambda *_: stop.set())
58
+ status.run(cfg, stop, on_error=lambda exc: print(f"status.json not written: {exc}",
59
+ file=sys.stderr, flush=True))
60
+
61
+
40
62
  def cmd_tray(cfg, args):
41
63
  tray.run(cfg)
42
64
 
@@ -62,6 +84,10 @@ def cmd_install(cfg, args):
62
84
  f" SessionStart/SessionEnd hooks in {cfg.claude / 'settings.json'}")
63
85
  if args.no_autostart:
64
86
  return
87
+ if args.daemon:
88
+ print(f"Background daemon will start at login ({install.enable_autostart('daemon')}).")
89
+ print("Start it now with: sessionpulse daemon &")
90
+ return
65
91
  missing = tray.available()
66
92
  if missing:
67
93
  print(f"Skipped login autostart: {missing}\nYou can still run: sessionpulse serve")
@@ -113,6 +139,11 @@ def cmd_doctor(cfg, args):
113
139
  busy = s.connect_ex(("127.0.0.1", cfg.port)) == 0
114
140
  print(f" {'ok ' if busy else '-- '} dashboard {'running' if busy else 'not running'} on {cfg.url}"
115
141
  + ("" if busy else ": start it with `sessionpulse serve`"))
142
+ a = status.age(cfg)
143
+ if a is not None:
144
+ fresh = a < status.INTERVAL * 3
145
+ print(f" {'ok ' if fresh else '-- '} status.json {'updated' if fresh else 'stale, last updated'} "
146
+ f"{int(a)}s ago{'' if fresh else ': is `sessionpulse daemon` running?'}")
116
147
  for w in h["warnings"]:
117
148
  line(False, w)
118
149
  return 0 if ok else 1
@@ -132,11 +163,16 @@ def cmd_todo(cfg, args):
132
163
  except ValueError as exc:
133
164
  sys.exit(f"error: {exc}")
134
165
  items = todos.load(cfg)
135
- show = [i for i in items if bool(i["done_at"]) == (args.action == "list-done")]
166
+ want = (lambda i: i["done_at"]) if args.action == "list-done" else todos.is_open
167
+ show = [i for i in items if want(i)]
136
168
  for i in show:
137
169
  tag = f" [{i['project']}]" if i.get("project") else ""
138
170
  when = f" done {i['done_at'][:10]}" if i["done_at"] else ""
139
171
  print(f"{i['id']} {i['text']}{tag}{when}")
172
+ parked = [i for i in items if not i["done_at"] and i.get("status") in todos.STATUSES]
173
+ if parked and args.action != "list-done":
174
+ held = sum(i["status"] == "hold" for i in parked)
175
+ print(f"({held} on hold, {len(parked) - held} archived; see the dashboard board)")
140
176
  if not show:
141
177
  print("Nothing here." if args.action == "list-done" else "No open to-dos.")
142
178
 
@@ -155,15 +191,50 @@ def cmd_followups(cfg, args):
155
191
  print("Tip: add `followups = true` to ~/.config/sessionpulse/config.toml to do this automatically.")
156
192
 
157
193
 
194
+ def cmd_dedupe(cfg, args):
195
+ if not followups.claude_bin(cfg):
196
+ sys.exit("error: the `claude` command was not found; set claude_command in config.toml")
197
+ items = todos.load(cfg)
198
+ counts = Counter(i.get("project") or "" for i in items if todos.is_open(i))
199
+ projects = [args.project] if args.project is not None else [p for p, n in counts.items() if n > 1]
200
+ text = {i["id"]: i["text"] for i in items}
201
+ total = 0
202
+ for project in projects:
203
+ try:
204
+ groups = followups.find_duplicates(cfg, project)
205
+ except (RuntimeError, OSError, ValueError, subprocess.TimeoutExpired) as exc:
206
+ print(f"{project or '(no project)'}: skipped, {exc}", flush=True)
207
+ continue
208
+ if not groups:
209
+ continue
210
+ print(f"\n{project or '(no project)'}", flush=True)
211
+ for g in groups:
212
+ print(" same:")
213
+ for x in g:
214
+ print(f" {x} {text.get(x, '')}")
215
+ if args.apply:
216
+ n = todos.merge_duplicates(cfg, groups)
217
+ else:
218
+ n = sum(len(g) - 1 for g in groups)
219
+ total += n
220
+ if args.apply:
221
+ print(f"\nArchived {total} duplicate(s); each keeps `duplicate_of` pointing at the one kept.")
222
+ else:
223
+ print(f"\n{total} duplicate(s) found. Run again with --apply to archive them, keeping the newest.")
224
+
225
+
158
226
  def main(argv=None):
159
227
  p = argparse.ArgumentParser(prog="sessionpulse", description=__doc__)
160
228
  p.add_argument("--version", action="version", version=f"sessionpulse {__version__}")
161
229
  sub = p.add_subparsers(dest="cmd", required=True)
162
- sub.add_parser("install", help="add Claude Code hooks and login autostart") \
163
- .add_argument("--no-autostart", action="store_true", help="only add the hooks")
230
+ i = sub.add_parser("install", help="add Claude Code hooks and login autostart")
231
+ i.add_argument("--no-autostart", action="store_true", help="only add the hooks")
232
+ i.add_argument("--daemon", action="store_true",
233
+ help="autostart the background daemon (for the GNOME extension) instead of the panel icon")
164
234
  sub.add_parser("uninstall", help="remove the hooks and autostart (keeps your data)")
165
235
  sub.add_parser("tray", help="panel icon + dashboard server")
166
236
  sub.add_parser("serve", help="dashboard server only")
237
+ sub.add_parser("daemon", help="dashboard server + status.json for the GNOME extension, no icon")
167
238
  sub.add_parser("open", help="open the dashboard in your browser")
168
239
  sub.add_parser("doctor", help="check the setup and data formats")
169
240
  sub.add_parser("snapshot", help="print the dashboard data as JSON")
@@ -177,6 +248,9 @@ def main(argv=None):
177
248
  f.add_argument("--session", help="only this session id (or its first characters)")
178
249
  f.add_argument("--now", action="store_true", help="include sessions that are still active")
179
250
  f.add_argument("--again", action="store_true", help="re-read sessions already read")
251
+ d = sub.add_parser("dedupe", help="find open to-dos that are the same work reworded, with Claude")
252
+ d.add_argument("-p", "--project", help="only this project (default: every project)")
253
+ d.add_argument("--apply", action="store_true", help="archive the copies (default: only list them)")
180
254
  sub.add_parser("hook", help=argparse.SUPPRESS)
181
255
  args = p.parse_args(argv)
182
256
  cfg = config.load()
@@ -30,6 +30,7 @@ DIGEST_CHARS = 60000 # how much of a transcript goes to the model
30
30
  USER_CHARS = 1200 # per prompt
31
31
  CLAUDE_CHARS = 2500 # per reply
32
32
  MAX_ITEMS = 12
33
+ MAX_OPEN = 150 # open to-dos shown to the model per project
33
34
  TIMEOUT = 180
34
35
 
35
36
  SYSTEM = """You read a transcript of a Claude Code session between a user and Claude and list the \
@@ -51,8 +52,9 @@ English, naming the concrete thing ("Ask the vendor for the September invoice br
51
52
  Put the reason or context in `detail` (max 200 characters). Never copy passwords, keys or tokens into items.
52
53
 
53
54
  You also get the user's currently open to-dos for this project, with ids. Put an id in `resolved` only \
54
- if this transcript clearly shows that item was completed. Do not repeat an open to-do as a new item, and \
55
- do not re-add anything listed under "dismissed"."""
55
+ if this transcript clearly shows that item was completed. Do not repeat an open to-do as a new item, even \
56
+ in different words: if an open to-do already covers the work, leave it out. Do not re-add anything listed \
57
+ under "dismissed"."""
56
58
 
57
59
  SCHEMA = {
58
60
  "type": "object",
@@ -111,15 +113,15 @@ def claude_bin(cfg):
111
113
  return None
112
114
 
113
115
 
114
- def ask_claude(cfg, prompt):
116
+ def ask_claude(cfg, prompt, system=SYSTEM, schema=SCHEMA):
115
117
  exe = claude_bin(cfg)
116
118
  if not exe:
117
119
  raise RuntimeError("the `claude` command was not found; set claude_command in config.toml")
118
120
  work = cfg.data / "followups-cwd"
119
121
  work.mkdir(parents=True, exist_ok=True)
120
122
  cmd = [exe, "-p", "--model", cfg.followups_model, "--no-session-persistence", "--tools", "",
121
- "--settings", json.dumps({"disableAllHooks": True}), "--system-prompt", SYSTEM,
122
- "--output-format", "json", "--json-schema", json.dumps(SCHEMA)]
123
+ "--settings", json.dumps({"disableAllHooks": True}), "--system-prompt", system,
124
+ "--output-format", "json", "--json-schema", json.dumps(schema)]
123
125
  env = {**os.environ, "SESSIONPULSE_CHILD": "1"}
124
126
  r = subprocess.run(cmd, input=prompt, capture_output=True, text=True, cwd=work, env=env, timeout=TIMEOUT)
125
127
  try:
@@ -156,7 +158,10 @@ def process(cfg, sess, path):
156
158
  """Extract follow-ups from one session and merge them into the to-do list."""
157
159
  items = todos.load(cfg)
158
160
  project = sess.get("project") or ""
159
- open_ = [i for i in items if not i.get("done_at") and i.get("project") == project][:40]
161
+ # newest first: the model must see recent items, or it adds them again reworded
162
+ open_ = sorted((i for i in items if not i.get("done_at") and i.get("status") != "archived"
163
+ and i.get("project") == project),
164
+ key=lambda i: i.get("created") or "", reverse=True)[:MAX_OPEN]
160
165
  dismissed = [d for d in todos.dismissed(cfg) if d.get("project") == project][-40:]
161
166
  prompt = digest(path, project)
162
167
  prompt += "\n\n## Open to-dos for this project\n" + (
@@ -167,6 +172,39 @@ def process(cfg, sess, path):
167
172
  return todos.merge_followups(cfg, sess, out.get("items") or [], out.get("resolved") or [])
168
173
 
169
174
 
175
+ # ---------- one-off cleanup ----------
176
+ DEDUPE_SYSTEM = """You get one project's open to-do list, one item per line with its id. Group the items \
177
+ that are the same piece of work written in different words. Reply only with the JSON the schema asks for.
178
+
179
+ Two items are duplicates only if doing one would finish the other. Items about different devices, sites, \
180
+ people, dates or steps are NOT duplicates, even if worded alike ("Disable Telnet on the routers" and \
181
+ "Disable WAN management on the routers" are separate). A broader item does not swallow a narrower one \
182
+ unless it names that same work. Leave out every item that has no duplicate. When unsure, leave it out."""
183
+
184
+ DEDUPE_SCHEMA = {
185
+ "type": "object",
186
+ "properties": {"groups": {"type": "array", "items": {
187
+ "type": "array", "minItems": 2, "items": {"type": "string"}}}},
188
+ "required": ["groups"],
189
+ }
190
+
191
+
192
+ def find_duplicates(cfg, project):
193
+ """Ask the model which open to-dos in one project are reworded copies; returns groups of ids."""
194
+ open_ = [i for i in todos.load(cfg) if todos.is_open(i) and (i.get("project") or "") == (project or "")]
195
+ if len(open_) < 2:
196
+ return []
197
+ ids = {i["id"] for i in open_}
198
+ out = ask_claude(cfg, "\n".join(f"[{i['id']}] {i['text']}" for i in open_),
199
+ system=DEDUPE_SYSTEM, schema=DEDUPE_SCHEMA)
200
+ groups = []
201
+ for g in out.get("groups") or []:
202
+ g = [x for x in dict.fromkeys(map(str, g)) if x in ids]
203
+ if len(g) > 1:
204
+ groups.append(g)
205
+ return groups
206
+
207
+
170
208
  def candidates(cfg, snap, days=None, idle_minutes=None, force=False):
171
209
  """Sessions worth (re)reading: recent, quiet for a while, and changed since last read."""
172
210
  days = cfg.followups_days if days is None else days
@@ -126,7 +126,8 @@ def uninstall_hooks(cfg: Config):
126
126
  return changed
127
127
 
128
128
 
129
- def enable_autostart():
129
+ def enable_autostart(mode="tray"):
130
+ """Start `sessionpulse tray` (panel icon) or `sessionpulse daemon` (no icon) at login."""
130
131
  icon = Path(__file__).with_name("icons") / "sessionpulse-idle.svg"
131
132
  AUTOSTART.parent.mkdir(parents=True, exist_ok=True)
132
133
  AUTOSTART.write_text(
@@ -134,7 +135,7 @@ def enable_autostart():
134
135
  "Type=Application\n"
135
136
  "Name=Session Pulse\n"
136
137
  "Comment=Panel icon and dashboard for Claude Code sessions\n"
137
- f'Exec="{sys.executable}" -m sessionpulse tray\n'
138
+ f'Exec="{sys.executable}" -m sessionpulse {mode}\n'
138
139
  f"Icon={icon}\n"
139
140
  "Terminal=false\n"
140
141
  "X-GNOME-Autostart-enabled=true\n"
@@ -180,6 +180,7 @@ input[type=search] { flex: 1; min-width: 200px; }
180
180
  .k-decision { --c: var(--crit); } .k-external { --c: var(--busy); } .k-waiting { --c: var(--series-7); }
181
181
  .k-checklist { --c: var(--accent); } .k-task { --c: var(--series-5); } .k-mine, .k-todo { --c: var(--other); }
182
182
  .k-doing { --c: var(--accent); } .k-done { --c: var(--good); }
183
+ .k-hold { --c: var(--series-4); } .k-archived { --c: var(--other); }
183
184
  .kindtag { color: color-mix(in srgb, var(--c) 78%, var(--text-primary));
184
185
  background: color-mix(in srgb, var(--c) 16%, transparent); }
185
186
  .kindtag.dot { background: var(--c); }
@@ -232,6 +233,7 @@ a:hover { text-decoration: underline; }
232
233
  .card2.dragging { opacity: .4; }
233
234
  .card2 { border-left: 3px solid var(--c); }
234
235
  .card2.done { border-left-color: var(--good); }
236
+ .card2.parked .t { color: var(--text-secondary); }
235
237
  .doing-pill { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: #fff;
236
238
  background: var(--accent); border-radius: 5px; padding: 1px 6px; }
237
239
  .card2.done .t { color: var(--text-secondary); }
@@ -651,6 +653,8 @@ $("#history").addEventListener("click", ev => {
651
653
  const dayLabel = iso => { const d = dkey(new Date(iso)), t = new Date(); const today = dkey(t);
652
654
  t.setDate(t.getDate()-1); return d === today ? "Today" : d === dkey(t) ? "Yesterday" : dayTitle(d); };
653
655
  const KIND = {external: "Outside work", checklist: "Checklist", decision: "Decide", waiting: "Waiting on", task: "Later"};
656
+ const PARKED = ["hold", "archived"];
657
+ const isOpen = i => !i.done_at && !PARKED.includes(i.status);
654
658
  const todoRow = (i, done, grouped) => {
655
659
  const born = i.session_at || i.created;
656
660
  const from = i.source === "session" && i.session
@@ -666,7 +670,7 @@ const todoRow = (i, done, grouped) => {
666
670
 
667
671
  function renderTodos() {
668
672
  const items = DATA.todos || [], r = rangeStart();
669
- const open = items.filter(i => !i.done_at && (!TODOF || (TODOF === "session") === (i.source === "session")))
673
+ const open = items.filter(i => isOpen(i) && (!TODOF || (TODOF === "session") === (i.source === "session")))
670
674
  .sort((a, b) => (a.source === "session") - (b.source === "session") || a.created.localeCompare(b.created));
671
675
  const F = DATA.followups || {};
672
676
  $("#fu-status").innerHTML = !F.enabled
@@ -676,7 +680,8 @@ function renderTodos() {
676
680
  : `Follow-ups are picked up from sessions after 20 quiet minutes${F.last_run ? ` · last checked ${ago(Date.parse(F.last_run))}` : ""}.`;
677
681
  const done = items.filter(i => i.done_at && (!r || dkey(new Date(i.done_at)) >= r))
678
682
  .sort((a, b) => b.done_at.localeCompare(a.done_at));
679
- $("#todo-count").textContent = open.length ? `${open.length} open` : "";
683
+ const held = items.filter(i => !i.done_at && i.status === "hold").length, arch = items.filter(i => !i.done_at && i.status === "archived").length;
684
+ $("#todo-count").textContent = [open.length && `${open.length} open`, held && `${held} on hold`, arch && `${arch} archived`].filter(Boolean).join(" · ");
680
685
  // Group by project, newest activity first. Groups with something from the last 3 days start open.
681
686
  const G = {};
682
687
  for (const i of open) (G[i.project || ""] ||= []).push(i);
@@ -772,27 +777,31 @@ function renderLearn() {
772
777
  const COLS = {
773
778
  kind: [["decision", "Decide", "choices waiting on you"], ["external", "Outside work", "send, call, sign, visit"],
774
779
  ["waiting", "Waiting on", "someone owes you"], ["checklist", "Checklist", "audit and verify"],
775
- ["task", "Later", "for a future session"], ["mine", "Added by me", ""], ["done", "Done", ""]],
776
- status: [["todo", "To do", ""], ["doing", "Doing", "what you're on now"], ["done", "Done", ""]],
780
+ ["task", "Later", "for a future session"], ["mine", "Added by me", ""], ["hold", "On hold", "paused for now"],
781
+ ["done", "Done", ""], ["archived", "Archived", "dropped, kept for reference"]],
782
+ status: [["todo", "To do", ""], ["doing", "Doing", "what you're on now"], ["hold", "On hold", "paused for now"],
783
+ ["done", "Done", ""], ["archived", "Archived", "dropped, kept for reference"]],
777
784
  };
778
785
  let BY = "kind", DRAG = null;
779
- const colOf = i => i.done_at ? "done" : BY === "status" ? (i.doing ? "doing" : "todo") : (i.kind || "mine");
786
+ const colOf = i => i.done_at ? "done" : PARKED.includes(i.status) ? i.status
787
+ : BY === "status" ? (i.doing ? "doing" : "todo") : (i.kind || "mine");
780
788
  function moveOp(i, col) {
781
789
  if (col === "done") return {op: "set", id: i.id, done: true};
782
- const op = {op: "set", id: i.id, done: false};
790
+ if (PARKED.includes(col)) return {op: "set", id: i.id, done: false, status: col};
791
+ const op = {op: "set", id: i.id, done: false, status: null};
783
792
  if (BY === "status") op.doing = col === "doing";
784
793
  else op.kind = col === "mine" ? null : col;
785
794
  return op;
786
795
  }
787
796
  const card = i => {
788
- const born = i.session_at || i.created, cols = COLS[BY], here = colOf(i);
789
- return `<div class="card2 k-${esc(i.kind || "mine")}${i.doing && !i.done_at ? " doing" : ""}${i.done_at ? " done" : ""}" draggable="true" data-id="${esc(i.id)}">
790
- <div class="t">${BY === "status" && i.kind ? `<span class="kindtag k-${esc(i.kind)}">${esc(KIND[i.kind])}</span>` : ""}${esc(i.text)}</div>
797
+ const born = i.session_at || i.created, cols = COLS[BY], here = colOf(i), parked = PARKED.includes(here);
798
+ return `<div class="card2 k-${esc(i.kind || "mine")}${i.doing && here !== "done" && !parked ? " doing" : ""}${i.done_at ? " done" : ""}${parked ? " parked" : ""}" draggable="true" data-id="${esc(i.id)}">
799
+ <div class="t">${(BY === "status" || parked) && i.kind ? `<span class="kindtag k-${esc(i.kind)}">${esc(KIND[i.kind])}</span>` : ""}${esc(i.text)}</div>
791
800
  ${i.detail ? `<div class="d" title="Click to expand">${esc(i.detail)}</div>` : ""}
792
- <div class="meta">${i.doing && !i.done_at && BY === "kind" ? `<span class="doing-pill">Doing</span>` : ""}${i.project ? `<span class="tag" title="${esc(i.project)}">${esc(i.project.split("/").pop())}</span>` : ""}
801
+ <div class="meta">${i.doing && !i.done_at && !parked && BY === "kind" ? `<span class="doing-pill">Doing</span>` : ""}${i.project ? `<span class="tag" title="${esc(i.project)}">${esc(i.project.split("/").pop())}</span>` : ""}
793
802
  <span title="${i.done_at ? "Done " + esc(fmtD(i.done_at)) : (i.session_at ? "Discussed " : "Added ") + esc(fmtD(born))}">${i.done_at ? "done " + ago(Date.parse(i.done_at)) : ago(Date.parse(born))}</span>
794
803
  ${i.closed_by && i.done_at ? `<span title="A later session showed this was done">· auto</span>` : ""}
795
- <span class="acts">${i.session && !i.done_at ? `<button class="copy" data-copy="cd ${esc(i.cwd || "")} && claude --resume ${esc(i.session)}" title="Copy the command that reopens the session this came from">Resume</button>` : ""}
804
+ <span class="acts">${i.session && !i.done_at && !parked ? `<button class="copy" data-copy="cd ${esc(i.cwd || "")} && claude --resume ${esc(i.session)}" title="Copy the command that reopens the session this came from">Resume</button>` : ""}
796
805
  <select data-move="${esc(i.id)}" aria-label="Move to"><option value="">Move…</option>${cols.filter(c => c[0] !== here).map(c => `<option value="${c[0]}">${c[1]}</option>`).join("")}</select>
797
806
  <button class="x" data-del="${esc(i.id)}" title="${i.source === "session" ? "Dismiss (won't be picked up again)" : "Delete"}" aria-label="Delete">×</button></span>
798
807
  </div></div>`;
@@ -802,9 +811,9 @@ function renderBoard() {
802
811
  if (!DATA || DRAG || $("#boardview").hidden) return;
803
812
  if (document.activeElement?.matches?.("#board select")) return; // don't yank a menu that's open
804
813
  const items = DATA.todos || [], r = rangeStart(), proj = $("#board-proj").value, q = $("#board-q").value.trim().toLowerCase();
805
- const projects = [...new Set(items.filter(i => !i.done_at).map(i => i.project).filter(Boolean))].sort();
814
+ const projects = [...new Set(items.filter(i => !i.done_at).map(i => i.project).filter(Boolean))].sort(); // parked ones too, so you can find them
806
815
  const sel = $("#board-proj");
807
- const opts = `<option value="">All projects</option>` + projects.map(p => `<option value="${esc(p)}"${p === proj ? " selected" : ""}>${esc(p)} (${items.filter(i => !i.done_at && i.project === p).length})</option>`).join("");
816
+ const opts = `<option value="">All projects</option>` + projects.map(p => `<option value="${esc(p)}"${p === proj ? " selected" : ""}>${esc(p)} (${items.filter(i => isOpen(i) && i.project === p).length})</option>`).join("");
808
817
  if (sel.dataset.sig !== opts) { sel.innerHTML = opts; sel.dataset.sig = opts; sel.value = projects.includes(proj) ? proj : ""; }
809
818
  const shown = items.filter(i => (!proj || i.project === proj)
810
819
  && (!q || [i.text, i.detail, i.project].some(v => (v||"").toLowerCase().includes(q)))
@@ -813,12 +822,12 @@ function renderBoard() {
813
822
  const when = i => i.done_at || i.session_at || i.created;
814
823
  $("#board").innerHTML = COLS[BY].map(([k, label, hint]) => {
815
824
  const list = (by[k] || []).sort((a, b) => when(b).localeCompare(when(a)));
816
- const cap = k === "done" ? 150 : 400;
825
+ const cap = k === "done" || k === "archived" ? 150 : 400;
817
826
  return `<section class="col k-${k}" data-col="${k}"><div class="col-head"><span class="kindtag dot k-${k}"></span>${label}<span class="n">${list.length}</span>${hint ? `<span class="hint">${hint}</span>` : ""}</div>
818
- <div class="col-body">${list.slice(0, cap).map(card).join("") || `<div class="col-empty">${k === "done" ? "Drop a card here when it's done" : "Nothing here"}</div>`}
827
+ <div class="col-body">${list.slice(0, cap).map(card).join("") || `<div class="col-empty">${k === "done" ? "Drop a card here when it's done" : k === "hold" ? "Drop a card here to pause it" : k === "archived" ? "Drop a card here to shelve it" : "Nothing here"}</div>`}
819
828
  ${list.length > cap ? `<div class="col-empty">+${list.length - cap} more; narrow it with the project filter</div>` : ""}</div></section>`;
820
829
  }).join("");
821
- const open = items.filter(i => !i.done_at).length;
830
+ const open = items.filter(isOpen).length;
822
831
  $("#board-note").textContent = `${open} open · Done shows ${RANGE ? (RANGE === 1 ? "today" : `the last ${RANGE} days`) : "everything"} (change it top right) · drag cards between columns`;
823
832
  }
824
833
 
@@ -859,7 +868,7 @@ B.addEventListener("click", ev => {
859
868
  // ---------- wiring ----------
860
869
  function render() {
861
870
  if (!DATA) return;
862
- const open = (DATA.todos || []).filter(i => !i.done_at).length;
871
+ const open = (DATA.todos || []).filter(isOpen).length;
863
872
  $("#board-badge").textContent = open ? open : "";
864
873
  if (!$("#boardview").hidden) return renderBoard();
865
874
  const sessions = DATA.sessions.filter(inRange);
@@ -0,0 +1,96 @@
1
+ """`status.json`: a small, token-free summary for panel widgets such as the GNOME extension.
2
+
3
+ `sessionpulse daemon` rewrites it every few seconds (atomically, mode 0600) and
4
+ removes it on a clean exit. A reader should treat it as stale when `updated` is
5
+ older than about three intervals: the daemon was killed or the machine slept.
6
+ """
7
+ import json
8
+ import os
9
+ import time
10
+ from pathlib import Path
11
+
12
+ from . import __version__, config, reader, todos, tray
13
+
14
+ SCHEMA = 1
15
+ INTERVAL = 5 # seconds between writes
16
+
17
+
18
+ def path(cfg: config.Config) -> Path:
19
+ return cfg.data / "status.json"
20
+
21
+
22
+ def build(cfg: config.Config, snap, today=None):
23
+ v = tray.summary(snap, today=today)
24
+ now = int(time.time() * 1000)
25
+ return {
26
+ "schema": SCHEMA,
27
+ "version": __version__,
28
+ "updated": now,
29
+ "interval": INTERVAL,
30
+ "pid": os.getpid(),
31
+ "port": cfg.port,
32
+ "state": v["icon"], # busy | idle | none
33
+ "open": len(v["live"]),
34
+ "working": v["busy"],
35
+ "sessions": [{
36
+ "project": s.get("project") or "?",
37
+ "state": "busy" if s.get("state") == "busy" else "idle",
38
+ "started": (s.get("live") or {}).get("startedAt") or now,
39
+ } for s in v["live"]],
40
+ "today": {
41
+ "sessions": v["today_sessions"],
42
+ "active_hours": round(v["today_hours"], 2),
43
+ "output_tokens": int(v["today_output"]),
44
+ },
45
+ "todos_open": sum(1 for t in snap.get("todos") or [] if todos.is_open(t)),
46
+ "warnings": len(v["warnings"]),
47
+ }
48
+
49
+
50
+ def write(cfg: config.Config, data):
51
+ """Atomic replace, readable only by you (project names are private enough)."""
52
+ target = path(cfg)
53
+ target.parent.mkdir(parents=True, exist_ok=True)
54
+ tmp = target.with_name(f".status.{os.getpid()}.tmp")
55
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
56
+ with os.fdopen(fd, "w") as f:
57
+ json.dump(data, f, separators=(",", ":"))
58
+ os.replace(tmp, target)
59
+
60
+
61
+ def write_now(cfg: config.Config, today=None):
62
+ with reader.lock:
63
+ snap = reader.snapshot(cfg)
64
+ data = build(cfg, snap, today=today)
65
+ write(cfg, data)
66
+ return data
67
+
68
+
69
+ def run(cfg: config.Config, stop, on_error=None):
70
+ """Write status.json every INTERVAL seconds until `stop` (a threading.Event) is set, then remove it."""
71
+ try:
72
+ while not stop.is_set():
73
+ try:
74
+ write_now(cfg)
75
+ except Exception as exc: # noqa: BLE001 - keep the daemon alive
76
+ if on_error:
77
+ on_error(exc)
78
+ stop.wait(INTERVAL)
79
+ finally:
80
+ remove(cfg)
81
+
82
+
83
+ def remove(cfg: config.Config):
84
+ try:
85
+ path(cfg).unlink()
86
+ except FileNotFoundError:
87
+ pass
88
+
89
+
90
+ def age(cfg: config.Config):
91
+ """Seconds since the daemon last wrote status.json, or None if there is none."""
92
+ try:
93
+ data = json.loads(path(cfg).read_text())
94
+ return max(0.0, time.time() - data["updated"] / 1000)
95
+ except (OSError, ValueError, KeyError, TypeError):
96
+ return None
@@ -13,6 +13,7 @@ from datetime import datetime
13
13
 
14
14
  MAX_TEXT = 500
15
15
  KINDS = ("external", "checklist", "decision", "waiting", "task")
16
+ STATUSES = ("hold", "archived") # parked items: not done, but not on the active list either
16
17
  lock = threading.Lock()
17
18
 
18
19
 
@@ -46,6 +47,11 @@ def _save(cfg, items):
46
47
  _write(_path(cfg), items)
47
48
 
48
49
 
50
+ def is_open(item):
51
+ """Still to do: not done, on hold or archived."""
52
+ return not item.get("done_at") and item.get("status") not in STATUSES
53
+
54
+
49
55
  def _dismissed_path(cfg):
50
56
  return cfg.data / "todos-dismissed.json"
51
57
 
@@ -63,19 +69,58 @@ def _norm(text):
63
69
  return re.sub(r"[^a-z0-9]+", " ", text.lower()).strip()
64
70
 
65
71
 
72
+ SIMILAR = 0.6 # word overlap at which two to-dos count as the same one reworded
73
+ RECENT_DAYS = 30
74
+ _STOP = frozenset("a an the and or of to in on for with from by at as is be it its this that into via "
75
+ "per their your our my all any each then than not no do get set up out off whether".split())
76
+
77
+
78
+ def _words(text):
79
+ """The content words of a to-do, with a plain plural 's' dropped."""
80
+ out = set()
81
+ for w in re.findall(r"[a-z0-9]+", text.lower()):
82
+ if w in _STOP:
83
+ continue
84
+ if len(w) > 3 and w.endswith("s") and not w.endswith("ss"):
85
+ w = w[:-1]
86
+ out.add(w)
87
+ return out
88
+
89
+
90
+ def similar(a, b):
91
+ """True if two to-do texts say the same thing in different words."""
92
+ wa, wb = _words(a), _words(b)
93
+ return bool(wa and wb) and len(wa & wb) / len(wa | wb) >= SIMILAR
94
+
95
+
96
+ def _recent(stamp, days=RECENT_DAYS):
97
+ try:
98
+ return (datetime.now().astimezone() - datetime.fromisoformat(stamp)).days < days
99
+ except (TypeError, ValueError):
100
+ return False
101
+
102
+
66
103
  def merge_followups(cfg, sess, found, resolved):
67
104
  """Add follow-ups found in a session and close the ones it resolved.
68
105
 
69
- Returns (added, closed). Skips anything already on the list or dismissed."""
106
+ Returns (added, closed). Skips anything already on the list or dismissed,
107
+ including the same to-do reworded (see similar()) within the project."""
70
108
  added = closed = 0
109
+ project = sess.get("project")
71
110
  with lock:
72
111
  items = load(cfg)
73
- seen = {_norm(i["text"]) for i in items} | {_norm(d.get("text", "")) for d in dismissed(cfg)}
112
+ gone = dismissed(cfg)
113
+ seen = {_norm(i["text"]) for i in items} | {_norm(d.get("text", "")) for d in gone}
114
+ # reworded copies: compare with this project's live items, recently done ones and dismissals
115
+ near = [i["text"] for i in items if i.get("project") == project
116
+ and (not i.get("done_at") or _recent(i["done_at"]))]
117
+ near += [d.get("text", "") for d in gone if d.get("project") == project]
74
118
  for f in found:
75
119
  text = _clean(f.get("text") if isinstance(f, dict) else "", 200)
76
- if not text or _norm(text) in seen:
120
+ if not text or _norm(text) in seen or any(similar(text, t) for t in near):
77
121
  continue
78
122
  seen.add(_norm(text))
123
+ near.append(text)
79
124
  kind = f.get("kind") if f.get("kind") in KINDS else "task"
80
125
  items.append({"id": secrets.token_hex(4), "text": text, "project": sess.get("project"),
81
126
  "created": _now(), "done_at": None, "source": "session", "kind": kind,
@@ -84,7 +129,7 @@ def merge_followups(cfg, sess, found, resolved):
84
129
  "session_at": sess.get("end")})
85
130
  added += 1
86
131
  for i in items:
87
- if i["id"] in resolved and not i.get("done_at") and i.get("project") == sess.get("project"):
132
+ if i["id"] in resolved and not i.get("done_at") and i.get("project") == project:
88
133
  i["done_at"] = _now()
89
134
  i["closed_by"] = sess.get("id")
90
135
  closed += 1
@@ -93,6 +138,31 @@ def merge_followups(cfg, sess, found, resolved):
93
138
  return added, closed
94
139
 
95
140
 
141
+ def merge_duplicates(cfg, groups):
142
+ """Archive all but one open item of each group of duplicate ids; returns how many.
143
+
144
+ The kept item is the one in progress, else the newest. Archived copies record
145
+ `duplicate_of`, so they stay on the board and can be moved back."""
146
+ merged = 0
147
+ with lock:
148
+ items = load(cfg)
149
+ by_id = {i["id"]: i for i in items}
150
+ for group in groups:
151
+ live = [by_id[g] for g in dict.fromkeys(group) if g in by_id and is_open(by_id[g])]
152
+ if len(live) < 2:
153
+ continue
154
+ keep = max(live, key=lambda i: (bool(i.get("doing")), i.get("created") or ""))
155
+ for i in live:
156
+ if i is not keep:
157
+ i["status"] = "archived"
158
+ i["doing"] = False
159
+ i["duplicate_of"] = keep["id"]
160
+ merged += 1
161
+ if merged:
162
+ _save(cfg, items)
163
+ return merged
164
+
165
+
96
166
  def _clean(text, limit=MAX_TEXT):
97
167
  return " ".join(str(text or "").split())[:limit]
98
168
 
@@ -101,7 +171,7 @@ def apply(cfg, op):
101
171
  """Run one change and return the new list.
102
172
 
103
173
  op is a dict: {"op": "add", "text", "project"?} | {"op": "done"|"undo"|"delete", "id"}
104
- | {"op": "delete", "ids": [...]} | {"op": "set", "id", "kind"?, "doing"?, "done"?}
174
+ | {"op": "delete", "ids": [...]} | {"op": "set", "id", "kind"?, "doing"?, "done"?, "status"?}
105
175
  | {"op": "edit", "id", "text"}. Raises ValueError on anything else."""
106
176
  if not isinstance(op, dict):
107
177
  raise ValueError("expected an object")
@@ -134,15 +204,26 @@ def apply(cfg, op):
134
204
  hit["kind"] = op["kind"] or None
135
205
  if "doing" in op:
136
206
  hit["doing"] = bool(op["doing"])
207
+ if "status" in op:
208
+ if op["status"] not in (*STATUSES, None, ""):
209
+ raise ValueError("unknown status")
210
+ if op["status"]:
211
+ hit["status"] = op["status"]
212
+ hit["doing"] = False
213
+ else:
214
+ hit.pop("status", None)
215
+ hit.pop("duplicate_of", None)
137
216
  if "done" in op:
138
217
  hit["done_at"] = (hit.get("done_at") or _now()) if op["done"] else None
139
218
  if not op["done"]:
140
219
  hit.pop("closed_by", None)
141
220
  if hit.get("done_at"):
142
221
  hit["doing"] = False
222
+ hit.pop("status", None)
143
223
  elif kind == "done":
144
224
  hit["done_at"] = hit.get("done_at") or _now()
145
225
  hit["doing"] = False
226
+ hit.pop("status", None)
146
227
  elif kind == "undo":
147
228
  hit["done_at"] = None
148
229
  hit.pop("closed_by", None)
@@ -13,7 +13,7 @@ import webbrowser
13
13
  from datetime import datetime
14
14
  from importlib import resources
15
15
 
16
- from . import config, followups, reader, server
16
+ from . import config, followups, reader, server, todos
17
17
 
18
18
  SLOTS = 8 # fixed rows for open sessions: replacing the whole menu would close it while open
19
19
  LIVE = ("busy", "idle", "running")
@@ -140,7 +140,7 @@ def run(cfg: config.Config):
140
140
  f"{fmt_n(v['today_output'])} output tokens")
141
141
  warn.set_label(f"⚠ {len(v['warnings'])} data warning(s): see dashboard")
142
142
  warn.set_visible(bool(v["warnings"]))
143
- n = sum(1 for t in snap.get("todos") or [] if not t.get("done_at"))
143
+ n = sum(1 for t in snap.get("todos") or [] if todos.is_open(t))
144
144
  board.set_label(f"Open to-do board ({n} open)" if n else "Open to-do board")
145
145
  return False
146
146
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sessionpulse
3
- Version: 0.1.2
3
+ Version: 0.1.3
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
@@ -154,6 +154,27 @@ sessionpulse followups # read the last 30 days now instead of waiting
154
154
 
155
155
  Check everything with `sessionpulse doctor`. Remove it with `sessionpulse uninstall` (your data stays) and `pipx uninstall sessionpulse`.
156
156
 
157
+ ## GNOME extension
158
+
159
+ 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.
160
+
161
+ 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:
162
+
163
+ ```sh
164
+ pipx install sessionpulse
165
+ sessionpulse install --daemon # hooks, plus the daemon (no icon) at login
166
+ sessionpulse daemon & # start it now
167
+ ```
168
+
169
+ Then install the extension. It isn't on extensions.gnome.org yet, so install it from a clone of this repo:
170
+
171
+ ```sh
172
+ gnome-extension/pack.sh
173
+ gnome-extensions install --force gnome-extension/dist/sessionpulse@vijayshuklaHL.github.io.shell-extension.zip
174
+ ```
175
+
176
+ 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.
177
+
157
178
  ## Commands
158
179
 
159
180
  | Command | What it does |
@@ -162,10 +183,12 @@ Check everything with `sessionpulse doctor`. Remove it with `sessionpulse uninst
162
183
  | `sessionpulse uninstall` | Removes exactly those hooks and the autostart entry. Your data stays. |
163
184
  | `sessionpulse tray` | Panel icon and dashboard server |
164
185
  | `sessionpulse serve` | Dashboard server only |
186
+ | `sessionpulse daemon` | Dashboard server plus `status.json` for panel widgets such as the GNOME extension, no icon (`install --daemon` starts it at login) |
165
187
  | `sessionpulse open` | Opens the dashboard in your browser, signed in (add `#board` to the address for the board) |
166
188
  | `sessionpulse doctor` | Checks the setup and warns if Claude Code's file formats look different |
167
189
  | `sessionpulse todo [add TEXT -p PROJECT \| done ID \| undo ID \| delete ID \| list-done]` | Your to-do list from the terminal |
168
190
  | `sessionpulse followups [--days N] [--session ID] [--now] [--again]` | Read recent sessions now and add their open follow-ups |
191
+ | `sessionpulse dedupe [-p PROJECT] [--apply]` | Finds open to-dos that are the same work reworded; `--apply` archives the copies |
169
192
  | `sessionpulse snapshot` | Prints the dashboard data as JSON |
170
193
 
171
194
  ## Settings
@@ -11,6 +11,7 @@ src/sessionpulse/install.py
11
11
  src/sessionpulse/learn.py
12
12
  src/sessionpulse/reader.py
13
13
  src/sessionpulse/server.py
14
+ src/sessionpulse/status.py
14
15
  src/sessionpulse/todos.py
15
16
  src/sessionpulse/tray.py
16
17
  src/sessionpulse.egg-info/PKG-INFO
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
 
15
15
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
16
16
 
17
- from sessionpulse import cli, config, followups, hook, install, learn, reader, server, todos, tray # noqa: E402
17
+ from sessionpulse import cli, config, followups, hook, install, learn, reader, server, status, todos, tray # noqa: E402
18
18
 
19
19
  SID = "11111111-2222-3333-4444-555555555555"
20
20
 
@@ -313,6 +313,31 @@ class TodoTests(Fixture):
313
313
  self.assertEqual(sorted(d["text"] for d in todos.dismissed(self.cfg)), ["a", "b"])
314
314
  self.assertEqual(todos.merge_followups(self.cfg, {"id": "s2", "project": "p"}, [{"text": "a"}], []), (0, 0))
315
315
 
316
+ def test_reworded_followups_skipped(self):
317
+ merge = todos.merge_followups
318
+ merge(self.cfg, {"id": "s", "project": "p"},
319
+ [{"text": "Put Grafana alert rules on no data and execution error"}], [])
320
+ self.assertEqual(merge(self.cfg, {"id": "s2", "project": "p"},
321
+ [{"text": "Check that Grafana alert rules fire on no data and on execution error"},
322
+ {"text": "Disable Telnet on the Credo routers"},
323
+ {"text": "Disable WAN management on the Credo routers"}], []), (2, 0))
324
+ # the same words in another project are a different to-do
325
+ self.assertEqual(merge(self.cfg, {"id": "s3", "project": "q"},
326
+ [{"text": "Check Grafana alert rules on no data and execution error"}], []), (1, 0))
327
+
328
+ def test_merge_duplicates(self):
329
+ a = todos.apply(self.cfg, {"op": "add", "text": "old wording"})[-1]["id"]
330
+ b = todos.apply(self.cfg, {"op": "add", "text": "new wording"})[-1]["id"]
331
+ c = todos.apply(self.cfg, {"op": "add", "text": "in progress"})[-1]["id"]
332
+ todos.apply(self.cfg, {"op": "set", "id": c, "doing": True})
333
+ self.assertEqual(todos.merge_duplicates(self.cfg, [[a, b, c], [a, "missing"]]), 2)
334
+ by = {i["id"]: i for i in todos.load(self.cfg)}
335
+ self.assertTrue(todos.is_open(by[c])) # the one in progress is kept
336
+ self.assertEqual([(by[x]["status"], by[x]["duplicate_of"]) for x in (a, b)], [("archived", c)] * 2)
337
+ it = next(i for i in todos.apply(self.cfg, {"op": "set", "id": a, "status": None}) if i["id"] == a)
338
+ self.assertNotIn("duplicate_of", it)
339
+ self.assertEqual(todos.merge_duplicates(self.cfg, [[b, c]]), 0) # b is already archived
340
+
316
341
  def test_board_moves(self):
317
342
  i = todos.apply(self.cfg, {"op": "add", "text": "x"})[0]["id"]
318
343
  it = todos.apply(self.cfg, {"op": "set", "id": i, "kind": "decision", "doing": True})[0]
@@ -325,6 +350,23 @@ class TodoTests(Fixture):
325
350
  with self.assertRaises(ValueError):
326
351
  todos.apply(self.cfg, {"op": "set", "id": i, "kind": "nope"})
327
352
 
353
+ def test_hold_and_archive(self):
354
+ i = todos.apply(self.cfg, {"op": "add", "text": "x"})[0]["id"]
355
+ it = todos.apply(self.cfg, {"op": "set", "id": i, "doing": True})[0]
356
+ it = todos.apply(self.cfg, {"op": "set", "id": i, "done": False, "status": "hold"})[0]
357
+ self.assertEqual((it["status"], it["doing"], it["done_at"]), ("hold", False, None))
358
+ self.assertFalse(todos.is_open(it))
359
+ it = todos.apply(self.cfg, {"op": "set", "id": i, "status": "archived"})[0]
360
+ self.assertEqual(it["status"], "archived")
361
+ it = todos.apply(self.cfg, {"op": "set", "id": i, "done": False, "status": None})[0]
362
+ self.assertNotIn("status", it)
363
+ self.assertTrue(todos.is_open(it))
364
+ todos.apply(self.cfg, {"op": "set", "id": i, "status": "hold"})
365
+ it = todos.apply(self.cfg, {"op": "done", "id": i})[0]
366
+ self.assertNotIn("status", it) # finishing a held item un-parks it
367
+ with self.assertRaises(ValueError):
368
+ todos.apply(self.cfg, {"op": "set", "id": i, "status": "someday"})
369
+
328
370
  def test_bad_ops_rejected(self):
329
371
  for op in ({"op": "add", "text": " "}, {"op": "done", "id": "missing"}, {"op": "x"}, []):
330
372
  with self.assertRaises(ValueError):
@@ -346,6 +388,72 @@ class TodoTests(Fixture):
346
388
  self.assertIn("call Sam [ops] done", out.getvalue())
347
389
 
348
390
 
391
+ class StatusTests(Fixture):
392
+ def live_session(self):
393
+ t = self.t0
394
+ self.write([user(t, "hi"), assistant(t, "m1", 42)])
395
+ (self.cfg.claude / "sessions" / "1.json").write_text(json.dumps(
396
+ {"pid": os.getpid(), "sessionId": SID, "status": "busy", "startedAt": 1234}))
397
+ return t.date().isoformat()
398
+
399
+ def test_status_file(self):
400
+ day = self.live_session()
401
+ todos.apply(self.cfg, {"op": "add", "text": "ship it"})
402
+ data = status.write_now(self.cfg, today=day)
403
+ on_disk = json.loads(status.path(self.cfg).read_text())
404
+ self.assertEqual(on_disk, data)
405
+ self.assertEqual((data["schema"], data["state"], data["open"], data["working"]), (1, "busy", 1, 1))
406
+ self.assertEqual(data["sessions"][0]["state"], "busy")
407
+ self.assertEqual(data["sessions"][0]["started"], 1234)
408
+ self.assertEqual(data["today"]["output_tokens"], 42)
409
+ self.assertEqual(data["todos_open"], 1)
410
+ self.assertEqual(status.path(self.cfg).stat().st_mode & 0o777, 0o600)
411
+ self.assertNotIn(config.token(self.cfg), status.path(self.cfg).read_text())
412
+ self.assertLess(status.age(self.cfg), 5)
413
+ self.assertEqual(list(self.cfg.data.glob(".status.*")), []) # no temp files left
414
+
415
+ def test_no_sessions_and_removal(self):
416
+ data = status.write_now(self.cfg)
417
+ self.assertEqual((data["state"], data["open"], data["sessions"]), ("none", 0, []))
418
+ status.remove(self.cfg)
419
+ status.remove(self.cfg) # twice is fine
420
+ self.assertIsNone(status.age(self.cfg))
421
+
422
+ def test_daemon_loop_writes_then_cleans_up(self):
423
+ self.live_session()
424
+ stop, seen = threading.Event(), []
425
+
426
+ def watch():
427
+ for _ in range(200):
428
+ if status.path(self.cfg).exists():
429
+ seen.append(json.loads(status.path(self.cfg).read_text())["open"])
430
+ break
431
+ stop.wait(0.01)
432
+ stop.set()
433
+
434
+ threading.Thread(target=watch).start()
435
+ with unittest.mock.patch.object(status, "INTERVAL", 0.02):
436
+ status.run(self.cfg, stop)
437
+ self.assertEqual(seen, [1])
438
+ self.assertFalse(status.path(self.cfg).exists())
439
+
440
+ def test_daemon_command_runs_without_icon(self):
441
+ with unittest.mock.patch.object(config, "load", return_value=self.cfg), \
442
+ unittest.mock.patch.object(cli.signal, "signal"), \
443
+ unittest.mock.patch.object(cli.followups, "start_worker"), \
444
+ unittest.mock.patch.object(cli.status, "run") as run, \
445
+ unittest.mock.patch("sys.stdout", io.StringIO()) as out:
446
+ cli.main(["daemon"])
447
+ run.assert_called_once()
448
+ self.assertIn("status.json", out.getvalue())
449
+
450
+ def test_install_daemon_autostart(self):
451
+ target = Path(self.tmp.name) / "autostart" / "sessionpulse.desktop"
452
+ with unittest.mock.patch.object(install, "AUTOSTART", target):
453
+ install.enable_autostart("daemon")
454
+ self.assertIn("-m sessionpulse daemon", target.read_text())
455
+
456
+
349
457
  class HeadlessTests(Fixture):
350
458
  def run_cli(self, argv, env):
351
459
  out = io.StringIO()
File without changes
File without changes