From 7e91338aaf76e4e08f923c2128b6178eda5f4600 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:28:31 -0300 Subject: [PATCH 1/8] feat(mods): Add arcade function-hooks mod above the prompt Port the cc-arcade pattern into the codedeck plugin as an arcade mod: matcher-based /arcade command, AbovePrompt picker plus 2048 Client board, turn-complete pause banner, counter-only pet feeding from tool calls, and persisted best scores. Manifest declares the hooks module, open defaults CLAUDE_CODE_ENABLE_FUNCTION_HOOKS to 1 with user opt-out preserved, and docs describe enablement plus constraints. Co-Authored-By: Claude --- .gitignore | 2 + .specs/features/cc-arcade-mod-port/spec.md | 81 ++++++++++++ docs/mods.md | 64 ++++++++++ plugin/.claude-plugin/plugin.json | 2 +- plugin/hooks/boards/common.tsx | 54 ++++++++ plugin/hooks/boards/twenty48.tsx | 95 ++++++++++++++ plugin/hooks/hooks.json | 2 + plugin/hooks/register.tsx | 122 ++++++++++++++++++ plugin/mods/arcade/games/auto.ts | 37 ++++++ plugin/mods/arcade/games/best.ts | 9 ++ plugin/mods/arcade/games/pet.ts | 119 ++++++++++++++++++ plugin/mods/arcade/games/twenty48.ts | 136 +++++++++++++++++++++ plugin/mods/arcade/index.ts | 13 ++ src/open/runtime.ts | 9 ++ tests/mods-arcade/auto.test.ts | 50 ++++++++ tests/mods-arcade/best.test.ts | 19 +++ tests/mods-arcade/pet.test.ts | 118 ++++++++++++++++++ tests/mods-arcade/twenty48.test.ts | 89 ++++++++++++++ tests/open-args.test.ts | 12 +- 19 files changed, 1031 insertions(+), 2 deletions(-) create mode 100644 .specs/features/cc-arcade-mod-port/spec.md create mode 100644 docs/mods.md create mode 100644 plugin/hooks/boards/common.tsx create mode 100644 plugin/hooks/boards/twenty48.tsx create mode 100644 plugin/hooks/register.tsx create mode 100644 plugin/mods/arcade/games/auto.ts create mode 100644 plugin/mods/arcade/games/best.ts create mode 100644 plugin/mods/arcade/games/pet.ts create mode 100644 plugin/mods/arcade/games/twenty48.ts create mode 100644 plugin/mods/arcade/index.ts create mode 100644 tests/mods-arcade/auto.test.ts create mode 100644 tests/mods-arcade/best.test.ts create mode 100644 tests/mods-arcade/pet.test.ts create mode 100644 tests/mods-arcade/twenty48.test.ts diff --git a/.gitignore b/.gitignore index 8212173..c02b045 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ dist .DS_Store .env coverage +# plugin-types generates .claude/ slash types here +.claude/ diff --git a/.specs/features/cc-arcade-mod-port/spec.md b/.specs/features/cc-arcade-mod-port/spec.md new file mode 100644 index 0000000..817346e --- /dev/null +++ b/.specs/features/cc-arcade-mod-port/spec.md @@ -0,0 +1,81 @@ +# cc-arcade Mod Port (arcade) + +## Goal + +Port the cc-arcade pattern (sezaakgun cc-arcade v0.2.0 at github.com/sezaakgun/cc-arcade) into the codedeck plugin as a Mod named `arcade`. + +Source behavior to port (from session 2bab discovery): + +- `hooks/register.tsx` registers the arcade slash command with `immediate: true` on `session.start`. +- Draws a picker or a board above the prompt on `ui.render` for `AbovePrompt` using `Box`, `Button`, `Client`, `Text` from `$.ui.resolve`. +- Mounts one `Client` surface module per board from `hooks/boards`. +- Records turn start time on `turn.start` for the auto picker heuristic. +- Pauses the open game with a banner on `turn.complete`. +- Feeds a pet from `tool.call` by matching Bash test runner patterns plus `git commit` plus `Edit`, `Write`, `NotebookEdit`, keeping counters only with no command text stored. +- Persists pet plus best scores plus colorblind flag with `$.store`. +- Costs zero tokens because command handling runs locally. + +Codedeck baseline (from session e1ed mapping): + +- Plugin version 0.2.0. +- `plugin/hooks/hooks.json` declares only `SessionStart` and `UserPromptSubmit` shell command hooks with no function hooks module. +- `codedeck open` builds the settings payload at launch with theme plus fullscreen renderer plus spinner verbs plus tips plus statusline. +- Installed claude is 2.1.270, above the 2.1.269 drawing gate. +- Root tsconfig includes only `src`, so plugin tsx never breaks `npm run build`. +- Vitest includes `tests/**/*.test.ts`. + +Port deliverables: + +1. `arcade` slash command. +2. `AbovePrompt` board. +3. Pure pet plus best plus auto logic with unit tests. +4. Manifest declaring the hooks module. +5. `open` enabling function hooks. +6. Docs. + +Port constraints carried over from upstream: + +- Never declare a local variable named `h` in board files. +- `Client` module paths must be string literals. +- The band is about half the terminal height. +- Redraw is about ten times per second with doom at twenty. +- The board needs a click for keyboard focus and Esc always returns to the prompt. +- Terminal only with nothing drawn in `claude -p` headless, desktop, or mobile. +- A failed hot reload needs a session restart. + +Upstream validation reference: `bun test` for pure logic, `oxlint`, `claude plugin validate`, and `/plugin-types` generating git ignored `.claude/types`. + +## Acceptance criteria + +- Arcade picker renders above the prompt in terminal sessions with function hooks on. +- One demo board is playable with keyboard after a click and Esc returns to the prompt. +- `turn.complete` pauses with a visible banner. +- `tool.call` feeds pet counters only. +- Best scores persist across sessions. +- `claude plugin validate` passes on the manifest and on the repo root. +- The scoped vitest run on the new tests passes. +- Existing suite expectations stay green. + +## Out of scope + +- Full nine game catalog parity including doom. +- Desktop, mobile, and headless support. +- Cross machine sync. +- Any dependency install, fetch, or vendor. +- Any push or destructive git action. + +## Verification + +Scoped runs only. Do not run the full suite. Do not install or fetch anything. + +- `npm run build` +- `npx vitest run tests/mods-arcade` +- `claude plugin validate .claude-plugin/plugin.json` +- `claude plugin validate .` + +Each command is run scoped as listed. Quote the output when reporting results. + +## Stop conditions + +- If `/plugin-types` cannot run noninteractively, specify engine types as stubbed and continue. +- Never invent engine APIs. diff --git a/docs/mods.md b/docs/mods.md new file mode 100644 index 0000000..34fac48 --- /dev/null +++ b/docs/mods.md @@ -0,0 +1,64 @@ +# CodeDeck mods (arcade) + +Function hooks backing the arcade mod. Boards and game logic live in the MOD +slice; this file covers integration only: how the hooks get enabled, typed, +and validated. + +## Enablement + +`codedeck open` launches claude sessions with `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1` +in the spawn environment (`sanitizeEnv` in `src/open/runtime.ts`, set with +`??=` so an explicit value of the user's own wins and an opt-out keeps +working). + +The variable is namespaced to Claude Code: the opencode and codex spawns that +share the helper carry it inertly and behave exactly as before. The +`--no-bypass`, `--no-theme` and `--no-pty` escape hatches ride in argv, not +env, so they are untouched. + +## Declaration + +`plugin/hooks/hooks.json` carries a top-level `modules` array with one entry +pointing at the register file, plus a description naming the arcade mod and +its function hooks requirement. The existing `hooks` key (`SessionStart` for +`session-id.sh`, `UserPromptSubmit` for `session-name.sh`) is unchanged. + +The new capability bumps the plugin to 0.3.0 in +`plugin/.claude-plugin/plugin.json`. + +## plugin-types flow + +Run the plugin-types flow from the repo root. It writes the generated types +under `.claude/`, which stays gitignored, so generated output never lands in a +diff or a review. + +## Validation + +Validate the manifest path after any manifest edit: + +```sh +claude plugin validate plugin/.claude-plugin/plugin.json +``` + +Validate the whole plugin from the repo root: + +```sh +claude plugin validate . +``` + +Root-level validation resolves the `modules` entry, so it needs +`plugin/hooks/register.tsx` from the MOD slice and stays pending until that +slice lands. + +## Board constraints + +Board files in the MOD slice hold to these limits: + +- Never a local variable named `h`. +- `Client` module paths are string literals. +- Board height stays around half the terminal height band. +- Redraw runs around ten frames per second. +- Click gives keyboard focus; Esc returns to the prompt. +- Terminal only: nothing renders in headless desktop or mobile. +- Version gate: Claude Code 2.1.269 or later (verified against installed + 2.1.270). diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 1c2f83b..3d8ff87 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codedeck", - "version": "0.2.0", + "version": "0.3.0", "description": "A focused Claude Code workspace for running and reviewing CodeDeck sessions.", "author": { "name": "CodeDeck" diff --git a/plugin/hooks/boards/common.tsx b/plugin/hooks/boards/common.tsx new file mode 100644 index 0000000..3e924f9 --- /dev/null +++ b/plugin/hooks/boards/common.tsx @@ -0,0 +1,54 @@ +/** @jsx h */ +import type { ClientElements, ClientSurface } from "claude-code"; + +import { banner, type SeenDone } from "../../mods/arcade/games/twenty48.js"; + +export interface ArcadeBoardProps { + done: number; + colorblind: boolean; + best: number; +} + +export interface CommonState { + paused: boolean; + seenDone: number; +} + +export function initialCommonState(): CommonState { + return { paused: false, seenDone: 0 }; +} + +export function reportScore(surface: ClientSurface, game: string, score: number, done: number): void { + surface.post({ kind: "arcade-score", game, score, done }); +} + +export default function CommonBoard(props: ArcadeBoardProps, surface: ClientSurface): ClientElements { + const current = (surface.state ?? null) as CommonState | null; + const state: CommonState = current ?? initialCommonState(); + if (current === null) surface.setState(state); + const seen: SeenDone = { current: state.seenDone }; + // banner() owns the transition: it pauses exactly once per new done value. + banner(props.done, seen, () => { + surface.setState({ ...state, paused: true, seenDone: seen.current }); + }); + // The notice is driven by paused state, not by banner()'s transient return, + // so it stays visible until the user resumes. + const paused = state.paused || seen.current !== state.seenDone; + surface.onKey((key) => { + void key; + if (paused) surface.setState({ ...state, paused: false, seenDone: seen.current }); + }); + surface.onPointer((point) => { + void point; + if (paused) surface.setState({ ...state, paused: false, seenDone: seen.current }); + }); + const palette = props.colorblind ? "high-contrast" : "standard"; + const line = paused + ? `Round ${seen.current} finished - board paused. Press any key to resume.` + : `Arcade ready (${palette}) - best ${props.best}`; + return ( + + {line} + + ) as unknown as ClientElements; +} diff --git a/plugin/hooks/boards/twenty48.tsx b/plugin/hooks/boards/twenty48.tsx new file mode 100644 index 0000000..631272b --- /dev/null +++ b/plugin/hooks/boards/twenty48.tsx @@ -0,0 +1,95 @@ +/** @jsx h */ +import type { ClientElements, ClientSurface } from "claude-code"; + +import { + banner, + directionForKey, + initialTwenty48State, + isFinished, + moveGrid, + spawnTile, + type SeenDone, + type Twenty48State, +} from "../../mods/arcade/games/twenty48.js"; + +export interface Twenty48Props { + done: number; + colorblind: boolean; + best: number; +} + +export const BOARD_PATH = "hooks/boards/twenty48.tsx"; + +export default function Twenty48Board(props: Twenty48Props, surface: ClientSurface): ClientElements { + const current = (surface.state ?? null) as Twenty48State | null; + const state: Twenty48State = current ?? initialTwenty48State(); + if (current === null) surface.setState(state); + const seen: SeenDone = { current: state.seenDone }; + // banner() owns the transition: it pauses exactly once per new done value. + banner(props.done, seen, () => { + surface.setState({ ...state, paused: true, seenDone: seen.current }); + }); + // The notice is driven by paused state, not by banner()'s transient return, + // so it stays visible until the user resumes and no keypress is eaten blind. + const paused = state.paused || seen.current !== state.seenDone; + + const finish = (final: Twenty48State) => { + if (!final.posted) { + surface.post({ kind: "arcade-score", game: "twenty48", score: final.score, done: props.done }); + surface.setState({ ...final, posted: true }); + } else { + surface.setState(final); + } + }; + + surface.onKey((key) => { + if (paused) { + surface.setState({ ...state, paused: false, seenDone: seen.current }); + return; + } + if (state.over) return; + const direction = directionForKey(key.name); + if (direction === null) return; + const stepped = moveGrid(state.grid, direction); + if (!stepped.moved) return; + const grid = spawnTile(stepped.grid, () => Math.floor(Math.random() * 16)); + const score = state.score + stepped.gained; + const won = state.won || grid.some((cell) => cell >= 2048); + const over = isFinished(grid); + const final: Twenty48State = { ...state, grid, score, won, over }; + if (over || won) finish(final); + else surface.setState(final); + }); + + surface.onPointer((point) => { + void point; + if (paused) { + surface.setState({ ...state, paused: false, seenDone: seen.current }); + return; + } + if (state.over && !state.posted) { + surface.post({ kind: "arcade-score", game: "twenty48", score: state.score, done: props.done }); + surface.setState({ ...state, posted: true }); + } + }); + + const palette = props.colorblind ? "high-contrast" : "standard"; + const rows: string[] = []; + for (let rowIdx = 0; rowIdx < 4; rowIdx += 1) { + const cells: string[] = []; + for (let col = 0; col < 4; col += 1) { + const cell = state.grid[rowIdx * 4 + col]; + cells.push(cell === 0 ? " ." : String(cell).padStart(5, " ")); + } + rows.push(cells.join(" ")); + } + const status = state.over ? "game over" : state.won ? "you win" : `${palette} - best ${props.best}`; + const line = paused + ? `Round ${seen.current} finished - board paused. Press any key to resume.` + : `2048 score ${state.score} (${status})\n${rows.join("\n")}`; + return ( + + {line} + + ) as unknown as ClientElements; +} diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json index 8b13bda..276e5e9 100644 --- a/plugin/hooks/hooks.json +++ b/plugin/hooks/hooks.json @@ -1,4 +1,6 @@ { + "description": "Codedeck arcade mod: registers function hooks (requires Claude Code with function hooks enabled).", + "modules": ["./register.tsx"], "hooks": { "SessionStart": [ { diff --git a/plugin/hooks/register.tsx b/plugin/hooks/register.tsx new file mode 100644 index 0000000..3f88741 --- /dev/null +++ b/plugin/hooks/register.tsx @@ -0,0 +1,122 @@ +/** @jsx h */ +import type { Register } from "claude-code"; + +import { feed, newPet, petEvent, type Pet } from "../mods/arcade/games/pet.js"; +import { isBetter } from "../mods/arcade/games/best.js"; +import { ARCADE_BOARDS } from "../mods/arcade/index.js"; + +const PET_KEY = "pet"; +const COLORBLIND_KEY = "colorblind"; +const bestKey = (game: string): string => `best:${game}`; + +export const register: Register = (on) => { + let pet: Pet = newPet(); + let best: Record = {}; + let colorblind = false; + let turnClock = 0; + let lastTurnMs = 0; + let openBoard: string | null = null; + let done = 0; + + on("session.start", async ($, e, next) => { + try { + const savedPet = await $.store.get(PET_KEY); + if (savedPet) pet = savedPet as Pet; + for (const board of ARCADE_BOARDS) { + const saved = await $.store.get(bestKey(board)); + if (typeof saved === "number") best[board] = saved; + } + const savedColorblind = await $.store.get(COLORBLIND_KEY); + if (typeof savedColorblind === "boolean") colorblind = savedColorblind; + } catch (err) { + $.ui.log(`arcade restore failed: ${String(err)}`); + } + await $.command.register({ name: "arcade", immediate: true }); + return next(e); + }); + + on("command.run", { command: "arcade" }, async ($, e) => { + const arg = e.args.trim().toLowerCase(); + if (arg === "") { + openBoard = null; + await $.ui.invalidate("ui.render"); + return { text: "arcade boards: twenty48" }; + } + if (arg === "list") { + return { text: "arcade boards: twenty48" }; + } + if (arg === "open" || arg.startsWith("open ")) { + const name = arg === "open" ? "twenty48" : arg.slice("open ".length).trim() || "twenty48"; + if (!(ARCADE_BOARDS as readonly string[]).includes(name)) { + return { text: `no game called "${name}"` }; + } + openBoard = name; + await $.ui.invalidate("ui.render"); + return { text: `opened ${name}` }; + } + if (arg === "stop") { + openBoard = null; + await $.ui.invalidate("ui.render"); + return { text: "arcade closed" }; + } + return { text: `no game called "${arg}"` }; + }); + + on("turn.start", async ($, e, next) => { + turnClock = Date.now(); + return next(e); + }); + + on("turn.complete", async ($, e, next) => { + lastTurnMs = Date.now() - turnClock; + void lastTurnMs; + done += 1; + await $.ui.invalidate("ui.render"); + return next(e); + }); + + on("tool.call", async ($, e, next) => { + const r = await next(e); + // petEvent already ignores denied calls; only classified events feed the pet. + const command = (e as { command?: string }).command ?? undefined; + const denied = "deny" in (r as Record); + const ok = denied ? undefined : !(r as { isError?: boolean }).isError; + const event = petEvent({ tool: e.tool, command, ok, denied }); + if (event !== undefined) { + pet = feed(pet, event); + await $.store.set(PET_KEY, { xp: pet.xp, mood: pet.mood, tests: pet.tests, commits: pet.commits, edits: pet.edits }); + } + return r; + }); + + on("ui.message", async ($, e, next) => { + const posted = e.data as { game?: string; score?: number } | undefined; + if (!posted || typeof posted.game !== "string" || typeof posted.score !== "number") { + return next(e); + } + if (isBetter(posted.game, posted.score, best)) { + best[posted.game] = posted.score; + await $.store.set(bestKey(posted.game), posted.score); + $.ui.toast(`New arcade record in ${posted.game}: ${posted.score}`); + } + return { game: posted.game, score: posted.score }; + }); + + on("ui.render", async ($, e, next) => { + if (e.surface !== "terminal") return await next(e); + const { Box, Button, Client, Text } = await $.ui.resolve(e, "Box", "Button", "Client", "Text"); + if (openBoard === null) { + return [ + + Arcade boards + + , + await next(e), + ]; + } + return [ + , + await next(e), + ]; + }); +}; diff --git a/plugin/mods/arcade/games/auto.ts b/plugin/mods/arcade/games/auto.ts new file mode 100644 index 0000000..c302aaa --- /dev/null +++ b/plugin/mods/arcade/games/auto.ts @@ -0,0 +1,37 @@ +export interface AutoInput { + lastTurnMs?: number; + idle?: boolean; +} + +// One minute (60000 ms) separates quick turns from deep turns. +export const TURN_THRESHOLD_MS = 60_000; + +export const DEEP_GAMES = ["twenty48"]; +export const QUICK_GAMES = ["twenty48"]; +export const DROPIN_GAMES = ["twenty48"]; + +export type AutoPool = "deep" | "quick" | "dropin"; + +// Pure pool picker: turns over one minute suggest deep games, +// turns under one minute suggest quick games, idle suggests drop-in games. +export function pickPool(input: AutoInput = {}): AutoPool { + if (input.idle) return "dropin"; + if (typeof input.lastTurnMs === "number" && input.lastTurnMs > TURN_THRESHOLD_MS) { + return "deep"; + } + return "quick"; +} + +// Turn duration heuristic: maps the picked pool to a board. +export function pickAuto(input: AutoInput = {}): string { + const pool = pickPool(input); + if (pool === "deep") return DEEP_GAMES[0]; + if (pool === "dropin") return DROPIN_GAMES[0]; + return QUICK_GAMES[0]; +} + +export function pickRandom(games: string[] = DROPIN_GAMES): string { + if (games.length === 0) return DROPIN_GAMES[0]; + const index = Math.floor(Math.random() * games.length); + return games[index]; +} diff --git a/plugin/mods/arcade/games/best.ts b/plugin/mods/arcade/games/best.ts new file mode 100644 index 0000000..c3c7174 --- /dev/null +++ b/plugin/mods/arcade/games/best.ts @@ -0,0 +1,9 @@ +export type BestTable = Record; + +// Higher score wins for every arcade game. Returns true when there is no +// stored best yet for that game, or when the new score beats it. +export function isBetter(game: string, score: number, best: BestTable): boolean { + const prev = best[game]; + if (prev === undefined) return true; + return score > prev; +} diff --git a/plugin/mods/arcade/games/pet.ts b/plugin/mods/arcade/games/pet.ts new file mode 100644 index 0000000..df7833f --- /dev/null +++ b/plugin/mods/arcade/games/pet.ts @@ -0,0 +1,119 @@ +export type PetEventKind = "test-pass" | "test-fail" | "commit" | "edit"; + +export interface PetToolCall { + tool: string; + command?: string; + text?: string; + ok?: boolean; + denied?: boolean; + exitCode?: number; +} + +export interface PetEvent { + kind: PetEventKind; + ok: boolean | undefined; + xp: number; + mood: number; +} + +export interface Pet { + xp: number; + mood: number; + tests: number; + commits: number; + edits: number; +} + +export const XP: Record = { + "test-pass": 10, + "test-fail": 2, + commit: 15, + edit: 1, +}; + +export const MOOD: Record = { + "test-pass": 8, + "test-fail": -12, + commit: 10, + edit: 1, +}; + +// Matches common test invocations: +// bun / npm / pnpm / yarn test, npx jest vitest mocha, +// pytest jest vitest rspec phpunit mocha at a command start or after a +// shell separator, go test, cargo test, make test, +// mvn gradle gradlew sbt test tasks. +// Bare tool names are anchored so words inside other commands +// (e.g. "cat vitest.config.ts") do not count as test runs. +export const TEST_COMMAND = + /(?:\b(?:bun|npm|pnpm|yarn)\s+(?:run\s+)?test\b|\bnpx\s+(?:jest|vitest|mocha)\b|(?:^|[;&|]\s*)\b(?:pytest|jest|vitest|rspec|phpunit|mocha)\b(?=\s|$)|\bgo\s+test\b|\bcargo\s+test\b|\bmake\s+test\b|\b(?:mvn|gradle|gradlew|sbt)\b[^\n]*\btest\b)/; + +const GIT_COMMIT = /\bgit\s+commit\b/; +const DRY_RUN = /--dry-run/; + +const EDIT_TOOLS = new Set(["Edit", "Write", "NotebookEdit"]); + +function commandText(call: PetToolCall): string { + return call.command ?? call.text ?? ""; +} + +function kindForTest(call: PetToolCall): PetEventKind { + if (call.ok === false) return "test-fail"; + if (typeof call.exitCode === "number" && call.exitCode !== 0) return "test-fail"; + return "test-pass"; +} + +export function petEvent(call: PetToolCall): PetEvent | undefined { + // Denied calls carry undefined ok and are ignored. + if (call.denied) return undefined; + + if (call.tool === "Bash") { + const text = commandText(call); + // Commits win over test words: "git commit -m 'fix jest config'" + // is a commit, not a test run. + if (GIT_COMMIT.test(text) && !DRY_RUN.test(text)) { + return { kind: "commit", ok: call.ok, xp: XP.commit, mood: MOOD.commit }; + } + if (TEST_COMMAND.test(text)) { + const kind = kindForTest(call); + return { kind, ok: call.ok, xp: XP[kind], mood: MOOD[kind] }; + } + return undefined; + } + + if (EDIT_TOOLS.has(call.tool)) { + return { kind: "edit", ok: call.ok, xp: XP.edit, mood: MOOD.edit }; + } + + return undefined; +} + +export function newPet(): Pet { + return { xp: 0, mood: 0, tests: 0, commits: 0, edits: 0 }; +} + +export function feed(pet: Pet, event: PetEvent): Pet { + const next: Pet = { + xp: pet.xp + event.xp, + mood: pet.mood + event.mood, + tests: pet.tests, + commits: pet.commits, + edits: pet.edits, + }; + if (event.kind === "test-pass" || event.kind === "test-fail") next.tests += 1; + if (event.kind === "commit") next.commits += 1; + if (event.kind === "edit") next.edits += 1; + return next; +} + +export function level(pet: Pet): number { + return 1 + Math.floor(Math.max(0, pet.xp) / 100); +} + +export function stage(input: Pet | number): string { + const lvl = typeof input === "number" ? input : level(input); + if (lvl <= 1) return "egg"; + if (lvl === 2) return "baby"; + if (lvl === 3) return "teen"; + return "adult"; +} diff --git a/plugin/mods/arcade/games/twenty48.ts b/plugin/mods/arcade/games/twenty48.ts new file mode 100644 index 0000000..58ada5c --- /dev/null +++ b/plugin/mods/arcade/games/twenty48.ts @@ -0,0 +1,136 @@ +// Pure 2048 logic for the arcade demo board. No engine imports so plain +// vitest runs it. The board components under plugin/hooks/boards/ own the +// surface wiring (state, keys, pointer, score posts) and import from here. + +export interface SeenDone { + current: number; +} + +// Banner helper: pauses once per new done value. The caller keeps `seen` +// across renders; when `done` changes we record it and pause a single time. +export function banner(done: number, seen: SeenDone, pause: () => void): string | null { + if (done === seen.current) return null; + seen.current = done; + pause(); + return `Round ${done} finished - board paused. Press any key to resume.`; +} + +export type Direction = "up" | "down" | "left" | "right"; + +export interface Twenty48State { + grid: number[]; + score: number; + over: boolean; + won: boolean; + posted: boolean; + paused: boolean; + seenDone: number; +} + +export function emptyGrid(): number[] { + return [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; +} + +export function spawnTile(grid: number[], pick: () => number): number[] { + const open: number[] = []; + for (let idx = 0; idx < 16; idx += 1) { + if (grid[idx] === 0) open.push(idx); + } + if (open.length === 0) return grid; + const slot = open[pick() % open.length]; + const next = grid.slice(); + next[slot] = pick() % 10 === 0 ? 4 : 2; + return next; +} + +export function slideRow(row: number[]): { row: number[]; gained: number } { + const tiles = row.filter((cell) => cell !== 0); + const out: number[] = []; + let gained = 0; + let idx = 0; + while (idx < tiles.length) { + if (idx + 1 < tiles.length && tiles[idx] === tiles[idx + 1]) { + const merged = tiles[idx] * 2; + out.push(merged); + gained += merged; + idx += 2; + } else { + out.push(tiles[idx]); + idx += 1; + } + } + while (out.length < 4) out.push(0); + return { row: out, gained }; +} + +export function moveGrid(grid: number[], direction: Direction): { grid: number[]; gained: number; moved: boolean } { + const next = grid.slice(); + let gained = 0; + let moved = false; + const setRow = (rowIdx: number, row: number[]) => { + for (let col = 0; col < 4; col += 1) { + const at = rowIdx * 4 + col; + if (next[at] !== row[col]) moved = true; + next[at] = row[col]; + } + }; + const getRow = (rowIdx: number): number[] => { + const row: number[] = []; + for (let col = 0; col < 4; col += 1) row.push(grid[rowIdx * 4 + col]); + return row; + }; + const getCol = (colIdx: number): number[] => { + const col: number[] = []; + for (let rowIdx = 0; rowIdx < 4; rowIdx += 1) col.push(grid[rowIdx * 4 + colIdx]); + return col; + }; + const setCol = (colIdx: number, col: number[]) => { + for (let rowIdx = 0; rowIdx < 4; rowIdx += 1) { + const at = rowIdx * 4 + colIdx; + if (next[at] !== col[rowIdx]) moved = true; + next[at] = col[rowIdx]; + } + }; + if (direction === "left" || direction === "right") { + for (let rowIdx = 0; rowIdx < 4; rowIdx += 1) { + let row = getRow(rowIdx); + if (direction === "right") row = row.reverse(); + const slid = slideRow(row); + gained += slid.gained; + const placed = direction === "right" ? slid.row.reverse() : slid.row; + setRow(rowIdx, placed); + } + } else { + for (let colIdx = 0; colIdx < 4; colIdx += 1) { + let col = getCol(colIdx); + if (direction === "down") col = col.reverse(); + const slid = slideRow(col); + gained += slid.gained; + const placed = direction === "down" ? slid.row.reverse() : slid.row; + setCol(colIdx, placed); + } + } + return { grid: next, gained, moved }; +} + +export function isFinished(grid: number[]): boolean { + if (grid.some((cell) => cell === 0)) return false; + const probe: Direction[] = ["up", "down", "left", "right"]; + for (const direction of probe) { + if (moveGrid(grid, direction).moved) return false; + } + return true; +} + +export function initialTwenty48State(pick: () => number = () => Math.floor(Math.random() * 16)): Twenty48State { + const seeded = spawnTile(spawnTile(emptyGrid(), pick), pick); + return { grid: seeded, score: 0, over: false, won: false, posted: false, paused: false, seenDone: 0 }; +} + +export function directionForKey(name: string): Direction | null { + if (name === "up" || name === "w") return "up"; + if (name === "down" || name === "s") return "down"; + if (name === "left" || name === "a") return "left"; + if (name === "right" || name === "d") return "right"; + return null; +} diff --git a/plugin/mods/arcade/index.ts b/plugin/mods/arcade/index.ts new file mode 100644 index 0000000..1ccb34e --- /dev/null +++ b/plugin/mods/arcade/index.ts @@ -0,0 +1,13 @@ +// Arcade mod: a single demo board (twenty48) plus a companion pet that +// feeds on tool activity. The manifest declaration for this mod lands in a +// parallel slice; this file only names the mod and its boards. + +export const ARCADE_MOD_NAME = "arcade"; + +export const ARCADE_BOARDS = ["twenty48"] as const; + +export type ArcadeBoardName = (typeof ARCADE_BOARDS)[number]; + +export const ARCADE_BOARD_MODULES: Record = { + twenty48: "hooks/boards/twenty48.tsx", +}; diff --git a/src/open/runtime.ts b/src/open/runtime.ts index 5902e68..708f266 100644 --- a/src/open/runtime.ts +++ b/src/open/runtime.ts @@ -179,6 +179,15 @@ export function sanitizeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { // the boot screen. Only the line matters. A setting of the user's own is left // alone, since silencing mise everywhere is not this command's call. sanitized.MISE_QUIET ??= "1"; + + // Function hooks back the codedeck arcade mod, and Claude Code only loads + // hook modules when the spawning environment enables them. The variable is + // namespaced to Claude Code, so the opencode and codex spawns that share + // this helper carry it inertly and behave exactly as before. A value of the + // user's own wins, which keeps an explicit opt-out working. The --no-bypass, + // --no-theme and --no-pty escape hatches ride in argv, not env, so they are + // untouched. + sanitized.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS ??= "1"; return sanitized; } diff --git a/tests/mods-arcade/auto.test.ts b/tests/mods-arcade/auto.test.ts new file mode 100644 index 0000000..9f10e24 --- /dev/null +++ b/tests/mods-arcade/auto.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { DROPIN_GAMES, pickAuto, pickPool, pickRandom } from "../../plugin/mods/arcade/games/auto"; + +describe("pickAuto", () => { + it("suggests deep games for turns over one minute", () => { + expect(pickAuto({ lastTurnMs: 61_000 })).toBe("twenty48"); + expect(pickAuto({ lastTurnMs: 5 * 60_000 })).toBe("twenty48"); + }); + + it("suggests quick games for turns under one minute", () => { + expect(pickAuto({ lastTurnMs: 5_000 })).toBe("twenty48"); + expect(pickAuto({})).toBe("twenty48"); + }); + + it("suggests drop-in games when idle", () => { + expect(DROPIN_GAMES).toContain("twenty48"); + expect(pickAuto({ idle: true })).toBe("twenty48"); + expect(pickAuto({ idle: true, lastTurnMs: 10 * 60_000 })).toBe("twenty48"); + }); +}); + +describe("pickPool", () => { + it("flips from quick to deep across the 60000 ms threshold", () => { + expect(pickPool({ lastTurnMs: 59_999 })).toBe("quick"); + expect(pickPool({ lastTurnMs: 60_000 })).toBe("quick"); + expect(pickPool({ lastTurnMs: 60_001 })).toBe("deep"); + expect(pickPool({ lastTurnMs: 61_000 })).toBe("deep"); + }); + + it("maps idle to dropin regardless of turn length", () => { + expect(pickPool({ idle: true })).toBe("dropin"); + expect(pickPool({ idle: true, lastTurnMs: 10 * 60_000 })).toBe("dropin"); + }); + + it("defaults to quick without input", () => { + expect(pickPool({})).toBe("quick"); + expect(pickPool({ lastTurnMs: 5_000 })).toBe("quick"); + }); +}); + +describe("pickRandom", () => { + it("returns a member of the given list", () => { + expect(["a", "b"]).toContain(pickRandom(["a", "b"])); + }); + + it("falls back to the drop-in list by default", () => { + expect(DROPIN_GAMES).toContain(pickRandom()); + }); +}); diff --git a/tests/mods-arcade/best.test.ts b/tests/mods-arcade/best.test.ts new file mode 100644 index 0000000..0bc33f6 --- /dev/null +++ b/tests/mods-arcade/best.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { isBetter } from "../../plugin/mods/arcade/games/best"; + +describe("isBetter", () => { + it("accepts the first score for a game", () => { + expect(isBetter("twenty48", 128, {})).toBe(true); + }); + + it("compares the new score against the stored best per game", () => { + expect(isBetter("twenty48", 256, { twenty48: 128 })).toBe(true); + expect(isBetter("twenty48", 64, { twenty48: 128 })).toBe(false); + expect(isBetter("twenty48", 128, { twenty48: 128 })).toBe(false); + }); + + it("keeps bests isolated per game", () => { + expect(isBetter("other", 10, { twenty48: 9999 })).toBe(true); + }); +}); diff --git a/tests/mods-arcade/pet.test.ts b/tests/mods-arcade/pet.test.ts new file mode 100644 index 0000000..fd09d57 --- /dev/null +++ b/tests/mods-arcade/pet.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; + +import { feed, level, MOOD, newPet, petEvent, stage, TEST_COMMAND, XP } from "../../plugin/mods/arcade/games/pet"; + +describe("XP table", () => { + it("rewards test passes, commits and edits", () => { + expect(XP["test-pass"]).toBe(10); + expect(XP["test-fail"]).toBe(2); + expect(XP.commit).toBe(15); + expect(XP.edit).toBe(1); + }); +}); + +describe("MOOD table", () => { + it("tracks mood per event kind", () => { + expect(MOOD["test-pass"]).toBe(8); + expect(MOOD["test-fail"]).toBe(-12); + expect(MOOD.commit).toBe(10); + expect(MOOD.edit).toBe(1); + }); +}); + +describe("TEST_COMMAND", () => { + it.each([ + "bun test", + "npm test", + "npm run test", + "pnpm test", + "yarn test", + "npx jest", + "npx vitest", + "npx mocha", + "pytest", + "pytest tests/test_pet.py", + "jest", + "vitest run", + "rspec", + "phpunit", + "mocha", + "go test ./...", + "cargo test", + "make test", + "mvn test", + "gradle test", + "./gradlew test", + "sbt test", + ])("matches %s", (cmd) => { + expect(TEST_COMMAND.test(cmd)).toBe(true); + }); + + it.each(["git commit -m hi", "go build ./...", "npm run lint"])("does not match %s", (cmd) => { + expect(TEST_COMMAND.test(cmd)).toBe(false); + }); + + it.each([ + "cat vitest.config.ts", + "rm -rf node_modules/.vitest", + "git add jest.config.js", + "grep -rn mocha docs/", + "ls tests/pytest.ini", + ])("does not mistake %s for a test run", (cmd) => { + expect(TEST_COMMAND.test(cmd)).toBe(false); + }); +}); + +describe("petEvent", () => { + it("classifies Bash test runs by command text", () => { + expect(petEvent({ tool: "Bash", command: "npm test", ok: true })?.kind).toBe("test-pass"); + expect(petEvent({ tool: "Bash", command: "go test ./...", ok: false })?.kind).toBe("test-fail"); + }); + + it("classifies git commit without the dry-run flag into commits", () => { + expect(petEvent({ tool: "Bash", command: "git commit -m hi", ok: true })?.kind).toBe("commit"); + expect(petEvent({ tool: "Bash", command: "git commit --dry-run", ok: true })).toBeUndefined(); + }); + + it("prefers commit over test words in the message", () => { + expect(petEvent({ tool: "Bash", command: "git commit -m 'fix jest config'", ok: true })?.kind).toBe( + "commit", + ); + }); + + it.each(["Edit", "Write", "NotebookEdit"])("classifies %s tool names into edits", (tool) => { + expect(petEvent({ tool })?.kind).toBe("edit"); + }); + + it("ignores denied calls with undefined ok", () => { + expect(petEvent({ tool: "Bash", command: "npm test", denied: true })).toBeUndefined(); + const denied = petEvent({ tool: "Bash", command: "npm test", denied: true, ok: undefined }); + expect(denied?.ok).toBeUndefined(); + }); + + it("ignores unrelated tools", () => { + expect(petEvent({ tool: "Read" })).toBeUndefined(); + expect(petEvent({ tool: "Bash", command: "echo hi" })).toBeUndefined(); + }); +}); + +describe("feed / level / stage", () => { + it("starts from zero with newPet", () => { + expect(newPet()).toEqual({ xp: 0, mood: 0, tests: 0, commits: 0, edits: 0 }); + }); + + it("accumulates xp, mood and counters", () => { + const start = newPet(); + const after = feed(start, { kind: "test-pass", ok: true, xp: XP["test-pass"], mood: MOOD["test-pass"] }); + expect(after.xp).toBe(10); + expect(after.mood).toBe(8); + expect(after.tests).toBe(1); + }); + + it("derives level from xp and stage from level", () => { + expect(level(newPet())).toBe(1); + expect(level({ xp: 250, mood: 0, tests: 0, commits: 0, edits: 0 })).toBe(3); + expect(stage(1)).toBe("egg"); + expect(stage(newPet())).toBe("egg"); + }); +}); diff --git a/tests/mods-arcade/twenty48.test.ts b/tests/mods-arcade/twenty48.test.ts new file mode 100644 index 0000000..5269435 --- /dev/null +++ b/tests/mods-arcade/twenty48.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { + banner, + directionForKey, + initialTwenty48State, + isFinished, + moveGrid, + slideRow, +} from "../../plugin/mods/arcade/games/twenty48"; + +describe("slideRow", () => { + it("merges pairs once and scores the merge", () => { + expect(slideRow([2, 2, 4, 4])).toEqual({ row: [4, 8, 0, 0], gained: 12 }); + }); + + it("slides without merging distinct tiles", () => { + expect(slideRow([0, 2, 0, 4])).toEqual({ row: [2, 4, 0, 0], gained: 0 }); + }); +}); + +describe("moveGrid", () => { + it("reports moved false when nothing changes", () => { + const grid = [2, 4, 2, 4, 4, 2, 4, 2, 2, 4, 2, 4, 4, 2, 4, 2]; + expect(moveGrid(grid, "left").moved).toBe(false); + }); + + it("moves tiles left with merges", () => { + const grid = [2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + const stepped = moveGrid(grid, "left"); + expect(stepped.moved).toBe(true); + expect(stepped.grid.slice(0, 4)).toEqual([4, 0, 0, 0]); + expect(stepped.gained).toBe(4); + }); +}); + +describe("directionForKey", () => { + it.each([ + ["up", "up"], + ["w", "up"], + ["down", "down"], + ["s", "down"], + ["left", "left"], + ["a", "left"], + ["right", "right"], + ["d", "right"], + ])("maps %s", (name, expected) => { + expect(directionForKey(name)).toBe(expected); + }); + + it("rejects other keys", () => { + expect(directionForKey("q")).toBeNull(); + }); +}); + +describe("isFinished", () => { + it("is not finished with open cells", () => { + expect(isFinished(initialTwenty48State(() => 0).grid)).toBe(false); + }); + + it("is finished on a full grid with no merges", () => { + expect(isFinished([2, 4, 2, 4, 4, 2, 4, 2, 2, 4, 2, 4, 4, 2, 4, 2])).toBe(true); + }); + + it("is not finished when a merge is still available", () => { + expect(isFinished([2, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768])).toBe( + false, + ); + }); +}); + +describe("banner", () => { + it("pauses once per new done value", () => { + const seen = { current: 0 }; + let pauses = 0; + expect(banner(0, seen, () => (pauses += 1))).toBeNull(); + expect(banner(1, seen, () => (pauses += 1))).toContain("1"); + expect(banner(1, seen, () => (pauses += 1))).toBeNull(); + expect(pauses).toBe(1); + }); +}); + +describe("initialTwenty48State", () => { + it("seeds two tiles on an empty board", () => { + const state = initialTwenty48State(() => 0); + expect(state.grid.filter((cell) => cell !== 0)).toHaveLength(2); + expect(state).toMatchObject({ score: 0, over: false, won: false, posted: false, paused: false }); + }); +}); diff --git a/tests/open-args.test.ts b/tests/open-args.test.ts index b92390a..1cf2629 100644 --- a/tests/open-args.test.ts +++ b/tests/open-args.test.ts @@ -387,7 +387,7 @@ describe("open command pure helpers", () => { const env = { PATH: "/bin", CLAUDE_CODE_CHILD_SESSION: "1" }; const sanitized = sanitizeEnv(env); - expect(sanitized).toEqual({ PATH: "/bin", MISE_QUIET: "1" }); + expect(sanitized).toEqual({ PATH: "/bin", MISE_QUIET: "1", CLAUDE_CODE_ENABLE_FUNCTION_HOOKS: "1" }); expect(env.CLAUDE_CODE_CHILD_SESSION).toBe("1"); // The caller's own object is never touched, whether a key is dropped or // added: it is process.env, and this runs before the launch. @@ -836,6 +836,16 @@ describe("open command pure helpers", () => { expect(sanitizeEnv({ MISE_QUIET: "0" }).MISE_QUIET).toBe("0"); }); + // Function hooks back the codedeck arcade mod, and Claude Code only loads + // hook modules when the spawning environment enables them. A value of the + // user's own wins, which keeps an explicit opt-out working. + it("enables function hooks without overriding a setting of the user's own", () => { + expect(sanitizeEnv({}).CLAUDE_CODE_ENABLE_FUNCTION_HOOKS).toBe("1"); + expect( + sanitizeEnv({ CLAUDE_CODE_ENABLE_FUNCTION_HOOKS: "0" }).CLAUDE_CODE_ENABLE_FUNCTION_HOOKS, + ).toBe("0"); + }); + it("resolves a module-relative plugin directory", () => { const pluginDir = resolvePluginDir(); From 1deae198ea01cf646406db54b14eb1aa2e2762eb Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:38:21 -0300 Subject: [PATCH 2/8] fix(mods): Back arcade randomness with crypto.getRandomValues Math.random draws flagged Sonar rule S2245 as vulnerabilities on new code and sank the Security Rating gate. Tile spawns and game picks now draw from a shared randomInt helper over getRandomValues, with a bounds test covering it. Co-Authored-By: Claude --- plugin/hooks/boards/twenty48.tsx | 3 ++- plugin/mods/arcade/games/auto.ts | 4 +++- plugin/mods/arcade/games/random.ts | 12 ++++++++++++ plugin/mods/arcade/games/twenty48.ts | 4 +++- tests/mods-arcade/random.test.ts | 27 +++++++++++++++++++++++++++ 5 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 plugin/mods/arcade/games/random.ts create mode 100644 tests/mods-arcade/random.test.ts diff --git a/plugin/hooks/boards/twenty48.tsx b/plugin/hooks/boards/twenty48.tsx index 631272b..2abb15b 100644 --- a/plugin/hooks/boards/twenty48.tsx +++ b/plugin/hooks/boards/twenty48.tsx @@ -11,6 +11,7 @@ import { type SeenDone, type Twenty48State, } from "../../mods/arcade/games/twenty48.js"; +import { randomInt } from "../../mods/arcade/games/random.js"; export interface Twenty48Props { done: number; @@ -52,7 +53,7 @@ export default function Twenty48Board(props: Twenty48Props, surface: ClientSurfa if (direction === null) return; const stepped = moveGrid(state.grid, direction); if (!stepped.moved) return; - const grid = spawnTile(stepped.grid, () => Math.floor(Math.random() * 16)); + const grid = spawnTile(stepped.grid, () => randomInt(16)); const score = state.score + stepped.gained; const won = state.won || grid.some((cell) => cell >= 2048); const over = isFinished(grid); diff --git a/plugin/mods/arcade/games/auto.ts b/plugin/mods/arcade/games/auto.ts index c302aaa..a6ed9c5 100644 --- a/plugin/mods/arcade/games/auto.ts +++ b/plugin/mods/arcade/games/auto.ts @@ -1,3 +1,5 @@ +import { randomInt } from "./random.js"; + export interface AutoInput { lastTurnMs?: number; idle?: boolean; @@ -32,6 +34,6 @@ export function pickAuto(input: AutoInput = {}): string { export function pickRandom(games: string[] = DROPIN_GAMES): string { if (games.length === 0) return DROPIN_GAMES[0]; - const index = Math.floor(Math.random() * games.length); + const index = randomInt(games.length); return games[index]; } diff --git a/plugin/mods/arcade/games/random.ts b/plugin/mods/arcade/games/random.ts new file mode 100644 index 0000000..710cee3 --- /dev/null +++ b/plugin/mods/arcade/games/random.ts @@ -0,0 +1,12 @@ +// Game randomness backed by the platform CSPRNG instead of Math.random. +// Tile spawns and game picks are not secrets, but a predictable generator is +// one confused refactor away from mattering, and Sonar flags Math.random +// (typescript:S2245) wherever it appears. +export function randomInt(bound: number): number { + if (!Number.isInteger(bound) || bound <= 0) { + throw new RangeError(`randomInt needs a positive integer bound, got ${bound}`); + } + const sample = new Uint32Array(1); + globalThis.crypto.getRandomValues(sample); + return sample[0] % bound; +} diff --git a/plugin/mods/arcade/games/twenty48.ts b/plugin/mods/arcade/games/twenty48.ts index 58ada5c..afa0bc9 100644 --- a/plugin/mods/arcade/games/twenty48.ts +++ b/plugin/mods/arcade/games/twenty48.ts @@ -2,6 +2,8 @@ // vitest runs it. The board components under plugin/hooks/boards/ own the // surface wiring (state, keys, pointer, score posts) and import from here. +import { randomInt } from "./random.js"; + export interface SeenDone { current: number; } @@ -122,7 +124,7 @@ export function isFinished(grid: number[]): boolean { return true; } -export function initialTwenty48State(pick: () => number = () => Math.floor(Math.random() * 16)): Twenty48State { +export function initialTwenty48State(pick: () => number = () => randomInt(16)): Twenty48State { const seeded = spawnTile(spawnTile(emptyGrid(), pick), pick); return { grid: seeded, score: 0, over: false, won: false, posted: false, paused: false, seenDone: 0 }; } diff --git a/tests/mods-arcade/random.test.ts b/tests/mods-arcade/random.test.ts new file mode 100644 index 0000000..76bd37e --- /dev/null +++ b/tests/mods-arcade/random.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { randomInt } from "../../plugin/mods/arcade/games/random"; + +describe("randomInt", () => { + it("rejects non-positive integer bounds", () => { + expect(() => randomInt(0)).toThrow(RangeError); + expect(() => randomInt(-4)).toThrow(RangeError); + expect(() => randomInt(1.5)).toThrow(RangeError); + }); + + it("returns 0 for a bound of 1", () => { + expect(randomInt(1)).toBe(0); + }); + + it("stays an integer inside the bound", () => { + const seen = new Set(); + for (let i = 0; i < 100; i += 1) { + const value = randomInt(16); + expect(Number.isInteger(value)).toBe(true); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(16); + seen.add(value); + } + expect(seen.size).toBeGreaterThan(1); + }); +}); From 0f4a78d259ebbd4ee54e46491332071b163972ea Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:38:21 -0300 Subject: [PATCH 3/8] fix(ci): Pin plugin validation to claude 2.1.270 Function hooks events only exist from 2.1.269 on, so the 2.1.258 pin rejects the modules declaration outright. Move the pin to the version this feature was probed against. Co-Authored-By: Claude --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3945cd6..5eb26ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,9 @@ jobs: # Pinned on purpose: this is the version every spelling in the plugin # contract was probed against, so the job checks the plugin under the # CLI the contract describes rather than whatever shipped today. + # Bumped to 2.1.270 for the arcade mod: function hooks (and their + # events) only exist from 2.1.269 on, so the old pin rejects the + # modules declaration outright. # # The install runs no lifecycle scripts, then invokes the one script # this package actually needs, by name. claude ships its real binary @@ -76,7 +79,7 @@ jobs: # keeps that working while nothing else in the tree gets to run code # during install. run: | - npm i -g --ignore-scripts @anthropic-ai/claude-code@2.1.258 + npm i -g --ignore-scripts @anthropic-ai/claude-code@2.1.270 node "$(npm root -g)/@anthropic-ai/claude-code/install.cjs" claude --version From 3d71f4dc2a209474919c59167782a10dea428ce4 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:32:49 -0300 Subject: [PATCH 4/8] specs --- .../agy-empty-profile-swap/run-notes.md | 22 ++++++++++ .../agy-empty-profile-swap/run-report.md | 42 ++++++++++++++++++ .../features/cc-arcade-mod-port/run-notes.md | 32 ++++++++++++++ .../features/cc-arcade-mod-port/run-report.md | 43 +++++++++++++++++++ 4 files changed, 139 insertions(+) create mode 100644 .specs/features/agy-empty-profile-swap/run-notes.md create mode 100644 .specs/features/agy-empty-profile-swap/run-report.md create mode 100644 .specs/features/cc-arcade-mod-port/run-notes.md create mode 100644 .specs/features/cc-arcade-mod-port/run-report.md diff --git a/.specs/features/agy-empty-profile-swap/run-notes.md b/.specs/features/agy-empty-profile-swap/run-notes.md new file mode 100644 index 0000000..27f0afb --- /dev/null +++ b/.specs/features/agy-empty-profile-swap/run-notes.md @@ -0,0 +1,22 @@ +# Run notes: agy-empty-profile-swap + +> APPEND-ONLY. New entries go at the bottom with a timestamp. Never rewrite or delete earlier entries, correct by appending. + +## 2026-09-15 00:33 UTC — bootstrap (bucket 1) + +- Created `.specs/features/agy-empty-profile-swap/` with `run-notes.md` and `run-report.md`. +- Decisions so far: + - 2 discovery sessions completed: `2bbf antigravity-empty` and `050c profile-swap`. + - Both diffs empty, no code changes landed from discovery. +- Bucket categories in use: + - Bucket 1: reversible file creation (docs, notes, specs). Proceed autonomously. + - Bucket 2: needs human confirm (deps, destructive git ops, scope changes). + - Bucket 3: never destructive (no force-push, no data loss, no secret exposure). +- Assumption (logged): this bootstrap is bucket 1, reversible file creation under `.specs/`, no `src/` or `tests/` touched. + +## Closing — PR #84 opened (bucket 1 integration, bucket 2 merge deferred) + +- Integrated `05fb` (profile, completed exit 0, 129 tests) and `e80c` snapshot (antigravity, 29-43 tests across rounds, 3 reviews success) onto fresh `origin/main` worktree `/tmp/codedeck-pr` as branch `fix/agy-empty-profile-swap`. +- Verification: 172 scoped tests green, tsc clean, diff-check clean, mutation probe 2 kills / 0 survivors (scratch reverted). +- Commits `b7efe19` (profile) + `9b3c130` (antigravity); pushed; draft PR https://github.com/4ndreello/codedeck/pull/84. +- Workers retired: `e80c` + `35c2` stopped, rest completed; pre-existing `7c8b`/`7f0d` left alone. Full detail folded into `run-report.md`. diff --git a/.specs/features/agy-empty-profile-swap/run-report.md b/.specs/features/agy-empty-profile-swap/run-report.md new file mode 100644 index 0000000..af1e1f4 --- /dev/null +++ b/.specs/features/agy-empty-profile-swap/run-report.md @@ -0,0 +1,42 @@ +# Run report: agy-empty-profile-swap + +Autonomous run covering two reported bugs: Antigravity sessions surfacing as an empty string, and setup with --profile showing another profile's content. Both fixed, verified, and proposed as draft PR #84. + +## Done + +- Branch: [fix/agy-empty-profile-swap](https://github.com/4ndreello/codedeck/tree/fix/agy-empty-profile-swap) +- Commit: [8772491](https://github.com/4ndreello/codedeck/commit/8772491) (tip; base `07e693f` = origin/main) +- PR: https://github.com/4ndreello/codedeck/pull/84 (MERGED via merge commit [ba05b32](https://github.com/4ndreello/codedeck/commit/ba05b32fef5003431473a65f827b82a77aedebf0); branch deleted locally and remotely) +- Commit 1 `b7efe19` fix(profile): 5 files, 187 insertions, 10 deletions. `profile save` resolves the explicit target instead of the active profile, new `--profile` setups start from base with an empty agent map, role pin respects `loaded.defaultAgent`. +- Commit 2 `9b3c130` fix(antigravity): 3 files, 807 insertions, 6 deletions. Stateful per-session delta accumulation with message reconstruction, `session.failed` on empty/whitespace-only results, lifecycle resets plus bounded replay of consumed output on reattach. +- Discovery: `2bbf` (reviewer/antigravity, completed) reproduced the empty bug live with `result.response: ""` and zero `text.delta`; `050c` (auditor/antigravity, completed) ranked 4 profile root causes with file:line evidence. Both diffs empty (read-only). +- Fixes: `05fb` (opencode, completed exit 0, 134 events) delivered the profile fix including a corrective cycle for 3 reviewer regressions; `e80c` (codex, stopped after delivery, 304 events) delivered the antigravity fix through 3 read-only review rounds (`798b`, `6a3a`, `4dab`, all completed success). +- Verification quoted from the integration worktree: `Test Files 4 passed (4), Tests 172 passed (172)`, `tsc --noEmit` clean, `git diff --check` clean. Mutation probe: M1 (empty branch to completed) killed by 13 tests, M2 (restore root-agent leak) killed by 1 test; 2 kills, 0 survivors; scratch reverted. +- CI fix after PR: SonarCloud failed the gate on `new_duplicated_lines_density` 16.2% (all 161 lines in `tests/antigravity-driver.test.ts`). Two test-only dedup commits (`2038078` helpers, `8772491` it.each + shared replay helper) brought it to 0.0%; full gate green with no src changes. + +## Assumptions I made + +- Bucket 1 (reversible, taken): per-session delta accumulation and empty-to-failed semantics; `{ ...baseConfig(loaded), agents: {} }` for new profiles; `loaded.defaultAgent` for the role pin; chunked accumulator with cap plus truncation marker; bounded fd-based replay read; notes/bootstrap files under `.specs/`. +- Bucket 1 (process): nested `--role reviewer` runs landing on the root Claude binding instead of low-cost Antigravity was treated as lucky routing-around, not as license to use Antigravity for implementation. +- Fixes were integrated by applying `codedeck diff 05fb` (299 lines) and `codedeck diff e80c` (935 lines) onto a fresh `origin/main` worktree, keeping the arcade agent's uncommitted checkout untouched. + +## Deferred / waiting for you + +- Merge PR #84 (publishing/sharing is bucket 2; the draft is ready for human review). +- Decide the `profile save` fork semantics: `use a; save a-copy` no longer forks the active setup (documented in the save description; README line 196 still says "current setup" and was left untouched as outside owned files). +- Decide what `unset` roles in a partial profile should mean at launch (currently falls back to driver defaults with a visible `general unset` line). +- Probe why nested `codedeck run --role reviewer --no-worktree` from fix worktrees resolved to root Claude instead of active-profile Antigravity while `run.ts:55` reads correct; sessions `cff7`, `798b`, `6a3a`, `4dab`, `35c2` carry the exact commands. + +## Blocked / failed + +- `2bbf` delivered no finding text because the Antigravity harness itself returned `""` (299s, 820k input tokens); treated as live reproduction, profile diagnosis `050c` carried the reporting. +- Fix worktrees ship no `node_modules`, so `pnpm vitest` fails there (`ERR_PNPM_IGNORED_BUILDS` / missing package); both fix workers correctly refused installs (bucket 2) and verification ran in the integration worktree via a temporary `node_modules` symlink (removed before commit). +- `e80c` entered scope churn on its 4th review cycle (diff 361 to 807 insertions); capped with a complete-now directive and snapshotted. Residual 4dab perf notes (quadratic past-cap append, head-drop marker wording, driver-lifecycle mutation coverage) are partly addressed; anything further is follow-up. +- Pre-existing, not a regression: `classifyFailure` throws on non-string `result.error` (4dab finding 5); `profile.ts:127` freezing inherited keys on re-save of partial profiles matches HEAD behavior. + +## Not covered + +- No live `agy` turn was ever spent (model spend, all evidence from parser/runtime/probes); empty-response shape against agy 1.2.2 in the wild stays unverified, as does `--conversation` init-frame behavior gating one stale-text scenario. +- No full suite (repo batching rule); only `tests/antigravity-driver.test.ts` (43 tests), `tests/profiles.test.ts` (20), `tests/setup-cli-contract.test.ts` (26), `tests/setup-wizard.test.ts` (83). +- No `dist/` rebuild check, no `gh pr checks` / SonarCloud (uncommitted at review time; CI will run on PR #84). +- Picker rendering, `doctor`/`open`/`web` profile consumption beyond call-site reads, and empty-response handling in non-antigravity drivers. diff --git a/.specs/features/cc-arcade-mod-port/run-notes.md b/.specs/features/cc-arcade-mod-port/run-notes.md new file mode 100644 index 0000000..cfc7fcd --- /dev/null +++ b/.specs/features/cc-arcade-mod-port/run-notes.md @@ -0,0 +1,32 @@ +# Run notes: cc-arcade-mod-port + +Autonomous run. Append-only. One line per entry. Time in UTC. + +- 2026-09-15T00:33Z | activation | autonomous mode invoked by human in orchestrator session | bucket: n/a | contract: never ask human, bucket1 reversible now with logged assumption, bucket2 irreversible deferred, route around blockers, parallelize for speed +- 2026-09-15T00:33Z | assumption | slug cc-arcade-mod-port names this work item: port cc-arcade function-hooks Mod pattern into codedeck plugin | bucket: 1 | reason: reversible prose choice, directory created +- 2026-09-15T00:33Z | assumption | prior swarm findings 2bab and e1ed treated as grounded input, not as delivered code | bucket: 1 | reason: read-only discovery, diffs empty, quoted test output trusted over success messages +- 2026-09-15T00:33Z | decision | implementation split in 3 parallel slices by file ownership: SPEC owns .specs spec.md, MOD owns plugin hooks register plus boards plus pure logic plus new tests, INTEGRATION owns plugin manifests plus open plus docs | bucket: 1 | reason: avoids two workers in one file, reversible slice plan +- 2026-09-15T00:33Z | constraint | no installs, fetches, or vendoring by any worker, including bunx remote fetches and npm minus g. scoped local test and build commands only. claude plugin validate allowed, already installed claude 2.1.270 | bucket: 2 deferred by contract | reason: network fetch and transitive code execution are irreversible +- 2026-09-15T00:33Z | constraint | no pushes, no git clean, no reset hard, no shared state writes. workers leave changes uncommitted in worktrees for codedeck diff review | bucket: 2 deferred by contract | reason: publishing and destructive git actions need human +- 2026-09-15T00:34Z | dispatch | SPEC worker 3c47 general worktree, MOD worker 6ffe general worktree, INTEGRATION worker 542f general worktree, all parallel | bucket: 1 | reason: implementation slices own disjoint file sets +- 2026-09-15T00:34Z | registry | 3c47 SPEC owns .specs spec.md, 6ffe MOD owns register plus boards plus mods plus new tests, 542f INTEGRATION owns manifests plus src/open plus docs/mods.md | bucket: 1 | reason: slice ownership record +- 2026-09-15T00:35Z | finding | SPEC worker 3c47 completed with spec.md verified on disk at .specs/features/cc-arcade-mod-port/spec.md, 3502 bytes, all five headings present. codedeck diff stat was empty because new files are untracked, lesson: verify new-file slices with git status in the worktree, not diff stat alone. SPEC accepted. +- 2026-09-15T00:35Z | blocked | MOD worker 6ffe and INTEGRATION worker 542f failed, blame harness retryable: opencode sandbox auto-rejects external_directory reads under /tmp/cc-arcade-review referenced by both briefings. No code was written by either. Lesson: keep opencode workers hermetic inside the worktree, inline reference content in briefings. +- 2026-09-15T00:35Z | finding | reference schema read directly by orchestrator, no worker fetch needed: cc-arcade hooks/hooks.json is exactly description plus modules with value ["./register.tsx"]. register.tsx shape is @jsx h pragma, import type Register from claude-code, export const register assignment, on() handlers for session.start command.run turn.start turn.complete tool.call ui.message ui.render AbovePrompt, Client with string literal module, store get and set with catch, ui resolve invalidate toast log, clock now, command register immediate true. Board shape is @jsx h pragma, ClientElements and ClientSurface type imports, default component export, never a local h. +- 2026-09-15T00:35Z | dispatch | corrective cycle, one per failed slice: MOD retry and INTEGRATION retry, hermetic briefings with inlined reference, parallel | bucket: 1 | reason: failures are harness sandbox blocks, retryable, no drift to correct +- 2026-09-15T00:36Z | dispatch | MOD RETRY worker 7125 general worktree, INTEGRATION RETRY worker c2c7 general worktree, parallel | bucket: 1 | reason: single corrective cycle per failed slice, hermetic briefings +- 2026-09-15T00:53Z | finding | MOD worker 7125 completed exit 0, files verified on disk: register.tsx, boards common.tsx plus twenty48.tsx, mods arcade games pet best auto twenty48 plus index, tests mods-arcade four files. Claims 69 tests pass with empty-config caveat since worktree has no node_modules. Self reviewed via reviewer session 7f9c and acted on findings. Status: delivered, pending validation. +- 2026-09-15T00:53Z | finding | INTEGRATION worker c2c7 completed exit 0, diff verified: hooks.json gains description plus modules register entry with hooks key intact, plugin.json 0.2.0 to 0.3.0, runtime.ts sanitizeEnv sets CLAUDE_CODE_ENABLE_FUNCTION_HOOKS default 1 with user opt-out preserved, docs/mods.md created. Validator accepts manifest shape, single missing-file error for register.tsx which the MOD slice supplies. Build plus scoped tests BLOCKED in worktree, no node_modules. No test files edited. Status: delivered, pending validation. +- 2026-09-15T00:53Z | risk | MOD register uses engine call shapes that differ from the reference account: ui.render.invalidate versus ui.invalidate with event string, next with dollar versus next with event, resolve with string args versus resolve with event, namespaced arcade store keys versus upstream pet and best keys. Unresolvable without engine types. Flagged for the review slice to reconcile against discovery evidence. +- 2026-09-15T00:53Z | dispatch | VALIDATION worker 3cf7 reviewer no-worktree, merged scratch tree plus per worktree checks plus mutation probe | bucket: 1 | reason: independent proof before integration decision +- 2026-09-15T00:58Z | finding | merged scratch validation by 3cf7: npm run build green, scoped vitest 7 files 117 tests green, claude plugin validate on repo root passed. Manifest-path validate reports 1 error: register.tsx compiled line 32 reads dollar dot name as a value, engine contract requires dollar dot noun dot event at call sites. Reproduced by orchestrator in scratch. MUST FIX in remediation. +- 2026-09-15T00:58Z | finding | orchestrator mutation probe in scratch, pure logic only, files restored after: XP value flip KILLED with 2 failures, isBetter inversion KILLED with 1 failure, auto threshold direction flip SURVIVED all 69 green. Survivor means the turn duration branch direction is untested. Test gap goes to remediation. +- 2026-09-15T00:58Z | dispatch | REVIEW worker f8ef reviewer no-worktree read only over both worktrees plus spec | bucket: 1 | reason: final read-only review, findings remediated before delivery +- 2026-09-15T01:03Z | finding | REVIEW worker f8ef completed: 3 BLOCKER plus 2 MAJOR plus 3 MINOR plus 2 INFO acceptable. Blockers all in register.tsx engine call shapes: dollar dot name read, invalidate shape, ui.render handler signatures. Majors: surface gate placement, upstream store key shape. Minors: callback param naming, .claude missing from gitignore, sanitizeEnv test gap. Sound: pet privacy, Client literals, no local h, banner, manifest shape, version bump, env opt-out, pure logic tests. +- 2026-09-15T01:03Z | dispatch | FIX-MOD worker 8ac1 general in place in 7125 worktree owns register plus boards plus mods plus new tests, findings 1 to 6 plus auto pool refactor and threshold test. FIX-INT worker 223f general in place in c2c7 worktree owns gitignore plus open-args test, findings 7 and 8. Parallel, disjoint ownership | bucket: 1 | reason: remediate confirmed findings before delivery +- 2026-09-15T01:12Z | finding | FIX-MOD round 2 plus round 3 done: handlers now positional dollar engine e event next continuation, command.run matcher with string e dot args, invalidate with event string, resolve and next with e, Client module relative ./boards/twenty48.tsx. Manifest validate now lists 7 events plus 7 dollar calls plus 1 surface module and passes. Root validate passes. Build green. +- 2026-09-15T01:12Z | finding | revalidation in merged scratch after fixes: 8 files 211 tests green, manifest validate passed, root validate passed. Pre-existing open-args exact equality test needed the new env key added to its expectation, one line, done by orchestrator in FIX-INT worktree and recorded as assumption. +- 2026-09-15T01:12Z | finding | mutation probe closed: threshold flip now KILLED by 2 failures in the new pickPool boundary test, scratch restored to fixed state and green again. Probe total 2 killed before, 1 survived then killed after the new test. +- 2026-09-15T01:12Z | decision | changes stay uncommitted in worker worktrees 7125 MOD plus fixes and c2c7 INTEGRATION plus fixes for human integration. No merge no push per contract. Scratch at /tmp/opencode/validate-arcade holds the verified merged tree. +- 2026-09-15T01:20Z | decision | human ordered PR open, bucket 2 publish action explicitly authorized. Branch feat/arcade-mod-port created from main, verified delta applied from 7125 plus c2c7 plus spec, build green, 211 tests green, both validates green. Commit db2207d with Co-Authored-By footer. Pushed and opened draft PR 83. Untracked process notes left out of the branch. +- 2026-09-15T01:30Z | finding | PR 83 checks red: plugin job pinned claude 2.1.258 predates function hooks so session.start is not an event there, and Sonar gate failed Security Rating on 3x S2245 Math.random in new game code. Fixed on branch: randomInt helper over getRandomValues plus bounds test, CI pin to 2.1.270. Commits 4c888bb and fcb472b. All checks green: sonar pass, plugin pass, test 24 plus 26 pass. diff --git a/.specs/features/cc-arcade-mod-port/run-report.md b/.specs/features/cc-arcade-mod-port/run-report.md new file mode 100644 index 0000000..fe909d5 --- /dev/null +++ b/.specs/features/cc-arcade-mod-port/run-report.md @@ -0,0 +1,43 @@ +# Run report: cc-arcade-mod-port + +Autonomous run 2026-09-15. Goal: port the cc-arcade function-hooks Mod pattern (anthropics claude-code issue 91870, comment 5666255143) into the codedeck plugin as an `arcade` mod: `/arcade` command, AbovePrompt board, pet fed by tool calls, persisted best scores. Swarm of 9 worker sessions plus orchestrator verification. This report is self-contained. + +## Done + +Branch: [main](https://github.com/4ndreello/codedeck/tree/main). Commit: [07e693f7e0e5ac0a18123e26de1b9a125f31e850](https://github.com/4ndreello/codedeck/commit/07e693f7e0e5ac0a18123e26de1b9a125f31e850). No merge and no push happened in this run. The delivered changes sit uncommitted in two worker worktrees, verified merged in scratch at `/tmp/opencode/validate-arcade`: + +- MOD worktree `ra/you-are-implementing-for-coded-7125` (worker 7125, fixes 8ac1): `plugin/hooks/register.tsx` (7 engine events, matcher-based `/arcade`, picker plus 2048 Client board, banner pause, counter-only pet feeding), `plugin/hooks/boards/common.tsx` plus `twenty48.tsx`, `plugin/mods/arcade/` (pet, best, auto with pool picker, 2048 rules, index), `tests/mods-arcade/` (4 files). +- INTEGRATION worktree `ra/you-are-implementing-for-coded-c2c7` (worker 542f retry c2c7, fixes 223f): `plugin/hooks/hooks.json` gains `modules: ["./register.tsx"]` with shell hooks intact, `plugin.json` 0.2.0 to 0.3.0, `src/open/runtime.ts` defaults `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS` to 1 with user opt-out, `docs/mods.md` created, `.gitignore` gains `.claude/`, `tests/open-args.test.ts` gains env tests plus updated expectation. +- Spec of record: `.specs/features/cc-arcade-mod-port/spec.md` (worker 3c47). + +Verified evidence, quoted from runs I executed or read from worker artifacts: `npm run build` green (tsc plus copy-plugin). Scoped vitest: `Test Files 8 passed (8)`, `Tests 211 passed (211)`. `claude plugin validate` on the manifest: `register.tsx hooks: session.start, command.run{command=arcade}, turn.start, turn.complete, tool.call, ui.message, ui.render`, `calls: $.command.register, $.store.get, $.store.set, $.ui.invalidate, $.ui.log, $.ui.resolve, $.ui.toast`, `surface modules: hooks/boards/twenty48.tsx`, `Validation passed`. Root validate: `Validation passed`. Mutation probe on pure logic: XP flip killed (2 failures), isBetter inversion killed (1 failure), threshold flip survived then killed (2 failures) after the new `pickPool` boundary test. Scratch restored to the fixed state and green afterward. + +## Assumptions I made + +- Slug `cc-arcade-mod-port` names this work item, and the run splits into SPEC, MOD, INTEGRATION slices owning disjoint file sets so no two workers share a file. +- Prior read-only discovery (2bab on cc-arcade, e1ed on codedeck, both empty diffs) counts as grounded input, with quoted test output trusted over success messages. +- Store keys follow the upstream shape (`pet`, `best:`, `colorblind`) per review, not the namespaced draft. +- One-line expectation update in `tests/open-args.test.ts` for the new env default was done by me directly in the FIX-INT worktree because the worker had finished; it mirrors intended behavior and the full scoped suite passes with it. + +## Deferred / waiting for you + +- Merge the two worktree branches into main, and any push. Both are retirement-ready: `7125` holds MOD plus fixes, `c2c7` holds INTEGRATION plus fixes. +- Any dependency install, fetch, or vendor action, including remote `bunx` fetches. All runs used already installed tools (claude 2.1.270, local vitest). +- Live verification in an interactive Claude Code PTY with `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`: real drawing above the prompt, click for keyboard focus, Esc behavior, frame pacing. +- Running `/plugin-types` to generate `.claude/types` and the full test suite. +- Product call: grow beyond the single 2048 demo board toward more games, or keep the mod minimal. + +## Blocked / failed + +- First MOD (6ffe) and INTEGRATION (542f) attempts failed before writing anything: the opencode harness sandbox auto-rejected reads outside the worktree referenced by their briefings (blame harness, retryable). Recovered with one hermetic corrective cycle each (7125, c2c7), both delivered. +- Manifest validation failed twice during the run and both were fixed: `$.name` read as a value, then handler parameter order (`$` engine first, `e` event second, `e.args` a string), then the Client module path made relative (`./boards/twenty48.tsx`). Each fix was re-validated to green. +- Validation worker 3cf7 finished without a readable final summary event, so its verdict was reconstructed from its 89 tool outputs (build, tests, validates, file listings) plus my own reruns. Nothing was taken on trust: every quoted result above was reproduced in scratch. +- One pre-existing `open-args` exact-equality test broke on the intended new env key and was updated as noted above. + +## Not covered + +- The other eight games, doom, and the pet board: explicitly out of scope in the spec, one demo board delivered. +- Desktop, mobile, and headless rendering: terminal only by design and by engine constraint. +- `oxlint` and upstream `bun test`: remote fetch and out of scope; pure logic is covered by the new vitest files instead. +- Cross-machine score sync: scores and pet live in the local plugin store. +- Engine-type strictness beyond what `claude plugin validate` checks: `/plugin-types` output was never generated here. From db70d2ac09083b82f71b6b7a55f073a6ec3c390e Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Sat, 19 Sep 2026 23:01:13 -0300 Subject: [PATCH 5/8] feat(mods): Replace the arcade with the orchestrator agents pane The arcade drew a game above the prompt. What the run actually needs there is the `codedeck web` canvas, so this deletes the arcade slice and puts an ASCII version of that canvas in a docked pane on the right: one card per agent of the current run, harness glyph, status and age, ordered by last touch, with a header counting sessions and a footer legend. The pane opens at session start when CODEDECK_RUN_ID is set, so a plain `claude --plugin-dir` session gets nothing rather than a permanently empty dock. It refreshes on turn.complete and on tool.call behind a 1.5s gap, and redraws only when the snapshot actually changed. A button above the prompt is the way back in. The pane can be closed by the engine's own close box, which fires no hook at all, so the module cannot track whether the pane is open; the button therefore only ever opens, never toggles, and there is no state to desync. It also re-syncs the flag /band reads. Removal and replacement land together because the previous register.tsx imports the arcade modules, so deleting them alone leaves a tree that cannot build. The pure layer never throws, because a throw inside a render hook drops the whole drawing, and it sizes itself from what the pane reports rather than from an assumed width. Co-Authored-By: Claude --- .github/workflows/ci.yml | 6 +- .../orchestrator-agents-band/design.md | 285 ++++++++ .../reference-register.tsx | 131 ++++ .../features/orchestrator-agents-band/spec.md | 209 ++++++ .../orchestrator-agents-band/tasks.md | 354 ++++++++++ plugin/hooks/boards/common.tsx | 54 -- plugin/hooks/boards/twenty48.tsx | 96 --- plugin/hooks/hooks.json | 2 +- plugin/hooks/register.tsx | 311 ++++++--- plugin/mods/agents/pane.ts | 440 +++++++++++++ plugin/mods/agents/parse.ts | 29 + plugin/mods/agents/types.ts | 43 ++ plugin/mods/arcade/games/auto.ts | 39 -- plugin/mods/arcade/games/best.ts | 9 - plugin/mods/arcade/games/pet.ts | 119 ---- plugin/mods/arcade/games/random.ts | 12 - plugin/mods/arcade/games/twenty48.ts | 138 ---- plugin/mods/arcade/index.ts | 13 - scripts/pane-mock.mjs | 105 +++ scripts/pane-probe.sh | 178 +++++ src/open/runtime.ts | 2 +- tests/mods-agents/pane.test.ts | 616 ++++++++++++++++++ tests/mods-agents/parse.test.ts | 55 ++ tests/mods-arcade/auto.test.ts | 50 -- tests/mods-arcade/best.test.ts | 19 - tests/mods-arcade/pet.test.ts | 118 ---- tests/mods-arcade/random.test.ts | 27 - tests/mods-arcade/twenty48.test.ts | 89 --- tests/open-args.test.ts | 2 +- 29 files changed, 2671 insertions(+), 880 deletions(-) create mode 100644 .specs/features/orchestrator-agents-band/design.md create mode 100644 .specs/features/orchestrator-agents-band/reference-register.tsx create mode 100644 .specs/features/orchestrator-agents-band/spec.md create mode 100644 .specs/features/orchestrator-agents-band/tasks.md delete mode 100644 plugin/hooks/boards/common.tsx delete mode 100644 plugin/hooks/boards/twenty48.tsx create mode 100644 plugin/mods/agents/pane.ts create mode 100644 plugin/mods/agents/parse.ts create mode 100644 plugin/mods/agents/types.ts delete mode 100644 plugin/mods/arcade/games/auto.ts delete mode 100644 plugin/mods/arcade/games/best.ts delete mode 100644 plugin/mods/arcade/games/pet.ts delete mode 100644 plugin/mods/arcade/games/random.ts delete mode 100644 plugin/mods/arcade/games/twenty48.ts delete mode 100644 plugin/mods/arcade/index.ts create mode 100644 scripts/pane-mock.mjs create mode 100755 scripts/pane-probe.sh create mode 100644 tests/mods-agents/pane.test.ts create mode 100644 tests/mods-agents/parse.test.ts delete mode 100644 tests/mods-arcade/auto.test.ts delete mode 100644 tests/mods-arcade/best.test.ts delete mode 100644 tests/mods-arcade/pet.test.ts delete mode 100644 tests/mods-arcade/random.test.ts delete mode 100644 tests/mods-arcade/twenty48.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5eb26ba..b235d9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,9 +68,9 @@ jobs: # Pinned on purpose: this is the version every spelling in the plugin # contract was probed against, so the job checks the plugin under the # CLI the contract describes rather than whatever shipped today. - # Bumped to 2.1.270 for the arcade mod: function hooks (and their - # events) only exist from 2.1.269 on, so the old pin rejects the - # modules declaration outright. + # Bumped to 2.1.270 when the plugin gained a function-hooks module: + # function hooks (and their events) only exist from 2.1.269 on, so the + # old pin rejects the modules declaration outright. # # The install runs no lifecycle scripts, then invokes the one script # this package actually needs, by name. claude ships its real binary diff --git a/.specs/features/orchestrator-agents-band/design.md b/.specs/features/orchestrator-agents-band/design.md new file mode 100644 index 0000000..2438b8e --- /dev/null +++ b/.specs/features/orchestrator-agents-band/design.md @@ -0,0 +1,285 @@ +# Design: orchestrator agents pane + +> **Revised.** The first cut drew a table above the prompt. It worked, and it +> was rejected: the ask is the `codedeck web` canvas, as ASCII, docked on the +> right. `AbovePrompt` is gone from the plan. What survives is the pure layer +> and every engine fact below, all of which were paid for. + +## Pane contract, verified live + +One probe module, one PTY session, dumping the render event: + +``` +requestId = "wide" // which pane is being drawn, NOT e.id +viewport = { columns: 120, rows: 50 } +props = { title, isFocused, bodyColumns: 89, placement: "dock", scroll, view } +``` + +- `$.ui.open({ id })` opens a docked pane. `{ id, width, side: "right" }` is + also accepted. `id` is 1 to 64 of letters, digits, `_` or `-`. +- `ui.render` then fires with `e.component === "Pane"`, and the module tells + panes apart by `e.requestId`, which carries the id passed to open. +- **`e.props.bodyColumns` is the usable content width.** The pane reports its + own size, so the drawing adapts instead of asking. A requested `width` is + accepted but is not what comes back. +- `e.id` is `undefined` on the render event. Reaching for it is the obvious + mistake and it fails silently. +- **`e.props.scroll` is `{ offset, bodyRows }`**, and `bodyRows` is the usable + height: 44 in a 50 row terminal. Anything taller is clipped at the bottom + with no warning, which eats the footer first. Measured together in one 200 + by 50 terminal: `bodyColumns` 89, `scroll.bodyRows` 44, `viewport` + `{ columns: 110, rows: 50 }`, so `viewport` is the space left over for the + rest of the screen, not the terminal and not the pane. +- A requested `width` is not merely different from what comes back, it is + **ignored**: asking for 46 still returned `bodyColumns: 89`. The engine sizes + the dock and the module adapts, full stop. +- **`Box` lays its children out in a row.** A list of `Text` lines needs + `` or the drawing is shredded into vertical + slivers. This cost a day: every validate passed, the module loaded, the hook + ran, zero errors were raised, and the pane was garbage. The only instrument + that sees it is a screen capture, which is why T5 exists. +- The pane draws from session start, as soon as one refresh has a snapshot. It + does not wait for a turn. An early probe concluded the opposite by reading a + PTY byte stream, where a docked pane's repaints interleave and a grep for the + header finds nothing even when the pane is perfect. Use `tmux capture-pane`, + which returns the settled screen. + +## Old design below, kept for the pure layer and the failure posture + + +Source of truth: `spec.md` in this directory. Where the two disagree, the spec +wins and this file is the one that is wrong. + +## Shape + +Two layers, split so that everything with logic in it is testable without an +engine, and everything that needs an engine has no logic in it. + +``` + plugin/mods/agents/ pure, no engine imports, unit tested + types.ts the row shape and the snapshot shape + select.ts filter to this run, drop the orchestrator, order, budget + format.ts one row to one line, truncation, the overflow line + parse.ts raw stdout to a snapshot, or a failure + + plugin/hooks/register.tsx engine only, no logic worth testing + ui.render gate, resolve, draw the lines the pure layer produced + turn.complete start a refresh + tool.call start a refresh, throttled + command.run /agents toggles visibility +``` + +The rule that keeps this honest: `register.tsx` may hold state and call the +engine, but it may not decide anything. Every decision, which rows, which +order, what a line says, how a fault is handled, lives in `plugin/mods/agents/` +where a unit test can reach it. + +## Why no Client + +Proven in a live PTY session: a hooks module drawing `Box`, `Text` and `Button` +straight from `$.ui.resolve(e, ...)` renders correctly above the prompt, and a +`Button` carrying `key`, `label` and `onPress` works. + +Also proven, in the same way: a `Client` surface module's state does not +survive across renders in the arcade port, and the engine's guard text blames +setting state during render. That defect is open and unowned. + +The band is read-only and needs no keyboard focus, so it needs no Client. This +is not a preference, it is the reason the band can ship while the game cannot. + +## Data flow + +``` +tool.call / turn.complete + | + v + refresh() --- in flight? --> drop + | + v + $.process.run(["codedeck", "ps", "--all", "--json"]) + | + v + parseSnapshot(stdout) -> Snapshot | undefined + | | + failure success + | | + keep last good differs from drawn? + | + yes -> $.ui.invalidate("ui.render") +``` + +`$.process.run` is a verified engine capability. Full contract, read out of +the engine implementation rather than inferred: + +- `$.process.run(argv, init?)`. **Both arguments are positional.** `argv` is a + string array whose first element is the command. No shell, so no quoting + question. +- `init` is the optional second argument, `{ cwd?, env?, stdin?, timeoutMs? }`. + `cwd` resolves against the session cwd, `env` merges over the inherited env, + `stdin` is piped only when supplied. `timeoutMs` is a whole number, 1 to + 600000. +- default `timeoutMs` 30000, capped by an engine maximum. Each of stdout and + stderr is captured and truncated at 4194304 characters. The child runs under + cgroup class `plugin`. +- resolves to `{ exitCode, stdout, stderr }`. `exitCode` falls back to `1` when + the process closes without one. +- **throws** in two cases, both of which the caller must catch: timeout or + abort (`$.process.run(codedeck) aborted: still running after 30000ms`) and + failure to start (`$.process.run(codedeck) failed to start: `). + +Two rules that only a live session revealed, both of which cost a worker: + +- Calling it the way the host-side implementation reads, `$.process.run({ argv: + [...] })`, is **refused**: `process.run: takes argv, a non-empty list of + strings naming the command first`. The host function does destructure + `{ argv, init }`, but the module-side proxy packs the positional arguments + into that object first. Reading the implementation and skipping the proxy is + how the wrong shape got into this document in the first place. +- `$` may be passed into a helper function, verified. What is refused is pulling + a namespace off it: `const P = $.process` fails to load the module with + `$.process is used as a value (a noun...)`. The engine analyses the compiled + source statically, so every engine call has to appear literally as + `$..(...)` at the call site. + +Verified live, in a PTY session, one probe module, four shapes: + +``` +$.process.run(["echo","hi"]) OK, exit 0 +$.process.run(ARGV) // module-level const OK, so no literal rule on the array +$.process.run(["echo","hi"], { timeoutMs: 5000 }) OK, init is positional +$.process.run(["codedeck","ps","--all","--json"]) OK, exit 0, 73279 chars +$.process.run({ argv: ["echo","hi"] }) REFUSED +``` + +`codedeck ps --json` and `codedeck ps --all --json` return the same 100 rows and +the same 73685 bytes on this machine: `--all` changes the table, not the JSON. +`ps` has no run filter, so the band over-fetches and narrows in `select.ts`. Of +those 100 rows, 18 belonged to the run under test. + +The run id comes from `CODEDECK_RUN_ID`, which `codedeck open` already sets and +which `plugin/statusline.sh` already depends on for its `N agents` field. The +band reads it the same way. + +### Why not the web server + +`codedeck web` serves `/api/sessions` and an SSE stream, and `$.http.fetch` +exists. Rejected: it makes the band depend on a server the user has to remember +to start, and it would show nothing with no way to say why. `$.process.run` +against the same CLI the human uses has no such precondition. + +## Frozen interface + +Both implementation slices are written against exactly this. Neither may change +it without the other being redispatched. + +```ts +// plugin/mods/agents/types.ts +export interface SessionRow { + id: string; + runId?: string; + origin?: string | null; + name?: string; + agent?: string; + status?: string; + updatedAt?: string; +} + +export interface BandRow { + id: string; + status: string; + agent: string; + name: string; +} + +export interface Snapshot { + rows: BandRow[]; + hidden: number; // rows beyond the budget +} + +// plugin/mods/agents/parse.ts +// Raw stdout to rows. Returns undefined for anything that is not a JSON array, +// which is how a failed or truncated command is reported. Never throws. +export function parseRows(stdout: string): SessionRow[] | undefined; + +// plugin/mods/agents/select.ts +export const ROW_BUDGET = 8; +// Filter to runId, drop origin "open", order by updatedAt descending, +// cut to budget. Missing updatedAt sorts last. Never throws. +export function selectRows( + rows: SessionRow[], + runId: string, + budget?: number, +): Snapshot; + +// plugin/mods/agents/format.ts +export const NAME_WIDTH = 28; +// One row to one line. Missing fields render as "-". Never throws. +export function formatRow(row: BandRow): string; +// Header plus rows plus the overflow line when hidden > 0. Empty array when +// the snapshot holds no rows, which is how "draw nothing" is expressed. +export function formatBand(snapshot: Snapshot): string[]; +``` + +`register.tsx` consumes those four functions and nothing else from the mod. + +## Drawing + +One `Box` holding one `Text` per line, returned as a single element with the +downstream drawing nested as its last child: + +```tsx +return ( + + {lines.map((line) => {line})} + {await next(e)} + +); +``` + +Contract points this satisfies, each one a bug the arcade port actually hit: + +- exactly one tree element returned, never an array +- drawn only for `e.component === "AbovePrompt"` and `e.surface === "terminal"` +- capitalized tags, taken from `$.ui.resolve`, because the intrinsic table is + `{ Box: 'Box', Text: 'Text' }` +- no `Client`, so no surface state + +## State in the hooks module + +Closure state in `register`, which is the path proven to work: + +``` +let snapshot: Snapshot | undefined // last good, undefined until first success +let inFlight = false +let lastRefreshEndedAt = 0 +let visible = true // /agents toggles +``` + +`snapshot` staying `undefined` is how AC18 is met: nothing drawn until a +refresh has succeeded once. + +## Failure posture + +Every fault degrades to the last good snapshot and never to a thrown hook. A +thrown hook is worse than a stale band: the engine drops the whole drawing, and +that is how the arcade port lost its picker. + +| Fault | Behaviour | +|---|---| +| command exits non-zero | keep last good, no invalidate | +| command times out | keep last good, no invalidate | +| stdout is not a JSON array | keep last good, no invalidate | +| a row is missing a field | draw the row, field renders as `-` | +| no refresh has ever succeeded | draw nothing | +| `CODEDECK_RUN_ID` absent | draw nothing, pass through | + +## Open risks + +- ~~`$.process.run` may be refused by a permission or capability gate.~~ + Closed. The extracted implementation contains no permission or capability + check: it resolves the command and spawns it directly, with the only guards + being the timeout, the output cap and the `plugin` cgroup class. The residual + risk is now just first use, which the probe slice covers. +- The band competes for vertical space with whatever else draws above the + prompt. The row budget bounds it, but the right budget is a matter of taste + and may need a second look once it is on screen. diff --git a/.specs/features/orchestrator-agents-band/reference-register.tsx b/.specs/features/orchestrator-agents-band/reference-register.tsx new file mode 100644 index 0000000..7a33262 --- /dev/null +++ b/.specs/features/orchestrator-agents-band/reference-register.tsx @@ -0,0 +1,131 @@ +/** @jsx h */ +import type { Register } from "claude-code"; + +import { feed, newPet, petEvent, type Pet } from "../mods/arcade/games/pet.js"; +import { isBetter } from "../mods/arcade/games/best.js"; +import { ARCADE_BOARDS } from "../mods/arcade/index.js"; + +const PET_KEY = "pet"; +const COLORBLIND_KEY = "colorblind"; +const bestKey = (game: string): string => `best:${game}`; + +export const register: Register = (on) => { + let pet: Pet = newPet(); + let best: Record = {}; + let colorblind = false; + let turnClock = 0; + let lastTurnMs = 0; + let openBoard: string | null = null; + let done = 0; + + on("session.start", async ($, e, next) => { + try { + const savedPet = await $.store.get(PET_KEY); + if (savedPet) pet = savedPet as Pet; + for (const board of ARCADE_BOARDS) { + const saved = await $.store.get(bestKey(board)); + if (typeof saved === "number") best[board] = saved; + } + const savedColorblind = await $.store.get(COLORBLIND_KEY); + if (typeof savedColorblind === "boolean") colorblind = savedColorblind; + } catch (err) { + $.ui.log(`arcade restore failed: ${String(err)}`); + } + await $.command.register({ name: "arcade", description: "play 2048 above the prompt while Claude works", immediate: true }); + return next(e); + }); + + on("command.run", { command: "arcade" }, async ($, e) => { + const arg = e.args.trim().toLowerCase(); + if (arg === "") { + openBoard = null; + await $.ui.invalidate("ui.render"); + return { text: "arcade boards: twenty48" }; + } + if (arg === "list") { + return { text: "arcade boards: twenty48" }; + } + if (arg === "open" || arg.startsWith("open ")) { + const name = arg === "open" ? "twenty48" : arg.slice("open ".length).trim() || "twenty48"; + if (!(ARCADE_BOARDS as readonly string[]).includes(name)) { + return { text: `no game called "${name}"` }; + } + openBoard = name; + await $.ui.invalidate("ui.render"); + return { text: `opened ${name}` }; + } + if (arg === "stop") { + openBoard = null; + await $.ui.invalidate("ui.render"); + return { text: "arcade closed" }; + } + return { text: `no game called "${arg}"` }; + }); + + on("turn.start", async ($, e, next) => { + turnClock = Date.now(); + return next(e); + }); + + on("turn.complete", async ($, e, next) => { + lastTurnMs = Date.now() - turnClock; + void lastTurnMs; + done += 1; + await $.ui.invalidate("ui.render"); + return next(e); + }); + + on("tool.call", async ($, e, next) => { + const r = await next(e); + // petEvent already ignores denied calls; only classified events feed the pet. + const command = (e as { command?: string }).command ?? undefined; + const denied = "deny" in (r as Record); + const ok = denied ? undefined : !(r as { isError?: boolean }).isError; + const event = petEvent({ tool: e.tool, command, ok, denied }); + if (event !== undefined) { + pet = feed(pet, event); + await $.store.set(PET_KEY, { xp: pet.xp, mood: pet.mood, tests: pet.tests, commits: pet.commits, edits: pet.edits }); + } + return r; + }); + + on("ui.message", async ($, e, next) => { + const posted = e.data as { game?: string; score?: number } | undefined; + if (!posted || typeof posted.game !== "string" || typeof posted.score !== "number") { + return next(e); + } + if (isBetter(posted.game, posted.score, best)) { + best[posted.game] = posted.score; + await $.store.set(bestKey(posted.game), posted.score); + $.ui.toast(`New arcade record in ${posted.game}: ${posted.score}`); + } + return { game: posted.game, score: posted.score }; + }); + + on("ui.render", async ($, e, next) => { + if (e.surface !== "terminal" || e.component !== "AbovePrompt") return await next(e); + const { Box, Button, Client, Text } = await $.ui.resolve(e, "Box", "Button", "Client", "Text"); + if (openBoard === null) { + return ( + + Arcade boards + - , - await next(e), - ]; + // The way back in. The pane can be closed from its own close box, which + // fires no hook, so without a visible affordance the only recovery is + // knowing that /band exists. One button above the prompt, only when there + // is a run behind it. + if (e.component === "AbovePrompt") { + if (!state.hasRun) return await next(e); + const { Box, Button } = await $.ui.resolve(e, "Box", "Button"); + return ( + +