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
266 lines
8.7 KiB
TypeScript
266 lines
8.7 KiB
TypeScript
/**
|
||
* 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();
|