npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@crow-agent/codemode

v0.2.0

Published

Sandboxed execution modes for running untrusted code safely.

Downloads

373

Readme

@crow-agent/codemode

Let a model write JavaScript that calls your tools — without ever letting that JavaScript touch your process. Each run executes inside a QuickJS virtual machine (compiled to WebAssembly) whose only way out is the tool functions you inject. Nested tool calls stay inside the sandbox; the LLM sees only the script's output and return value.

The coding agent's built-in codemode tool is one consumer. The package has no Crow dependencies, so anything — remote APIs, MCP servers, application services — can be exposed to model-written scripts through it.

A first script

import { CodemodeSandbox } from "@crow-agent/codemode";

const sandbox = new CodemodeSandbox({
  timeoutMs: 60_000,
  tools: [
    {
      name: "list-dir",
      description: "List files in a directory",
      execute: async (args, { signal }) => {
        const { dir } = args as { dir: string };
        return await readdir(dir);
      },
    },
  ],
});

const result = await sandbox.execute(`
  const entries = await tools.list_dir({ dir: "src" });
  text("entries " + entries.length);
  return entries.filter((name) => name.endsWith(".ts"));
`);

console.log(result.output); // [{ type: "text", text: "entries 12" }]
if (result.ok) console.log(result.value);
else console.error(result.error.kind, result.error.message);

await sandbox.close();

execute() takes the body of an async function: top-level await and return work. It never rejects for script failures — inspect result.ok instead.

What a script can touch

Deliberately little:

| Name | Meaning | | --- | --- | | tools.<name>(args) | Call an injected tool; returns a promise | | ALL_TOOLS | [{ name, description }] for every tool, names in identifier form | | text(value) | Append a text item to the output (non-strings are JSON-stringified) | | image(urlOrItem) | Append an image item | | exit() | Finish successfully right now, keeping output and store writes | | store(key, value) / load(key) | Synchronous JSON persistence across runs | | console.log/info/warn/error/debug | Append text items, like text() |

Tool names are also exposed as identifiers: anything that isn't valid in an identifier becomes _ (see toCodemodeIdentifier), so a tool named my-tool is reachable as both tools.my_tool and tools["my-tool"].

Arguments and results cross the boundary as JSON. A tool that throws rejects the script-side promise with an Error carrying the same message. image() accepts a base64 data: URL, an { image_url } object, or an MCP ImageContent block — remote URLs are refused.

There is nothing else: no timers, no fetch, no process, no require, no modules, no WebAssembly. eval and Function still work, but they can only manufacture more code inside the same VM.

Tools and globals

Tools are the async workhorses. Globals are a second injection channel for synchronous host helpers that scripts call as plain top-level functions: a global named models.classify materializes as a frozen models object with a classify function. Globals behave like tools except they never appear in result.calls. Two options tune them: spread: true passes every call argument through as an array instead of just the first, and signature replaces the schema-derived declaration.

Tools and globals may carry description, inputSchema, and outputSchema (JSON Schema). renderDeclarations() turns them into TypeScript declarations you can paste into a model-facing tool description:

renderDeclarations({
  tools: sandbox.tools,
  globals: sandbox.globals,
});
// declare const tools: {
//   /** List files in a directory */
//   list_dir(args: {
//     dir: string;
//   }): Promise<string[]>;
// };

