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>
execute_code dropped the console output and world state whenever the code
threw or timed out, returning only the error. That's when the agent most
needs to see it died, a dialog is open, or it got "I can't reach that" -
in runebench runs a bot was killed mid-timeout and the result just said
"timed out". Errors now return console output, the error with up to six
stack frames, and the world state, flagged isError.
The world state appended to every call (~560 tokens) now uses a brief mode
that folds untrained skills into one line and drops NPC indexes and
NPC/object action lists, cutting about 25% per call measured over 44.5k
runebench blocks. The CLI keeps the full output.
The code parameter's example no longer suggests `return sdk.getState()`,
which duplicated the appended dump.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The gateway can also listen on GATEWAY_PUBLIC_PORT (on Fly:
wss://rs-sdk-demo.fly.dev:7443 -> 7781) with permessage-deflate. On live
bot state frames that is ~16% of the uncompressed bytes at ~6 ms CPU per MB,
spent in the gateway process rather than on the game server's tick thread.
The main port is unchanged and uncompressed, so the engine's /gateway relay
and every existing SDK and runner behave exactly as before.
SDK connect() and the lite runner check the direct port (3 s) and use it when
it answers, otherwise they keep wss://.../gateway; a failed connect checks
again. GATEWAY_DIRECT=false opts out. HTTP endpoints keep the relayed origin.
GATEWAY_HOSTNAME lets a fleet run a private loopback gateway (sdk/README.md).
sdk/chat.ts and the MCP server now honor a bot.env GATEWAY_URL, as cli.ts and
runScript already did. The Dockerfile sed that forced 0.0.0.0 is replaced by
that option's default.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
execute_code collected console output in an unbounded array. A script whose
catch block logs and loops without awaiting (e.g. retrying a method that
throws synchronously) never yields, so the execution timeout can't fire and
the array grows until the process runs out of memory. Two RuneBench trials
(Sonnet 5.5, hitpoints) most likely lost their sandbox this way looping on the
removed bot.attackNpc; a surviving run logged the same error 16,210 times.
ConsoleCapture keeps the first 300 and last 300 lines, clips lines at 2000
chars, and reports how many lines were omitted, so memory stays constant.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Engine
- World.processLogins takeover: closeModal() before save(), so items parked in
temp invs (tradeoffer/dueloffer/duelwinnings/partyroom_tempinv) return to the
pack before the snapshot. Logging in from a second client mid-trade deleted
the offer and left the partner's screen stuck; reproduced locally before and
after.
- Web server proxies /state/:bot, /chat/:bot, POST /say/:bot, POST /dm/:bot to
the gateway (cli.ts / chat.ts HTTP fast paths 404'd on deployments that
front the gateway through the engine web port).
SDK
- interactNpc/useItemOnNpc/talk path re-find an entity target by server index
after walking (ActionHelpers.refindNpc) instead of by name, so a chosen
fishing spot is not swapped for the nearest same-named one.
- runner: log a failed initial connect and tear down the orphan BotSDK (its
reconnect timers kept the process alive as a zombie); connect race 30s -> 90s
to cover a cold browser launch; browserLaunchTimeout default 30000 as documented.
- mcp/api: bot.env repo-root fallback was one directory short.
- cli.ts honours GATEWAY_URL from bot.env like the runner does.
- shortestNameMatch falls back to a substring match when a literal name with
regex metacharacters ("Serum 207 (1)") matches nothing as a regex (+test).
- eatFood accepts Drink; openBank skips "Closed bank booth" and prefers a booth
with a Bank/Use op; attack re-finds a bare {index} handle instead of throwing;
depositItem(number) returns an invalid-target result instead of a TypeError.
- ActionDispatchError sets its name via defineProperty (hosts that freeze
Error.prototype made the assignment throw from ws.onclose).
Webclient
- Client.say() routes '::' to CLIENT_CHEAT like the keyboard path and the lite
client, instead of speaking cheats aloud as public chat.
- Client.isBankOpen() returns false when no main modal is open (component 2006
linkObjType is never cleared, so state.bank.isOpen stuck true all session).
- acceptCharacterDesign (lite + browser) prefers the CC_ACCEPT_DESIGN button
inside the open modal; other kits carry the same clientcode with no trigger.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
From the market-split-n24 audit (24 bots, 64 sdk-kind problems):
- tradeWith verifies the partner: declines a session open with a different
player (a pending request racing yours used to be silently adopted -
bots completed trades with the wrong counterparty) and re-pursues the
requested one until the deadline; trade() gains retryOnBusy
- serveTrades maxTrades counts only completed trades (a lowballer's
declined session used to consume a maxTrades:1 window); new rejectAfterMs
declines offers that stay unacceptable; accept() predicate evaluated on
offer change + every ~2s instead of every tick (console-spam fix)
- trade completion waits for the inventory delta to land before reporting
received items as missing; TradeResult items carry an `amount` alias
- walkTo rejects non-numeric coordinates (walkTo(undefined, undefined)
used to march bots into scorpions/wolves) and aborts when the player
dies en route; NearbyPlayer gains worldX/worldZ aliases
- interactLoc/interactNpc reject null targets (used to hit a random loc);
find* patterns get a clear error for non-string/RegExp input
- formatter no longer flags the open trade window (3323) as a blocking
modal to close - that advice cost a bot a 100gp advance
- Spells component ids (HIGH_ALCHEMY etc.) exported from sdk/spells.ts
and injected as an execute_code global
- MCP execute_code transpiles TS (the tool advertised TS but rejected
type syntax), formats Errors in console capture (was '{}'), and warns
about ~60s MCP client caps with code continuing to run
- gateway serializes HTTP says per bot: concurrent PMs clobbered
currentActionId, so delivered messages reported false "Say timed out"
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
78 new reports since 8/12 triaged; the tradeoff-free fixes:
- dismissBlockingUI no longer clicks multi-option choice dialogs. The 8/12
fix (9afd6d961) made it click options[0].index, which ANSWERS a p_choice
as option 1 before every bot action - the wrong quest branch ran and the
caller's intended click was silently refused as stale. Root-caused via
live repro; the published index/componentId mapping was never wrong.
Blocks-quests class (Restless Ghost, Rune Mysteries, Al Kharid gate).
- interactLoc/useItemOnLoc/useItemOnNpc/equipItem accept real evidence:
same-level teleports (dungeon trapdoors, z +/-6400), inventory deltas
(cow milking, flour bin), late equipment arrival. No more false failures
on actions that worked.
- 'interactLoc froze the event loop' root cause was lazy synchronous
initPathfinding() (~30s on slow hosts) killing the socket mid-action:
runner + MCP server now warm it before connecting. Runner also exits
non-zero on signals (128+n), bounds disconnect, force-exits a wedged
event loop, and honors a second signal immediately.
- ground items on blocked tiles: browser client now mirrors the authentic
OP_OBJ handler (retry route with 1x1 footprint, dispatch regardless);
lite ground-item op dispatches unconditionally too. Unblocks Monk's
Friend / Plague City / Witch's House pickups.
- interactLoc/useItemOnLoc re-find an entity target by (id,x,z) identity,
not name - same-named neighbours (Varrock cart crates) can't win.
- lite runner/swarm repoRoot via fileURLToPath - Windows and
space-containing clones can run headless again (4 reports).
- smithAtAnvil: longest-key product match ('mithril longsword' no longer
smiths a sword), metal-prefix bar selection, honest smithed-product
report; castSpell resolves known spell names, rejects unknown ones, and
no longer reports a never-dispatched cast as a splash; buyFromShop fails
fast with out_of_stock/no_inventory_space; depositItem -1 with an
alternation pattern deposits every matching id; openShop falls back to
the nearest Trade-option NPC when called with no target; cli.ts honors
blank SERVER= as local instead of diagnosing against the demo gateway.
- NearbyNpc publishes tileX/tileZ (authoritative route tile) and size;
x/z are interpolated render coords, off by 1-3 tiles on movers and
floor(size/2) on multi-tile NPCs. Also lands the isSkulled/
autoRetaliateEnabled collector for the already-published type decls.
- Melzar's Maze funexit doors added to ONE_WAY_DOORS (verified in
collision data) so walkTo/attack stop stranding bots.
Checked: imp red-bead drop table is fair (5/128, reachable branch) - the
report was 1-in-18 variance, no fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Agents hit SDK papercuts constantly - a method returns success:true and
nothing happened, a dialog option isn't where the docs say it is - and
there was nowhere to put that. Filing one is now a single command:
bun sdk/bug-report.ts "walkTo returns success at closed gates"
The SDK's git commit is stamped on every report, so a report points at the
code that produced it.
Server side, POST /bug-report appends JSONL to the mounted volume
(BUG_REPORTS_FILE in fly.toml, so reports survive deploys). The endpoint is
deliberately write-only: nothing is served back over HTTP, and reports are
read off the machine with
fly ssh console -C "cat /opt/server/data/bug-reports.jsonl"
Reports always go to rs-sdk-demo.fly.dev, never to whatever SERVER a bot is
playing on - a bug found against a local server is still a bug in the SDK.
Telemetry is on by default; TELEMETRY=false in bot.env or the environment
turns it off.
Also renames claude.md -> CLAUDE.md and agents.md -> AGENTS.md. Those names
only resolved on macOS: the filesystem is case-insensitive here, so git had
recorded lowercase names that no tool would find on a case-sensitive
checkout. Verified on a case-sensitive volume - before the rename, lookups
for CLAUDE.md and AGENTS.md both missed; after, all three resolve to the one
real file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- @modelcontextprotocol/sdk moves to the root package.json; mcp/ no longer
carries its own package.json/bun.lock/node_modules (23MB install layer
and second lockfile gone). Smoke-tested: initialize handshake +
resources/read both work off root node_modules.
- The two hand-maintained MCP resources (mcp/api/{bot,sdk}.ts) had
drifted stale (missing the Jul 6 chat APIs) — deleted, and the resource
now points at the auto-generated sdk/API.md.
- Fixed connect_bot references (tool doesn't exist; bots auto-connect),
the pre-rename server path in the manual-setup snippet, and install
instructions in mcp/README.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The SDK read chat only from the latest state-sync snapshot (50 messages),
so anything older was unrecoverable and getNewChat missed messages that
scrolled out of the window between polls. Also, chat cursors were
high-water *ticks* (Client.loopCycle), which restart near 0 on page
reload — permanently muting getNewChat and the MCP chat display.
- sdk/chat-history.ts: ChatHistory merges every snapshot into a deduped
500-deep buffer. Handles the same-tick split (chat packet processed
after the PLAYER_INFO that triggered the previous sync shares its
loopCycle) and re-baselines on tick reset instead of dropping messages.
- getChat/getNewChat/getChatFrom read the accumulated history;
getChat({limit: 0}) returns everything since connect. getNewChat's
cursor is now a count into the history — immune to resets/eviction.
- sdk.waitForChat({from, matching, types, timeout}): await the next
matching message (null on timeout), own private cursor so it doesn't
consume getNewChat's. One-liner for bot<->bot coordination.
- mcp/server.ts: reset lastShownMessageTick when every visible tick is
below it (client reloaded), instead of muting chat forever.
- learnings/chat.md usage guide, CLAUDE.md quick-ref rows, API.md
regenerated, 24-case logic test (sdk/test/chat-history.test.ts).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the MCP server is launched from a different cwd (e.g. /workspace
in Docker), it can't find bots/{name}/bot.env. Fall back to resolving
relative to the mcp/ directory (one level up = repo root).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds retry logic (3 attempts with linear backoff) to handle race
conditions during bot connection, instead of failing immediately on
first timeout.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>