agentleFS
JaimeJunrJaimeJunr/polyagent-mcp/CLAUDE.md

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

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
  1. Very specific:

    Start with levels: ["low", "middle"].

    line 83
  2. Distinctive:

    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 …

    line 173
  3. Very specific:

    When changing hook behavior, put decisions in pure functions (decide, sessionStartContext, subagentStartContext, promptRouteContext) and …

    line 667

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
CLAUDE.md

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 name

There 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 shared routing params (cwd/model/effort). The second arg to new McpServer(...) is an instructions string 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 failed codex MCP 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 via agent — 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/skills is visible (inside cwd), ~/.claude/skills and ~/.claude/plugins are not — a skill's bundled files outside cwd never reach the worker. sessionStartContext and the managed CLAUDE.md block carry the same two points. The five core tools (delegate, explore, read_slice, run_filtered, web_lookup) plus fast_delegate, fan_out, and rate register 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_delegate and fan_out were deferred and never got called — the same adoption bug that motivated alwaysLoad on the core five. format() appends the session_id footer and logs usage; delegate/fast_delegate accept an optional deny: string[] (own inputSchema, not routing) — see "deny blocks commands via shims" in the sandbox section; fan_out consensus can use the optional Jev agreement gate before its Codex arbiter; a Jev failure or low agreement keeps the existing arbiter path. follow_up feeds that id back as RunOpts.resume so a prior worker session continues without resending its context — the footer and follow_up are two ends of the same loop. follow_up takes an optional mode — without it, a resumed session regains full tool access, so continuing a read-only session (explore/read_slice/web_lookup) must pass mode:'ask' to stay read-only. The default (no mode) is for continuing a delegate.
  • cli.ts — the only module that touches the child process. runCursor() spawns the engine's CLI; buildCursorArgs()/buildGrokArgs()/buildCodexArgs()/buildClaudeArgs()/buildOpencodeArgs()/buildKimiArgs()/buildMuseArgs() (+ buildArgs dispatcher), resolveModel(), parseCliJson()/parseCodexJsonl()/parseOpencodeJsonl()/parseKimiJsonl()/parseMuseJsonl() (+ parseOutput dispatcher), 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 optional delegate/fast_delegate persona on the host. A name such as pit:issue-investigator searches the worker cwd's .claude/agents, then the host project's (process.cwd() of the MCP server — the host often points cwd at a sibling worktree, 2026-10-28), then home .claude/agents and ~/.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" } resolves skills/<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 as mountRo, which delegate/fast_delegate pass as RunOpts.roBinds — the one exception to "no new bind", so the skill's scripts//references/ are readable (RO) in the sandbox. Motivated by handoff 0/2 in research/2026-09-30-adoption-handoff.md.
  • claudeMd.ts — managed routing block in the user's global ~/.claude/CLAUDE.md (override path with POLYAGENT_CLAUDE_MD_PATH), delimited by <!-- polyagent-mcp:begin v=<version> --> … <!-- polyagent-mcp:end -->. renderPolyagentBlock generates it from TIERS (levels never drift) and carries the fan_out triggers: 27 real fan_out calls against 744 delegate in the usage log (2026-07-03 → 2026-09-26) motivated it — the host judged alone instead of fanning out. Start with levels: ["low", "middle"]; add "high" only after a cheaper consensus disagreed or for a costly verdict. fan_out accepts at least two names, with repeats allowed. npm run install-claude-md (src/installClaudeMd.ts, --dry-run to preview) is the explicit first install: it removes the old hand-written <polyagent_preference> section, writes CLAUDE.md.polyagent.bak, and appends the block. After that, index.ts refreshes 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=off disables the boot refresh. A broken marker pair (begin without end, or two blocks) is skipped on boot and raises on install. writeFileSync follows a symlinked CLAUDE.md (dotfiles), so the link survives.
  • skill.ts — managed Agent Skill polyagent, modeled on ai-memory's skills: renderPolyagentSkill generates it from TIERS, syncSkill writes <root>/polyagent/SKILL.md for each of SKILL_ROOTS (~/.claude/skills and ~/.agents/skills; POLYAGENT_SKILL_ROOTS overrides). The always-loaded CLAUDE.md block routes; the skill loads on demand and carries the how-to (worker prompt shape, deny, agent handover, follow_up mode:'ask', rate scale). 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=off disables 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_BIN defaults to cursor-agent, NOT agent (in the user's PATH agent may be the grok binary). Dialect: -p <prompt> positional, --trust, effort encoded in model ids, --force. Output {result, session_id}. Its default model id is composer-2.5-fast; the old composer-2.5[fast=true] bracket is invalid now.
  • grok (grok) — dialect: prompt is the VALUE of --single, --effort is a separate flag, autonomy is --always-approve (not --force). Output {text, sessionId}.
  • codex (codex exec) — dialect: exec subcommand, JSONL output (--json, parsed by parseCodexJsonl → last agent_message), effort via -c model_reasoning_effort=, autonomy via --dangerously-bypass-approvals-and-sandbox, plus --ignore-user-config/--ignore-rules so it never loads ~/.codex/config.toml (whose MCP servers hung the CLI, spawning runaway mcp-server procs). parseCliJson is tolerant of cursor AND grok single-object shapes; codex uses the JSONL parser. Armadilha de versão: um id novo de modelo pode falhar com failed to refresh available models quando o codex-cli instalado é antigo. Em 2026-09-30, gpt-6.1-sol falhou na versão 0.155.1 e funcionou na 0.159.2; atualizar com npm i -g @openai/codex. Session id: codex emits it as thread_id in the thread.started event (NOT session_id) — parseCodexJsonl reads thread_id (with session_id as fallback), else follow_up on a Codex delegate loses the session. Resume is a subcommand, not a flag: buildCodexArgs emits exec resume <id> <prompt> when opts.resume is 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 with Not logged in. Output is {result, session_id} and deliberately reuses parseCliJson (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 json emits JSONL events, -m requires provider/model. Sem modelo explícito, sempre passa OPENCODE_DEFAULT_MODEL em -m: POLYAGENT_OPENCODE_MODEL, lida uma vez no módulo, ou openrouter/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 from TIERS (pick it via delegate.engine or an auxiliary tool's engine override). It is the third FAST_CANDIDATES entry (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 common fast_delegate path 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. -p already 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 in quotaCandidates but not in TIERS, FAST_CANDIDATES, or FALLBACK_ENGINE_ORDER.
  • muse (muse exec) — dialect: exec <PROMPT> positional, JSONL via --json (parsed by parseMuseJsonl), model via --model, effort via --reasoning-effort (none|minimal|low|medium|high|xhigh|max|ultra, default high), resume via --session-id <uuid> on the same exec (do not use the interactive muse resume subcommand). Autonomy is --approval-mode never under 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 in stream.id when stream.kind == "session"; response text is concatenated payload.text from payload_type == "run.output.delta" events — ignore turn.input.user (that is the prompt). It is pay-per-token (API key in ~/.config/muse/auth.json), so it is excluded from TIERS and FAST_CANDIDATES; choose it explicitly via delegate.engine or an auxiliary tool's engine override. It enters quotaCandidates but not FALLBACK_ENGINE_ORDER (that list is a codex environment-failure retry, and fallbackOpts drops mode, which would collapse the read-only guarantee). It has no engine-level read-only (engineReadOnly: false); bwrap supplies that guard. --provider echo exercises 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), not cli.ts.
  • usage.ts — JSONL usage log behind POLYAGENT_LOG; drives the bridge_stats and rate tools. New delegate/fan_out receipts store named requestedLevel; fast_delegate keeps neutral 0. Readers/stats tolerate numeric requestedLevel in old logs and skip malformed JSONL lines. bridge_stats mostra 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 eventos outcome: "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 exec hangs 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: buildCodexArgs always passes --ignore-user-config/--ignore-rules so ~/.codex/config.toml (with its external MCP servers, which spawned runaway mcp-server procs) is never loaded; auth still resolves via CODEX_HOME.
  • CODEX_HOME is bound dynamically when it points outside ~/.codex. External account managers (e.g. the orca desktop app) route multiple codex logins by exporting CODEX_HOME to a path like ~/.config/orca/codex-accounts/<uuid>/home per pane/workspace, inherited by whatever spawns the bridge's MCP server. The static SANDBOX_ENGINE_RW list only knows the conventional ~/.codex, so without this the sandbox's isoHome overlay hides the real account entirely — codex fails to init (missing dir, or "Read-only file system" trying to write into an invisible path). buildSandboxSpec adds process.env.CODEX_HOME to homeRw for the codex engine whenever it's set and the directory exists. Even so, treat any codex environment failure as possibly host-specific and recoverable: see isCodexEnvError/FALLBACK_ENGINE_ORDER below.
  • isCodexEnvError(stderr) + FALLBACK_ENGINE_ORDER recuperam falhas de ambiente do codex. runCursor retenta o mesmo prompt uma única vez na primeira engine instalada e compatível (grok → claude, mais cursor sob CURSOR_ENABLED), descartando model/effort do codex. codexFallbackCandidates filtra explore/read_slice/web_lookup por meetsAuxRequirements: 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, fallbackOpts preserva mode e o workspace RO. A nota usa codexEnvCause para 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. fallbackFrom carrega causa, stderr redigido (até 500 caracteres) e duração, inclusive quando o retry falha. O boundary de chamada em index.ts grava a tentativa codex uma única vez com outcome: "failure" e error, sem sessionId para preservar o join de ratingStats; assim o health aprende a falha. A tentativa seguinte pertence à engine efetiva. logUsage segue 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 no runCursor — só a falha de ambiente cai no FALLBACK_ENGINE_ORDER. Exceção aprovada pelo dono em 2026-10-25: delegate sem engine explícita e cada worker de fan_out fazem um único rerun na reserva do nível quando o titular lança QuotaError com quota_exhausted; nunca em rate_limited ou outros erros. Mantêm prompt/opções, trocam engine/model/effort pelos da reserva e registram ambas as tentativas (titular com outcome quota, reserva com matchedRequest: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, devolvendo quota_exhausted | rate_limited | null. runCursor converte o acerto em QuotaError com mensagem acionável — quotaCandidates monta a lista de engines instaladas, habilitadas (CURSOR_ENABLED) e capazes do que a tool exige (intersecção com AUX_TOOL_REQUIREMENTS/ENGINE_CAPABILITIES), e quotaErrorMessage escolhe 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) no delegate, nenhuma em fast_delegate/fan_out, a lista restrita a codex/grok em generate_image (as duas com tool de imagem própria), e "presa à sessão" em follow_up. RunOpts.tool também restringe o fallback de ambiente, além de definir a forma do erro. rate_limited pede espera e nunca sugere troca de engine. Um padrão que não casa devolve null e a falha propaga crua — classificar errado é pior que não classificar: o ralph.sh tratou OAuth session expired como 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, com http_status aninhado como TEXTO dentro de errors[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 (like buildCursorArgs). Bind order is load-bearing: isoHome mounts the empty $HOME before the HOME-subpath overlays (auth/ toolchain), and the workspace bind 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, menos systemRo. 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 ~/.local de fora: bwrap: execvp codex: No such file or directory). systemRo não se repete: sob / RO já aparece igual, e repetir aborta onde /bin é symlink. buildSandboxSpec(workspace, engine) is the impure half (mkdtemp + existsSync probing) — keep the fs/tmp side effects there.
  • Binário da engine fora dos binds padrão: buildSandboxSpec resolve o primeiro executável no PATH do host e seu realpath via resolveEngineBinary (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 /. buildSandboxArgs permanece pura: isoHome → overlays HOME → instalações RO → extras → workspace por último. O CLI continua por nome, com o PATH do host. Na falha bwrap execvp, sandboxExecError acrescenta o caminho resolvido no host (ou informa que não foi encontrado), preservando os canais de ProcessError. test/engine-bin-bwrap.test.ts prova executável e symlink em instalações temporárias fora dos binds padrão, com controle sem os novos binds.
  • Only cwd is mounted — paths outside it are invisible. A command touching a sibling path (additional working dir, adjacent monorepo) fails with No such file or directory. Mount extras RO per call via RunOpts.roBinds (skill folders), or RW via POLYAGENT_SANDBOX_EXTRA (:-separated absolute paths); buildSandboxSpec keeps only the ones that exist and aren't the workspace, and buildSandboxArgs binds them after the HOME overlays but before the workspace, so the workspace stays the last (never-shadowed) bind.
  • deny blocks 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 (so git commit -m push passes), 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 down blocked; db-up2, db:migrate:status, reversed order pass). parseDenyRules rejects 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). buildShimScript emits the POSIX /bin/sh shim: for git it skips global options (-C, -c, --git-dir, …) and resolves aliases via "$REAL" config --get alias.<sub> (limits: alias read from the cwd/global config, and git pull merges internally without calling git merge); block = stderr polyagent: '<rule>' is blocked in this sandbox (deny list) + exit 126, otherwise exec "$REAL" "$@". buildSandboxSpec (impure half) writes the shims (0755) into a separate cbx-shim- dir — never in isoHome/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 injected exists/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's REAL; a relocated ELF still runs). Never bind onto a symlink destination: bwrap aborts with Can't mount on symlink destination /usr/bin/npx and the whole sandbox dies (found live 2026-10-24; the sh-level tests cannot see it — test/deny-bwrap.test.ts runs real bwrap). If it resolves to a script (npx/npm → …/npx-cli.js, #!/usr/bin/env node), nothing is overlaid or relocated (the script requires relative to its own path) and REAL is 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 /bin is a mount separate from /usr/bin even when /bin → usr/bin on the host, so overlaying /usr/bin/rm alone leaves /bin/rm open; hence PATH dirs plus /usr/bin, /bin, /usr/sbin, /sbin, /usr/local/bin all get the shim (a command found nowhere is skipped, not an error). In buildSandboxArgs the real/shim binds come after --tmpfs /run and 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); denyNote tells it up front what exits 126. deny needs bwrap: with POLYAGENT_SANDBOX=off runCursor rejects (DENY_NEEDS_SANDBOX) instead of running unguarded, and fallbackOpts carries deny to 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 exec loading in-process) — only commands on PATH/system dirs are shimmed and slashes are rejected in cmd. Verified live under real bwrap 2026-10-24 (/bin/rm, /usr/bin/rm, git -C . -c a=b push, alias git p blocked; git status/git init pass; make, npx, chmod, python, git in test/deny-bwrap.test.ts).
  • Mandatory, with no graceful fallback. SANDBOX_ON is true unless POLYAGENT_SANDBOX is off/0/false/no/empty. With the sandbox on and bwrap missing from PATH, sandboxPreflight() (called in index.ts right before server.connect) throws naming the remedy (installing bubblewrap), so the server never boots degraded; runOnce rejects with the same BWRAP_MISSING message 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=off still disables it as an explicit operator choice, and in that case assertReadOnlyEngine(tool, engine) refuses any non-codex engine on explore/read_slice/ web_lookup (only codex has its own -s read-only). The two ephemeral tmp dirs (iso-home, /tmp) are cleanup()-ed on close/error/timeout.
  • The spawn boundary stays in cli.ts — the sandbox composes bwrap <args> <engineBin> <engineArgs> in the single spawn(); don't spawn bwrap from 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 buildGrokArgs ignora mode e emite sempre --always-approve, e no claude mode emite --dangerously-skip-permissions — o oposto de read-only. Isso está declarado em código na matriz ENGINE_CAPABILITIES (cli.ts), campo modeAtEngineLevel. assertReadOnlyEngine recusa, nomeando o motivo, um engine não-codex com o sandbox desligado, e resolveAuxTool se apoia nesse guard. explore, read_slice, and web_lookup pass RunOpts.mode, which buildCodexArgs converts to -s read-only -c approval_policy="never"; follow_up takes the same mode to keep a resumed read-only session read-only. run_filtered and delegate omit 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 or auto. DEFAULT_MODEL (env POLYAGENT_MODEL) applies to the opt-in Cursor path. The current cursor-agent rejects composer-2.5[fast=true]. resolveModel still accepts caller-supplied auto, but it is not the default.
  • Health latency uses the real runtime timeout. computeEngineHealth mantém o mapa numérico e o score por decaimento: success/failure/timeout usam 30 min; quota usa QUOTA_WINDOW_MS (6h). O teto default permanece 300.000 ms por compatibilidade; currentEngineHealth() injeta DEFAULT_TIMEOUT_MS. LATENCY_FLOOR=0.4 fica acima de HEALTH_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), prazo until e probe. Quando só a cota causa o bloqueio, libera nova tentativa após QUOTA_PROBE_MS (env POLYAGENT_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 do fan_out. Erros mostram causa, hora local HH:MM e bridge_stats reset_health. Cascatas devolvem skipped; o resultado mostra uma única linha antes do footer quando houve salto. Sem POLYAGENT_LOG não há memória de saúde; engine sem histórico é saudável por omissão.
  • fast_delegate is speed-first and alwaysLoad. FAST_CANDIDATES is ordered GPT-6 Luna medium → Claude Haiku 5.5 medium → OpenCode openrouter/inception/mercury-2 → Grok 4.5 low. resolveFastTier skips 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 marked alwaysLoad: 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 em research/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; ver research/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_lookup resolvem por resolveReadTool (2026-09-25: antes ficavam presas no codex e, com a cota dele esgotada, só falhavam); o run_filtered por resolveRunFiltered → resolveFastTier. Nas três de leitura a cascata também filtra pelo requisito da tool (meetsAuxRequirements): sem sandbox só o codex serve, e no web_lookup só 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êm EXPLORE_MODEL=gpt-6-luna + EXPLORE_EFFORT=medium (envs POLYAGENT_EXPLORE_MODEL/_EFFORT), e a env <TOOL>_MODEL só vale no codex (é id de codex); fora dele valem o modelo/effort da cascata, e um model/effort explí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 do claude -p, sem flag: confirmado ao vivo no bwrap em 2026-09-25 (haiku low, 14s, resposta citou WebSearch e fontes). Não há mais engine: "codex" hardcoded no handler: cada uma lê POLYAGENT_<TOOL>_ENGINE/_MODEL e aceita um parâmetro engine opcional no próprio inputSchema — nunca no objeto routing compartilhado, que é spread nas ferramentas de roteamento e daria engine também a delegate/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: resolveReadTool e resolveRunFiltered delegam à função pura resolveAuxTool(tool, params, env, sandboxOn) em cli.ts. Ela recusa, nomeando o motivo, um engine que não atenda o requisito declarado em AUX_TOOL_REQUIREMENTS — read-only para as três de leitura, web search (codex ou claude) para web_lookup — e nunca degrada para acesso total em silêncio; run_filtered aceita qualquer engine porque roda com force: true por desenho, e a cascata não esbarra em assertReadOnlyEngine (essa guard vale só para as três de leitura). Com engine não-codex e nenhum modelo definido, o modelo fica undefined de propósito: gpt-6-luna é id de codex e quebraria em grok/claude. An explicit model still wins. explore and read_slice pass a mode for -s read-only; web_lookup also sets RunOpts.web, which adds -c tools.web_search=true for real web search; run_filtered deliberately omits mode and uses bypass so it can run the requested command. explore takes breadth (medium|thorough) and LOCATES, never reviews.
  • plan and build no longer exist as tools. They were removed together with planPrompt/ buildPrompt in prompts.ts and their tests; the server registers twelve tools. What survives is the homonym: RunOpts.mode: "plan" | "ask" in cli.ts and ExploreMode in prompts.ts are the codex read-only mode (-s read-only), used by explore/read_slice/web_lookup and follow_up — do not delete them chasing the removed tool. test/tools.test.ts pins the twelve-tool surface.
  • Agent personas are additive and cross-engine. delegate and fast_delegate accept a named or inline agent. Resolve it on the host in agents.ts, then pass its body via RunOpts.agentPrompt: Claude --append-system-prompt, Grok --rules, Codex -c developer_instructions= encoded by tomlString, Cursor/OpenCode/Kimi/Muse prompt prefix. Do not mount agent directories into the sandbox.
  • delegate.engine is an explicit override, not a tier input. level remains required for difficulty; without engine, resolveDelegate uses resolveTier. When the explicit engine differs from the level's primary engine, tier model/effort are dropped so the selected CLI uses its own defaults (or explicit caller values). opencode and muse (pay-per-token) stay outside TIERS. muse also stays outside FAST_CANDIDATES; opencode is the third FAST_CANDIDATES entry (pay-per-token fallback after subscription GPT-6 Luna medium and Claude Haiku 5.5 medium — see the fast_delegate invariant).
  • read_slice must return source lines, not just file:line prefixes — this is an explicit instruction in readSlicePrompt and was a real regression (commit c41c2af). Preserve it.
  • read_slice blocks full-file/verbatim dumps before spawning a worker. isFullFileRequest in prompts.ts is a deterministic code guard called by the handler in index.ts, not just prompt text; the prompt's soft-guard remains fallback coverage for regex misses.
  • generate_image is codex-only. It is the sole tool with no cursor fallback: the built-in image_gen/gpt-image-2 (keyless, via the ChatGPT/Codex subscription) exists only in codex, so the handler hard-fails when hasEngine("codex") is false. It forces codex exec at IMAGE_MODEL (env POLYAGENT_IMAGE_MODEL, default gpt-6-sol) with effort:"low" — the built-in tool does the pixels, the driver model just fires it. RunOpts.images (input files for editing) become -i <file> in buildCodexArgs, followed by a -- terminator: -i/--image is 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". generateImagePrompt pins 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_path must live inside cwd.
  • parseCliJson degrades gracefully: non-JSON stdout falls back to raw text; usage.ts skips 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 de runOnce é uma ProcessError (exportada de cli.ts) que carrega {stdout, stderr, exitCode} separados — o JSON estruturado dos CLIs sai em stdout e se perdia sempre que stderr tinha qualquer conteúdo. error.message segue idêntica (<engine> agent exited <code>: <stderr||stdout>, com diagnóstico adicional em falhas bwrap execvp) e o fallback de engine continua decidindo pelo isCodexEnvError sobre 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) plus fast_delegate, fan_out, and rate register 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_delegate and fan_out both hit it while deferred (the agent never called them). Secondary tools (generate_image, follow_up, bridge_stats, decide) stay deferred. Do not strip alwaysLoad from the core five, fast_delegate, fan_out, or rate, or add it to the remaining secondary set, without intent.
  • Timeout is a safety net, not a work budget. DEFAULT_TIMEOUT_MS is 30 min (1_800_000), overridable via POLYAGENT_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 real fast_delegate case 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 fake codex-inexistente:fantasma into 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 evidenceNote as 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.

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_slice recebem deny uma vez. As chaves de dedup da sessão são salvas antes da emissão; a segunda chamada passa. O motivo termina em FAILOPEN_SUFFIX e 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_id in an os.tmpdir() file, mode 0600): every PreToolUse nudge/redirect fires at most once. A repeated fire is worse than none. This is why Grep/Glob can 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.
  • SessionStart injeta o preload antes da primeira chamada: o hook também cobre Bash de busca via PreToolUse, mas o lembrete no início da sessão chega antes da escolha da ferramenta. sessionStartContext() injects it as additionalContext before the first tool decision and pre-marks preload in the dedup file so the PreToolUse piggyback never repeats it. sessionStartContext() and AGENT_PREF_BODY also name fast_delegate: prefer it over delegate for 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 → rebuild dist/ → MCP restart). Each one's description says when it applies.
  • Hooks (.claude/settings.json, versioned): polyagent-git-sync.sh (SessionStart, fast-forward only on a clean tree) and polyagent-pr-reminder.sh (after git push, reminds to feed back CLAUDE.md and research/). .claude/settings.local.json is machine-local — never commit it.
  • research/ is the project's research base: one dated study per decision, with sources and a "Reavaliar" section, indexed in research/README.md. Code comments that justify a model choice point there. research/bench/ holds raw JSONL from npm run bench (bench/aux-bench.mjs) and from node bench/adoption-eval.mjs <label> — the adoption eval: fixed prompts run in a real claude -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. baseline vs after).

Conventions

  • ESM + TypeScript, Node16 module resolution. dist/ and node_modules/ are gitignored; imports use .js extensions (Node16 requirement) even though sources are .ts. Note "Node16" is the TS moduleResolution, not the runtime — package.json engines requires 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 scatter process.env reads.

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.