pi-gang 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.
@@ -0,0 +1,272 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>gang · mission control</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Anton&display=swap" rel="stylesheet" />
10
+ <style>
11
+ /* Azuki: near-black ink, the signature red #dd323f, warm bone text, paper highlight.
12
+ Type system — 3 families, one scale:
13
+ Anton (display) → brand only: logo + section titles, large
14
+ system UI → all functional text + labels
15
+ mono → data: timestamps, paths, log bodies
16
+ Scale: 22 logo / 18 titles+counts / 14 names / 13 routes+body / 11 meta+time / 10 labels */
17
+ :root {
18
+ --ink:#0a0a0a; --ink-2:#111111; --ink-3:#161616; --line:#262626;
19
+ --red:#dd323f; --red-tint:rgba(221,50,63,.12); --paper:#ebebe6;
20
+ --bone:#e8e6df; --fg:#ededed; --muted:#8a877d; --muted-2:#56544c;
21
+ --amber:#f0a000; --green:#16a34a;
22
+ --display:"Anton","Arial Narrow","Helvetica Neue",Impact,sans-serif;
23
+ --ui:-apple-system,BlinkMacSystemFont,"Helvetica Neue",Helvetica,Arial,sans-serif;
24
+ --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
25
+ --ease-out:cubic-bezier(.23,1,.32,1);
26
+ }
27
+ * { box-sizing:border-box; }
28
+ ::selection { background:var(--red); color:#fff; }
29
+ body {
30
+ margin:0; height:100vh; overflow:hidden;
31
+ display:grid; grid-template-rows:auto 1fr;
32
+ font:13px/1.5 var(--ui); background:var(--ink); color:var(--fg);
33
+ -webkit-font-smoothing:antialiased;
34
+ }
35
+ /* shared micro-label: replaces the old pixel font everywhere labels appear */
36
+ .lbl { font:600 10px/1 var(--ui); letter-spacing:.12em; text-transform:uppercase; color:var(--muted); }
37
+
38
+ /* ── command bar ─────────────────────────────────────────────── */
39
+ .topbar {
40
+ display:flex; align-items:center; gap:16px; padding:0 18px; height:56px;
41
+ background:radial-gradient(120% 180% at 0% -40%, rgba(221,50,63,.22), transparent 60%), var(--ink);
42
+ border-bottom:2px solid var(--red);
43
+ }
44
+ .logo {
45
+ font-family:var(--display); font-size:22px; letter-spacing:1px; color:var(--paper);
46
+ background:var(--red); padding:5px 12px 3px; border-radius:2px; line-height:1;
47
+ }
48
+ .kicker {
49
+ font:600 11px/1 var(--ui); letter-spacing:.18em; text-transform:uppercase; color:var(--muted);
50
+ padding-left:14px; border-left:1px solid var(--line); height:20px; display:flex; align-items:center;
51
+ }
52
+ .spacer { flex:1; }
53
+ .stat { display:flex; flex-direction:column; align-items:flex-end; gap:3px; }
54
+ .stat .v { font:600 18px/1 var(--ui); color:var(--bone); font-variant-numeric:tabular-nums; }
55
+ .conn {
56
+ display:inline-flex; align-items:center; gap:8px;
57
+ padding:7px 11px; border:1px solid var(--line); border-radius:2px; background:var(--ink-2);
58
+ }
59
+ #dot { width:7px; height:7px; border-radius:50%; background:var(--muted-2); transition:background-color .25s ease, box-shadow .25s ease; }
60
+ .conn.live { border-color:rgba(221,50,63,.5); }
61
+ .conn.live .lbl { color:var(--bone); }
62
+ .conn.live #dot { background:var(--red); box-shadow:0 0 9px var(--red); animation:pulse 1.6s infinite; }
63
+ @keyframes pulse { 50% { opacity:.35; } }
64
+
65
+ /* ── body split ──────────────────────────────────────────────── */
66
+ .main { display:grid; grid-template-columns:304px 1fr; overflow:hidden; }
67
+ .sec-head {
68
+ position:sticky; top:0; z-index:2; display:flex; align-items:baseline; gap:10px;
69
+ padding:15px 18px 12px; background:var(--ink-2); border-bottom:1px solid var(--line);
70
+ }
71
+ .sec-head h2 { margin:0; font-family:var(--display); font-size:18px; letter-spacing:1.5px; text-transform:uppercase; color:var(--bone); }
72
+ .sec-head .n { margin-left:auto; font-family:var(--display); font-size:18px; color:var(--red); font-variant-numeric:tabular-nums; }
73
+ .sec-head .hist { margin-left:auto; display:inline-flex; gap:9px; align-self:center; font:600 12px/1 var(--mono); font-variant-numeric:tabular-nums; }
74
+
75
+ /* ── roster ──────────────────────────────────────────────────── */
76
+ aside { background:var(--ink-2); border-right:1px solid var(--line); display:flex; flex-direction:column; overflow:hidden; }
77
+ #members { flex:1; overflow-y:auto; }
78
+ .member { position:relative; padding:13px 16px 13px 18px; border-bottom:1px solid var(--line); transition:background-color .15s ease; }
79
+ .member::before { content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background:transparent; }
80
+ @media (hover:hover) and (pointer:fine) { .member:hover { background:var(--ink-3); } }
81
+ .member.tool::before, .member.thinking::before { background:var(--red); }
82
+ .member.idle::before { background:var(--muted-2); }
83
+ .member .name { font:600 14px/1.2 var(--ui); letter-spacing:.3px; text-transform:uppercase; color:var(--fg); }
84
+ .member .meta { font:11px/1.4 var(--mono); color:var(--muted); margin-top:4px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
85
+ .badge {
86
+ float:right; font:600 10px/1 var(--ui); letter-spacing:.1em; text-transform:uppercase;
87
+ padding:4px 8px; border-radius:2px; background:var(--ink-3); color:var(--muted); border:1px solid var(--line);
88
+ }
89
+ .badge.thinking { color:#1a1205; background:var(--amber); border-color:var(--amber); }
90
+ .badge.tool { color:var(--paper); background:var(--red); border-color:var(--red); }
91
+ .badge.idle { color:var(--green); border-color:rgba(22,163,74,.4); }
92
+ .rail-foot { padding:10px 16px; border-top:1px solid var(--line); }
93
+ .rail-foot .lbl { color:var(--muted-2); }
94
+
95
+ /* ── feed ────────────────────────────────────────────────────── */
96
+ main { display:flex; flex-direction:column; overflow:hidden; }
97
+ #feed { flex:1; overflow-y:auto; }
98
+ .msg { padding:12px 18px; border-bottom:1px solid var(--line); display:grid; grid-template-columns:72px 1fr; gap:14px; animation:msg-in 180ms var(--ease-out) both; }
99
+ @keyframes msg-in { from { opacity:0; transform:translateY(-4px); } }
100
+ .msg.await { background:var(--red-tint); box-shadow:inset 3px 0 0 var(--red); }
101
+ .msg .t { font:11px/1.4 var(--mono); color:var(--muted-2); padding-top:2px; font-variant-numeric:tabular-nums; }
102
+ .route { font:600 13px/1.3 var(--ui); letter-spacing:.3px; text-transform:uppercase; }
103
+ .route .from { color:var(--bone); }
104
+ .route .arrow { color:var(--muted-2); margin:0 5px; }
105
+ .route .to { color:var(--red); }
106
+ .await-tag { font:600 10px/1 var(--ui); letter-spacing:.1em; text-transform:uppercase; color:var(--red); border:1px solid rgba(221,50,63,.5); border-radius:2px; padding:3px 6px; margin-left:9px; }
107
+ .text { font:13px/1.6 var(--mono); white-space:pre-wrap; word-break:break-word; margin-top:6px; color:var(--bone); }
108
+
109
+ /* ── empty states ────────────────────────────────────────────── */
110
+ .empty { display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; padding:64px 24px; }
111
+ .empty .empty-h { font-family:var(--display); font-size:18px; letter-spacing:1.5px; text-transform:uppercase; color:var(--muted); }
112
+ .empty .empty-s { font:600 11px/1 var(--ui); letter-spacing:.12em; text-transform:uppercase; color:var(--muted-2); margin-top:9px; }
113
+
114
+ ::-webkit-scrollbar { width:9px; }
115
+ ::-webkit-scrollbar-thumb { background:var(--line); }
116
+ ::-webkit-scrollbar-thumb:hover { background:var(--red); }
117
+
118
+ @media (max-width:720px) {
119
+ .main { grid-template-columns:1fr; grid-template-rows:40% 1fr; }
120
+ aside { border-right:0; border-bottom:1px solid var(--line); }
121
+ }
122
+ @media (prefers-reduced-motion: reduce) {
123
+ .msg { animation:none; }
124
+ .conn.live #dot { animation:none; }
125
+ }
126
+ </style>
127
+ </head>
128
+ <body>
129
+ <div class="topbar">
130
+ <span class="logo">GANG</span>
131
+ <span class="kicker">Mission Control</span>
132
+ <span class="spacer"></span>
133
+ <div class="stat"><span class="v" id="mcount">0</span><span class="lbl">Members</span></div>
134
+ <div class="stat"><span class="v" id="fcount">0</span><span class="lbl">Signals</span></div>
135
+ <span class="conn" id="conn"><span id="dot"></span><span class="lbl" id="conn-label">Offline</span></span>
136
+ </div>
137
+
138
+ <div class="main">
139
+ <aside>
140
+ <div class="sec-head"><h2>The Squad</h2><span class="hist" id="hist"></span><span class="n" id="mn">0</span></div>
141
+ <div id="members"></div>
142
+ <div class="rail-foot"><span class="lbl" id="bus-label"></span></div>
143
+ </aside>
144
+ <main>
145
+ <div class="sec-head"><h2>The Wire</h2><span class="n" id="fn">0</span></div>
146
+ <div id="feed"></div>
147
+ </main>
148
+ </div>
149
+
150
+ <script>
151
+ const EMPTY_MEMBERS = '<div class="empty"><div class="empty-h">The garden is empty</div><div class="empty-s">spawn a member to begin</div></div>';
152
+ const EMPTY_FEED = '<div class="empty"><div class="empty-h">The wire is quiet</div><div class="empty-s">listening for transmissions</div></div>';
153
+
154
+ const members = new Map(); // sessionId -> SessionInfo
155
+ const membersEl = document.getElementById("members");
156
+ const feedEl = document.getElementById("feed");
157
+ const conn = document.getElementById("conn");
158
+ const connLabel = document.getElementById("conn-label");
159
+ const busLabel = document.getElementById("bus-label");
160
+ let msgCount = 0;
161
+
162
+ busLabel.textContent = `:${window.location.port || "7717"} · intercom bus`;
163
+
164
+ function setCount(id, n){ document.getElementById(id).textContent = n; }
165
+ function short(s){ return (s||"").slice(0,8); }
166
+ function statusClass(st){
167
+ st = st || "";
168
+ if (st.startsWith("tool:")) return "tool";
169
+ if (st.startsWith("thinking")) return "thinking";
170
+ if (st.startsWith("idle")) return "idle";
171
+ return "";
172
+ }
173
+ function statusLabel(st){
174
+ st = st || "—";
175
+ if (st.startsWith("tool:")) return st.slice(5) || "tool"; // CSS uppercases
176
+ if (st.startsWith("thinking")) return "thinking";
177
+ if (st.startsWith("idle")) return "idle";
178
+ return st;
179
+ }
180
+ function fmtDur(ms){
181
+ const s = Math.floor(ms/1000); if (s < 60) return s+"s";
182
+ const m = Math.floor(s/60); if (m < 60) return m+"m";
183
+ const h = Math.floor(m/60); if (h < 24) return h+"h";
184
+ return Math.floor(h/24)+"d";
185
+ }
186
+ function renderHist(){
187
+ let tool=0, think=0, idle=0, other=0;
188
+ for (const s of members.values()){
189
+ const st = s.status || "";
190
+ if (st.startsWith("tool:")) tool++;
191
+ else if (st.startsWith("thinking")) think++;
192
+ else if (st.startsWith("idle")) idle++;
193
+ else other++;
194
+ }
195
+ const chip = (n,sym,col) => n ? `<span style="color:${col}">${sym}${n}</span>` : "";
196
+ document.getElementById("hist").innerHTML =
197
+ chip(tool,"◆","var(--red)") + chip(think,"◐","var(--bone)") +
198
+ chip(idle,"●","var(--muted-2)") + chip(other,"·","var(--muted)");
199
+ }
200
+ function renderMembers(){
201
+ setCount("mcount", members.size); setCount("mn", members.size);
202
+ renderHist();
203
+ if (members.size === 0){ membersEl.innerHTML = EMPTY_MEMBERS; return; }
204
+ const now = Date.now();
205
+ membersEl.innerHTML = "";
206
+ for (const s of members.values()){
207
+ const el = document.createElement("div");
208
+ const st = s.status || "—";
209
+ const cls = statusClass(st);
210
+ el.className = "member " + cls;
211
+
212
+ const badge = document.createElement("span");
213
+ badge.className = "badge " + cls;
214
+ badge.textContent = statusLabel(st);
215
+
216
+ const name = document.createElement("div");
217
+ name.className = "name";
218
+ name.textContent = s.name || short(s.id);
219
+
220
+ const meta = document.createElement("div");
221
+ meta.className = "meta";
222
+ const cwd = (s.cwd || "").replace(/^.*\//, "…/"); // tail only — full path on hover
223
+ const up = s.startedAt ? fmtDur(now - s.startedAt) : "";
224
+ const idle = s.lastActivity ? "·"+fmtDur(now - s.lastActivity) : ""; // time since last transition
225
+ const dur = [up, idle].filter(Boolean).join(" ");
226
+ meta.textContent = [s.model, cwd, dur].filter(Boolean).join(" · ");
227
+ if (s.cwd) meta.title = s.cwd;
228
+
229
+ el.append(badge, name, meta);
230
+ membersEl.appendChild(el);
231
+ }
232
+ }
233
+ function addMessage(m){
234
+ const ph = feedEl.querySelector(".empty"); if (ph) ph.remove();
235
+ const el = document.createElement("div");
236
+ el.className = "msg" + (m.expectsReply ? " await" : "");
237
+ const time = new Date(m.ts || Date.now()).toLocaleTimeString([], {hour:"2-digit",minute:"2-digit",second:"2-digit"});
238
+ el.innerHTML =
239
+ `<div class="t">${time}</div>` +
240
+ `<div><div class="route"><span class="from"></span><span class="arrow">→</span><span class="to"></span>` +
241
+ (m.expectsReply ? ' <span class="await-tag">↩ awaiting reply</span>' : "") + `</div>` +
242
+ `<div class="text"></div></div>`;
243
+ el.querySelector(".from").textContent = m.from || "?";
244
+ el.querySelector(".to").textContent = m.to || "?";
245
+ el.querySelector(".text").textContent = m.text || "";
246
+ feedEl.prepend(el);
247
+ setCount("fcount", ++msgCount); setCount("fn", msgCount);
248
+ }
249
+ function resetFeed(){ feedEl.innerHTML = EMPTY_FEED; msgCount = 0; setCount("fcount", 0); setCount("fn", 0); }
250
+
251
+ const es = new EventSource("/events");
252
+ es.onopen = () => { conn.classList.add("live"); connLabel.textContent = "live"; };
253
+ es.onerror = () => { conn.classList.remove("live"); connLabel.textContent = "offline"; };
254
+ es.onmessage = (e) => {
255
+ let ev; try { ev = JSON.parse(e.data); } catch { return; }
256
+ switch (ev.type){
257
+ case "snapshot":
258
+ members.clear(); (ev.sessions||[]).forEach(s => members.set(s.id, s)); renderMembers();
259
+ resetFeed(); (ev.feed||[]).forEach(addMessage); // buffer is oldest→newest; each prepends → newest on top
260
+ break;
261
+ case "session_joined": members.set(ev.session.id, ev.session); renderMembers(); break;
262
+ case "presence_update": members.set(ev.session.id, ev.session); renderMembers(); break;
263
+ case "session_left": members.delete(ev.sessionId); renderMembers(); break;
264
+ case "message": addMessage(ev); break;
265
+ }
266
+ };
267
+
268
+ renderMembers(); resetFeed(); // paint empty states before first event
269
+ setInterval(renderMembers, 1000); // keep uptime/idle durations ticking between SSE events
270
+ </script>
271
+ </body>
272
+ </html>
@@ -0,0 +1,108 @@
1
+ import { createServer, type Server, type ServerResponse } from "http";
2
+ import { readFileSync } from "fs";
3
+ import { join, dirname } from "path";
4
+ import { fileURLToPath } from "url";
5
+ import type { SessionInfo } from "../types.js";
6
+
7
+ const DASHBOARD = join(dirname(fileURLToPath(import.meta.url)), "dashboard.html");
8
+ const GUI_HOST = "127.0.0.1";
9
+ const DEFAULT_GUI_PORT = 7717;
10
+
11
+ export function parseGuiPort(value: unknown, fallback = DEFAULT_GUI_PORT): number {
12
+ const port = typeof value === "number" ? value : Number(value ?? fallback);
13
+ return Number.isInteger(port) && port >= 0 && port <= 65_535 ? port : fallback;
14
+ }
15
+
16
+ export const GUI_PORT = parseGuiPort(process.env.GANG_GUI_PORT);
17
+
18
+ /** Mission control only shows feed activity this recent (GANG_FEED_HOURS, default 6h). */
19
+ export const FEED_MAX_AGE_MS = Math.max(1, Number(process.env.GANG_FEED_HOURS) || 6) * 3_600_000;
20
+
21
+ /** Keep only feed entries within the recency window — what mission control shows on load/reconnect. */
22
+ export function recentFeed<T extends { ts?: number }>(feed: T[], now: number, maxAgeMs = FEED_MAX_AGE_MS): T[] {
23
+ const cutoff = now - maxAgeMs;
24
+ return feed.filter((e) => typeof e.ts === "number" && e.ts >= cutoff);
25
+ }
26
+
27
+ export interface GuiServer {
28
+ /** Push an event to every connected browser (SSE). */
29
+ broadcast(event: Record<string, unknown>): void;
30
+ close(): void;
31
+ server: Server;
32
+ }
33
+
34
+ /**
35
+ * Mission-control: serves a vanilla dashboard at / and an SSE feed at /events.
36
+ * Optional and best-effort — a busy port disables the GUI, never crashes the broker.
37
+ */
38
+ export interface GuiSnapshot {
39
+ sessions: SessionInfo[];
40
+ feed: Record<string, unknown>[];
41
+ }
42
+
43
+ export function startGuiServer(opts: { port?: number; getSnapshot: () => GuiSnapshot }): GuiServer {
44
+ const clients = new Set<ServerResponse>();
45
+ const port = parseGuiPort(opts.port, GUI_PORT);
46
+
47
+ const server = createServer((req, res) => {
48
+ if (req.url === "/" || req.url === "/index.html") {
49
+ try {
50
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
51
+ res.end(readFileSync(DASHBOARD));
52
+ } catch {
53
+ res.writeHead(500);
54
+ res.end("dashboard.html missing");
55
+ }
56
+ return;
57
+ }
58
+ if (req.url === "/events") {
59
+ res.writeHead(200, {
60
+ "content-type": "text/event-stream",
61
+ "cache-control": "no-cache",
62
+ connection: "keep-alive",
63
+ });
64
+ res.write(": connected\n\n");
65
+ const snap = opts.getSnapshot();
66
+ res.write(`data: ${JSON.stringify({ type: "snapshot", sessions: snap.sessions, feed: snap.feed })}\n\n`);
67
+ clients.add(res);
68
+ req.on("close", () => clients.delete(res));
69
+ return;
70
+ }
71
+ res.writeHead(404);
72
+ res.end("not found");
73
+ });
74
+
75
+ server.on("error", (err: NodeJS.ErrnoException) => {
76
+ console.error(`Gang GUI disabled: ${err.message}`);
77
+ });
78
+ server.listen(port, GUI_HOST, () => {
79
+ const address = server.address();
80
+ const actualPort = typeof address === "object" && address ? address.port : port;
81
+ console.log(`Gang mission-control: http://${GUI_HOST}:${actualPort}`);
82
+ });
83
+
84
+ return {
85
+ broadcast(event) {
86
+ const payload = `data: ${JSON.stringify(event)}\n\n`;
87
+ for (const res of clients) {
88
+ try {
89
+ res.write(payload);
90
+ } catch {
91
+ clients.delete(res);
92
+ }
93
+ }
94
+ },
95
+ close() {
96
+ for (const res of clients) {
97
+ try {
98
+ res.end();
99
+ } catch {
100
+ // already gone
101
+ }
102
+ }
103
+ clients.clear();
104
+ server.close();
105
+ },
106
+ server,
107
+ };
108
+ }