Files
rs-sdk/sdk/runner.ts
T
max bittkerandClaude Opus 5.5 df3d8888ae feat(sdk,mcp): cancellable execute_code, bot.mineRock, bounded walkTo
execute_code
- Cancellation now reaches inside the SDK via an AsyncLocalStorage scope:
  timeout, MCP cancel, or a newer call rejects pending waits and blocks
  further action packets, including those inside walkTo().
- One execution per bot: a new call supersedes the running one and starts
  once it has unwound; disconnect_bot does the same.
- Cancelled user code parks on bot/sdk calls instead of throwing, so a
  catch-all loop can't spin and starve the server.
- Console capture is routed per execution, so concurrent bots no longer
  clobber each other's output; leftover logs go to stderr.
- Stale world state warns and still runs, so a stale client can be diagnosed.
- Credentials are redacted from logs, results and launch messages.

SDK
- bot.mineRock() waits for ore plus Mining XP; interactLoc docs note that
  success can mean the action only started.
- walkTo() takes options.timeout (default 120s), refuses stale state, and
  reports the real last position.
- runScript uses the same cancellation scope for timeouts and signals.

Docs trimmed of duplicated or stale execute_code text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:30:19 -04:00

500 lines
19 KiB
TypeScript

// Script Runner - Zero boilerplate script execution
// Auto-finds bot.env sibling to script, or from command line arg, or --env-file
import { BotSDK, deriveGatewayUrl } from './index';
import { BotActions } from './actions';
import { executionContext, abortable } from './execution-context';
import { initPathfinding } from './pathfinding';
import { formatWorldStateSummary } from './formatter';
import type { BotWorldState } from './types';
import { readFileSync, existsSync } from 'fs';
import { dirname, join, resolve } from 'path';
// ============ Types ============
/** Error thrown when bot client disconnects during script execution */
export class BotDisconnectedError extends Error {
constructor(message: string = 'Bot client disconnected') {
super(message);
this.name = 'BotDisconnectedError';
}
}
export interface ScriptContext {
bot: BotActions;
sdk: BotSDK;
}
export type ScriptFunction = (ctx: ScriptContext) => Promise<any>;
export interface RunOptions {
/** Overall timeout in ms (default: none) */
timeout?: number;
/** Existing connection - use instead of process.env */
connection?: { bot: BotActions; sdk: BotSDK };
/** Connect if not connected (default: true) */
autoConnect?: boolean;
/**
* Disconnect the bot when the script finishes (default: true when the
* runner opened the connection itself, false when one was passed via
* `connection`). Pass `false` to keep the connection alive after the
* script exits — e.g. when an MCP server or another process shares this
* bot and a disconnect would leave it stale.
*/
disconnectAfter?: boolean;
/**
* Print a short state summary after execution (default: true) -
* position, XP gained, inventory, anything blocking, last few messages.
* Run `bun sdk/cli.ts {username}` for the full world state.
*/
printState?: boolean;
/**
* How to handle bot client disconnection during script execution:
* - 'error': Throw BotDisconnectedError immediately when disconnected (default)
* - 'wait': Pause and wait for reconnection (requires autoReconnect on SDK)
* - 'ignore': Don't monitor, let actions fail naturally
*/
onDisconnect?: 'error' | 'wait' | 'ignore';
/** Timeout for waiting for reconnection when onDisconnect='wait' (default: 60000ms) */
reconnectTimeout?: number;
}
export interface RunResult {
success: boolean;
result?: any;
error?: Error;
duration: number;
finalState: BotWorldState | null;
}
// ============ Connection Management ============
interface BotConnection {
sdk: BotSDK;
bot: BotActions;
username: string;
}
const connections = new Map<string, BotConnection>();
/**
* Parse a bot.env file and load into process.env
*/
function loadEnvFile(envPath: string): void {
const content = readFileSync(envPath, 'utf-8');
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex > 0) {
const key = trimmed.slice(0, eqIndex).trim();
const value = trimmed.slice(eqIndex + 1).trim();
process.env[key] = value;
}
}
}
/**
* Load bot credentials automatically.
* Priority:
* 1. Already set in process.env (via --env-file)
* 2. bot.env sibling to the script file
* 3. Command line arg: bun script.ts <botname> -> bots/<botname>/bot.env
*/
function loadEnvFromArgs(): void {
// Skip if already have credentials (e.g., from --env-file)
if (process.env.BOT_USERNAME && process.env.PASSWORD) return;
// Try bot.env sibling to the script file
const scriptPath = process.argv[1];
if (scriptPath) {
const scriptDir = dirname(resolve(scriptPath));
const siblingEnv = join(scriptDir, 'bot.env');
if (existsSync(siblingEnv)) {
loadEnvFile(siblingEnv);
return;
}
}
// Try bot name from command line args
const args = process.argv.slice(2);
const botName = args.find(arg => !arg.startsWith('-'));
if (!botName) return;
const envPath = join(process.cwd(), 'bots', botName, 'bot.env');
if (!existsSync(envPath)) {
throw new Error(`Bot "${botName}" not found at ${envPath}`);
}
loadEnvFile(envPath);
}
async function getOrCreateConnection(): Promise<BotConnection> {
// Try to load from command line args first
loadEnvFromArgs();
// Read credentials from process.env
const username = process.env.BOT_USERNAME;
const password = process.env.PASSWORD;
const server = process.env.SERVER;
// Default: true. Opt out with SHOW_CHAT=false in bot.env.
const showChat = process.env.SHOW_CHAT?.toLowerCase() !== 'false';
if (!username) {
throw new Error('BOT_USERNAME not set. Run with: bun --env-file=bots/{name}/bot.env script.ts\nOr: bun script.ts {botname}');
}
if (!password) {
throw new Error('PASSWORD not set. Run with: bun --env-file=bots/{name}/bot.env script.ts\nOr: bun script.ts {botname}');
}
const existing = connections.get(username);
if (existing && existing.sdk.isConnected()) {
return existing;
}
const gatewayUrl = deriveGatewayUrl(server);
// Warm the pathfinder BEFORE the live socket exists. Init is fully
// synchronous (tens of seconds on slow hosts), and paying it lazily
// inside the first walkTo blocks the event loop long enough for the
// gateway to kill the connection on missed pings - which presented as
// "interactLoc froze the whole process". Idempotent, so repeat
// connections skip it.
initPathfinding();
console.error(`[Runner] Connecting to bot "${username}"...`);
const sdk = new BotSDK({
botUsername: username,
password,
gatewayUrl,
connectionMode: 'control',
autoReconnect: true,
// Standalone bot scripts must attach to an already-running client. In
// particular, Lite fleets should never fall back to opening a browser
// when a headless session is temporarily unavailable.
autoLaunchBrowser: false,
showChat
});
const bot = new BotActions(sdk);
// Cover the browser-launch wait (up to browserLaunchTimeout) plus the
// handshake and ready waits that follow it, not just the handshake.
const CONNECT_TIMEOUT_MS = 90_000;
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => reject(new Error(`Connection timed out after ${CONNECT_TIMEOUT_MS / 1000}s`)), CONNECT_TIMEOUT_MS);
});
try {
await Promise.race([sdk.connect(), timeoutPromise]);
await requireLiveClient(sdk, username, server);
} catch (error) {
// The BotSDK was never registered in `connections`, so nothing else will
// ever tear it down - and with autoReconnect on, its reconnect timers
// would keep the process alive as a zombie holding the bot's control
// connection while runScript has already given up.
await sdk.disconnect().catch(() => {});
throw error;
} finally {
clearTimeout(timeoutHandle);
}
console.error(`[Runner] Connected to bot "${username}"`);
const connection: BotConnection = { sdk, bot, username };
connections.set(username, connection);
return connection;
}
/** How long to wait for a fresh state frame before deciding no client is attached. */
const LIVE_CLIENT_WAIT_MS = 10_000;
/**
* Throw unless a game client (browser tab or lite runner) is publishing state
* for this bot right now.
*
* connect() succeeds against the gateway alone, so without this check a script
* with no client "connects" and every action silently does nothing. Two shapes:
* the gateway has no state at all (no client ever attached; connect() already
* waited out its ready timeout), or it replays the last state it cached before
* the client left, which passes the ready check. A live client advances
* `tick` every game tick, so wait for one - this compares ticks rather than
* `getStateAge()`, which mixes gateway and local clocks.
*/
async function requireLiveClient(sdk: BotSDK, username: string, server: string | undefined): Promise<void> {
const cached = sdk.getState();
if (cached) {
try {
await sdk.waitForCondition(s => s.tick !== cached.tick, LIVE_CLIENT_WAIT_MS);
return;
} catch {}
}
const why = cached
? `its last state is ${Math.round(sdk.getStateAge() / 1000)}s old and no new tick arrived in ${LIVE_CLIENT_WAIT_MS / 1000}s`
: 'no game state was received';
// Same derivation as BotSDK's browser launch URL: SERVER may be a bare
// host or a ws(s):// gateway URL.
let origin = 'http://localhost:8888';
if (server && !server.startsWith('localhost') && !server.startsWith('127.')) {
origin = /^wss?:\/\//.test(server)
? server.replace(/^ws/, 'http').replace(/\/gateway$/, '')
: `https://${server}`;
}
throw new Error(
`No game client is attached to "${username}" (${why}).\n` +
`Scripts drive a running client; they don't start one. Start one, leave it running, then rerun:\n` +
` browser: ${origin}/bot?bot=${encodeURIComponent(username)}&password=<PASSWORD from bot.env>\n` +
`MCP execute_code can open that browser automatically. For fleets of many bots, an optional lite client is available: cd server/webclient && bun src/lite/runner.ts ${username}`
);
}
// ============ Core Runner ============
/**
* Run a script with zero boilerplate.
*
* Credentials are loaded automatically:
* 1. From process.env (via bun --env-file)
* 2. From bot.env sibling to the script (bun bots/mybot/script.ts)
* 3. From command line arg (bun script.ts mybot)
*
* Returns after cleanup without scheduling process termination, so later
* runs and retry delays can safely use the same process. One-shot CLI callers
* can explicitly exit after their final run if their own handles remain open.
*
* @example
* // Just run from the bot directory - auto-finds bot.env:
* // bun bots/mybot/script.ts
*
* import { runScript } from '../../sdk/runner';
*
* await runScript(async (ctx) => {
* await ctx.bot.chopTree();
* });
*
* @example
* // With existing connection
* await runScript(async (ctx) => {
* await ctx.bot.chopTree();
* }, { connection: { bot, sdk } });
*/
export async function runScript(
script: ScriptFunction,
options: RunOptions = {}
): Promise<RunResult> {
const {
timeout,
connection,
autoConnect = true,
printState = true,
onDisconnect = 'error',
reconnectTimeout = 60000
} = options;
// Default disconnectAfter to true for CLI (managed connections), false for external connections
const disconnectAfter = options.disconnectAfter ?? !connection;
const startTime = Date.now();
// Get bot/sdk either from connection or by connecting
let bot: BotActions;
let sdk: BotSDK;
let managedConnection = false;
if (connection) {
bot = connection.bot;
sdk = connection.sdk;
} else {
// Use process.env for credentials (loaded by bun --env-file or command line arg)
try {
if (autoConnect) {
const conn = await getOrCreateConnection();
bot = conn.bot;
sdk = conn.sdk;
managedConnection = true;
} else {
// Try to load env from args first
loadEnvFromArgs();
const username = process.env.BOT_USERNAME;
if (!username) {
throw new Error('BOT_USERNAME not set. Run with: bun --env-file=bots/{name}/bot.env script.ts\nOr: bun script.ts {botname}');
}
const existing = connections.get(username);
if (!existing || !existing.sdk.isConnected()) {
throw new Error(`Bot "${username}" is not connected and autoConnect is false`);
}
bot = existing.bot;
sdk = existing.sdk;
managedConnection = true;
}
} catch (error: any) {
console.error(`[Runner] Failed to connect: ${error?.message ?? error}`);
// Same as a script error below: `bun script.ts` must exit non-zero.
process.exitCode = 1;
return {
success: false,
error,
duration: Date.now() - startTime,
finalState: null
};
}
}
// Create script context
const ctx: ScriptContext = {
bot,
sdk,
};
// Baseline XP so the summary can report what the script actually earned
const xpBefore: Record<string, number> = {};
for (const skill of sdk.getState()?.skills ?? []) {
xpBefore[skill.name] = skill.experience;
}
// Clean exit on signals - prevents orphaned processes when parent shell is killed
const executionController = new AbortController();
let signalReceived = false;
const signalCleanup = (signal: string, exitCode: number) => {
if (signalReceived) {
// Second signal: the disconnect is wedged, exit immediately.
process.exit(exitCode);
}
signalReceived = true;
executionController.abort(new Error(`Script interrupted by ${signal}`));
console.error(`[Runner] Received ${signal} - disconnecting and exiting...`);
// Exit 128+signum, never 0: a supervisor must not read a killed run
// as success. And a wedged disconnect must not hang the process.
setTimeout(() => process.exit(exitCode), 5000);
sdk.disconnect().finally(() => process.exit(exitCode));
};
const onSigterm = () => signalCleanup('SIGTERM', 143);
const onSigint = () => signalCleanup('SIGINT', 130);
const onSighup = () => signalCleanup('SIGHUP', 129);
process.on('SIGTERM', onSigterm);
process.on('SIGINT', onSigint);
process.on('SIGHUP', onSighup);
// Execute script with connection monitoring
let result: any;
let error: Error | undefined;
let unsubscribeConnection: (() => void) | null = null;
let disconnectReject: ((err: Error) => void) | null = null;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
try {
// Set up connection monitoring
disconnectReject = err => executionController.abort(err);
const scriptPromise = executionContext.run({ signal: executionController.signal }, () => abortable(script(ctx)));
if (timeout) timeoutId = setTimeout(() => executionController.abort(new Error(`Script timeout after ${timeout}ms`)), timeout);
// Add connection monitoring if not ignored
if (onDisconnect !== 'ignore') {
unsubscribeConnection = sdk.onConnectionStateChange(async (state, attempt) => {
if (state === 'disconnected' || state === 'reconnecting') {
if (onDisconnect === 'error') {
// Fail immediately
console.error(`[Runner] Bot client disconnected - aborting script`);
if (disconnectReject) {
disconnectReject(new BotDisconnectedError('Bot client disconnected during script execution'));
}
} else if (onDisconnect === 'wait') {
// Log and wait for reconnection
console.error(`[Runner] Bot client disconnected - waiting for reconnection (timeout: ${reconnectTimeout}ms)...`);
try {
await sdk.waitForConnection(reconnectTimeout);
console.error(`[Runner] Bot client reconnected - resuming script`);
} catch (e) {
console.error(`[Runner] Reconnection failed - aborting script`);
if (disconnectReject) {
disconnectReject(new BotDisconnectedError('Bot client failed to reconnect within timeout'));
}
}
}
}
});
}
result = await scriptPromise;
} catch (e: any) {
error = e;
} finally {
if (!executionController.signal.aborted) executionController.abort(error ?? new Error('Script finished'));
// Failures and disconnects must release the deadline too, or a finished
// run keeps the process alive until its original timeout expires.
if (timeoutId) clearTimeout(timeoutId);
// Clean up connection listener
if (unsubscribeConnection) {
unsubscribeConnection();
}
// Remove signal handlers
process.off('SIGTERM', onSigterm);
process.off('SIGINT', onSigint);
process.off('SIGHUP', onSighup);
}
// Get final state
const finalState = sdk.getState();
const duration = Date.now() - startTime;
// Print result if any
if (result !== undefined && !error) {
console.log('');
console.log('── Result ──');
console.log(typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result));
}
// Print error if any. Also fail the process so `bun script.ts` exits non-zero.
if (error) {
console.log('');
console.log(`── Error ── ${error.name}: ${error.message}`);
// First few frames are enough to locate it
for (const frame of (error.stack ?? '').split('\n').slice(1, 5)) {
console.log(frame);
}
if (managedConnection) process.exitCode = 1;
}
// Print state if requested
if (printState && finalState) {
console.log('');
console.log('── State ──');
console.log(formatWorldStateSummary(finalState, sdk.getStateAge(), { xpBefore }));
}
// Disconnect if requested (only for managed connections)
if (disconnectAfter && managedConnection) {
const username = process.env.BOT_USERNAME;
if (username) {
console.error(`[Runner] Disconnecting bot "${username}"...`);
// Bound cleanup, but don't leave its losing timer alive or kill
// unrelated work (including the caller's next run) afterwards.
let disconnectTimeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
sdk.disconnect(),
new Promise(resolve => {
disconnectTimeout = setTimeout(resolve, 10000);
}),
]);
} finally {
clearTimeout(disconnectTimeout);
connections.delete(username);
}
}
}
return {
success: !error,
result: error ? undefined : result,
error,
duration,
finalState
};
}