forge-cw 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/console/assets/addon-fit-BlxrFPDK.js +1 -0
- package/console/assets/addon-web-links-BWjmmSgS.js +12 -0
- package/console/assets/index-Digctq9W.css +32 -0
- package/console/assets/index-ItVUTuQf.js +31 -0
- package/console/assets/xterm-DlVFs1Kw.js +9 -0
- package/console/index.html +13 -0
- package/cw/.bundle.json +4 -0
- package/cw/agents/presentation-curator.md +119 -0
- package/cw/agents/weather-agent.md +67 -0
- package/cw/cw +6188 -0
- package/cw/cw-shell-integration.sh +132 -0
- package/cw/hooks/HOOKS-README.md +471 -0
- package/cw/hooks/config/hooks-config.json +24 -0
- package/cw/hooks/scripts/auto-compact.py +133 -0
- package/cw/hooks/scripts/hooks.py +472 -0
- package/cw/hooks/scripts/review-autoclose.py +171 -0
- package/cw/hooks/sounds/agent_permissionrequest/agent_permissionrequest.mp3 +0 -0
- package/cw/hooks/sounds/agent_permissionrequest/agent_permissionrequest.wav +0 -0
- package/cw/hooks/sounds/agent_posttooluse/agent_posttooluse.mp3 +0 -0
- package/cw/hooks/sounds/agent_posttooluse/agent_posttooluse.wav +0 -0
- package/cw/hooks/sounds/agent_posttoolusefailure/agent_posttoolusefailure.mp3 +0 -0
- package/cw/hooks/sounds/agent_posttoolusefailure/agent_posttoolusefailure.wav +0 -0
- package/cw/hooks/sounds/agent_pretooluse/agent_pretooluse.mp3 +0 -0
- package/cw/hooks/sounds/agent_pretooluse/agent_pretooluse.wav +0 -0
- package/cw/hooks/sounds/agent_stop/agent_stop.mp3 +0 -0
- package/cw/hooks/sounds/agent_stop/agent_stop.wav +0 -0
- package/cw/hooks/sounds/agent_subagentstop/agent_subagentstop.mp3 +0 -0
- package/cw/hooks/sounds/agent_subagentstop/agent_subagentstop.wav +0 -0
- package/cw/hooks/sounds/configchange/configchange.mp3 +0 -0
- package/cw/hooks/sounds/configchange/configchange.wav +0 -0
- package/cw/hooks/sounds/notification/notification.mp3 +0 -0
- package/cw/hooks/sounds/notification/notification.wav +0 -0
- package/cw/hooks/sounds/permissionrequest/permissionrequest.mp3 +0 -0
- package/cw/hooks/sounds/permissionrequest/permissionrequest.wav +0 -0
- package/cw/hooks/sounds/posttooluse/posttooluse.mp3 +0 -0
- package/cw/hooks/sounds/posttooluse/posttooluse.wav +0 -0
- package/cw/hooks/sounds/posttoolusefailure/posttoolusefailure.mp3 +0 -0
- package/cw/hooks/sounds/posttoolusefailure/posttoolusefailure.wav +0 -0
- package/cw/hooks/sounds/precompact/precompact.mp3 +0 -0
- package/cw/hooks/sounds/precompact/precompact.wav +0 -0
- package/cw/hooks/sounds/pretooluse/pretooluse-git-committing.mp3 +0 -0
- package/cw/hooks/sounds/pretooluse/pretooluse-git-committing.wav +0 -0
- package/cw/hooks/sounds/pretooluse/pretooluse.mp3 +0 -0
- package/cw/hooks/sounds/pretooluse/pretooluse.wav +0 -0
- package/cw/hooks/sounds/sessionend/sessionend.mp3 +0 -0
- package/cw/hooks/sounds/sessionend/sessionend.wav +0 -0
- package/cw/hooks/sounds/sessionstart/sessionstart.mp3 +0 -0
- package/cw/hooks/sounds/sessionstart/sessionstart.wav +0 -0
- package/cw/hooks/sounds/setup/Setup.mp3 +0 -0
- package/cw/hooks/sounds/setup/Setup.wav +0 -0
- package/cw/hooks/sounds/stop/stop.mp3 +0 -0
- package/cw/hooks/sounds/stop/stop.wav +0 -0
- package/cw/hooks/sounds/subagentstart/subagentstart.mp3 +0 -0
- package/cw/hooks/sounds/subagentstart/subagentstart.wav +0 -0
- package/cw/hooks/sounds/subagentstop/subagentstop.mp3 +0 -0
- package/cw/hooks/sounds/subagentstop/subagentstop.wav +0 -0
- package/cw/hooks/sounds/taskcompleted/taskcompleted.mp3 +0 -0
- package/cw/hooks/sounds/taskcompleted/taskcompleted.wav +0 -0
- package/cw/hooks/sounds/teammateidle/teammateidle.mp3 +0 -0
- package/cw/hooks/sounds/teammateidle/teammateidle.wav +0 -0
- package/cw/hooks/sounds/userpromptsubmit/userpromptsubmit.mp3 +0 -0
- package/cw/hooks/sounds/userpromptsubmit/userpromptsubmit.wav +0 -0
- package/cw/hooks/sounds/worktreecreate/worktreecreate.mp3 +0 -0
- package/cw/hooks/sounds/worktreecreate/worktreecreate.wav +0 -0
- package/cw/hooks/sounds/worktreeremove/worktreeremove.mp3 +0 -0
- package/cw/hooks/sounds/worktreeremove/worktreeremove.wav +0 -0
- package/cw/install.sh +243 -0
- package/cw/lib/context/github.sh +51 -0
- package/cw/lib/context/linear.sh +49 -0
- package/cw/lib/context/notion.sh +38 -0
- package/cw/lib/dashboard/activity-hook.py +134 -0
- package/cw/lib/dashboard/index.html +1515 -0
- package/cw/lib/dashboard/server.py +379 -0
- package/cw/lib/dashboard/start.sh +15 -0
- package/cw/lib/harnesses/claude.sh +92 -0
- package/cw/lib/harnesses/codex.sh +245 -0
- package/cw/lib/harnesses/opencode.sh +120 -0
- package/cw/lib/harnesses/pi.sh +66 -0
- package/cw/lib/iterm2.sh +140 -0
- package/cw/templates/CLAUDE.template.md +48 -0
- package/dist/index.js +4376 -0
- package/package.json +51 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CW Retro Dashboard — Data server.
|
|
3
|
+
|
|
4
|
+
Tiny HTTP server that:
|
|
5
|
+
GET / → serves index.html
|
|
6
|
+
GET /api/data → returns live CW workspace data as JSON
|
|
7
|
+
GET /api/activity?after=<ts> → returns recent activity events
|
|
8
|
+
GET /api/stream → SSE stream of real-time activity events
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import http.server
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import glob
|
|
15
|
+
import sys
|
|
16
|
+
import webbrowser
|
|
17
|
+
import socket
|
|
18
|
+
import time
|
|
19
|
+
import threading
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from urllib.parse import urlparse, parse_qs
|
|
23
|
+
|
|
24
|
+
CW_HOME = os.environ.get("CW_HOME", os.path.expanduser("~/.cw"))
|
|
25
|
+
DASHBOARD_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
26
|
+
ACTIVITY_FILE = os.path.join(CW_HOME, "activity.jsonl")
|
|
27
|
+
|
|
28
|
+
# per-harness marker files that indicate a logged-in credential dir
|
|
29
|
+
HARNESS_AUTH_MARKERS = {
|
|
30
|
+
"claude": [".claude.json"],
|
|
31
|
+
"codex": ["auth.json", "env"],
|
|
32
|
+
"pi": ["auth.json", "env"],
|
|
33
|
+
"opencode": ["auth.json", "env"],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def harness_dir(account_path, harness):
|
|
38
|
+
"""claude keeps the flat dir unless a claude subdir exists"""
|
|
39
|
+
sub = os.path.join(account_path, "claude")
|
|
40
|
+
if harness == "claude" and not os.path.isdir(sub):
|
|
41
|
+
return account_path
|
|
42
|
+
return os.path.join(account_path, harness)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def account_harness(account_path):
|
|
46
|
+
"""the account's default harness, mirroring _account_default_harness"""
|
|
47
|
+
meta_file = os.path.join(account_path, "meta.json")
|
|
48
|
+
if os.path.isfile(meta_file):
|
|
49
|
+
try:
|
|
50
|
+
with open(meta_file) as f:
|
|
51
|
+
h = json.load(f).get("harness")
|
|
52
|
+
if h:
|
|
53
|
+
return h
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
return "claude"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def harness_authenticated(account_path, harness):
|
|
60
|
+
hdir = harness_dir(account_path, harness)
|
|
61
|
+
markers = HARNESS_AUTH_MARKERS.get(harness, [])
|
|
62
|
+
return any(os.path.isfile(os.path.join(hdir, m)) for m in markers)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def collect_data():
|
|
66
|
+
data = {
|
|
67
|
+
"version": "0.1.0",
|
|
68
|
+
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
69
|
+
"accounts": [],
|
|
70
|
+
"projects": [],
|
|
71
|
+
"sessions": [],
|
|
72
|
+
"stats": {},
|
|
73
|
+
"recent_log": [],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# ── Accounts ──────────────────────────────────────────────
|
|
77
|
+
accounts_dir = os.path.join(CW_HOME, "accounts")
|
|
78
|
+
if os.path.isdir(accounts_dir):
|
|
79
|
+
for name in sorted(os.listdir(accounts_dir)):
|
|
80
|
+
acct_path = os.path.join(accounts_dir, name)
|
|
81
|
+
if not os.path.isdir(acct_path) or name.startswith("."):
|
|
82
|
+
continue
|
|
83
|
+
harness = account_harness(acct_path)
|
|
84
|
+
data["accounts"].append(
|
|
85
|
+
{
|
|
86
|
+
"name": name,
|
|
87
|
+
"harness": harness,
|
|
88
|
+
"authenticated": harness_authenticated(acct_path, harness),
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# ── Projects ──────────────────────────────────────────────
|
|
93
|
+
registry = os.path.join(CW_HOME, "projects.json")
|
|
94
|
+
if os.path.isfile(registry):
|
|
95
|
+
with open(registry) as f:
|
|
96
|
+
projects = json.load(f)
|
|
97
|
+
for name, info in projects.items():
|
|
98
|
+
pp = info.get("path", "")
|
|
99
|
+
claude_md = os.path.isfile(os.path.join(pp, "CLAUDE.md"))
|
|
100
|
+
agents_dir = os.path.join(pp, ".claude", "agents")
|
|
101
|
+
commands_dir = os.path.join(pp, ".claude", "commands")
|
|
102
|
+
agents = (
|
|
103
|
+
len(glob.glob(os.path.join(agents_dir, "*.md")))
|
|
104
|
+
if os.path.isdir(agents_dir)
|
|
105
|
+
else 0
|
|
106
|
+
)
|
|
107
|
+
commands = (
|
|
108
|
+
len(glob.glob(os.path.join(commands_dir, "*.md")))
|
|
109
|
+
if os.path.isdir(commands_dir)
|
|
110
|
+
else 0
|
|
111
|
+
)
|
|
112
|
+
has_mcp = False
|
|
113
|
+
settings_file = os.path.join(pp, ".claude", "settings.json")
|
|
114
|
+
if os.path.isfile(settings_file):
|
|
115
|
+
try:
|
|
116
|
+
with open(settings_file) as sf:
|
|
117
|
+
s = json.load(sf)
|
|
118
|
+
if s.get("mcpServers") or s.get("mcp_servers"):
|
|
119
|
+
has_mcp = True
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
data["projects"].append(
|
|
124
|
+
{
|
|
125
|
+
"name": name,
|
|
126
|
+
"account": info.get("account", ""),
|
|
127
|
+
"path": pp,
|
|
128
|
+
"type": info.get("type", ""),
|
|
129
|
+
"has_claude_md": claude_md,
|
|
130
|
+
"agents": agents,
|
|
131
|
+
"commands": commands,
|
|
132
|
+
"has_mcp": has_mcp,
|
|
133
|
+
"git": os.path.isdir(os.path.join(pp, ".git")),
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# ── Sessions ──────────────────────────────────────────────
|
|
138
|
+
sessions_dir = os.path.join(CW_HOME, "sessions")
|
|
139
|
+
if os.path.isdir(sessions_dir):
|
|
140
|
+
for project in sorted(os.listdir(sessions_dir)):
|
|
141
|
+
project_dir = os.path.join(sessions_dir, project)
|
|
142
|
+
if not os.path.isdir(project_dir):
|
|
143
|
+
continue
|
|
144
|
+
for session in sorted(os.listdir(project_dir)):
|
|
145
|
+
meta_file = os.path.join(project_dir, session, "session.json")
|
|
146
|
+
if os.path.isfile(meta_file):
|
|
147
|
+
try:
|
|
148
|
+
with open(meta_file) as f:
|
|
149
|
+
meta = json.load(f)
|
|
150
|
+
meta["harness"] = meta.get("harness") or "claude"
|
|
151
|
+
meta["provider"] = meta.get("provider") or "native"
|
|
152
|
+
data["sessions"].append(meta)
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
# ── Recent log ────────────────────────────────────────────
|
|
157
|
+
log_file = os.path.join(CW_HOME, "sessions.log")
|
|
158
|
+
if os.path.isfile(log_file):
|
|
159
|
+
with open(log_file) as f:
|
|
160
|
+
lines = f.readlines()
|
|
161
|
+
data["recent_log"] = [l.strip() for l in lines[-30:] if l.strip()]
|
|
162
|
+
|
|
163
|
+
# ── Stats ─────────────────────────────────────────────────
|
|
164
|
+
active = [s for s in data["sessions"] if s.get("status") == "active"]
|
|
165
|
+
done = [s for s in data["sessions"] if s.get("status") == "done"]
|
|
166
|
+
tasks = [s for s in active if s.get("type") == "task"]
|
|
167
|
+
reviews = [s for s in active if s.get("type") == "review"]
|
|
168
|
+
total_opens = sum(s.get("opens", 0) for s in data["sessions"])
|
|
169
|
+
|
|
170
|
+
data["stats"] = {
|
|
171
|
+
"total_sessions": len(data["sessions"]),
|
|
172
|
+
"active_sessions": len(active),
|
|
173
|
+
"completed_sessions": len(done),
|
|
174
|
+
"active_tasks": len(tasks),
|
|
175
|
+
"active_reviews": len(reviews),
|
|
176
|
+
"total_opens": total_opens,
|
|
177
|
+
"total_projects": len(data["projects"]),
|
|
178
|
+
"total_accounts": len(data["accounts"]),
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return data
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def collect_activity(after_ts=0.0, limit=100):
|
|
185
|
+
"""Read recent activity events from the JSONL file."""
|
|
186
|
+
events = []
|
|
187
|
+
if not os.path.isfile(ACTIVITY_FILE):
|
|
188
|
+
return events
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
with open(ACTIVITY_FILE) as f:
|
|
192
|
+
for line in f:
|
|
193
|
+
line = line.strip()
|
|
194
|
+
if not line:
|
|
195
|
+
continue
|
|
196
|
+
try:
|
|
197
|
+
entry = json.loads(line)
|
|
198
|
+
if entry.get("ts", 0) > after_ts:
|
|
199
|
+
events.append(entry)
|
|
200
|
+
except Exception:
|
|
201
|
+
continue
|
|
202
|
+
except Exception:
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
# Return most recent entries
|
|
206
|
+
return events[-limit:]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def get_live_sessions(events, window=30):
|
|
210
|
+
"""Derive which sessions are 'live' (had activity in last N seconds)."""
|
|
211
|
+
now = time.time()
|
|
212
|
+
cutoff = now - window
|
|
213
|
+
live = {}
|
|
214
|
+
|
|
215
|
+
for e in events:
|
|
216
|
+
ts = e.get("ts", 0)
|
|
217
|
+
if ts < cutoff:
|
|
218
|
+
continue
|
|
219
|
+
key = e.get("project", "") + "/" + e.get("task", "")
|
|
220
|
+
if not key or key == "/":
|
|
221
|
+
key = e.get("session", "unknown")
|
|
222
|
+
|
|
223
|
+
existing = live.get(key)
|
|
224
|
+
if not existing or ts > existing["ts"]:
|
|
225
|
+
live[key] = {
|
|
226
|
+
"project": e.get("project", ""),
|
|
227
|
+
"task": e.get("task", ""),
|
|
228
|
+
"type": e.get("type", ""),
|
|
229
|
+
"account": e.get("account", ""),
|
|
230
|
+
"session": e.get("session", ""),
|
|
231
|
+
"event": e.get("event", ""),
|
|
232
|
+
"tool": e.get("tool", ""),
|
|
233
|
+
"summary": e.get("summary", ""),
|
|
234
|
+
"ts": ts,
|
|
235
|
+
"age": round(now - ts, 1),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return list(live.values())
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class DashboardHandler(http.server.SimpleHTTPRequestHandler):
|
|
242
|
+
def __init__(self, *args, **kwargs):
|
|
243
|
+
super().__init__(*args, directory=DASHBOARD_DIR, **kwargs)
|
|
244
|
+
|
|
245
|
+
def do_GET(self):
|
|
246
|
+
parsed = urlparse(self.path)
|
|
247
|
+
|
|
248
|
+
if parsed.path == "/api/data":
|
|
249
|
+
self._json_response(collect_data())
|
|
250
|
+
|
|
251
|
+
elif parsed.path == "/api/activity":
|
|
252
|
+
qs = parse_qs(parsed.query)
|
|
253
|
+
after = float(qs.get("after", ["0"])[0])
|
|
254
|
+
limit = int(qs.get("limit", ["100"])[0])
|
|
255
|
+
events = collect_activity(after, limit)
|
|
256
|
+
live = get_live_sessions(events)
|
|
257
|
+
self._json_response({"events": events, "live": live})
|
|
258
|
+
|
|
259
|
+
elif parsed.path == "/api/stream":
|
|
260
|
+
self._sse_stream()
|
|
261
|
+
|
|
262
|
+
elif parsed.path == "/" or parsed.path == "/index.html":
|
|
263
|
+
self.send_response(200)
|
|
264
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
265
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
266
|
+
self.send_header("Pragma", "no-cache")
|
|
267
|
+
self.end_headers()
|
|
268
|
+
html_path = os.path.join(DASHBOARD_DIR, "index.html")
|
|
269
|
+
with open(html_path, "rb") as f:
|
|
270
|
+
self.wfile.write(f.read())
|
|
271
|
+
else:
|
|
272
|
+
super().do_GET()
|
|
273
|
+
|
|
274
|
+
def _json_response(self, data):
|
|
275
|
+
self.send_response(200)
|
|
276
|
+
self.send_header("Content-Type", "application/json")
|
|
277
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
278
|
+
self.end_headers()
|
|
279
|
+
self.wfile.write(json.dumps(data).encode())
|
|
280
|
+
|
|
281
|
+
def _sse_stream(self):
|
|
282
|
+
"""Server-Sent Events: tail activity.jsonl and push new lines."""
|
|
283
|
+
self.send_response(200)
|
|
284
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
285
|
+
self.send_header("Cache-Control", "no-cache")
|
|
286
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
287
|
+
self.send_header("Connection", "keep-alive")
|
|
288
|
+
self.end_headers()
|
|
289
|
+
|
|
290
|
+
# Seek to end of file
|
|
291
|
+
try:
|
|
292
|
+
if os.path.isfile(ACTIVITY_FILE):
|
|
293
|
+
f = open(ACTIVITY_FILE, "r")
|
|
294
|
+
f.seek(0, 2) # seek to end
|
|
295
|
+
else:
|
|
296
|
+
f = None
|
|
297
|
+
|
|
298
|
+
last_size = os.path.getsize(ACTIVITY_FILE) if f else 0
|
|
299
|
+
|
|
300
|
+
while True:
|
|
301
|
+
# Check if file was recreated/truncated
|
|
302
|
+
try:
|
|
303
|
+
cur_size = os.path.getsize(ACTIVITY_FILE) if os.path.isfile(ACTIVITY_FILE) else 0
|
|
304
|
+
except OSError:
|
|
305
|
+
cur_size = 0
|
|
306
|
+
|
|
307
|
+
if f is None and os.path.isfile(ACTIVITY_FILE):
|
|
308
|
+
f = open(ACTIVITY_FILE, "r")
|
|
309
|
+
f.seek(0, 2)
|
|
310
|
+
last_size = cur_size
|
|
311
|
+
elif f and cur_size < last_size:
|
|
312
|
+
# File was truncated/rotated, reopen
|
|
313
|
+
f.close()
|
|
314
|
+
f = open(ACTIVITY_FILE, "r")
|
|
315
|
+
last_size = 0
|
|
316
|
+
|
|
317
|
+
if f:
|
|
318
|
+
new_lines = f.readlines()
|
|
319
|
+
last_size = cur_size
|
|
320
|
+
for line in new_lines:
|
|
321
|
+
line = line.strip()
|
|
322
|
+
if not line:
|
|
323
|
+
continue
|
|
324
|
+
try:
|
|
325
|
+
entry = json.loads(line)
|
|
326
|
+
# Send as SSE event
|
|
327
|
+
payload = json.dumps(entry, ensure_ascii=False)
|
|
328
|
+
self.wfile.write(f"data: {payload}\n\n".encode())
|
|
329
|
+
self.wfile.flush()
|
|
330
|
+
except Exception:
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
# Send keepalive comment every cycle
|
|
334
|
+
try:
|
|
335
|
+
self.wfile.write(f": keepalive {time.time()}\n\n".encode())
|
|
336
|
+
self.wfile.flush()
|
|
337
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
338
|
+
break
|
|
339
|
+
|
|
340
|
+
time.sleep(0.5)
|
|
341
|
+
|
|
342
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
343
|
+
pass
|
|
344
|
+
finally:
|
|
345
|
+
if f:
|
|
346
|
+
f.close()
|
|
347
|
+
|
|
348
|
+
def log_message(self, format, *args):
|
|
349
|
+
pass
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def find_free_port(start=3737):
|
|
353
|
+
for port in range(start, start + 100):
|
|
354
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
355
|
+
try:
|
|
356
|
+
s.bind(("127.0.0.1", port))
|
|
357
|
+
return port
|
|
358
|
+
except OSError:
|
|
359
|
+
continue
|
|
360
|
+
return start
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def main():
|
|
364
|
+
port = find_free_port()
|
|
365
|
+
# Use ThreadingHTTPServer so SSE doesn't block other requests
|
|
366
|
+
server = http.server.ThreadingHTTPServer(("127.0.0.1", port), DashboardHandler)
|
|
367
|
+
url = f"http://127.0.0.1:{port}"
|
|
368
|
+
print(f"\033[0;32m[cw]\033[0m Retro Dashboard \u2192 {url}")
|
|
369
|
+
print(f"\033[2m Press Ctrl+C to stop\033[0m")
|
|
370
|
+
webbrowser.open(url)
|
|
371
|
+
try:
|
|
372
|
+
server.serve_forever()
|
|
373
|
+
except KeyboardInterrupt:
|
|
374
|
+
print(f"\n\033[0;32m[cw]\033[0m Dashboard stopped")
|
|
375
|
+
server.server_close()
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
if __name__ == "__main__":
|
|
379
|
+
main()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ── CW Retro Dashboard Launcher ──────────────────────────────
|
|
3
|
+
# Usage: bash lib/dashboard/start.sh
|
|
4
|
+
# Or via cw: cw arcade
|
|
5
|
+
set -uo pipefail
|
|
6
|
+
|
|
7
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
8
|
+
|
|
9
|
+
G='\033[0;32m' C='\033[0;36m' DIM='\033[2m' NC='\033[0m'
|
|
10
|
+
|
|
11
|
+
echo -e "${G}[cw]${NC} ${C}Launching CW Station...${NC}"
|
|
12
|
+
echo -e "${DIM} Retro Arcade Dashboard${NC}"
|
|
13
|
+
echo ""
|
|
14
|
+
|
|
15
|
+
python3 "$SCRIPT_DIR/server.py"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# claude code driver
|
|
2
|
+
# no custom_provider: the Anthropic-compatible endpoint path is not built, CW_PROVIDER is never read
|
|
3
|
+
claude_supports() {
|
|
4
|
+
local cap
|
|
5
|
+
for cap in resume_by_name continue_last non_interactive_prompt interactive_prompt \
|
|
6
|
+
mcp hooks skip_permissions agent_teams plugins \
|
|
7
|
+
model_flag statusline instructions_file skills slash_commands; do
|
|
8
|
+
[[ "$1" == "$cap" ]] && return 0
|
|
9
|
+
done
|
|
10
|
+
return 1
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
claude_config_env() {
|
|
14
|
+
printf 'CLAUDE_CONFIG_DIR=%s\n' "$CW_HARNESS_DIR"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
claude_session_ref() {
|
|
18
|
+
printf '%s' ""
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# builds argv for a plugin subcommand without spawning it
|
|
22
|
+
claude_plugin() {
|
|
23
|
+
local op="${1:-}" plugin="${2:-}"
|
|
24
|
+
_claude_base_env
|
|
25
|
+
case "$op" in
|
|
26
|
+
list) HARNESS_ARGV=(claude plugin list) ;;
|
|
27
|
+
add) HARNESS_ARGV=(claude plugin add "$plugin") ;;
|
|
28
|
+
*) return 1 ;;
|
|
29
|
+
esac
|
|
30
|
+
return 0
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# builds the common prefix shared by launch and resume
|
|
34
|
+
_claude_base_argv() {
|
|
35
|
+
HARNESS_ARGV=(claude)
|
|
36
|
+
local f
|
|
37
|
+
for f in $CW_EXTRA_FLAGS; do HARNESS_ARGV+=("$f"); done
|
|
38
|
+
[[ -n "$CW_MODEL" ]] && HARNESS_ARGV+=(--model "$CW_MODEL")
|
|
39
|
+
return 0
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_claude_base_env() {
|
|
43
|
+
HARNESS_ENV=("CLAUDE_CONFIG_DIR=$CW_HARNESS_DIR")
|
|
44
|
+
[[ -n "${CW_TEAM_ENV:-}" ]] && HARNESS_ENV+=("$CW_TEAM_ENV")
|
|
45
|
+
return 0
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
claude_launch() {
|
|
49
|
+
_claude_base_env
|
|
50
|
+
if [[ -n "${CW_PASSTHRU_ARGV+x}" ]]; then
|
|
51
|
+
HARNESS_ARGV=(claude ${CW_PASSTHRU_ARGV[@]+"${CW_PASSTHRU_ARGV[@]}"})
|
|
52
|
+
return 0
|
|
53
|
+
fi
|
|
54
|
+
_claude_base_argv
|
|
55
|
+
[[ -n "$CW_SESSION_NAME" ]] && HARNESS_ARGV+=(--name "$CW_SESSION_NAME")
|
|
56
|
+
[[ -n "$CW_PROMPT" ]] && HARNESS_ARGV+=("$CW_PROMPT")
|
|
57
|
+
return 0
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# three attempts: resume by name, continue last, start named
|
|
61
|
+
claude_resume() {
|
|
62
|
+
local attempt="$1"
|
|
63
|
+
_claude_base_env
|
|
64
|
+
_claude_base_argv
|
|
65
|
+
case "$attempt" in
|
|
66
|
+
1) HARNESS_ARGV+=(--resume "$CW_SESSION_NAME") ;;
|
|
67
|
+
2) HARNESS_ARGV+=(--continue) ;;
|
|
68
|
+
3) HARNESS_ARGV+=(--name "$CW_SESSION_NAME") ;;
|
|
69
|
+
*) return 1 ;;
|
|
70
|
+
esac
|
|
71
|
+
[[ -n "$CW_PROMPT" ]] && HARNESS_ARGV+=("$CW_PROMPT")
|
|
72
|
+
return 0
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
claude_doctor() {
|
|
76
|
+
local status detail="null"
|
|
77
|
+
if ! command -v claude >/dev/null 2>&1; then
|
|
78
|
+
status="not_installed"; detail='"claude not found on PATH"'
|
|
79
|
+
elif [[ -f "$CW_HARNESS_DIR/.claude.json" ]]; then
|
|
80
|
+
status="connected"
|
|
81
|
+
else
|
|
82
|
+
status="not_logged_in"
|
|
83
|
+
fi
|
|
84
|
+
printf '{"harness":"claude","status":"%s","detail":%s}\n' "$status" "$detail"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# unverified: no confirmed --no-browser/device-code flag or api-key import path for claude
|
|
88
|
+
claude_login() {
|
|
89
|
+
HARNESS_ENV=("CLAUDE_CONFIG_DIR=$CW_HARNESS_DIR")
|
|
90
|
+
HARNESS_ARGV=(claude /login)
|
|
91
|
+
return 0
|
|
92
|
+
}
|