worklog-for-claude-code 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.
worklog/web.py ADDED
@@ -0,0 +1,776 @@
1
+ """Local web UI: daily dashboard + settings. Standard library only.
2
+
3
+ Security model (it can edit config and run git/claude, so it is locked down):
4
+ * binds to loopback only (127.0.0.1 and ::1), never to the network
5
+ * Host header must be a known loopback name -> blocks DNS-rebinding attacks
6
+ * every POST needs a per-process CSRF token and a same-origin Origin header
7
+ * all output is HTML-escaped; strict Content-Security-Policy; no external assets
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import html
12
+ import json
13
+ import logging
14
+ import os
15
+ import re
16
+ import secrets
17
+ import shutil
18
+ import socket
19
+ import subprocess
20
+ import threading
21
+ from dataclasses import replace
22
+ from datetime import date, datetime, timedelta
23
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
24
+ from pathlib import Path
25
+ from urllib.parse import parse_qs, urlsplit
26
+
27
+ from . import services
28
+ from .collector import collect, sanitize_remote
29
+ from .config import (
30
+ _NAME_RE,
31
+ CONFIG_PATH,
32
+ Account,
33
+ Config,
34
+ ConfigError,
35
+ _expand,
36
+ example_config,
37
+ home_relative,
38
+ load_config,
39
+ save_config,
40
+ under_home,
41
+ )
42
+ from .hooks import hook_installed, install_hook
43
+ from .identity import detected_logins, mask_email
44
+ from .report import aggregate, build_report, load_saved_summaries, plain_summary
45
+ from .store import GitStore
46
+
47
+ log = logging.getLogger(__name__)
48
+ DEFAULT_PORT = 8765
49
+ HOST_NAME = "worklog.localhost" # *.localhost resolves to loopback in browsers and systemd-resolved
50
+ MAX_BODY = 64 * 1024
51
+ COLORS = ["#2F5BEA", "#0F9D8A", "#C98A12", "#8A4FBF", "#C4456A", "#3E8E3E"]
52
+
53
+
54
+ def e(v: object) -> str:
55
+ return html.escape(str(v), quote=True)
56
+
57
+
58
+ OK_MESSAGES = {
59
+ "setup": "Setup saved. Next: install the hook for each account below.",
60
+ "account_added": "Account added. Log in to it once in a terminal (command below), then install its hook.",
61
+ "account_removed": "Account removed from this device. Its records already synced are kept.",
62
+ "saved": "Settings saved.",
63
+ "named": "Login named. Its sessions are counted under that account from the next sync.",
64
+ "job": "Started. This page updates when it finishes.",
65
+ }
66
+
67
+
68
+ # ================================================================== jobs
69
+ class Jobs:
70
+ """One background job at a time (sync / summaries), so the UI never blocks."""
71
+
72
+ def __init__(self):
73
+ self._lock = threading.Lock()
74
+ self._state = {"running": None, "finished": None, "message": "", "ok": True}
75
+
76
+ def start(self, name: str, fn) -> bool:
77
+ with self._lock:
78
+ if self._state["running"]:
79
+ return False
80
+ self._state["running"] = name
81
+
82
+ def run():
83
+ try:
84
+ msg, ok = fn(), True
85
+ except Exception as exc: # noqa: BLE001 - report any failure to the UI
86
+ log.exception("%s failed", name)
87
+ msg, ok = f"{name} failed: {exc}", False
88
+ with self._lock:
89
+ self._state.update(running=None, finished=datetime.now().strftime("%H:%M"), message=msg, ok=ok)
90
+
91
+ threading.Thread(target=run, daemon=True).start()
92
+ return True
93
+
94
+ def snapshot(self) -> dict:
95
+ with self._lock:
96
+ return dict(self._state)
97
+
98
+
99
+ # ================================================================== helpers
100
+ def _try_config() -> Config | None:
101
+ try:
102
+ return load_config()
103
+ except ConfigError:
104
+ return None
105
+
106
+
107
+ def _store(cfg: Config) -> GitStore | None:
108
+ try:
109
+ return GitStore(cfg.store_repo)
110
+ except ConfigError:
111
+ return None
112
+
113
+
114
+ def _n(v: int) -> str:
115
+ return f"{v:,}"
116
+
117
+
118
+ def _short(v: int) -> str:
119
+ for div, suf in ((1_000_000_000, "B"), (1_000_000, "M"), (1_000, "k")):
120
+ if v >= div:
121
+ return f"{v / div:.1f}".rstrip("0").rstrip(".") + suf
122
+ return str(v)
123
+
124
+
125
+ def _dur(m: int) -> str:
126
+ h, m = divmod(m, 60)
127
+ return f"{h}h {m:02d}m" if h else f"{m}m"
128
+
129
+
130
+ def _join(items) -> str:
131
+ items = [e(i) for i in items]
132
+ return items[0] if len(items) == 1 else ", ".join(items[:-1]) + " and " + items[-1] if items else ""
133
+
134
+
135
+ def _transcript_count(a: Account, cap: int = 5000) -> int:
136
+ base = a.config_dir / "projects"
137
+ n = 0
138
+ if base.is_dir():
139
+ for _ in base.rglob("*.jsonl"):
140
+ n += 1
141
+ if n >= cap:
142
+ break
143
+ return n
144
+
145
+
146
+ def _git_out(repo: Path, *args: str) -> str:
147
+ try:
148
+ r = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, timeout=10)
149
+ return r.stdout.strip() if r.returncode == 0 else ""
150
+ except (OSError, subprocess.TimeoutExpired):
151
+ return ""
152
+
153
+
154
+ # ================================================================== layout
155
+ CSS = """
156
+ :root{--paper:#EEF1F4;--sheet:#fff;--ink:#1B2A41;--muted:#5B6B82;--rule:#D3DAE3;--accent:#2F5BEA;--ok:#0F7B5F;--bad:#B3261E;
157
+ font-family:"Ubuntu","Noto Sans","DejaVu Sans",system-ui,sans-serif;color:var(--ink);background:var(--paper);
158
+ font-variant-numeric:tabular-nums;line-height:1.5}
159
+ @media (prefers-color-scheme:dark){:root{--paper:#121822;--sheet:#1A2230;--ink:#DCE3ED;--muted:#93A1B5;--rule:#2A3444;--accent:#7B9BFF;--ok:#4CC9A0;--bad:#FF8A80}}
160
+ *{box-sizing:border-box}body{margin:0}a{color:var(--accent)}
161
+ :focus-visible{outline:2px solid var(--accent);outline-offset:2px}
162
+ .wrap{max-width:1080px;margin:0 auto;padding:0 20px 64px}
163
+ header{display:flex;align-items:center;gap:20px;padding:18px 0;border-bottom:1px solid var(--rule);margin-bottom:28px}
164
+ .brand{font-weight:700;font-size:1.05rem;color:var(--ink);text-decoration:none;margin-right:auto}
165
+ nav{display:flex;gap:22px}nav a{color:var(--muted);text-decoration:none;padding:4px 2px}nav a[aria-current]{color:var(--ink);border-bottom:2px solid var(--accent)}
166
+ h1{font-size:clamp(2rem,5vw,3.1rem);line-height:1.05;margin:0;font-weight:700;letter-spacing:-.02em}
167
+ h1 small{display:block;font-size:.4em;font-weight:400;color:var(--muted);letter-spacing:0;margin-top:6px}
168
+ h2{font-size:1.15rem;margin:0 0 12px}h3{font-size:1.1rem;margin:0}
169
+ .daynav{display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap}.daynav .arrows{display:flex;gap:6px;margin-left:auto}
170
+ .lede{font-size:1.15rem;max-width:62ch;margin:14px 0 22px;color:var(--ink)}
171
+ .strip{background:var(--sheet);border:1px solid var(--rule);border-radius:10px;padding:14px 14px 6px;margin-bottom:30px}
172
+ .strip svg{width:100%;height:auto;display:block}.legend{display:flex;gap:14px;flex-wrap:wrap;font-size:.85rem;color:var(--muted);margin:4px 2px 6px}
173
+ .legend i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:6px;vertical-align:-1px}
174
+ .cols{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:36px;align-items:start}
175
+ @media (max-width:860px){.cols{grid-template-columns:1fr}}
176
+ .entry{padding:20px 0;border-top:1px solid var(--rule)}.entry:first-of-type{border-top:0;padding-top:4px}
177
+ .entry .where{color:var(--muted);font-size:.9rem;margin:2px 0 10px}.entry ul{margin:8px 0 12px;padding-left:1.1em;max-width:70ch}
178
+ .entry li{margin:3px 0}.src{font-size:.8rem;color:var(--muted)}
179
+ dl.facts{display:flex;flex-wrap:wrap;gap:4px 22px;margin:8px 0 0;font-size:.88rem}dl.facts div{display:flex;gap:6px}
180
+ dl.facts dt{color:var(--muted)}dl.facts dd{margin:0}
181
+ details{margin-top:8px;font-size:.9rem}summary{cursor:pointer;color:var(--muted)}details ul{margin:6px 0}
182
+ aside .panel{background:var(--sheet);border:1px solid var(--rule);border-radius:10px;padding:16px;margin-bottom:18px}
183
+ table{width:100%;border-collapse:collapse;font-size:.88rem}th,td{padding:6px 4px;text-align:right;border-bottom:1px solid var(--rule)}
184
+ td{white-space:nowrap}th{vertical-align:bottom;line-height:1.2}th:first-child,td:first-child{text-align:left}th{color:var(--muted);font-weight:500}tr:last-child td{border-bottom:0}
185
+ .tablewrap{overflow-x:auto}
186
+ button,.btn{font:inherit;font-size:.9rem;border:1px solid var(--accent);background:var(--accent);color:#fff;border-radius:7px;padding:7px 14px;cursor:pointer;text-decoration:none;display:inline-block}
187
+ button.quiet,.btn.quiet{background:transparent;color:var(--accent)}button.danger{background:transparent;border-color:var(--bad);color:var(--bad)}
188
+ button:disabled{opacity:.55;cursor:progress}
189
+ .banner{border-radius:8px;padding:10px 14px;margin-bottom:22px;border:1px solid var(--rule);background:var(--sheet)}
190
+ .banner.ok{border-color:var(--ok)}.banner.bad{border-color:var(--bad);color:var(--bad)}
191
+ section.block{background:var(--sheet);border:1px solid var(--rule);border-radius:10px;padding:20px;margin-bottom:22px}
192
+ section.block p.help{color:var(--muted);margin:0 0 14px;max-width:70ch}
193
+ label{display:block;font-size:.9rem;margin:12px 0 4px}input[type=text],input[type=date],select{font:inherit;width:100%;max-width:420px;padding:7px 9px;border:1px solid var(--rule);border-radius:6px;background:var(--paper);color:var(--ink)}
194
+ .check{display:flex;gap:8px;align-items:center;margin:12px 0}.check label{margin:0}
195
+ .row{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:14px}
196
+ code,.cmd{font-family:"Ubuntu Mono","DejaVu Sans Mono",monospace;font-size:.9em}
197
+ .cmd{display:block;background:var(--paper);border:1px solid var(--rule);border-radius:6px;padding:8px 10px;margin:6px 0;overflow-x:auto;white-space:pre}
198
+ .state-ok{color:var(--ok)}.state-bad{color:var(--bad)}.muted{color:var(--muted)}
199
+ .empty{padding:28px;border:1px dashed var(--rule);border-radius:10px;text-align:center;color:var(--muted)}
200
+ @media (prefers-reduced-motion:no-preference){.bar{transition:opacity .15s}a:hover .bar{opacity:.8}}
201
+ """
202
+
203
+ SCRIPT = """
204
+ (function(){var b=document.getElementById('jobstate');if(!b||b.dataset.running!=='1')return;
205
+ function poll(){fetch('/api/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(s){
206
+ if(!s.running){location.reload()}else{setTimeout(poll,2000)}}).catch(function(){setTimeout(poll,4000)})}
207
+ setTimeout(poll,2000)})();
208
+ """
209
+
210
+
211
+ def layout(title: str, body: str, active: str, nonce: str, job: dict, ok: str | None = None, error: str | None = None) -> str:
212
+ nav = "".join(f'<a href="{h}"{" aria-current=page" if k == active else ""}>{t}</a>'
213
+ for k, h, t in (("day", "/", "Today"), ("settings", "/settings", "Settings")))
214
+ banners = ""
215
+ if job.get("running"):
216
+ banners += f'<div class="banner" id="jobstate" data-running="1" role="status">{e(job["running"])}…</div>'
217
+ elif job.get("message") and job.get("finished"):
218
+ cls = "ok" if job.get("ok") else "bad"
219
+ banners += f'<div class="banner {cls}" role="status">{e(job["message"])} <span class="muted">({e(job["finished"])})</span></div>'
220
+ if ok in OK_MESSAGES:
221
+ banners += f'<div class="banner ok" role="status">{e(OK_MESSAGES[ok])}</div>'
222
+ if error:
223
+ banners += f'<div class="banner bad" role="alert">{e(error)}</div>'
224
+ return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
225
+ <meta name="viewport" content="width=device-width,initial-scale=1"><title>{e(title)} – Worklog</title>
226
+ <style>{CSS}</style></head><body><div class="wrap">
227
+ <header><a class="brand" href="/">Worklog</a><nav>{nav}</nav></header>{banners}{body}</div>
228
+ <script nonce="{nonce}">{SCRIPT}</script></body></html>"""
229
+
230
+
231
+ # ================================================================== pages
232
+ def _chart(history: list[tuple[date, dict[str, int]]], accounts: list[str], selected: date) -> str:
233
+ w, h, top, bottom = 700, 130, 8, 22
234
+ peak = max((sum(v.values()) for _, v in history), default=0) or 1
235
+ slot = w / len(history)
236
+ bw = slot * 0.62
237
+ parts = []
238
+ for i, (d, per) in enumerate(history):
239
+ x = i * slot + (slot - bw) / 2
240
+ y = h - bottom
241
+ total = sum(per.values())
242
+ rects = []
243
+ for name in accounts:
244
+ v = per.get(name, 0)
245
+ if not v:
246
+ continue
247
+ bh = (h - top - bottom) * v / peak
248
+ y -= bh
249
+ rects.append(f'<rect class="bar" x="{x:.1f}" y="{y:.1f}" width="{bw:.1f}" height="{bh:.1f}" rx="2" '
250
+ f'fill="{COLORS[accounts.index(name) % len(COLORS)]}"/>')
251
+ if not rects:
252
+ rects.append(f'<rect x="{x:.1f}" y="{h - bottom - 2}" width="{bw:.1f}" height="2" fill="var(--rule)"/>')
253
+ is_sel = d == selected
254
+ label_weight = "700" if is_sel else "400"
255
+ label_fill = "var(--ink)" if is_sel else "var(--muted)"
256
+ mark = (f'<rect x="{x - 3:.1f}" y="{top - 4}" width="{bw + 6:.1f}" height="{h - bottom - top + 6}" rx="4" '
257
+ f'fill="none" stroke="var(--accent)" stroke-width="1.5"/>') if is_sel else ""
258
+ tip = f"{d:%a %d %b}: {_n(total)} tokens" + "".join(f", {k} {_n(v)}" for k, v in per.items() if v)
259
+ parts.append(f'<a href="/?d={d.isoformat()}"><title>{e(tip)}</title>{mark}{"".join(rects)}'
260
+ f'<rect x="{i * slot:.1f}" y="0" width="{slot:.1f}" height="{h}" fill="transparent"/>'
261
+ f'<text x="{x + bw / 2:.1f}" y="{h - 6}" text-anchor="middle" font-size="11" '
262
+ f'font-weight="{label_weight}" fill="{label_fill}">{d.day}</text></a>')
263
+ legend = "".join(f'<span><i style="background:{COLORS[i % len(COLORS)]}"></i>{e(n)}</span>'
264
+ for i, n in enumerate(accounts))
265
+ return (f'<div class="strip"><div class="legend">{legend or "<span>No activity yet</span>"}'
266
+ f'<span style="margin-left:auto">Last {len(history)} days, peak {_short(peak) if peak > 1 else 0} tokens</span></div>'
267
+ f'<svg viewBox="0 0 {w} {h}" role="img" aria-label="Tokens per day for the last {len(history)} days">'
268
+ f'{"".join(parts)}</svg></div>')
269
+
270
+
271
+ def page_day(app: App, cfg: Config, day: date) -> tuple[str, str | None]:
272
+ store = _store(cfg)
273
+ if store is None:
274
+ return (f'<div class="empty">No data repo found at <code>{e(home_relative(cfg.store_repo))}</code>. '
275
+ f'<a href="/settings">Set it up in Settings</a>.</div>'), None
276
+ records = list(store.iter_json(f"records/{day.isoformat()}"))
277
+ accounts, projects = aggregate(records)
278
+ saved = load_saved_summaries(store, day).get("summaries", {})
279
+
280
+ history_days = [day - timedelta(days=i) for i in reversed(range(14))]
281
+ history, names = [], [a.name for a in cfg.accounts]
282
+ for d in history_days:
283
+ per: dict[str, int] = {}
284
+ for r in store.iter_json(f"records/{d.isoformat()}"):
285
+ name = r.get("account", "?")
286
+ per[name] = per.get(name, 0) + sum(v for v in (r.get("tokens") or {}).values() if isinstance(v, int))
287
+ if name not in names:
288
+ names.append(name) # account configured on another device only
289
+ history.append((d, per))
290
+
291
+ prev_d, next_d = day - timedelta(days=1), day + timedelta(days=1)
292
+ today = date.today()
293
+ arrows = (f'<a class="btn quiet" href="/?d={prev_d.isoformat()}" aria-label="Previous day">‹</a>'
294
+ + (f'<a class="btn quiet" href="/?d={next_d.isoformat()}" aria-label="Next day">›</a>' if day < today else "")
295
+ + ('<a class="btn quiet" href="/">Today</a>' if day != today else ""))
296
+ jump = (f'<form method="get" action="/" class="row" style="margin:0"><label for="d" class="muted" style="margin:0">Go to</label>'
297
+ f'<input type="date" id="d" name="d" value="{day.isoformat()}" max="{today.isoformat()}" style="width:auto">'
298
+ f'<button class="quiet">Show</button></form>')
299
+ head = (f'<div class="daynav"><h1>{day:%A, %d %B}<small>{day.year}'
300
+ f'{", today" if day == today else ""}</small></h1><div class="arrows">{arrows}</div></div>')
301
+
302
+ busy = " disabled" if app.jobs.snapshot().get("running") else ""
303
+ actions = (f'<div class="row"><form method="post" action="/action/collect"><input type="hidden" name="csrf" value="{app.csrf}">'
304
+ f'<button{busy}>Sync now</button></form>'
305
+ f'<form method="post" action="/action/report"><input type="hidden" name="csrf" value="{app.csrf}">'
306
+ f'<input type="hidden" name="d" value="{day.isoformat()}">'
307
+ f'<button class="quiet"{busy}>{"Rewrite summaries" if saved else "Write summaries"}</button></form>{jump}</div>')
308
+
309
+ if not records:
310
+ body = (head + '<p class="lede">Nothing recorded for this day yet.</p>' + _chart(history, names, day)
311
+ + '<div class="empty">Sessions appear here after a Claude Code session ends, or when you press '
312
+ '<b>Sync now</b>. Other devices show up once they have synced.</div>' + actions)
313
+ return body, None
314
+
315
+ total = sum(sum(a["tokens"].values()) for a in accounts.values())
316
+ sessions = sum(a["sessions"] for a in accounts.values())
317
+ devices = sorted({d for a in accounts.values() for d in a["devices"]})
318
+ lede = (f'<p class="lede">{_n(total)} tokens across {sessions} session{"s" * (sessions != 1)} in '
319
+ f'{len(projects)} project{"s" * (len(projects) != 1)}, on {_join(devices)}.</p>')
320
+
321
+ entries = []
322
+ for p in sorted(projects.values(), key=lambda p: (p.kind == "misc", -sum(p.tokens.values()))):
323
+ s = saved.get(p.key)
324
+ bullets = s["bullets"] if s else plain_summary(p)
325
+ src = ("Summary by Claude" if s and s.get("source") == "ai" else
326
+ "Your prompts. Press “Write summaries” for a written summary." if not s else "Your prompts")
327
+ items = "".join(f"<li>{e(b[2:] if b.startswith('- ') else b)}</li>" for b in bullets)
328
+ where = (f"{e(p.remote['host'])}/{e(p.remote['slug'])}" if p.remote
329
+ else ("Folders: " + _join(sorted(p.folders)) if p.folders else "Not in a git repo"))
330
+ facts = [("Accounts", _join(sorted(p.accounts))), ("Devices", _join(sorted(p.devices))),
331
+ ("Sessions", str(p.sessions)), ("Time", _dur(p.minutes)),
332
+ ("Tokens", _n(sum(p.tokens.values()))), ("Files edited", str(len(p.files)))]
333
+ dl = "".join(f"<div><dt>{k}</dt><dd>{v}</dd></div>" for k, v in facts)
334
+ extra = ""
335
+ if p.commits:
336
+ extra += (f'<details><summary>{len(p.commits)} commit{"s" * (len(p.commits) != 1)}</summary><ul>'
337
+ + "".join(f"<li><code>{e(h)}</code> {e(sub)}</li>" for h, sub in p.commits.items()) + "</ul></details>")
338
+ if p.files:
339
+ extra += ('<details><summary>Files edited</summary><ul>'
340
+ + "".join(f"<li><code>{e(f)}</code></li>" for f in sorted(p.files)[:60]) + "</ul></details>")
341
+ entries.append(f'<article class="entry"><h3>{e(p.name)}</h3><div class="where">{where}</div>'
342
+ f'<ul>{items}</ul><div class="src">{e(src)}</div><dl class="facts">{dl}</dl>{extra}</article>')
343
+
344
+ acct_rows = "".join(
345
+ f'<tr><td><i style="display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:6px;'
346
+ f'background:{COLORS[names.index(n) % len(COLORS)] if n in names else "var(--muted)"}"></i>{e(n)}</td>'
347
+ f'<td>{_short(a["tokens"]["input"])}</td><td>{_short(a["tokens"]["output"])}</td>'
348
+ f'<td>{_short(a["tokens"]["cache_creation"])}</td><td>{_short(a["tokens"]["cache_read"])}</td>'
349
+ f'<td><b>{_short(sum(a["tokens"].values()))}</b></td></tr>' for n, a in sorted(accounts.items()))
350
+ model_rows = "".join(
351
+ f'<tr><td>{e(n)}</td><td style="text-align:left">{e(m)}</td><td>{_short(sum(t.values()))}</td></tr>'
352
+ for n, a in sorted(accounts.items()) for m, t in sorted(a["models"].items()))
353
+ aside = (f'<aside><div class="panel"><h2>Tokens by account</h2><div class="tablewrap"><table>'
354
+ f'<tr><th>Account</th><th>In</th><th>Out</th><th>Cache<br>write</th><th>Cache<br>read</th><th>Total</th></tr>'
355
+ f'{acct_rows}</table></div></div><div class="panel"><h2>By model</h2><div class="tablewrap"><table>'
356
+ f'<tr><th>Account</th><th style="text-align:left">Model</th><th>Total</th></tr>{model_rows}</table></div>'
357
+ f'<p class="muted" style="font-size:.8rem;margin:10px 0 0">Usage as logged by Claude Code, not a bill.</p></div></aside>')
358
+ body = (head + lede + _chart(history, names, day) + actions
359
+ + f'<div class="cols" style="margin-top:26px"><main><h2>What I worked on</h2>{"".join(entries)}</main>{aside}</div>')
360
+ return body, None
361
+
362
+
363
+ def page_setup(app: App, form: dict | None = None) -> str:
364
+ f = form or {}
365
+ default_device = re.search(r'device_name = "([^"]+)"', example_config()).group(1)
366
+
367
+ def v(k: str, d: str = "") -> str:
368
+ return e(f.get(k, d))
369
+
370
+ return f"""<h1>Set up this device<small>One time only. You can change everything later in Settings.</small></h1>
371
+ <form method="post" action="/setup" style="margin-top:24px"><input type="hidden" name="csrf" value="{app.csrf}">
372
+ <section class="block"><h2>This device</h2><p class="help">A short name so the report can tell your machines apart. Use a different name on each device.</p>
373
+ <label for="device">Device name</label><input type="text" id="device" name="device" value="{v('device', default_device)}" required pattern="[A-Za-z0-9_-]{{1,40}}"></section>
374
+ <section class="block"><h2>Data repo</h2><p class="help">A private git repo (GitHub, GitLab or Bitbucket) that every device syncs to. Create it empty on the website first.
375
+ If this device already has a clone, just enter its folder. Otherwise also paste the clone URL and it will be cloned for you. The URL must work without a password prompt (SSH deploy key, see README).</p>
376
+ <label for="store">Folder</label><input type="text" id="store" name="store" value="{v('store', '~/claude-worklog-data')}" required>
377
+ <label for="clone">Clone URL (only if the folder doesn't exist yet)</label><input type="text" id="clone" name="clone" value="{v('clone')}" placeholder="git@github.com:you/claude-worklog-data.git"></section>
378
+ <section class="block"><h2>First Claude account</h2><p class="help">The account you use with plain <code>claude</code> lives in <code>~/.claude</code>. You can add more accounts after setup.</p>
379
+ <label for="aname">Account name</label><input type="text" id="aname" name="aname" value="{v('aname', 'personal')}" required pattern="[A-Za-z0-9_-]{{1,40}}">
380
+ <label for="adir">Claude config folder</label><input type="text" id="adir" name="adir" value="{v('adir', '~/.claude')}" required></section>
381
+ <button>Save setup</button></form>"""
382
+
383
+
384
+ def _detected_section(cfg: Config, csrf_field: str) -> str:
385
+ """Logins seen by the SessionStart hook. E-mails are masked; forms carry an opaque ref, never the id."""
386
+ by_dir = cfg.accounts_by_dir()
387
+ rows = []
388
+ for login in detected_logins():
389
+ for d in login["dirs"]:
390
+ if d not in by_dir:
391
+ continue # folder not configured on this device
392
+ current = cfg.resolve_account(d, login["account_id"], login["email"])
393
+ linked = current.match_account_id == login["account_id"] or (
394
+ current.match_email and login["email"] and current.match_email.casefold() == login["email"].casefold())
395
+ status = (f'Counted as <b>{e(current.name)}</b>' if linked
396
+ else f'Counted as <b>{e(current.name)}</b> <span class="muted">(folder default)</span>')
397
+ names = "".join(f'<option value="{e(a.name)}">' for a in by_dir[d])
398
+ list_id = f"names-{login['ref']}-{len(rows)}"
399
+ seen = login["last_seen"][:16].replace("T", " ")
400
+ rows.append(
401
+ f'<div class="entry"><h3>{e(mask_email(login["email"]))}</h3>'
402
+ f'<div class="where"><code>{e(home_relative(d))}</code> · last seen {e(seen)} · {status}</div>'
403
+ f'<form method="post" action="/accounts/name" class="row" style="margin-top:4px">{csrf_field}'
404
+ f'<input type="hidden" name="ref" value="{e(login["ref"])}"><input type="hidden" name="dir" value="{e(home_relative(d))}">'
405
+ f'<label for="{list_id}-in" class="muted" style="margin:0">Account name</label>'
406
+ f'<input type="text" id="{list_id}-in" name="name" list="{list_id}" value="{e(current.name) if linked else ""}" '
407
+ f'required pattern="[A-Za-z0-9_-]{{1,40}}" style="width:auto"><datalist id="{list_id}">{names}</datalist>'
408
+ f'<button class="quiet">{"Rename" if linked else "Name this account"}</button></form></div>')
409
+ body = "".join(rows) or ('<div class="empty">No logins recorded yet. Install the hook, then start a Claude Code '
410
+ 'session. Each account you log in with appears here.</div>')
411
+ return (f'<section class="block"><h2>Detected accounts</h2><p class="help">Accounts seen logged in when a session started. '
412
+ f'If you switch accounts in one folder with <code>/logout</code> and <code>/login</code>, give each one a name so '
413
+ f'its sessions are counted separately. Sessions from a login without a name go to the folder&#39;s default account. '
414
+ f'Only the name is written to your data repo; the e-mail and account id stay on this device.</p>{body}</section>')
415
+
416
+
417
+ def page_settings(app: App, cfg: Config) -> str:
418
+ c = f'<input type="hidden" name="csrf" value="{app.csrf}">'
419
+ store = _store(cfg)
420
+ if store:
421
+ remotes = [sanitize_remote(u) for u in _git_out(cfg.store_repo, "remote", "get-url", "origin").splitlines()]
422
+ remote = next((f"{r['host']}/{r['slug']}" for r in remotes if r), "none (local only)")
423
+ last = _git_out(cfg.store_repo, "log", "-1", "--format=%cr") or "no commits yet"
424
+ store_state = f'<span class="state-ok">Connected</span>. Remote: {e(remote)}. Last change {e(last)}.'
425
+ else:
426
+ store_state = '<span class="state-bad">Not a git clone.</span> Clone your private data repo into this folder.'
427
+
428
+ rows = []
429
+ for a in cfg.accounts:
430
+ exists = a.config_dir.is_dir()
431
+ n = _transcript_count(a) if exists else 0
432
+ hooked = hook_installed(a)
433
+ env = "" if a.config_dir == Path.home() / ".claude" else f"CLAUDE_CONFIG_DIR={home_relative(a.config_dir)} "
434
+ login = f'<span class="cmd">{e(env)}claude\n# then type /login and sign in with this account</span>'
435
+ state = (f'<span class="state-ok">{n} session file{"s" * (n != 1)}</span>' if exists and n
436
+ else '<span class="state-bad">Not logged in yet</span>' if not exists else '<span class="muted">No sessions yet</span>')
437
+ hook_btn = ('<span class="state-ok">Hook installed</span>' if hooked else
438
+ f'<form method="post" action="/accounts/hook">{c}<input type="hidden" name="name" value="{e(a.name)}">'
439
+ f'<button class="quiet">Install hook</button></form>')
440
+ remove = (f'<form method="post" action="/accounts/remove">{c}<input type="hidden" name="name" value="{e(a.name)}">'
441
+ f'<button class="danger">Remove</button></form>') if len(cfg.accounts) > 1 else ""
442
+ rows.append(f'<div class="entry"><h3>{e(a.name)}</h3><div class="where"><code>{e(home_relative(a.config_dir))}</code></div>'
443
+ f'<div class="row" style="margin-top:4px">{state}{hook_btn}{remove}</div>'
444
+ f'{"" if exists and n else "<details open><summary>How to log in</summary>" + login + "</details>"}</div>')
445
+
446
+ detected = _detected_section(cfg, c)
447
+ acct_opts = "".join(f'<option{" selected" if a.name == cfg.summarizer_account else ""}>{e(a.name)}</option>' for a in cfg.accounts)
448
+ found = shutil.which(cfg.claude_bin)
449
+ claude_state = (f'<span class="state-ok">Found at {e(home_relative(found))}</span>' if found
450
+ else '<span class="state-bad">Not found. Summaries will fall back to your prompts.</span>')
451
+ svc = services.status()
452
+ svc_rows = "".join(f"<tr><td><code>{e(u)}</code></td><td>{e(s)}</td></tr>" for u, s in svc.items())
453
+ report_on = services.report_timer_enabled()
454
+ return f"""<h1>Settings<small>Device {e(cfg.device)}</small></h1>
455
+ <section class="block" style="margin-top:24px"><h2>Claude accounts on this device</h2>
456
+ <p class="help">Each account keeps its sessions in its own folder. Installing the hook makes every finished session sync automatically.</p>
457
+ {"".join(rows)}
458
+ <form method="post" action="/accounts/add" style="border-top:1px solid var(--rule);padding-top:14px;margin-top:6px">{c}
459
+ <h3>Add an account</h3><label for="name">Name</label><input type="text" id="name" name="name" placeholder="work" required pattern="[A-Za-z0-9_-]{{1,40}}">
460
+ <label for="dir">Claude config folder (leave empty for <code>~/.claude-&lt;name&gt;</code>)</label><input type="text" id="dir" name="dir" placeholder="~/.claude-work">
461
+ <div class="row"><button>Add account</button></div></form></section>
462
+ {detected}
463
+
464
+ <section class="block"><h2>Summaries and daily report</h2>
465
+ <form method="post" action="/settings/report">{c}
466
+ <div class="check"><input type="checkbox" id="use_ai" name="use_ai" value="1"{" checked" if cfg.use_ai else ""}><label for="use_ai">Write summaries with Claude (uses a small amount of tokens)</label></div>
467
+ <label for="summ">Account that writes the summaries</label><select id="summ" name="summarizer">{acct_opts}</select>
468
+ <label for="bin">Claude command</label><input type="text" id="bin" name="claude_bin" value="{e(cfg.claude_bin)}"><div class="muted" style="font-size:.85rem;margin-top:4px">{claude_state}</div>
469
+ <div class="row"><button>Save changes</button></div></form>
470
+ <form method="post" action="/settings/report-timer" style="border-top:1px solid var(--rule);padding-top:14px;margin-top:16px">{c}
471
+ <p class="help">Build the daily report automatically at 23:30 on this device. Turn this on for one device only.</p>
472
+ <input type="hidden" name="enabled" value="{"0" if report_on else "1"}">
473
+ <button class="quiet"{"" if services.available() else " disabled"}>{"Turn off daily report here" if report_on else "Turn on daily report here"}</button></form></section>
474
+
475
+ <section class="block"><h2>This device</h2>
476
+ <form method="post" action="/settings/device">{c}
477
+ <label for="device">Device name</label><input type="text" id="device" name="device" value="{e(cfg.device)}" required pattern="[A-Za-z0-9_-]{{1,40}}">
478
+ <p class="muted" style="font-size:.85rem">Renaming files new sessions under the new name. Earlier days keep the old one.</p>
479
+ <label for="store">Data repo folder</label><input type="text" id="store" name="store" value="{e(home_relative(cfg.store_repo))}" required>
480
+ <p style="font-size:.9rem">{store_state}</p><div class="row"><button>Save changes</button></div></form></section>
481
+
482
+ <section class="block"><h2>Background services</h2><p class="help">These keep running after you close the browser and start again when you log in.</p>
483
+ <div class="tablewrap"><table><tr><th>Service</th><th>State</th></tr>{svc_rows}</table></div>
484
+ <p class="muted" style="font-size:.85rem">Config file: <code>{e(home_relative(cfg.path))}</code></p></section>"""
485
+
486
+
487
+ # ================================================================== app / routing
488
+ class App:
489
+ def __init__(self, port: int):
490
+ self.port = port
491
+ self.csrf = secrets.token_urlsafe(32)
492
+ self.jobs = Jobs()
493
+ self.hosts = {f"{h}:{port}" for h in ("127.0.0.1", "localhost", HOST_NAME, "[::1]")}
494
+
495
+ # ---- POST handlers: return (redirect_location | None, error | None, form)
496
+ def post(self, path: str, form: dict):
497
+ cfg = _try_config()
498
+ if path == "/setup":
499
+ return self._setup(form)
500
+ if cfg is None:
501
+ return "/setup", None, form
502
+ if path == "/action/collect":
503
+ today = date.today()
504
+ started = self.jobs.start("Syncing", lambda: f"Synced: {collect(cfg, [today - timedelta(days=1), today])} new or updated session record(s).")
505
+ return ("/?ok=job" if started else "/"), None, form
506
+ if path == "/action/report":
507
+ day = _parse_date(form.get("d")) or date.today()
508
+
509
+ def job():
510
+ collect(cfg, [day])
511
+ build_report(cfg, day, use_ai=cfg.use_ai, write=True)
512
+ return f"Summaries written for {day:%d %b}."
513
+ started = self.jobs.start("Writing summaries", job)
514
+ return (f"/?d={day.isoformat()}&ok=job" if started else f"/?d={day.isoformat()}"), None, form
515
+ if path == "/accounts/add":
516
+ return self._account_add(cfg, form)
517
+ if path == "/accounts/remove":
518
+ name = form.get("name", "")
519
+ keep = [a for a in cfg.accounts if a.name != name]
520
+ if not keep or len(keep) == len(cfg.accounts):
521
+ return None, "That account can't be removed.", form
522
+ cfg.accounts = keep
523
+ if cfg.summarizer_account == name:
524
+ cfg.summarizer_account = keep[0].name
525
+ return self._save(cfg, "account_removed")
526
+ if path == "/accounts/name":
527
+ return self._account_name(cfg, form)
528
+ if path == "/accounts/hook":
529
+ acct = cfg.account(form.get("name", ""))
530
+ if acct is None:
531
+ return None, "Unknown account.", form
532
+ ok, msg = install_hook(acct)
533
+ return ("/settings?ok=saved" if ok else None), (None if ok else msg), form
534
+ if path == "/settings/report":
535
+ summ = form.get("summarizer", "")
536
+ binv = form.get("claude_bin", "").strip()
537
+ if cfg.account(summ) is None:
538
+ return None, "Pick one of your accounts to write summaries.", form
539
+ if not binv or len(binv) > 300 or any(ch in binv for ch in "\n\r\0"):
540
+ return None, "Enter the claude command or its full path.", form
541
+ cfg.use_ai, cfg.summarizer_account, cfg.claude_bin = form.get("use_ai") == "1", summ, binv
542
+ return self._save(cfg, "saved")
543
+ if path == "/settings/report-timer":
544
+ try:
545
+ services.set_report_timer(form.get("enabled") == "1")
546
+ except (RuntimeError, OSError, subprocess.TimeoutExpired) as exc:
547
+ return None, str(exc), form
548
+ return "/settings?ok=saved", None, form
549
+ if path == "/settings/device":
550
+ device = form.get("device", "")
551
+ store = _expand(form.get("store", "") or "~/claude-worklog-data")
552
+ if not _NAME_RE.match(device):
553
+ return None, "Device name: use 1-40 letters, digits, '_' or '-'.", form
554
+ if not under_home(store):
555
+ return None, "The data repo folder must be inside your home folder.", form
556
+ cfg.device, cfg.store_repo = device, store
557
+ return self._save(cfg, "saved")
558
+ return None, "Unknown action.", form
559
+
560
+ def _save(self, cfg: Config, ok: str):
561
+ try:
562
+ save_config(cfg)
563
+ except (ConfigError, OSError) as exc:
564
+ return None, f"Couldn't save settings: {exc}", {}
565
+ return f"/settings?ok={ok}", None, {}
566
+
567
+ def _account_add(self, cfg: Config, form: dict):
568
+ name = form.get("name", "").strip()
569
+ if not _NAME_RE.match(name):
570
+ return None, "Account name: use 1-40 letters, digits, '_' or '-'.", form
571
+ if cfg.account(name):
572
+ return None, f"There is already an account called {name}.", form
573
+ cdir = _expand(form.get("dir", "").strip() or f"~/.claude-{name}")
574
+ if not under_home(cdir):
575
+ return None, "The Claude config folder must be inside your home folder.", form
576
+ if any(a.config_dir == cdir for a in cfg.accounts):
577
+ return None, ("Another account already uses that folder. To count a second login in the same folder, "
578
+ "start a session with it, then name it under Detected accounts."), form
579
+ cfg.accounts.append(Account(name, cdir))
580
+ return self._save(cfg, "account_added")
581
+
582
+ def _account_name(self, cfg: Config, form: dict):
583
+ """Give a detected login a name: link it to an existing account in its folder, or add one."""
584
+ name, ref_ = form.get("name", "").strip(), form.get("ref", "")
585
+ login = next((x for x in detected_logins() if secrets.compare_digest(x["ref"], ref_)), None)
586
+ if login is None:
587
+ return None, "That login is no longer recorded on this device.", form
588
+ cdir = _expand(form.get("dir", ""))
589
+ if cdir not in login["dirs"] or cdir not in cfg.accounts_by_dir():
590
+ return None, "That folder isn't configured for this login.", form
591
+ if not _NAME_RE.match(name):
592
+ return None, "Account name: use 1-40 letters, digits, '_' or '-'.", form
593
+ uid = login["account_id"]
594
+ target = cfg.account(name)
595
+ if target is not None and target.config_dir != cdir:
596
+ return None, f"{name} is an account for another folder. Pick a different name.", form
597
+ if target is not None and target.match_account_id not in (None, uid):
598
+ return None, f"{name} is already linked to another login.", form
599
+ linked = next((a for a in cfg.accounts_by_dir()[cdir] if a.match_account_id == uid), None)
600
+ if target is None and linked is not None: # new name for an already-named login: rename it
601
+ cfg.accounts = [replace(a, name=name) if a is linked else a for a in cfg.accounts]
602
+ if cfg.summarizer_account == linked.name:
603
+ cfg.summarizer_account = name
604
+ elif target is None:
605
+ cfg.accounts.append(Account(name, cdir, match_account_id=uid))
606
+ elif linked is not None and linked is not target:
607
+ return None, f"This login is already named {linked.name}. Rename that one instead.", form
608
+ else: # link an existing account (for example the folder's default) to this login
609
+ cfg.accounts = [replace(a, match_account_id=uid) if a is target else a for a in cfg.accounts]
610
+ return self._save(cfg, "named")
611
+
612
+ def _setup(self, form: dict):
613
+ device, aname = form.get("device", "").strip(), form.get("aname", "").strip()
614
+ store, adir = _expand(form.get("store", "").strip()), _expand(form.get("adir", "").strip())
615
+ clone = form.get("clone", "").strip()
616
+ if not _NAME_RE.match(device) or not _NAME_RE.match(aname):
617
+ return None, "Names can use 1-40 letters, digits, '_' or '-'.", form
618
+ if not under_home(store) or not under_home(adir):
619
+ return None, "Folders must be inside your home folder.", form
620
+ if not (store / ".git").exists():
621
+ if not clone:
622
+ return None, f"{home_relative(store)} is not a git clone yet. Paste the clone URL of your private data repo.", form
623
+ if clone.startswith("-") or not re.match(r"^(https://|ssh://|[A-Za-z0-9_.-]+@[A-Za-z0-9_.-]+:|[A-Za-z0-9_.-]+:)\S+$", clone):
624
+ return None, "That doesn't look like a git clone URL (use git@host:owner/repo.git or https://…).", form
625
+ if store.exists() and any(store.iterdir()):
626
+ return None, f"{home_relative(store)} already exists and isn't empty.", form
627
+ env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
628
+ env.setdefault("GIT_SSH_COMMAND", "ssh -o BatchMode=yes -o ConnectTimeout=15")
629
+ try:
630
+ r = subprocess.run(["git", "clone", "--", clone, str(store)], capture_output=True, text=True, timeout=120, env=env)
631
+ except (OSError, subprocess.TimeoutExpired) as exc:
632
+ return None, f"git clone failed: {exc}", form
633
+ if r.returncode != 0:
634
+ return None, f"git clone failed: {r.stderr.strip()[-300:]}", form
635
+ cfg = Config(device=device, store_repo=store, accounts=[Account(aname, adir)], summarizer_account=aname,
636
+ claude_bin=shutil.which("claude") or "claude")
637
+ try:
638
+ save_config(cfg, CONFIG_PATH)
639
+ except (ConfigError, OSError) as exc:
640
+ return None, f"Couldn't save settings: {exc}", form
641
+ return "/settings?ok=setup", None, form
642
+
643
+
644
+ def _parse_date(v: str | None) -> date | None:
645
+ try:
646
+ d = date.fromisoformat(v or "")
647
+ except ValueError:
648
+ return None
649
+ return min(d, date.today())
650
+
651
+
652
+ def make_handler(app: App):
653
+ class Handler(BaseHTTPRequestHandler):
654
+ server_version = "claude-worklog"
655
+ sys_version = ""
656
+
657
+ def log_message(self, fmt, *args): # route access logs to our logger, not stderr
658
+ log.debug("%s %s", self.address_string(), fmt % args)
659
+
660
+ # ---------------------------------------------------------- guards
661
+ def _host_ok(self) -> bool:
662
+ return (self.headers.get("Host") or "").lower() in app.hosts
663
+
664
+ def _send(self, status: int, body: str, ctype="text/html; charset=utf-8", nonce: str = ""):
665
+ data = body.encode("utf-8")
666
+ self.send_response(status)
667
+ self.send_header("Content-Type", ctype)
668
+ self.send_header("Content-Length", str(len(data)))
669
+ self.send_header("Cache-Control", "no-store")
670
+ self.send_header("X-Content-Type-Options", "nosniff")
671
+ self.send_header("Referrer-Policy", "same-origin") # "no-referrer" makes browsers send Origin: null on POST
672
+ self.send_header("X-Frame-Options", "DENY")
673
+ self.send_header("Content-Security-Policy",
674
+ f"default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-{nonce}'; "
675
+ "img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
676
+ self.end_headers()
677
+ self.wfile.write(data)
678
+
679
+ def _redirect(self, location: str):
680
+ self.send_response(303)
681
+ self.send_header("Location", location)
682
+ self.send_header("Content-Length", "0")
683
+ self.end_headers()
684
+
685
+ def _page(self, title, body, active, ok=None, error=None, status=200):
686
+ nonce = secrets.token_urlsafe(16)
687
+ self._send(status, layout(title, body, active, nonce, app.jobs.snapshot(), ok, error), nonce=nonce)
688
+
689
+ # ---------------------------------------------------------- GET
690
+ def do_GET(self):
691
+ if not self._host_ok():
692
+ return self._send(403, "Forbidden host", "text/plain")
693
+ try:
694
+ url = urlsplit(self.path)
695
+ q = {k: v[0] for k, v in parse_qs(url.query).items()}
696
+ if url.path == "/api/status":
697
+ return self._send(200, json.dumps(app.jobs.snapshot()), "application/json")
698
+ cfg = _try_config()
699
+ if url.path == "/setup" or (cfg is None and url.path in ("/", "/settings")):
700
+ if cfg is not None:
701
+ return self._redirect("/settings")
702
+ return self._page("Set up", page_setup(app), "settings")
703
+ if url.path == "/":
704
+ day = _parse_date(q.get("d")) or date.today()
705
+ body, err = page_day(app, cfg, day)
706
+ return self._page(f"{day:%d %b %Y}", body, "day", q.get("ok"), err)
707
+ if url.path == "/settings":
708
+ return self._page("Settings", page_settings(app, cfg), "settings", q.get("ok"))
709
+ return self._send(404, "Not found", "text/plain")
710
+ except Exception: # noqa: BLE001
711
+ log.exception("GET %s failed", self.path)
712
+ return self._send(500, "Something went wrong. See ~/.local/state/claude-worklog/worklog.log", "text/plain")
713
+
714
+ # ---------------------------------------------------------- POST
715
+ def do_POST(self):
716
+ if not self._host_ok():
717
+ return self._send(403, "Forbidden host", "text/plain")
718
+ origin = self.headers.get("Origin")
719
+ if origin and urlsplit(origin).netloc.lower() not in app.hosts:
720
+ return self._send(403, "Cross-origin request blocked", "text/plain")
721
+ try:
722
+ length = int(self.headers.get("Content-Length") or 0)
723
+ except ValueError:
724
+ length = -1
725
+ if not 0 <= length <= MAX_BODY:
726
+ return self._send(413, "Request too large", "text/plain")
727
+ form = {k: v[0] for k, v in parse_qs(self.rfile.read(length).decode("utf-8", "replace"),
728
+ keep_blank_values=True).items()}
729
+ if not secrets.compare_digest(form.get("csrf", ""), app.csrf):
730
+ return self._send(403, "Form expired. Go back and reload the page.", "text/plain")
731
+ path = urlsplit(self.path).path
732
+ try:
733
+ location, error, kept = app.post(path, form)
734
+ except Exception as exc: # noqa: BLE001
735
+ log.exception("POST %s failed", path)
736
+ location, error, kept = None, f"Something went wrong: {exc}", form
737
+ if location:
738
+ return self._redirect(location)
739
+ cfg = _try_config()
740
+ if cfg is None or path == "/setup":
741
+ return self._page("Set up", page_setup(app, kept), "settings", error=error, status=400)
742
+ return self._page("Settings", page_settings(app, cfg), "settings", error=error, status=400)
743
+
744
+ return Handler
745
+
746
+
747
+ class _Server(ThreadingHTTPServer):
748
+ daemon_threads = True
749
+ allow_reuse_address = True
750
+
751
+
752
+ class _Server6(_Server):
753
+ address_family = socket.AF_INET6
754
+
755
+
756
+ def serve(port: int = DEFAULT_PORT) -> None:
757
+ app = App(port)
758
+ handler = make_handler(app)
759
+ try:
760
+ servers = [_Server(("127.0.0.1", port), handler)]
761
+ except OSError as exc:
762
+ raise SystemExit(f"Port {port} is busy ({exc}). Is the service already running? Try another --port.") from exc
763
+ try:
764
+ servers.append(_Server6(("::1", port), handler)) # browsers may try ::1 first for *.localhost
765
+ except OSError:
766
+ log.info("IPv6 loopback unavailable; serving on 127.0.0.1 only")
767
+ for s in servers[1:]:
768
+ threading.Thread(target=s.serve_forever, daemon=True).start()
769
+ print(f"Worklog UI: http://{HOST_NAME}:{port} (also http://localhost:{port})", flush=True)
770
+ try:
771
+ servers[0].serve_forever()
772
+ except KeyboardInterrupt:
773
+ pass
774
+ finally:
775
+ for s in servers:
776
+ s.server_close()