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

@molecule/api-ai-tools

v1.5.20

Published

Shared AI agent tools with backend abstraction for sandbox and local execution

Readme

@molecule/api-ai-tools

Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit src/index.ts JSDoc, not this file.

Shared AI agent tool set for molecule.dev — filesystem, search, and shell tools an LLM agent can call, with a swappable execution backend.

buildTools(backend) returns ready-to-use AITools (the @molecule/api-ai tool shape): list_files, read_file, write_file, edit_file, search_files, find_files, create_directory, rename_file, delete_file, exec_command, save_plan, load_skill. The backend decides WHERE they act: createLocalBackend(projectRoot) (host filesystem) or createSandboxBackend(...) (an isolated @molecule/api-code-sandbox container). buildAgentPrompt(ctx) composes a matching system prompt (identity, tool listing, project docs, discovered skills).

Quick Start

import { buildAgentPrompt, buildTools, createLocalBackend } from '@molecule/api-ai-tools'
import { requireProvider } from '@molecule/api-ai'

const backend = createLocalBackend('/path/to/project')
const tools = buildTools(backend, { include: ['read_file', 'edit_file', 'search_files'] })
const system = buildAgentPrompt({
  agentName: 'My Agent',
  projectRoot: backend.projectRoot,
  tools: tools.map((t) => t.name),
})

// Hand them to the bonded AI provider (or an @molecule/api-ai-agents run).
for await (const event of requireProvider().chat({ system, tools, messages, stream: true })) {
  // forward text chunks; tool calls are executed against the backend
}

Type

core

Installation

npm install @molecule/api-ai-tools @molecule/api-ai

API

Interfaces

NormalizableSchema

The JSON-Schema-ish shape every tool in this package declares.

interface NormalizableSchema {
  name: string
  parameters?: {
    type?: string
    properties?: Record<string, { type?: string; description?: string }>
    required?: string[]
  }
}

NormalizedInput

The result of normalizing one tool call's input.

interface NormalizedInput {
  /** The input to hand the handler, with aliases moved onto the declared names. */
  input: Record<string, unknown>
  /** Declared-required parameters still absent after aliasing. */
  missing: string[]
  /** `alias→declared` pairs that were moved, for logging. */
  aliased: string[]
}

GuardOptions

Options shared by {@link guardToolExecute} and {@link guardAITool}.

interface GuardOptions {
  /**
   * Redactor applied to a THROWN error's message before it is returned to the
   * model. Defaults to this package's `redactSecrets`.
   */
  redactError?: (message: string) => string
}

ToolSchema

JSON-schema-backed definition for a single agent tool.

interface ToolSchema {
  name: string
  description: string
  parameters: JSONSchema
}

ExecutionBackend

Abstraction over the execution environment. Implemented by SandboxBackend (Docker) and LocalBackend (host filesystem).

interface ExecutionBackend {
  /** The root directory for all operations (e.g. '/workspace' or '/Users/.../project'). */
  readonly projectRoot: string

  /**
   * Marks backends that operate DIRECTLY on the host filesystem (the local
   * backend), as opposed to a contained sandbox. Consumers use it to judge
   * how loud to be about disabled safety guards — a `..` path or symlink
   * escape on a host backend reaches the operator's real files, while the
   * same escape in a sandbox stays contained.
   */
  readonly hostFs?: boolean

  /** Read a file's content as UTF-8 string. */
  readFile(path: string): Promise<string>

  /** Write content to a file. Creates parent directories as needed. */
  writeFile(path: string, content: string): Promise<void>

  /** Delete a file. */
  deleteFile(path: string): Promise<void>

  /** List entries in a directory. */
  readDir(path: string): Promise<Array<{ name: string; type: 'file' | 'directory' }>>

  /**
   * Run a shell command. Returns stdout, stderr, and exit code.
   * Backends implement this safely (sandbox.exec for Docker, execFile for local).
   */
  run(
    command: string,
    opts?: {
      cwd?: string
      timeout?: number
      /**
       * A budget (ms) the backend enforces INSIDE its own shell, after any
       * environment sourcing a consumer wraps around the command — so an
       * overrun is stopped where it runs and the output so far still comes
       * back, with exit code 124. A backend that cannot enforce it ignores it.
       */
      budgetMs?: number
    },
  ): Promise<{ stdout: string; stderr: string; exitCode: number }>
}

ToolBuildConfig

Configuration for building the tool set. Allows consumers to customize security, callbacks, and tool selection.

interface ToolBuildConfig {
  /** Which tools to include. Defaults to all. */
  include?: string[]

  /** Which tools to exclude. Applied after include. */
  exclude?: string[]

  /** Whether to validate paths stay within projectRoot. Default: true. */
  pathGuards?: boolean

  /** Whether to check symlinks resolve within projectRoot. Default: false (sandbox-only). */
  symlinkGuards?: boolean

  /** Whether to redact secrets in file reads and command output. Default: true. */
  redactSecrets?: boolean

  /** Whether to block dangerous shell commands (env dumps, /proc access). Default: false. */
  blockDangerousCommands?: boolean

  /**
   * Consumer-specific command guard for `exec_command`, checked BEFORE execution (after
   * the built-in dangerous-command check). Return an error string to block the command —
   * it is returned to the model verbatim, so make it actionable (say what to do instead) —
   * or `null`/`undefined` to allow it. Keeps environment-specific rules (e.g. an IDE
   * sandbox forbidding installs that would break its preinstalled library) out of this
   * shared package.
   *
   * @param command - The shell command the model asked to run.
   * @param cwd - The resolved working directory it would run in.
   * @returns An error string to block, or null/undefined to allow.
   */
  blockCommand?: (command: string, cwd: string) => string | null | undefined

