claude-finops 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +361 -0
- package/bin/claude-finops.js +74 -0
- package/config/free_models.json +49 -0
- package/config/model_compare.json +13 -0
- package/config/pricing.json +232 -0
- package/config/settings.json +57 -0
- package/finops/__init__.py +0 -0
- package/finops/actions.py +671 -0
- package/finops/agents.py +296 -0
- package/finops/analytics.py +1579 -0
- package/finops/api.py +426 -0
- package/finops/classify.py +48 -0
- package/finops/cloud.py +336 -0
- package/finops/diagnose.py +1002 -0
- package/finops/etl.py +533 -0
- package/finops/paths.py +111 -0
- package/finops/playbook.py +229 -0
- package/finops/pricing.py +74 -0
- package/finops/procs.py +499 -0
- package/finops/report.py +169 -0
- package/package.json +52 -0
- package/run.cmd +4 -0
- package/run.py +210 -0
- package/run.sh +3 -0
- package/web/app.js +2923 -0
- package/web/charts.js +340 -0
- package/web/index.html +14 -0
- package/web/styles.css +387 -0
package/finops/procs.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""Running agent sessions (Claude Code, Codex, Gemini CLI, Cursor) and the actions the dashboard can take on them.
|
|
2
|
+
|
|
3
|
+
Source of truth is ~/.claude/sessions/<pid>.json (written by Claude Code), checked
|
|
4
|
+
against the live process table. A signal is only ever sent to a PID that, at the
|
|
5
|
+
moment of the request, is alive, runs `claude`, and has the same start time as the
|
|
6
|
+
registry entry — so a recycled PID can never be hit.
|
|
7
|
+
"""
|
|
8
|
+
import glob
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import signal
|
|
13
|
+
import sys
|
|
14
|
+
import subprocess
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
HOME = os.path.expanduser("~")
|
|
18
|
+
REG = os.path.join(HOME, ".claude", "sessions")
|
|
19
|
+
PROJECTS = os.path.join(HOME, ".claude", "projects")
|
|
20
|
+
|
|
21
|
+
ACTIONS = {
|
|
22
|
+
"interrupt": (getattr(signal, "CTRL_C_EVENT", signal.SIGINT) if os.name == "nt" else signal.SIGINT, "Interrupted the current turn (like pressing Esc/Ctrl-C)."),
|
|
23
|
+
"close": (signal.SIGTERM, "Asked the session to exit. Resume it later with `claude --resume`."),
|
|
24
|
+
"kill": (getattr(signal, "SIGKILL", signal.SIGTERM), "Force-killed the process."),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _ps():
|
|
29
|
+
"""pid -> {ppid, lstart, command, rss_kb, etime}"""
|
|
30
|
+
if os.name == "nt":
|
|
31
|
+
return _ps_windows()
|
|
32
|
+
out = subprocess.run(["ps", "-axo", "pid=,ppid=,rss=,etime=,lstart=,command="],
|
|
33
|
+
capture_output=True, text=True,
|
|
34
|
+
env=dict(os.environ, TZ="UTC", LC_ALL="C")).stdout # registry is UTC
|
|
35
|
+
procs = {}
|
|
36
|
+
for line in out.splitlines():
|
|
37
|
+
parts = line.split(None, 9)
|
|
38
|
+
if len(parts) < 10:
|
|
39
|
+
continue
|
|
40
|
+
pid, ppid, rss, etime = parts[:4]
|
|
41
|
+
lstart = " ".join(parts[4:9])
|
|
42
|
+
procs[int(pid)] = {"ppid": int(ppid), "rss_kb": int(rss), "etime": etime,
|
|
43
|
+
"lstart": lstart, "command": parts[9]}
|
|
44
|
+
return procs
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _ps_windows():
|
|
48
|
+
"""Windows has no `ps`; ask CIM for the same fields (lstart/etime unavailable -> blank)."""
|
|
49
|
+
ps = ("Get-CimInstance Win32_Process | ForEach-Object { "
|
|
50
|
+
"\"$($_.ProcessId)`t$($_.ParentProcessId)`t$([int]($_.WorkingSetSize/1024))`t$($_.CommandLine)\" }")
|
|
51
|
+
try:
|
|
52
|
+
out = subprocess.run(["powershell", "-NoProfile", "-Command", ps],
|
|
53
|
+
capture_output=True, text=True, timeout=20).stdout
|
|
54
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
55
|
+
return {}
|
|
56
|
+
procs = {}
|
|
57
|
+
for line in out.splitlines():
|
|
58
|
+
parts = line.split("\t", 3)
|
|
59
|
+
if len(parts) < 4 or not parts[0].isdigit():
|
|
60
|
+
continue
|
|
61
|
+
procs[int(parts[0])] = {"ppid": int(parts[1] or 0), "rss_kb": int(parts[2] or 0),
|
|
62
|
+
"etime": "", "lstart": "", "command": parts[3]}
|
|
63
|
+
return procs
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _is_claude(p):
|
|
67
|
+
cmd = p["command"].split()[0].strip('"') if p["command"] else ""
|
|
68
|
+
base = os.path.basename(cmd.replace("\\", "/")).lower()
|
|
69
|
+
return base in ("claude", "claude.exe", "claude.cmd") or cmd.endswith("/claude")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _ancestors(pid, procs):
|
|
73
|
+
seen = set()
|
|
74
|
+
while pid in procs and pid not in seen and pid > 1:
|
|
75
|
+
seen.add(pid)
|
|
76
|
+
pid = procs[pid]["ppid"]
|
|
77
|
+
return seen
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _transcript_stats(session_id, pricing):
|
|
81
|
+
files = glob.glob(os.path.join(PROJECTS, "*", f"{session_id}.jsonl"))
|
|
82
|
+
if not files:
|
|
83
|
+
return {}
|
|
84
|
+
fp = files[0]
|
|
85
|
+
steps = tokens = 0
|
|
86
|
+
ctx = None
|
|
87
|
+
cost = 0.0
|
|
88
|
+
with open(fp, errors="replace") as fh:
|
|
89
|
+
for line in fh:
|
|
90
|
+
if '"usage"' not in line:
|
|
91
|
+
continue
|
|
92
|
+
try:
|
|
93
|
+
r = json.loads(line)
|
|
94
|
+
except ValueError:
|
|
95
|
+
continue
|
|
96
|
+
if r.get("type") != "assistant":
|
|
97
|
+
continue
|
|
98
|
+
m = r.get("message") or {}
|
|
99
|
+
u = m.get("usage") or {}
|
|
100
|
+
cc = u.get("cache_creation") or {}
|
|
101
|
+
inp, out = u.get("input_tokens") or 0, u.get("output_tokens") or 0
|
|
102
|
+
cr, cw = u.get("cache_read_input_tokens") or 0, u.get("cache_creation_input_tokens") or 0
|
|
103
|
+
steps += 1
|
|
104
|
+
tokens += inp + out + cr + cw
|
|
105
|
+
ctx = inp + cr + cw
|
|
106
|
+
c5 = cc.get("ephemeral_5m_input_tokens") or 0
|
|
107
|
+
c1 = cc.get("ephemeral_1h_input_tokens") or 0
|
|
108
|
+
if not (c5 or c1):
|
|
109
|
+
c5 = cw
|
|
110
|
+
cost += pricing.estimate(m.get("model"), inp, out, cr, c5, c1)
|
|
111
|
+
return {"transcript": fp, "steps": steps, "tokens": tokens, "context": ctx,
|
|
112
|
+
"est_cost_usd": cost, "last_write_s": time.time() - os.path.getmtime(fp)}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def list_sessions(pricing):
|
|
116
|
+
procs = _ps()
|
|
117
|
+
mine = _ancestors(os.getpid(), procs) # the Claude session that launched this server
|
|
118
|
+
out = []
|
|
119
|
+
for fp in glob.glob(os.path.join(REG, "*.json")):
|
|
120
|
+
try:
|
|
121
|
+
with open(fp) as fh:
|
|
122
|
+
reg = json.load(fh)
|
|
123
|
+
except (OSError, ValueError):
|
|
124
|
+
continue
|
|
125
|
+
pid = reg.get("pid")
|
|
126
|
+
p = procs.get(pid)
|
|
127
|
+
alive = bool(p) and _is_claude(p) and (
|
|
128
|
+
not reg.get("procStart") or reg["procStart"] == p["lstart"])
|
|
129
|
+
if not alive:
|
|
130
|
+
continue
|
|
131
|
+
st = _transcript_stats(reg.get("sessionId"), pricing)
|
|
132
|
+
ctx = st.get("context") or 0
|
|
133
|
+
out.append({
|
|
134
|
+
"pid": pid, "session_id": reg.get("sessionId"), "name": reg.get("name"),
|
|
135
|
+
"cwd": reg.get("cwd"), "project": os.path.basename(reg.get("cwd") or ""),
|
|
136
|
+
"status": reg.get("status"), "kind": reg.get("kind"), "version": reg.get("version"),
|
|
137
|
+
"started_at": reg.get("startedAt"), "uptime": p["etime"],
|
|
138
|
+
"memory_mb": round(p["rss_kb"] / 1024), "hosts_dashboard": pid in mine,
|
|
139
|
+
"severity": "high" if ctx >= 300_000 else "medium" if ctx >= 150_000 else "ok",
|
|
140
|
+
**st,
|
|
141
|
+
})
|
|
142
|
+
out.sort(key=lambda x: (-(x.get("context") or 0)))
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def act(pid, action):
|
|
147
|
+
if action not in ACTIONS:
|
|
148
|
+
return {"ok": False, "error": f"unknown action {action}"}
|
|
149
|
+
procs = _ps()
|
|
150
|
+
reg_path = os.path.join(REG, f"{pid}.json")
|
|
151
|
+
try:
|
|
152
|
+
with open(reg_path) as fh:
|
|
153
|
+
reg = json.load(fh)
|
|
154
|
+
except (OSError, ValueError):
|
|
155
|
+
return {"ok": False, "error": "Not a registered Claude Code session."}
|
|
156
|
+
p = procs.get(pid)
|
|
157
|
+
if not p or not _is_claude(p):
|
|
158
|
+
return {"ok": False, "error": "Process is no longer running."}
|
|
159
|
+
if reg.get("procStart") and reg["procStart"] != p["lstart"]:
|
|
160
|
+
return {"ok": False, "error": "PID was reused by another process; refusing."}
|
|
161
|
+
sig, msg = ACTIONS[action]
|
|
162
|
+
try:
|
|
163
|
+
os.kill(pid, sig)
|
|
164
|
+
except ProcessLookupError:
|
|
165
|
+
return {"ok": False, "error": "Process already exited."}
|
|
166
|
+
except PermissionError:
|
|
167
|
+
return {"ok": False, "error": "Not permitted to signal this process."}
|
|
168
|
+
if action in ("close", "kill"):
|
|
169
|
+
for _ in range(20): # wait up to 2s to report the real outcome
|
|
170
|
+
time.sleep(0.1)
|
|
171
|
+
if pid not in _ps():
|
|
172
|
+
return {"ok": True, "exited": True, "message": msg,
|
|
173
|
+
"resume": f"claude --resume {reg.get('sessionId')}"}
|
|
174
|
+
return {"ok": True, "exited": False,
|
|
175
|
+
"message": msg + " It is still running — use Force kill if it doesn't exit.",
|
|
176
|
+
"resume": f"claude --resume {reg.get('sessionId')}"}
|
|
177
|
+
return {"ok": True, "message": msg}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ------------------------------------------------------------ other agents ----
|
|
181
|
+
# Codex, Gemini CLI and Cursor keep no pid registry, so a session counts as running
|
|
182
|
+
# when its transcript was written recently. A CLI session is tied to a process by
|
|
183
|
+
# working directory; Cursor sessions live inside the IDE and are never signalled.
|
|
184
|
+
LIVE_WINDOW_S = 20 * 60
|
|
185
|
+
BUSY_WINDOW_S = 60
|
|
186
|
+
AGENT_BINS = {"codex": ("codex",), "gemini": ("gemini",)}
|
|
187
|
+
RESUME = {"codex": "codex resume {sid}", "gemini": "gemini --resume"}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _is_agent(p, agent):
|
|
191
|
+
if agent == "claude":
|
|
192
|
+
return _is_claude(p)
|
|
193
|
+
words = [os.path.basename(w.strip('"').replace("\\", "/")).lower()
|
|
194
|
+
for w in (p["command"] or "").split()[:3]]
|
|
195
|
+
return any(w in AGENT_BINS.get(agent, ()) or w in tuple(b + ".exe" for b in AGENT_BINS.get(agent, ()))
|
|
196
|
+
for w in words)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _cwd_of(pid):
|
|
200
|
+
if os.name == "nt":
|
|
201
|
+
return None
|
|
202
|
+
try:
|
|
203
|
+
out = subprocess.run(["lsof", "-a", "-d", "cwd", "-p", str(pid), "-Fn"],
|
|
204
|
+
capture_output=True, text=True, timeout=5).stdout
|
|
205
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
206
|
+
return None
|
|
207
|
+
return next((l[1:] for l in out.splitlines() if l.startswith("n")), None)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _recent(pattern, now):
|
|
211
|
+
out = []
|
|
212
|
+
for fp in glob.glob(pattern, recursive=True):
|
|
213
|
+
try:
|
|
214
|
+
age = now - os.path.getmtime(fp)
|
|
215
|
+
except OSError:
|
|
216
|
+
continue
|
|
217
|
+
if age <= LIVE_WINDOW_S:
|
|
218
|
+
out.append((fp, age))
|
|
219
|
+
return out
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _codex_live(fp, pricing):
|
|
223
|
+
meta, model, steps, tokens, ctx, cost, prev = {}, "unknown", 0, 0, None, 0.0, None
|
|
224
|
+
with open(fp, errors="replace") as fh:
|
|
225
|
+
for line in fh:
|
|
226
|
+
try:
|
|
227
|
+
r = json.loads(line)
|
|
228
|
+
except ValueError:
|
|
229
|
+
continue
|
|
230
|
+
typ, p = r.get("type"), r.get("payload") or {}
|
|
231
|
+
if typ == "session_meta":
|
|
232
|
+
meta = p
|
|
233
|
+
elif typ == "turn_context":
|
|
234
|
+
model = p.get("model") or model
|
|
235
|
+
elif typ == "event_msg" and p.get("type") == "token_count" and p.get("info"):
|
|
236
|
+
tot = tuple(sorted(((p["info"] or {}).get("total_token_usage") or {}).items()))
|
|
237
|
+
if tot == prev:
|
|
238
|
+
continue
|
|
239
|
+
prev = tot
|
|
240
|
+
last = (p["info"] or {}).get("last_token_usage") or {}
|
|
241
|
+
cached = int(last.get("cached_input_tokens") or 0)
|
|
242
|
+
inp, out = int(last.get("input_tokens") or 0), int(last.get("output_tokens") or 0)
|
|
243
|
+
steps += 1
|
|
244
|
+
tokens += inp + out
|
|
245
|
+
ctx = inp
|
|
246
|
+
if pricing.is_known(model):
|
|
247
|
+
cost += pricing.estimate(model, max(inp - cached, 0), out, cached)
|
|
248
|
+
return {"session_id": meta.get("id"), "cwd": meta.get("cwd"), "version": meta.get("cli_version"),
|
|
249
|
+
"model": model, "steps": steps, "tokens": tokens, "context": ctx, "est_cost_usd": cost}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _gemini_live(fp, pricing):
|
|
253
|
+
with open(fp, errors="replace") as fh:
|
|
254
|
+
d = json.load(fh)
|
|
255
|
+
steps, tokens, ctx, cost, model = 0, 0, None, 0.0, None
|
|
256
|
+
for m in d.get("messages") or []:
|
|
257
|
+
if m.get("type") != "gemini":
|
|
258
|
+
continue
|
|
259
|
+
t = m.get("tokens") or {}
|
|
260
|
+
model = m.get("model") or model
|
|
261
|
+
cached, inp = int(t.get("cached") or 0), int(t.get("input") or 0)
|
|
262
|
+
out = int(t.get("output") or 0) + int(t.get("thoughts") or 0)
|
|
263
|
+
steps += 1
|
|
264
|
+
tokens += inp + out
|
|
265
|
+
ctx = inp
|
|
266
|
+
if pricing.is_known(model):
|
|
267
|
+
cost += pricing.estimate(model, max(inp - cached, 0), out, cached)
|
|
268
|
+
return {"session_id": d.get("sessionId"), "cwd": None, "model": model, "steps": steps,
|
|
269
|
+
"tokens": tokens, "context": ctx, "est_cost_usd": cost}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _cursor_live(fp, pricing):
|
|
273
|
+
steps = 0
|
|
274
|
+
with open(fp, errors="replace") as fh:
|
|
275
|
+
for line in fh:
|
|
276
|
+
if '"tool_use"' in line:
|
|
277
|
+
steps += line.count('"tool_use"')
|
|
278
|
+
slug = os.path.relpath(fp, os.path.join(HOME, ".cursor", "projects")).split(os.sep)[0]
|
|
279
|
+
guess = "/" + slug.replace("-", "/")
|
|
280
|
+
return {"session_id": os.path.splitext(os.path.basename(fp))[0],
|
|
281
|
+
"cwd": guess if os.path.isdir(guess) else None, "project_hint": slug.rsplit("-", 1)[-1],
|
|
282
|
+
"model": None, "steps": steps, "tokens": None, "context": None, "est_cost_usd": None}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
LIVE_SOURCES = {
|
|
286
|
+
"codex": (os.path.join(HOME, ".codex", "sessions", "**", "*.jsonl"), _codex_live),
|
|
287
|
+
"gemini": (os.path.join(HOME, ".gemini", "tmp", "*", "chats", "*.json"), _gemini_live),
|
|
288
|
+
"cursor": (os.path.join(HOME, ".cursor", "projects", "*", "agent-transcripts", "*", "*.jsonl"), _cursor_live),
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def list_agent_sessions(pricing, agents=None, procs=None):
|
|
293
|
+
procs = procs if procs is not None else _ps()
|
|
294
|
+
now = time.time()
|
|
295
|
+
cli = {a: [(pid, p) for pid, p in procs.items() if _is_agent(p, a)] for a in AGENT_BINS}
|
|
296
|
+
cwds = {}
|
|
297
|
+
out = []
|
|
298
|
+
for agent, (pattern, parse) in LIVE_SOURCES.items():
|
|
299
|
+
if agents and agent not in agents:
|
|
300
|
+
continue
|
|
301
|
+
for fp, age in _recent(pattern, now):
|
|
302
|
+
try:
|
|
303
|
+
st = parse(fp, pricing)
|
|
304
|
+
except (OSError, ValueError):
|
|
305
|
+
continue
|
|
306
|
+
match = None
|
|
307
|
+
for pid, p in cli.get(agent, []):
|
|
308
|
+
if pid not in cwds:
|
|
309
|
+
cwds[pid] = _cwd_of(pid)
|
|
310
|
+
if st.get("cwd") is None or cwds[pid] == st.get("cwd"):
|
|
311
|
+
match = (pid, p)
|
|
312
|
+
break
|
|
313
|
+
ctx = st.get("context") or 0
|
|
314
|
+
cwd = st.get("cwd")
|
|
315
|
+
out.append({
|
|
316
|
+
"agent": agent, "pid": match[0] if match else None,
|
|
317
|
+
"signalable": bool(match), "session_id": st.get("session_id"), "name": None,
|
|
318
|
+
"cwd": cwd, "project": os.path.basename(cwd or "") or st.get("project_hint") or agent,
|
|
319
|
+
"status": "busy" if age <= BUSY_WINDOW_S else "idle", "version": st.get("version"),
|
|
320
|
+
"model": st.get("model"), "uptime": match[1]["etime"] if match else "",
|
|
321
|
+
"memory_mb": round(match[1]["rss_kb"] / 1024) if match else None,
|
|
322
|
+
"hosts_dashboard": False, "transcript": fp, "last_write_s": age,
|
|
323
|
+
"steps": st.get("steps"), "tokens": st.get("tokens"), "context": st.get("context"),
|
|
324
|
+
"est_cost_usd": st.get("est_cost_usd"),
|
|
325
|
+
"resume": RESUME.get(agent, "").format(sid=st.get("session_id") or "") or None,
|
|
326
|
+
"severity": "high" if ctx >= 300_000 else "medium" if ctx >= 150_000 else "ok",
|
|
327
|
+
})
|
|
328
|
+
return out
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def act_agent(pid, action, agent):
|
|
332
|
+
"""Signal a Codex / Gemini CLI process — only one the live list currently shows."""
|
|
333
|
+
if action not in ACTIONS:
|
|
334
|
+
return {"ok": False, "error": f"unknown action {action}"}
|
|
335
|
+
if agent not in AGENT_BINS:
|
|
336
|
+
return {"ok": False, "error": "This agent's sessions can't be stopped from here."}
|
|
337
|
+
procs = _ps()
|
|
338
|
+
p = procs.get(pid)
|
|
339
|
+
if not p or not _is_agent(p, agent):
|
|
340
|
+
return {"ok": False, "error": "Process is no longer running."}
|
|
341
|
+
live = [s for s in list_agent_sessions(_NoPricing(), [agent], procs) if s["pid"] == pid]
|
|
342
|
+
if not live:
|
|
343
|
+
return {"ok": False, "error": "Not a live session the dashboard shows; refusing."}
|
|
344
|
+
sig, msg = ACTIONS[action]
|
|
345
|
+
try:
|
|
346
|
+
os.kill(pid, sig)
|
|
347
|
+
except ProcessLookupError:
|
|
348
|
+
return {"ok": False, "error": "Process already exited."}
|
|
349
|
+
except PermissionError:
|
|
350
|
+
return {"ok": False, "error": "Not permitted to signal this process."}
|
|
351
|
+
res = {"ok": True, "message": msg.replace("`claude --resume`", "its resume command"),
|
|
352
|
+
"resume": live[0]["resume"]}
|
|
353
|
+
if action in ("close", "kill"):
|
|
354
|
+
for _ in range(20):
|
|
355
|
+
time.sleep(0.1)
|
|
356
|
+
if pid not in _ps():
|
|
357
|
+
res["exited"] = True
|
|
358
|
+
return res
|
|
359
|
+
res["exited"] = False
|
|
360
|
+
return res
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class _NoPricing:
|
|
364
|
+
def is_known(self, model):
|
|
365
|
+
return False
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------- handover ----
|
|
369
|
+
HANDOFFS = os.path.join(HOME, ".claude", "finops-handoffs")
|
|
370
|
+
EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _text_of(content):
|
|
374
|
+
if isinstance(content, str):
|
|
375
|
+
return content
|
|
376
|
+
return "\n".join(b.get("text", "") for b in content or []
|
|
377
|
+
if isinstance(b, dict) and b.get("type") == "text")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _clip(s, n):
|
|
381
|
+
s = (s or "").strip()
|
|
382
|
+
return s if len(s) <= n else s[:n].rstrip() + " …"
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def brief_from_transcript(fp):
|
|
386
|
+
"""Everything a fresh session needs, read straight from the transcript —
|
|
387
|
+
no model call, so building a handover costs nothing."""
|
|
388
|
+
prompts, files, todos, last_reply = [], [], [], ""
|
|
389
|
+
with open(fp, errors="replace") as fh:
|
|
390
|
+
for line in fh:
|
|
391
|
+
try:
|
|
392
|
+
r = json.loads(line)
|
|
393
|
+
except ValueError:
|
|
394
|
+
continue
|
|
395
|
+
m = r.get("message") or {}
|
|
396
|
+
if r.get("type") == "user" and not r.get("isMeta") and not r.get("isSidechain"):
|
|
397
|
+
t = _text_of(m.get("content"))
|
|
398
|
+
if t and not t.lstrip().startswith(("<command-", "<local-command", "<system-reminder", "Caveat:")):
|
|
399
|
+
prompts.append(t)
|
|
400
|
+
elif r.get("type") == "assistant" and not r.get("isSidechain"):
|
|
401
|
+
for b in m.get("content") or []:
|
|
402
|
+
if not isinstance(b, dict):
|
|
403
|
+
continue
|
|
404
|
+
if b.get("type") == "text" and b.get("text", "").strip():
|
|
405
|
+
last_reply = b["text"]
|
|
406
|
+
elif b.get("type") == "tool_use":
|
|
407
|
+
inp = b.get("input") or {}
|
|
408
|
+
if b.get("name") in EDIT_TOOLS:
|
|
409
|
+
f = inp.get("file_path") or inp.get("notebook_path")
|
|
410
|
+
if f and f not in files:
|
|
411
|
+
files.append(f)
|
|
412
|
+
elif b.get("name") == "TodoWrite":
|
|
413
|
+
todos = inp.get("todos") or todos
|
|
414
|
+
return {"goal": prompts[0] if prompts else "", "recent": prompts[-6:] if len(prompts) > 1 else [],
|
|
415
|
+
"files": files, "todos": todos, "last_reply": last_reply, "turns": len(prompts)}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _render_brief(reg, b, task=None, part=None):
|
|
419
|
+
L = [f"# Handover from session {reg.get('sessionId')}", "",
|
|
420
|
+
f"- Project: `{reg.get('cwd')}`",
|
|
421
|
+
f"- Previous session had {b['turns']} prompts; its full history is available with "
|
|
422
|
+
f"`claude --resume {reg.get('sessionId')}` if you truly need it — prefer this brief.", ""]
|
|
423
|
+
if task:
|
|
424
|
+
L += [f"## Your task{f' (part {part})' if part else ''}", "", task, "",
|
|
425
|
+
"Only do this task. Other parts of the work are handled in separate sessions.", ""]
|
|
426
|
+
L += ["## Original goal", "", _clip(b["goal"], 2500), ""]
|
|
427
|
+
if b["recent"]:
|
|
428
|
+
L += ["## Most recent requests (oldest first)", ""] + [f"- {_clip(p, 600)}" for p in b["recent"]] + [""]
|
|
429
|
+
open_todos = [t for t in b["todos"] if t.get("status") != "completed"]
|
|
430
|
+
if b["todos"]:
|
|
431
|
+
L += ["## Task list at handover", ""] + [
|
|
432
|
+
f"- [{'x' if t.get('status') == 'completed' else ' '}] {t.get('content')}" for t in b["todos"]] + [""]
|
|
433
|
+
if b["files"]:
|
|
434
|
+
L += ["## Files changed so far", ""] + [f"- `{f}`" for f in b["files"][-40:]] + [""]
|
|
435
|
+
if b["last_reply"]:
|
|
436
|
+
L += ["## Last status from the previous session", "", _clip(b["last_reply"], 3000), ""]
|
|
437
|
+
L += ["## How to start", "",
|
|
438
|
+
"Read the files above that matter for the task, verify the current state (it may have moved on "
|
|
439
|
+
"since this brief), then continue." + (" Open items from the task list come first." if open_todos else "")]
|
|
440
|
+
return "\n".join(L)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _launch_terminal(cwd, prompt_path):
|
|
444
|
+
"""Open a new Terminal window running a fresh `claude` seeded with the brief."""
|
|
445
|
+
q = lambda s: "'" + s.replace("'", "'\\''") + "'"
|
|
446
|
+
seed = f"Read the handover brief at {prompt_path} and continue from it."
|
|
447
|
+
cmd = f"cd {q(cwd)} && claude {q(seed)}"
|
|
448
|
+
if sys.platform == "darwin":
|
|
449
|
+
osa = 'tell application "Terminal"\nactivate\ndo script ' + json.dumps(cmd) + "\nend tell"
|
|
450
|
+
r = subprocess.run(["osascript", "-e", osa], capture_output=True, text=True)
|
|
451
|
+
return r.returncode == 0, (r.stderr or "").strip()
|
|
452
|
+
if os.name == "nt":
|
|
453
|
+
subprocess.Popen(["cmd", "/c", "start", "", "cmd", "/k", "claude", seed], cwd=cwd)
|
|
454
|
+
return True, ""
|
|
455
|
+
for term in (["x-terminal-emulator", "-e"], ["gnome-terminal", "--"], ["konsole", "-e"],
|
|
456
|
+
["xfce4-terminal", "-x"], ["xterm", "-e"]):
|
|
457
|
+
if shutil.which(term[0]):
|
|
458
|
+
subprocess.Popen(term + ["bash", "-lc", cmd + "; exec bash"])
|
|
459
|
+
return True, ""
|
|
460
|
+
return False, "No terminal emulator found. Run this yourself: " + cmd
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def handover(pid, opts):
|
|
464
|
+
"""Start fresh session(s) from a brief of this one.
|
|
465
|
+
|
|
466
|
+
opts: tasks – list of sub-tasks; one new session each (empty = one continuation)
|
|
467
|
+
close – close the old session afterwards (SIGTERM, resumable)
|
|
468
|
+
launch – open Terminal windows (False = only write the briefs)
|
|
469
|
+
"""
|
|
470
|
+
try:
|
|
471
|
+
with open(os.path.join(REG, f"{pid}.json")) as fh:
|
|
472
|
+
reg = json.load(fh)
|
|
473
|
+
except (OSError, ValueError):
|
|
474
|
+
return {"ok": False, "error": "Not a registered Claude Code session."}
|
|
475
|
+
files = glob.glob(os.path.join(PROJECTS, "*", f"{reg.get('sessionId')}.jsonl"))
|
|
476
|
+
if not files:
|
|
477
|
+
return {"ok": False, "error": "Transcript not found for this session."}
|
|
478
|
+
b = brief_from_transcript(files[0])
|
|
479
|
+
tasks = [t.strip() for t in (opts.get("tasks") or []) if t and t.strip()][:8]
|
|
480
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
481
|
+
os.makedirs(HANDOFFS, exist_ok=True)
|
|
482
|
+
out, jobs = [], tasks or [None]
|
|
483
|
+
for i, t in enumerate(jobs, 1):
|
|
484
|
+
path = os.path.join(HANDOFFS, f"{stamp}-{reg.get('sessionId', 'x')[:8]}-{i}.md")
|
|
485
|
+
with open(path, "w") as fh:
|
|
486
|
+
fh.write(_render_brief(reg, b, t, f"{i} of {len(jobs)}" if len(jobs) > 1 else None))
|
|
487
|
+
item = {"brief": path, "task": t, "launched": False}
|
|
488
|
+
if opts.get("launch", True):
|
|
489
|
+
item["launched"], err = _launch_terminal(reg.get("cwd") or HOME, path)
|
|
490
|
+
if err:
|
|
491
|
+
item["error"] = err
|
|
492
|
+
out.append(item)
|
|
493
|
+
res = {"ok": True, "sessions": out,
|
|
494
|
+
"message": f"{'Started' if opts.get('launch', True) else 'Wrote briefs for'} {len(out)} "
|
|
495
|
+
f"new session{'s' * (len(out) > 1)} from a handover brief."}
|
|
496
|
+
if opts.get("close") and all(x["launched"] for x in out):
|
|
497
|
+
res["closed"] = act(pid, "close")
|
|
498
|
+
res["message"] += " Old session closed (resumable)."
|
|
499
|
+
return res
|