Files
timmy-tower/scripts/src/timmy-watch.ts
alexpaynex b837094ed4 Add a live feed to view Timmy's internal state and agent activity
Introduces a new CLI script `timmy-watch` to establish a WebSocket connection and display real-time updates of Timmy's status, agent states, and recent events.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 90c7a60b-2c61-4699-b5c6-6a1ac7469a4d
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 56ce6fae-6759-4857-bf0c-606a96a71bdb
Replit-Helium-Checkpoint-Created: true
2026-03-19 23:07:26 +00:00

266 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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=<n> API server port (default 3000)
* --port <n> Override PORT
* --host <h> 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<string, string> = {
alpha: "α orchestrator",
beta: "β eval ",
gamma: "γ work ",
delta: "δ lightning ",
timmy: "✦ timmy ",
};
const agentStates: Record<string, string> = {
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<string, unknown>;
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<string, string> | 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();