chatdeck 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.
chatdeck/server.py ADDED
@@ -0,0 +1,345 @@
1
+ """chatdeck: a local dashboard for Claude Code chats.
2
+ Lists running sessions (claude agents --json) and saved transcripts under
3
+ ~/.claude/projects, and can view, search, rename, export and delete saved chats, stop
4
+ sessions, and open chats in a terminal (see terminals.py). Binds 127.0.0.1 only; every API call needs
5
+ the per-run token embedded in the page (custom header => cross-site pages can't call it)."""
6
+ import json, os, glob, secrets, shlex, signal, subprocess, threading, time
7
+ from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
8
+ from urllib.parse import urlparse, parse_qs, quote
9
+
10
+ from . import config, terminals, transcripts, usage
11
+ from .transcripts import UUID
12
+
13
+ HOME = config.HOME
14
+ PROJECTS = os.path.join(HOME, ".claude", "projects")
15
+ DATA = config.DATA
16
+ STATUS, WAITING = os.path.join(DATA, "status"), os.path.join(DATA, "waiting")
17
+ TOKEN = secrets.token_hex(16)
18
+ ASSETS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
19
+ PAGE = os.path.join(ASSETS, "index.html")
20
+ STATIC = {"/logo.svg": ("logo.svg", "image/svg+xml"), "/logo-192.png": ("logo-192.png", "image/png"),
21
+ "/logo-512.png": ("logo-512.png", "image/png")}
22
+ MANIFEST = {"name": "chatdeck", "short_name": "chatdeck", "display": "standalone", "background_color": "#0f1115",
23
+ "theme_color": "#0f1115", "start_url": "/",
24
+ "icons": [{"src": "/logo-192.png", "sizes": "192x192", "type": "image/png"},
25
+ {"src": "/logo-512.png", "sizes": "512x512", "type": "image/png"},
26
+ {"src": "/logo.svg", "sizes": "any", "type": "image/svg+xml"}]}
27
+ INDEX = transcripts.Index(PROJECTS)
28
+ LIMITS = usage.LimitHistory(os.path.join(DATA, "limits-history.jsonl"))
29
+
30
+ _agents = (0.0, {})
31
+ _agents_lock = threading.Lock()
32
+ def _fetch_agents():
33
+ global _agents
34
+ try:
35
+ if config.DEMO:
36
+ with open(os.path.join(DATA, "demo-agents.json")) as f: out = f.read()
37
+ else:
38
+ out = subprocess.run(["claude", "agents", "--json"], capture_output=True, text=True, timeout=30).stdout
39
+ res = {s["sessionId"]: s for s in json.loads(out or "[]") if s.get("sessionId")}
40
+ with _agents_lock: _agents = (time.time(), res)
41
+ except Exception:
42
+ pass
43
+ return _agents[1]
44
+
45
+ def _agents_loop():
46
+ while True:
47
+ _fetch_agents(); time.sleep(4)
48
+
49
+ def agents(fresh=False):
50
+ """Running sessions. Normal reads use the list a background thread keeps fresh, so pages never wait;
51
+ destructive actions (delete/stop) ask for a fresh copy first."""
52
+ return _fetch_agents() if fresh else _agents[1]
53
+
54
+ def waiting(act, chats):
55
+ """{sid: message} for running chats stuck on a permission prompt or question. A mark from the
56
+ waiting hook counts until the transcript is written to after it (the prompt was answered)."""
57
+ out = {}
58
+ for f in glob.glob(os.path.join(WAITING, "*.json")):
59
+ sid = os.path.basename(f)[:-5]
60
+ try:
61
+ if sid not in act:
62
+ if os.path.getmtime(f) < time.time() - 3600: os.remove(f)
63
+ continue
64
+ with open(f) as fh: w = json.load(fh)
65
+ hit = chats.get(sid)
66
+ if not hit or hit[1].mtime <= w["at"] + 1.5:
67
+ out[sid] = w.get("message") or "Waiting for you"
68
+ except (OSError, ValueError, KeyError):
69
+ pass
70
+ return out
71
+
72
+ def _as_written(cwd, real):
73
+ """git answers with symlinks resolved (macOS: /var is /private/var); give the folder as cwd spells it."""
74
+ d = cwd
75
+ while True:
76
+ if os.path.realpath(d) == real: return d
77
+ up = os.path.dirname(d)
78
+ if up == d: return real
79
+ d = up
80
+
81
+ _projects = {}
82
+ def project(cwd):
83
+ """The project a folder belongs to: the main checkout of its git repo (so subfolders and
84
+ worktrees count as the repo itself), or the folder when it isn't in a repo."""
85
+ if not cwd: return ""
86
+ if cwd not in _projects:
87
+ root = cwd.split(os.sep + ".claude" + os.sep + "worktrees" + os.sep)[0] # Claude's worktrees, even once removed
88
+ try:
89
+ r = subprocess.run(["git", "-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"],
90
+ capture_output=True, text=True, timeout=5)
91
+ common = r.stdout.strip()
92
+ if r.returncode == 0 and os.path.basename(common) == ".git": root = _as_written(cwd, os.path.dirname(common))
93
+ except (OSError, subprocess.TimeoutExpired):
94
+ pass
95
+ _projects[cwd] = root
96
+ return _projects[cwd].replace(HOME, "~", 1)
97
+
98
+ def chat_project(e, start_cwd=None):
99
+ """The project a chat belongs to: the one it did most of its work in. A chat started in your home
100
+ folder that then worked in ~/code/app belongs to app, not to the home folder."""
101
+ totals = {}
102
+ for cwd, n in (e.cwds if e else {}).items():
103
+ p = project(cwd); totals[p] = totals.get(p, 0) + n
104
+ return max(totals, key=totals.get) if totals else project(start_cwd or (e.cwd if e else ""))
105
+
106
+ # Names given to chats while they were running. Claude Code keeps a running chat's name in memory
107
+ # and rewrites it, so the new name is shown from here and written into the chat once it ends.
108
+ PENDING = os.path.join(DATA, "pending-names.json")
109
+ _pending_lock = threading.Lock()
110
+
111
+ def pending_names():
112
+ try:
113
+ with open(PENDING) as f: return json.load(f)
114
+ except (OSError, ValueError):
115
+ return {}
116
+
117
+ def _save_pending(names):
118
+ os.makedirs(DATA, exist_ok=True)
119
+ with open(PENDING + ".tmp", "w") as f: json.dump(names, f)
120
+ os.replace(PENDING + ".tmp", PENDING)
121
+
122
+ def apply_pending(act, paths):
123
+ """Write pending names into chats that have stopped ({sid: transcript path}); returns {sid: name} still pending.
124
+ A name is dropped instead if the chat was renamed in Claude Code meanwhile (/rename there wins)."""
125
+ with _pending_lock:
126
+ names = pending_names(); changed = False
127
+ for sid, p in list(names.items()):
128
+ renamed_there = sid in act and act[sid].get("name") not in (p["was"], p["title"])
129
+ if sid in act and not renamed_there: continue
130
+ if not renamed_there and sid in paths: transcripts.rename(paths[sid], sid, p["title"])
131
+ del names[sid]; changed = True
132
+ if changed: _save_pending(names)
133
+ return {sid: p["title"] for sid, p in names.items()}
134
+
135
+ def state():
136
+ act = agents(); pend = apply_pending(act, INDEX.files()) # before reading chats, so a name just written shows
137
+ chats = INDEX.all(); wait = waiting(act, chats); out = []
138
+ for sid, (p, e) in chats.items():
139
+ a = act.get(sid, {})
140
+ out.append({"sid": sid, "name": pend.get(sid) or a.get("name") or e.title or "(empty)",
141
+ "cwd": (a.get("cwd") or e.cwd or "").replace(HOME, "~"), "project": chat_project(e, a.get("cwd")),
142
+ "ctx": e.ctx, "msgs": e.msgs, "mtime": e.mtime, "size": e.size, "cost": round(e.cost, 2),
143
+ "status": a.get("status"), "kind": a.get("kind"), "started": a.get("startedAt"), "waiting": wait.get(sid)})
144
+ for sid, a in act.items(): # running but no transcript yet
145
+ if sid not in chats:
146
+ out.append({"sid": sid, "name": pend.get(sid) or a.get("name") or "(new chat)", "cwd": (a.get("cwd") or "").replace(HOME, "~"),
147
+ "project": project(a.get("cwd")),
148
+ "ctx": 0, "msgs": 0, "mtime": time.time(), "size": 0, "cost": 0, "status": a.get("status"),
149
+ "kind": a.get("kind"), "started": a.get("startedAt"), "waiting": wait.get(sid)})
150
+ out.sort(key=lambda x: -x["mtime"])
151
+ return {"chats": out, "now": time.time(), "usage": usage.summary([e for _, e in chats.values()], STATUS, LIMITS, project=chat_project)}
152
+
153
+ def manual(cwd, command):
154
+ """What to paste into a terminal when no terminal could be opened for the person."""
155
+ return f"cd {shlex.quote(cwd)} && {command}"
156
+
157
+ DEMO_OFF = {"ok": False, "error": "this is the demo - it doesn't open terminals or touch real chats"}
158
+
159
+ def open_running(sid):
160
+ if config.DEMO: return DEMO_OFF
161
+ a = agents(fresh=True).get(sid)
162
+ if not a: return {"ok": False, "error": "not running any more"}
163
+ cwd = a.get("cwd") if os.path.isdir(a.get("cwd") or "") else HOME
164
+ if a.get("kind") == "background":
165
+ cmd = f"claude attach {a.get('id') or sid}"
166
+ if terminals.launch(cwd, cmd): return {"ok": True, "how": "attached in a new terminal tab"}
167
+ return {"ok": False, "copy": manual(cwd, cmd)}
168
+ if a.get("pid") and terminals.focus(int(a["pid"]), a.get("name")):
169
+ return {"ok": True, "how": "switched to its terminal"}
170
+ return {"ok": False, "error": "couldn't find its terminal tab - switch to it yourself (tabs with a locked title can't be found)"}
171
+
172
+ def transcript(sid):
173
+ if not UUID.match(sid or ""): return None
174
+ hits = glob.glob(os.path.join(PROJECTS, "*", sid + ".jsonl"))
175
+ if not hits or not os.path.realpath(hits[0]).startswith(os.path.realpath(PROJECTS) + os.sep): return None
176
+ return hits[0]
177
+
178
+ def delete(sids):
179
+ act = agents(fresh=True); done, refused = [], []
180
+ for sid in sids:
181
+ p = transcript(sid)
182
+ if not p or sid in act:
183
+ refused.append(sid); continue
184
+ os.remove(p); INDEX.forget(p)
185
+ side = p[:-6] # sibling folder with this chat's tool output / subagents
186
+ if os.path.isdir(side):
187
+ subprocess.run(["rm", "-rf", "--", side])
188
+ done.append(sid)
189
+ return {"deleted": done, "refused": refused}
190
+
191
+ def _demo_rename(sid, title):
192
+ path = os.path.join(DATA, "demo-agents.json")
193
+ with open(path) as f: running = json.load(f)
194
+ for x in running:
195
+ if x["sessionId"] == sid: x["name"] = title
196
+ with open(path, "w") as f: json.dump(running, f)
197
+ _fetch_agents()
198
+ return {"ok": True, "title": title, "typed": True}
199
+
200
+ def auto_rename(sid):
201
+ """Run Claude Code's /rename with no name in a running chat: Claude names it after what it's about."""
202
+ act = agents(fresh=True); a = act.get(sid)
203
+ if not a: return {"ok": False, "error": "it isn't running - open it and type /rename, or type a name for it here"}
204
+ if a.get("status") != "idle" or sid in waiting(act, INDEX.all()):
205
+ return {"ok": False, "error": "it's busy - try again when it's waiting for your next message"}
206
+ if config.DEMO: return DEMO_OFF
207
+ if a.get("kind") != "interactive" or not a.get("pid") or not terminals.type_into(int(a["pid"]), a.get("name"), "/rename"):
208
+ return {"ok": False, "error": "couldn't find its terminal tab (tabs with a locked title can't be found) - type /rename in it"}
209
+ return {"ok": True}
210
+
211
+ def rename(sid, title):
212
+ title = " ".join(str(title or "").split())[:80]
213
+ p = transcript(sid)
214
+ if not p or not title: return {"ok": False, "error": "no such chat"}
215
+ act = agents(fresh=True)
216
+ if sid in act:
217
+ a = act[sid]
218
+ # Idle at its prompt in a terminal: run Claude Code's own /rename in it, so the name changes everywhere.
219
+ idle = a.get("status") == "idle" and a.get("kind") == "interactive" and sid not in waiting(act, INDEX.all())
220
+ if config.DEMO: return _demo_rename(sid, title)
221
+ if idle and a.get("pid") and terminals.type_into(int(a["pid"]), a.get("name"), f"/rename {title}"):
222
+ return {"ok": True, "title": title, "typed": True}
223
+ with _pending_lock: # busy, waiting on you, or not in a terminal we can reach: see PENDING
224
+ names = pending_names(); names[sid] = {"title": title, "was": a.get("name")}; _save_pending(names)
225
+ return {"ok": True, "title": title, "pending": True, "busy": not idle}
226
+ transcripts.rename(p, sid, title)
227
+ return {"ok": True, "title": title}
228
+
229
+ def compact(sid):
230
+ """Run Claude Code's /compact in a running chat, the same way rename types /rename."""
231
+ act = agents(fresh=True); a = act.get(sid)
232
+ if not a: return {"ok": False, "error": "it isn't running"}
233
+ if a.get("status") != "idle" or sid in waiting(act, INDEX.all()):
234
+ return {"ok": False, "error": "it's busy - try again when it's waiting for your next message"}
235
+ if config.DEMO: return DEMO_OFF
236
+ if a.get("kind") != "interactive" or not a.get("pid") or not terminals.type_into(int(a["pid"]), a.get("name"), "/compact"):
237
+ return {"ok": False, "error": "couldn't reach its terminal - type /compact in it"}
238
+ return {"ok": True}
239
+
240
+ def stop(sid):
241
+ if config.DEMO: return DEMO_OFF
242
+ a = agents(fresh=True).get(sid)
243
+ if not a or not a.get("pid"): return {"ok": False, "error": "not running"}
244
+ pid = int(a["pid"])
245
+ cmd = subprocess.run(["ps", "-o", "command=", "-p", str(pid)], capture_output=True, text=True).stdout
246
+ if "claude" not in cmd: return {"ok": False, "error": "pid is not a Claude process"}
247
+ os.kill(pid, signal.SIGTERM)
248
+ return {"ok": True}
249
+
250
+ def open_chat(sid, view=False):
251
+ if config.DEMO: return DEMO_OFF
252
+ if view:
253
+ inner = "claude agents"; cwd = HOME
254
+ else:
255
+ p = transcript(sid)
256
+ if not p: return {"ok": False}
257
+ cwd = INDEX.get(p).cwd or HOME
258
+ inner = f"claude --resume {sid}"
259
+ cwd = cwd if os.path.isdir(cwd) else HOME
260
+ if terminals.launch(cwd, inner): return {"ok": True}
261
+ return {"ok": False, "copy": manual(cwd, inner)}
262
+
263
+ def new_chat(proj):
264
+ """Start a new Claude Code chat in a project folder. Only folders chatdeck lists as projects are allowed."""
265
+ if config.DEMO: return DEMO_OFF
266
+ known = {chat_project(e) for _, e in INDEX.all().values()} | {project(a.get("cwd")) for a in agents().values()}
267
+ if proj not in known: return {"ok": False, "error": "unknown project"}
268
+ cwd = os.path.join(HOME, proj[2:]) if proj.startswith("~/") else HOME if proj == "~" else proj
269
+ if not os.path.isdir(cwd): return {"ok": False, "error": f"{proj} doesn't exist any more"}
270
+ if terminals.launch(cwd, "claude"): return {"ok": True}
271
+ return {"ok": False, "copy": manual(cwd, "claude")}
272
+
273
+ def chat(sid):
274
+ p = transcript(sid)
275
+ if not p: return None
276
+ e = INDEX.get(p); a = agents().get(sid, {})
277
+ return dict(transcripts.messages(p), sid=sid, name=(pending_names().get(sid) or {}).get("title") or a.get("name") or e.title or "(empty)", running=bool(a),
278
+ cwd=(e.cwd or "").replace(HOME, "~"), cost=round(e.cost, 2), size=e.size, mtime=e.mtime)
279
+
280
+ class H(BaseHTTPRequestHandler):
281
+ def log_message(self, *a): pass
282
+ def send(self, code, body, ctype="application/json", extra=()):
283
+ b = body if isinstance(body, bytes) else json.dumps(body).encode()
284
+ self.send_response(code); self.send_header("Content-Type", ctype)
285
+ self.send_header("Cache-Control", "no-store")
286
+ for k, v in extra: self.send_header(k, v)
287
+ self.end_headers(); self.wfile.write(b)
288
+ def authed(self):
289
+ return self.headers.get("X-Token") == TOKEN and self.headers.get("Host", "").split(":")[0] in ("localhost", "127.0.0.1")
290
+ def do_GET(self):
291
+ u = urlparse(self.path); q = {k: v[0] for k, v in parse_qs(u.query).items()}
292
+ if u.path == "/":
293
+ with open(PAGE) as f:
294
+ return self.send(200, f.read().replace("__TOKEN__", TOKEN).encode(), "text/html; charset=utf-8")
295
+ if u.path in STATIC: # the logo and app manifest are public; they give nothing away
296
+ name, ctype = STATIC[u.path]
297
+ with open(os.path.join(ASSETS, name), "rb") as f:
298
+ return self.send(200, f.read(), ctype)
299
+ if u.path == "/manifest.webmanifest":
300
+ return self.send(200, json.dumps(MANIFEST).encode(), "application/manifest+json")
301
+ if not self.authed(): return self.send(404, {"error": "not found"})
302
+ if u.path == "/api/state": return self.send(200, state())
303
+ if u.path == "/api/summary": # small payload for the desktop bubble's badge
304
+ act = agents(); w = waiting(act, INDEX.all())
305
+ return self.send(200, {"running": len(act), "busy": sum(a.get("status") == "busy" for a in act.values()),
306
+ "waiting": [act[sid].get("name") or "a chat" for sid in w if sid in act]})
307
+ if u.path == "/api/search": return self.send(200, INDEX.search(q.get("q", "")[:200]))
308
+ if u.path == "/api/chat":
309
+ c = chat(q.get("sid", ""))
310
+ return self.send(200, c) if c else self.send(404, {"error": "no such chat"})
311
+ if u.path == "/api/export":
312
+ c = transcript(q.get("sid", ""))
313
+ if not c: return self.send(404, {"error": "no such chat"})
314
+ title = INDEX.get(c).title or "chat"
315
+ name = "".join(ch if ch.isalnum() or ch in "-_ " else "-" for ch in title).strip()[:60] or "chat"
316
+ return self.send(200, transcripts.markdown(c, title).encode(), "text/markdown; charset=utf-8",
317
+ [("Content-Disposition", f"attachment; filename*=UTF-8''{quote(name)}.md")])
318
+ self.send(404, {"error": "not found"})
319
+ def do_POST(self):
320
+ if not self.authed(): return self.send(403, {"error": "forbidden"})
321
+ n = int(self.headers.get("Content-Length") or 0)
322
+ body = json.loads(self.rfile.read(n) or b"{}")
323
+ if self.path == "/api/delete": return self.send(200, delete(body.get("sids", [])))
324
+ if self.path == "/api/rename":
325
+ if body.get("auto"): return self.send(200, auto_rename(body.get("sid", "")))
326
+ return self.send(200, rename(body.get("sid", ""), body.get("title", "")))
327
+ if self.path == "/api/stop": return self.send(200, stop(body.get("sid", "")))
328
+ if self.path == "/api/compact": return self.send(200, compact(body.get("sid", "")))
329
+ if self.path == "/api/open": return self.send(200, open_chat(body.get("sid", ""), body.get("view", False)))
330
+ if self.path == "/api/new": return self.send(200, new_chat(body.get("project", "")))
331
+ if self.path == "/api/open-running": return self.send(200, open_running(body.get("sid", "")))
332
+ self.send(404, {"error": "not found"})
333
+
334
+ def serve(port):
335
+ """Run the dashboard until stopped. Raises OSError if the port is taken."""
336
+ httpd = ThreadingHTTPServer(("127.0.0.1", port), H)
337
+ os.makedirs(DATA, exist_ok=True)
338
+ pidfile = os.path.join(DATA, "server.pid")
339
+ with open(pidfile, "w") as f: f.write(str(os.getpid()))
340
+ threading.Thread(target=_agents_loop, daemon=True).start()
341
+ try:
342
+ httpd.serve_forever()
343
+ finally:
344
+ try: os.remove(pidfile)
345
+ except OSError: pass