Schemas shape the declarations only — values are never validated against them. Local references (#/$defs/…) are expanded inline; recursive or remote ones render as unknown.

Carrying state between runs: the store

The sandbox persists nothing itself. Pass the current values in as options.store; a successful run reports what changed as result.storeWrites ({ set, delete }), and failed runs report no writes:

const result = await sandbox.execute(
  `store("visits", (load("visits") ?? 0) + 1)`,
  { store: saved },
);
if (result.ok) {
  for (const gone of result.storeWrites.delete) delete saved[gone];
  Object.assign(saved, result.storeWrites.set);
}

load() returns a copy, so mutating the result leaves the store alone. Storing undefined deletes the key. Values are capped: one value may hold at most MAX_STORE_VALUE_CHARS (256 KiB) of JSON and all values together at most MAX_STORE_TOTAL_CHARS (1 MiB); larger writes throw a RangeError inside the script.

Timeouts, memory, and aborts

  • timeoutMs bounds the run; timeoutMs: Infinity removes the deadline, leaving the script to run until it settles or signal aborts it.
  • A script parked on a promise nothing can ever settle — no tool call pending, and the VM has no timers or I/O — fails immediately instead of hanging.
  • memoryLimitBytes caps the VM heap; over-allocation surfaces inside the script as InternalError: out of memory.

The options line

parseCodemodeSource() accepts a script whose first line may carry options:

// @options: {"max_output_tokens": 2000, "timeout_ms": 30000}
const entries = await tools.list_dir({ dir: "src" });
text(entries.join(", "));

max_output_tokens budgets the output, timeout_ms sets a hard deadline. The sandbox ignores both — the caller decides what they mean. The line is replaced with an empty line so stack-trace line numbers still match the input. Empty input, bad JSON, unknown fields, or an options line with no code after it throw CodemodeSourceError. CODEMODE_SOURCE_GRAMMAR is a Lark grammar for providers that constrain tool input with a grammar. Both also live on the lightweight @crow-agent/codemode/source entry point.

Reading results

execute() resolves for every outcome. When result.ok is false, result.error.kind tells you what happened:

| kind | Meaning | | --- | --- | | script | The script threw or failed to parse; stack points at codemode.js:<line> | | timeout | The deadline expired; the worker was terminated | | aborted | options.signal fired or close() was called; the worker was terminated | | sandbox | The worker or VM itself failed — a wasm trap, a missing worker file, … |

result.output holds text and image items in production order, including for failed runs. result.calls lists every tool call with status: "ok" | "error" | "cancelled". A call still in flight when the script returns (never awaited) is aborted through the tool's signal and reported as cancelled.

Bundling

By default the sandbox loads quickjs-wasi/quickjs.wasm from the installed package and starts the worker file next to this package's module. Neither path exists once you bundle the host, so pass both explicitly:

import { CodemodeSandbox, loadQuickJSWasm } from "@crow-agent/codemode";

const sandbox = new CodemodeSandbox({
  tools,
  wasm: loadQuickJSWasm(pathToQuickJSWasm), // compiled once per path, then cached
  workerUrl: new URL("./codemode-worker.js", import.meta.url), // a file of your build containing `import "@crow-agent/codemode/worker";`
});

Giving an agent a codemode tool

Expose the agent's other tools to a sandbox and wrap execute() as an AgentTool:

import type { AgentTool } from "@crow-agent/agent-core";
import {
  type CodemodeJsonSchema,
  CodemodeSandbox,
  type CodemodeTool,
  renderDeclarations,
} from "@crow-agent/codemode";
import { Type } from "typebox";

const exposed: CodemodeTool[] = agentTools.map((tool) => ({
  name: tool.name,
  description: tool.description,
  inputSchema: tool.parameters as CodemodeJsonSchema,
  outputSchema:
    (tool.outputSchema as CodemodeJsonSchema | undefined) ?? { type: "string" },
  execute: async (args, { signal }) => {
    const outcome = await tool.execute("nested", args as never, signal);
    if (tool.outputSchema && outcome.structuredContent !== undefined) {
      return outcome.structuredContent;
    }
    return outcome.content
      .map((block) => (block.type === "text" ? block.text : ""))
      .join("\n");
  },
}));

const codemodeTool: AgentTool = {
  name: "codemode",
  label: "Codemode",
  description:
    "Run JavaScript that calls tools as `await tools.<name>(args)`. " +
    "Output with text() or return.\n\n" +
    renderDeclarations({ tools: exposed }),
  parameters: Type.Object({
    code: Type.String(),
  }),
  execute: async (_toolCallId, { code }, signal) => {
    const sandbox = new CodemodeSandbox({ tools: exposed });
    try {
      const outcome = await sandbox.execute(code, { signal });
      const content = [...outcome.output];
      if (outcome.ok && outcome.value !== undefined) {
        content.push({ type: "text", text: JSON.stringify(outcome.value) });
      }
      if (!outcome.ok) {
        content.push({
          type: "text",
          text: outcome.error.stack ?? outcome.error.message,
        });
      }
      return { content, details: undefined, isError: !outcome.ok };
    } finally {
      await sandbox.close();
    }
  },
};

result.output items already match @crow-agent/ai's TextContent/ImageContent shapes. Calling tool.execute() directly skips the agent's beforeToolCall / afterToolCall hooks; to apply them to nested calls as well, route each call through runToolCall() from @crow-agent/agent-core — the mcp-codemode example shows how, including failing the script on failed nested calls and combining codemode with MCP tools.

How it works

Each execute() starts a worker thread (roughly 20 ms including VM creation) that instantiates a fresh QuickJS VM from the compiled wasm module — a separate wasm instance with its own linear memory. The VM's only imports are a WASI shim (clock, random, stdout/stderr writes the worker discards) and a single host-call entry point, so a script cannot reach the host except through registered functions. A prelude evaluated inside the VM keeps that one bridge in a closure and builds tools, console, and globals on top of it; the script itself is compiled as an async function body.

Tool calls travel to the host thread as messages; the host runs the tool and posts the JSON result back. The host also owns the deadline and the abort signal: when either fires it sets a shared interrupt flag the VM polls, then calls worker.terminate(). The flag exists for Bun, where terminate() cannot stop a thread spinning inside wasm (while (true) {} or while (true) await null).

Keeping execution on a worker matters because QuickJS runs synchronously — a spinning script on the host thread would freeze its event loop.

Runtime notes

  • Node and Bun behave the same, including memory limits, interrupts, and the catchable RangeError for deep recursion (QuickJS's stack guard is on; without it the wasm stack overflows and traps).
  • Stack traces use QuickJS frames (at f (codemode.js:2:31)) prefixed with Name: message like V8. The wrapper shares line 1 with the script, so line numbers are exact; column numbers on line 1 shift.
  • QuickJS is an interpreter. Glue code and filtering tool results are plenty fast; heavy number-crunching runs slower than in V8.