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,149 @@
1
+ """Adds and removes Session Pulse's hooks in Claude Code's settings.json,
2
+ and its login autostart entry.
3
+
4
+ Our hook entries are recognised by HOOK_MARK in the command, so install is
5
+ idempotent (re-running updates the interpreter path) and uninstall removes
6
+ only what we added. settings.json is backed up before every change and
7
+ written atomically; if it isn't valid JSON we refuse to touch it.
8
+ """
9
+ import json
10
+ import os
11
+ import shutil
12
+ import sys
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+
16
+ from .config import Config
17
+
18
+ HOOK_MARK = "-m sessionpulse hook"
19
+ EVENTS = ("SessionStart", "SessionEnd")
20
+ AUTOSTART = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "autostart" / "sessionpulse.desktop"
21
+
22
+
23
+ class SettingsError(Exception):
24
+ pass
25
+
26
+
27
+ def hook_command():
28
+ return f'"{sys.executable}" {HOOK_MARK}'
29
+
30
+
31
+ def _settings_path(cfg: Config) -> Path:
32
+ return cfg.claude / "settings.json"
33
+
34
+
35
+ def _load(path: Path):
36
+ if not path.exists():
37
+ return {}
38
+ try:
39
+ data = json.loads(path.read_text())
40
+ except ValueError as exc:
41
+ raise SettingsError(f"{path} is not valid JSON ({exc}); not changing it.") from exc
42
+ if not isinstance(data, dict):
43
+ raise SettingsError(f"{path} is not a JSON object; not changing it.")
44
+ return data
45
+
46
+
47
+ def _save(path: Path, data):
48
+ if path.exists():
49
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
50
+ shutil.copy2(path, path.with_name(f"{path.name}.sessionpulse-backup-{stamp}"))
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ tmp = path.with_name(path.name + ".sessionpulse-tmp")
53
+ tmp.write_text(json.dumps(data, indent=2) + "\n")
54
+ os.replace(tmp, path)
55
+
56
+
57
+ def _ours(hook):
58
+ return isinstance(hook, dict) and HOOK_MARK in str(hook.get("command", ""))
59
+
60
+
61
+ def hooks_installed(cfg: Config):
62
+ data = _load(_settings_path(cfg))
63
+ found = set()
64
+ for ev in EVENTS:
65
+ for group in (data.get("hooks") or {}).get(ev) or []:
66
+ if any(_ours(h) for h in (group or {}).get("hooks") or []):
67
+ found.add(ev)
68
+ return found
69
+
70
+
71
+ def install_hooks(cfg: Config):
72
+ path = _settings_path(cfg)
73
+ data = _load(path)
74
+ hooks = data.setdefault("hooks", {})
75
+ if not isinstance(hooks, dict):
76
+ raise SettingsError(f'"hooks" in {path} is not an object; not changing it.')
77
+ ours = {"type": "command", "command": hook_command(), "timeout": 10}
78
+ changed = False
79
+ for ev in EVENTS:
80
+ groups = hooks.setdefault(ev, [])
81
+ present = False
82
+ for group in groups:
83
+ for i, h in enumerate((group or {}).get("hooks") or []):
84
+ if _ours(h):
85
+ present = True
86
+ if h != ours:
87
+ group["hooks"][i] = dict(ours)
88
+ changed = True
89
+ if not present:
90
+ groups.append({"hooks": [dict(ours)]})
91
+ changed = True
92
+ if changed:
93
+ _save(path, data)
94
+ return changed
95
+
96
+
97
+ def uninstall_hooks(cfg: Config):
98
+ path = _settings_path(cfg)
99
+ data = _load(path)
100
+ hooks = data.get("hooks")
101
+ if not isinstance(hooks, dict):
102
+ return False
103
+ changed = False
104
+ for ev in EVENTS:
105
+ groups = hooks.get(ev)
106
+ if not isinstance(groups, list):
107
+ continue
108
+ kept = []
109
+ for group in groups:
110
+ inner = (group or {}).get("hooks") or []
111
+ left = [h for h in inner if not _ours(h)]
112
+ if len(left) != len(inner):
113
+ changed = True
114
+ if not left:
115
+ continue
116
+ group = {**group, "hooks": left}
117
+ kept.append(group)
118
+ if kept:
119
+ hooks[ev] = kept
120
+ else:
121
+ del hooks[ev]
122
+ if not hooks:
123
+ data.pop("hooks")
124
+ if changed:
125
+ _save(path, data)
126
+ return changed
127
+
128
+
129
+ def enable_autostart():
130
+ icon = Path(__file__).with_name("icons") / "sessionpulse-idle.svg"
131
+ AUTOSTART.parent.mkdir(parents=True, exist_ok=True)
132
+ AUTOSTART.write_text(
133
+ "[Desktop Entry]\n"
134
+ "Type=Application\n"
135
+ "Name=Session Pulse\n"
136
+ "Comment=Panel icon and dashboard for Claude Code sessions\n"
137
+ f'Exec="{sys.executable}" -m sessionpulse tray\n'
138
+ f"Icon={icon}\n"
139
+ "Terminal=false\n"
140
+ "X-GNOME-Autostart-enabled=true\n"
141
+ "X-GNOME-Autostart-Delay=5\n")
142
+ return AUTOSTART
143
+
144
+
145
+ def disable_autostart():
146
+ if AUTOSTART.exists():
147
+ AUTOSTART.unlink()
148
+ return True
149
+ return False
sessionpulse/learn.py ADDED
@@ -0,0 +1,158 @@
1
+ """The dashboard's "Get better at Claude Code" section.
2
+
3
+ LIBRARY is a short, hand-checked list of free, official places to learn.
4
+ suggest() looks at your own sessions and puts the most relevant items first,
5
+ with the reason taken from your data ("9 sessions ran past 40 prompts").
6
+ It only reads what the reader already parsed, plus whether CLAUDE.md exists in
7
+ your busiest project folders and whether settings.json has hooks of your own.
8
+ """
9
+ import json
10
+ from datetime import datetime, timedelta
11
+ from pathlib import Path
12
+
13
+ DOCS = "https://code.claude.com/docs/en/"
14
+
15
+ # level: start = new to Claude Code, better = using it daily, advanced = extending it.
16
+ LIBRARY = [
17
+ {"id": "quickstart", "level": "start", "kind": "Docs", "title": "Quickstart",
18
+ "url": DOCS + "quickstart", "blurb": "Your first real task, step by step."},
19
+ {"id": "course-cc", "level": "better", "kind": "Course",
20
+ "title": "Claude Code in Action (Anthropic Academy)",
21
+ "url": "https://anthropic.skilljar.com/claude-code-in-action",
22
+ "blurb": "Free course for daily users: long sessions, CLAUDE.md and skills, automating repeat work."},
23
+ {"id": "best-practices", "level": "start", "kind": "Docs", "title": "Best practices for Claude Code",
24
+ "url": DOCS + "best-practices", "blurb": "How to give context, check work and keep sessions on track."},
25
+ {"id": "workflows", "level": "start", "kind": "Docs", "title": "Common workflows",
26
+ "url": DOCS + "common-workflows", "blurb": "Recipes: understand a codebase, fix bugs, refactor, write tests, open PRs."},
27
+ {"id": "eng-blog", "level": "better", "kind": "Article", "title": "Claude Code: best practices for agentic coding",
28
+ "url": "https://www.anthropic.com/engineering/claude-code-best-practices",
29
+ "blurb": "Anthropic's engineers on the habits that work: explore, plan, code, commit."},
30
+ {"id": "memory", "level": "better", "kind": "Docs", "title": "How Claude remembers your project (CLAUDE.md)",
31
+ "url": DOCS + "memory", "blurb": "Write project instructions once instead of repeating them every session."},
32
+ {"id": "context", "level": "better", "kind": "Docs", "title": "Explore the context window",
33
+ "url": DOCS + "context-window", "blurb": "What fills the context, and when to /clear or /compact."},
34
+ {"id": "costs", "level": "better", "kind": "Docs", "title": "Manage costs effectively",
35
+ "url": DOCS + "costs", "blurb": "Keep token use down without slowing yourself down."},
36
+ {"id": "plan-mode", "level": "better", "kind": "Docs", "title": "Permission modes, including plan mode",
37
+ "url": DOCS + "permission-modes", "blurb": "Have Claude plan and agree the approach before it edits anything."},
38
+ {"id": "prompting", "level": "better", "kind": "Docs", "title": "Prompt engineering overview",
39
+ "url": "https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/overview",
40
+ "blurb": "Be clear and specific, give examples, say what done looks like."},
41
+ {"id": "prompt-tutorial", "level": "better", "kind": "Tutorial", "title": "Interactive prompt engineering tutorial",
42
+ "url": "https://github.com/anthropics/prompt-eng-interactive-tutorial",
43
+ "blurb": "Nine hands-on chapters with exercises, from Anthropic."},
44
+ {"id": "subagents", "level": "advanced", "kind": "Docs", "title": "Create custom subagents",
45
+ "url": DOCS + "sub-agents", "blurb": "Hand research or review to a helper with its own context."},
46
+ {"id": "skills", "level": "advanced", "kind": "Docs", "title": "Extend Claude with skills",
47
+ "url": DOCS + "skills", "blurb": "Package a procedure you repeat so Claude follows it the same way every time."},
48
+ {"id": "course-skills", "level": "advanced", "kind": "Course",
49
+ "title": "Introduction to agent skills (Anthropic Academy)",
50
+ "url": "https://anthropic.skilljar.com/introduction-to-agent-skills",
51
+ "blurb": "Free course on writing and using skills."},
52
+ {"id": "mcp", "level": "advanced", "kind": "Docs", "title": "Connect Claude Code to tools via MCP",
53
+ "url": DOCS + "mcp", "blurb": "Let Claude use your issue tracker, database, browser or chat."},
54
+ {"id": "course-mcp", "level": "advanced", "kind": "Course",
55
+ "title": "Introduction to Model Context Protocol (Anthropic Academy)",
56
+ "url": "https://anthropic.skilljar.com/introduction-to-model-context-protocol",
57
+ "blurb": "Free course: how MCP servers work and how to build one."},
58
+ {"id": "hooks", "level": "advanced", "kind": "Docs", "title": "Automate actions with hooks",
59
+ "url": DOCS + "hooks-guide", "blurb": "Run formatters, checks or notifications automatically at set points."},
60
+ {"id": "worktrees", "level": "advanced", "kind": "Docs", "title": "Run parallel sessions with worktrees",
61
+ "url": DOCS + "worktrees", "blurb": "Work on several tasks at once without them touching each other."},
62
+ {"id": "headless", "level": "advanced", "kind": "Docs", "title": "Run Claude Code programmatically",
63
+ "url": DOCS + "headless", "blurb": "Use Claude Code from scripts, cron and CI."},
64
+ {"id": "whats-new", "level": "current", "kind": "Docs", "title": "What's new (weekly)",
65
+ "url": DOCS + "whats-new", "blurb": "A short weekly note on new features."},
66
+ {"id": "academy", "level": "current", "kind": "Course", "title": "All Anthropic Academy courses",
67
+ "url": "https://anthropic.skilljar.com/", "blurb": "Every free course, including the API and MCP tracks."},
68
+ ]
69
+
70
+ WINDOW_DAYS = 30
71
+ MIN_SESSIONS = 5 # below this there isn't enough to judge habits
72
+ LONG_PROMPTS = 40 # a session past this many prompts is "long"
73
+ SHORT_WORDS = 4 # a first prompt this short rarely carries enough context
74
+ BUSIEST = 8 # how many projects to check for CLAUDE.md
75
+
76
+
77
+ def _has_memory(cwd):
78
+ p = Path(cwd)
79
+ return any((p / f).is_file() for f in ("CLAUDE.md", ".claude/CLAUDE.md", "CLAUDE.local.md"))
80
+
81
+
82
+ def _own_hooks(cfg):
83
+ """True/False if settings.json has hooks besides Session Pulse's; None if unreadable."""
84
+ try:
85
+ hooks = json.loads((cfg.claude / "settings.json").read_text()).get("hooks") or {}
86
+ except (OSError, ValueError, AttributeError):
87
+ return None
88
+ text = json.dumps(hooks)
89
+ return text.count('"command"') > text.count("-m sessionpulse hook")
90
+
91
+
92
+ def suggest(cfg, sessions, now=None):
93
+ now = now or datetime.now().astimezone()
94
+ cutoff = (now - timedelta(days=WINDOW_DAYS)).isoformat()
95
+ recent = [s for s in sessions if (s.get("end") or "") >= cutoff and s.get("prompts")]
96
+ out = {"window_days": WINDOW_DAYS, "sessions": len(recent), "suggestions": [],
97
+ "strengths": [], "library": LIBRARY}
98
+ if len(recent) < MIN_SESSIONS:
99
+ return out
100
+
101
+ tools = {}
102
+ for s in sessions: # feature use counts over all history: one use is enough to know it
103
+ for k, v in (s.get("tool_names") or {}).items():
104
+ tools[k] = tools.get(k, 0) + v
105
+ agents = sum(s.get("agents") or 0 for s in sessions)
106
+
107
+ def add(sid, title, why, links):
108
+ out["suggestions"].append({"id": sid, "title": title, "why": why, "links": links})
109
+
110
+ hours = {}
111
+ for s in recent:
112
+ if s.get("cwd"):
113
+ hours[s["cwd"]] = hours.get(s["cwd"], 0) + (s.get("hours") or 0)
114
+ busiest = sorted(hours, key=hours.get, reverse=True)[:BUSIEST]
115
+ missing = [s for s in busiest if Path(s) != Path.home() and Path(s).is_dir() and not _has_memory(s)]
116
+ if missing:
117
+ names = ", ".join(Path(m).name for m in missing[:4]) + (" …" if len(missing) > 4 else "")
118
+ add("memory", "Give your projects a CLAUDE.md",
119
+ f"{len(missing)} of your {len(busiest)} busiest projects have none ({names}). "
120
+ "Run /init in one to start it.", ["memory", "best-practices"])
121
+ else:
122
+ out["strengths"].append("Your busiest projects have a CLAUDE.md")
123
+
124
+ long_ = [s for s in recent if (s.get("prompts") or 0) >= LONG_PROMPTS]
125
+ if len(long_) >= 2:
126
+ add("context", "Keep sessions short and focused",
127
+ f"{len(long_)} sessions in the last {WINDOW_DAYS} days ran past {LONG_PROMPTS} prompts. "
128
+ "Long sessions fill the context, which costs tokens and makes earlier details easier to lose. "
129
+ "Use /clear between unrelated tasks.", ["context", "costs", "course-cc"])
130
+
131
+ short = [s for s in recent if 0 < len((s.get("first_prompt") or "").split()) <= SHORT_WORDS]
132
+ if len(short) >= 3 and len(short) / len(recent) >= 0.25:
133
+ add("prompting", "Open with more context",
134
+ f"{len(short)} of {len(recent)} recent sessions started with {SHORT_WORDS} words or fewer. "
135
+ "Say what you want, where it is and how you'll judge it done.",
136
+ ["prompting", "prompt-tutorial", "eng-blog"])
137
+
138
+ def feature(used, sid, title, why, links, strength):
139
+ if used:
140
+ out["strengths"].append(strength)
141
+ else:
142
+ add(sid, title, why, links)
143
+
144
+ feature(tools.get("ExitPlanMode") or tools.get("EnterPlanMode"), "plan-mode", "Try plan mode on bigger changes",
145
+ "None of your sessions used plan mode. Press Shift+Tab until it shows plan mode, and Claude "
146
+ "proposes a plan before it touches files.", ["plan-mode", "workflows"], "You use plan mode")
147
+ feature(agents or tools.get("Agent") or tools.get("Task"), "subagents", "Hand side-work to subagents",
148
+ "No session used a subagent yet. They do research or reviews in their own context, "
149
+ "so your main session stays clean.", ["subagents"], "You use subagents")
150
+ feature(tools.get("Skill"), "skills", "Turn repeated instructions into a skill",
151
+ "No session used a skill yet.", ["skills", "course-skills"], "You use skills")
152
+ feature(any(k.startswith("mcp__") for k in tools), "mcp", "Connect the tools you work in",
153
+ "No session used an MCP server yet.", ["mcp", "course-mcp"], "You connect tools through MCP")
154
+ own = _own_hooks(cfg)
155
+ if own is not None:
156
+ feature(own, "hooks", "Automate the routine with hooks",
157
+ "Your settings have no hooks of your own yet.", ["hooks"], "You automate with hooks")
158
+ return out
sessionpulse/reader.py ADDED
@@ -0,0 +1,333 @@
1
+ """Reads Claude Code's own files and turns them into one JSON-able snapshot.
2
+
3
+ Sources (all read-only):
4
+ <claude_dir>/sessions/*.json one file per running CLI process
5
+ <claude_dir>/projects/*/*.jsonl session transcripts
6
+ <claude_dir>/projects/*/<id>/subagents/*.jsonl subagent transcripts
7
+ <data_dir>/events.jsonl start/exit events written by our hook
8
+
9
+ None of these formats is documented by Anthropic, so every field is read
10
+ defensively and anything surprising is counted in snapshot["health"] instead
11
+ of raising. The dashboard shows those warnings so a Claude Code update that
12
+ changes a format is visible rather than silently producing wrong numbers.
13
+ """
14
+ import json
15
+ import os
16
+ import re
17
+ import threading
18
+ from collections import defaultdict
19
+ from datetime import datetime, timedelta
20
+ from pathlib import Path
21
+
22
+ from . import learn, todos
23
+ from .config import Config
24
+
25
+ TESTED_CLI = (2, 1) # newest Claude Code minor version these parsers were checked against
26
+ LIVE_KEYS = ("pid", "sessionId", "status")
27
+
28
+ _cache = {} # path -> (mtime_ns, size, summary)
29
+ lock = threading.Lock()
30
+
31
+
32
+ def local(s):
33
+ return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone()
34
+
35
+
36
+ def text_of(content):
37
+ if isinstance(content, str):
38
+ return content
39
+ if isinstance(content, list):
40
+ return "\n".join(c.get("text", "") for c in content
41
+ if isinstance(c, dict) and c.get("type") == "text")
42
+ return ""
43
+
44
+
45
+ def is_noise(t):
46
+ """Messages the CLI injects (command output, skill text, interrupts) rather than prompts."""
47
+ t = t.strip()
48
+ return (not t or t.startswith("<") or t.startswith("Caveat:")
49
+ or t.startswith("Base directory for this skill")
50
+ or "[Request interrupted" in t)
51
+
52
+
53
+ def active_hours(stamps, gap=timedelta(minutes=30), min_block=timedelta(minutes=15)):
54
+ if not stamps:
55
+ return 0.0
56
+ stamps = sorted(stamps)
57
+ total, start, prev = timedelta(), stamps[0], stamps[0]
58
+ for t in stamps[1:]:
59
+ if t - prev > gap:
60
+ total += max(prev - start, min_block)
61
+ start = t
62
+ prev = t
63
+ total += max(prev - start, min_block)
64
+ return total.total_seconds() / 3600
65
+
66
+
67
+ def project_name(cwd):
68
+ if not cwd:
69
+ return "?"
70
+ home = str(Path.home())
71
+ if cwd == home:
72
+ return "~"
73
+ if cwd.startswith(home + os.sep):
74
+ return os.path.relpath(cwd, home).replace(os.sep, "/")
75
+ return cwd
76
+
77
+
78
+ def parse_transcript(path, main=True):
79
+ """Summarise one transcript file.
80
+
81
+ Token usage is de-duplicated by message id: the CLI writes one line per
82
+ content block, and each line repeats the same usage object."""
83
+ s = {"cwd": None, "first": None, "last": None, "prompts": 0, "first_prompt": "",
84
+ "last_prompt": "", "models": defaultdict(int), "version": None, "title": None,
85
+ "usage": defaultdict(int), "days": defaultdict(lambda: defaultdict(int)),
86
+ "stamps": [], "tools": 0, "tool_names": defaultdict(int), "bad_lines": 0, "lines": 0, "usage_msgs": 0}
87
+ seen = set()
88
+ with open(path, errors="replace") as f:
89
+ for line in f:
90
+ s["lines"] += 1
91
+ try:
92
+ e = json.loads(line)
93
+ except ValueError:
94
+ s["bad_lines"] += 1
95
+ continue
96
+ if not isinstance(e, dict):
97
+ s["bad_lines"] += 1
98
+ continue
99
+ typ = e.get("type")
100
+ if main and typ in ("custom-title", "summary"):
101
+ s["title"] = e.get("customTitle") or e.get("summary") or s["title"]
102
+ if s["cwd"] is None and isinstance(e.get("cwd"), str):
103
+ s["cwd"] = e["cwd"]
104
+ v = e.get("version")
105
+ if isinstance(v, str) and (_version_tuple(v) or ()) >= (_version_tuple(s["version"]) or ()):
106
+ s["version"] = v # a resumed session can span CLI upgrades; keep the newest
107
+ try:
108
+ t = local(e["timestamp"])
109
+ except (KeyError, TypeError, ValueError, AttributeError):
110
+ continue
111
+ s["first"] = min(s["first"] or t, t)
112
+ s["last"] = max(s["last"] or t, t)
113
+ msg = e.get("message") if isinstance(e.get("message"), dict) else {}
114
+ if main and not e.get("isSidechain"):
115
+ s["stamps"].append(t)
116
+ if typ == "user" and msg.get("role") == "user":
117
+ body = text_of(msg.get("content"))
118
+ if not is_noise(body):
119
+ body = re.sub(r"\s+", " ", body).strip()
120
+ s["prompts"] += 1
121
+ s["first_prompt"] = s["first_prompt"] or body[:300]
122
+ s["last_prompt"] = body[:300]
123
+ if typ != "assistant":
124
+ continue
125
+ content = msg.get("content")
126
+ for c in content if isinstance(content, list) else []:
127
+ if isinstance(c, dict) and c.get("type") == "tool_use":
128
+ s["tools"] += 1
129
+ if isinstance(c.get("name"), str):
130
+ s["tool_names"][c["name"]] += 1
131
+ u = msg.get("usage")
132
+ key = msg.get("id") or e.get("requestId") or e.get("uuid")
133
+ if not isinstance(u, dict) or key in seen:
134
+ continue
135
+ seen.add(key)
136
+ model = msg.get("model") or "unknown"
137
+ if model.startswith("<"): # synthetic messages, e.g. "<synthetic>"
138
+ continue
139
+ s["usage_msgs"] += 1
140
+ vals = {"input": u.get("input_tokens") or 0,
141
+ "output": u.get("output_tokens") or 0,
142
+ "cache_write": u.get("cache_creation_input_tokens") or 0,
143
+ "cache_read": u.get("cache_read_input_tokens") or 0}
144
+ s["models"][model] += vals["input"] + vals["output"]
145
+ day = t.date().isoformat()
146
+ for k, v in vals.items():
147
+ if isinstance(v, (int, float)):
148
+ s["usage"][k] += v
149
+ s["days"][day][f"{model}|{k}"] += v
150
+ return s
151
+
152
+
153
+ def _cached(path, main=True):
154
+ try:
155
+ st = os.stat(path)
156
+ except OSError:
157
+ return None
158
+ hit = _cache.get(path)
159
+ if hit and hit[0] == st.st_mtime_ns and hit[1] == st.st_size:
160
+ return hit[2]
161
+ try:
162
+ summ = parse_transcript(path, main)
163
+ except OSError:
164
+ return None
165
+ _cache[path] = (st.st_mtime_ns, st.st_size, summ)
166
+ return summ
167
+
168
+
169
+ def pid_alive(pid):
170
+ if not isinstance(pid, int) or pid <= 0:
171
+ return False
172
+ try:
173
+ os.kill(pid, 0)
174
+ return True
175
+ except PermissionError:
176
+ return True
177
+ except OSError:
178
+ return False
179
+
180
+
181
+ def read_live(claude: Path, health):
182
+ live = {}
183
+ d = claude / "sessions"
184
+ if not d.is_dir():
185
+ health["warnings"].append(f"{d} not found: live sessions can't be shown "
186
+ "(older Claude Code, or a changed layout).")
187
+ return live
188
+ for p in d.glob("*.json"):
189
+ try:
190
+ j = json.loads(p.read_text())
191
+ except (OSError, ValueError):
192
+ continue
193
+ if not isinstance(j, dict) or any(k not in j for k in LIVE_KEYS):
194
+ health["live_unrecognised"] += 1
195
+ continue
196
+ if not pid_alive(j.get("pid")):
197
+ continue
198
+ live[j["sessionId"]] = {
199
+ "pid": j.get("pid"), "status": j.get("status"), "name": j.get("name"),
200
+ "kind": j.get("kind"), "entrypoint": j.get("entrypoint"),
201
+ "startedAt": j.get("startedAt"),
202
+ "updatedAt": j.get("statusUpdatedAt") or j.get("updatedAt"),
203
+ }
204
+ return live
205
+
206
+
207
+ def read_events(path: Path):
208
+ out = []
209
+ if path.exists():
210
+ with open(path, errors="replace") as f:
211
+ for line in f:
212
+ try:
213
+ e = json.loads(line)
214
+ except ValueError:
215
+ continue
216
+ if isinstance(e, dict) and e.get("ts"):
217
+ out.append(e)
218
+ return out
219
+
220
+
221
+ def _version_tuple(v):
222
+ try:
223
+ return tuple(int(x) for x in v.split("."))
224
+ except (AttributeError, ValueError):
225
+ return None
226
+
227
+
228
+ def snapshot(cfg: Config):
229
+ claude = cfg.claude
230
+ gap = timedelta(minutes=cfg.gap_minutes)
231
+ min_block = timedelta(minutes=cfg.min_block_minutes)
232
+ health = {"warnings": [], "files": 0, "bad_lines": 0, "lines": 0,
233
+ "live_unrecognised": 0, "newest_cli": None}
234
+ sessions = {}
235
+ proj_dir = claude / "projects"
236
+ if not proj_dir.is_dir():
237
+ health["warnings"].append(f"No transcripts at {proj_dir}. Is claude_dir right?")
238
+ newest = None
239
+ usage_msgs = 0
240
+ for pdir in proj_dir.iterdir() if proj_dir.is_dir() else []:
241
+ if not pdir.is_dir():
242
+ continue
243
+ for tf in pdir.glob("*.jsonl"):
244
+ sid = tf.stem
245
+ s = _cached(str(tf))
246
+ if not s:
247
+ continue
248
+ health["files"] += 1
249
+ health["bad_lines"] += s["bad_lines"]
250
+ health["lines"] += s["lines"]
251
+ usage_msgs += s["usage_msgs"]
252
+ if not s["first"]:
253
+ continue
254
+ if s["version"] and (newest is None or
255
+ (_version_tuple(s["version"]) or ()) > (_version_tuple(newest) or ())):
256
+ newest = s["version"]
257
+ usage = dict(s["usage"])
258
+ days = {d: dict(v) for d, v in s["days"].items()}
259
+ models = dict(s["models"])
260
+ tool_names = dict(s["tool_names"])
261
+ agents = 0
262
+ for af in (pdir / sid / "subagents").glob("*.jsonl"):
263
+ a = _cached(str(af), main=False)
264
+ if not a:
265
+ continue
266
+ agents += 1
267
+ for k, v in a["usage"].items():
268
+ usage[k] = usage.get(k, 0) + v
269
+ for m, v in a["models"].items():
270
+ models[m] = models.get(m, 0) + v
271
+ for d, kv in a["days"].items():
272
+ dd = days.setdefault(d, {})
273
+ for k, v in kv.items():
274
+ dd[k] = dd.get(k, 0) + v
275
+ if not s["prompts"] and not usage.get("output"):
276
+ continue
277
+ by_day = defaultdict(list)
278
+ for t in s["stamps"]:
279
+ by_day[t.date().isoformat()].append(t)
280
+ sessions[sid] = {
281
+ "id": sid, "cwd": s["cwd"], "project": project_name(s["cwd"]), "transcript": str(tf),
282
+ "title": s["title"], "start": s["first"].isoformat(), "end": s["last"].isoformat(),
283
+ "prompts": s["prompts"], "first_prompt": s["first_prompt"],
284
+ "last_prompt": s["last_prompt"], "tools": s["tools"], "tool_names": tool_names,
285
+ "agents": agents,
286
+ "version": s["version"], "usage": usage, "days": days,
287
+ "model": max(models, key=models.get) if models else None,
288
+ "hours": round(active_hours(s["stamps"], gap, min_block), 2),
289
+ "hours_by_day": {d: round(active_hours(v, gap, min_block), 2)
290
+ for d, v in by_day.items()},
291
+ }
292
+
293
+ health["newest_cli"] = newest
294
+ if health["lines"] and health["bad_lines"] / health["lines"] > 0.05:
295
+ health["warnings"].append(f"{health['bad_lines']} of {health['lines']} transcript lines "
296
+ "could not be read; the transcript format may have changed.")
297
+ if health["files"] and sessions and not usage_msgs:
298
+ health["warnings"].append("No token usage found in any transcript; "
299
+ "the usage format may have changed.")
300
+ if health["live_unrecognised"]:
301
+ health["warnings"].append(f"{health['live_unrecognised']} live-session files had an "
302
+ "unexpected shape and were skipped.")
303
+ vt = _version_tuple(newest)
304
+ if vt and vt[:2] > TESTED_CLI:
305
+ health["warnings"].append(f"Claude Code {newest} is newer than the version Session Pulse "
306
+ f"was tested with ({'.'.join(map(str, TESTED_CLI))}). "
307
+ "Check the numbers look right.")
308
+
309
+ live = read_live(claude, health)
310
+ events = read_events(cfg.events_file)
311
+ exits = {e.get("session"): e for e in events if e.get("event") == "SessionEnd"}
312
+ for sid, s in sessions.items():
313
+ if sid in live:
314
+ s["state"] = live[sid]["status"] or "running"
315
+ s["live"] = live[sid]
316
+ elif sid in exits:
317
+ s["state"] = "exited"
318
+ s["exit_reason"] = exits[sid].get("reason")
319
+ s["exit_at"] = exits[sid].get("ts")
320
+ else:
321
+ s["state"] = "closed"
322
+ for sid, l in live.items(): # just started; no transcript written yet
323
+ if sid not in sessions:
324
+ sessions[sid] = {"id": sid, "project": "?", "state": l["status"] or "running",
325
+ "live": l, "start": None, "end": None, "prompts": 0,
326
+ "usage": {}, "days": {}, "hours": 0, "hours_by_day": {}}
327
+ for e in events:
328
+ s = sessions.get(e.get("session"))
329
+ e["project"] = s["project"] if s else project_name(e.get("cwd"))
330
+ sessions = list(sessions.values())
331
+ return {"now": datetime.now().astimezone().isoformat(), "refresh": cfg.refresh_seconds,
332
+ "sessions": sessions, "events": events[-300:], "health": health,
333
+ "todos": todos.load(cfg), "learn": learn.suggest(cfg, sessions)}