Add waiting arcade MVP

This commit is contained in:
Anthony Ettinger 2026-06-06 22:09:22 +00:00
parent b1d8fe475a
commit 4682b261c0
16 changed files with 1192 additions and 2 deletions

45
docs/arcade.md Normal file
View file

@ -0,0 +1,45 @@
# Waiting Arcade
Waiting Arcade lets you play small terminal games while Logicsrc runs long tasks.
```bash
logicsrc arcade list
logicsrc arcade play hangman
logicsrc --yolo --arcade=hangman
logicsrc agentswarm --yolo --arcade=random
logicsrc agentswarm --yolo --no-arcade
```
Built-in games:
- `hangman`
- `snake`
- `moon-runner`
- `minefield`
- `agent-swarm`
Use `random` to pick a built-in game.
## Task Mode
When arcade mode is launched from `--yolo` or `agentswarm --yolo`, the task continues in the background. The game shows a compact task overlay with status, phase, progress, last message, and cost when available.
The arcade pauses immediately when the task completes, fails, needs approval, or needs expense approval. Expense approval requires an explicit modal action; the game never silently approves cost.
## Shortcuts
| Key | Action |
| --- | --- |
| `L` / `Ctrl+G` | Toggle task logs |
| `Esc` | Pause game |
| `Q` | Quit arcade |
| `Ctrl+C` | Open stop prompt in interactive mode |
| `Arrow` / `WASD` | Move in supported games |
## Non-Interactive Terminals
In CI or redirected output, Waiting Arcade does not enter raw interactive mode. It prints a static arcade snapshot and returns without changing terminal state.
## Safety
The runtime restores raw mode, cursor visibility, and the alternate screen on normal exit or `Ctrl+C`. Quitting the arcade does not imply task approval.

51
docs/config.md Normal file
View file

@ -0,0 +1,51 @@
# Config
Logicsrc stores user config at:
```text
$HOME/.logicsrc/config.json
```
Read and write values with dot paths:
```bash
logicsrc config get waiting.arcade.enabled
logicsrc config set waiting.arcade.enabled true
logicsrc config set waiting.arcade.defaultGame hangman
logicsrc config set waiting.arcade.autoStartAfterSeconds 15
```
Default Waiting Arcade config:
```json
{
"waiting": {
"arcade": {
"enabled": true,
"defaultGame": "hangman",
"random": false,
"autoStartAfterSeconds": 0,
"interruptOnApproval": true,
"interruptOnDone": true,
"interruptOnError": true,
"showTaskStatusOverlay": true
}
}
}
```
Precedence for arcade launch behavior is:
1. CLI flags
2. Environment variables
3. User config
4. Defaults
Environment variables:
```bash
LOGICSRC_ARCADE=1
LOGICSRC_ARCADE_GAME=hangman
LOGICSRC_NO_ARCADE=1
LOGICSRC_ARCADE_AUTOSTART_SECONDS=15
```

13
docs/tui.md Normal file
View file

@ -0,0 +1,13 @@
# TUI
The Logicsrc TUI package contains terminal rendering helpers for the static dashboard and Waiting Arcade.
```bash
logicsrc tui
logicsrc arcade
logicsrc arcade play snake
```
Waiting Arcade is dependency-free and runs inside the terminal. It uses keyboard input only and falls back to a static snapshot when stdout or stdin is not a TTY.
Task integrations should emit progress, log, approval, expense, completion, and error events into the arcade runtime so attention events can pause the game and show an actionable modal.

View file

