sessionpulse 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """Session Pulse: a local dashboard and panel icon for Claude Code sessions."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
sessionpulse/cli.py ADDED
@@ -0,0 +1,158 @@
1
+ """Command line: sessionpulse <command>."""
2
+ import argparse
3
+ import json
4
+ import socket
5
+ import sys
6
+ import webbrowser
7
+
8
+ from . import __version__, config, followups, hook, install, reader, server, todos, tray
9
+
10
+
11
+ def cmd_serve(cfg, args):
12
+ srv = server.make_server(cfg)
13
+ followups.start_worker(cfg)
14
+ print(f"Session Pulse on {cfg.url}\nOpen it with: sessionpulse open")
15
+ try:
16
+ srv.serve_forever()
17
+ except KeyboardInterrupt:
18
+ pass
19
+
20
+
21
+ def cmd_tray(cfg, args):
22
+ tray.run(cfg)
23
+
24
+
25
+ def cmd_open(cfg, args):
26
+ webbrowser.open(server.open_url(cfg))
27
+
28
+
29
+ def cmd_hook(cfg, args):
30
+ return hook.run()
31
+
32
+
33
+ def cmd_install(cfg, args):
34
+ try:
35
+ changed = install.install_hooks(cfg)
36
+ except install.SettingsError as exc:
37
+ sys.exit(f"error: {exc}")
38
+ print(("Added" if changed else "Already present:") +
39
+ f" SessionStart/SessionEnd hooks in {cfg.claude / 'settings.json'}")
40
+ if args.no_autostart:
41
+ return
42
+ missing = tray.available()
43
+ if missing:
44
+ print(f"Skipped login autostart: {missing}\nYou can still run: sessionpulse serve")
45
+ return
46
+ print(f"Panel icon will start at login ({install.enable_autostart()}).")
47
+ print("Start it now with: sessionpulse tray &")
48
+
49
+
50
+ def cmd_uninstall(cfg, args):
51
+ try:
52
+ changed = install.uninstall_hooks(cfg)
53
+ except install.SettingsError as exc:
54
+ sys.exit(f"error: {exc}")
55
+ print("Removed Session Pulse hooks." if changed else "No Session Pulse hooks were installed.")
56
+ if install.disable_autostart():
57
+ print("Removed login autostart.")
58
+ print(f"Your data is kept in {cfg.data}; delete that folder to remove it.")
59
+
60
+
61
+ def cmd_doctor(cfg, args):
62
+ ok = True
63
+
64
+ def line(good, text):
65
+ nonlocal ok
66
+ ok &= bool(good)
67
+ print(f" {'ok ' if good else 'FIX'} {text}")
68
+
69
+ print(f"Session Pulse {__version__}")
70
+ line(cfg.claude.is_dir(), f"Claude Code folder: {cfg.claude}")
71
+ line((cfg.claude / "sessions").is_dir(), "live-session files (sessions/)")
72
+ snap = reader.snapshot(cfg)
73
+ h = snap["health"]
74
+ line(h["files"] > 0, f"{h['files']} transcripts, {len(snap['sessions'])} sessions, "
75
+ f"newest Claude Code {h['newest_cli'] or '?'}")
76
+ try:
77
+ inst = install.hooks_installed(cfg)
78
+ line(inst == set(install.EVENTS), "start/exit hooks installed" if inst == set(install.EVENTS)
79
+ else "start/exit hooks missing: run `sessionpulse install`")
80
+ except install.SettingsError as exc:
81
+ line(False, str(exc))
82
+ missing = tray.available()
83
+ print(f" {'ok ' if not missing else '-- '} {'panel icon available' if not missing else missing}")
84
+ with socket.socket() as s:
85
+ busy = s.connect_ex(("127.0.0.1", cfg.port)) == 0
86
+ print(f" {'ok ' if busy else '-- '} dashboard {'running' if busy else 'not running'} on {cfg.url}")
87
+ for w in h["warnings"]:
88
+ line(False, w)
89
+ return 0 if ok else 1
90
+
91
+
92
+ def cmd_snapshot(cfg, args):
93
+ json.dump(reader.snapshot(cfg), sys.stdout, indent=1)
94
+
95
+
96
+ def cmd_todo(cfg, args):
97
+ try:
98
+ if args.action == "add":
99
+ todos.apply(cfg, {"op": "add", "text": " ".join(args.text), "project": args.project})
100
+ elif args.action in ("done", "undo", "delete"):
101
+ for i in args.text:
102
+ todos.apply(cfg, {"op": args.action, "id": i})
103
+ except ValueError as exc:
104
+ sys.exit(f"error: {exc}")
105
+ items = todos.load(cfg)
106
+ show = [i for i in items if bool(i["done_at"]) == (args.action == "list-done")]
107
+ for i in show:
108
+ tag = f" [{i['project']}]" if i.get("project") else ""
109
+ when = f" done {i['done_at'][:10]}" if i["done_at"] else ""
110
+ print(f"{i['id']} {i['text']}{tag}{when}")
111
+ if not show:
112
+ print("Nothing here." if args.action == "list-done" else "No open to-dos.")
113
+
114
+
115
+ def cmd_followups(cfg, args):
116
+ if not followups.claude_bin(cfg):
117
+ sys.exit("error: the `claude` command was not found; set claude_command in config.toml")
118
+ try:
119
+ n, added, closed = followups.scan(cfg, days=args.days, idle_minutes=0 if args.now else None,
120
+ session=args.session, force=args.again,
121
+ log=lambda m: print(m, flush=True))
122
+ except (RuntimeError, OSError, ValueError) as exc:
123
+ sys.exit(f"error: {exc}")
124
+ print(f"Read {n} session(s): {added} follow-up(s) added, {closed} closed.")
125
+ if not cfg.followups:
126
+ print("Tip: add `followups = true` to ~/.config/sessionpulse/config.toml to do this automatically.")
127
+
128
+
129
+ def main(argv=None):
130
+ p = argparse.ArgumentParser(prog="sessionpulse", description=__doc__)
131
+ p.add_argument("--version", action="version", version=f"sessionpulse {__version__}")
132
+ sub = p.add_subparsers(dest="cmd", required=True)
133
+ sub.add_parser("install", help="add Claude Code hooks and login autostart") \
134
+ .add_argument("--no-autostart", action="store_true", help="only add the hooks")
135
+ sub.add_parser("uninstall", help="remove the hooks and autostart (keeps your data)")
136
+ sub.add_parser("tray", help="panel icon + dashboard server")
137
+ sub.add_parser("serve", help="dashboard server only")
138
+ sub.add_parser("open", help="open the dashboard in your browser")
139
+ sub.add_parser("doctor", help="check the setup and data formats")
140
+ sub.add_parser("snapshot", help="print the dashboard data as JSON")
141
+ t = sub.add_parser("todo", help="your to-do list: add, done, undo, delete, list, list-done")
142
+ t.add_argument("action", nargs="?", default="list",
143
+ choices=["list", "list-done", "add", "done", "undo", "delete"])
144
+ t.add_argument("text", nargs="*", help="the to-do text (add) or item ids (done/undo/delete)")
145
+ t.add_argument("-p", "--project", help="tag the new to-do with a project")
146
+ f = sub.add_parser("followups", help="pick open follow-ups out of recent sessions with Claude")
147
+ f.add_argument("--days", type=int, help="look back this many days (default: followups_days)")
148
+ f.add_argument("--session", help="only this session id (or its first characters)")
149
+ f.add_argument("--now", action="store_true", help="include sessions that are still active")
150
+ f.add_argument("--again", action="store_true", help="re-read sessions already read")
151
+ sub.add_parser("hook", help=argparse.SUPPRESS)
152
+ args = p.parse_args(argv)
153
+ cfg = config.load()
154
+ return globals()[f"cmd_{args.cmd}"](cfg, args) or 0
155
+
156
+
157
+ if __name__ == "__main__":
158
+ sys.exit(main())
sessionpulse/config.py ADDED
@@ -0,0 +1,90 @@
1
+ """Settings, read from ~/.config/sessionpulse/config.toml when it exists.
2
+
3
+ Every key is optional; the defaults below apply otherwise. Example file:
4
+
5
+ port = 8765
6
+ refresh_seconds = 5
7
+ claude_dir = "~/.claude"
8
+ gap_minutes = 30
9
+ """
10
+ import os
11
+ import secrets
12
+ import tomllib
13
+ from dataclasses import dataclass, field, fields
14
+ from pathlib import Path
15
+
16
+
17
+ def _xdg(var, fallback):
18
+ return Path(os.environ.get(var) or Path.home() / fallback)
19
+
20
+
21
+ CONFIG_DIR = _xdg("XDG_CONFIG_HOME", ".config") / "sessionpulse"
22
+ DATA_DIR = _xdg("XDG_DATA_HOME", ".local/share") / "sessionpulse"
23
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
24
+
25
+
26
+ def _default_claude_dir():
27
+ return os.environ.get("CLAUDE_CONFIG_DIR") or "~/.claude"
28
+
29
+
30
+ @dataclass
31
+ class Config:
32
+ port: int = 8765
33
+ refresh_seconds: int = 5
34
+ claude_dir: str = field(default_factory=_default_claude_dir)
35
+ data_dir: str = str(DATA_DIR)
36
+ # Active time: activity closer together than this counts as one block,
37
+ # and a block is never shorter than min_block_minutes.
38
+ gap_minutes: int = 30
39
+ min_block_minutes: int = 15
40
+ # Follow-ups: let Claude read quiet sessions and add their open items to your to-dos.
41
+ # Off by default because it runs `claude -p`, which counts toward your usage.
42
+ followups: bool = False
43
+ followups_model: str = "haiku"
44
+ followups_days: int = 30 # only sessions active in this many days
45
+ followups_idle_minutes: int = 20 # wait until a session has been quiet this long
46
+ followups_every_minutes: int = 5
47
+ followups_batch: int = 5 # sessions read per round
48
+ followups_parallel: int = 3 # sessions read at the same time
49
+ claude_command: str = "" # path to `claude`, if it isn't on PATH
50
+
51
+ @property
52
+ def claude(self) -> Path:
53
+ return Path(self.claude_dir).expanduser()
54
+
55
+ @property
56
+ def data(self) -> Path:
57
+ return Path(self.data_dir).expanduser()
58
+
59
+ @property
60
+ def events_file(self) -> Path:
61
+ return self.data / "events.jsonl"
62
+
63
+ @property
64
+ def url(self) -> str:
65
+ return f"http://127.0.0.1:{self.port}"
66
+
67
+
68
+ def load(path: Path = CONFIG_FILE) -> Config:
69
+ cfg = Config()
70
+ if path.exists():
71
+ with open(path, "rb") as f:
72
+ raw = tomllib.load(f)
73
+ known = {f.name for f in fields(Config)}
74
+ for k, v in raw.items():
75
+ if k in known:
76
+ setattr(cfg, k, v)
77
+ return cfg
78
+
79
+
80
+ def token(cfg: Config) -> str:
81
+ """The dashboard's access token, created on first use (mode 600)."""
82
+ path = cfg.data / "token"
83
+ if path.exists():
84
+ return path.read_text().strip()
85
+ cfg.data.mkdir(parents=True, exist_ok=True)
86
+ tok = secrets.token_urlsafe(24)
87
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
88
+ with os.fdopen(fd, "w") as f:
89
+ f.write(tok)
90
+ return tok
@@ -0,0 +1,273 @@
1
+ """Follow-ups: to-dos picked out of your sessions automatically.
2
+
3
+ Sessions often end with work that can't be finished in them: something to do
4
+ outside (call a vendor, check a site), an audit checklist, a decision you need
5
+ to take, an answer you're waiting for. This module asks Claude to read a quiet
6
+ session and list exactly those, then adds them to your to-do list, linked to the
7
+ session. It also closes earlier follow-ups that a later session shows are done.
8
+
9
+ It is off unless config.toml has `followups = true`, because it runs
10
+ `claude -p` (counts toward your Claude usage) on your transcripts. Each run uses
11
+ --no-session-persistence and disables all hooks, so it doesn't show up as a
12
+ session, in the dashboard or in any timesheet hook of yours.
13
+ """
14
+ import fcntl
15
+ import json
16
+ import os
17
+ import re
18
+ import shutil
19
+ import subprocess
20
+ import threading
21
+ import time
22
+ from concurrent.futures import ThreadPoolExecutor
23
+ from datetime import datetime, timedelta
24
+ from pathlib import Path
25
+
26
+ from . import reader, todos
27
+
28
+ KINDS = todos.KINDS
29
+ DIGEST_CHARS = 60000 # how much of a transcript goes to the model
30
+ USER_CHARS = 1200 # per prompt
31
+ CLAUDE_CHARS = 2500 # per reply
32
+ MAX_ITEMS = 12
33
+ TIMEOUT = 180
34
+
35
+ SYSTEM = """You read a transcript of a Claude Code session between a user and Claude and list the \
36
+ follow-ups the user still has open when the session went quiet. Reply only with the JSON the schema asks for.
37
+
38
+ A follow-up is something real and specific that was NOT finished in the session:
39
+ - external: work outside this session (contact a person or vendor, visit or check something physically, \
40
+ log in somewhere, send or sign something, buy or renew something)
41
+ - checklist: audit, review or verification items the user has to go through
42
+ - decision: a choice the user was asked to make, or deferred ("decide later", "waiting on you")
43
+ - waiting: an answer, document or action expected from someone else
44
+ - task: work to do in a later session (it was planned or promised but not done)
45
+
46
+ Do not list: things completed in the session; Claude's own intermediate steps; vague ideas; general \
47
+ advice; anything the user explicitly rejected. Merge duplicates. If nothing is open, return no items.
48
+
49
+ Write each item as a short imperative the user would put on their own list (max 110 characters), in \
50
+ English, naming the concrete thing ("Ask the vendor for the September invoice broken down by team"). \
51
+ Put the reason or context in `detail` (max 200 characters). Never copy passwords, keys or tokens into items.
52
+
53
+ 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"."""
56
+
57
+ SCHEMA = {
58
+ "type": "object",
59
+ "properties": {
60
+ "items": {"type": "array", "maxItems": MAX_ITEMS, "items": {
61
+ "type": "object",
62
+ "properties": {"text": {"type": "string"}, "kind": {"type": "string", "enum": list(KINDS)},
63
+ "detail": {"type": "string"}},
64
+ "required": ["text", "kind"]}},
65
+ "resolved": {"type": "array", "items": {"type": "string"}},
66
+ },
67
+ "required": ["items", "resolved"],
68
+ }
69
+
70
+ _state_lock = threading.Lock()
71
+ status = {"running": False, "last_run": None, "last_error": None, "done": 0}
72
+
73
+
74
+ # ---------- transcript digest ----------
75
+ def digest(path, project="", limit=DIGEST_CHARS):
76
+ """The user prompts and Claude's prose replies, oldest first, trimmed to fit."""
77
+ parts = []
78
+ with open(path, errors="replace") as f:
79
+ for line in f:
80
+ try:
81
+ e = json.loads(line)
82
+ except ValueError:
83
+ continue
84
+ if not isinstance(e, dict) or e.get("isSidechain") or e.get("type") not in ("user", "assistant"):
85
+ continue
86
+ msg = e.get("message") if isinstance(e.get("message"), dict) else {}
87
+ body = reader.text_of(msg.get("content")).strip()
88
+ if not body or (e["type"] == "user" and reader.is_noise(body)):
89
+ continue
90
+ body = re.sub(r"\n{3,}", "\n\n", body)
91
+ cap = USER_CHARS if e["type"] == "user" else CLAUDE_CHARS
92
+ if len(body) > cap:
93
+ body = body[:cap // 2] + "\n[…]\n" + body[-cap // 2:]
94
+ when = str(e.get("timestamp", ""))[:16].replace("T", " ")
95
+ parts.append(f"### {'USER' if e['type'] == 'user' else 'CLAUDE'} {when}\n{body}")
96
+ text = "\n\n".join(parts)
97
+ if len(text) > limit: # keep the opening request and the end, where open items get summed up
98
+ head = parts[0][:3000] if parts else ""
99
+ text = head + "\n\n[… earlier part of the session left out …]\n\n" + text[-(limit - len(head)):]
100
+ return f"Project: {project}\n\n{text}"
101
+
102
+
103
+ # ---------- running claude ----------
104
+ def claude_bin(cfg):
105
+ for c in (cfg.claude_command, shutil.which("claude"), str(Path.home() / ".local/bin/claude"),
106
+ str(Path.home() / ".claude/local/claude")):
107
+ if c and (os.sep not in c or Path(c).is_file()):
108
+ found = shutil.which(c) if os.sep not in c else c
109
+ if found:
110
+ return found
111
+ return None
112
+
113
+
114
+ def ask_claude(cfg, prompt):
115
+ exe = claude_bin(cfg)
116
+ if not exe:
117
+ raise RuntimeError("the `claude` command was not found; set claude_command in config.toml")
118
+ work = cfg.data / "followups-cwd"
119
+ work.mkdir(parents=True, exist_ok=True)
120
+ 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
+ env = {**os.environ, "SESSIONPULSE_CHILD": "1"}
124
+ r = subprocess.run(cmd, input=prompt, capture_output=True, text=True, cwd=work, env=env, timeout=TIMEOUT)
125
+ try:
126
+ out = json.loads(r.stdout)
127
+ except ValueError:
128
+ raise RuntimeError(f"claude exited {r.returncode}: {(r.stderr or r.stdout).strip()[:300]}")
129
+ if out.get("is_error") or not isinstance(out.get("structured_output"), dict):
130
+ raise RuntimeError(f"claude returned an error: {str(out.get('result'))[:300]}")
131
+ return out["structured_output"]
132
+
133
+
134
+ # ---------- state ----------
135
+ def _state_path(cfg):
136
+ return cfg.data / "followups.json"
137
+
138
+
139
+ def load_state(cfg):
140
+ try:
141
+ st = json.loads(_state_path(cfg).read_text())
142
+ return st if isinstance(st, dict) else {}
143
+ except (OSError, ValueError):
144
+ return {}
145
+
146
+
147
+ def _save_state(cfg, st):
148
+ cfg.data.mkdir(parents=True, exist_ok=True)
149
+ tmp = _state_path(cfg).with_suffix(".tmp")
150
+ tmp.write_text(json.dumps(st, indent=1))
151
+ os.replace(tmp, _state_path(cfg))
152
+
153
+
154
+ # ---------- one session ----------
155
+ def process(cfg, sess, path):
156
+ """Extract follow-ups from one session and merge them into the to-do list."""
157
+ items = todos.load(cfg)
158
+ project = sess.get("project") or ""
159
+ open_ = [i for i in items if not i.get("done_at") and i.get("project") == project][:40]
160
+ dismissed = [d for d in todos.dismissed(cfg) if d.get("project") == project][-40:]
161
+ prompt = digest(path, project)
162
+ prompt += "\n\n## Open to-dos for this project\n" + (
163
+ "\n".join(f"- [{i['id']}] {i['text']}" for i in open_) or "(none)")
164
+ prompt += "\n\n## Dismissed (never re-add)\n" + (
165
+ "\n".join(f"- {d['text']}" for d in dismissed) or "(none)")
166
+ out = ask_claude(cfg, prompt)
167
+ return todos.merge_followups(cfg, sess, out.get("items") or [], out.get("resolved") or [])
168
+
169
+
170
+ def candidates(cfg, snap, days=None, idle_minutes=None, force=False):
171
+ """Sessions worth (re)reading: recent, quiet for a while, and changed since last read."""
172
+ days = cfg.followups_days if days is None else days
173
+ idle = timedelta(minutes=cfg.followups_idle_minutes if idle_minutes is None else idle_minutes)
174
+ now = datetime.now().astimezone()
175
+ st = load_state(cfg)
176
+ out = []
177
+ for s in snap["sessions"]:
178
+ if not s.get("end") or not s.get("prompts") or not s.get("transcript"):
179
+ continue
180
+ end = datetime.fromisoformat(s["end"])
181
+ if now - end > timedelta(days=days) or now - end < idle or s.get("state") == "busy":
182
+ continue
183
+ try:
184
+ size = os.stat(s["transcript"]).st_size
185
+ except OSError:
186
+ continue
187
+ if not force and st.get(s["id"], {}).get("size") == size:
188
+ continue
189
+ out.append((s, size))
190
+ out.sort(key=lambda x: x[0]["end"]) # oldest first, so later sessions can close earlier items
191
+ return out
192
+
193
+
194
+ class Busy(RuntimeError):
195
+ pass
196
+
197
+
198
+ def scan(cfg, limit=None, days=None, idle_minutes=None, session=None, force=False, log=None):
199
+ """Read the sessions that need it. Returns (sessions read, items added, items closed).
200
+
201
+ Only one scan runs at a time across processes (the tray's worker and the CLI)."""
202
+ cfg.data.mkdir(parents=True, exist_ok=True)
203
+ with open(cfg.data / "followups.lock", "w") as lk:
204
+ try:
205
+ fcntl.flock(lk, fcntl.LOCK_EX | fcntl.LOCK_NB)
206
+ except OSError:
207
+ raise Busy("another follow-up scan is running; try again when it finishes")
208
+ return _scan(cfg, limit, days, idle_minutes, session, force, log)
209
+
210
+
211
+ def _scan(cfg, limit, days, idle_minutes, session, force, log):
212
+ with reader.lock:
213
+ snap = reader.snapshot(cfg)
214
+ todo = candidates(cfg, snap, days, idle_minutes, force or bool(session))
215
+ if session:
216
+ todo = [x for x in todo if x[0]["id"].startswith(session)]
217
+ if limit:
218
+ todo = todo[-limit:]
219
+ added = closed = 0
220
+ errors = []
221
+
222
+ def one(job):
223
+ n, (s, size) = job
224
+ try:
225
+ a, c = process(cfg, s, s["transcript"])
226
+ except (RuntimeError, OSError, ValueError, subprocess.TimeoutExpired) as exc:
227
+ errors.append(str(exc))
228
+ if log:
229
+ log(f"[{n}/{len(todo)}] {s['project']}: failed, will retry next time ({str(exc)[:120]})")
230
+ return 0, 0
231
+ with _state_lock:
232
+ st = load_state(cfg)
233
+ st[s["id"]] = {"size": size, "at": datetime.now().astimezone().isoformat(timespec="seconds"),
234
+ "added": a, "closed": c}
235
+ _save_state(cfg, st)
236
+ if log:
237
+ log(f"[{n}/{len(todo)}] {s['project']} · {s['end'][:16]} · +{a} new, {c} closed")
238
+ return a, c
239
+
240
+ # A few at a time: each request mostly waits on the model. Oldest first, so a later
241
+ # session usually gets to see (and close) what an earlier one left open.
242
+ with ThreadPoolExecutor(max_workers=max(1, cfg.followups_parallel)) as pool:
243
+ for a, c in pool.map(one, enumerate(todo, 1)):
244
+ added += a
245
+ closed += c
246
+ if errors and len(errors) == len(todo):
247
+ raise RuntimeError(errors[-1])
248
+ return len(todo), added, closed
249
+
250
+
251
+ # ---------- background worker ----------
252
+ def start_worker(cfg):
253
+ if not cfg.followups or os.environ.get("SESSIONPULSE_CHILD"):
254
+ return None
255
+
256
+ def loop():
257
+ time.sleep(30)
258
+ while True:
259
+ status["running"] = True
260
+ try:
261
+ status["done"] += scan(cfg, limit=cfg.followups_batch)[0]
262
+ status["last_error"] = None
263
+ status["last_run"] = datetime.now().astimezone().isoformat(timespec="seconds")
264
+ except Busy:
265
+ pass # `sessionpulse followups` is running; it covers the same sessions
266
+ except Exception as exc: # keep the worker alive; show the problem on the dashboard
267
+ status["last_error"] = str(exc)[:300]
268
+ status["running"] = False
269
+ time.sleep(cfg.followups_every_minutes * 60)
270
+
271
+ t = threading.Thread(target=loop, daemon=True, name="followups")
272
+ t.start()
273
+ return t
sessionpulse/hook.py ADDED
@@ -0,0 +1,39 @@
1
+ """`sessionpulse hook`: run by Claude Code on SessionStart and SessionEnd.
2
+
3
+ Appends one line to <data_dir>/events.jsonl. It must never fail loudly or
4
+ slow the session down, so every error is swallowed into errors.log.
5
+ """
6
+ import json
7
+ import os
8
+ import socket
9
+ import sys
10
+ from datetime import datetime, timezone
11
+
12
+ from . import config
13
+
14
+
15
+ def run(stdin=sys.stdin):
16
+ cfg = config.load()
17
+ try:
18
+ hook = json.load(stdin)
19
+ event = {
20
+ "ts": datetime.now(timezone.utc).isoformat(),
21
+ "event": hook.get("hook_event_name"),
22
+ "session": hook.get("session_id"),
23
+ "cwd": hook.get("cwd"),
24
+ "host": socket.gethostname(),
25
+ # SessionEnd sends "reason" (clear, logout, prompt_input_exit, other);
26
+ # SessionStart sends "source" (startup, resume, clear, compact).
27
+ "reason": hook.get("reason") or hook.get("source"),
28
+ }
29
+ cfg.data.mkdir(parents=True, exist_ok=True)
30
+ with open(cfg.events_file, "a") as f:
31
+ f.write(json.dumps(event) + "\n")
32
+ except Exception as exc: # noqa: BLE001 - never break the user's session
33
+ try:
34
+ cfg.data.mkdir(parents=True, exist_ok=True)
35
+ with open(cfg.data / "errors.log", "a") as f:
36
+ f.write(f"{datetime.now().isoformat()} hook {exc!r}\n")
37
+ except OSError:
38
+ pass
39
+ return 0
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22">
2
+ <!-- Session Pulse mark: a prompt whose cursor became a pulse line (from the illustration kit) -->
3
+ <path d="M2.6 7.2 L6.4 11 L2.6 14.8" fill="none" stroke="#eeeeee" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
4
+ <path d="M8.4 11 H10.2 L11.6 8.6 L13.4 14.6 L15.4 5.4 L17.2 12.6 L18.2 11 H20.2" fill="none" stroke="#f0a24a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
5
+ </svg>
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22">
2
+ <!-- Session Pulse mark: a prompt whose cursor became a pulse line (from the illustration kit) -->
3
+ <path d="M2.6 7.2 L6.4 11 L2.6 14.8" fill="none" stroke="#eeeeee" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
4
+ <path d="M8.4 11 H10.2 L11.6 8.6 L13.4 14.6 L15.4 5.4 L17.2 12.6 L18.2 11 H20.2" fill="none" stroke="#5cc27a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
5
+ </svg>
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22">
2
+ <!-- Session Pulse mark: a prompt whose cursor became a pulse line (from the illustration kit) -->
3
+ <path d="M2.6 7.2 L6.4 11 L2.6 14.8" fill="none" stroke="#eeeeee" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
4
+ <path d="M8.4 11 H10.2 L11.6 8.6 L13.4 14.6 L15.4 5.4 L17.2 12.6 L18.2 11 H20.2" fill="none" stroke="#8a94a0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
5
+ </svg>