opencode + tman

The agent's pre-tool hook can replace the command before it runs, so a bare npm test becomes a supervised one with no cooperation from the model.

vendor
open source
integration
rewrites
hook event
tool.execute.before
config
.opencode/plugin/ or ~/.config/opencode/plugin/

opencode's extension point is a plugin rather than a config-declared shell hook. That is more direct, not less: tool.execute.before receives the tool's arguments as a mutable object, so assigning to output.args.command replaces what runs.

1. Install tman and adopt the project

Everything below assumes the binary is on your PATH and the repository has a .tman.kdl. That file is what scopes caps to this project, and it is also the signal several of the integrations test for before they do anything.

shell
npm install -g @standardbeagle/tman   # or the install.sh one-liner

cd your-project
tman init --shims --gitignore

tman init writes a defaults block and an alias for each test or build command it can detect. Commands it cannot detect are left commented out rather than stubbed, so an alias you never filled in fails loudly instead of exiting 0 having run nothing.

2. Drop in the plugin

Project scope is .opencode/plugin/; global scope is ~/.config/opencode/plugin/. The plugin shells out to tman hook pretooluse so the decision about which commands deserve supervision stays in tman.

.opencode/plugin/tman.js
import { execFileSync } from "node:child_process";

/**
 * Route bare test and build commands through tman.
 *
 * tman owns the classification: which commands count, whether this project is adopted, and whether
 * the supervising binary can be named safely. Empty output means "leave it exactly as written",
 * which is also what every failure here degrades to.
 */
export const TmanSupervision = async () => ({
  "tool.execute.before": async (input, output) => {
    if (input.tool !== "bash") return;
    const command = output.args?.command;
    if (typeof command !== "string" || !command) return;

    let verdict;
    try {
      verdict = execFileSync("tman", ["hook", "pretooluse"], {
        input: JSON.stringify({
          tool_name: "Bash",
          cwd: process.cwd(),
          tool_input: { command },
        }),
        encoding: "utf8",
        timeout: 5000,
      });
    } catch {
      return; // tman missing or unhappy: never cost the user their build
    }

    const supervised = verdict && JSON.parse(verdict)?.hookSpecificOutput?.updatedInput?.command;
    if (supervised) output.args.command = supervised;
  },
});

Subagent tool calls have historically bypassed plugin hooks. If your workflow delegates test runs to subagents, treat the plugin as a convenience and the PATH shims from step 1 as the mechanism you actually depend on — the shims sit below the agent entirely and cannot be routed around.

Tell the model, in AGENTS.md

Shims and hooks catch the mechanical cases. The remaining gap is the model deciding to do something clever — running the suite through a subshell, or with an inline environment assignment, both of which tman deliberately refuses to rewrite because prefixing a string it did not parse changes what runs. A standing instruction in AGENTS.md closes that gap:

AGENTS.md
## Running tests and builds

Run every test, build, lint, and typecheck command through tman — never bare.
Prefer the repo-root shims (`./test`, `./lint`) when they exist; they carry
this project's `.tman.kdl` caps. Otherwise prefix explicitly:

    tman run -- <command>

Never widen the caps from the command line. Exit 124 is a timeout, 125 a stall,
126 a resource cull — report those, do not retry with looser limits.

Verify it is actually on

A supervision integration that silently does nothing is worse than none, because it stops you looking. Two checks, both of which fail visibly:

shell
# 1. the classifier agrees this command is worth supervising
echo '{"tool_name":"Bash","cwd":"'"$PWD"'","tool_input":{"command":"npm test"}}' \
  | tman hook pretooluse

# 2. a real run shows up while it is in flight
tman run --name smoke -- sleep 30 &
tman list

The first prints a JSON object naming the rewritten command. Empty output means tman decided to stay out of the way — no .tman.kdl above cwd, a command it does not classify as test or build work, or a shell string it refused to parse. The second must list the run; if tman list is empty, nothing is being supervised.

Caps worth setting for agent-driven work

An agent re-runs the same command far more often than a human does, and it does it while you are not watching. That changes which caps earn their keep:

capwhy it matters more under an agent
--nameThe agent will start the suite again before the last one finished. A dedup lock refuses the duplicate instead of running two copies against the same port or database.
--max-parallel 2The default, and the right one for most repos. Excess runs queue on a slot file rather than stampeding your cores.
--max-timeThe only cap that bounds a run which is genuinely working but will never finish — a wait on a peer that never answers looks identical to healthy IO.
--stall 30mThe built-in. A hang backstop, not a runtime budget: it should sit well above the longest quiet stretch your slowest command legitimately has.
--max-memOpt-in, and worth it where a leak mode exists — worker pools, browser fleets, long-lived daemons. Off by default because builds are supposed to be greedy.

Per-command starting points live in the tuning guides — one page per test and lint tool, with the reasoning behind each number rather than just the number.

Frequently asked

Where do opencode plugins live?

`.opencode/plugin/` for a single project, `~/.config/opencode/plugin/` globally. Both are loaded, and all hooks from all plugins run in sequence, so a global tman plugin and a project-specific one coexist.

Why shell out to tman instead of matching commands in the plugin?

So there is one classifier. Which commands count as test or build work, whether the project has been adopted, and whether the supervising binary can be safely named are decisions tman already makes and tests; a second copy in JavaScript is a second thing to keep in sync and a second place for it to drift.

Other agents

Sources

Vendor documentation this guide is written against — hook surfaces move, so check yours if something below does not match what you see.