  /**
   * Timeout (ms) for a single `exec_command` run. `exec_command` legitimately
   * runs LONG — `npm install`, a production build, a test suite — so the default
   * is generous (2 min); the old 30 s hardcap killed those spuriously. A consumer
   * that wraps tool calls in its own outer timeout should set this to match (or
   * slightly exceed) that budget so its own timeout is the effective bound and
   * produces the nicer "tool timed out" message. Quick commands are unaffected —
   * this is only the ceiling before a wedged command is killed.
   */
  execTimeoutMs?: number

  /**
   * Budget (ms) for a single `exec_command`, passed to `backend.run` as
   * `budgetMs`: the backend runs the command under `timeout` in its own shell,
   * so when it overruns it is stopped there and the tool still returns
   * everything it printed until then (exit code 124 plus an `error` naming
   * the limit). Without this, an outer per-tool timeout races the run and
   * discards minutes of build or test output along with the result. Set it a
   * little under that outer timeout. Unset = no budget.
   */
  commandBudgetMs?: number

  /**
   * How long (ms) exec_command waits for a command it backgrounded only because the
   * command asked for more than {@link commandBudgetMs}, before handing back a task
   * id; one that finishes inside it returns like a foreground run. Default 45000.
   */
  overCeilingGraceMs?: number

  /**
   * Directory names `search_files` and `find_files` skip (VS Code
   * `search.exclude` semantics). Defaults to `DEFAULT_SEARCH_EXCLUDED_DIRS`
   * (node_modules, VCS dirs, build output). Pass the consumer's per-project
   * setting so every search surface shares ONE synchronized set.
   */
  searchExcludedDirs?: string[]

  /**
   * Whether a LARGE HTML document should come back as its OUTLINE — the head's
   * tags and the tags that open each body section, one line each, plus a note
   * naming the offset/limit that returns the whole document — instead of its
   * whole text. Over {@link MAX_HTML_INLINE_CHARS}, and only when the read named
   * no line window.
   *
   * A getter, not a boolean, because the answer belongs to the TURN, not to the
   * tool set: a host that plans first and builds after wants an outline while the
   * executor is researching (where a read it cannot act on is pure context cost)
   * and the document itself while it is writing the page. Unset means every big
   * HTML read returns its text, which is what a host with no such phases wants.
   *
   * @returns True when a large HTML read should be outlined rather than returned.
   */
  outlineLargeHtml?: () => boolean

  /** Post-write hook (e.g. auto-format via Prettier/ESLint). Called after every write_file/edit_file. */
  onAfterWrite?: (path: string) => Promise<void>

  /** Diff tracking callback. Called before writes with old/new content. */
  onFileDiff?: (event: FileDiffEvent) => void

  /** Structural change callback. Called on create_directory, delete_file, rename_file. */
  onFileChange?: (event: FileChangeEvent) => void
}

FileDiffEvent

Payload emitted when a tracked file changes contents.

interface FileDiffEvent {
  path: string
  oldContent: string | null
  newContent: string
}

FileChangeEvent

Payload emitted when a file is created, modified, or deleted structurally.

interface FileChangeEvent {
  type: 'created' | 'modified' | 'deleted'
  path: string
}

SkillEntry

Metadata for a discovered skill (used in PromptContext).

interface SkillEntry {
  /** Skill name. */
  name: string
  /** Short description. */
  description: string
  /** Relative path to the SKILL.md file. */
  path: string
}

PromptContext

Context for building a composable system prompt.

interface PromptContext {
  /** Agent identity (e.g. 'Synthase', 'Polish Agent'). */
  agentName: string

  /** Project root path. */
  projectRoot: string

  /** Names of available tools (for the tool listing section). */
  tools: string[]

  /** Project-specific rules (AGENTS.md or CLAUDE.md content). */
  projectDocs?: string

  /** Additional skill/reference content to inject. */
  skills?: string[]

  /** Discovered skills to list in the prompt (use load_skill to read on demand). */
  discoveredSkills?: SkillEntry[]

  /** Custom sections to append to the prompt. */
  customSections?: string[]
}

FailedTest

One failed test, as a running suite's log names it.

interface FailedTest {
  /** The location the log writes (`specs/e2e/articles.spec.ts:32`), or '' when it writes none. */
  where: string
  /** The test's own title — the last segment of its title path. */
  title: string
  /** How long it ran before failing, in seconds, or null when the log does not say. */
  seconds: number | null
}

RepeatingFailure

A cause repeating among a running suite's failures.

interface RepeatingFailure {
  /** How many failures share the signature. */
  count: number
  /** The shared error line, when the log carries the failures' bodies (`line` reporter). */
  message: string | null
  /** The shared duration in seconds, when the failures line up by how long they ran. */
  seconds: number | null
  /** The failures that share it, in log order. */
  tests: FailedTest[]
}

Types

DiscoveredSkill (deprecated)

Re-export SkillEntry as DiscoveredSkill for backwards compatibility.

type DiscoveredSkill = SkillEntry

Classes

ToolInputError

A tool call the handler refuses with a message meant for the model as is — thrown from deep inside a handler (a path check), returned by the guard as the call's error without the generic "check your parameters" wrapping.

class ToolInputError extends Error {}

Functions

createLocalBackend(projectRoot)

Create an ExecutionBackend that operates on the local filesystem.

function createLocalBackend(projectRoot: string): ExecutionBackend
  • projectRoot — Absolute path to the project root directory

Returns: A backend wired to fs/promises and guarded subprocess calls.

createSandboxBackend(sandbox, projectRoot)

Create an ExecutionBackend that delegates to a Docker sandbox instance. The sandbox.exec method is inherently safe — it runs inside an isolated Docker container.

function createSandboxBackend(sandbox: SandboxLike, projectRoot?: string): ExecutionBackend
  • sandbox — A running Sandbox instance from @molecule/api-code-sandbox
  • projectRoot — Root directory inside the sandbox (default: '/workspace')

