martin-loop
Advanced tools
| export { playWhileWaiting } from "./space-invaders.js"; | ||
| export type { ArcadeOptions } from "./space-invaders.js"; | ||
| export interface ArcadePromptOptions { | ||
| /** ms to wait before prompting. Default: 30_000 */ | ||
| promptAfterMs?: number; | ||
| /** Skip the wait and start/offer the game immediately. */ | ||
| force?: boolean; | ||
| /** Never prompt โ pass-through mode. */ | ||
| disabled?: boolean; | ||
| } | ||
| /** | ||
| * Waits `promptAfterMs` for the task to finish. If it is still running, | ||
| * prompts once in an interactive terminal. If the user accepts, plays | ||
| * Space Invaders while the task continues. Always returns the task result. | ||
| * | ||
| * Never prompts in CI, non-TTY, or when disabled. | ||
| */ | ||
| export declare function maybePlayArcadeWhileWaiting<T>(task: Promise<T>, opts?: ArcadePromptOptions): Promise<T>; |
| export { playWhileWaiting } from "./space-invaders.js"; | ||
| /** | ||
| * Waits `promptAfterMs` for the task to finish. If it is still running, | ||
| * prompts once in an interactive terminal. If the user accepts, plays | ||
| * Space Invaders while the task continues. Always returns the task result. | ||
| * | ||
| * Never prompts in CI, non-TTY, or when disabled. | ||
| */ | ||
| export async function maybePlayArcadeWhileWaiting(task, opts = {}) { | ||
| const { promptAfterMs = 30_000, force = false, disabled = false } = opts; | ||
| const isInteractive = process.stdout.isTTY === true && | ||
| process.stdin.isTTY === true && | ||
| !process.env["CI"]; | ||
| if (disabled || !isInteractive) { | ||
| return task; | ||
| } | ||
| if (force) { | ||
| const { playWhileWaiting } = await import("./space-invaders.js"); | ||
| return playWhileWaiting(task); | ||
| } | ||
| // Race the task against the prompt delay. | ||
| let timerId; | ||
| const delayPromise = new Promise(resolve => { | ||
| timerId = setTimeout(resolve, promptAfterMs); | ||
| }); | ||
| const outcome = await Promise.race([ | ||
| task.then((v) => ({ done: true, value: v })), | ||
| delayPromise.then(() => ({ done: false })) | ||
| ]); | ||
| if (outcome.done) { | ||
| clearTimeout(timerId); | ||
| return outcome.value; | ||
| } | ||
| // Task is still running โ prompt once. | ||
| const accepted = await promptOnce(); | ||
| if (accepted) { | ||
| const { playWhileWaiting } = await import("./space-invaders.js"); | ||
| return playWhileWaiting(task); | ||
| } | ||
| return task; | ||
| } | ||
| /** | ||
| * Prompt the user once for the arcade. Returns true if they pressed y/Y. | ||
| * Times out after 15 s and returns false. Restores terminal state on exit. | ||
| */ | ||
| async function promptOnce() { | ||
| return new Promise(resolve => { | ||
| const stdin = process.stdin; | ||
| if (!stdin.isTTY) { | ||
| resolve(false); | ||
| return; | ||
| } | ||
| const prevRaw = stdin.isRaw; | ||
| const cleanup = (answer) => { | ||
| clearTimeout(autoNo); | ||
| stdin.removeListener("data", onKey); | ||
| try { | ||
| stdin.setRawMode(prevRaw ?? false); | ||
| } | ||
| catch { /* ignore */ } | ||
| stdin.pause(); | ||
| resolve(answer); | ||
| }; | ||
| // Auto-decline after 15 s so unattended runs are never blocked. | ||
| const autoNo = setTimeout(() => { | ||
| process.stdout.write("n\n"); | ||
| cleanup(false); | ||
| }, 15_000); | ||
| try { | ||
| stdin.setRawMode(true); | ||
| } | ||
| catch { | ||
| // setRawMode can fail in certain environments โ fall back to no prompt. | ||
| clearTimeout(autoNo); | ||
| resolve(false); | ||
| return; | ||
| } | ||
| stdin.resume(); | ||
| stdin.setEncoding("utf-8"); | ||
| process.stdout.write("\n\x1b[36mStill working.\x1b[0m " + | ||
| "Play \x1b[1mMartinLoop Arcade\x1b[0m while you wait? " + | ||
| "[\x1b[32my\x1b[0m/\x1b[2mN\x1b[0m] "); | ||
| const onKey = (key) => { | ||
| // Ctrl+C โ honour it even mid-prompt. | ||
| if (key === "\u0003") { | ||
| process.stdout.write("\n"); | ||
| cleanup(false); | ||
| process.exit(130); | ||
| } | ||
| const accepted = key === "y" || key === "Y"; | ||
| process.stdout.write(accepted ? "y\n" : "n\n"); | ||
| cleanup(accepted); | ||
| }; | ||
| stdin.on("data", onKey); | ||
| }); | ||
| } | ||
| //# sourceMappingURL=index.js.map |
| /** | ||
| * MartinLoop Arcade โ Space Invaders | ||
| * | ||
| * A terminal Space Invaders game that runs while a background task is in | ||
| * flight. Resolves with the task result when the task completes and the | ||
| * user exits, or immediately falls through if the terminal is not suitable. | ||
| * | ||
| * Zero external dependencies. Node 18+ required. | ||
| * | ||
| * Controls | ||
| * โ โ / A D move | ||
| * Space / โ / Z fire | ||
| * P pause / unpause | ||
| * R restart (game over screen only) | ||
| * Q quit | ||
| */ | ||
| export interface ArcadeOptions { | ||
| /** | ||
| * Short label shown in the run-complete overlay beneath the โ header. | ||
| * E.g. "verified ยท 1 attempt ยท $0.18 spent" | ||
| */ | ||
| runResultLabel?: string; | ||
| } | ||
| /** | ||
| * Play a Space Invaders game in the terminal while `task` runs in the | ||
| * background. Returns the same value `task` resolves with. | ||
| * | ||
| * Gracefully skips the game (returns `task` directly) when: | ||
| * - stdout / stdin are not interactive TTYs | ||
| * - CI environment variable is set | ||
| * - Terminal is too small (< 50 cols or < 18 rows) | ||
| */ | ||
| export declare function playWhileWaiting<T>(task: Promise<T>, opts?: ArcadeOptions): Promise<T>; |
| /** | ||
| * MartinLoop Arcade โ Space Invaders | ||
| * | ||
| * A terminal Space Invaders game that runs while a background task is in | ||
| * flight. Resolves with the task result when the task completes and the | ||
| * user exits, or immediately falls through if the terminal is not suitable. | ||
| * | ||
| * Zero external dependencies. Node 18+ required. | ||
| * | ||
| * Controls | ||
| * โ โ / A D move | ||
| * Space / โ / Z fire | ||
| * P pause / unpause | ||
| * R restart (game over screen only) | ||
| * Q quit | ||
| */ | ||
| import * as readline from "node:readline"; | ||
| // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| // ANSI helpers | ||
| // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| const ESC = "\x1b"; | ||
| const ansi = { | ||
| altOn: `${ESC}[?1049h`, | ||
| altOff: `${ESC}[?1049l`, | ||
| hide: `${ESC}[?25l`, | ||
| show: `${ESC}[?25h`, | ||
| clear: `${ESC}[2J`, | ||
| reset: `${ESC}[0m`, | ||
| at: (row, col) => `${ESC}[${row};${col}H`, | ||
| rgb: (r, g, b) => `${ESC}[38;2;${r};${g};${b}m`, | ||
| }; | ||
| // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| // Palette | ||
| // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| const P = { | ||
| border: ansi.rgb(55, 155, 255), | ||
| player: ansi.rgb(84, 255, 198), | ||
| playerWarn: ansi.rgb(255, 240, 100), | ||
| bullet: ansi.rgb(255, 255, 255), | ||
| enemyBullet: ansi.rgb(255, 180, 50), | ||
| saucer: ansi.rgb(255, 70, 190), | ||
| score: ansi.rgb(0, 255, 214), | ||
| ui: ansi.rgb(160, 220, 255), | ||
| accent: ansi.rgb(120, 255, 255), | ||
| danger: ansi.rgb(255, 90, 90), | ||
| // 4-tuple โ indexed by enemy row 0-3, always valid when row: EnemyRow | ||
| enemy: [ | ||
| ansi.rgb(255, 120, 120), | ||
| ansi.rgb(255, 190, 85), | ||
| ansi.rgb(255, 90, 230), | ||
| ansi.rgb(255, 70, 90), | ||
| ], | ||
| // 3-tuple โ indexed by 0/1/2 (low/mid/full hp), always valid | ||
| shield: [ | ||
| ansi.rgb(255, 120, 120), | ||
| ansi.rgb(255, 216, 76), | ||
| ansi.rgb(98, 255, 109), | ||
| ], | ||
| starDim: ansi.rgb(70, 110, 165), | ||
| starMid: ansi.rgb(120, 195, 255), | ||
| starBright: ansi.rgb(255, 255, 255), | ||
| }; | ||
| // 4-tuple of 2-tuples โ indexed by EnemyRow then EnemyFrame, never undefined. | ||
| const ENEMY_SPRITES = [ | ||
| ["[-^-]", "[^_^]"], // row 0 โ Token Spender (30 pts) | ||
| ["<o_o>", "<O_O>"], // row 1 โ CPU Hog (20 pts) | ||
| ["{x_x}", "{X_X}"], // row 2 โ API Caller (15 pts) | ||
| ["/vvv\\", "\\vvv/"], // row 3 โ Budget Drain (10 pts) | ||
| ]; | ||
| // 4-tuple โ indexed by EnemyRow, never undefined. | ||
| const ENEMY_PTS = [30, 20, 15, 10]; | ||
| const SHIELD_ROWS = [" ##### ", "#######", "## ##"]; | ||
| const PLAYER_SPRITE = "<[โ]>"; | ||
| const SAUCER_SPRITE = "ยซ$RUNยป"; | ||
| const PLAYER_W = PLAYER_SPRITE.length; // 5 | ||
| const ENEMY_W = 5; | ||
| const SAUCER_W = SAUCER_SPRITE.length; // 6 | ||
| const ENEMY_COLS = 10; | ||
| const ENEMY_ROWS = 4; | ||
| // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| // RNG | ||
| // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| const rng = (lo, hi) => Math.floor(Math.random() * (hi - lo)) + lo; | ||
| const rngf = () => Math.random(); | ||
| // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| // State factory | ||
| // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| function makeStar(s) { | ||
| const depth = rng(1, 4); | ||
| return { | ||
| x: rng(s.left + 1, s.right), | ||
| y: rng(s.top + 1, s.bottom), | ||
| speed: 1.2 + depth * 2.2, | ||
| glyph: depth === 1 ? "." : depth === 2 ? "*" : "+", | ||
| color: depth === 1 ? P.starDim : depth === 2 ? P.starMid : P.starBright, | ||
| }; | ||
| } | ||
| function initStars(s) { | ||
| const n = Math.max(30, Math.floor(s.cols / 2)); | ||
| s.stars = Array.from({ length: n }, () => makeStar(s)); | ||
| } | ||
| function initShields(s) { | ||
| s.shields = []; | ||
| const count = 4; | ||
| const shieldW = SHIELD_ROWS[0].length; // 7 | ||
| const totalW = count * shieldW + (count - 1) * 3; | ||
| const startX = s.left + Math.floor((s.cols - totalW) / 2); | ||
| const startY = s.playerRow - 4; | ||
| for (let si = 0; si < count; si++) { | ||
| const bx = startX + si * (shieldW + 3); | ||
| for (let r = 0; r < SHIELD_ROWS.length; r++) { | ||
| const shieldRow = SHIELD_ROWS[r]; | ||
| for (let c = 0; c < shieldRow.length; c++) { | ||
| if (shieldRow.charAt(c) === "#") { | ||
| s.shields.push({ x: bx + c, y: startY + r, hp: 3 }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| function initWave(s) { | ||
| s.enemies = []; | ||
| const gap = 3; | ||
| const totalW = ENEMY_COLS * ENEMY_W + (ENEMY_COLS - 1) * gap; | ||
| const startX = s.left + Math.floor((s.cols - totalW) / 2); | ||
| const startY = s.top + 3; | ||
| for (let row = 0; row < ENEMY_ROWS; row++) { | ||
| for (let col = 0; col < ENEMY_COLS; col++) { | ||
| s.enemies.push({ | ||
| x: startX + col * (ENEMY_W + gap), | ||
| y: startY + row * 2, | ||
| row: row, | ||
| alive: true, | ||
| }); | ||
| } | ||
| } | ||
| s.eDx = 1; | ||
| s.eMoveTimer = Math.max(0.08, 0.38 - (s.level - 1) * 0.04); | ||
| s.eFireTimer = Math.max(0.40, 0.90 - (s.level - 1) * 0.05); | ||
| s.eFrame = 0; | ||
| } | ||
| function createState(cols, rows) { | ||
| const left = 1, right = cols - 2, top = 2, bottom = rows - 2; | ||
| const s = { | ||
| cols, rows, left, right, top, bottom, | ||
| playerRow: rows - 4, | ||
| px: Math.floor((cols - PLAYER_W) / 2), | ||
| lives: 3, cooldown: 0, invuln: 0, | ||
| enemies: [], eDx: 1, eMoveTimer: 0.38, eFireTimer: 0.9, eFrame: 0, | ||
| pBullets: [], eBullets: [], particles: [], stars: [], shields: [], | ||
| saucer: null, nextSaucer: 12 + rngf() * 10, | ||
| score: 0, level: 1, ticks: 0, | ||
| status: "playing", waveClearTimer: 0, | ||
| runDone: false, runLabel: "", | ||
| }; | ||
| initStars(s); | ||
| initShields(s); | ||
| initWave(s); | ||
| return s; | ||
| } | ||
| // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| // Physics | ||
| // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| function explode(s, x, y, color, n = 10) { | ||
| const glyphs = [".", "*", "+", "ยท", "ร"]; | ||
| for (let i = 0; i < n; i++) { | ||
| s.particles.push({ | ||
| x, y, | ||
| vx: (rngf() * 2 - 1) * 16, | ||
| vy: (rngf() * 2 - 1) * 8, | ||
| life: 0.5 + rngf() * 0.5, | ||
| maxLife: 1.0, | ||
| glyph: glyphs[rng(0, glyphs.length)], | ||
| color, | ||
| }); | ||
| } | ||
| } | ||
| function tickShieldAt(s, x, y) { | ||
| for (let i = s.shields.length - 1; i >= 0; i--) { | ||
| const sh = s.shields[i]; | ||
| if (sh === undefined) | ||
| continue; // loop bounds guarantee this never fires | ||
| if (sh.x === x && sh.y === y) { | ||
| sh.hp--; | ||
| if (sh.hp <= 0) | ||
| s.shields.splice(i, 1); | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
| /** Returns the lowest-row enemy for each x column (the ones that can shoot). */ | ||
| function frontlineEnemies(alive) { | ||
| const front = new Map(); | ||
| for (const e of alive) { | ||
| const cur = front.get(e.x); | ||
| if (!cur || e.y > cur.y) | ||
| front.set(e.x, e); | ||
| } | ||
| return [...front.values()]; | ||
| } | ||
| function updateParticles(s, dt) { | ||
| for (const p of s.particles) { | ||
| p.x += p.vx * dt; | ||
| p.y += p.vy * dt; | ||
| p.life -= dt; | ||
| } | ||
| s.particles = s.particles.filter(p => p.life > 0); | ||
| } | ||
| function updateStars(s, dt) { | ||
| for (const st of s.stars) { | ||
| st.y += st.speed * dt; | ||
| if (st.y >= s.bottom) | ||
| Object.assign(st, makeStar(s)); | ||
| } | ||
| } | ||
| function updateEnemies(s, dt) { | ||
| const alive = s.enemies.filter(e => e.alive); | ||
| if (alive.length === 0) { | ||
| s.status = "wave_clear"; | ||
| s.waveClearTimer = 2.5; | ||
| return; | ||
| } | ||
| // movement tick | ||
| s.eMoveTimer -= dt; | ||
| if (s.eMoveTimer <= 0) { | ||
| const base = Math.max(0.06, 0.38 - (s.level - 1) * 0.04); | ||
| const density = alive.length / (ENEMY_COLS * ENEMY_ROWS); | ||
| s.eMoveTimer = base / (1 + (1 - density) * 1.2); | ||
| s.eFrame = s.eFrame === 0 ? 1 : 0; | ||
| const lx = Math.min(...alive.map(e => e.x)); | ||
| const rx = Math.max(...alive.map(e => e.x + ENEMY_W)); | ||
| let drop = false; | ||
| if (s.eDx > 0 && rx >= s.right - 1) { | ||
| s.eDx = -1; | ||
| drop = true; | ||
| } | ||
| if (s.eDx < 0 && lx <= s.left + 1) { | ||
| s.eDx = 1; | ||
| drop = true; | ||
| } | ||
| for (const e of s.enemies) { | ||
| if (!e.alive) | ||
| continue; | ||
| if (drop) | ||
| e.y += 1; | ||
| else | ||
| e.x += s.eDx; | ||
| if (e.y >= s.playerRow) { | ||
| s.status = "game_over"; | ||
| return; | ||
| } | ||
| } | ||
| } | ||
| // fire tick | ||
| s.eFireTimer -= dt; | ||
| if (s.eFireTimer <= 0) { | ||
| s.eFireTimer = Math.max(0.35, 0.9 - (s.level - 1) * 0.05); | ||
| const shooters = frontlineEnemies(alive); | ||
| if (shooters.length > 0) { | ||
| const sh = shooters[rng(0, shooters.length)]; | ||
| if (sh !== undefined) { | ||
| s.eBullets.push({ x: sh.x + 2, y: sh.y + 1, vy: 13 + Math.min(s.level, 6) * 0.8 }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| function updateSaucer(s, dt) { | ||
| if (!s.saucer) { | ||
| if (s.ticks < s.nextSaucer) | ||
| return; | ||
| const dir = rngf() > 0.5 ? 1 : -1; | ||
| s.saucer = { | ||
| x: dir === 1 ? s.left - SAUCER_W : s.right + 1, | ||
| y: s.top + 1, | ||
| vx: 11 * dir, | ||
| }; | ||
| s.nextSaucer = s.ticks + 14 + rngf() * 12; | ||
| return; | ||
| } | ||
| s.saucer.x += s.saucer.vx * dt; | ||
| if ((s.saucer.vx > 0 && s.saucer.x > s.right + SAUCER_W) || | ||
| (s.saucer.vx < 0 && s.saucer.x < s.left - SAUCER_W * 2)) { | ||
| s.saucer = null; | ||
| } | ||
| } | ||
| function updatePlayerBullets(s, dt) { | ||
| for (let i = s.pBullets.length - 1; i >= 0; i--) { | ||
| const b = s.pBullets[i]; | ||
| if (b === undefined) | ||
| continue; // loop bounds guarantee this never fires | ||
| b.y += b.vy * dt; | ||
| const bx = Math.round(b.x), by = Math.round(b.y); | ||
| if (by <= s.top) { | ||
| s.pBullets.splice(i, 1); | ||
| continue; | ||
| } | ||
| // saucer hit | ||
| if (s.saucer) { | ||
| const sx = Math.floor(s.saucer.x); | ||
| if (bx >= sx && bx < sx + SAUCER_W && by === Math.round(s.saucer.y)) { | ||
| s.score += 150; | ||
| explode(s, s.saucer.x + 3, s.saucer.y, P.saucer, 14); | ||
| s.saucer = null; | ||
| s.pBullets.splice(i, 1); | ||
| continue; | ||
| } | ||
| } | ||
| // enemy hit | ||
| let hit = false; | ||
| for (const e of s.enemies) { | ||
| if (!e.alive) | ||
| continue; | ||
| if (bx >= e.x && bx < e.x + ENEMY_W && by === e.y) { | ||
| e.alive = false; | ||
| s.score += ENEMY_PTS[e.row]; | ||
| explode(s, e.x + 2, e.y, P.enemy[e.row], 10); | ||
| s.pBullets.splice(i, 1); | ||
| hit = true; | ||
| break; | ||
| } | ||
| } | ||
| if (hit) | ||
| continue; | ||
| // shield hit | ||
| if (tickShieldAt(s, bx, by)) { | ||
| s.pBullets.splice(i, 1); | ||
| } | ||
| } | ||
| } | ||
| function updateEnemyBullets(s, dt) { | ||
| for (let i = s.eBullets.length - 1; i >= 0; i--) { | ||
| const b = s.eBullets[i]; | ||
| if (b === undefined) | ||
| continue; // loop bounds guarantee this never fires | ||
| b.y += b.vy * dt; | ||
| const bx = Math.round(b.x), by = Math.round(b.y); | ||
| if (by >= s.bottom) { | ||
| s.eBullets.splice(i, 1); | ||
| continue; | ||
| } | ||
| // player hit | ||
| if (s.invuln <= 0 && | ||
| bx >= s.px && bx < s.px + PLAYER_W && | ||
| by === s.playerRow) { | ||
| s.lives--; | ||
| explode(s, s.px + 2, s.playerRow, P.player, 16); | ||
| if (s.lives <= 0) { | ||
| s.status = "game_over"; | ||
| } | ||
| else { | ||
| s.invuln = 2.5; | ||
| s.px = Math.floor((s.cols - PLAYER_W) / 2); | ||
| } | ||
| s.eBullets.splice(i, 1); | ||
| continue; | ||
| } | ||
| if (tickShieldAt(s, bx, by)) { | ||
| s.eBullets.splice(i, 1); | ||
| } | ||
| } | ||
| } | ||
| function update(s, dt, keys) { | ||
| s.ticks += dt; | ||
| updateParticles(s, dt); | ||
| updateStars(s, dt); | ||
| if (s.status === "wave_clear") { | ||
| s.waveClearTimer -= dt; | ||
| if (s.waveClearTimer <= 0) { | ||
| s.level++; | ||
| initShields(s); | ||
| initWave(s); | ||
| s.pBullets = []; | ||
| s.eBullets = []; | ||
| s.status = "playing"; | ||
| } | ||
| return; | ||
| } | ||
| if (s.status === "game_over") { | ||
| if (keys.restart) { | ||
| const fresh = createState(s.cols, s.rows); | ||
| // preserve run state so a completed run stays completed on restart | ||
| fresh.runDone = s.runDone; | ||
| fresh.runLabel = s.runLabel; | ||
| if (s.runDone) | ||
| fresh.status = "run_complete"; | ||
| Object.assign(s, fresh); | ||
| } | ||
| return; | ||
| } | ||
| if (s.status === "run_complete") | ||
| return; | ||
| if (s.status === "paused") { | ||
| if (keys.pause) | ||
| s.status = "playing"; | ||
| return; | ||
| } | ||
| // playing | ||
| if (keys.pause) { | ||
| s.status = "paused"; | ||
| return; | ||
| } | ||
| if (keys.left) | ||
| s.px = Math.max(s.left, s.px - 2); | ||
| if (keys.right) | ||
| s.px = Math.min(s.right - PLAYER_W, s.px + 2); | ||
| if (keys.fire && s.cooldown <= 0) { | ||
| const cx = s.px + 2; | ||
| const offsets = s.level >= 4 ? [-1, 1] : [0]; | ||
| for (const off of offsets) { | ||
| s.pBullets.push({ x: cx + off, y: s.playerRow - 1, vy: -28 }); | ||
| } | ||
| s.cooldown = s.level >= 5 ? 0.18 : 0.26; | ||
| } | ||
| if (s.cooldown > 0) | ||
| s.cooldown -= dt; | ||
| if (s.invuln > 0) | ||
| s.invuln -= dt; | ||
| updateEnemies(s, dt); | ||
| updateSaucer(s, dt); | ||
| updatePlayerBullets(s, dt); | ||
| updateEnemyBullets(s, dt); | ||
| } | ||
| class FrameBuffer { | ||
| W; | ||
| H; | ||
| // Flat arrays (W*H) โ eliminate double-indexing and its noUncheckedIndexedAccess issues. | ||
| curr; | ||
| prev; | ||
| constructor(W, H) { | ||
| this.W = W; | ||
| this.H = H; | ||
| const blank = () => ({ ch: " ", color: "" }); | ||
| this.curr = Array.from({ length: W * H }, blank); | ||
| this.prev = Array.from({ length: W * H }, blank); | ||
| } | ||
| idx(col, row) { return row * this.W + col; } | ||
| clear() { | ||
| for (let i = 0; i < this.curr.length; i++) { | ||
| this.curr[i] = { ch: " ", color: "" }; | ||
| } | ||
| } | ||
| put(col, row, ch, color = "") { | ||
| if (col < 0 || col >= this.W || row < 0 || row >= this.H) | ||
| return; | ||
| this.curr[this.idx(col, row)] = { ch, color }; | ||
| } | ||
| str(col, row, text, color = "") { | ||
| for (let i = 0; i < text.length; i++) { | ||
| this.put(col + i, row, text.charAt(i), color); | ||
| } | ||
| } | ||
| /** Centre a string horizontally. */ | ||
| center(row, text, color = "") { | ||
| this.str(Math.floor((this.W - text.length) / 2), row, text, color); | ||
| } | ||
| flush() { | ||
| let out = ""; | ||
| let lastColor = ""; | ||
| for (let r = 0; r < this.H; r++) { | ||
| for (let c = 0; c < this.W; c++) { | ||
| const i = this.idx(c, r); | ||
| const cell = this.curr[i]; | ||
| const prev = this.prev[i]; | ||
| if (cell === undefined || prev === undefined) | ||
| continue; | ||
| if (cell.ch === prev.ch && cell.color === prev.color) | ||
| continue; | ||
| out += ansi.at(r + 1, c + 1); | ||
| if (cell.color !== lastColor) { | ||
| out += cell.color || ansi.reset; | ||
| lastColor = cell.color; | ||
| } | ||
| out += cell.ch; | ||
| this.prev[i] = { ...cell }; | ||
| } | ||
| } | ||
| if (lastColor) | ||
| out += ansi.reset; | ||
| if (out) { | ||
| try { | ||
| process.stdout.write(out); | ||
| } | ||
| catch { /* pipe closed */ } | ||
| } | ||
| } | ||
| } | ||
| // Shield hp is 1-3. Returns palette index 0=red(low) / 1=yellow(mid) / 2=green(full). | ||
| function shieldColor(hp) { | ||
| const idx = (hp - 1); | ||
| return P.shield[idx]; | ||
| } | ||
| function draw(fb, s) { | ||
| fb.clear(); | ||
| const { cols, rows, left, right, top, bottom, playerRow } = s; | ||
| // โโ border โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| for (let c = left; c <= right; c++) { | ||
| fb.put(c, top, "โ", P.border); | ||
| fb.put(c, bottom, "โ", P.border); | ||
| } | ||
| for (let r = top; r <= bottom; r++) { | ||
| fb.put(left, r, "โ", P.border); | ||
| fb.put(right, r, "โ", P.border); | ||
| } | ||
| fb.put(left, top, "โญ", P.border); | ||
| fb.put(right, top, "โฎ", P.border); | ||
| fb.put(left, bottom, "โฐ", P.border); | ||
| fb.put(right, bottom, "โฏ", P.border); | ||
| // โโ HUD (row 0, above the border) โโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| fb.str(left + 2, 0, `BUDGET PROTECTED: $${s.score}`, P.score); | ||
| fb.center(0, `WAVE ${s.level}`, P.ui); | ||
| const livesStr = `โ ร ${s.lives}`; | ||
| fb.str(right - livesStr.length - 1, 0, livesStr, s.lives === 1 ? P.danger : P.player); | ||
| // โโ controls hint (bottom status bar) โโโโโโโโโโโโโโโโโโโโโ | ||
| const hint = " โโ move SPC fire P pause Q quit "; | ||
| fb.str(left + 1, rows - 1, hint, P.ui); | ||
| // โโ stars โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| for (const st of s.stars) { | ||
| fb.put(Math.floor(st.x), Math.floor(st.y), st.glyph, st.color); | ||
| } | ||
| // โโ shields โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| for (const sh of s.shields) { | ||
| fb.put(sh.x, sh.y, "#", shieldColor(sh.hp)); | ||
| } | ||
| // โโ saucer โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| if (s.saucer) { | ||
| fb.str(Math.floor(s.saucer.x), Math.floor(s.saucer.y), SAUCER_SPRITE, P.saucer); | ||
| } | ||
| // โโ enemies โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| for (const e of s.enemies) { | ||
| if (!e.alive) | ||
| continue; | ||
| fb.str(e.x, e.y, ENEMY_SPRITES[e.row][s.eFrame], P.enemy[e.row]); | ||
| } | ||
| // โโ player โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| if (s.lives > 0) { | ||
| const blink = s.invuln > 0 && Math.floor(s.ticks * 8) % 2 === 0; | ||
| if (!blink) { | ||
| fb.str(s.px, playerRow, PLAYER_SPRITE, s.lives === 1 ? P.playerWarn : P.player); | ||
| } | ||
| } | ||
| // โโ bullets โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| for (const b of s.pBullets) | ||
| fb.put(Math.round(b.x), Math.round(b.y), "โ", P.bullet); | ||
| for (const b of s.eBullets) | ||
| fb.put(Math.round(b.x), Math.round(b.y), "ยฆ", P.enemyBullet); | ||
| // โโ particles โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| for (const p of s.particles) { | ||
| if (p.life / p.maxLife < 0.08) | ||
| continue; | ||
| fb.put(Math.round(p.x), Math.round(p.y), p.glyph, p.color); | ||
| } | ||
| // โโ overlays โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| const cy = Math.floor(rows / 2); | ||
| if (s.status === "wave_clear") { | ||
| fb.center(cy - 1, ` WAVE ${s.level} CLEARED `, P.accent); | ||
| fb.center(cy, " agents governed. ", P.ui); | ||
| } | ||
| if (s.status === "paused") { | ||
| fb.center(cy, " PAUSED โ P to continue ", P.ui); | ||
| } | ||
| if (s.status === "game_over") { | ||
| fb.center(cy - 1, " GOVERNANCE FAILED ", P.danger); | ||
| fb.center(cy, ` budget protected: $${s.score} `, P.ui); | ||
| fb.center(cy + 1, " R restart ยท Q quit ", P.ui); | ||
| } | ||
| if (s.status === "run_complete") { | ||
| fb.center(cy - 1, " โ RUN COMPLETE โ ", P.player); | ||
| fb.center(cy, ` ${s.runLabel} `, P.accent); | ||
| fb.center(cy + 1, ` session: $${s.score} governed `, P.ui); | ||
| fb.center(cy + 2, " Q to exit ", P.ui); | ||
| } | ||
| fb.flush(); | ||
| } | ||
| // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| // Terminal lifecycle helpers | ||
| // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| function enterArcadeMode() { | ||
| process.stdout.write(ansi.altOn + ansi.hide + ansi.clear); | ||
| } | ||
| function leaveArcadeMode() { | ||
| try { | ||
| process.stdout.write(ansi.reset + ansi.show + ansi.altOff); | ||
| } | ||
| catch { /* stdout may already be closed */ } | ||
| } | ||
| /** | ||
| * Play a Space Invaders game in the terminal while `task` runs in the | ||
| * background. Returns the same value `task` resolves with. | ||
| * | ||
| * Gracefully skips the game (returns `task` directly) when: | ||
| * - stdout / stdin are not interactive TTYs | ||
| * - CI environment variable is set | ||
| * - Terminal is too small (< 50 cols or < 18 rows) | ||
| */ | ||
| export function playWhileWaiting(task, opts = {}) { | ||
| const cols = process.stdout.columns ?? 0; | ||
| const rows = process.stdout.rows ?? 0; | ||
| if (!process.stdout.isTTY || | ||
| !process.stdin.isTTY || | ||
| process.env["CI"] || | ||
| cols < 50 || | ||
| rows < 18) { | ||
| return task; | ||
| } | ||
| return new Promise((resolve, reject) => { | ||
| const gameCols = Math.min(cols, 100); | ||
| const gameRows = Math.min(rows, 32); | ||
| const state = createState(gameCols, gameRows); | ||
| const fb = new FrameBuffer(gameCols, gameRows); | ||
| // โโ key state โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| // Track last-event timestamp per key. A key is considered "held" when | ||
| // the most recent keypress event for it occurred within HOLD_MS. | ||
| // The OS emits repeat keypress events while a key is held, refreshing | ||
| // the timestamp continuously โ no polling needed. | ||
| const held = {}; | ||
| const HOLD_MS = 140; | ||
| // One-shot flags cleared after a single game-loop tick consumes them. | ||
| let wantPause = false; | ||
| let wantRestart = false; | ||
| let wantQuit = false; | ||
| readline.emitKeypressEvents(process.stdin); | ||
| const wasRaw = process.stdin.isRaw ?? false; | ||
| process.stdin.setRawMode(true); | ||
| process.stdin.resume(); | ||
| function onKeypress(_ch, key) { | ||
| if (!key) | ||
| return; | ||
| const { name = "", ctrl = false } = key; | ||
| if (ctrl && name === "c") { | ||
| cleanup(); | ||
| process.exit(130); | ||
| } | ||
| if (["left", "a"].includes(name)) | ||
| held["left"] = Date.now(); | ||
| if (["right", "d"].includes(name)) | ||
| held["right"] = Date.now(); | ||
| if (["space", "up", "w", "z", "x"].includes(name)) | ||
| held["fire"] = Date.now(); | ||
| if (name === "p") | ||
| wantPause = true; | ||
| if (name === "r") | ||
| wantRestart = true; | ||
| if (name === "q") | ||
| wantQuit = true; | ||
| } | ||
| process.stdin.on("keypress", onKeypress); | ||
| // โโ task tracking โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| let taskResult; | ||
| let taskError; | ||
| let taskDone = false; | ||
| task | ||
| .then(v => { taskResult = v; taskDone = true; }) | ||
| .catch(e => { taskError = e; taskDone = true; }); | ||
| // โโ cleanup โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| let cleanedUp = false; | ||
| function cleanup() { | ||
| if (cleanedUp) | ||
| return; | ||
| cleanedUp = true; | ||
| clearInterval(loop); | ||
| process.stdin.removeListener("keypress", onKeypress); | ||
| try { | ||
| if (!wasRaw) | ||
| process.stdin.setRawMode(false); | ||
| } | ||
| catch { /* already cleaned up */ } | ||
| process.stdin.pause(); | ||
| leaveArcadeMode(); | ||
| } | ||
| function finish() { | ||
| cleanup(); | ||
| if (taskDone) { | ||
| taskError !== undefined ? reject(taskError) : resolve(taskResult); | ||
| } | ||
| else { | ||
| // Task still in flight โ wait for it without the game UI | ||
| task.then(resolve).catch(reject); | ||
| } | ||
| } | ||
| // โโ game loop โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | ||
| enterArcadeMode(); | ||
| const FRAME_MS = 1000 / 30; | ||
| let lastTick = Date.now(); | ||
| const loop = setInterval(() => { | ||
| const now = Date.now(); | ||
| const dt = Math.min((now - lastTick) / 1000, 0.1); // cap to avoid spiral-of-death | ||
| lastTick = now; | ||
| const keys = { | ||
| left: (held["left"] ?? 0) > now - HOLD_MS, | ||
| right: (held["right"] ?? 0) > now - HOLD_MS, | ||
| fire: (held["fire"] ?? 0) > now - HOLD_MS, | ||
| pause: wantPause, | ||
| restart: wantRestart, | ||
| quit: wantQuit, | ||
| }; | ||
| wantPause = wantRestart = wantQuit = false; | ||
| // Notify game when the background task completes | ||
| if (taskDone && !state.runDone) { | ||
| state.runDone = true; | ||
| state.runLabel = opts.runResultLabel ?? "run complete."; | ||
| state.status = "run_complete"; | ||
| explode(state, Math.floor(gameCols / 2), Math.floor(gameRows / 2), P.player, 20); | ||
| } | ||
| if (keys.quit) { | ||
| finish(); | ||
| return; | ||
| } | ||
| update(state, dt, keys); | ||
| draw(fb, state); | ||
| }, FRAME_MS); | ||
| }); | ||
| } | ||
| //# sourceMappingURL=space-invaders.js.map |
@@ -14,3 +14,3 @@ /** | ||
| */ | ||
| import type { MartinAdapter } from "../core/index.js"; | ||
| import type { MartinAdapter, MartinAdapterRequest } from "../core/index.js"; | ||
| import { type SpawnLike } from "./cli-bridge.js"; | ||
@@ -22,3 +22,3 @@ /** | ||
| */ | ||
| export type CliArgsBuilder = (prompt: string) => string[]; | ||
| export type CliArgsBuilder = (prompt: string, request: MartinAdapterRequest) => string[]; | ||
| export type CliStdinBuilder = (prompt: string) => string | undefined; | ||
@@ -25,0 +25,0 @@ export interface AgentCliAdapterOptions { |
@@ -20,18 +20,14 @@ /** | ||
| // | ||
| // Token costs are estimated using a blended average across top models: | ||
| // Anthropic Sonnet, OpenAI GPT-4o Mini, Gemini Flash, Meta Llama 3. | ||
| // Override at runtime with --input-cost-per-1k / --output-cost-per-1k CLI | ||
| // flags or martin.config.yaml pricing section. | ||
| // Streaming enforcement needs a model-specific estimate until Claude emits | ||
| // authoritative total_cost_usd on the final result event. Unknown Claude | ||
| // models are never assigned another model's price. | ||
| // --------------------------------------------------------------------------- | ||
| const BLENDED_INPUT_COST_PER_1K = 0.003; // $/1K input tokens | ||
| const BLENDED_OUTPUT_COST_PER_1K = 0.012; // $/1K output tokens | ||
| // Per-model overrides for common Claude models (fallback: blended average) | ||
| // USD per 1K tokens. Claude cache creation uses the documented default | ||
| // five-minute rate; cache reads are priced independently from fresh input. | ||
| const MODEL_PRICING = { | ||
| "claude-opus-4-6": { inputPer1K: 0.015, outputPer1K: 0.075 }, | ||
| "claude-sonnet-4-6": { inputPer1K: 0.003, outputPer1K: 0.015 }, | ||
| "claude-haiku-4-5": { inputPer1K: 0.00025, outputPer1K: 0.00125 }, | ||
| // Keep legacy names working | ||
| "claude-opus": { inputPer1K: 0.015, outputPer1K: 0.075 }, | ||
| "claude-sonnet": { inputPer1K: 0.003, outputPer1K: 0.015 }, | ||
| "claude-haiku": { inputPer1K: 0.00025, outputPer1K: 0.00125 }, | ||
| "claude-opus-4-6": { inputPer1K: 0.005, cachedInputPer1K: 0.0005, cacheCreationInputPer1K: 0.00625, outputPer1K: 0.025 }, | ||
| "claude-sonnet-4-6": { inputPer1K: 0.003, cachedInputPer1K: 0.0003, cacheCreationInputPer1K: 0.00375, outputPer1K: 0.015 }, | ||
| "claude-haiku-4-5": { inputPer1K: 0.001, cachedInputPer1K: 0.0001, cacheCreationInputPer1K: 0.00125, outputPer1K: 0.005 }, | ||
| // OpenAI coding models | ||
@@ -45,2 +41,33 @@ "codex": { inputPer1K: 0.00125, cachedInputPer1K: 0.000125, outputPer1K: 0.01 }, | ||
| }; | ||
| const CLAUDE_MODEL_ALIASES = [ | ||
| { canonicalModelId: "claude-opus-4-6", aliases: [/^claude-opus-4-6-\d{8}$/u, /^claude-opus$/u] }, | ||
| { canonicalModelId: "claude-sonnet-4-6", aliases: [/^claude-sonnet-4-6-\d{8}$/u, /^claude-sonnet$/u] }, | ||
| { canonicalModelId: "claude-haiku-4-5", aliases: [/^claude-haiku-4-5-\d{8}$/u, /^claude-haiku$/u] } | ||
| ]; | ||
| function resolveModelPricing(modelLabel) { | ||
| const normalized = modelLabel?.trim().toLowerCase(); | ||
| if (!normalized) { | ||
| return { status: "unknown" }; | ||
| } | ||
| const exact = MODEL_PRICING[normalized]; | ||
| if (exact) { | ||
| return { status: "exact", canonicalModelId: normalized, pricing: exact }; | ||
| } | ||
| for (const family of CLAUDE_MODEL_ALIASES) { | ||
| if (family.aliases.some((alias) => alias.test(normalized))) { | ||
| return { | ||
| status: "alias", | ||
| canonicalModelId: family.canonicalModelId, | ||
| pricing: MODEL_PRICING[family.canonicalModelId] | ||
| }; | ||
| } | ||
| } | ||
| return { status: "unknown" }; | ||
| } | ||
| function calculateUsageCost(usage, pricing) { | ||
| return ((usage.inputTokens / 1000) * pricing.inputPer1K + | ||
| (usage.cachedInputTokens / 1000) * (pricing.cachedInputPer1K ?? pricing.inputPer1K) + | ||
| (usage.cacheCreationInputTokens / 1000) * (pricing.cacheCreationInputPer1K ?? pricing.inputPer1K) + | ||
| (usage.outputTokens / 1000) * pricing.outputPer1K); | ||
| } | ||
| function extractUsage(parsed, modelLabel) { | ||
@@ -55,9 +82,8 @@ if (!parsed?.usage) { | ||
| } | ||
| const promptTokens = (parsed.usage.inputTokens ?? parsed.usage.input_tokens ?? 0) + | ||
| (parsed.usage.cacheCreationInputTokens ?? parsed.usage.cache_creation_input_tokens ?? 0); | ||
| const promptTokens = parsed.usage.inputTokens ?? parsed.usage.input_tokens ?? 0; | ||
| const cacheCreationInputTokens = parsed.usage.cacheCreationInputTokens ?? parsed.usage.cache_creation_input_tokens ?? 0; | ||
| const cachedInputTokens = parsed.usage.cacheReadInputTokens ?? parsed.usage.cache_read_input_tokens ?? 0; | ||
| const tokensIn = promptTokens + cachedInputTokens; | ||
| const tokensIn = promptTokens + cachedInputTokens + cacheCreationInputTokens; | ||
| const tokensOut = parsed.usage.outputTokens ?? parsed.usage.output_tokens ?? 0; | ||
| const pricing = (modelLabel ? MODEL_PRICING[modelLabel] : undefined) ?? | ||
| { inputPer1K: BLENDED_INPUT_COST_PER_1K, outputPer1K: BLENDED_OUTPUT_COST_PER_1K }; | ||
| const pricingResolution = resolveModelPricing(modelLabel); | ||
| // Prefer Claude's own authoritative total_cost_usd (present on the final | ||
@@ -69,5 +95,10 @@ // `result` event in json/stream-json output) over our pricing-table estimate, | ||
| ? parsed.total_cost_usd | ||
| : (promptTokens / 1000) * pricing.inputPer1K + | ||
| (cachedInputTokens / 1000) * (pricing.cachedInputPer1K ?? pricing.inputPer1K) + | ||
| (tokensOut / 1000) * pricing.outputPer1K; | ||
| : pricingResolution.pricing | ||
| ? calculateUsageCost({ | ||
| inputTokens: promptTokens, | ||
| cachedInputTokens, | ||
| cacheCreationInputTokens, | ||
| outputTokens: tokensOut | ||
| }, pricingResolution.pricing) | ||
| : 0; | ||
| return normalizeUsage({ | ||
@@ -78,3 +109,3 @@ actualUsd: Number(actualUsd.toFixed(6)), | ||
| cachedInputTokens, | ||
| provenance: hasAuthoritativeCost ? "actual" : "estimated", | ||
| provenance: hasAuthoritativeCost ? "actual" : pricingResolution.pricing ? "estimated" : "unavailable", | ||
| providerSettlement: { | ||
@@ -85,3 +116,3 @@ providerId: "claude", | ||
| source: "claude_json", | ||
| inputTokens: promptTokens, | ||
| inputTokens: promptTokens + cacheCreationInputTokens, | ||
| cachedInputTokens, | ||
@@ -248,16 +279,19 @@ outputTokens: tokensOut, | ||
| } | ||
| function createStreamingUsageInspector(capUsd, modelLabel) { | ||
| const pricing = (modelLabel ? MODEL_PRICING[modelLabel] : undefined) ?? | ||
| { inputPer1K: BLENDED_INPUT_COST_PER_1K, outputPer1K: BLENDED_OUTPUT_COST_PER_1K }; | ||
| function createStreamingUsageInspector(capUsd, modelLabel, promptTokenEstimate) { | ||
| let pricingResolution = resolveModelPricing(modelLabel); | ||
| // Safety margin: terminate at 80% of cap to bound one-turn overshoot. | ||
| // Without this, a single expensive turn can blow past the cap before the | ||
| // next check fires (proven live: $1.50 cap โ $28.42 actual). | ||
| const effectiveCapUsd = capUsd * 0.8; | ||
| const largeContext = promptTokenEstimate > 10_000; | ||
| const effectiveCapRatio = largeContext ? 0.7 : 0.8; | ||
| const effectiveCapUsd = capUsd * effectiveCapRatio; | ||
| // Token-count ceiling fallback: if no usage events are ever parsed (e.g. | ||
| // Claude changes its stream-json event format), use raw byte volume as a | ||
| // last-resort circuit breaker. Derived from budget / blended cost per char. | ||
| const blendedCostPerChar = (pricing.inputPer1K / 1000 / 4) + (pricing.outputPer1K / 1000 / 4); | ||
| const bytesCeiling = blendedCostPerChar > 0 | ||
| ? Math.ceil((capUsd / blendedCostPerChar) * 2) | ||
| : 100_000_000; | ||
| const resolveBlendedCostPerChar = () => { | ||
| const pricing = pricingResolution.pricing; | ||
| return pricing | ||
| ? (pricing.inputPer1K / 1000 / 4) + (pricing.outputPer1K / 1000 / 4) | ||
| : undefined; | ||
| }; | ||
| // Time-based fallback: if we receive data but no usage events for this long, | ||
@@ -279,3 +313,3 @@ // estimate spend from byte volume and enforce the cap. Prevents the inspector | ||
| terminate(`Streaming usage cap exceeded after ${String(turns)} turn(s): cumulative cost ~$${cumulativeUsd.toFixed(4)} ` + | ||
| `surpassed the per-attempt cap $${capUsd.toFixed(4)} (80% threshold: $${effectiveCapUsd.toFixed(4)}). ` + | ||
| `surpassed the per-attempt cap $${capUsd.toFixed(4)} (${String(Math.round(effectiveCapRatio * 100))}% threshold: $${effectiveCapUsd.toFixed(4)}). ` + | ||
| `Subprocess terminated to bound runaway overspend.`); | ||
@@ -285,2 +319,5 @@ } | ||
| const extractUsageFromEvent = (event, terminate) => { | ||
| if (event.type === "system" && event.subtype === "init" && typeof event.model === "string") { | ||
| pricingResolution = resolveModelPricing(event.model); | ||
| } | ||
| // Check for authoritative total_cost_usd on ANY event โ if Claude reports | ||
@@ -305,5 +342,6 @@ // cost exceeding cap, terminate immediately regardless of event type. | ||
| const usageRecord = usage; | ||
| const turnTokensIn = (usageRecord.input_tokens ?? usageRecord.inputTokens ?? 0) + | ||
| (usageRecord.cache_read_input_tokens ?? usageRecord.cacheReadInputTokens ?? 0) + | ||
| (usageRecord.cache_creation_input_tokens ?? usageRecord.cacheCreationInputTokens ?? 0); | ||
| const turnInputTokens = usageRecord.input_tokens ?? usageRecord.inputTokens ?? 0; | ||
| const turnCachedInputTokens = usageRecord.cache_read_input_tokens ?? usageRecord.cacheReadInputTokens ?? 0; | ||
| const turnCacheCreationInputTokens = usageRecord.cache_creation_input_tokens ?? usageRecord.cacheCreationInputTokens ?? 0; | ||
| const turnTokensIn = turnInputTokens + turnCachedInputTokens + turnCacheCreationInputTokens; | ||
| const turnTokensOut = usageRecord.output_tokens ?? usageRecord.outputTokens ?? 0; | ||
@@ -313,2 +351,24 @@ if (turnTokensIn === 0 && turnTokensOut === 0) { | ||
| } | ||
| const turnUsd = pricingResolution.pricing | ||
| ? calculateUsageCost({ | ||
| inputTokens: turnInputTokens, | ||
| cachedInputTokens: turnCachedInputTokens, | ||
| cacheCreationInputTokens: turnCacheCreationInputTokens, | ||
| outputTokens: turnTokensOut | ||
| }, pricingResolution.pricing) | ||
| : undefined; | ||
| const remainingBudgetBeforeTurn = Math.max(capUsd - cumulativeUsd, 0); | ||
| if (turnUsd !== undefined && | ||
| capUsd > 0 && | ||
| remainingBudgetBeforeTurn > 0 && | ||
| turnUsd > remainingBudgetBeforeTurn * 0.5) { | ||
| cumulativeUsd += turnUsd; | ||
| tokensIn += turnTokensIn; | ||
| tokensOut += turnTokensOut; | ||
| turns += 1; | ||
| usageEventSeen = true; | ||
| terminate(`Single turn spend ~$${turnUsd.toFixed(4)} consumed more than 50% of the remaining per-attempt budget ` + | ||
| `($${remainingBudgetBeforeTurn.toFixed(4)} before the turn). Subprocess terminated to prevent a one-turn overshoot.`); | ||
| return; | ||
| } | ||
| tokensIn += turnTokensIn; | ||
@@ -318,3 +378,5 @@ tokensOut += turnTokensOut; | ||
| usageEventSeen = true; | ||
| cumulativeUsd += (turnTokensIn / 1000) * pricing.inputPer1K + (turnTokensOut / 1000) * pricing.outputPer1K; | ||
| if (turnUsd !== undefined) { | ||
| cumulativeUsd += turnUsd; | ||
| } | ||
| checkBudgetExceeded(terminate); | ||
@@ -334,6 +396,10 @@ }; | ||
| } | ||
| extractUsageFromEvent(event, terminate); | ||
| // result events contain aggregate usage that duplicates previously streamed | ||
| // assistant-message usage events โ skip to avoid double-counting. | ||
| if (event.type === "result") { | ||
| finalResult = event; | ||
| } | ||
| else { | ||
| extractUsageFromEvent(event, terminate); | ||
| } | ||
| }; | ||
@@ -349,3 +415,7 @@ return { | ||
| // and the inspector is silently blind. Terminate as a last resort. | ||
| if (!usageEventSeen && capUsd > 0 && totalBytes > bytesCeiling) { | ||
| const blendedCostPerChar = resolveBlendedCostPerChar(); | ||
| const bytesCeiling = blendedCostPerChar && blendedCostPerChar > 0 | ||
| ? Math.ceil((capUsd / blendedCostPerChar) * 2) | ||
| : undefined; | ||
| if (!usageEventSeen && capUsd > 0 && bytesCeiling !== undefined && totalBytes > bytesCeiling) { | ||
| terminate(`Streaming byte ceiling exceeded (${String(totalBytes)} bytes > ${String(bytesCeiling)} ceiling) ` + | ||
@@ -365,4 +435,4 @@ `without any usage events parsed. The Claude stream-json event format may have changed. ` + | ||
| totalBytes > 10_000) { | ||
| const estimatedUsd = totalBytes * blendedCostPerChar; | ||
| if (estimatedUsd > effectiveCapUsd) { | ||
| const estimatedUsd = blendedCostPerChar === undefined ? undefined : totalBytes * blendedCostPerChar; | ||
| if (estimatedUsd !== undefined && estimatedUsd > effectiveCapUsd) { | ||
| terminate(`No usage events received after ${String(Math.round((Date.now() - firstChunkAt) / 1000))}s ` + | ||
@@ -461,7 +531,7 @@ `(${String(totalBytes)} bytes). Estimated cost ~$${estimatedUsd.toFixed(4)} exceeds cap ` + | ||
| const prompt = buildPrompt(request); | ||
| const estimatedUsage = estimateUsage(prompt, options.model ?? options.command); | ||
| const estimatedUsage = estimateUsage(prompt, options.model ?? options.command, options.command); | ||
| // Preflight: bail if projected cost exceeds remaining budget | ||
| if (request.context.remainingBudgetUsd > 0) { | ||
| const projected = estimatePromptCost(prompt, options.model ?? ""); | ||
| if (projected > request.context.remainingBudgetUsd * 0.95) { | ||
| const projected = estimatePromptCost(prompt, options.model ?? "", options.command); | ||
| if (projected !== undefined && projected > request.context.remainingBudgetUsd * 0.95) { | ||
| return { | ||
@@ -482,3 +552,3 @@ status: "failed", | ||
| } | ||
| const args = options.argsBuilder(prompt); | ||
| const args = options.argsBuilder(prompt, request); | ||
| const stdinData = options.stdinBuilder?.(prompt); | ||
@@ -492,3 +562,3 @@ // Live cumulative-cost circuit breaker: a single attempt should never be | ||
| const streamingUsage = options.streamingUsageCap && request.context.remainingBudgetUsd > 0 | ||
| ? createStreamingUsageInspector(request.context.remainingBudgetUsd, options.model ?? options.command) | ||
| ? createStreamingUsageInspector(request.context.remainingBudgetUsd, options.model ?? options.command, estimatedUsage.tokensIn) | ||
| : undefined; | ||
@@ -818,3 +888,3 @@ const agentResult = await runSubprocess(options.command, args, { | ||
| spawnImpl: options.spawnImpl, | ||
| argsBuilder: (_prompt) => [ | ||
| argsBuilder: (_prompt, request) => [ | ||
| "--output-format", | ||
@@ -1048,17 +1118,23 @@ "stream-json", | ||
| } | ||
| function estimatePromptCost(promptText, model) { | ||
| function estimatePromptCost(promptText, model, providerCommand) { | ||
| const inputTokens = Math.ceil(promptText.length / 3.5); | ||
| const outputTokens = 2000; | ||
| const pricing = MODEL_PRICING[model] ?? { inputPer1K: BLENDED_INPUT_COST_PER_1K, outputPer1K: BLENDED_OUTPUT_COST_PER_1K }; | ||
| const pricing = resolveModelPricing(model).pricing ?? (providerCommand === "claude" | ||
| ? undefined | ||
| : { inputPer1K: BLENDED_INPUT_COST_PER_1K, outputPer1K: BLENDED_OUTPUT_COST_PER_1K }); | ||
| if (!pricing) { | ||
| return undefined; | ||
| } | ||
| return (inputTokens / 1000) * pricing.inputPer1K + (outputTokens / 1000) * pricing.outputPer1K; | ||
| } | ||
| function estimateUsage(promptText, model) { | ||
| function estimateUsage(promptText, model, providerCommand) { | ||
| const inputTokens = Math.ceil(promptText.length / 3.5); | ||
| const outputTokens = 2_000; | ||
| const estimatedUsd = estimatePromptCost(promptText, model, providerCommand); | ||
| return normalizeUsage({ | ||
| actualUsd: estimatePromptCost(promptText, model), | ||
| estimatedUsd: estimatePromptCost(promptText, model), | ||
| actualUsd: estimatedUsd ?? 0, | ||
| ...(estimatedUsd === undefined ? {} : { estimatedUsd }), | ||
| tokensIn: inputTokens, | ||
| tokensOut: outputTokens, | ||
| provenance: "estimated" | ||
| provenance: estimatedUsd === undefined ? "unavailable" : "estimated" | ||
| }); | ||
@@ -1065,0 +1141,0 @@ } |
@@ -29,2 +29,6 @@ import { probeCodexLaunch, resolveCliCommandAvailability } from "../adapters/index.js"; | ||
| acceptanceCriteria?: string[]; | ||
| /** Offer or start the Arcade immediately (skips the 30 s wait). */ | ||
| arcade?: boolean; | ||
| /** Disable the automatic Arcade prompt for this run. */ | ||
| noArcade?: boolean; | ||
| }; | ||
@@ -31,0 +35,0 @@ type InspectCommand = { |
| { | ||
| "name": "@martin/cli", | ||
| "version": "0.4.3", | ||
| "version": "0.4.5", | ||
| "type": "module", | ||
@@ -5,0 +5,0 @@ "description": "Martin Loop CLI โ budget-aware coding loops with failure classification and verified exits.", |
+1
-1
| { | ||
| "name": "martin-loop", | ||
| "private": false, | ||
| "version": "0.4.3", | ||
| "version": "0.4.5", | ||
| "type": "module", | ||
@@ -6,0 +6,0 @@ "description": "Open-source command center for governed AI coding agents with built-in onboarding, hard gates, MCP, and shareable run receipts.", |
+21
-1
@@ -89,4 +89,24 @@ # MartinLoop | ||
| Release notes for the current root package: [MartinLoop 0.4.3](./docs/release/OSS-0.4.3-RELEASE-NOTES.md). | ||
| Release notes for the current root package: [MartinLoop 0.4.5](./docs/release/OSS-0.4.5-RELEASE-NOTES.md). | ||
| ## MartinLoop Arcade | ||
| Long governed runs can take a few minutes. MartinLoop Arcade keeps the terminal useful while you wait. | ||
| After 30 seconds, if the run is still going and you are in an interactive terminal, MartinLoop asks once: | ||
| ``` | ||
| Still working. Play MartinLoop Arcade while you wait? [y/N] | ||
| ``` | ||
| Pressing `y` launches a terminal Space Invaders game. The governed run continues in the background โ receipts, budget tracking, and the final result are untouched. The game closes automatically when the run finishes and the terminal is fully restored. | ||
| The prompt never appears in CI, piped output, JSON mode, or non-interactive environments. | ||
| ```sh | ||
| martin run "your task" --verify "npm test" # prompts after 30 s if still running | ||
| martin run "your task" --verify "npm test" --arcade # offer the game immediately | ||
| martin run "your task" --verify "npm test" --no-arcade # disable the prompt | ||
| ``` | ||
| ## Visual Proof | ||
@@ -93,0 +113,0 @@ |
Sorry, the diff of this file is too big to display
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
954800
4.26%132
3.13%21910
4.55%460
4.55%65
3.17%