Sign In

martin-loop

Package Overview
Dependencies
Maintainers
1
Versions
46
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

martin-loop - npm Package Compare versions

Comparing version
0.4.3
to
0.4.5
+18
dist/vendor/cli/arcade/index.d.ts
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
+2
-2

@@ -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.",

{
"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.",

@@ -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