Returns: A backend that proxies all filesystem calls into the sandbox.

normalizeToolInput(schema, raw)

Move known aliases onto the declared parameter names and report what is still missing. Unknown keys are left in place — handlers ignore them, and dropping them would hide a model's intent from anything reading the call log.

function normalizeToolInput(schema: NormalizableSchema, raw: unknown): NormalizedInput
  • schema — The tool's declared schema.
  • raw — Whatever the model sent as the tool input.

Returns: The normalized input plus the missing-required and aliased lists.

missingParamError(schema, input, missing)

The error a tool returns when a required parameter is still absent after aliasing. Model-facing, so it names the right parameter, what was actually sent, and the shape of a correct call — everything needed to fix it in one turn instead of concluding the tool is broken.

function missingParamError(
  schema: NormalizableSchema,
  input: Record<string, unknown>,
  missing: string[],
): string
  • schema — The tool's declared schema.
  • input — The normalized input (used to list the keys that were sent).
  • missing — The required parameters still absent.

Returns: A single-sentence-per-clause error string.

guardToolExecute(schema, impl)

Wrap a tool handler so every call is normalized first and a handler that THROWS returns something the model can act on.

The guard deliberately does NOT pre-empt the handler when a required parameter looks absent: several handlers accept a documented older shape (edit_file's top-level old_string/new_string) or answer a missing parameter with far better guidance than a generic message could (edit_file telling the model to re-read the file). It steps in only where the handler would otherwise crash — and when it does, a still-missing required parameter is the accurate diagnosis for that crash, so it is reported as one.

function guardToolExecute(
  schema: NormalizableSchema,
  impl: (input: Record<string, unknown>, ...rest: never[]) => Promise<unknown>,
  options?: GuardOptions,
): (input: unknown, ...rest: never[]) => Promise<unknown>
  • schema — The tool's declared schema.
  • impl — The raw handler.

Returns: A handler with the same signature, guarded.

guardAITool(tool)

Apply {@link guardToolExecute} to a ready-made tool object (the @molecule/api-ai AITool shape: { name, description, parameters, execute }), returning a copy whose execute is guarded.

Use it at the point a consumer hands its full tool list to the model, so tools built outside this package get the same treatment as the ones built inside it.

function guardAITool(tool: T, options?: GuardOptions): T
  • tool — The tool to guard.

Returns: A copy with a guarded execute.

commandRunsNoTests(command)

Whether a shell command invokes a test runner and EVERY such invocation only prints — so the command runs no test at all.

A command that invokes no runner is NOT one of these (false): "the command runs no test" is then true for ls, which is not the question any caller is asking. The question is whether the runner invocation each caller read as a run is one.

A command that also runs tests is false however it is spelled: npx playwright install && npx playwright test installs and runs, so a caller that sees playwright test in it is reading a real run.

function commandRunsNoTests(command: string): boolean
  • command — The shell command as the executor sent it.

Returns: True when the command invokes a runner only to print.

discoverSkills(backend)

Discover skills from a project directory.

Scans .agents/skills/ and .claude/skills/ for SKILL.md files. Reads the YAML frontmatter of each to extract name: and description: fields.

function discoverSkills(backend: ExecutionBackend): Promise<SkillEntry[]>
  • backend — Execution backend to use for filesystem access

Returns: Array of discovered skills with name, description, and path

buildAgentPrompt(ctx)

Build a coding-focused system prompt from composable sections.

Returns a string that includes:

  • Agent identity
  • Available tools listing
  • Coding best practices
  • Tool argument formatting guidance
  • Project docs (if provided)
  • Discovered skills listing (if provided)
  • Inline skills (if provided)
  • Custom sections (if provided)
function buildAgentPrompt(ctx: PromptContext): string
  • ctx — Prompt construction inputs (tools, docs, skills, etc.).

Returns: Fully assembled system prompt text for the coding agent.

moleculeDistReadNote(path)

The steer attached to a SUCCESSFUL read of a package's built output. The ENOENT redirect below fires only when the read FAILS, so a model that knows the hoisted layout reads node_modules/@molecule/<pkg>/dist/*.js happily and never learns the documented API is one call away (X0 flash-m70: 24 such reads in 57 s, no note on any of them). Carried as distNote rather than merged into note so a host that has read_molecule_doc (molecule-dev's cadence note, which also names the functions) can supersede it without string-matching.

function moleculeDistReadNote(path: string): string | null
  • path — The resolved path that was read.

Returns: The note, or null when the path is not inside a package's dist.

buildTools(backend, config)

Build a complete set of AI agent tools bound to an execution backend.

function buildTools(backend: ExecutionBackend, config?: ToolBuildConfig): AITool[]
  • backend — The execution environment (sandbox or local filesystem)
  • config — Optional configuration for security, callbacks, and tool selection

Returns: Array of AITool objects ready to pass to an AI provider

shellQuote(s)

Shell-safe quoting using single quotes. Unlike JSON.stringify (double quotes), single-quoted strings prevent command substitution ($(), backticks) and variable expansion.

function shellQuote(s: string): string
  • s — Raw string to wrap for POSIX shell single-quoted context.

Returns: A single-quoted shell literal representing s.

stripControlChars(s)

Strip C0 control chars (except tab, newline, CR) that break PostgreSQL JSONB and can cause rendering issues.

function stripControlChars(s: string): string
  • s — Arbitrary text that may contain disallowed control characters.

Returns: A copy of s with unsafe control characters removed.

redactSecrets(s)

Redact values of common secret/credential patterns in text output.

ENV-DUMP GRADE — includes the JSON KEY: 'value' passes, which key off the NAME beside the value and therefore cannot tell a credential from ordinary code that happens to use a keyword-ish identifier. Use this for .env reads and command output (where an env dump is the actual threat); use {@link redactSecretsInCode} for source-file content.

