diff --git a/scripts/package.json b/scripts/package.json index 3413c78..ac10eb3 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "hello": "tsx ./src/hello.ts", + "timmy-watch": "tsx ./src/timmy-watch.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "devDependencies": { diff --git a/scripts/src/timmy-watch.ts b/scripts/src/timmy-watch.ts new file mode 100644 index 0000000..bafbb47 --- /dev/null +++ b/scripts/src/timmy-watch.ts @@ -0,0 +1,265 @@ +/** + * timmy-watch — Live terminal feed of Timmy's inner world. + * + * Connects to the /api/ws WebSocket and streams a human-readable, + * ANSI-coloured log to stdout. Works in any tmux pane or Emacs buffer + * (shell-mode, comint, vterm, eat, etc.). + * + * Usage: + * pnpm --filter @workspace/scripts timmy-watch + * node --import tsx/esm scripts/src/timmy-watch.ts + * + * Env / flags: + * PORT= API server port (default 3000) + * --port Override PORT + * --host Override host (default 127.0.0.1) + * --no-color Disable ANSI colour + */ + +// ── ANSI helpers ────────────────────────────────────────────────────────────── + +const NO_COLOR = + process.argv.includes("--no-color") || + process.env["NO_COLOR"] !== undefined || + process.env["TERM"] === "dumb"; + +function esc(code: string, text: string): string { + return NO_COLOR ? text : `\x1b[${code}m${text}\x1b[0m`; +} + +const c = { + dim: (s: string) => esc("2", s), + bold: (s: string) => esc("1", s), + cyan: (s: string) => esc("36", s), + yellow: (s: string) => esc("33", s), + green: (s: string) => esc("32", s), + red: (s: string) => esc("31", s), + magenta: (s: string) => esc("35", s), + blue: (s: string) => esc("34", s), + white: (s: string) => esc("97", s), + bgRed: (s: string) => esc("41;97", s), + bCyan: (s: string) => esc("1;36", s), + bGreen: (s: string) => esc("1;32", s), + bYellow: (s: string) => esc("1;33", s), +}; + +// ── CLI args ────────────────────────────────────────────────────────────────── + +function argAfter(flag: string): string | undefined { + const idx = process.argv.indexOf(flag); + return idx !== -1 ? process.argv[idx + 1] : undefined; +} + +const host = argAfter("--host") ?? "127.0.0.1"; +const port = Number(argAfter("--port") ?? process.env["PORT"] ?? 3000); +const wsUrl = `ws://${host}:${port}/api/ws`; + +// ── Timestamp ───────────────────────────────────────────────────────────────── + +function ts(): string { + return c.dim(new Date().toLocaleTimeString("en-US", { hour12: false })); +} + +// ── Agent display ───────────────────────────────────────────────────────────── + +const AGENT_LABELS: Record = { + alpha: "α orchestrator", + beta: "β eval ", + gamma: "γ work ", + delta: "δ lightning ", + timmy: "✦ timmy ", +}; + +const agentStates: Record = { + alpha: "idle", beta: "idle", gamma: "idle", delta: "idle", +}; + +function agentLabel(id: string): string { + return AGENT_LABELS[id] ?? id; +} + +function stateChip(state: string): string { + switch (state) { + case "working": return c.bGreen(`[${state}]`); + case "thinking": return c.bCyan(`[${state}]`); + case "active": return c.bYellow(`[${state}]`); + case "idle": return c.dim(`[${state} ]`); + default: return c.dim(`[${state}]`); + } +} + +function agentRow(): string { + return Object.entries(agentStates) + .map(([id, st]) => `${c.bold(id)} ${stateChip(st)}`) + .join(" "); +} + +// ── Event pretty-printer ────────────────────────────────────────────────────── + +type WsMsg = Record; + +function printLine(label: string, detail: string): void { + process.stdout.write(`${ts()} ${label} ${detail}\n`); +} + +function handleMessage(msg: WsMsg): void { + const type = msg["type"] as string; + + switch (type) { + case "ping": + return; // handled externally; suppress output + + case "world_state": { + const timmy = msg["timmyState"] as { mood: string; activity: string } | undefined; + const agents = msg["agentStates"] as Record | undefined; + const events = msg["recentEvents"] as Array<{ type: string; summary: string }> | undefined; + + if (agents) Object.assign(agentStates, agents); + + printLine( + c.bCyan("◉ connected "), + `${wsUrl}`, + ); + printLine( + c.cyan(" world "), + timmy + ? `Timmy is ${c.bold(timmy.mood)} / ${c.bold(timmy.activity)}` + : "state unknown", + ); + printLine(c.cyan(" agents "), agentRow()); + + if (events && events.length > 0) { + printLine(c.dim(" history "), c.dim(`last ${events.length} events:`)); + for (const ev of events) { + printLine(c.dim(" "), c.dim(`${ev.type} ${ev.summary}`)); + } + } + printLine(c.dim("─────────────"), c.dim("live feed starting ↓")); + break; + } + + case "agent_state": { + const id = String(msg["agentId"] ?? "?"); + const state = String(msg["state"] ?? "?"); + const prev = agentStates[id]; + if (prev === state) return; // skip no-op transitions + agentStates[id] = state; + printLine( + c.yellow(" agent "), + `${c.bold(agentLabel(id))} ${c.dim(prev ?? "?")} → ${stateChip(state)}`, + ); + break; + } + + case "job_started": { + const jobId = String(msg["jobId"] ?? "?").slice(0, 8); + const agentId = String(msg["agentId"] ?? "?"); + printLine( + c.bYellow("▶ job start "), + `job ${c.bold(jobId)} via ${c.bold(agentLabel(agentId))}`, + ); + break; + } + + case "job_completed": { + const jobId = String(msg["jobId"] ?? "?").slice(0, 8); + const agentId = String(msg["agentId"] ?? "?"); + printLine( + c.bGreen("✔ job done "), + `job ${c.bold(jobId)} via ${c.bold(agentLabel(agentId))}`, + ); + break; + } + + case "chat": { + const agentId = String(msg["agentId"] ?? "?"); + const text = String(msg["text"] ?? ""); + const isTimmy = agentId === "timmy"; + const isDelta = agentId === "delta"; + const isVisitor = agentId === "visitor"; + + const label = isTimmy ? c.magenta("✦ timmy ") + : isDelta ? c.green("⚡ delta ") + : isVisitor ? c.blue("👤 visitor ") + : c.blue(`💬 ${agentId.padEnd(8)} `); + + const body = isTimmy ? c.magenta(text) : isDelta ? c.green(text) : text; + printLine(label, body); + break; + } + + case "visitor_count": { + const count = msg["count"] as number; + printLine(c.dim(" visitors "), c.dim(`${count} connected`)); + break; + } + + case "agent_count": { + const count = msg["count"] as number; + printLine(c.dim(" agents "), c.dim(`${count} connected`)); + break; + } + + default: { + const json = JSON.stringify(msg); + printLine(c.dim(" unknown "), c.dim(json.slice(0, 120))); + } + } +} + +// ── WebSocket connection with auto-reconnect ────────────────────────────────── + +let retryDelay = 1000; +const MAX_DELAY = 30_000; + +function connect(): void { + printLine(c.dim(" connecting"), c.dim(wsUrl)); + + const ws = new WebSocket(wsUrl); + + ws.addEventListener("open", () => { + retryDelay = 1000; + ws.send(JSON.stringify({ type: "subscribe" })); + ws.send(JSON.stringify({ type: "visitor_enter", visitorId: "timmy-watch-cli" })); + }); + + ws.addEventListener("message", (ev) => { + try { + const msg = JSON.parse(String(ev.data)) as WsMsg; + if (msg["type"] === "ping") { + ws.send(JSON.stringify({ type: "pong" })); + return; + } + handleMessage(msg); + } catch { + printLine(c.red(" parse err "), String(ev.data).slice(0, 80)); + } + }); + + ws.addEventListener("close", (ev) => { + printLine( + c.dim(" closed "), + c.dim(`code=${ev.code} retrying in ${retryDelay / 1000}s…`), + ); + setTimeout(() => { + retryDelay = Math.min(retryDelay * 2, MAX_DELAY); + connect(); + }, retryDelay); + }); + + ws.addEventListener("error", () => { + printLine(c.red(" ws error "), c.red(`cannot reach ${wsUrl}`)); + }); +} + +// ── Banner ──────────────────────────────────────────────────────────────────── + +const banner = [ + "", + c.bold(c.magenta(" TIMMY WATCH")), + c.dim(` ${wsUrl} · Ctrl-C to quit`), + "", +].join("\n"); + +process.stdout.write(banner + "\n"); +connect();