@ -0,0 +1,87 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { homedir } from "node:os";
export type JsonObject = Record<string, unknown>;
export const defaultConfig: JsonObject = {
waiting: {
arcade: {
enabled: true,
defaultGame: "hangman",
random: false,
autoStartAfterSeconds: 0,
interruptOnApproval: true,
interruptOnDone: true,
interruptOnError: true,
showTaskStatusOverlay: true
}
}
};
export function configPath() {
return join(homedir(), ".logicsrc", "config.json");
}
export function readConfig() {
const file = configPath();
if (!existsSync(file)) {
return structuredClone(defaultConfig);
}
const parsed = JSON.parse(readFileSync(file, "utf8")) as JsonObject;
return mergeConfig(structuredClone(defaultConfig), parsed);
}
export function writeConfig(config: JsonObject) {
const file = configPath();
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, JSON.stringify(config, null, 2) + "\n");
return file;
}
export function getConfigValue(path: string, config = readConfig()) {
return path.split(".").reduce<unknown>((current, key) => (isObject(current) ? current[key] : undefined), config);
}
export function setConfigValue(path: string, rawValue: string, config = readConfig()) {
const parts = path.split(".").filter(Boolean);
if (parts.length === 0) {
throw new Error("Config path cannot be empty.");
}
let current: JsonObject = config;
for (const part of parts.slice(0, -1)) {
if (!isObject(current[part])) {
current[part] = {};
}
current = current[part] as JsonObject;
}
current[parts[parts.length - 1] as string] = parseConfigValue(rawValue);
return config;
}
export function parseConfigValue(value: string): unknown {
if (value === "true") return true;
if (value === "false") return false;
if (value === "null") return null;
if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
try {
return JSON.parse(value) as unknown;
} catch {
return value;
}
}
function mergeConfig(base: JsonObject, override: JsonObject): JsonObject {
for (const [key, value] of Object.entries(override)) {
if (isObject(value) && isObject(base[key])) {
base[key] = mergeConfig(base[key] as JsonObject, value);
} else {
base[key] = value;
}
}
return base;
}
function isObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -1,22 +1,61 @@
#!/usr/bin/env node
import { readFileSync } from "node:fs";
import { Command } from "commander";
import { renderPluginStatus, renderTui } from "@logicsrc/tui";
import { renderArcadeList, renderPluginStatus, renderTui, runArcadeSession, type TaskSnapshot } from "@logicsrc/tui";
import { assertSchemaKind, parseDocument, validate } from "@logicsrc/validators";
import { getConfigValue, readConfig, setConfigValue, writeConfig } from "./config.js";
import { boards, tasks } from "./fixtures.js";
import { print, type OutputFormat } from "./format.js";
import { exportOpenSpecSummary, importOpenSpec, writeOpenSpecChange } from "./openspec.js";
import { defaultPluginRegistry } from "./registry.js";
process.stdout.on("error", (error: NodeJS.ErrnoException) => {
if (error.code === "EPIPE") {
process.exit(0);
}
throw error;
});
const program = new Command();
program.enablePositionalOptions();
program
.name("logicsrc")
.description("LogicSRC OpenSpec CLI for schemas, boards, tasks, agents, payments, plugins, and TUI.")
.option("--openspec", "Enable OpenSpec.dev-compatible repo-local specs, proposals, tasks, and deltas where supported.")
.option("--openspec-only", "Restrict workflows to LogicSRC OpenSpec schemas, SDKs, MCP, CLI, TUI, and PWA contracts.")
.option("--yolo", "Start the default AgentSwarm YOLO flow.")
.option("--arcade [game]", "Launch Waiting Arcade while a long-running task executes.")
.option("--waiting-arcade", "Alias for --arcade.")
.option("--waiting-game <game>", "Alias for --arcade=<game>.")
.option("--no-arcade", "Disable Waiting Arcade.")
.version("0.1.0");
program.action(async (options) => {
if (!options.yolo) {
program.outputHelp();
return;
}
const arcadeGame = resolveArcadeGame(options);
if (arcadeGame) {
await runYoloArcade(arcadeGame);
return;
}
print(
{
type: "logicsrc.agentswarm.session",
status: "opening",
mode: "yolo",
master_agent: "agentswarm-master",
slave_agents: ["reproduce", "patch", "review"],
arcade: false
},
"json"
);
});
program
.command("login")
.option("--did <did>", "CoinPay DID")
@ -124,16 +163,59 @@ program.command("events").description("Listen to LogicSRC event stream.").argume
console.log(JSON.stringify({ type: "logicsrc.event", event: "task.created", resource_id: "task_789" }));
});
const arcade = program.command("arcade").description("Play Waiting Arcade games.");
arcade.action(async () => {
await runArcadeSession({ game: process.env.LOGICSRC_ARCADE_GAME || "hangman", standalone: true });
});
arcade.command("list").description("List built-in Waiting Arcade games.").action(() => {
console.log(renderArcadeList());
});
arcade
.command("play")
.argument("[game]", "Game id or random", "hangman")
.description("Play a standalone Waiting Arcade game.")
.action(async (game) => {
await runArcadeSession({ game, standalone: true });
});
const config = program.command("config").description("Read and write LogicSRC config.");
config
.command("get")
.argument("<path>", "Dot-path config key")
.description("Print a config value.")
.action((path) => {
console.log(JSON.stringify(getConfigValue(path), null, 2));
});
config
.command("set")
.argument("<path>", "Dot-path config key")
.argument("<value>", "JSON, boolean, number, or string value")
.description("Set a config value.")
.action((path, value) => {
const next = setConfigValue(path, value);
const file = writeConfig(next);
console.log(`Set ${path} in ${file}`);
});
program
.command("agentswarm")
.alias("agent-swarm")
.description("Open an AgentSwarm master agent session.")
.option("--yolo", "Start the master agent with autonomous execution enabled.")
.option("--arcade [game]", "Launch Waiting Arcade while AgentSwarm runs.")
.option("--waiting-arcade", "Alias for --arcade.")
.option("--waiting-game <game>", "Alias for --arcade=<game>.")
.option("--no-arcade", "Disable Waiting Arcade.")
.option("--repo <repo>", "Target repository, for example profullstack/logicsrc")
.option("--agents <agents>", "Comma-separated slave agent roles", "reproduce,patch,review")
.option("--change <id>", "OpenSpec-compatible change id", "agentswarm-yolo")
.option("--out <dir>", "OpenSpec-compatible output directory")
.action((options) => {
.action(async (options) => {
if (!options.yolo) {
console.log("AgentSwarm is coming soon. Run `logicsrc agentswarm --yolo` to open the master agent flow.");
return;
@ -157,6 +239,12 @@ program
})
: null;
const arcadeGame = resolveArcadeGame(options);
if (arcadeGame) {
await runYoloArcade(arcadeGame, options.repo);
return;
}
print({
type: "logicsrc.agentswarm.session",
status: "opening",
@ -284,6 +372,49 @@ function toTaskSchema(item: (typeof tasks)[number]) {
};
}
function resolveArcadeGame(options: { arcade?: string | boolean; waitingArcade?: boolean; waitingGame?: string }) {
if (options.arcade === false || process.env.LOGICSRC_NO_ARCADE === "1") {
return undefined;
}
if (options.waitingGame) {
return options.waitingGame;
}
if (typeof options.arcade === "string") {
return options.arcade;
}
const config = readConfig();
const configuredDefault = String(getConfigValue("waiting.arcade.defaultGame", config) ?? "hangman");
if (options.arcade === true || options.waitingArcade || process.env.LOGICSRC_ARCADE === "1") {
return process.env.LOGICSRC_ARCADE_GAME || configuredDefault;
}
return undefined;
}
async function runYoloArcade(game: string, repo?: string) {
const task: TaskSnapshot = {
id: "agentswarm_yolo",
title: repo ? `AgentSwarm YOLO on ${repo}` : "AgentSwarm YOLO session",
status: "running",
phase: "starting",
progress: 0.05,
costUsd: 0,
lastMessage: "launching Waiting Arcade"
};
await runArcadeSession({
game,
standalone: false,
task,
simulateTask: true,
logs: ["AgentSwarm master session started.", "Task continues while Waiting Arcade is active."]
});
}
program.parseAsync(process.argv).catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;

View file

@ -0,0 +1,49 @@
import type { GameAction, KeyEvent, TerminalFrame, WaitingGame } from "../types.js";
export class AgentSwarmGame implements WaitingGame {
readonly id = "agent-swarm";
readonly title = "Agent Swarm";
readonly description = "Route agents around blockers and collect approvals.";
readonly controls = [
{ key: "Arrow/WASD", action: "move lead agent" },
{ key: "L/Ctrl+G", action: "logs" },
{ key: "Esc", action: "pause" },
{ key: "Q", action: "quit" }
];
private x = 18;
private y = 8;
private tickCount = 0;
init() {
return;
}
tick() {
this.tickCount += 1;
}
input(event: KeyEvent): GameAction | void {
if (event.ctrl && event.name === "g") return { type: "toggle_logs" };
if (event.name === "escape") return { type: "pause" };
if (event.name === "q") return { type: "quit" };
if (event.name === "l") return { type: "toggle_logs" };
if (["up", "w", "k"].includes(event.name)) this.y = Math.max(2, this.y - 1);
if (["down", "s", "j"].includes(event.name)) this.y = Math.min(13, this.y + 1);
if (["left", "a", "h"].includes(event.name)) this.x = Math.max(4, this.x - 1);
if (["right", "d"].includes(event.name)) this.x = Math.min(50, this.x + 1);
}
render(frame: TerminalFrame) {
frame.box(0, 0, frame.width, frame.height - 5, this.title.toUpperCase());
frame.write(2, 4, "Guide the lead agent. Avoid blockers. Grab approvals.");
frame.write(6, 12, "B");
frame.write(10, 28, "B");
frame.write(5, 42, "$");
frame.write(12, 48, "A");
frame.write(this.y, this.x, "@");
frame.write(this.y + 1, Math.max(4, this.x - 4), "a a a");
frame.write(15, 4, `Swarm pulse: ${this.tickCount % 100}`);
frame.write(16, 4, "[Arrows/WASD] Move [L/Ctrl+G] Logs [Esc] Pause [Q] Quit");
}
}

View file

@ -0,0 +1,77 @@
import type { GameAction, GameContext, KeyEvent, TerminalFrame, WaitingGame } from "../types.js";
import { fit } from "../renderer.js";
const WORDS = ["LOGICSRC", "COMMAND BOARD", "AGENT SWARM", "PULL REQUEST", "TERMINAL", "SUPABASE", "COINPAY", "CRAWLPROOF", "UGIG", "SHIPT"];
const BAD_IDEAS = ["JIRA", "WATERFALL", "WEBPACK", "XML CONFIG", "VENDOR LOCK-IN", "MEETING", "BIG REWRITE", "GLOBAL STATE"];
export class HangmanGame implements WaitingGame {
readonly id = "hangman";
readonly title = "Logicsrc Hangman";
readonly description = "Guess terminal, agent, and Logicsrc words while a task runs.";
readonly controls = [
{ key: "A-Z", action: "guess" },
{ key: "L/Ctrl+G", action: "logs" },
{ key: "Esc", action: "pause" },
{ key: "Q", action: "quit" }
];
private word = WORDS[0] ?? "LOGICSRC";
private guesses = new Set<string>();
private mistakes = 0;
init(ctx: GameContext) {
this.word = WORDS[Math.floor(ctx.random() * WORDS.length)] ?? "LOGICSRC";
}
tick() {
return;
}
input(event: KeyEvent): GameAction | void {
if (event.ctrl && event.name === "g") {
return { type: "toggle_logs" };
}
if (event.name === "escape") {
return { type: "pause" };
}
if (event.name === "q") {
return { type: "quit" };
}
if (event.name === "l") {
return { type: "toggle_logs" };
}
if (/^[a-z]$/i.test(event.name)) {
const guess = event.name.toUpperCase();
if (!this.guesses.has(guess)) {
this.guesses.add(guess);
if (!this.word.includes(guess)) {
this.mistakes += 1;
}
}
}
}
render(frame: TerminalFrame) {
frame.box(0, 0, frame.width, frame.height - 5, this.title.toUpperCase());
frame.write(2, 4, "Phrase:");
frame.write(4, 4, this.maskedWord());
frame.write(6, 4, `Wrong guesses: ${this.mistakes}/6`);
frame.write(7, 4, BAD_IDEAS.slice(0, this.mistakes).join(" ") || "none yet");
frame.write(9, 4, fit(`Guessed: ${Array.from(this.guesses).sort().join(" ") || "none"}`, frame.width - 8));
const state = this.isWon() ? "solved" : this.mistakes >= 6 ? "reset with R or keep guessing" : "running";
frame.write(11, 4, `Round: ${state}`);
frame.write(13, 4, "[A-Z] Guess [L/Ctrl+G] Logs [Esc] Pause [Q] Quit");
}
private maskedWord() {
return this.word
.split("")
.map((char) => (char === " " ? " " : this.guesses.has(char) ? `${char} ` : "_ "))
.join("");
}
private isWon() {
return this.word.split("").every((char) => char === " " || this.guesses.has(char));
}
}

View file

@ -0,0 +1,83 @@
import type { GameAction, GameContext, KeyEvent, TerminalFrame, WaitingGame } from "../types.js";
type Cell = { mine: boolean; open: boolean; flag: boolean };
export class MinefieldGame implements WaitingGame {
readonly id = "minefield";
readonly title = "Deploy Minefield";
readonly description = "Clear risky deployment cells without triggering incidents.";
readonly controls = [
{ key: "Arrow/WASD", action: "move" },
{ key: "Space/Enter", action: "reveal" },
{ key: "F", action: "flag" },
{ key: "Q", action: "quit" }
];
private width = 8;
private height = 6;
private cells: Cell[] = [];
private cursor = { x: 0, y: 0 };
private state = "clear risky cells";
init(ctx: GameContext) {
this.cells = Array.from({ length: this.width * this.height }, (_, index) => ({
mine: index > 0 && ctx.random() < 0.16,
open: false,
flag: false
}));
}
tick() {
return;
}
input(event: KeyEvent): GameAction | void {
if (event.ctrl && event.name === "g") return { type: "toggle_logs" };
if (event.name === "escape") return { type: "pause" };
if (event.name === "q") return { type: "quit" };
if (event.name === "l") return { type: "toggle_logs" };
if (["up", "w", "k"].includes(event.name)) this.cursor.y = Math.max(0, this.cursor.y - 1);
if (["down", "s", "j"].includes(event.name)) this.cursor.y = Math.min(this.height - 1, this.cursor.y + 1);
if (["left", "a", "h"].includes(event.name)) this.cursor.x = Math.max(0, this.cursor.x - 1);
if (["right", "d"].includes(event.name)) this.cursor.x = Math.min(this.width - 1, this.cursor.x + 1);
if (event.name === "f") this.cell(this.cursor.x, this.cursor.y).flag = !this.cell(this.cursor.x, this.cursor.y).flag;
if (event.name === "space" || event.name === "return") this.reveal(this.cursor.x, this.cursor.y);
}
render(frame: TerminalFrame) {
frame.box(0, 0, frame.width, frame.height - 5, this.title.toUpperCase());
frame.write(2, 4, `Status: ${this.state}`);
for (let y = 0; y < this.height; y += 1) {
for (let x = 0; x < this.width; x += 1) {
const cell = this.cell(x, y);
const selected = this.cursor.x === x && this.cursor.y === y;
const value = cell.flag ? "F" : cell.open ? (cell.mine ? "!" : String(this.neighbors(x, y))) : "#";
frame.write(4 + y, 6 + x * 4, selected ? `[${value}]` : ` ${value} `);
}
}
frame.write(12, 4, "[Arrows/WASD] Move [Space/Enter] Reveal [F] Flag [Q] Quit");
}
private reveal(x: number, y: number) {
const cell = this.cell(x, y);
if (cell.flag) return;
cell.open = true;
this.state = cell.mine ? "incident triggered; keep mapping the blast radius" : "cell cleared";
}
private cell(x: number, y: number) {
return this.cells[y * this.width + x] ?? { mine: false, open: false, flag: false };
}
private neighbors(x: number, y: number) {
let count = 0;
for (let dy = -1; dy <= 1; dy += 1) {
for (let dx = -1; dx <= 1; dx += 1) {
if (dx !== 0 || dy !== 0) {
count += this.cell(x + dx, y + dy).mine ? 1 : 0;
}
}
}
return count;
}
}

View file

@ -0,0 +1,62 @@
import type { GameAction, GameContext, KeyEvent, TerminalFrame, WaitingGame } from "../types.js";
export class MoonRunnerGame implements WaitingGame {
readonly id = "moon-runner";
readonly title = "Moon Runner";
readonly description = "Jump over failed builds and collect green checks.";
readonly controls = [
{ key: "Space/Up", action: "jump" },
{ key: "L/Ctrl+G", action: "logs" },
{ key: "Esc", action: "pause" },
{ key: "Q", action: "quit" }
];
private runnerY = 0;
private velocity = 0;
private obstacleX = 46;
private score = 0;
private random = Math.random;
init(ctx: GameContext) {
this.random = ctx.random;
}
tick(deltaMs: number) {
const step = Math.max(1, Math.round(deltaMs / 60));
this.obstacleX -= step;
if (this.obstacleX < 4) {
this.obstacleX = 42 + Math.floor(this.random() * 24);
this.score += 1;
}
this.velocity -= 0.08 * step;
this.runnerY = Math.max(0, this.runnerY + this.velocity);
if (this.runnerY === 0 && this.velocity < 0) {
this.velocity = 0;
}
if (this.obstacleX >= 8 && this.obstacleX <= 10 && this.runnerY < 1) {
this.score = 0;
this.obstacleX = 46;
}
}
input(event: KeyEvent): GameAction | void {
if (event.ctrl && event.name === "g") return { type: "toggle_logs" };
if (event.name === "escape") return { type: "pause" };
if (event.name === "q") return { type: "quit" };
if (event.name === "l") return { type: "toggle_logs" };
if (["space", "up", "w"].includes(event.name) && this.runnerY === 0) {
this.velocity = 1.15;
}
}
render(frame: TerminalFrame) {
const ground = Math.max(7, frame.height - 9);
frame.box(0, 0, frame.width, frame.height - 5, `${this.title.toUpperCase()} CHECKS ${this.score}`);
frame.write(ground + 1, 4, "_".repeat(Math.max(10, frame.width - 8)));
frame.write(ground - Math.round(this.runnerY), 9, "@");
frame.write(ground, this.obstacleX, "X");
frame.write(ground - 2, Math.max(18, this.obstacleX + 10), "+");
frame.write(2, 4, "Low gravity. High uptime.");
frame.write(ground + 3, 4, "[Space/Up] Jump [L/Ctrl+G] Logs [Esc] Pause [Q] Quit");
}
}

View file

@ -0,0 +1,78 @@
import type { GameAction, GameContext, KeyEvent, TerminalFrame, WaitingGame } from "../types.js";
type Point = { x: number; y: number };
export class SnakeGame implements WaitingGame {
readonly id = "snake";
readonly title = "Token Snake";
readonly description = "Eat task tokens, grow the trail, and avoid blockers.";
readonly controls = [
{ key: "Arrow/WASD", action: "move" },
{ key: "L/Ctrl+G", action: "logs" },
{ key: "Esc", action: "pause" },
{ key: "Q", action: "quit" }
];
private snake: Point[] = [{ x: 12, y: 6 }];
private direction: Point = { x: 1, y: 0 };
private token: Point = { x: 20, y: 6 };
private elapsed = 0;
private score = 0;
private random = Math.random;
init(ctx: GameContext) {
this.random = ctx.random;
}
tick(deltaMs: number) {
this.elapsed += deltaMs;
if (this.elapsed < 150) {
return;
}
this.elapsed = 0;
const head = this.snake[0] ?? { x: 12, y: 6 };
const next = { x: head.x + this.direction.x, y: head.y + this.direction.y };
const maxX = 38;
const maxY = 12;
if (next.x < 1 || next.x > maxX || next.y < 1 || next.y > maxY || this.snake.some((point) => point.x === next.x && point.y === next.y)) {
this.snake = [{ x: 12, y: 6 }];
this.direction = { x: 1, y: 0 };
this.score = 0;
return;
}
this.snake.unshift(next);
if (next.x === this.token.x && next.y === this.token.y) {
this.score += 1;
this.token = { x: 1 + Math.floor(this.random() * maxX), y: 1 + Math.floor(this.random() * maxY) };
} else {
this.snake.pop();
}
}
input(event: KeyEvent): GameAction | void {
if (event.ctrl && event.name === "g") return { type: "toggle_logs" };
if (event.name === "escape") return { type: "pause" };
if (event.name === "q") return { type: "quit" };
if (event.name === "l") return { type: "toggle_logs" };
if (["up", "w", "k"].includes(event.name) && this.direction.y !== 1) this.direction = { x: 0, y: -1 };
if (["down", "s", "j"].includes(event.name) && this.direction.y !== -1) this.direction = { x: 0, y: 1 };
if (["left", "a", "h"].includes(event.name) && this.direction.x !== 1) this.direction = { x: -1, y: 0 };
if (["right", "d", "l"].includes(event.name) && this.direction.x !== -1) this.direction = { x: 1, y: 0 };
}
render(frame: TerminalFrame) {
frame.box(0, 0, frame.width, frame.height - 5, `${this.title.toUpperCase()} SCORE ${this.score}`);
for (let y = 1; y <= 12; y += 1) {
for (let x = 1; x <= 38; x += 1) {
frame.write(y + 2, x + 4, ".");
}
}
frame.write(this.token.y + 2, this.token.x + 4, "$");
for (const [index, point] of this.snake.entries()) {
frame.write(point.y + 2, point.x + 4, index === 0 ? "@" : "o");
}
frame.write(16, 4, "[Arrows/WASD] Move [L/Ctrl+G] Logs [Esc] Pause [Q] Quit");
}
}

View file

@ -0,0 +1,3 @@
export { createDefaultArcadeRegistry, renderArcadeList, ArcadeRegistry } from "./registry.js";
export { renderArcadeSnapshot, runArcadeSession } from "./runtime.js";
export type { ArcadeEvent, GameAction, GameContext, GameControl, KeyEvent, TaskEvent, TaskSnapshot, TerminalFrame, WaitingGame } from "./types.js";

View file

@ -0,0 +1,75 @@
import type { WaitingGame } from "./types.js";
import { AgentSwarmGame } from "./games/agent-swarm.js";
import { HangmanGame } from "./games/hangman.js";
import { MinefieldGame } from "./games/minefield.js";
import { MoonRunnerGame } from "./games/moon-runner.js";
import { SnakeGame } from "./games/snake.js";
export type GameFactory = () => WaitingGame;
export class ArcadeRegistry {
private readonly games = new Map<string, GameFactory>();
add(factory: GameFactory) {
const game = factory();
if (this.games.has(game.id)) {
throw new Error(`Arcade game already registered: ${game.id}`);
}
this.games.set(game.id, factory);
}
create(id: string) {
const factory = this.games.get(id);
if (!factory) {
throw new Error(`Unknown arcade game "${id}". Run "logicsrc arcade list" to see available games.`);
}
return factory();
}
list() {
return Array.from(this.games.values()).map((factory) => {
const game = factory();
return {
id: game.id,
title: game.title,
description: game.description,
controls: game.controls
};
});
}
choose(id: string | undefined, random = Math.random) {
if (!id || id === "default") {
return "hangman";
}
if (id !== "random") {
return id;
}
const games = this.list();
return games[Math.floor(random() * games.length)]?.id ?? "hangman";
}
}
export function createDefaultArcadeRegistry() {
const registry = new ArcadeRegistry();
registry.add(() => new HangmanGame());
registry.add(() => new SnakeGame());
registry.add(() => new MoonRunnerGame());
registry.add(() => new MinefieldGame());
registry.add(() => new AgentSwarmGame());
return registry;
}
export function renderArcadeList(registry = createDefaultArcadeRegistry()) {
const rows = registry.list();
const idWidth = Math.max(...rows.map((row) => row.id.length), 2);
const titleWidth = Math.max(...rows.map((row) => row.title.length), 5);
return [
"Waiting Arcade games",
"",
`${"id".padEnd(idWidth)} ${"title".padEnd(titleWidth)} description`,
`${"-".repeat(idWidth)} ${"-".repeat(titleWidth)} ${"-".repeat(40)}`,
...rows.map((row) => `${row.id.padEnd(idWidth)} ${row.title.padEnd(titleWidth)} ${row.description}`)
].join("\n");
}

View file

@ -0,0 +1,98 @@
import type { TaskSnapshot, TerminalFrame } from "./types.js";
export class TextFrame implements TerminalFrame {
readonly width: number;
readonly height: number;
private readonly cells: string[][];
constructor(width: number, height: number) {
this.width = Math.max(40, width);
this.height = Math.max(16, height);
this.cells = Array.from({ length: this.height }, () => Array.from({ length: this.width }, () => " "));
}
write(row: number, col: number, text: string) {
if (row < 0 || row >= this.height || col >= this.width) {
return;
}
for (let index = 0; index < text.length && col + index < this.width; index += 1) {
if (col + index >= 0) {
this.cells[row][col + index] = text[index] ?? " ";
}
}
}
box(row: number, col: number, width: number, height: number, title?: string) {
const right = Math.min(this.width - 1, col + width - 1);
const bottom = Math.min(this.height - 1, row + height - 1);
const left = Math.max(0, col);
const top = Math.max(0, row);
for (let x = left; x <= right; x += 1) {
this.write(top, x, x === left || x === right ? "+" : "-");
this.write(bottom, x, x === left || x === right ? "+" : "-");
}
for (let y = top + 1; y < bottom; y += 1) {
this.write(y, left, "|");
this.write(y, right, "|");
}
if (title) {
this.write(top, left + 2, ` ${fit(title, Math.max(0, width - 6))} `);
}
}
toString() {
return this.cells.map((line) => line.join("").trimEnd()).join("\n").trimEnd();
}
}
export function fit(value: string, width: number) {
if (width <= 0) {
return "";
}
return value.length > width ? value.slice(0, Math.max(0, width - 1)) + "." : value.padEnd(width);
}
export function center(value: string, width: number) {
if (value.length >= width) {
return value.slice(0, width);
}
const left = Math.floor((width - value.length) / 2);
return `${" ".repeat(left)}${value}`;
}
export function renderTaskOverlay(frame: TerminalFrame, task: TaskSnapshot | undefined, standalone: boolean) {
const top = frame.height - 5;
frame.box(top, 0, frame.width, 5, standalone ? "STANDALONE MODE" : "TASK STATUS");
if (!task) {
frame.write(top + 1, 2, "No active task. Play mode only.");
frame.write(top + 2, 2, "[L/Ctrl+G] logs [Esc] pause [Q] quit");
return;
}
const cost = typeof task.costUsd === "number" ? ` Cost: $${task.costUsd.toFixed(2)}` : "";
const progress = typeof task.progress === "number" ? ` ${Math.round(task.progress * 100)}%` : "";
frame.write(top + 1, 2, fit(`Task: ${task.title}`, frame.width - 4));
frame.write(top + 2, 2, fit(`Status: ${task.status}${progress} Phase: ${task.phase ?? "unknown"}${cost}`, frame.width - 4));
frame.write(top + 3, 2, fit(`Last: ${task.lastMessage ?? "waiting for task events"} [L/Ctrl+G] logs [Esc] pause [Q] quit`, frame.width - 4));
}
export function renderModal(title: string, body: string[], actions: string[], width = 58) {
const inner = width - 4;
const lines = [
`+${"=".repeat(width - 2)}+`,
`| ${fit(title, inner)} |`,
`|${" ".repeat(width - 2)}|`
];
for (const line of body) {
lines.push(`| ${fit(line, inner)} |`);
}
lines.push(`|${" ".repeat(width - 2)}|`);
lines.push(`| ${fit(actions.join(" "), inner)} |`);
lines.push(`+${"=".repeat(width - 2)}+`);
return lines.join("\n");
}

View file

@ -0,0 +1,265 @@
import { createDefaultArcadeRegistry, type ArcadeRegistry } from "./registry.js";
import { fit, renderModal, renderTaskOverlay, TextFrame } from "./renderer.js";
import type { KeyEvent, TaskEvent, TaskSnapshot, WaitingGame } from "./types.js";
export interface ArcadeSessionOptions {
game?: string | boolean;
standalone?: boolean;
task?: TaskSnapshot;
logs?: string[];
registry?: ArcadeRegistry;
input?: NodeJS.ReadStream;
output?: NodeJS.WriteStream;
simulateTask?: boolean;
}
type Modal =
| { kind: "pause"; title: string; body: string[]; actions: string[] }
| { kind: "complete"; title: string; body: string[]; actions: string[] }
| { kind: "approval"; title: string; body: string[]; actions: string[] }
| { kind: "error"; title: string; body: string[]; actions: string[] };
export function renderArcadeSnapshot(options: ArcadeSessionOptions = {}) {
const registry = options.registry ?? createDefaultArcadeRegistry();
const gameId = registry.choose(typeof options.game === "string" ? options.game : undefined);
const game = registry.create(gameId);
const task = options.task;
const frame = new TextFrame(80, 28);
game.init({ width: frame.width, height: frame.height, random: Math.random, task, emit: () => undefined });
game.render(frame);
renderTaskOverlay(frame, task, options.standalone ?? true);
return frame.toString();
}
export async function runArcadeSession(options: ArcadeSessionOptions = {}) {
const input = options.input ?? process.stdin;
const output = options.output ?? process.stdout;
const registry = options.registry ?? createDefaultArcadeRegistry();
const gameId = registry.choose(typeof options.game === "string" ? options.game : undefined);
const game = registry.create(gameId);
let task = options.task;
const logs = [...(options.logs ?? [])];
if (!input.isTTY || !output.isTTY) {
output.write(renderArcadeSnapshot({ ...options, game: gameId, task, logs }) + "\n");
if (!options.standalone) {
output.write("\nWaiting Arcade interactive mode is disabled outside an interactive terminal.\n");
}
return;
}
await game.init({ width: output.columns ?? 80, height: output.rows ?? 28, random: Math.random, task, emit: () => undefined });
let modal: Modal | undefined;
let showLogs = false;
let closed = false;
let lastFrameAt = Date.now();
let cleanupComplete = false;
const previousRawMode = input.isRaw;
const cleanup = () => {
if (cleanupComplete) {
return;
}
cleanupComplete = true;
clearInterval(loop);
for (const timer of timers) {
clearTimeout(timer);
}
input.off("data", onData);
process.off("SIGINT", onSigint);
if (input.isTTY) {
input.setRawMode(previousRawMode ?? false);
}
input.pause();
output.write("\x1b[?25h\x1b[?1049l");
};
const onSigint = () => {
closed = true;
cleanup();
};
const onData = (data: Buffer) => {
const event = parseKey(data);
if (event.ctrl && event.name === "c") {
modal = { kind: "pause", title: "STOP TASK?", body: ["Ctrl+C received.", "Press Q to quit arcade, or Esc to continue."], actions: ["[Q] Quit", "[Esc] Continue"] };
return;
}
if (showLogs) {
if (event.name === "escape" || event.name === "l" || (event.ctrl && event.name === "g")) {
showLogs = false;
}
return;
}
if (modal) {
if (event.name === "q") {
closed = true;
cleanup();
} else if (event.name === "l" || (event.ctrl && event.name === "g")) {
showLogs = true;
} else if (event.name === "escape" || event.name === "c" || event.name === "a" || event.name === "d" || event.name === "r") {
modal = undefined;
game.resume?.();
}
return;
}
const action = game.input(event);
if (action?.type === "quit") {
closed = true;
cleanup();
} else if (action?.type === "pause") {
game.pause?.();
modal = { kind: "pause", title: "ARCADE PAUSED", body: ["Task continues in the background."], actions: ["[Esc/C] Continue", "[L] Logs", "[Q] Quit"] };
} else if (action?.type === "toggle_logs") {
showLogs = true;
} else if (action?.type === "redraw") {
render();
}
};
const applyTaskEvent = (event: TaskEvent) => {
if ("task" in event) {
task = event.task;
}
if (event.type === "task:log") {
logs.push(`[${event.timestamp}] ${event.line}`);
}
if (event.type === "task:approval_required" || event.type === "task:expense_required" || event.type === "task:done" || event.type === "task:error") {
game.pause?.();
}
if (event.type === "task:approval_required") {
modal = { kind: "approval", title: "APPROVAL REQUIRED", body: [event.reason], actions: ["[A] Approve", "[D] Deny", "[L] Logs", "[P] Pause Job"] };
}
if (event.type === "task:expense_required") {
modal = { kind: "approval", title: "EXPENSE APPROVAL", body: [`Provider: ${event.provider}`, `Cost: $${event.amountUsd.toFixed(2)}`, event.reason], actions: ["[A] Approve", "[D] Deny", "[L] Logs"] };
}
if (event.type === "task:done") {
modal = { kind: "complete", title: "TASK COMPLETE", body: [event.result, `Cost: $${event.task.costUsd?.toFixed(2) ?? "0.00"}`], actions: ["[R] Review", "[L] Logs", "[C] Continue", "[Q] Quit"] };
}
if (event.type === "task:error") {
modal = { kind: "error", title: "TASK FAILED", body: [event.error], actions: ["[R] Retry", "[L] Logs", "[Q] Quit"] };
}
};
const timers = options.simulateTask ? createDemoTaskEvents(applyTaskEvent) : [];
input.setRawMode(true);
input.resume();
input.on("data", onData);
process.on("SIGINT", onSigint);
output.write("\x1b[?1049h\x1b[?25l");
const render = () => {
if (closed) {
return;
}
const width = output.columns ?? 80;
const height = output.rows ?? 28;
const frame = new TextFrame(width, height);
if (showLogs) {
renderLogs(frame, logs);
} else {
game.render(frame);
renderTaskOverlay(frame, task, options.standalone ?? true);
}
output.write(`\x1b[H\x1b[2J${frame.toString()}`);
if (modal) {
output.write("\n\n" + renderModal(modal.title, modal.body, modal.actions));
}
};
const loop = setInterval(() => {
const now = Date.now();
if (!modal && !showLogs) {
game.tick(now - lastFrameAt);
}
lastFrameAt = now;
render();
}, 66);
render();
await new Promise<void>((resolve) => {
const done = setInterval(() => {
if (closed) {
clearInterval(done);
resolve();
}
}, 50);
});
}
function renderLogs(frame: TextFrame, logs: string[]) {
frame.box(0, 0, frame.width, frame.height, "TASK LOGS");
const visible = logs.slice(-(frame.height - 4));
if (visible.length === 0) {
frame.write(2, 2, "No logs yet.");
}
for (const [index, line] of visible.entries()) {
frame.write(2 + index, 2, fit(line, frame.width - 4));
}
frame.write(frame.height - 2, 2, "[Esc/L/Ctrl+G] close logs");
}
function parseKey(data: Buffer): KeyEvent {
const sequence = data.toString("utf8");
if (sequence === "\u0003") return { name: "c", sequence, ctrl: true };
if (sequence === "\u0007") return { name: "g", sequence, ctrl: true };
if (sequence === "\u001b") return { name: "escape", sequence };
if (sequence === "\r") return { name: "return", sequence };
if (sequence === " ") return { name: "space", sequence };
if (sequence === "\u001b[A") return { name: "up", sequence };
if (sequence === "\u001b[B") return { name: "down", sequence };
if (sequence === "\u001b[C") return { name: "right", sequence };
if (sequence === "\u001b[D") return { name: "left", sequence };
return { name: sequence.toLowerCase(), sequence };
}
function createDemoTaskEvents(apply: (event: TaskEvent) => void) {
const started: TaskSnapshot = {
id: "demo_yolo",
title: "AgentSwarm YOLO session",
status: "running",
phase: "inspecting repo",
progress: 0.2,
costUsd: 0.08,
lastMessage: "cloned repo"
};
return [
setTimeout(() => apply({ type: "task:started", task: started }), 250),
setTimeout(() => apply({ type: "task:log", timestamp: new Date().toISOString(), line: "agent swarm inspected issue context" }), 900),
setTimeout(
() =>
apply({
type: "task:progress",
task: { ...started, phase: "running tests", progress: 0.58, costUsd: 0.21, lastMessage: "generated patch" }
}),
2200
),
setTimeout(
() =>
apply({
type: "task:expense_required",
task: { ...started, status: "approval_required", phase: "browser QA", progress: 0.72, costUsd: 0.21, lastMessage: "approval required" },
provider: "Browserless",
amountUsd: 0.18,
reason: "Verify checkout flow before opening the PR."
}),
5200
),
setTimeout(
() =>
apply({
type: "task:done",
task: { ...started, status: "done", phase: "complete", progress: 1, costUsd: 0.72, lastMessage: "PR ready for review" },
result: "PR #184 is ready for review. Tests passed."
}),
11000
)
];
}

View file

@ -0,0 +1,71 @@
export interface GameControl {
key: string;
action: string;
}
export interface KeyEvent {
name: string;
sequence: string;
ctrl?: boolean;
}
export type GameAction =
| { type: "quit" }
| { type: "pause" }
| { type: "toggle_logs" }
| { type: "redraw" }
| { type: "message"; message: string };
export interface TaskSnapshot {
id: string;
title: string;
status: "queued" | "running" | "paused" | "approval_required" | "done" | "error" | "cancelled";
phase?: string;
progress?: number;
costUsd?: number;
lastMessage?: string;
}
export type TaskEvent =
| { type: "task:started"; task: TaskSnapshot }
| { type: "task:progress"; task: TaskSnapshot }
| { type: "task:log"; line: string; timestamp: string }
| { type: "task:approval_required"; task: TaskSnapshot; reason: string }
| { type: "task:expense_required"; task: TaskSnapshot; provider: string; amountUsd: number; reason: string }
| { type: "task:done"; task: TaskSnapshot; result: string }
| { type: "task:error"; task: TaskSnapshot; error: string }
| { type: "task:cancelled"; task: TaskSnapshot };
export type ArcadeEvent = { type: "game:score"; score: number } | { type: "game:message"; message: string };
export interface GameContext {
width: number;
height: number;
random: () => number;
task?: TaskSnapshot;
emit: (event: ArcadeEvent) => void;
}
export interface WaitingGame {
id: string;
title: string;
description: string;
controls: GameControl[];
init(ctx: GameContext): void | Promise<void>;
tick(deltaMs: number): void;
input(event: KeyEvent): GameAction | void;
render(frame: TerminalFrame): void;
pause?(): void;
resume?(): void;
stop?(): void;
}
export interface TerminalFrame {
width: number;
height: number;
write(row: number, col: number, text: string): void;
box(row: number, col: number, width: number, height: number, title?: string): void;
toString(): string;
}

View file

@ -1,6 +1,8 @@
import { createPluginRegistry } from "@logicsrc/plugin-core";
import { coinPayPlugin } from "@logicsrc/plugin-coinpay";
import { uGigPlugin } from "@logicsrc/plugin-ugig";
export { ArcadeRegistry, createDefaultArcadeRegistry, renderArcadeList, renderArcadeSnapshot, runArcadeSession } from "./arcade/index.js";
export type { ArcadeEvent, GameAction, GameContext, GameControl, KeyEvent, TaskEvent, TaskSnapshot, TerminalFrame, WaitingGame } from "./arcade/index.js";
export interface TuiState {
did: string;