function redactSecrets(s: string): string
  • s — Log or command output that may contain .env-style secrets.

Returns: A redacted copy safe to surface to end users or models.

redactSecretsInCode(s)

CODE-SAFE redaction — the env-assignment pass of {@link redactSecrets} WITHOUT the JSON KEY: 'value' passes.

Those passes match on the NAME next to a quoted value, so over source code they replace legitimate content at enormous scale: forgotPasswordEndpoint: '/users/forgot-password', apiKeys: 'API keys', and every localized "Show password" string all became '[REDACTED]'. Because the agent writes back the content it reads, that token then lands in the user's project — measured at 10,952 of 27,919 flagship template files before this split.

No value heuristic can fix that: a legitimate password = 'TestPass123!' in a test helper is indistinguishable from a real credential by shape. So the name-keyed passes simply do not run over code. Credentials in source are still caught by the env-assignment pass here, and consumers layer VALUE-SHAPE detection (vendor prefixes, PEM blocks, credentials in a URL authority) on top — which is what actually catches a secret sitting under an innocuous name.

function redactSecretsInCode(s: string): string
  • s — Source-file content or other code-shaped text.

Returns: A redacted copy that preserves ordinary code verbatim.

printsOnlySource(command)

Whether a shell command only PRINTS source files (cat a.tsx, sed -n '1,40p' x.ts, head -50 b.hurl): its output is file content and gets the code-safe redaction, like read_file. Through the env-dump grade a printed type AuthState = 'authenticated' | … came back as '[REDACTED]', and the executor wrote the mask into a new file (X1 flash-m58). Any segment that is not such a print, or any env file named, keeps the full grade.

function printsOnlySource(command: string): boolean
  • command — The shell command.

Returns: True when every segment prints source files only.

isEnvFilePath(path)

Whether a path is an env file, for which {@link redactSecrets}' full env-dump treatment is appropriate rather than {@link redactSecretsInCode}. Matches .env, .env.<suffix>, and <name>.env.

function isEnvFilePath(path: string): boolean
  • path — A workspace-relative or absolute file path.

Returns: true when the file is an env file.

declaredBudgetSeconds(command, timeoutParam)

The time budget a COMMAND declares for itself — a timeout <seconds> prefix anywhere in it, or the tool call's own timeout (ms) parameter, whichever is larger. Zero when it declares none.

function declaredBudgetSeconds(command: string, timeoutParam: unknown): number
  • command — The shell command.
  • timeoutParam — The tool call's timeout input, in milliseconds.

Returns: The declared budget in seconds, or 0.

outputIsWithheldUntilExit(command)

Whether a command's output reaches the tool only when the whole pipeline ends — a pipe into tail, grep, sort, wc and friends, which block-buffer when stdout is not a terminal.

It matters only in combination with an overrun: a command stopped at the tool's ceiling normally hands back everything it printed, but behind one of these it printed nothing, so the whole budget is spent for no information. Measured across six real agent runs: ten such commands, every one of them piped, 49 minutes returning a few hundred bytes each.

function outputIsWithheldUntilExit(command: string): boolean
  • command — The shell command.

Returns: True when a kill would return no useful output.

outputRedirectTarget(command)

The file a command sends its own output to, when it redirects it (> out.txt, >> log, &> run.log, | tee [-a] file), or null. A background command that does this leaves its task log empty, so the log is the wrong place to look and an empty log proves nothing (X1 flash-m29).

function outputRedirectTarget(command: string): string | null
  • command — The shell command.

Returns: The target as written, or null (/dev/null and &1/&2 are not files).

streamingBackgroundCommand(command)

