polyagent-mcp
CLAUDE.md5 starsChanged 52 days agoMITView on GitHub
An MCP server (stdio) that lets any MCP host delegate to headless coding-agent CLIs: Codex, Grok, Claude Code, and an opt-in Cursor fallback. The fleet handles implementation, project mapping, surgical reads, filtered command output, and web lookups without putting the worker's full raw context into the caller. The design goal of every tool is context economy: format() in src/index.ts logs the char count returned to context, because that char count is the real cost being optimized. There is no linter…
- Reads credentials
- Installs packages
- Commits and pushes
In this repository4 other files it gives its agents.
- add-engineSkill
- model-refreshSkill
- run-benchSkill
- shipSkill
The file
We split this CLAUDE.md into the rules it gives an agent
3 rules. About 0 are common, 1 is distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 83
Start with levels: ["low", "middle"].
- Distinctive: line 173
All three defaults use subscriptions (codex + claude), with AA quota proxies $0.07 → $0.21 (~3×) → $1.82 (~8.7×): low=GPT-6 Luna max …
- Very specific: line 667
When changing hook behavior, put decisions in pure functions (decide, sessionStartContext, subagentStartContext, promptRouteContext) and …
≈ “Common” is an estimate: at least 1 in 5 of the 26,980 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
An MCP server (stdio) that lets any MCP host delegate to headless coding-agent CLIs: Codex, Grok,
Claude Code, and an opt-in Cursor fallback. The fleet handles implementation, project mapping,
surgical reads, filtered command output, and web lookups without putting the worker's full raw
context into the caller. The design goal of every tool is context economy: format() in
src/index.ts logs the char count returned to context, because that char count is the real cost
being optimized.
Commands
npm run build # tsc → dist/ (the published/registered artifact is dist/index.js)
npm run dev # run the server from source via tsx (no build step)
npm test # vitest run — all unit tests
npx vitest run test/cli.test.ts # single test file
npx vitest run -t "resolveModel" # single test by nameThere is no linter configured. npm run build (tsc, strict: true) is the type-check gate.
prepublishOnly runs the build; only dist/ is published (see files in package.json).
Architecture
Small modules under src/, with pure logic covered by test/*.test.ts. The split exists so
the pure logic is testable without spawning a worker process:
index.ts— MCP server + tool registrations (twelve tools:delegate,fast_delegate,explore,read_slice,run_filtered,web_lookup,fan_out,generate_image,follow_up,bridge_stats,decide,rate). Owns tool descriptions and the sharedroutingparams (cwd/model/effort). The second arg tonew McpServer(...)is aninstructionsstring that states the routing boundary (read/locate/web/grunt-work → bridge tools; native Read only when about to edit). These load at startup and are visible to the host even while tool schemas are deferred — that is why they matter for adoption. The same text also kills two false reasons a host gave (2026-09-27) for skipping polyagent: reading a failedcodexMCP server as "polyagent has no codex" (the bridge spawns the CLI, unrelated to that server), and dropping delegation entirely because the task also needed skills, subagents or a browser (subagents and skills cross viaagent— name or inline{prompt}with the SKILL.md body; only MCP/browser steps stay on the host). Measured live 2026-09-27: an inline persona is followed, the repo's.claude/skillsis visible (inside cwd),~/.claude/skillsand~/.claude/pluginsare not — a skill's bundled files outside cwd never reach the worker.sessionStartContextand the managed CLAUDE.md block carry the same two points. The five core tools (delegate,explore,read_slice,run_filtered,web_lookup) plusfast_delegate,fan_out, andrateregister with_meta: { "anthropic/alwaysLoad": true }so Claude Code (≥2.1.121) eagerly loads their schemas. Secondary tools (generate_image,follow_up,bridge_stats,decide) stay deferred.fast_delegateandfan_outwere deferred and never got called — the same adoption bug that motivated alwaysLoad on the core five.format()appends thesession_idfooter and logs usage;delegate/fast_delegateaccept an optionaldeny: string[](owninputSchema, notrouting) — see "denyblocks commands via shims" in the sandbox section;fan_outconsensus can use the optional Jev agreement gate before its Codex arbiter; a Jev failure or low agreement keeps the existing arbiter path.follow_upfeeds that id back asRunOpts.resumeso a prior worker session continues without resending its context — the footer andfollow_upare two ends of the same loop.follow_uptakes an optionalmode— without it, a resumed session regains full tool access, so continuing a read-only session (explore/read_slice/web_lookup) must passmode:'ask'to stay read-only. The default (no mode) is for continuing adelegate.cli.ts— the only module that touches the child process.runCursor()spawns the engine's CLI;buildCursorArgs()/buildGrokArgs()/buildCodexArgs()/buildClaudeArgs()/buildOpencodeArgs()/buildKimiArgs()/buildMuseArgs()(+buildArgsdispatcher),resolveModel(),parseCliJson()/parseCodexJsonl()/parseOpencodeJsonl()/parseKimiJsonl()/parseMuseJsonl()(+parseOutputdispatcher),resolveTier(),resolveDelegate(),resolveFastTier(),resolveAuxTool(),resolveReadTool(),resolveRunFiltered(),hasEngine(),binExists(),budgetNote(),evidenceNote()are pure and unit-tested. Keep the spawn boundary here — do not spawn from elsewhere.agents.ts— resolves an optionaldelegate/fast_delegatepersona on the host. A name such aspit:issue-investigatorsearches the workercwd's.claude/agents, then the host project's (process.cwd()of the MCP server — the host often pointscwdat a sibling worktree, 2026-10-28), then home.claude/agentsand~/.claude/plugins; plugin collisions pick the newest match by mtime. An inline{prompt}skips lookup. Only the markdown body crosses into the worker, and names containing/or..are rejected.{ skill: "plugin:name" }resolvesskills/<name>/SKILL.md(worker cwd.claude/skills, host project.claude/skills,~/.claude/skills, then~/.claude/plugins, newest by mtime, plugin prefix filters the path): the body becomes the persona and the canonical skill folder comes back asmountRo, whichdelegate/fast_delegatepass asRunOpts.roBinds— the one exception to "no new bind", so the skill'sscripts//references/are readable (RO) in the sandbox. Motivated by handoff 0/2 inresearch/2026-09-30-adoption-handoff.md.claudeMd.ts— managed routing block in the user's global~/.claude/CLAUDE.md(override path withPOLYAGENT_CLAUDE_MD_PATH), delimited by<!-- polyagent-mcp:begin v=<version> -->…<!-- polyagent-mcp:end -->.renderPolyagentBlockgenerates it fromTIERS(levels never drift) and carries thefan_outtriggers: 27 realfan_outcalls against 744delegatein the usage log (2026-07-03 → 2026-09-26) motivated it — the host judged alone instead of fanning out. Start withlevels: ["low", "middle"]; add"high"only after a cheaper consensus disagreed or for a costly verdict.fan_outaccepts at least two names, with repeats allowed.npm run install-claude-md(src/installClaudeMd.ts,--dry-runto preview) is the explicit first install: it removes the old hand-written<polyagent_preference>section, writesCLAUDE.md.polyagent.bak, and appends the block. After that,index.tsrefreshes the block on every boot — only when the markers already exist, never creating one, never throwing, never writing to stdout (the MCP channel).POLYAGENT_CLAUDE_MD=offdisables the boot refresh. A broken marker pair (begin without end, or two blocks) is skipped on boot and raises on install.writeFileSyncfollows a symlinkedCLAUDE.md(dotfiles), so the link survives.skill.ts— managed Agent Skillpolyagent, modeled on ai-memory's skills:renderPolyagentSkillgenerates it fromTIERS,syncSkillwrites<root>/polyagent/SKILL.mdfor each ofSKILL_ROOTS(~/.claude/skillsand~/.agents/skills;POLYAGENT_SKILL_ROOTSoverrides). The always-loadedCLAUDE.mdblock routes; the skill loads on demand and carries the how-to (worker prompt shape,deny,agenthandover,follow_upmode:'ask',ratescale). Ownership is the<!-- polyagent-mcp-managed: routing-skill v=… -->marker: a same-name file without it is user-owned and never written.npm run install-skill(src/installSkill.ts,--dry-run) creates it; boot only refreshes marked copies, never creates, never throws.POLYAGENT_SKILL=offdisables the boot refresh.
Engines & tiers (multi-CLI)
The bridge drives seven coding-agent CLIs, each with its own dialect and output format —
RunOpts.engine ("cursor"|"grok"|"codex"|"claude"|"opencode"|"kimi"|"muse") selects one. Cursor is outside the default
tier path; it is available as a fallback only when POLYAGENT_ENABLE_CURSOR=1
(CURSOR_ENABLED).
- cursor (
cursor-agent) — opt-in fallback only.POLYAGENT_CURSOR_BINdefaults tocursor-agent, NOTagent(in the user's PATHagentmay be the grok binary). Dialect:-p <prompt>positional,--trust, effort encoded in model ids,--force. Output{result, session_id}. Its default model id iscomposer-2.5-fast; the oldcomposer-2.5[fast=true]bracket is invalid now. - grok (
grok) — dialect: prompt is the VALUE of--single,--effortis a separate flag, autonomy is--always-approve(not--force). Output{text, sessionId}. - codex (
codex exec) — dialect:execsubcommand, JSONL output (--json, parsed byparseCodexJsonl→ lastagent_message), effort via-c model_reasoning_effort=, autonomy via--dangerously-bypass-approvals-and-sandbox, plus--ignore-user-config/--ignore-rulesso it never loads~/.codex/config.toml(whose MCP servers hung the CLI, spawning runawaymcp-serverprocs).parseCliJsonis tolerant of cursor AND grok single-object shapes; codex uses the JSONL parser. Armadilha de versão: um id novo de modelo pode falhar comfailed to refresh available modelsquando o codex-cli instalado é antigo. Em 2026-09-30,gpt-6.1-solfalhou na versão 0.155.1 e funcionou na 0.159.2; atualizar comnpm i -g @openai/codex. Session id: codex emits it asthread_idin thethread.startedevent (NOTsession_id) —parseCodexJsonlreadsthread_id(withsession_idas fallback), elsefollow_upon a Codex delegate loses the session. Resume is a subcommand, not a flag:buildCodexArgsemitsexec resume <id> <prompt>whenopts.resumeis set (cursor uses--resume, grok-r). - claude (
claude -p) — headless dialect:-p --output-format json --strict-mcp-config --setting-sources project; never add--bare, because it breaks auth withNot logged in. Output is{result, session_id}and deliberately reusesparseCliJson(no Claude-only parser). Resume uses--resume; force OR mode always adds--dangerously-skip-permissions, because headless Claude otherwise hangs waiting for approval. - opencode (
opencode run) — prompt positional,--format jsonemits JSONL events,-mrequiresprovider/model. Sem modelo explícito, sempre passaOPENCODE_DEFAULT_MODELem-m:POLYAGENT_OPENCODE_MODEL, lida uma vez no módulo, ouopenrouter/inception/mercury-2— evita herdar silenciosamente o modelo da config do CLI. Effort maps to--variant, resume uses-s; the CLI also offers last-session continuation via-c, but the bridge does not use-c. Autonomy uses--auto, persona uses a prompt prefix, and cwd uses--dir. The positional prompt follows--. It is pay-per-token, so it stays excluded fromTIERS(pick it viadelegate.engineor an auxiliary tool's engine override). It is the thirdFAST_CANDIDATESentry (openrouter/inception/mercury-2) — pay-per-token fallback after the first two subscription candidates (codex GPT-6 Luna medium, then Claude Haiku 5.5 medium) are missing, quota-exhausted or unhealthy. The commonfast_delegatepath is subscription; OpenRouter spend only happens after both codex and claude fail. It has no engine-level read-only mode; bwrap supplies that guard. - kimi (
kimi -p) — headless prompt via-p,--output-format stream-json, model via-m, resume via-S.-palready is non-interactive and rejects--auto/-y/--yolo; persona is a prompt prefix. No engine-level read-only; bwrap supplies that guard. Subscription OAuth, so it is inquotaCandidatesbut not inTIERS,FAST_CANDIDATES, orFALLBACK_ENGINE_ORDER. - muse (
muse exec) — dialect:exec <PROMPT>positional, JSONL via--json(parsed byparseMuseJsonl), model via--model, effort via--reasoning-effort(none|minimal|low|medium|high|xhigh|max|ultra, default high), resume via--session-id <uuid>on the sameexec(do not use the interactivemuse resumesubcommand). Autonomy is--approval-mode neverunder force OR mode — never--yolo, which would also disable muse's internal sandbox; bwrap already covers isolation. Persona is a prompt prefix (the--agents <JSON>flag exists but its JSON shape is unconfirmed — do not invent it). The positional prompt follows--. Session id lives instream.idwhenstream.kind == "session"; response text is concatenatedpayload.textfrompayload_type == "run.output.delta"events — ignoreturn.input.user(that is the prompt). It is pay-per-token (API key in~/.config/muse/auth.json), so it is excluded fromTIERSandFAST_CANDIDATES; choose it explicitly viadelegate.engineor an auxiliary tool's engine override. It entersquotaCandidatesbut notFALLBACK_ENGINE_ORDER(that list is a codex environment-failure retry, andfallbackOptsdropsmode, which would collapse the read-only guarantee). It has no engine-level read-only (engineReadOnly: false); bwrap supplies that guard.--provider echoexercises the dialect without token cost.
delegate takes a required named level ("low" | "middle" | "high") → resolveTier maps
it to (engine, model, effort). LEVELS exports those names in increasing cost order and Level
is the shared type. The advertised schema is names only, but delegate.level and fan_out.levels
run z.preprocess(legacyLevel, …): the old integers map 1→low, 2–3→middle, 4–5→high, anything else
is rejected. Reason (2026-10-25): a resumed or forked session keeps the old integer schema and no
reload refreshes it, so rejecting numbers left those sessions with no delegate at all.
notifications/tools/list_changed would not help them: the stale schema lives in the transcript,
and a fresh connection already lists tools on initialize.
All three defaults use subscriptions (codex + claude), with AA quota proxies
$0.07 → $0.21 (~3×) → $1.82 (~8.7×): low=GPT-6 Luna max (codex, AA 38),
middle=GPT-6.1 Sol medium (codex, AA 48), high=Claude Opus 5.5 high (claude, AA 54).
Reserves (owner decision 2026-10-25, research/2026-10-25-reserva-por-nivel.md):
low → claude claude-haiku-5-5 xhigh; middle → claude claude-haiku-5-5 max;
high → codex gpt-6.1-sol max. Each reserve uses the other subscription.
Decision and evidence for primaries: research/2026-10-23-tres-niveis.md. The five-level ladder made the host
keep choosing among the cheapest options; three names simplify that choice. Sol xhigh and Opus
max are no longer delegate levels; explicit model/effort overrides and historical costs remain.
Ids confirmed in real execution: gpt-6-luna on 2026-09-23; gpt-6.1-sol on 2026-09-30
(codex-cli 0.159.2); claude-opus-5-5 with high effort on 2026-09-26 (modelUsage).
The alias opus still resolves to the old claude-opus-5 — use the full id.
Custo: high é caro e compartilha a assinatura Claude do host Claude Code. Low/middle dão
conta da maior parte do trabalho, implementação inclusa. Escalar para high só depois de um nível
barato falhar ou para raciocínio de fronteira/veredito caro. Codex concentra low/middle: cota
esgotada nele afeta os dois titulares, que passam à reserva Claude. Claude concentra high e o
host: cota esgotada afeta ambos juntos, enquanto high pode passar à reserva Codex.
Grok continua fora da matriz padrão.
resolveTier(level, has, cursorEnabled) uses the preferred healthy CLI when present. If it is
missing or unhealthy, it tries the level reserve when installed and healthy, recording the primary
in skipped; then the retained tier's legacy Cursor model only when cursorEnabled is true and
Cursor is installed and healthy. If all are unavailable, the error names primary and reserve with
their causes.
The Cursor ids were preserved from the retained tiers, without refreshing them.
fast_delegate has no level. resolveFastTier(has, cursorEnabled, health) picks the first installed,
healthy candidate in FAST_CANDIDATES (GPT-6 Luna medium → Claude Haiku 5.5 medium → OpenCode
mercury-2 → Grok 4.5 low), then the opt-in Cursor DEFAULT_MODEL as the final fallback. The first
two candidates are subscriptions (codex and claude); the third is pay-per-token (OpenRouter
API key / mercury-2). The Claude subscription candidate deliberately uses the same subscription as a
Claude Code host orchestrator. On the common path fast_delegate is marginal-zero cost; it only
spends real money on OpenRouter when codex and claude are missing, quota-exhausted or unhealthy.
Older historical measurement (host, runCursor, sandbox on, same long-output prompt, 2 runs):
mercury-2 7006ms (7885, 6126); haiku 10736ms (11330, 10141); grok-4.5 low 16301ms; GPT-6 Luna
was not measured because codex quota was exhausted. Discarded: gemini-flash-lite-latest (unstable,
18519ms with a 30s outlier), gpt-oss-120b OpenRouter (19251ms), groq gpt-oss-120b (120s timeout +
wrong answer). The 2026-09-23 bench (40 real runs via runCursor + bwrap) measured Luna medium
8/8, median 10.8s, worst 13.1s; Luna low 8/8, median 11.8s, worst 15.4s; Haiku 6/6, median
10.4s, worst 13.7s; mercury-2 5/6, median 14.8s, worst 240s timeout; Grok had no quota.
See research/2026-09-23-aux-tools-bench.md. It keeps the same full read/edit/shell access,
persona resolution, timeout budget note, and explicit model/effort overrides as delegate.
prompts.ts— pure prompt builders (readSlicePrompt,runFilteredPrompt,explorePrompt,webLookupPrompt,generateImagePrompt,fanOutArbiterPrompt). The tools' behavior lives in these prompt strings, so changing a tool's contract usually means editing a prompt here (and its test), notcli.ts.usage.ts— JSONL usage log behindPOLYAGENT_LOG; drives thebridge_statsandratetools. New delegate/fan_out receipts store namedrequestedLevel;fast_delegatekeeps neutral0. Readers/stats tolerate numericrequestedLevelin old logs and skip malformed JSONL lines.bridge_statsmostra tabela de saúde só das engines vistas no log (score com duas casas, causa e elegibilidade HH:MM local).reset_health: ["codex"]valida nomes e persiste eventosoutcome: "reset"sem apagar o histórico nem inflar contagens de chamadas. Exige log gravável; nomes desconhecidos são recusados com o valor recebido e as sete engines válidas.
Ratings (rate)
Ratings stay local in the JSONL file configured by POLYAGENT_LOG; rate stores only the session
handle, 1–5 score, and scrubbed note, never prompts or results. ratingStats joins each rating to
the last usage entry with the same sessionId and groups engine/model/effort/tool. The anchored
scale is 5 = correct and complete, no fixes needed; 4 = correct, small gaps; 3 = usable after
fixes; 2 = mostly wrong or incomplete; 1 = wrong, harmful, or hollow evidence (claimed checks that
proved nothing). bridge_stats(export:
true) writes the table to research/bench/<YYYY-MM-DD>-ratings.md.
The est $/task (AA) column uses model/effort costs from src/costs.ts, sourced from
research/2026-09-24-custo-por-tarefa.md; it estimates subscription quota use, not a per-call bill.
Jev (decide)
The HTTP boundary lives in src/jev.ts, separate from cli.ts and its process-spawn boundary.
Jev is not an engine and is not part of TIERS or FAST_CANDIDATES. Key resolution checks
OPENROUTER_API_KEY, then .openrouter.key in ~/.local/share/opencode/auth.json, then the system
keyring: lookupKeyringSecret(OPENROUTER_KEYRING_ATTRS) in cli.ts runs secret-tool lookup on the
item the secret-request --keyring openrouter --env OPENROUTER_API_KEY skill stores (5 s timeout;
index.ts caches only a hit). Found 2026-10-24: the registered server had neither env nor file, so
every decision since 2026-10-19 logged choice: null although the flags were on.
"Jev" names the integration, not the model: JEV_MODEL (resolveJevModel, env
POLYAGENT_JEV_MODEL) defaults to baseten/elephant-1:free — free, same /v1/systemone schema.
Bench 2026-10-24 (research/2026-10-24-modelos-decisao-gratis.md): fan_out gate tied (0 false
skips at 0.85), shadow exact 24/25 vs 19/25 for typesafe/jev-1.13. Trade-offs: 16K context (Jev
32K) and the OpenRouter :free limit (20 req/min, 50/day; 1000/day with ≥ US$10 credit) — a
429 is just a Jev error, so the existing fallbacks apply. POLYAGENT_JEV_MODEL=typesafe/jev-1.13
brings back the pay-per-token Jev. decide is secondary/deferred and has no anthropic/alwaysLoad metadata.
Three internal uses are on by default since 2026-10-24 (the owner is the only MCP user and an
opt-in flag gets forgotten); 0/false/off/no disables each. Without an OpenRouter key every
call fails fast and takes the existing fallback. POLYAGENT_JEV_FANOUT asks Jev whether at least two
successful fan_out consensus workers substantially agree; probability at or above
POLYAGENT_JEV_FANOUT_THRESHOLD (default 0.85) returns the first successful output and worker
session handles without the Codex arbiter. A low probability, malformed answer, missing key, or
Jev error or a 5-second timeout runs the existing arbiter. POLYAGENT_JEV_SHADOW asks Jev for a delegate level in
parallel after starting the worker; it records the suggestion but never changes the requested
level, and waits at most 5 seconds after the worker finishes. The same Jev call also carries three
noul prompt-clarity criteria (names_target, defines_done, single_task, from the Jev prompt-gate
idea): a second record, delegate_prompt_clarity_shadow, is clear only when every criterion is
≥ 0.5 and stores per-criterion probabilities. Both shadow records carry the worker handle in
decision.session (read from the format() footer) — never in the top-level sessionId, which
would steal the ratingStats join from the worker's own entry. askJev rejects a response missing
any question, so a partial answer nulls both records. Shadow only: it never blocks a prompt. POLYAGENT_JEV_VERIFY
runs withDelegateVerify around delegate and fast_delegate: after a successful
worker result it asks four noul questions (claims_success, reports_denominator,
evidence_shown, addresses_task) over the scrubbed prompt plus the head+tail-clipped result, and
logs delegate_result_verify_shadow (supported/unsupported/no_claim, with session). It is
not awaited — it never delays the tool response — and skips worker errors and isError results.
It checks what evidenceNote only asks for: a success claim with no denominator or evidence. Smoke on
Elephant 1 (2026-10-24): 6/6 hand cases right after rewording claims_success (success "regardless of
whether that work matches the task") and addresses_task ("a finding of nothing still counts"). For fan_out agreement, each scrubbed
worker output keeps roughly one-quarter head and three-quarters tail within its character budget,
including an elision marker, because verdicts live at the end and head-only clipping caused 24/24
false skips in the benchmark. Both paths redact credential-shaped
strings and use bounded task/output
text and logDecision in src/usage.ts: JSONL entries have tool:"decide", engine:"jev",
zero returned chars, and a decision object with candidates, choice, confidence, acceptance,
fallback, latency, optional cost, and the requested level for shadow. With the free default the
calls cost nothing but spend the daily :free quota (about two calls per delegate: shadow + verify). src/jevDecisions.ts keeps request/verdict logic pure
and injects Jev/log dependencies for orchestration tests.
The sandbox (default-on, mandatory for ALL engines, in cli.ts)
runCursor wraps the spawn in bubblewrap (bwrap) with an isolated $HOME, so each CLI can't
load the user's global behavior config (~/.cursor/rules, ~/.grok/config.toml,
~/.codex/config.toml, ~/.claude/settings, mcp.json, hooks, skills). That config was the real
cost: it inflated every call to ~57k input tokens and made the CLI try to spin up the user's MCP
servers on each run (the "hangs until timeout" symptom).
Sandboxed, a trivial call drops to ~11k input tokens (−80%). Only auth + toolchains are bound in; the
workspace (cwd) is bound RW as the last mount. Per-engine HOME binds are declared in
SANDBOX_ENGINE_RO and SANDBOX_ENGINE_RW: grok and codex need their engine homes RW; OpenCode gets
~/.opencode RO for installation and ~/.local/share/opencode RW for sessions, logs, auth and
SQLite (including WAL/SHM), plus ~/.local/state/opencode RW for locks; Muse gets RW
~/.config/muse (auth.json, settings, trust) and ~/.local/share/muse (sessions, skills, plugins,
runtime, SQLite) — two directories, and .local requires an explicit RW declaration because
SANDBOX_HOME_RO mounts ~/.local whole as RO. Claude gets
RO ~/.claude.json, and RW
~/.claude/{.credentials.json,statsig,projects,todos,shell-snapshots}. The credential is RW on
purpose: the CLI renews the subscription oauth and must persist the new pair. Mounted RO, the
refresh fails with EROFS and the already-rotated refresh token stays burned on disk, taking down
the host's whole auth with 401 OAuth token has been revoked — not just the worker. Never bind all of ~/.claude: agent personas
are resolved on the host and injected as strings, preserving config and cost isolation. Design
points, all in cli.ts:
- stdin MUST be closed (
stdio: ["ignore",…]).codex exechangs forever ("Reading additional input from stdin…") if stdin is an open pipe — this, NOT the namespace, was why codex appeared to "not survive the sandbox". With stdin closed, codex runs in the bwrap like the others (~9s). cursor, grok, claude, opencode, kimi, and muse take the prompt by arg and never read stdin, so closing it is safe for all of them. - codex config is neutralized by flags, not just the sandbox:
buildCodexArgsalways passes--ignore-user-config/--ignore-rulesso~/.codex/config.toml(with its external MCP servers, which spawned runawaymcp-serverprocs) is never loaded; auth still resolves viaCODEX_HOME. CODEX_HOMEis bound dynamically when it points outside~/.codex. External account managers (e.g. the orca desktop app) route multiple codex logins by exportingCODEX_HOMEto a path like~/.config/orca/codex-accounts/<uuid>/homeper pane/workspace, inherited by whatever spawns the bridge's MCP server. The staticSANDBOX_ENGINE_RWlist only knows the conventional~/.codex, so without this the sandbox'sisoHomeoverlay hides the real account entirely — codex fails to init (missing dir, or "Read-only file system" trying to write into an invisible path).buildSandboxSpecaddsprocess.env.CODEX_HOMEtohomeRwfor the codex engine whenever it's set and the directory exists. Even so, treat any codex environment failure as possibly host-specific and recoverable: seeisCodexEnvError/FALLBACK_ENGINE_ORDERbelow.isCodexEnvError(stderr)+FALLBACK_ENGINE_ORDERrecuperam falhas de ambiente do codex.runCursorretenta o mesmo prompt uma única vez na primeira engine instalada e compatível (grok→claude, maiscursorsobCURSOR_ENABLED), descartando model/effort do codex.codexFallbackCandidatesfiltraexplore/read_slice/web_lookuppormeetsAuxRequirements: busca web só pode cair no Claude; sem sandbox nenhuma alternativa preserva read-only, então relança o erro original. Nessas três tools,fallbackOptspreservamodee o workspace RO. A nota usacodexEnvCausepara distinguir CODEX_HOME ausente, filesystem read-only e falha do app-server, com trecho de stderr em uma linha (até 200 caracteres), redigido antes do corte.fallbackFromcarrega causa, stderr redigido (até 500 caracteres) e duração, inclusive quando o retry falha. O boundary de chamada emindex.tsgrava a tentativa codex uma única vez comoutcome: "failure"eerror, semsessionIdpara preservar o join deratingStats; assim o health aprende a falha. A tentativa seguinte pertence à engine efetiva.logUsagesegue best-effort, nunca lança. Erros codex fora de ambiente não disparam esse retry. Cota esgotada e rate limit continuam sem retry automático norunCursor— só a falha de ambiente cai noFALLBACK_ENGINE_ORDER. Exceção aprovada pelo dono em 2026-10-25:delegatesemengineexplícita e cada worker defan_outfazem um único rerun na reserva do nível quando o titular lançaQuotaErrorcomquota_exhausted; nunca emrate_limitedou outros erros. Mantêm prompt/opções, trocam engine/model/effort pelos da reserva e registram ambas as tentativas (titular com outcomequota, reserva commatchedRequest:false). A reserva não dispara outro retry; sucesso anexa a nota da troca, falha nomeia ambas as engines. Mas não propagam mais crus:classifyQuotaError({stdout, stderr, exitCode}, engine)lê primeiro os campos JSON estruturados de stdout (é lá que o sinal vive) e só depois aplica regex sobre as mensagens extraídas, devolvendoquota_exhausted|rate_limited|null.runCursorconverte o acerto emQuotaErrorcom mensagem acionável —quotaCandidatesmonta a lista de engines instaladas, habilitadas (CURSOR_ENABLED) e capazes do que a tool exige (intersecção comAUX_TOOL_REQUIREMENTS/ENGINE_CAPABILITIES), equotaErrorMessageescolhe a forma da sugestão pela superfície da tool:engine:"<x>"nas quatro auxiliares,level:"<name>"(menor nível cuja engine primária sobrou) nodelegate, nenhuma emfast_delegate/fan_out, a lista restrita a codex/grok emgenerate_image(as duas com tool de imagem própria), e "presa à sessão" emfollow_up.RunOpts.tooltambém restringe o fallback de ambiente, além de definir a forma do erro.rate_limitedpede espera e nunca sugere troca de engine. Um padrão que não casa devolvenulle a falha propaga crua — classificar errado é pior que não classificar: oralph.shtratouOAuth session expiredcomo cota e mascarou um bug do bridge que queimava a credencial do usuário (ADENDO 3 do spike). Fonte dos padrões, com origem e confiança declaradas por engine:research/2026-09-14-quota-patterns.md. Só o grok (402 +Grok Build usage balance exhausted, comhttp_statusaninhado como TEXTO dentro deerrors[0]— por isso o parser desaninha) e a string de cota do codex foram observados em runtime; os padrões do claude vêm do fonte/doc e seguem sendo hipótese.buildSandboxArgs(spec)is pure and unit-tested (likebuildCursorArgs). Bind order is load-bearing:isoHomemounts the empty$HOMEbefore the HOME-subpath overlays (auth/ toolchain), and theworkspacebind is last, salvo a exceção RO abaixo. Exceção (2026-10-25): após um workspace RO, todo mount (RO, RW e--tmpfs) com destino descendente estrito dele é repetido na ordem original, menossystemRo. Isso preserva o estado gravável da engine (could not create PATH aliases: Read-only file system (os error 30)com cwd=$HOME) e, com cwd=/ou/home, recoloca os overlays RO depois do isoHome repetido (só os RW deixavam~/.localde fora:bwrap: execvp codex: No such file or directory).systemRonão se repete: sob/RO já aparece igual, e repetir aborta onde/biné symlink.buildSandboxSpec(workspace, engine)is the impure half (mkdtemp +existsSyncprobing) — keep the fs/tmp side effects there.- Binário da engine fora dos binds padrão:
buildSandboxSpecresolve o primeiro executável no PATH do host e seu realpath viaresolveEngineBinary(sondagens de fs injetáveis). Para cada caminho sem cobertura de sistema/HOME/extras/workspace, monta RO a raiz de instalação:$HOME/<primeiro segmento>dentro do HOME;/<seg1>/<seg2>fora dele. Nunca monta o HOME inteiro nem/.buildSandboxArgspermanece pura: isoHome → overlays HOME → instalações RO → extras → workspace por último. O CLI continua por nome, com o PATH do host. Na falha bwrapexecvp,sandboxExecErroracrescenta o caminho resolvido no host (ou informa que não foi encontrado), preservando os canais deProcessError.test/engine-bin-bwrap.test.tsprova executável e symlink em instalações temporárias fora dos binds padrão, com controle sem os novos binds. - Only
cwdis mounted — paths outside it are invisible. A command touching a sibling path (additional working dir, adjacent monorepo) fails withNo such file or directory. Mount extras RO per call viaRunOpts.roBinds(skill folders), or RW viaPOLYAGENT_SANDBOX_EXTRA(:-separated absolute paths);buildSandboxSpeckeeps only the ones that exist and aren't the workspace, andbuildSandboxArgsbinds them after the HOME overlays but before the workspace, so the workspace stays the last (never-shadowed) bind. denyblocks commands via shims (delegate/fast_delegate,RunOpts.deny). Each entry is a command + the args that identify it ("git push","rm","make db-up","python manage.py migrate"); matching in the shim: git compares the leading tokens after global options/alias resolution (sogit commit -m pushpasses), every other command matches the rule args as an ordered subsequence of argv, any position, exact token equality (make -j4 db-up,npx -y prisma migrate deploy,docker compose -f x.yml downblocked;db-up2,db:migrate:status, reversed order pass).parseDenyRulesrejects empty entries, a cmd outside/^[A-Za-z0-9._+-]+$/(no slash),sh/bash/dash(the shim itself runs on/bin/sh) and args with quotes/backslash/$/backtick (they are embedded in a shell script).buildShimScriptemits the POSIX/bin/shshim: forgitit skips global options (-C,-c,--git-dir, …) and resolves aliases via"$REAL" config --get alias.<sub>(limits: alias read from the cwd/global config, andgit pullmerges internally without callinggit merge); block = stderrpolyagent: '<rule>' is blocked in this sandbox (deny list)+exit 126, otherwiseexec "$REAL" "$@".buildSandboxSpec(impure half) writes the shims (0755) into a separatecbx-shim-dir — never inisoHome/tmpDir, which are RW inside the sandbox and would let the worker rewrite the bind source — and binds that dir RO at/run/polyagent-shims, prepended to the sandbox PATH (spec.path): bare-name calls hit the shim for every denied cmd, including those that cannot be overlaid. Overlay rule per cmd (resolveShimTargets, with injectedexists/realpath/isSymlink/isElf): if the first match resolves (realpath) to an ELF, overlay that ELF itself (covers every name pointing to it, e.g.python→python3.14) plus every non-symlink ELF location of the cmd, and bind the real binary RO at/run/polyagent-real/<cmd>(the shim'sREAL; a relocated ELF still runs). Never bind onto a symlink destination: bwrap aborts withCan't mount on symlink destination /usr/bin/npxand the whole sandbox dies (found live 2026-10-24; thesh-level tests cannot see it —test/deny-bwrap.test.tsruns real bwrap). If it resolves to a script (npx/npm→…/npx-cli.js,#!/usr/bin/env node), nothing is overlaid or relocated (the scriptrequires relative to its own path) andREALis the original absolute path of the first match, so only the PATH shim covers it. Two denied cmds that resolve to the same ELF (python,python3): only the first gets the overlay, the second relies on the PATH shim (its relocated real is still bound). Gotcha:--ro-bind /bin /binis a mount separate from/usr/bineven when/bin→usr/binon the host, so overlaying/usr/bin/rmalone leaves/bin/rmopen; hence PATH dirs plus/usr/bin,/bin,/usr/sbin,/sbin,/usr/local/binall get the shim (a command found nowhere is skipped, not an error). InbuildSandboxArgsthe real/shim binds come after--tmpfs /runand the HOME overlays and before the workspace, which stays the last bind. It is a guardrail against accidents, not a security boundary (the worker can still reach the binary some other way);denyNotetells it up front what exits 126.denyneeds bwrap: withPOLYAGENT_SANDBOX=offrunCursorrejects (DENY_NEEDS_SANDBOX) instead of running unguarded, andfallbackOptscarriesdenyto the retried engine. Not covered: an absolute path to a script (/usr/bin/npx→ node script, only the PATH shim covers it), project scripts invoked by path (bin/rails,./manage.py,node_modules/.bin/*) and indirect runs (npm run migrate, a make target that runs migrate internally,bundle execloading in-process) — only commands on PATH/system dirs are shimmed and slashes are rejected incmd. Verified live under real bwrap 2026-10-24 (/bin/rm,/usr/bin/rm,git -C . -c a=b push, aliasgit pblocked;git status/git initpass;make,npx,chmod,python,gitintest/deny-bwrap.test.ts).- Mandatory, with no graceful fallback.
SANDBOX_ONis true unlessPOLYAGENT_SANDBOXisoff/0/false/no/empty. With the sandbox on andbwrapmissing from PATH,sandboxPreflight()(called inindex.tsright beforeserver.connect) throws naming the remedy (installing bubblewrap), so the server never boots degraded;runOncerejects with the sameBWRAP_MISSINGmessage if the binary disappears after the preflight. There is no stderr-warning path any more — a warning the MCP caller never saw was how a security guarantee got dropped silently.POLYAGENT_SANDBOX=offstill disables it as an explicit operator choice, and in that caseassertReadOnlyEngine(tool, engine)refuses any non-codex engine onexplore/read_slice/web_lookup(only codex has its own-s read-only). The two ephemeral tmp dirs (iso-home, /tmp) arecleanup()-ed on close/error/timeout. - The spawn boundary stays in
cli.ts— the sandbox composesbwrap <args> <engineBin> <engineArgs>in the singlespawn(); don't spawnbwrapfrom elsewhere.
Key invariants (violating these breaks tools or tests)
- Read-only modes are load-bearing for safety. As três auxiliares não são mais fixas no codex
(engine por env/parâmetro), e por isso a garantia de read-only fora do codex vem do sandbox
bwrap, obrigatório (US-008): no nível do engine
buildGrokArgsignoramodee emite sempre--always-approve, e no claudemodeemite--dangerously-skip-permissions— o oposto de read-only. Isso está declarado em código na matrizENGINE_CAPABILITIES(cli.ts), campomodeAtEngineLevel.assertReadOnlyEnginerecusa, nomeando o motivo, um engine não-codex com o sandbox desligado, eresolveAuxToolse apoia nesse guard.explore,read_slice, andweb_lookuppassRunOpts.mode, whichbuildCodexArgsconverts to-s read-only -c approval_policy="never";follow_uptakes the same mode to keep a resumed read-only session read-only.run_filteredanddelegateomit mode and get full/bypass access because they execute commands or edit. Do not silently remove a read-only tool's mode. - The Cursor fallback default is
composer-2.5-fast, never the old bracket orauto.DEFAULT_MODEL(envPOLYAGENT_MODEL) applies to the opt-in Cursor path. The current cursor-agent rejectscomposer-2.5[fast=true].resolveModelstill accepts caller-suppliedauto, but it is not the default. - Health latency uses the real runtime timeout.
computeEngineHealthmantém o mapa numérico e o score por decaimento: success/failure/timeout usam 30 min; quota usaQUOTA_WINDOW_MS(6h). O teto default permanece 300.000 ms por compatibilidade;currentEngineHealth()injetaDEFAULT_TIMEOUT_MS.LATENCY_FLOOR=0.4fica acima deHEALTH_THRESHOLD=0.3: latência sozinha nunca bloqueia. Uma cota anterior ao sucesso mais novo da própria engine é ignorada; falhas e timeouts continuam contando. O reset mais novo invalida todos os registros anteriores da engine.engineHealthReporté puro e devolve score, causa (quota/failures/timeouts/ok), prazountileprobe. Quando só a cota causa o bloqueio, libera nova tentativa apósQUOTA_PROBE_MS(envPOLYAGENT_QUOTA_PROBE_MS, default 30 min, lida uma vez). Nova cota reinicia o relógio; sucesso limpa a cota. Isso não retenta a chamada que falhou nem reserva um único probe entre chamadas concorrentes. O score observado permanece baixo durante o probe liberado. Os resolvers aceitam o relatório ou o mapa numérico histórico; runtime usa o relatório, inclusive workers e árbitro dofan_out. Erros mostram causa, hora local HH:MM ebridge_stats reset_health. Cascatas devolvemskipped; o resultado mostra uma única linha antes do footer quando houve salto. SemPOLYAGENT_LOGnão há memória de saúde; engine sem histórico é saudável por omissão. fast_delegateis speed-first and alwaysLoad.FAST_CANDIDATESis ordered GPT-6 Luna medium → Claude Haiku 5.5 medium → OpenCodeopenrouter/inception/mercury-2→ Grok 4.5 low.resolveFastTierskips missing or unhealthy native engines before the opt-in Cursor fallback. Keep it level-free, with the neutral usage receipt{ requestedLevel: 0, matchedRequest: true }. It IS markedalwaysLoad: while deferred it was never called, the same adoption bug that motivated alwaysLoad on the five core tools (deferred schemas lose to always-loaded native Read/Grep). Custo: os dois primeiros são assinaturas (codex e claude); o 2º usa a assinatura Claude do orquestrador Claude Code e é um custo aceito. O pago (opencode) só entra quando codex e claude estão ausentes, sem cota ou unhealthy. A ordem e os números do bench de 2026-09-23 estão emresearch/2026-09-23-aux-tools-bench.md; medium manteve a velocidade do low e teve nota maior. Haiku 5.5 mediu mediana de 5,7 s contra 8,6 s do Luna medium no bench de 2026-10-07; verresearch/2026-10-07-haiku-5-5.md.- As quatro auxiliares seguem o mesmo caminho: a cascata
FAST_CANDIDATES, pulando engine ausente ou unhealthy.explore/read_slice/web_lookupresolvem porresolveReadTool(2026-09-25: antes ficavam presas no codex e, com a cota dele esgotada, só falhavam); orun_filteredporresolveRunFiltered→resolveFastTier. Nas três de leitura a cascata também filtra pelo requisito da tool (meetsAuxRequirements): sem sandbox só o codex serve, e noweb_lookupsó codex e claude (os dois com web search) — então a fila dele é codex → claude e para aí. Quando a cascata escolhe o codex, as três de leitura mantêmEXPLORE_MODEL=gpt-6-luna+EXPLORE_EFFORT=medium(envsPOLYAGENT_EXPLORE_MODEL/_EFFORT), e a env<TOOL>_MODELsó vale no codex (é id de codex); fora dele valem o modelo/effort da cascata, e ummodel/effortexplícito do chamador sempre vence. Custo: a cascata pode cair no claude (assinatura) e depois no opencode (pay-per-token); essas tools só gastam dinheiro quando codex e claude não estão disponíveis. A proteção é por aprendizado (health): a primeira chamada depois da cota estourar ainda falha — cota nunca retenta sozinha —, as seguintes já pulam a engine. O erro de cota também usa o health (RunOpts.health→quotaCandidates): não sugere engine unhealthy, então com codex, grok e claude fora ele aponta direto o opencode. O claude conta como web search pelo WebSearch nativo doclaude -p, sem flag: confirmado ao vivo no bwrap em 2026-09-25 (haiku low, 14s, resposta citou WebSearch e fontes). Não há maisengine: "codex"hardcoded no handler: cada uma lêPOLYAGENT_<TOOL>_ENGINE/_MODELe aceita um parâmetroengineopcional no próprio inputSchema — nunca no objetoroutingcompartilhado, que é spread nas ferramentas de roteamento e dariaenginetambém adelegate/fan_out/follow_up. Precedência (inalterada): parâmetro da chamada > env da tool > default. Engine explícita (param ou env) não passa pela cascata:resolveReadTooleresolveRunFiltereddelegam à função puraresolveAuxTool(tool, params, env, sandboxOn)emcli.ts. Ela recusa, nomeando o motivo, um engine que não atenda o requisito declarado emAUX_TOOL_REQUIREMENTS— read-only para as três de leitura, web search (codex ou claude) paraweb_lookup— e nunca degrada para acesso total em silêncio;run_filteredaceita qualquer engine porque roda comforce: truepor desenho, e a cascata não esbarra emassertReadOnlyEngine(essa guard vale só para as três de leitura). Com engine não-codex e nenhum modelo definido, o modelo ficaundefinedde propósito:gpt-6-lunaé id de codex e quebraria em grok/claude. An explicitmodelstill wins.exploreandread_slicepass a mode for-s read-only;web_lookupalso setsRunOpts.web, which adds-c tools.web_search=truefor real web search;run_filtereddeliberately omits mode and uses bypass so it can run the requested command.exploretakesbreadth(medium|thorough) and LOCATES, never reviews. planandbuildno longer exist as tools. They were removed together withplanPrompt/buildPromptinprompts.tsand their tests; the server registers twelve tools. What survives is the homonym:RunOpts.mode: "plan" | "ask"incli.tsandExploreModeinprompts.tsare the codex read-only mode (-s read-only), used byexplore/read_slice/web_lookupandfollow_up— do not delete them chasing the removed tool.test/tools.test.tspins the twelve-tool surface.- Agent personas are additive and cross-engine.
delegateandfast_delegateaccept a named or inline agent. Resolve it on the host inagents.ts, then pass its body viaRunOpts.agentPrompt: Claude--append-system-prompt, Grok--rules, Codex-c developer_instructions=encoded bytomlString, Cursor/OpenCode/Kimi/Muse prompt prefix. Do not mount agent directories into the sandbox. delegate.engineis an explicit override, not a tier input.levelremains required for difficulty; withoutengine,resolveDelegateusesresolveTier. When the explicit engine differs from the level's primary engine, tiermodel/effortare dropped so the selected CLI uses its own defaults (or explicit caller values).opencodeandmuse(pay-per-token) stay outsideTIERS.musealso stays outsideFAST_CANDIDATES;opencodeis the thirdFAST_CANDIDATESentry (pay-per-token fallback after subscription GPT-6 Luna medium and Claude Haiku 5.5 medium — see thefast_delegateinvariant).read_slicemust return source lines, not justfile:lineprefixes — this is an explicit instruction inreadSlicePromptand was a real regression (commit c41c2af). Preserve it.read_sliceblocks full-file/verbatim dumps before spawning a worker.isFullFileRequestinprompts.tsis a deterministic code guard called by the handler inindex.ts, not just prompt text; the prompt's soft-guard remains fallback coverage for regex misses.generate_imageis codex-only. It is the sole tool with no cursor fallback: the built-inimage_gen/gpt-image-2(keyless, via the ChatGPT/Codex subscription) exists only in codex, so the handler hard-fails whenhasEngine("codex")is false. It forcescodex execatIMAGE_MODEL(envPOLYAGENT_IMAGE_MODEL, defaultgpt-6-sol) witheffort:"low"— the built-in tool does the pixels, the driver model just fires it.RunOpts.images(input files for editing) become-i <file>inbuildCodexArgs, followed by a--terminator:-i/--imageis variadic (<FILE>...), so without--the clap parser swallows the positional prompt as another image file and codex falls back to the (closed) stdin → "No prompt provided via stdin".generateImagePromptpins gpt-image-2 best-effort (the tool has no model selector) and enforces generate-then-move into the cwd; the tool returns only the saved path, never the bytes (context economy).out_pathmust live insidecwd.parseCliJsondegrades gracefully: non-JSON stdout falls back to raw text;usage.tsskips malformed JSONL lines. Match this best-effort posture — logging/parsing must never throw up into a tool call. O erro de saída não-zero derunOnceé umaProcessError(exportada decli.ts) que carrega{stdout, stderr, exitCode}separados — o JSON estruturado dos CLIs sai em stdout e se perdia sempre questderrtinha qualquer conteúdo.error.messagesegue idêntica (<engine> agent exited <code>: <stderr||stdout>, com diagnóstico adicional em falhas bwrapexecvp) e o fallback de engine continua decidindo peloisCodexEnvErrorsobre a message/stderr. Não colapse os canais de volta: a classificação de causa (cota, rate limit, auth) depende do stdout preservado.- Core tools are
alwaysLoad. The five core tools (delegate,explore,read_slice,run_filtered,web_lookup) plusfast_delegate,fan_out, andrateregister with_meta: { "anthropic/alwaysLoad": true }so Claude Code (≥2.1.121) eagerly loads their schemas instead of deferring them. Deferred tools lose to always-loaded native Read/Grep — that was the root adoption bug.fast_delegateandfan_outboth hit it while deferred (the agent never called them). Secondary tools (generate_image,follow_up,bridge_stats,decide) stay deferred. Do not stripalwaysLoadfrom the core five,fast_delegate,fan_out, orrate, or add it to the remaining secondary set, without intent. Timeout is a safety net, not a work budget.
DEFAULT_TIMEOUT_MSis 30 min (1_800_000), overridable viaPOLYAGENT_TIMEOUT_MS. Two pure helpers append notes to the prompt of the two execution tools (delegate,fast_delegate):budgetNote(timeoutMs)appends a[Time budget: ~N min ... return partial results ...]note so the worker self-manages instead of being killed blind;evidenceNote()appends an[Evidence: ...]note demanding the denominator — how many candidates the check actually scanned — not just the result. A realfast_delegatecase returned "37 tests passing" on a "renamed with no orphan" check whose assertion matched nothing — an empty-set pass is vacuously green. The caller only discovered it by injecting a fakecodex-inexistente:fantasmainto a SKILL.md and watching the test still pass.The denominator is the whole point, and an earlier draft of this note got it wrong: asking only "how many items did the assertion find" made the model report violations (the numerator), which reads identical whether it scanned 14 items or zero. Measured on the reproduced case, same model (mercury-2), invocations hidden behind a pattern the naive check misses:
- without the note:
"Check passed: all invoked agents are defined"— false, the orphan was there - with the note:
"scanned 0 Skill invocations across 2 SKILL.md files, 0 violations"
The model still reached the wrong conclusion in both runs. The note does not fix a fast/weak model that optimizes "green" over "proves" — it makes the emptiness visible in one glance. Do not drop
evidenceNoteas prompt noise, and do not reword it away from the denominator. Read tools (explore,read_slice,run_filtered,web_lookup) get neither note. Keep that split.- without the note:
The hook (hooks/prefer-polyagent.mjs)
Distribuído separadamente do servidor: o host configura o hook no settings.json com matcher
PreToolUse Read|Grep|Glob|WebSearch|WebFetch|Bash|Edit|Write|MultiEdit|Agent|Task, além de
entradas UserPromptSubmit, SessionStart e SubagentStart, todas apontando para
hooks/prefer-polyagent.mjs. O hook orienta o agente para o alias polyagent e não menciona
as tools removidas plan/build. A renomeação do arquivo exige atualizar caminhos antigos
em settings.json. A variável
POLYAGENT_HOOK_MODE = off | nudge | redirect (padrão redirect): off não emite nada;
nudge usa additionalContext sem bloqueio; redirect retorna permissionDecision: "deny"
via denyRedirect() para WebSearch/WebFetch, Read grande e Bash de busca de código. Em Bash,
buscas read-only com grep/rg/egrep/fgrep/ag/ack/find ou git grep redirecionam
uma vez para explore ou read_slice. A busca deve iniciar o primeiro segmento da pipeline;
após &&, uma nova pipeline pode iniciar com busca. npm test | grep FAIL e ps aux | grep x
são filtros de saída e não disparam. Comando que também grava artefatos não é redirecionado;
o nudge existente para git commit/push, git worktree add, gh pr create e bkt pr create
tem prioridade. Agent/Task (uma vez por sessão) recebem deny em redirect quando o subagente é
encontrado (<cwd>/.claude/agents, ~/.claude/..., ~/.claude/plugins) e NÃO precisa de MCP
(exceto context-mode), browser nem subagents — o texto nomeia
delegate(prompt, level, agent: "<subagent>"); não achado, sem linha tools:, ou que precisa do host,
cai no nudge de sempre (o nudge sozinho não mudou nada: handoff 0/2 em
research/2026-09-30-adoption-handoff.md).
Desde 2026-10-25, o hook não inclui nem trata a tool nativa Skill: o deny fazia o host empurrar
até skills básicas para um worker. Usar Skill nativamente é válido; delegate ainda aceita
agent: { skill: "plugin:name" } quando o chamador escolhe esse handoff, sem incentivo do hook.
Agent/Task continuam com o comportamento de handoff descrito acima. Os auxiliares
claude-code-guide e statusline-setup são isentos. Restrições testadas em
test/hook.test.ts:
- Pure decision in
decide(input, deps)with injectable fs — that's what the tests exercise.decide()returns{ keys, text, redirect }. The I/O wrapper (main) only runs when invoked as a script. - Modo redirect (padrão): WebSearch/WebFetch →
web_lookup, Read integral grande (sem offset/limit, ≥POLYAGENT_HOOK_MIN_LINES) →read_slice, e Bash de busca de código →explore/read_slicerecebem deny uma vez. As chaves de dedup da sessão são salvas antes da emissão; a segunda chamada passa. O motivo termina emFAILOPEN_SUFFIXe informa que o modelo pode tentar novamente. Grep/Glob/Edit/Write/MultiEdit nunca recebem deny (Agent/Task só nas condições acima); Bash que grava artefatos mantém o nudge anterior. - PreToolUse dedup per session (keyed by
session_idin anos.tmpdir()file, mode0600): every PreToolUse nudge/redirect fires at most once. A repeated fire is worse than none. This is whyGrep/Globcan sit in the matcher — they collapse to a single preload reminder. - The first qualifying nudge of a session also carries the one-time preload reminder.
SessionStartinjeta o preload antes da primeira chamada: o hook também cobre Bash de busca viaPreToolUse, mas o lembrete no início da sessão chega antes da escolha da ferramenta.sessionStartContext()injects it asadditionalContextbefore the first tool decision and pre-markspreloadin the dedup file so the PreToolUse piggyback never repeats it.sessionStartContext()andAGENT_PREF_BODYalso namefast_delegate: prefer it overdelegatefor simple/urgent work where speed matters more than picking a level.- Fail-open on errors: any error → print nothing, exit 0. SubagentStart and SessionStart paths are unchanged by redirect mode.
- Threshold for the large-Read redirect/nudge is
POLYAGENT_HOOK_MIN_LINES(default 300).
SubagentStart reaches spawned subagents. Main-loop PreToolUse nudges never reach subagents, so
the hook wires a dedicated SubagentStart entry. When hook_event_name === "SubagentStart",
main() emits { hookSpecificOutput: { hookEventName: "SubagentStart", additionalContext:
subagentStartContext(data.agent_type) } }. Pure subagentStartContext(agent_type) reuses
AGENT_PREF_BODY and appends EXPLORE_EXTRA when agent_type === "Explore" — an extra line
telling that run (spawned on the orchestrator's expensive model) to route all reading through
explore/read_slice (which run on GPT-6 Luna). sessionStartContext() carries the matching
main-loop steer: prefer calling explore() directly over spawning the Explore subagent. The bridge
and context-mode coexist without a race: they use separate channels (context-mode may still do its
own thing; the bridge injects via additionalContext only). Fail-open/non-throwing as elsewhere.
UserPromptSubmit routes each prompt independently. The host must wire a separate
UserPromptSubmit entry in settings.json to the same hooks/prefer-polyagent.mjs script. When a
prompt asks for parallel opinions, current library information, filtered test/build output, or a
code location, main() emits one calm additionalContext sentence naming the matching tool.
This entry intentionally has no session dedup: every submitted prompt is judged on its own.
POLYAGENT_HOOK_MODE=off remains a no-op, and routing errors fail open.
When changing hook behavior, put decisions in pure functions (decide, sessionStartContext,
subagentStartContext, promptRouteContext) and keep main() limited to dispatch/output. Add or
adjust a case in test/hook.test.ts — the test imports the .mjs directly and injects fakes for fs.
Project harness (.claude/, research/, bench/)
Distinct from the shipped hook above: this is the harness for maintaining this repo, active only when an agent runs here.
- Skills (
.claude/skills/):model-refresh(new model released → ids, placement, tests, docs),run-bench(measure models on the real bridge path),add-engine(touch points for a new CLI),ship(commit → PR → merge → rebuilddist/→ MCP restart). Each one'sdescriptionsays when it applies. - Hooks (
.claude/settings.json, versioned):polyagent-git-sync.sh(SessionStart, fast-forward only on a clean tree) andpolyagent-pr-reminder.sh(aftergit push, reminds to feed backCLAUDE.mdandresearch/)..claude/settings.local.jsonis machine-local — never commit it. research/is the project's research base: one dated study per decision, with sources and a "Reavaliar" section, indexed inresearch/README.md. Code comments that justify a model choice point there.research/bench/holds raw JSONL fromnpm run bench(bench/aux-bench.mjs) and fromnode bench/adoption-eval.mjs <label>— the adoption eval: fixed prompts run in a realclaude -p(user config + hooks) to measure whether the host picks polyagent tools; it spends real Claude usage, so run it deliberately and compare labels (e.g.baselinevsafter).
Conventions
- ESM + TypeScript, Node16 module resolution.
dist/andnode_modules/are gitignored; imports use.jsextensions (Node16 requirement) even though sources are.ts. Note "Node16" is the TSmoduleResolution, not the runtime —package.jsonenginesrequires Node>=18. - Comments are in Portuguese; code identifiers and prompt strings are in English. Match this.
- Cross-cutting env vars are read once as module-level consts in
cli.ts/usage.ts— add new config there, don't scatterprocess.envreads.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