Rewrite a command about to run in the background so its log grows while it runs. Two shapes left the log empty until exit, and an empty log is what wait_for_task reads as "stuck", so working suites were reported stuck and killed (X1 run flash-m15 lost 20 of its 60 minutes this way, 2026-10-15):

  • a trailing | tail -N, which holds everything back until the command ends. It is dropped: wait_for_task already returns the end of the log.
  • playwright test with no --reporter, which runs the config's reporter — often html, which prints nothing per test. --reporter=list is added when the playwright run is the command's last step and nothing is piped or redirected from it: it writes each result as it finishes, and it is the shape the platform reads a background suite back in (suite-fail-fast.ts's fail-fast note and the Tests bar both parse list lines). A run that names --reporter=line is switched to list for the same reason — line writes [n/N] <title> with no ✓/✘ mark and no duration, so on X1 flash-m62 an 8.3-minute walk of one suite could never trip the fail-fast note.

A run being ITERATED on (capped, narrowed, or by file) gets list,line instead of list alone. list holds every failure's error for the summary at the end, so mid-run its log says only how long each failure took; line writes the body as the failure happens, and a look at a running suite (wait_for_task's

(description truncated — read the full text in node_modules/@molecule/api-ai-tools/dist/utilities.d.ts.)

function streamingBackgroundCommand(command: string): { command: string; changes: string[] }
  • command — The command as the executor sent it.

Returns: The command to run and one sentence per change made (empty when none).

isSuiteRunCommand(command)

Whether a command runs a whole test suite — a Playwright, Vitest or Hurl run — rather than a build, an install or a one-off script.

A command that invokes a runner only to print is not one of these ({@link commandRunsNoTests}): npx playwright test --list prints the tests it would run and exits, and npx playwright install, vitest list and hurl --help start no test either.

function isSuiteRunCommand(command: string): boolean
  • command — The shell command.

Returns: True when the command starts one of those runners.

isPlaywrightRunCommand(command)

Whether a command starts a Playwright run — playwright test in a form that runs something.

npx playwright test --list matched every \bplaywright\s+test\b test in the platform and was then treated as a run: held (flash-m79's config check), backgrounded for a run that outlasts the tool's ceiling, and rewritten with --reporter. A list takes two seconds, prints its lines and exits, so it is none of those; {@link commandRunsNoTests} is what decides.

function isPlaywrightRunCommand(command: string): boolean
  • command — The shell command.

Returns: True when the command starts a Playwright run.

suiteRunPriorityLaunch(command, launch)

Start a background suite run at lower CPU and IO priority, so the sandbox it shares with everything else stays responsive to what the executor does next.

A whole-suite Playwright run saturates the one container the app's Vite server, its API process and every other command also live in: on X1 flash-m75 the model backgrounded a 139-test run at +36:23 and the two-word shell command it sent next — tick its plan file — returned nothing for 2 min 40 s, measured on the chat payload and on the live-stream follower independently. The backgrounding note is right that the executor should go do other work; what it cannot promise is that the sandbox will let it.

nice -n 10 lowers the run's CPU share and ionice -c3 its IO class. Both are PROBED in the sandbox by RUNNING them, not with command -v: a syscall a container's seccomp policy refuses leaves -v passing while the real run fails. Each is applied only if its probe passed, so a sandbox with neither, or with only one, runs the suite exactly as it did before. Playwright's own workers are children of this shell and inherit both (niceness and IO class are inherited across fork/exec).

Only a command the caller has already detached reaches this: foreground commands are never touched.

function suiteRunPriorityLaunch(command: string, launch: string): { launch: string; note: string }
  • command — The command as the executor sent it; the suite detection reads this.
  • launch — The shell line that would start it, with no prefix of its own.

Returns: The line to run, and one sentence for the tool note ('' when unchanged).

checkBlockedCommand(command)

Check if a command is blocked for security reasons. Returns error message or null if allowed.

function checkBlockedCommand(command: string): string | null
  • command — Shell command string proposed for execution.

Returns: A human-readable block reason, or null when the command is allowed.

resolvePath(path, projectRoot)

Normalize a path to be absolute within the project root. Empty string and '/' both resolve to projectRoot. Rejects paths that escape via traversal or absolute paths outside root.

function resolvePath(path: string, projectRoot: string): string
  • path — Relative or absolute path inside the workspace.
  • projectRoot — Absolute filesystem root for the active project.

Returns: A normalized absolute path confined to projectRoot.

isOutsideRoot(path, projectRoot)

Whether a tool path points outside the project root once normalized (an absolute path elsewhere, or a .. that climbs out). {@link resolvePath} clamps such a path to the root; a file tool must refuse it instead of answering about the root — a list_files /tmp/x used to return the workspace listing as if it were /tmp/x (X1 runs sonnet-m17 and sol-m05, 2026-10-14).

function isOutsideRoot(path: string, projectRoot: string): boolean
  • path — The path the tool was given.
  • projectRoot — The project root.

Returns: True when the path leaves the root.

isValidGlob(pattern)

Validate that a glob/include pattern is safe (no shell metacharacters that could inject). Allows alphanumeric, * ? . _ - / and the bracket/paren glob chars [] ().

The brackets/parens matter for real frameworks: Next.js App Router names route directories [id], [...slug], (group), [[...optional]], so without them the executor cannot find_files/search_files its own routes on any Next.js project — a hard block observed on live imports. They are injection-safe here because every caller passes the pattern through shellQuote before it reaches find -name/grep --include, where inside single quotes []() are literal (a subshell (...) only starts UNquoted); to the glob engine [abc] is a normal character class. The genuinely dangerous metacharacters (; | & $ \ > < \n` space) remain disallowed.

function isValidGlob(pattern: string): boolean
  • pattern — User-supplied glob fragment for search/list operations.

Returns: true when the pattern contains only allowed characters.

findPatternArgs(pattern)

The find predicate for a find_files pattern.

find -name compares a pattern against a file's BASENAME, so a pattern containing a slash (helpers/*, e2e/*.spec.ts, src + ** + /App.tsx) can never match and the tool answers [] (xr-molecule-flash-m77, on two calls in one run). Such a pattern is matched with -path instead: an fnmatch glob over the whole printed path, whose * crosses / — the rule .gitignore uses, and the reason a bare helpers/* means "a helpers/ directory anywhere below the root". The pattern is anchored with a leading * then / ("any parent") so the search root — an absolute /workspace/<app> in a sandbox — need not be spelled out.

A doubled-star directory segment keeps its usual meaning of ZERO or more directories; -path alone would demand at least one, so the pattern is also emitted with each such segment removed.

function findPatternArgs(pattern: string): string
  • pattern — A pattern already accepted by {@link isValidGlob}.

Returns: A find predicate: -name '…' for a bare name, else one or more -path '…' alternatives, ORed inside parens when there is more than one.

pathArgError(path, tool)

Validate a file tool's path argument is a non-empty string. A weak model sometimes omits it or passes a non-string, which would otherwise crash resolvePath (path.replace on undefined) with the cryptic, unactionable "Cannot read properties of undefined (reading 'replace')" — wasting executor turns. Returns an actionable message, or null when the path is usable.

function pathArgError(path: unknown, tool: string): string | null
  • path — The raw path argument from the tool input.
  • tool — The tool name, for the error message (e.g. 'read_file').

Returns: An actionable error string, or null when path is a non-empty string.

directoryReadHint(message, path)

Detect a "read/edit targeted a directory, not a file" failure from a backend error message (local fs EISDIR or the sandbox's cat: X: Is a directory), and return an actionable message steering the model to list_files. Returns null when the error is not a directory error.

function directoryReadHint(message: string, path: string): string | null
  • message — The backend error message.
  • path — The resolved path that was targeted.

Returns: An actionable directory-error string, or null.

sliceLines(content, window)

Return a line window of content, or null when no window was asked for.

read_file returned whole files only, so an executor that wanted part of one shelled out: across six real agent runs, 260 of 377 shell-inspection commands (69%) were head/tail/sed -n windowing a file the tool could have windowed itself. offset is 1-based, matching the line numbers every other tool reports and what grep -n prints.

function sliceLines(
  content: string,
  window?: { offset?: unknown; limit?: unknown },
): { text: string; offset: number; lines: number; totalLines: number; truncated: boolean } | null
  • content — The file's full text.
  • window — The requested offset (1-based) and limit (line count).

Returns: The window plus its position, or null when neither was given.

looksLikeHtml(text)

Whether text is an HTML document (a doctype or an <html root tag).

function looksLikeHtml(text: string): boolean

htmlOutline(text, label)

An OUTLINE of an HTML document: its head's tags and the tags that open each body section, one per line, with line numbers.

Line-based on purpose — a parser would be a dependency and an attack surface for a read that only has to be navigable. A line is shown when it starts with < after trimming, which is what keeps the inside of a <style> block, a <script> body or a prerendered text section out of the outline while keeping every tag the document is built from. Long lines (a minified tag, a data URI) are cut, and the whole outline is capped, so a pathological document cannot turn one read into another wall of text.

function htmlOutline(text: string, label?: string): string | null
  • text — The document's full text.
  • label — What to call the document in the outline's header (its path).

Returns: The outline — or null when text is not an HTML document.

truncate(s, maxLength)

Truncate a string to a max length with a truncation notice.

function truncate(s: string, maxLength: number): string
  • s — Arbitrary text to bound in size.
  • maxLength — Maximum number of characters to retain before truncating.

Returns: Either the original string or a shortened copy with a trailing notice.

truncateMiddle(s, maxLength)

Truncate keeping BOTH the head and the tail, eliding the middle — for command output (build / test / migration / install logs). Plain head truncation ({@link truncate}) drops the TAIL, which is exactly where a failing command puts the reason: the npm ERR! line, the test-failure summary (1 failed, 240 passed), the migration stack trace. When that is cut, the executor sees only passing progress and can't tell WHY the command failed — a self-inflicted error then survives every fix round. The head still shows what ran and the first errors; the split is weighted toward the tail since the summary lives there. No-op when s already fits.

function truncateMiddle(s: string, maxLength: number): string
  • s — Arbitrary text (typically stdout/stderr) to bound in size.
  • maxLength — Maximum characters to retain (excluding the elision notice).

Returns: The original string, or head + an elision notice + tail.

failureBlocks(s)

The numbered failure blocks of a Playwright report ( 1) [chromium] › file:line › title up to the next block or the tally).

function failureBlocks(s: string): string[]
  • s — Test-runner output.

Returns: Each block, in order (empty when there are none).

runningOutputSliceCommand(log, own)

The shell command whose stdout is the interesting part of a log that is still being written: the failure lines so far (capped at {@link RUNNING_FAILURE_LINES}), then the end of the log.

A Playwright list run prints one line per test as it finishes and its error blocks only at the end, so the LAST bytes of a running log are its least informative part — in X1 flash-m61 the last 3 000 bytes of a 139-test walk were the skip list, and all 61 failures were above them, which cost the run the grep it had to make anyway.

function runningOutputSliceCommand(log: string, own?: string): string
  • log — The task log path.
  • own — A file the command redirects its own output to, if any.

Returns: The shell command, safe to hand to the execution backend as-is.

runningCauseCommand(log, own)

The shell command whose stdout is enough of a running log to tell whether one cause is repeating: its tail, {@link REPEATING_CAUSE_TAIL_BYTES} of it.

{@link runningOutputSliceCommand} is the wrong read for this: it keeps only the FIRST 40 failure lines plus 1 000 bytes, so on a run whose failures print a body each — which is what a line reporter does, and the only form that names the cause as an error message rather than a duration — three bodies do not fit, and the repeat is invisible. This read is not shown to anyone; it is parsed.

function runningCauseCommand(log: string, own?: string): string
  • log — The task log path.
  • own — A file the command redirects its own output to, if any.

Returns: The shell command, safe to hand to the execution backend as-is.

repeatingFailure(log)

The cause repeating among a running suite's failures, or null.

Playwright's list reporter prints one line per test as it finishes — its mark, its title path and how long it ran — and defers each failure's error to the summary at the end, so mid-run the only signature those lines carry is the duration. A line reporter prints the failure bodies as they happen, and there the repeated error message itself is the signature, which is stronger (it needs no assumption about how long a failure should take), so the message is looked for first.

Either way the point is the same: a suite whose failures repeat one cause spends its whole runtime proving that one cause, and the executor watching it can be told so at the third failure instead of at the last. X1 flash-m76 walked 15 tests in 4.0 minutes where 15.6 s each was the test timeout and one missing navigate('/') was the cause of all of them.

function repeatingFailure(log: string): RepeatingFailure | null
  • log — The log so far.

Returns: The repeating cause, or null when no signature has {@link REPEATING_FAILURE_MIN} failures behind it.

repeatingFailureNote(found, o)

The sentence that names a cause repeating among a running suite's failures, and the command that stops the run so the cause can be fixed first.

Kept here, beside {@link repeatingFailure}, so the two stay in step: every word below is about what that function could actually see in the log.

function repeatingFailureNote(
  found: RepeatingFailure,
  o: { taskId: string; log: string; stop: string },
): string
  • found — What {@link repeatingFailure} found.
  • o — The run's facts: its taskId, its log path, and stop — the shell command that stops it (the pids the platform's own pid file leads to).

Returns: The note.

whitespaceTolerantReplace(content, oldString, newString)

Attempt a whitespace-tolerant replacement when an exact old_string match failed. Finds a contiguous run of lines in content whose per-line whitespace-normalized form (runs of whitespace collapsed to one space, then trimmed) equals the normalized oldString lines, and replaces that run with newString verbatim. Applies ONLY when exactly one such run exists — uniqueness keeps it safe; an ambiguous (or zero) match is refused (returns null) so the caller falls back to its existing error path.

This rescues the most common edit_file failure: a (weak) executor reproduces the target text correctly but with different indentation or trailing whitespace, which would otherwise bounce it into a re-read/retry loop — the single biggest source of wasted edit turns.

function whitespaceTolerantReplace(
  content: string,
  oldString: string,
  newString: string,
): string | null
  • content — Current file content.
  • oldString — The search text (an exact match has already failed).
  • newString — The replacement text, applied verbatim.

Returns: The new content if a unique fuzzy run matched, else null.

parseCheckCommand(path)

The shell command that parses one file and exits non-zero with the error when it does not parse: node --check for JavaScript, JSON.parse for JSON, esbuild's transform (present wherever Vite is) for TypeScript.

function parseCheckCommand(path: string): string | null
  • path — The file's absolute path.

Returns: The command, or null when the file type has no cheap parser.

parseCheckProblem(result)

What a failed parse check says to the executor, or null when the check passed or could not run (no parser available is not a syntax error).

function parseCheckProblem(result: {
  stdout: string
  stderr: string
  exitCode: number
}): string | null
  • result — The check command's result.

Returns: The message for the tool result, or null.

fusedLine(content, newString)

The line a replacement fused when it dropped the newline its old_string ended with: new_string's text is followed on the same line by what used to be the next line. Read off the file as it now is, so the line shown is the line the parser is actually complaining about.

function fusedLine(content: string, newString: string): { line: number; text: string } | null
  • content — The file's content after the edit.
  • newString — A replacement's new_string, whose old_string ended with a newline.

Returns: The fused line's 1-based number and its text, or null when the replacement's text is not followed by more of the same line (it ends the file, the following newline survived, or another replacement rewrote it).

Constants

TOOL_SCHEMAS

Canonical tool schemas shared by the agent runtime and documentation.

const TOOL_SCHEMAS: Record<string, ToolSchema>

MAX_READ_SIZE

Max file size for read_file (5MB).

const MAX_READ_SIZE: number

MAX_WRITE_SIZE

Max content size for write_file (10MB).

const MAX_WRITE_SIZE: number

MAX_OUTPUT_SIZE

Max command output size (100KB per stream).

const MAX_OUTPUT_SIZE: number

MAX_SEARCH_CONTEXT_LINES

Most lines of surrounding context search_files will return per match. Bounded so a broad pattern with a generous context cannot pull a whole tree into one reply.

const MAX_SEARCH_CONTEXT_LINES: 40

MAX_BATCH_READ_FILES

Most files one batched read_file call returns. The cap exists so a survey of a large tree cannot swallow the turn's context in one result; the tool reports where it stopped so the model continues from there.

const MAX_BATCH_READ_FILES: 25

MAX_BATCH_READ_BYTES

Total content bytes one batched read_file call returns, across all files. The per-file {@link MAX_READ_SIZE} still applies to each one.

const MAX_BATCH_READ_BYTES: number

MAX_READ_RETURN_CHARS

The most one read_file call returns for ONE file when no window was asked for, in chars. Larger files come back as their first window with a note saying how to read the rest.

MAX_READ_SIZE (5 MB) is a bound on what the tool will open, not on what it should hand a model: a single batched read that returned a 1.2 MB README put the next three provider calls at 366k tokens (~330k of them fresh) against a 120k-token context cap (X0 run x5, call 26). ~80 KB is ~20–25k tokens — a fifth of that cap, and more than any one file read usually needs.

const MAX_READ_RETURN_CHARS: number

SMALL_FILE_WHOLE_CHARS

A file this small is returned whole on a second window of it (paging it costs a call each). The same bound as {@link MAX_READ_RETURN_CHARS}: the windowed-read note promises that a file under it "fits in ONE call", and a smaller bound here broke that promise (X1 flash-m67).

const SMALL_FILE_WHOLE_CHARS: number

MAX_HTML_INLINE_CHARS

How large an HTML document must be before a whole-file read is replaced by its outLINE — much smaller than {@link MAX_READ_RETURN_CHARS}, because the cost of an HTML read is almost never worth it.

An entry document is boilerplate the reader rarely needs in full: meta tags, stylesheet and preload links, a <style> block, and a handful of <script> tags. The parts an executor acts on — the body's structure, where a script is loaded — are all visible in an outline of one line per tag. X0 flash-m77 paid 63,735 chars for one such read: ~60 KB of it was the IDE's own preview bridges, and the read pushed the transcript past its context cap, so the e2e specs the executor had just read were trimmed to summaries and it read the whole suite again — twice (findings, +01:23 and +02:56). The scaffold now keeps those bridges in separate files, but a template's own head is still ~5-12 KB, and a static-site or SSR entry can be far more.

The outline is not a truncation: the note beside it names the exact offset/limit that returns the whole document in one call.

const MAX_HTML_INLINE_CHARS: number

MAX_HTML_OUTLINE_LINES

Most lines an HTML outline prints before it stops and says so.

const MAX_HTML_OUTLINE_LINES: 200

MAX_SEARCH_RESULTS

Max search results.

const MAX_SEARCH_RESULTS: 50

MAX_FIND_RESULTS

Max find results.

const MAX_FIND_RESULTS: 100

DEFAULT_SEARCH_EXCLUDED_DIRS

Default directory names search_files/find_files skip — VS Code's search.exclude + files.exclude defaults (node_modules, bower_components, VCS dirs) plus the platform's vendored/build dirs. Overridable per consumer via ToolBuildConfig.searchExcludedDirs (a per-project, user-editable setting in molecule.dev — keep the APP-SIDE copy in @molecule/app-ide-react's search types in sync with this list).

const DEFAULT_SEARCH_EXCLUDED_DIRS: readonly [
  'node_modules',
  'bower_components',
  '.git',
  '.svn',
  '.hg',
  'CVS',
  'dist',
  '.next',
  '.vite',
  'molecule',
]

RUNNING_FAILURE_LINES

Failure lines shown from a log that is still being written.

const RUNNING_FAILURE_LINES: 40

RUNNING_TAIL_BYTES

Bytes of a running log's end appended after its failure lines.

const RUNNING_TAIL_BYTES: 1000

RUNNING_FAILURE_LINE_RE

The lines worth showing from a log that is still being written: a failed test's mark or numbered block header, and a runner's own error lines. Matched with grep -E.

const RUNNING_FAILURE_LINE_RE: '^[[:space:]]*(✘|✗|×|✖|✕|[0-9]+\\)|[0-9]+ (passed|failed|flaky|skipped|did not run)|FAIL|FAILED|ERROR|Error:|error:|Traceback|AssertionError)'

REPEATING_CAUSE_TAIL_BYTES

Bytes of a running log a repeating-cause look reads back.

const REPEATING_CAUSE_TAIL_BYTES: 40000

REPEATING_FAILURE_MIN

Failures that must share one signature before a running suite's repeating cause is named.

const REPEATING_FAILURE_MIN: 3

REPEATING_FAILURE_LISTED

How many of a repeating cause's failures the note lists before it says "and N more".

const REPEATING_FAILURE_LISTED: 5

PARSE_CHECKABLE

Files a parse check can vouch for cheaply right after a write.

const PARSE_CHECKABLE: RegExp

Injection Notes

Requirements

Peer dependencies:

  • @molecule/api-ai ^1.0.1

Runtime Dependencies

  • @molecule/api-ai

  • This package only defines the tools — it runs no model loop. Hand them to the bonded AI provider (chat({ tools, … })) or to an @molecule/api-ai-agents run.

  • Safety defaults: keep them on for model-driven use. pathGuards (default true) rejects paths escaping projectRoot; redactSecrets (default true) masks secret-looking values in file reads and command output.

  • exec_command executes REAL shell commands on the backend. Prefer a sandbox backend for untrusted/model-driven work; add environment-specific rules via blockCommand (return an actionable error string to refuse — it is shown to the model verbatim) and enable blockDangerousCommands (default FALSE) where appropriate.

  • read_file takes paths as well as path. An agent that issues one tool call per model round-trip pays a full round-trip per file, so a project survey costs minutes; batched, the same reads cost one call each 25 files (MAX_BATCH_READ_FILES / MAX_BATCH_READ_BYTES, which report where they stopped). Each entry carries its own content or its own error, so one bad path never fails the batch.

  • edit_file shows the result, so the model need not re-read. A successful edit returns excerpt: each edited region as it now reads, with 1-based line numbers (N: text) and a few lines of context, capped at 60 lines in total (what did not fit is named by line range), plus totalLines. write_file returns totalLines, and excerpt only for a small file (up to 40 lines) — a big file is never echoed back. Line numbers count the way read_file does, so they are valid offsets. The excerpt is redacted like a read. The existing fields (ok, replacementsApplied, alreadyApplied, syntaxError) are unchanged.

  • A dropped line break and the parse error it causes are reported as ONE thing. When a replacement's old_string ended with a newline and its new_string does not (so the next line is pulled up onto it) and the file then fails its parse check, edit_file returns a single syntaxError that names the replacement, shows the fused line with its number, and says to re-send that replacement with the newline restored — instead of the newlineNote hint beside an unrelated-looking parse error, which an executor repairs by rewriting the fused line's text and fuses the next one. The newlineNote alone is still returned when the file parses.

  • A parameter under another name is not a broken tool. Every tool's input is normalized first (cmd → command, file → path, camelCase ⇄ snake_case), and a handler that throws returns an error naming the missing parameter rather than a bare internal message — which a weak executor reads as a broken tool and routes around, taking its verification with it. Use guardAITool to give tools built elsewhere the same treatment, passing redactError so a thrown message is masked by your own redactor.

  • Scope the tool set per agent with include/exclude — a read-only agent should not receive write_file/exec_command.

  • Outputs are size-capped (MAX_READ_SIZE, MAX_OUTPUT_SIZE, …) and searches skip DEFAULT_SEARCH_EXCLUDED_DIRS (node_modules, VCS dirs, build output) — pass searchExcludedDirs so every search surface shares the consumer's one setting.

E2E Tests

Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual agent/chat surface and its registered tools, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:

  • [ ] A prompt that should trigger a registered tool makes the model INVOKE it with args matching that tool's parameters schema (e.g. "read src/index.ts" -> calls read_file with { path: 'src/index.ts' }). Confirm the tool's execute actually RAN — its backend side effect / log / the file it touched — not that the model merely narrated calling it.
  • [ ] The tool's returned value flows back into the model and shapes the final answer: the REAL result (the file's actual contents, the command's real stdout/exitCode) appears in the reply, not a plausible hallucination.
  • [ ] A prompt that needs no tool is answered directly, with no spurious tool call.
  • [ ] A tool whose execute throws or returns { error } (missing file, failing command, blocked path) degrades gracefully — the error is caught and fed back to the model as text, the conversation continues, and nothing crashes the request.
  • [ ] The model can invoke ONLY the tools handed to this run: an include/exclude-scoped agent (e.g. read-only — no write_file / exec_command) cannot call an excluded tool, and a tool name the model invents that was never registered is refused, not executed.
  • [ ] Tool execution is server-side and authorized: exec_command / write_file run only on the bonded backend under its guards (pathGuards, symlinkGuards, redactSecrets, blockDangerousCommands / blockCommand) and stay inside projectRoot. Feed a prompt-injected instruction (a file or message telling the model to read /etc/passwd, escape the workspace, or run a privileged command) and confirm the guard REFUSES it — a user must not be able to trigger, via the model, any action they could not perform directly.