opencode: who decides what

Read the one-page summary first · The gallery · Make a map of your own system · The repository

The opencode coding agent (its core, CLI, server and terminal interface) in anomalyco/opencode, read at commit 0093fe57. Claims about how the code behaves refer to that commit. Their code is MIT-licensed; this page quotes short excerpts and cites files and lines at that commit.

How to read this page

A seat is a judgment the agent makes over and over: which session a command continues, what the model sees, may this action run, is the work done. Each card records how the judgment is answered, who holds it, what checks it and what happens when it fails, and says so where something was not traced. Seat ids (A1, B12 …) are links.

The chip on each card has two words. The first is about the question: a RULE has one right answer given its inputs, so a run that answers differently is a bug; a RULING is one that competent people could answer differently. Enforcing a limit someone set is a RULE; choosing what to keep, what to trust or when the work is done is a RULING, even when code holds it.

The second word is who holds it: llm = a language-model call; model = another learned model; code = code in their repository; human = a person; external = code outside the repository that the agent hands the decision to.

Paths are relative to the repository root. A bare :120-140 means lines in the file named just before it. Whether each cited opencode issue is open or closed was checked against GitHub on 2026-10-29.

Counts

123 seats across 9 stages: A 17, B 24, C 7, D 19, E 21, F 18, G 6, H 9, I 2.

Kind of question · holderSeats
RULE · code65
RULING · code31
RULING · human11
RULING · llm11
RULING · external5

What this edition does not have

There is no list of gaps. Where a card mentions a candidate gap, that gap has not yet been ruled against the code. No coverage figure: this edition does not say how many of opencode's model calls and approval points the seats leave out. We mean to add both in a later edition.

What is left out of this edition

Nothing is left out: all 123 cards are shown.

A. Intake and routing (17 of 17 seats shown)

A1 When a person runs opencode run --continue, which earlier session continues? RULE · code
Answered how

Code, when no --session is given: it lists top-level sessions (parentID: null) for the current directory newest-first (order: "desc"), 50 per page, and takes the first whose stored directory equals the current directory exactly, paging until one matches or pages run out. With --fork that session is forked first; with no match a new session is created. An explicit --session takes priority over --continue.

Held by

packages/cli/src/session-target.ts:120-131 with latestSession packages/cli/src/session-target.ts:141-161

Checked by

nothing found: Glob packages/cli/test/*session-target* (0); not searched further by import

Contract

not recorded

On error

fail-soft: no match creates a new session (session-target.ts:124, :61-75)

Failure history

none found: not searched separately

Notes

--fork forks the chosen session first (:126-130).

A2 When opencode run --session <id> names a session that does not exist, what happens? RULE · code
Answered how

Code: findSession turns a SessionNotFoundError into undefined; without --fork the run then creates a new session using the given id as its id; with --fork it throws "Session not found". Any other lookup error is re-thrown.

Held by

packages/cli/src/session-target.ts:105-108 and create-with-id packages/cli/src/session-target.ts:61-66; lookup packages/cli/src/session-target.ts:134-138; documented by the run flag text packages/cli/src/commands/commands.ts:414

Checked by

nothing found: same search as A1

Contract

not recorded

On error

raises with --fork ("Session not found", :107); otherwise creates

Failure history

none found: not searched separately

Notes

a typo in an id silently starts a fresh session with no history. Documented, so DESIGN.

A3 Which agent runs a session when none is named? RULE · code
Answered how

Code: the configured default_agent (latest config document that sets it) if that agent exists, is not a subagent and is not hidden; else the built-in build agent if selectable; else the first selectable agent in registry order. If none is selectable, select returns the id build with no agent info.

Held by

packages/core/src/agent.ts:74-86 selectedDefault, used by select packages/core/src/agent.ts:98-102; default_agent read at packages/core/src/config/plugin/agent.ts:89-90; built-in agents packages/core/src/plugin/agent.ts:55-116, packages/core/src/plugin/plan.ts:33-42

Checked by

packages/core/test/agent.test.ts:86 (asserts list order puts the selected default first; imports @opencode/core/agent :4 and @opencode/core/plugin/agent :10, verified by import read)

Contract

not recorded

On error

fail-soft: an unusable default_agent falls through silently to build (agent.ts:78-81)

Failure history

#58287 (open) "cli: default_agent=plan appends plan-mode reminder as a synthetic turn after the first response"

Notes

a session whose stored agent was later removed fails with AgentNotFoundError (packages/core/src/session/context.ts:133-134).

A4 Which model runs this session's next step? RULE · code
Answered how

Code, per step. If the session has a stored model, it must be found among the enabled ("available") models or the step fails with ModelUnavailableError. With no stored model, the selected agent's configured model is used if it is available; otherwise (silently) the catalog default from ModelResolver.resolve (the configured default if it has a package, else the first available text model with a package); nothing found raises ModelNotSelectedError.

Held by

packages/core/src/session/runner/model.ts:77-99; catalog default ModelResolver.resolve packages/core/src/model-resolver.ts:416-432

Checked by

packages/core/test/model-free.test.ts:76 (asserts the "Model unavailable" failure; imports SessionRunnerModel :12, verified by import read). The agent-model fallback branch: nothing found (not searched beyond the import list).

Contract

not recorded

On error

raises ModelUnavailableError / ModelNotSelectedError (packages/core/src/session/runner/model.ts:88, :94-97); fail-soft when the agent's configured model is unavailable: falls to the default with no warning (model.ts:82-86)

Failure history

#58681 (closed) "subagent: auto-selects a disabled model instead of respecting disabled state"; #51326 (open) "agent-level variant is resolved by debug agents but never applied to requests"; #54067 (closed) "Background task completion switches the parent session to the agent's default model"

Notes

A candidate gap (silent agent-model fallback).

A5 Which model does a person get when nobody has chosen one? RULING · code
Answered how

Code: the configured defaultModel if it exists and is enabled; otherwise the first model in the available list that supports text. The available list is every enabled model sorted newest release first, except that when every enabled model is a free OpenCode model, four named free models (PREFERRED_FREE) are moved to the front. (The session runner, A4, additionally requires a model package.)

Held by

packages/core/src/model.ts:271-276; ordering packages/core/src/model.ts:229-232; preferFree packages/core/src/model.ts:296-312

Checked by

packages/core/test/model-free.test.ts:57-61, :85 (default selection with free/paid models; imports @opencode/core/model :6, verified by import read)

Contract

not recorded

On error

returns undefined; A4 then raises ModelNotSelectedError

Failure history

none found: gh search issues --repo anomalyco/opencode "default model" agent (8 results, none about release-order choice)

Notes

RULING because "newest release first" is a product choice (the code comment at packages/core/src/model.ts:296-297 says so).

A6 In an ACP client, which model and mode are shown as the session's current ones? RULE · code
Answered how

Code: until the client picks one, the current model is the catalog default: the server's model.default looked up among enabled models, or the first enabled model ONLY when the server returns no default (a server default missing from the enabled list is not replaced; the catalog reports not-ready). The current mode is the first non-hidden agent whose mode is not subagent (so primary or all) in the server's agent list (core lists its resolved default agent first).

Held by

packages/cli/src/acp/catalog.ts:150-167; current values packages/cli/src/acp/config-option.ts:18-20, packages/cli/src/acp/config-option.ts:60

Checked by

nothing found: not searched by import (packages/cli/test not read)

Contract

not recorded

On error

fail-closed: CatalogNotReadyError when no default model is found (including a server default absent from the enabled list) or no primary agent (packages/cli/src/acp/catalog.ts:154, :158); load retries that every 25 ms for up to 5 s (:127, :134-136), then the error stands

Failure history

none found: not searched separately

Notes

A candidate gap: a new ACP session is created with no model (packages/cli/src/acp/service.ts:143), so core picks the agent's configured model first (A4), while ACP advertises model.default, which ignores the agent.

A7 Is an ACP prompt that starts with "/" a command, a compaction request, or plain text? RULE · code
Answered how

Code: the visible text parts are joined and trimmed; if the result starts with "/", the first whitespace-delimited word is the name. compact starts a compaction; a name matching a server command (built-ins excluded from that list) is sent as that command with the rest as its arguments and the files attached; any other "/x" is sent to the model as ordinary prompt text. All three go with delivery: "steer" (compaction via session.compact).

Held by

packages/cli/src/acp/prompt.ts:30-41 with detectSlashCommand packages/cli/src/acp/prompt.ts:53-59; built-in list packages/cli/src/acp/catalog.ts:9-11; dispatch packages/cli/src/acp/turn.ts:239-258

Checked by

nothing found: not searched by import

Contract

not recorded

On error

a server error from compact/command/prompt is mapped by ACPClient.classify (packages/cli/src/acp/turn.ts:240, :253, :258)

Failure history

none found: not searched separately

Notes

only the ACP surface parses slash text in code; TUI command dispatch was not traced (presentation layer).

A8 When a person runs a custom command, what text is sent, under which agent and model, and in which session? RULE · code
Answered how

Code: the command's template is expanded with the person's input ($1..$n positional, the last placeholder taking the rest; $ARGUMENTS the whole input; with neither, the input is appended), then every !`cmd` left in the expanded text is run in the config-priority shell in the location directory and replaced by its output. Agent is the command's agent when set; model is the command's model, else that agent's model. A command run as a child falls back to the parent's agent, and its model to the chosen agent's model, then the parent session's model. If the command is marked subagent/subtask (or, when unmarked, its agent is a subagent) a child session is created and the text is sent there with a "You are a subagent" preface and backgrounded; otherwise the session is switched to that agent/model (when set) and the text is prompted with the caller's delivery.

Held by

packages/core/src/config/plugin/command.ts:70-131; evaluateTemplate packages/core/src/config/plugin/command.ts:185-237; command files packages/core/src/config/plugin/command.ts:152-168

Checked by

packages/core/test/config/command.test.ts (imports ConfigCommandPlugin :16) and packages/core/test/config/command-subagent.test.ts (verified by import read; test bodies not read line by line)

Contract

not recorded

On error

raises Command.ExecutionError wrapping any failure (packages/core/src/command.ts:85-88), including a shell-interpolation failure (packages/core/src/config/plugin/command.ts:227-229)

Failure history

#52065 (open) "commands: unresolvable model in frontmatter silently drops the command"

Notes

The !`cmd` shell run has no permission check in this file, and the argument text is filled in BEFORE the shell pattern is matched (:205-210), so argument text containing !`...` would run too. The arguments come from whoever issues the command: the person, or a program connected to opencode's server, where their security page says "API access is expected behavior" (SECURITY.md:29). Their security page says the permission system "is not designed to provide security isolation" (SECURITY.md:17). Config commands load in the post plugin list (packages/core/src/plugin/internal.ts:259) after the built-ins (:226), and Command stores by name (command.ts:61), so a config command named init or review replaces the built-in. Their #54319 (closed) "Add the ability to substitute command arguments inside shell commands" shows arguments inside shell spans are wanted; whether arguments that ADD a shell span are intended is not stated. The only shell test found uses a template-authored span (packages/core/test/config/command-subagent.test.ts:133; grep !` over packages/core/test/config).

A9 What do the built-in /init and /review commands send? RULE · code
Answered how

Code: /init sends the initialize.txt template with the first ${path} replaced by the project directory; /review sends review.txt. In both, the trimmed input replaces every $ARGUMENTS if present, else is appended after a blank line. Either is prompted into the current session with the caller's delivery.

Held by

packages/core/src/plugin/command.ts:28-54 with append packages/core/src/plugin/command.ts:86-90

Checked by

packages/core/test/plugin/command.test.ts (imports CommandPlugin :8 and both prompt files :19-20, verified by import read)

Contract

not recorded

On error

raises through Command.execute

Failure history

none found: not searched separately

Notes

prompt anchors packages/core/src/plugin/command/initialize.txt, review.txt (literal, ${path} and $ARGUMENTS slots). They become the user's prompt, so the model sees them as user text, not system text.

A10 What does an MCP-prompt command send to the model? RULING · external
Answered how

External, through code: the input is split into positional words (quotes stripped) and mapped onto the MCP prompt's declared arguments in order (missing ones as ""); the server's prompts/get reply messages are reduced to their text content (non-text dropped, roles ignored), joined with newlines and sent as the user's prompt verbatim.

Held by

packages/core/src/plugin/command.ts:55-79 (fetch and send); parseArguments packages/core/src/plugin/command.ts:92-94; promptMessageText packages/core/src/plugin/command.ts:96-102

Checked by

packages/core/test/plugin/command.test.ts (imports CommandPlugin; whether it covers the MCP branch not read)

Contract

n/a (external text becomes the user prompt verbatim)

On error

raises "MCP prompt not found" (:69)

Failure history

none found: not searched separately

Notes

MCP server behaviour is out of scope in their SECURITY.md:25-33.

A11 Is a prompt rewritten (text, files, delivery) before it is admitted? RULING · code
Answered how

Code hands it to any plugin prompt hook: after plugins finish activating, the prompt (text, files, agents, skills), metadata and delivery (default steer) are passed through hooks.trigger("session", "prompt", ...) and whatever the hooks leave is what gets admitted. No shipped plugin registers a prompt hook, so by default the input passes unchanged.

Held by

packages/core/src/session/prompt.ts:38-52 (hook trigger); result used packages/core/src/session/prompt.ts:52-87

Checked by

packages/core/test/session-activation.test.ts:43 registers a prompt hook (file imports not read)

Contract

not recorded

On error

not traced

Failure history

none found: not searched separately

Notes

A user plugin makes this seat external.

A12 Which pending input does the session take next at a step boundary or when idle? RULE · code
Answered how

Code, under a per-session lock. Pending steers are read in enqueue order, with the first steered compaction moved to the front unless a move comes before it. If any steers are pending (or only steers may be taken, i.e. mid-turn), all steers up to the first control item are delivered; a control item at the head is left for the runner. Otherwise (idle/entry boundary) exactly one queued item is delivered (a queued control is left for the runner), followed by any steers that arrived meanwhile, again up to a control.

Held by

packages/core/src/session/inbox.ts:505-559 (promote, pendingSteers); runner boundary packages/core/src/session/runner/llm.ts:63-68, packages/core/src/session/runner/llm.ts:82-111, packages/core/src/session/runner/llm.ts:159-178

Checked by

packages/core/test/session-runner.test.ts and packages/core/test/session-run-coordinator.test.ts (import @opencode/core/session/inbox, verified by import read; specific cases not read)

Contract

not recorded

On error

raises: a LifecycleConflict defect from the delivered projection dies unless the item was already promoted (packages/core/src/session/inbox.ts:343, :484-491); admission projection conflicts at :302, :325

Failure history

none found: not searched separately

Notes

a promoted user prompt resets the step count (llm.ts:176).

A13 Should a pending prompt interrupt the agent at its next step, wait until it is idle, or be withdrawn? RULING · human
Answered how

The person (or whichever client submits) decides: each prompt is submitted with a delivery of steer or queue (core defaults to steer when none is given), and while it is still pending the person can switch it between steer and queue, or cancel it, from the TUI, which calls the inbox update/cancel APIs. Steering also wakes the session. Other clients (ACP always sends steer; SDK callers choose) answer it in code.

Held by

packages/tui/src/routes/session/index.tsx:749-767 (steer / queue / cancel a pending prompt); core packages/core/src/session/session.ts:140-154; default packages/core/src/session/session.ts:165

Checked by

nothing found: not searched in tui tests

Contract

not recorded

On error

fail-soft: a toast "Failed to ... pending prompt" (index.tsx:762-763)

Failure history

none found: not searched separately

Notes

opencode run and ACP always submit delivery: "steer" (packages/cli/src/run/noninteractive.ts:721, packages/cli/src/acp/turn.ts:257).

A14 When the same prompt id is submitted twice, is it new work? RULE · code
Answered how

Code: before preparing anything, the id is looked up in the pending inbox, then among delivered messages. If found with the same session and type (and not a compaction), the stored item is returned: first admission wins and the retry's text, files, metadata and delivery are ignored. Same id in another session, of another type, or a compaction raises a conflict. Not found means new work; a retry with resume not false still wakes the session.

Held by

packages/core/src/session/inbox.ts:164-176 (reconcile, with matches packages/core/src/session/inbox.ts:69-74 and promotedFromMessage packages/core/src/session/inbox.ts:113-151), called from packages/core/src/session/session.ts:161-167

Checked by

packages/core/test/session-prompt.test.ts (imports SessionInbox :25; cases not read)

Contract

not recorded

On error

raises PromptConflictError (session.ts:182)

Failure history

none found: not searched separately

Notes

not recorded

A15 Which attachments are accepted with a prompt, and in what form? RULE · code
Answered how

Code, per attachment: only data: URLs and file: URLs are accepted (anything else, an invalid URL, or an unreadable path rejects the prompt). A file: directory becomes a sorted name listing; a regular file over 20 MiB is rejected; ?start=&end= slices lines only when the detected type is text/plain. Image attachments are normalized by the image service, keeping the original when the resizer is unavailable; a data URL whose base64 is not canonical is rejected.

Held by

packages/core/src/session/prompt.ts:91-131 (materializeAttachment); packages/core/src/session/prompt.ts:133-147 (image normalize); packages/core/src/session/prompt.ts:149-201 (file: read, 20 MiB cap); packages/core/src/session/prompt.ts:208-223 (data URL decode)

Checked by

packages/core/test/session-prompt.test.ts (imports present; attachment cases not read)

Contract

not recorded

On error

raises AttachmentError (fail-closed for the whole prompt)

Failure history

none found: not searched separately

Notes

not recorded

A16 In opencode run, is an attached file sent as text or as binary data? RULING · code
Answered how

Code: an image or PDF (by file-name MIME) keeps its type; any other file is text/plain when it has no zero byte, at most 30% control bytes, and survives a UTF-8 round trip unchanged; otherwise it keeps its detected MIME. Text files are then inlined into the prompt text as <file name="...">...</file> blocks; everything else goes as a data: attachment. Directories, special files and files over 10 MiB stop the run.

Held by

packages/cli/src/run/run.ts:185-218 with isBinaryContent packages/cli/src/run/run.ts:220-224; send form packages/cli/src/run/noninteractive.ts:846-857, packages/cli/src/run/noninteractive.ts:719-720

Checked by

nothing found: not searched by import

Contract

not recorded

On error

fail-closed: the run stops with a message for a missing file, a directory/special file, or a file over 10 MiB (packages/cli/src/run/run.ts:187, :193)

Failure history

none found: not searched separately

Notes

the 30% threshold is a design-time choice.

A17 In opencode run, which agent and model apply to the prompt? RULE · code
Answered how

Code. Model: an explicit --model provider/model#variant, else the continued session's stored model, else (only when a variant is set through the V1 command boundary) the server default model; with none of these, no model is sent and the session's own resolution (A4) applies. Agent: --agent, else the continued session's agent. Before prompting, the agent (if --agent) and the model (if any) are written to the session with switchAgent / switchModel.

Held by

packages/cli/src/run/run.ts:91-129 with packages/cli/src/session-target.ts:57-58; written to the session packages/cli/src/run/noninteractive.ts:688-707

Checked by

nothing found: not searched by import

Contract

not recorded

On error

raises "Cannot select a variant before selecting a model" (run.ts:118-119, noninteractive.ts:704)

Failure history

none found: not searched separately

Notes

the switch persists on a resumed session; the next TUI turn keeps it.

B. Context: what the model sees (24 of 24 seats shown)

B1 Which standing system prompt does the model get? RULE · code
Answered how

Code: the first system part is the agent's own system text if it has one (the built-in explore, title and summary agents do, and any configured agent may); otherwise the shipped system.txt, whose ${OPENCODE_TOOL_GUIDANCE} slot is filled with shell, write and edit guidance lines only for those tools present in this request. The rendered instruction baseline follows as a second system part when non-empty. Plugin hooks (B2, B3, B20) may then rewrite it.

Held by

packages/core/src/session/model-request.ts:102-109 (baseTranscript); packages/core/src/session/system-prompt.ts:5-28

Checked by

packages/core/test/session-system-prompt.test.ts (imports SessionSystemPrompt :2, verified by import read); packages/core/test/session-runner.test.ts:75 imports it too

Contract

not recorded

On error

not traced (pure string build)

Failure history

none found: not searched separately

Notes

the tool names passed are the snapshot's BEFORE plugin hooks remove tools (B14), so the guidance can name a tool the hook later removes; B2 re-renders from the post-hook list for matched model families only. Not traced whether any shipped combination leaves a stale tool line.

B2 Which model-family prompt replaces or extends the system prompt? RULE · code
Answered how

Code, as plugin hooks on context, compaction and generate requests, run in registration order (OpenAI, Anthropic, Kimi, Arcee, Meta): a lower-cased model id containing gpt gets gpt.txt (or gpt-astra.txt if it also contains astra) in place of the first system part; claude gets anthropic.txt appended to it; kimi, trinity and muse get their own file in place of it (muse with the model name filled in). Skipped when the agent has its own system prompt or there is no first system part; any error leaves the prompt unchanged.

Held by

packages/core/src/plugin/optimize.ts:16-62 (matchers and order); make packages/core/src/plugin/optimize.ts:78-104; registered after the patch tool packages/core/src/plugin/internal.ts:232-236

Checked by

packages/core/test/plugin/optimize.test.ts (imports OptimizePlugin :8 and every template :17-22, verified by import read)

Contract

not recorded

On error

fail-soft: any error leaves the prompt unchanged (optimize.ts:98)

Failure history

none found: not searched separately

Notes

several plugins run in sequence, so a model id matching two substrings gets both (e.g. an id containing gpt and kimi: the later override wins). OpenAIToolsPlugin / AnthropicToolsPlugin (:35-49) are defined but not in Plugins (:62), by the comment at :32-33 "disabled intentionally": disconnected seat, DESIGN.

B3 Is the model told its own name and id? RULE · code
Answered how

Code: on every context, compaction and generate request (not title), the Identity plugin inserts a system part "# Your Model" with the catalog name, provider id and model id at index 1, right after the first system part. Errors are swallowed.

Held by

packages/core/src/plugin/identity.ts:10-31

Checked by

packages/core/test/plugin/identity.test.ts (imports IdentityPlugin :4, verified by import read)

Contract

not recorded

On error

fail-soft (:26)

Failure history

none found: not searched separately

Notes

not recorded

B4 Which instruction sources make up the baseline, and in what order? RULE · code
Answered how

Code, per step: the session's agent permissions merged with the session's own narrow the sources, which are combined in a fixed order chosen to keep a shared prompt-cache prefix: Code Mode catalog, MCP server guidance, project references, skills, discovered instruction files, date/environment builtins, then session entries. Two sources with the same key raise.

Held by

packages/core/src/session/context.ts:126-166 (select); combine packages/core/src/instructions/index.ts:124-132

Checked by

packages/core/test/instructions/index.test.ts (file present; imports not read); packages/core/test/session-runner.test.ts imports every source module (:72-75, :88)

Contract

not recorded

On error

raises DuplicateKeyError (thrown synchronously, packages/core/src/instructions/index.ts:128); an unknown session agent raises AgentNotFoundError (packages/core/src/session/context.ts:134)

Failure history

#50696 (open) "AGENTS.md: nearest-first injection order works against LLM recency bias"

Notes

the session permission merge narrows skills, MCP guidance and tools the same way (context.ts:135-136).

B5 Which instruction files (AGENTS.md and configured instructions entries) does the model see? RULE · code
Answered how

Code, rescanned on file/config change. Global: AGENTS.md in the global config directory (unless global discovery is off). Project, only when project discovery is on (default) and the start directory is inside the project root: EVERY AGENTS.md found walking up from the start directory to a stop directory, which is the HOME directory whenever the start is under home (so ancestors above the project root are included) and otherwise the project root. Then configured instructions entries: absolute paths/globs, relative globs searched upward from start to project root (or under the global config dir when project discovery is off), and http(s) URLs fetched with a 5 s timeout at config load. Order: global, project (nearest first), configured.

Held by

packages/core/src/config/plugin/instruction.ts:36-58 (scope and stop); sources packages/core/src/config/plugin/instruction.ts:78-146; URLs packages/core/src/config/plugin/instruction.ts:151-179; walk packages/util/src/fs-util.ts:167-190; rendered packages/core/src/instruction-discovery.ts:123-125

Checked by

packages/core/test/instruction-discovery.test.ts (imports ConfigInstructionPlugin :9 and InstructionDiscovery :11; describe("ConfigInstructionPlugin.Plugin") :197; verified by import read)

Contract

not recorded

On error

global and configured files: missing or access-denied are skipped (packages/core/src/config/plugin/instruction.ts:64-75, :81, :134). Project walk: an access-denied file is skipped, but a file found by the walk and then missing at read time (NotFound, e.g. mid-save) makes the project source unavailable (:72, :92). A failed URL keeps the last good copy or is skipped (:165-169). Any unavailable source or activation failure makes the whole set unavailable (:197-200, :235-238), which blocks the first step (B6).

Failure history

#53402 (open) "v2: no CLAUDE.md fallback (compatibility issue, as migrate-v1 asks)"; #47879 (closed) "Disable or replace project AGENTS.md (instructions currently only adds)"; #54489 (open) "allow instruction files to be named after the project instead of hardcoded AGENTS.md"

Notes

CLAUDE.md is never read as instructions at this pin (grep CLAUDE\.md|\.claude|CONTEXT\.md over packages/core/src: only .claude skill roots and a mention inside plugin/command/initialize.txt; the code comment packages/cli/src/server-process.ts:126 says ".claude is only read for skills"). Their docs say otherwise: (a candidate gap, not on this edition). Project discovery is switched off by OPENCODE_DISABLE_PROJECT_CONFIG / OPENCODE_CONFIG_PROJECT_DISABLE (server-process.ts:123-125 -> packages/server/src/routes.ts:133), and both scopes are off in packages/core/src/instance.ts:148.

B6 When instruction files change during a session, what does the model see? RULE · code
Answered how

Code, every step: the current instruction values are read and hashed against the session's stored values. The first time, every source must be available or the step is blocked and admitted input stays pending; the values become the baseline (published with no text). Later, only changed sources form a delta: an unavailable source keeps its stored value, a removed one is marked removed, and the rendered update text (for instruction files: a unified diff or the full new text, whichever is shorter; "no longer apply" for removed files) is frozen into a durable in-history event.

Held by

packages/core/src/session/instruction-state.ts:21-87 (observe, commit, prepare); diff packages/core/src/instructions/index.ts:142-159; render packages/core/src/instruction-discovery.ts:123-149

Checked by

packages/core/test/instruction-state.test.ts (imports InstructionState :14, verified by import read)

Contract

not recorded

On error

fail-closed at first use: an unavailable source blocks the baseline and admitted input stays pending (instructions/index.ts:143-144; packages/core/src/session/runner/llm.ts:200-201); afterwards fail-soft: an unavailable source keeps its stored value (instructions/index.ts:148); a missing blob raises (instruction-state.ts:254, :280)

Failure history

#58485 (closed) "Moving plan/build reminders invalidate the prompt cache" (adjacent: cache-driven design); otherwise none found: not searched separately

Notes

forks adopt the parent's newest values (packages/core/src/session.ts:356-358). What a person sees when the first-step block fires was not traced (a candidate gap, PROVISIONAL).

B7 After the model reads a file, which nearby AGENTS.md files are added to the conversation? RULE · code
Answered how

Code, after each successful read that is not an external-directory read: walk up from the read file's directory (or from a listed directory itself) toward the Location directory, collecting every AGENTS.md; drop any whose directory is the Location directory or one of its ancestors (already in the baseline) and the read file itself. Paths already carried by an earlier synthetic still in model-visible history, or claimed by a concurrent load, are skipped; the rest that are readable are published as one synthetic message ("Instructions from: <path>" plus content). Paths lost to compaction or revert are re-injected on a later read.

Held by

packages/core/src/tool/plugin/read.ts:82-107; inject and dedupe packages/core/src/session/instructions.ts:46-112

Checked by

packages/core/test/tool-read.test.ts (imports SessionInstructions :21, verified by import read)

Contract

not recorded

On error

fail-soft: discovery failures never fail the read (read.ts:104-107)

Failure history

#57389 (closed) "core: read injects AGENTS.md from sibling project directories outside the workspace"; #52625 (open) "Option to disable instruction-file injection into read tool results"

Notes

paths dropped from history by compaction or revert are re-injected on the next read (instructions.ts:39-43).

B8 What date and environment facts does the model get? RULE · code
Answered how

Code: two instruction sources. The date is today's toDateString() ("Today's date: ..."; on a later change "Today's date is now: ..."). The environment block lists the Location's working directory, the project directory as "Workspace root folder", whether the Location is a git repo, process.platform, and the preferred temp directory (global.tmp, said to be pre-approved for external access). Both are re-read every step, so a date change mid-session is published as an update.

Held by

packages/core/src/instructions/builtins.ts:21-54

Checked by

packages/core/test/instructions/builtins.test.ts (file present; imports not read)

Contract

not recorded

On error

not traced

Failure history

none found: not searched separately

Notes

a date change mid-session arrives as an update ("Today's date is now", :30).

B9 Which skills are listed to the model as available? RULE · code
Answered how

Code, per step: from all loaded skills, those whose skill permission for their id is not deny under the merged agent and session rules, that have a description and do not set autoinvoke: false, sorted by id, are listed as <available_skills> entries (id, name, description). None left means the guidance is marked removed; later changes are sent as added/changed/removed deltas.

Held by

packages/core/src/skill/instructions.ts:79-96; permission filter packages/core/src/skill.ts:33-34

Checked by

packages/core/test/skill/instructions.test.ts (imports SkillInstructions :8, verified by import read)

Contract

not recorded

On error

not traced

Failure history

none found: not searched separately

Notes

not recorded

B10 Which skill files are loaded at all, including from .claude and .agents folders? RULE · code
Answered how

Code. Besides native skill loading (not read), the compatibility plugin takes the .claude and .agents roots that config discovered and that exist as directories, scans <root>/skills in each (claude roots first, then agents), parses every skill file found, and registers each parsed skill by id, a later root overwriting an earlier one with the same id. It rescans on file changes (debounced 100 ms) and config changes. Config options claude: false (skips ~/.claude and project .claude) and global: false (skips ~/.claude and ~/.agents) remove roots.

Held by

packages/core/src/config/plugin/compatibility.ts:39-57; roots packages/core/src/config.ts:409-413 (options documented packages/core/src/config.ts:49-54); native loading packages/core/src/config/plugin/skill.ts (not read)

Checked by

packages/core/test/config/skill.test.ts (file present; imports not read)

Contract

not recorded

On error

fail-soft: unreadable directories and files are skipped (compatibility.ts:46-51)

Failure history

none found: not searched separately

Notes

.claude roots off with OPENCODE_DISABLE_CLAUDE_CODE or OPENCODE_DISABLE_CLAUDE_CODE_SKILLS (packages/cli/src/server-process.ts:127-130).

B11 What enters the conversation when a person attaches or activates a skill? RULE · code
Answered how

Code. A skill attached to a prompt is looked up by id (no permission filter at this point) and its prepared text is stored in the user message: a <skill_content> block with the skill body, its base directory, and, when the file is SKILL.md, up to the first 10 other files under its folder, subfolders included (a recursive scan, sorted). A skill activated directly publishes a Skill.Activated event carrying the raw skill content, then resumes the session unless resume: false.

Held by

packages/core/src/session/prompt.ts:56-76 (attached); packages/core/src/session/session.ts:233-255 (activation, via packages/core/src/session/skill.ts:10-16); content packages/core/src/skill.ts:36-74

Checked by

packages/core/test/session-skill.test.ts (imports SessionInbox :26; skill module import not checked)

Contract

not recorded

On error

raises SkillNotFoundError (prompt.ts:66)

Failure history

none found: not searched separately

Notes

a person-attached skill is not checked against the skill permission (only skillService.get, prompt.ts:65), unlike B9. The person chose it, so this reads as intended.

B12 Which MCP servers' instructions does the model see? RULE · code
Answered how

Code, per step: a server's own instructions are listed (verbatim, indented, sorted by server name) only if at least one of its tools is not denied for this session (each checked as Permission.evaluate(<mcp tool name>, "*")), and, when the server is in Code Mode (judged by its first tool's codemode flag), only if execute is not denied either. A server with no tools is never listed. Code Mode servers get an extra line telling the model to call them through execute.

Held by

packages/core/src/mcp/instructions.ts:75-111

Checked by

packages/core/test/mcp-instructions.test.ts (imports McpInstructions :5, verified by import read)

Contract

n/a (server-supplied text is shown verbatim, :24)

On error

not traced

Failure history

none found: not searched separately

Notes

not recorded

B13 Which project reference directories are listed to the model? RULE · code
Answered how

Code, per step: every project reference that has a description is listed by name, path and description, sorted by name; references without a description are left out, and none left marks the guidance removed. Changes later go out as deltas.

Held by

packages/core/src/reference/instructions.ts:74-93

Checked by

packages/core/test/reference-instructions.test.ts (imports ReferenceInstructions :6, verified by import read)

Contract

not recorded

On error

not traced

Failure history

none found: not searched separately

Notes

not recorded

B14 Which tools does the model see this step? RULE · code
Answered how

Code, per step. From the registered tools, one is hidden only when the LAST permission rule matching its action (its permission option, else its name) is a deny on resource * under the merged agent and session rules. Tools with codemode: false are listed directly (sorted by name); every other visible tool is folded into a single execute tool, which appears only when execute itself is not wholly denied. Plugin context/compaction/generate hooks may then rename or delete definitions; definitions a hook invents are dropped. Shipped hooks: websearch is removed once the person has chosen to disable web search (a stored selection of false, E14; with no provider configured it stays listed), and the patch plugin removes edit/write or patch by model (B15).

Held by

packages/core/src/tool.ts:227-263 with whollyDisabled packages/core/src/tool.ts:293-296; rules packages/core/src/session/context.ts:136-139; hook pass packages/core/src/session/model-request.ts:239-267; websearch removal packages/core/src/tool/plugin/websearch.ts:184-194

Checked by

packages/core/test/tool-registry.test.ts (imports Tool :10, calls snapshot() :101, verified by import read)

Contract

not recorded

On error

a call to a hidden or unknown tool returns a tool error to the model (tool.ts:275-284)

Failure history

none found: not searched separately

Notes

tool filtering is visibility, not authorization (their packages/core/src/tool/AGENTS.md, data).

B15 Which file-editing tool does this model get: patch, or edit and write? RULING · code
Answered how

Code, as a plugin hook on context, compaction and generate requests: if the model id contains gpt- and contains neither oss nor gpt-4 (case-sensitive), the edit and write tools are removed and patch is kept; otherwise patch is removed.

Held by

packages/core/src/tool/plugin/patch.ts:296-309

Checked by

packages/core/test/tool-patch.test.ts (file present, imports Model :12; whether it covers the hook not read)

Contract

not recorded

On error

not traced

Failure history

none found: not searched separately

Notes

matches the catalog model id, so a deployment whose id lacks gpt- gets edit/write. RULING because which edit format suits a model is a tuning judgment.

B16 What does the model see when the session enters or leaves Plan mode? RULE · code
Answered how

Code, two paths. On every context request, the Plan plugin finds the last enter/leave reminder in the messages: if the agent is plan and the last reminder is not the enter text, it inserts the enter reminder; if the agent is not plan and the last is the enter text, it inserts the leave reminder; either goes just before a trailing user message and is also persisted as a synthetic. Separately, on session.created with agent plan, or an agent switch into or out of plan, it publishes the matching reminder as a synthetic message.

Held by

packages/core/src/plugin/plan.ts:57-73 (per-request reconcile) and packages/core/src/plugin/plan.ts:75-112 (create/switch reminder)

Checked by

packages/core/test/plugin/plan.test.ts (imports PlanPlugin :10, verified by import read)

Contract

not recorded

On error

fail-soft: persist failure is logged (plan.ts:68-71, :89-92)

Failure history

#58287 (open) default_agent=plan reminder after first response; #57013 (open) "Plan mode leave reminder becomes the final user turn after a tool-driven agent switch, models refuse"; #58485 (closed) reminders invalidate the prompt cache; #55814 (open) plan-mode session can escalate via the general subagent

Notes

not recorded

B17 Which past messages are replayed to the model? RULE · code
Answered how

Code, per step: start at the latest completed compaction that applies to the current model (a provider-native window counts only if it was made by this same provider and model id, and still decodes; otherwise the read steps back to the compaction before it) and replay every stored message from there in order. A summary compaction gets the text of its kept recent messages rebuilt in memory; system messages older than the latest native checkpoint and non-replayable native windows are dropped. With no applicable compaction, the whole session is replayed.

Held by

packages/core/src/session/history.ts:103-142 (messageEntries); replay rule packages/core/src/session/history.ts:40-46; kept text packages/core/src/session/history.ts:149-182; runner entry packages/core/src/session/history.ts:192-209

Checked by

packages/core/test/session-native-compaction.test.ts (imports SessionHistory :17, verified by import read)

Contract

not recorded

On error

raises: one undecodable stored row fails the read (history.ts:92-101, :125-127; entriesForRunner .orDie :208)

Failure history

none found: not searched separately

Notes

lowering to provider messages (packages/core/src/session/runner/to-llm-message.ts) not read.

B18 What happens to an image, audio, video or PDF the model cannot read? RULE · code
Answered how

Code, on every request: each image, audio, video or PDF part (in user messages and in tool results) whose modality is not in the model's input capabilities, or, on xAI, an image that is not png/jpeg/webp, is replaced by the text "ERROR: Cannot read ... (this model does not support ... input). Inform the user." Other files pass through. A model whose input capability list is empty gets every such part replaced.

Held by

packages/core/src/session/model-request.ts:124-139; applied packages/core/src/session/model-request.ts:175-179, packages/core/src/session/model-request.ts:290

Checked by

packages/core/test/session-model-request.test.ts (imports unsupportedParts :3, verified by import read)

Contract

not recorded

On error

n/a (replacement is the handling)

Failure history

none found: not searched separately

Notes

not recorded

B19 When a conversation carries many images, which older ones are dropped from the request? RULING · code
Answered how

Code, on every request after B18: inline image payloads (base64 size; tool-result images by URI length; remote/provider-referenced images count zero) are summed oldest to newest; whenever the running total passes 25 MiB, the oldest images are cut until it is at or under 15 MiB. Each cut image is replaced by a fixed notice telling the model it was removed and not to describe it from memory. The hysteresis keeps the cut point stable between requests for the prompt cache.

Held by

packages/core/src/session/model-request.ts:181-209; thresholds packages/core/src/session/model-request.ts:42-45

Checked by

packages/core/test/session-model-request.test.ts (imports boundImages :3, verified by import read)

Contract

not recorded

On error

not recorded

Failure history

none found: not searched separately

Notes

thresholds are design-time (cache-friendly hysteresis :192-194). Possibly stage F by family; kept here because it decides what the model sees.

B20 Can a plugin rewrite what the model is sent (system, messages, tools, options, endpoint, headers)? RULING · code
Answered how

Code hands it to plugin hooks: context (primary), compaction or generate hooks get the system parts, messages, tool definitions and options (with the default output limit) and may change any of them; a title hook gets the same minus tools. Then a model.request hook may change the base URL and headers, and, only if a plugin registered them for this provider, http.request / http.response hooks may rewrite the raw HTTP request and response (WebSocket frames have their own experimental hooks). Shipped plugins use these for B2, B3, B14-B16, B21 and B23.

Held by

packages/core/src/session/model-request.ts:246-267 (request hooks via agentHook packages/core/src/session/model-request.ts:399-408); packages/core/src/session/model-request.ts:297-313 (model.request); packages/core/src/session/model-request.ts:314-369 (HTTP and WebSocket hooks)

Checked by

packages/core/test/session-model-request-hooks.test.ts (imports SessionModelRequest :11, verified by import read)

Contract

not recorded

On error

not traced

Failure history

none found: not searched separately

Notes

A user plugin makes it external. Plugins are trusted code per their SECURITY.md.

B21 Is the model asked for low verbosity? RULE · code
Answered how

Code, as a hook on context, compaction, generate and title requests: if nothing set textVerbosity (request options, model settings, or the selected variant's settings), and the model is reached through the OpenAI/Azure packages, an AI gateway with an openai/ id, or Bedrock Mantle with an openai. id, and its id names gpt-6, or gpt-5, gpt-5-mini/nano or gpt-5.1 through gpt-5.6 (not -chat or -image), verbosity is set to low.

Held by

packages/core/src/plugin/verbosity.ts:26-58

Checked by

packages/core/test/plugin/verbosity.test.ts (imports VerbosityPlugin :8, verified by import read)

Contract

not recorded

On error

not traced

Failure history

none found: not searched separately

Notes

not recorded

B22 Which Code Mode tools are listed inline, and which are left for the model to search? RULING · code
Answered how

Code: every namespace always gets a summary line, and pinned tools are always listed; their cost (characters / 4, rounded) comes out of a 2,000-token budget first. Then, going round the namespaces in name order, each namespace adds its next-cheapest tool listing (signature plus the description's first line cut to 120 characters) until its next candidate no longer fits, at which point that namespace stops. The rest are left for search, and the prompt says whether the catalog is partial or complete.

Held by

packages/core/src/codemode/catalog.ts:47-139 (summarize); packages/core/src/codemode/instructions.ts:9-30 (partial/complete prompt)

Checked by

packages/core/test/codemode/catalog.test.ts and codemode/instructions.test.ts (import CodeModeInstructions :3, verified by import read)

Contract

not recorded

On error

not traced

Failure history

none found: not searched separately

Notes

not recorded

B23 Which subagents does the model see as callable? RULE · code
Answered how

Code, as a hook on context, compaction and generate requests, only when the subagent tool is in the request's direct tool map: agents whose mode is not primary, are not hidden, and are not denied by the CURRENT agent's own permissions for the subagent action with the agent id as resource are appended, sorted by id, to the tool's description as "Available subagents:" with their descriptions. If none qualify the description is left unchanged.

Held by

packages/core/src/tool/plugin/subagent.ts:282-309

Checked by

packages/core/test/tool-subagent.test.ts (file present; covers this hook not read)

Contract

not recorded

On error

not traced

Failure history

none found: not searched separately

Notes

overlaps H (delegation); listed here because it is what the model sees.

B24 In ACP, how is client-supplied context (embedded resources, assistant-only text, links) added to the conversation? RULE · code
Answered how

Code, by content-block type. Text blocks annotated for the assistant audience only become one synthetic message (joined, delivery: "steer", resume: false) sent before the prompt; text annotated for the user audience only is dropped from what the model sees. An embedded text resource is inlined into the prompt text as [path:line] plus its text; an embedded blob with a MIME type becomes a data: file attachment; a file:// (or zed:// path) link becomes a file attachment, and one that is not readable locally, or any other link, becomes a markdown link in the text.

Held by

packages/cli/src/acp/turn.ts:228-238 (synthetic message); block mapping packages/cli/src/acp/content.ts:16-67, packages/cli/src/acp/content.ts:138-162; unreadable file:// to link packages/cli/src/acp/prompt.ts:25-29, packages/cli/src/acp/prompt.ts:45-51

Checked by

nothing found: not searched by import

Contract

not recorded

On error

classified ACP error (turn.ts:237)

Failure history

none found: not searched separately

Notes

not recorded

C. Choosing the next action (7 of 7 seats shown)

C1 What does the agent do next: answer in text, or call which tools with which arguments (including the program it writes for Code Mode)? RULING · llm
Answered how

The session's model decides in one streamed call per step, given the system prompt, history and tool definitions (direct tools sorted by name plus one Code Mode execute tool unless execute is wholly denied). Code then parses each streamed call's JSON, resolves it by name and validates it against the same schema after generation; only when the agent has a steps limit and the step reaches it does code send the max-steps prefill with toolChoice: "none".

Held by

packages/core/src/session/runner/step.ts:107 (one llm.stream call per step to the session's resolved model); request built at packages/core/src/session/runner/llm.ts:225-245 and packages/core/src/session/model-request.ts:272-295.

Checked by

packages/core/test/session-runner.test.ts (imports SessionRunnerLLM from @opencode/core/session/runner/llm at :50): scenarios :3763 "continues with reloaded history after durably settling one local tool call", :4087 "starts recorded local tools eagerly and awaits settlement", :6533 "continues after repeated malformed tool input", :6890 "rejects malformed streamed tool input ordering". These check the plumbing, not the quality of the model's choice; no eval or gold set for the choice itself was found (searched test names in packages/core/test and packages/simulation file list; PROVISIONAL, one route).

Contract

asks: tool definitions as JSON Schema (packages/core/src/tool/runtime.ts:22-28, assembled per request at packages/core/src/tool.ts:256-263: direct tools sorted by name plus one execute Code Mode tool), the system prompt (packages/core/src/session/model-request.ts:102-106), and on the last allowed step an assistant-prefill MAX_STEPS_PROMPT with toolChoice: "none" (packages/core/src/session/runner/llm.ts:238-242). Accepts: provider stream parsed into tool calls; argument JSON parsed at packages/ai/src/protocols/utils/tool-stream.ts:79-99 (parseToolInput, packages/ai/src/protocols/shared.ts:232-233, empty string read as {}); malformed local arguments become a tool-input-error event, never a repaired call (tool-stream.ts:93-97, comment :203-206); the call is resolved by name at packages/core/src/tool.ts:272-284 and its input validated against the same schema at packages/core/src/tool/runtime.ts:64-70. The contract is compilable (JSON Schema) and is enforced after generation, not during it; whether any provider adapter requests strict/constrained decoding was not checked (PROVISIONAL, not searched).

On error

fail-soft: malformed JSON, unknown tool name and schema failures are all returned to the model as tool failures and the step continues (packages/core/src/session/runner/publish-llm-event.ts:42-57, packages/core/src/tool.ts:282-284, packages/core/src/tool/runtime.ts:100).

Failure history

#57661 (closed) "invalid tool still leaks, malformed calls cascade intermittently (recurs #21900)"; #42359 (closed) "Response text leaks into tool call parameters"; #50206 (open) "Malformed XML/DSML tool-call output from Opencode Go models"; #45524 (closed) question tool calls omit a required field, SchemaError; #57484 (closed) Code Mode execute program shape silently returns null.

Notes

Code Mode is ON by default for tools registered without codemode: false (opencode-namespace tools, MCP tools by default packages/core/src/tool/mcp.ts:52), and every built-in leaf tool opts out (codemode: false in shell/read/edit/write/patch/grep/glob/webfetch/websearch/question/todo/skill/subagent plugins). So the model chooses between direct calls and one execute({code}) call whose program is itself part of this ruling. The model's decision to stop calling tools is also its "done" signal; the code side of that is C2, the judgment side belongs to stage G.

C2 After a step, does the session run another step without new user input? RULE · code
Answered how

Code: after a completed step, another step runs when any locally executed tool call in it was called or settled (a call failed for malformed input counts); this is forced false when tools were disabled at the step limit. Separately, a length finish with no tool work adds a synthetic continue message (failing after three consecutive length responses, packages/core/src/session/runner/llm.ts:299-328), and an interrupted stream after output may continue (E3).

Held by

packages/core/src/session/runner/publish-llm-event.ts:649-652 (needsContinuation: any non-provider-executed tool in the step was called or settled), forced false when tools were disabled packages/core/src/session/runner/step.ts:279, loop at packages/core/src/session/runner/llm.ts:186-193.

Checked by

packages/core/test/session-runner.test.ts (import verified, :50) :3763, :6533 "continues after repeated malformed tool input", :6556 "does not continue malformed tool input past the agent step limit", :6581 "does not continue automatically after a provider error follows a local tool call".

Contract

not recorded

On error

n/a for the rule itself; a step that fails raises StepFailedError (step.ts:278) and ends the drain.

Failure history

#18108 (closed) "Truncated tool calls are misclassified and unrecoverable (finishReason: length + repairToolCall + doom loop)"; #52378 (open) subagent MALFORMED_FUNCTION_CALL with empty content reported as success.

Notes

A tool call that fails on malformed input is "settled", so it also earns another step (publish-llm-event.ts:651). With no steps set on the agent (the default: packages/schema/src/agent.ts:39-52 has none) the only bound on a model that keeps emitting bad calls is the user; no current-runner doom-loop check was found.

C3 Is a streamed tool call complete and well-formed enough to execute, and if not, was it cut off by the output limit or just malformed? RULE · code
Answered how

Code: the provider parser tries to parse each call's streamed argument JSON and, on failure, emits tool-input-error instead of a call (a provider-executed call fails the stream instead). At flush the publisher fails each malformed call with the output-limit message if the step finished on length, else the malformed-JSON message, and fails any started-but-never-called input as incomplete; each failure returns up to 2,048 characters of the raw input to the model.

Held by

packages/ai/src/protocols/utils/tool-stream.ts:79-99 (parse or tool-input-error), packages/core/src/session/runner/publish-llm-event.ts:342-367 (malformed marked, then failed at flush with "length" when the step finished with length, else "malformed"), :398-403 (failIncompleteTools: started but never called), packages/core/src/session/runner/step.ts:227-232.

Checked by

packages/core/test/session-runner.test.ts (:50) :6231 "settles malformed streamed tool input before the provider failure", :6266 "settles unfinished tool input after an output limit", :6417, :6450, :6487.

Contract

not recorded

On error

fail-soft to the model (the call is not executed; an excerpt of up to 2048 characters of the raw input is returned, publish-llm-event.ts:33,659-670). Provider-executed (hosted) calls with bad input fail the stream instead (tool-stream.ts:94-95).

Failure history

#18108 (closed, above); #57661 (closed, above).

Notes

The parser never repairs JSON; repair of well-formed-but-mistyped arguments happens later (C4).

C4 Should the model's tool arguments be coerced to fit the tool's schema before validation (stringified JSON parsed, "20" to 20, unknown keys dropped from closed objects, null optional fields removed)? RULING · code
Answered how

Code, on every tool call except the Code Mode execute call itself: it looks the called tool up in the live registry and, when the input schema is an object, rewrites arguments only where the schema allows one reading (stringified JSON parsed, numeric and boolean strings converted, undeclared keys dropped from closed objects, null or {} removed from optional plain fields, a lone compatible value wrapped in an array). Anything ambiguous, or deeper than 6 levels, passes through unchanged.

Held by

packages/core/src/plugin/tool-input-repair.ts:21-36 (built-in plugin opencode.tool.input.repair, hook tool.execute.before, always registered first at packages/core/src/plugin/internal.ts:217-218), rules packages/core/src/plugin/tool-input-repair.ts:38-170, depth cap packages/core/src/plugin/tool-input-repair.ts:19.

Checked by

packages/core/test/plugin/tool-input-repair.test.ts and packages/core/test/plugin/tool-input-repair-integration.test.ts (both import ToolInputRepairPlugin from @opencode/core/plugin/tool-input-repair, line 3 and line 5).

Contract

not recorded

On error

fail-soft: any shape it cannot repair unambiguously is passed through unchanged (:39, :47, :65, :74) and left to schema validation (C5).

Failure history

#53025 (open) "V2: tool input repair uses the live schema instead of the request snapshot".

Notes

Skips the Code Mode execute call itself (:28); calls made from inside a Code Mode program pass through the same hook (packages/core/src/tool.ts:246-249). The repair silently drops undeclared keys from closed objects (:107-111), which changes what the model asked for without telling it. Reads the live registry, not the request's snapshot (#53025).

C5 Do the (repaired) arguments satisfy the tool's input schema? RULE · code
Answered how

Code decodes the (repaired) input against the tool's schema: Effect schemas and Standard Schemas are validated directly, raw JSON Schemas are converted once and cached. Issues (the first five) are returned to the model with the arguments and an instruction to call again; if a raw JSON Schema cannot be converted, the input is accepted unvalidated.

Held by

packages/core/src/tool/runtime.ts:64-87 (decodeInput/validateInput), JSON Schema conversion cache :11-20.

Checked by

packages/core/test/tool-schema.test.ts (imports definition, execute from ../src/tool/runtime at :10); packages/core/test/tool-execute.test.ts (imports execute from @opencode/core/tool/runtime at :4).

Contract

not recorded

On error

fail-soft to the model on a schema failure (:67-68, message :100). FAIL-OPEN when a raw JSON Schema cannot be converted: the cache stores undefined (:15-18) and the value is accepted unvalidated (:79).

Failure history

#45524 (closed) question tool SchemaError on multi-question calls.

Notes

Output is checked too (:47-61, :111-134), but a tool registered with a raw JSON Schema output (all MCP tools, packages/core/src/tool/mcp.ts:55) is only checked to be JSON, not against that schema (runtime.ts:129-133).

C6 Which registered tool does a call actually run, and with which input (aliases, plugin rewrites, tools removed for this request, Code Mode execute versus a direct tool)? RULE · code
Answered how

Code: after the tool.execute.before hooks (which may rewrite the name and input), it looks the name up in the request's advertised definitions (mapping hook renames back), refuses a known tool removed from this request, routes execute to the Code Mode tool, otherwise runs the direct tool of that name, else returns "No tool named ..." to the model.

Held by

packages/core/src/tool.ts:264-285 (after the tool.execute.before hook at :272, which may rewrite event.tool and event.input, :103-111).

Checked by

packages/core/test/session-runner.test.ts (:50) :1130 "executes a tool renamed by a session context hook", :1148 "executes the tool advertised before a registry reload"; packages/core/test/tool-registry.test.ts (registry behaviour; import line not re-verified).

Contract

not recorded

On error

fail-soft: "Tool is not available for this request" (:276) or "No tool named ..." (:283) returned to the model.

Failure history

#58778 (closed) "native tool calls to plugin-registered tools stall before dispatch"; #50652 (open) "External plugin tools bypass configured permissions".

Notes

Any installed plugin with a tool.execute.before hook can change the tool name and input before dispatch, so with plugins configured the effective holder is external. The "not available" guard (:275-276) stops a name that exists in the registry but was removed from this request, and only when the caller passed definitions (the session runner does, packages/core/src/session/model-request.ts:382). Tools called from inside a Code Mode program skip this guard: they resolve against the Code Mode inventory (tool.ts:244-249), which a session context hook never sees, because that hook only sees the single execute definition.

C7 What is the working plan, and which of its items are done? RULING · llm
Answered how

Only when config experimental.todo is true: the model writes the whole list through todowrite; code asks the todowrite permission, then stores and echoes the list as sent, with no check against the previous list or the work done.

Held by

packages/core/src/tool/plugin/todo.ts:52-79 (the session model, via the todowrite tool); registered only when config experimental.todo === true (packages/core/src/tool/plugin/todo.ts:50).

Checked by

packages/core/test/tool-todo.test.ts (imports TodoTool from @opencode/core/tool/plugin/todo at :8).

Contract

asks (packages/core/src/tool/todowrite.txt:1-7): send the FULL list every call, mark completed "as soon as they're actually done". Accepts: any array of {content: string, status} with status one of pending, in_progress, completed, cancelled (packages/core/src/tool/plugin/todo.ts:13-23); nothing compares a new list with the previous one or with work done; the list is echoed back as a count summary (packages/core/src/tool/plugin/todo.ts:30-41).

On error

fail-soft for a configured deny or a decline with feedback (typed errors, mapped to "Unable to update todos" at packages/core/src/tool/plugin/todo.ts:72); a plain decline is re-raised as a defect (packages/core/src/permission.ts:242-244), so it is not caught at :72 and interrupts the run (E4).

Failure history

none found: gh search issues --repo anomalyco/opencode "todowrite" not run; searched "max steps", "retry", "tool output truncated" only. PROVISIONAL.

Notes

Seat absent by default (present when experimental.todo: true).

D. Gatekeeping: may this action run? (19 of 19 seats shown)

D1 Given this agent's rules, this session's rules and the project's saved approvals, does this action on these resources run, wait for a person, or get refused? RULE · code
Answered how

Code merges the agent's rules (all-deny if the agent cannot be resolved) with the session's rules; if any resource's last matching rule is deny, the answer is deny. Otherwise the project's saved approvals are appended as allow rules, each resource is re-evaluated (no match = ask), any ask makes the whole call ask, and that effect then passes through the plugin permission.evaluate hooks (D4, D6), which may change it before the call runs, waits for a reply, or is blocked.

Held by

packages/core/src/permission.ts:160-176 (evaluateInput), matcher packages/core/src/permission.ts:74-84 (evaluate, last match wins, default ask), deny precedence packages/core/src/permission.ts:152-154,162.

Checked by

packages/core/test/permission.test.ts:100-445 imports @opencode/core/permission (:9) and exercises ask/assert/reply (e.g. :139 explicit allow/deny, :200 deny-all when no default agent, :235 merge order session-after-agent-before-saved, :263 saved approvals cannot lift a configured deny). Verified by the import line and test names.

Contract

not recorded

On error

fail-closed for an unknown agent: missingAgentPermissions deny-all (permission.ts:19,149); unknown session raises Session.NotFoundError (:147); after the service closes every ask returns deny (:215) and every assert dies with DeclinedError (:224); pending asks are rejected on close (:120-133). Saved-rule read failure dies (permission/saved.ts:65, Effect.orDie): raises.

Failure history

#55593 (closed) "core: permission 'ask' silently auto-approved — no request created (deny still enforced)"; #56470 (closed) "permissions: ask effect is fail-open for shell commands (command runs, client reply gets 404)"; #56613 (closed) "API-created session permissions (deny) not reliably enforced — rules don't match real tool names"; #40150 (open) "write permission rules are silently ignored — write.ts asks under edit". Searched gh search issues --repo anomalyco/opencode "auto-approve" permissions, policy "tool.use", "opencode run" permission auto-reject.

Notes

This card covers the built-in tools' permission asserts together with the MCP tool gate packages/core/src/tool/mcp.ts:58-70 (action = server tool name, resource *), the subagent launch gate tool/plugin/subagent.ts:138-145 (resource = agent id), and the HTTP session.permission.create route packages/server/src/handlers/permission.ts:39-58 (an outside caller can raise an ask on any session). Each evaluation reads the current agent rules, session rules and saved approvals; the run-time question is a lookup: RULE. The defaults disagree with two of opencode's own documents. The permissions docs say "Most permissions default to "allow"" and "doom_loop and external_directory default to "ask"" (packages/web/src/content/docs/permissions.mdx:176-177), but the default rule list (packages/schema/src/agent.ts:46-51) holds only *:* allow and three .env read rules, so in that list neither of those two actions matches anything but the allow. Their security page says the permission system "prompts for confirmation before executing commands, writing files, etc." (SECURITY.md:17); with no config, the default Build agent's shell and edit calls likewise match only *:* allow in that list and run without a prompt (Plan adds its own edit deny, D2). The same paragraph says it "exists as a UX feature to help users stay aware of what actions the agent is taking" and "is not designed to provide security isolation".

D2 In Plan mode, may this edit/write/patch call change a file outside ~/.opencode/plan? RULE · code
Answered how

Code: the built-in Plan agent starts from the default *:* allow rules and the Plan plugin appends edit:* deny then edit:<home>/.opencode/plan/* allow, so D1's last-match lookup blocks an edit-family call outside that directory, unless a config edit rule (global or for the plan agent) is appended later and matches, in which case it wins. A blocked edit/write/patch is rewritten into a tool failure naming the plan directory.

Held by

packages/core/src/plugin/plan.ts:38-40 (edit:* deny, then edit:<plan dir>/* allow), evaluated by D1; failure message rewritten at packages/core/src/plugin/plan.ts:44-53.

Checked by

packages/core/test/plugin/plan.test.ts:249 "allows edits only inside the Plan directory" and :270 "rewrites blocked mutation failures" (imports @opencode/core/plugin/plan :10 and @opencode/core/permission :11; verified by import lines).

Contract

not recorded

On error

fail-closed (a deny raises Permission.BlockedError, packages/core/src/permission.ts:228-235, rewritten to a ToolFailure naming the plan directory, plan.ts:49-51).

Failure history

#55127 (closed) "Global/project 'edit': 'allow' silently overrides built-in plan agent's read-only deny"; #39715 (closed) "Warn users when Plan mode permissions are silently overridden by global config"; #50241 (closed) "plan-mode guard denies writes to configured plan directory"; #57066 (closed) "Plan mode restriction inconsistency - files modified before mode enforcement".

Notes

Config-authored rules are pushed AFTER the built-in plan rules (ConfigAgentPlugin runs in the post list, plugin/internal.ts:254-257, pushing global rules into every agent at config/plugin/agent.ts:85-93 and per-agent rules at :122-124), so a user edit: allow wins by last match. That is the #55127 class; at the pin the order is unchanged. Only the edit action is denied; see D3.

D3 In Plan mode, may this shell command, subagent delegation or MCP call change files? RULING · llm
Answered how

The session's model decides, steered only by the Plan reminder text ("Do not modify any other files or ask a subagent to do so"), which code inserts on switch to Plan and re-inserts per request when missing. Code adds no rule for these routes: with no config rules, the Plan agent's *:* allow lets shell, subagent and MCP tool asserts through, and a subagent child runs under its own agent's rules plus only the parent's session-level rules.

Held by

packages/core/src/plugin/plan.ts:14-21 (the Plan reminder enter, obeyed or not by the session's model), injected on agent switch (packages/core/src/plugin/plan.ts:75-96) and reconciled per request (packages/core/src/plugin/plan.ts:57-73). No code rule: the Plan agent's ruleset is Agent.Info.default (packages/schema/src/agent.ts:46-51, *:* allow) plus question allow and the two edit rules (packages/core/src/plugin/plan.ts:38-40).

Checked by

nothing found: Grep plan in packages/core/test/tool-subagent.test.ts and packages/core/test/tool-shell.test.ts (no Plan-agent case); plan.test.ts:158-242 test reminder injection only, i.e. that the prompt piece is present, not that it is obeyed.

Contract

the reminder asks the model not to modify files other than plan files and not to ask a subagent to (plan.ts:15-20). The consumer: shell runs under *:* allow (packages/core/src/tool/plugin/shell.ts:137-145 asserts shell, which matches the allow); subagent asserts against the Plan agent's rules (subagent.ts:138-145, allowed) and the child runs under the CHILD agent's own rules plus inherited session rules only (packages/core/src/permission.ts:145-150, packages/core/src/session.ts:298-301), e.g. general = default *:* allow with question/subagent denied (packages/core/src/plugin/agent.ts:63-72). Nothing compares the model's action to the reminder. Compilable: partly (an edit-equivalent deny on shell writes is not compilable; a subagent deny or a propagated parent ruleset is).

On error

fail-open: if the model ignores the reminder, the action runs (no check found, see Checked by).

Failure history

#55814 (open) "agents: plan-mode session can escalate to edit access by delegating to the general subagent"; #58537 (closed) "agents: plan mode allows file mutations through MCP tools"; #53955 (open) "Agent is making edits when on plan mode"; #53635 (closed) "OpenCode edited code in plan mode"; #53681 (closed) "Plan Mode restriction bypass"; #56439 (closed) "agent executes changes while plan mode is active (mode not re-annotated on later turns)"; #56674 (closed) reminder wording. Searched gh search issues --repo anomalyco/opencode "plan mode" subagent edit and "plan mode" bash shell write.

Notes

The agent is described in code as "Read-only agent" (plan.ts:36). The HOLDER for "may Plan change files" resolves to two names (code for edit, the model for every other route); that split IS the finding (a candidate gap, not on this edition). Their security page says the permission system "exists as a UX feature" and "is not designed to provide security isolation" (SECURITY.md:17), so this is read against the product's own "Read-only agent" description, not as isolation.

D4 Does a configured or organization policy forbid this action on this resource, whatever the agent rules say? RULE · code
Answered how

Code, as a permission evaluate hook: for each resource it finds the last tool.use statement whose resource pattern matches <action>:<resource> (authored config documents in reverse load order, then organization statements from the connected Console last); if any resource's match is deny, the effect becomes deny with that statement's message. It never grants, and it is only reached when D1's configured rules did not already deny.

Held by

packages/core/src/config/plugin/policy.ts:54-69 (permission evaluate hook: last matching tool.use statement on "<action>:<resource>"; a deny sets effect deny with the statement's message). Statement order packages/core/src/managed-policy.ts:50-64 (authored documents reversed so user-global outranks repository, organization statements last); organization statements are pushed by the Console connection through ManagedPolicy.set (packages/core/src/managed-policy.ts:35-44).

Checked by

packages/core/test/config/policy.test.ts:51-213 imports ConfigPolicyPlugin (:6), ManagedPolicy (:8), PluginHooks (:10) and triggers hooks.trigger("permission","evaluate",...) (:41); cases :138 "turns an ask into a deny but never grants", :63 organization overrides authored, :92 project cannot override user-global. Verified by import lines.

Contract

not recorded

On error

not traced beyond: the hook is Effect.sync with no failure channel (plugin/hooks.ts:23-30, permission hooks have NoFailures). Policy can only turn an effect to deny, never grant (policy.ts:65-67).

Failure history

#56349 (open) "tool.use policy cannot deny the execute (Code Mode) tool"; #55232 (open) "shell/webfetch policy resources match raw strings — argv tokenization and URL host normalization needed"; #54214 (closed) "experimental.policies silently drops 'permission' action statements".

Notes

Runs only where a leaf calls assert and only when D1 did not already deny (packages/core/src/permission.ts:162 returns before the hook). Tools that never assert (todo, question, and any package-plugin tool whose author omits ctx.permission.assert) are outside it. Ordering: ConfigPolicyPlugin is the LAST post internal plugin (plugin/internal.ts:271), after package plugins (plugin/supervisor.ts:99-103), so its hook runs after third-party evaluate hooks and has the last word; it is protected from config remove (internal.ts:274-276, supervisor.ts:43). The code keeps its own stated guard here. Their security page says the permission system "is not designed to provide security isolation" (SECURITY.md:17).

D5 Does policy remove this provider, MCP server or skill from what the session can reach? RULE · code
Answered how

Code, in catalog transforms rerun whenever config or the organization policy changes: a provider whose last matching provider.use statement is deny is removed, as is an MCP server or skill whose last matching integration.use statement on mcp:<name> / skill:<id> is deny; no matching statement means allow. Package plugins get the same integration.use check on plugin:<target> before loading and are skipped when denied.

Held by

packages/core/src/config/plugin/policy.ts:28-53 (provider.use :28-41, integration.use mcp:<name> :42-47, skill:<id> :48-53), decision helper packages/core/src/managed-policy.ts:66-75 (default allow).

Checked by

packages/core/test/config/policy.test.ts:74,166,184,213 (provider filtering and organization rebuilds; same imports as D4); MCP removal packages/core/test/mcp.test.ts:2743-2778; skills packages/core/test/skill.test.ts:48-117, tool-skill.test.ts:50, session-skill.test.ts:104; plugin load packages/core/test/plugin/module.test.ts:418,435, plugin/supervisor-reload.test.ts:358,378 (each sets integration.use statements through managed.set or config; found by Grep integration\.use over packages//test/; imports of the policy plugin not opened per file).

Contract

not recorded

On error

fail-open by default (managed-policy.ts:73, no statement -> allow); a policy-store change reloads the catalogs (policy.ts:20-23).

Failure history

none found: gh search issues --repo anomalyco/opencode policy "tool.use" returned no provider/MCP/plugin removal defect.

Notes

Consolidated into one seat: three transforms in one plugin (policy.ts:28-53) plus the plugin-load gate packages/core/src/plugin/module.ts:50-60 (integration.use on plugin:<target> -> { blocked: true }, honoured at plugin/supervisor.ts:68-74), one helper (ManagedPolicy.decision), one holder. Stage note: this narrows A/B (what exists), recorded here because policy is the holder.

D6 Does an installed plugin change this permission decision (ask to allow, allow to ask or deny, or add a message)? RULING · external
Answered how

Third-party plugin code that registered a permission evaluate hook gets the computed effect (ask or allow) and may overwrite effect and message; hooks run in registration order and the last write stands, with the built-in policy hook (D4) registered after package plugins. It is never consulted when the configured rules already deny, and with no such plugin installed the seat is empty.

Held by

packages/core/src/plugin/host.ts:415 (any plugin's permission.hook("evaluate", ...)), run by PluginHooks.trigger in registration order (packages/core/src/plugin/hooks.ts:88-95) from packages/core/src/permission.ts:166-175; contract type packages/plugin/src/effect/permission.ts:10-23 (mutable effect, message).

Checked by

packages/server/test/session-instances.test.ts:102 registers an evaluate hook (not opened beyond the grep line; wiring not verified). Nothing else found: Grep "permission", "evaluate" over packages (policy test and provider-opencode test are internal hooks).

Contract

not recorded

On error

not traced: a hook that throws is a defect in trigger (no catch at hooks.ts:91-92), so assert dies.

Failure history

#56869 (closed) "browser: config deny for browser permission is overridden by plugin-appended allow rule" (a plugin APPENDING agent rules, a sibling route; see Notes). Searched policy "tool.use".

Notes

A hook cannot lift a configured deny (D1 returns first) and cannot lift a policy deny (D4 runs last). It CAN turn an ask into allow with no person. Plugins can also push agent rules through ctx.agent.transform (host.ts agent domain), which is how #56869 happened; that is the same external holder by a second route, not a second seat.

D7 Does an installed plugin reject this tool call (or rewrite its input) before it runs? RULING · external
Answered how

Before every tool call (direct, and each call made inside Code Mode) code triggers the tool execute.before hooks in registration order; a hook may rewrite the event's input (and, on the direct path, its tool name) or fail with a Tool.Error, which stops the call before it runs. With no third-party hook installed only built-in hooks (such as input repair) run, and the veto seat is empty.

Held by

packages/core/src/tool.ts:103-111 (tool hook execute.before), triggered for direct calls (packages/core/src/tool.ts:272) and code-mode calls (packages/core/src/tool.ts:246-249); it is the only hook allowed to fail (packages/core/src/plugin/hooks.ts:23, ToolFailures).

Checked by

packages/core/test/session-runner.test.ts:4960-4990 "preserves each parallel Promise hook failure": a Promise plugin's execute.before throws for two of three calls; the test asserts only the third executed and the two failures reached the context (verified by reading the scenario; it registers through PluginHooks.Service, the same service tool.ts:103-111 triggers).

Contract

not recorded

On error

a Tool.Error from the hook fails the call before execution (fail-closed for that call); through the Promise plugin adapter a thrown error also fails only that call and reaches the model as a failed tool (see Checked by).

Failure history

none found: not searched separately (time-box).

Notes

The built-in ToolInputRepairPlugin (plugin/tool-input-repair.ts) uses this hook to repair input, which is stage C (parsing). Counted here only as the veto a third party can exercise.

D8 Is this file or directory outside the session directory and project worktree, so that an external_directory approval is required, and which directory does that approval cover? RULE · code
Answered how

Code resolves the path lexically (no symlink resolution) against the session directory; it is inside if it lies under that directory, or under the project worktree unless the worktree is a filesystem root. Otherwise the tool raises one batched external_directory ask whose resource is <directory>/* and whose "always" covers <project root of that directory, else the directory>/*. For shell, the only paths checked are the working directory and literal cd-family arguments; arguments with $, backticks or a leading ( are dropped unchecked. Whether the ask reaches anyone is D1's call, and the built-in agents allow external_directory by default.

Held by

packages/core/src/file-access.ts:93-123 (resolve: inside = under the location directory, or under the worktree unless the worktree is a filesystem root), packages/core/src/file-access.ts:125-142 (authorizeExternal, one batched assert), packages/core/src/file-access.ts:144-161 (authorizeRead; a filename-recovery sibling skips the directory check, :150-153). Shell supplies directories only from cd-family commands (packages/core/src/shell/parse.ts:14,192-195,237-265) plus its own cwd (packages/core/src/tool/plugin/shell.ts:123-136).

Checked by

packages/core/test/file-access.test.ts and packages/core/test/file-access-path.test.ts import @opencode/core/file-access (file-access.test.ts:5); packages/core/test/plugin/plan.test.ts:260 "allows external directories without asking" (pins the default allow, see Notes). Verified by import lines.

Contract

not recorded

On error

fs.stat NotFound is tolerated and the path is treated as a file (file-access.ts:110-112); other stat errors raise (FSUtil.Error).

Failure history

#54524 (open) "permissions: built-in external_directory ask never prompts, identical explicit rule does"; #56288 (open) "external_directory ask is skipped for shell"; #46398 (open) "Security: external_directory does not gate PowerShell/bash file writes on Windows"; #58638 (open) "a symlink bypasses read rules and the external_directory check"; #57515 (open) phantom prompt on relative cd; #52272 (closed) macOS /var alias; #54869 (open) bound at instance directory instead of worktree. Searched gh search issues --repo anomalyco/opencode "external_directory".

Notes

Whether the check ASKS is D1's call, and out of the box it does not: Agent.Info.default grants *:* allow (packages/schema/src/agent.ts:46-51) and no built-in rule names external_directory with ask (Grep effect: "ask" over packages, tests excluded: only the three .env read rules). The public docs say it defaults to ask (packages/web/src/content/docs/permissions.mdx:177). A candidate gap. For shell, only directory-changing commands contribute paths; cat /outside/file contributes none (parse.ts:192-199); SECURITY.md:17 frames this as UX, so DESIGN unless the docs' "many bash commands" claim (permissions.mdx:114) is taken as the stated guard.

D9 Which commands does this shell string contain (each one is asked about separately), and what command prefix does an "always allow" reply approve for the future? RULING · code
Answered how

Code parses the command string (the portable scanner when experimental.portable_shell_scanner is set, or by default when the bash wasm parser is absent or the app channel is local/dev; otherwise tree-sitter) and asks about every non-cd command as its own resource in one assert. Each command's "always" pattern is its first tokens cut to the length the fixed ARITY table gives for the longest known prefix (one token if unknown), plus *. A string the parser cannot parse or the portable scanner calls opaque fails the tool before any ask.

Held by

packages/core/src/shell/parse.ts:164-288 (scan, scanLegacy, scanPortable), prefix by the ARITY table and prefix (packages/core/src/shell/parse.ts:23-162,375-381); the save pattern is "<prefix> *" (packages/core/src/shell/parse.ts:198,268-281); consumed at packages/core/src/tool/plugin/shell.ts:137-145.

Checked by

packages/core/test/shell-parse.test.ts:9-166 imports @opencode/core/shell/parse (:5) ("derives reusable prefixes" :9); parity and oracle suites packages/core/test/shell-scan/*.test.ts and shell-parse-parity.test.ts; permission.test.ts:447 "shell scanner permission impact". Verified by import lines. No test measures whether the arity table's prefixes are the right breadth (that is a judgment; see three passes).

Contract

not recorded

On error

fail-closed: a parse failure or an opaque command fails the tool before any assert (parse.ts:179,219-220; shell.ts:129).

Failure history

#52083 (open) "compound shell commands drop redirection, so an allowed command becomes an unprompted file write"; #49165 (open) choose exact command vs pattern for always-allow; #55232 (open) policy matches raw strings.

Notes

RULING implemented deterministically: git * vs git push * is a breadth choice the ARITY table makes once (Setting S-7). Directory-changing commands are not asked about as commands (parse.ts:192-195).

D10 When a person answers "always", which future actions are approved without asking, in which sessions, and until when? RULING · code
Answered how

When a person replies "always" to a request that carries save patterns, code stores one allow row per pattern for the project, with no expiry, and appends those rows to every later evaluation in the project for every agent and session; it then re-evaluates every other pending request (in any session) and approves those that now come out allow. How wide each pattern is was fixed per tool at its assert call: * for edit, write, patch, read, glob, grep, webfetch, websearch, todo and MCP tools; the agent id for subagent; the skill id for skill; the shell prefix pattern (D9); and the project root of the directory for external_directory. A saved allow never lifts a configured deny.

Held by

storage packages/core/src/permission.ts:290-296 -> packages/core/src/permission/saved.ts:69-84 (one allow row per save pattern, keyed by project, no expiry); applied to every later evaluation in the project for every agent (packages/core/src/permission.ts:135-143,163); pending requests re-evaluated and auto-approved at once (packages/core/src/permission.ts:299-313). The breadth is chosen per tool at its assert call: edit/write/patch save: ["*"] (packages/core/src/tool/plugin/edit.ts:278), webfetch ["*"] (packages/core/src/tool/plugin/webfetch.ts:119), read ["*"] (packages/core/src/file-access.ts:157), MCP tool ["*"] (packages/core/src/tool/mcp.ts:61), subagent [agent id] (packages/core/src/tool/plugin/subagent.ts:142), external directory = the project root of that directory + /* (packages/core/src/file-access.ts:120), shell = prefix pattern (D9).

Checked by

packages/core/test/permission.test.ts:263 (saved approvals keep configured deny precedence), :344 (stores and removes saved resources), :376 (edited saved approval enforced). Verified by imports (:9-11). Nothing checks the per-tool breadth choices.

Contract

not recorded

On error

saved.add dies on a DB error (saved.ts:83, Effect.orDie): raises; an empty save list stores nothing (:70) and the TUI then offers no "always" (tui/src/routes/session/permission.tsx:232-240).

Failure history

#48456 (open) "V2 'Always allow' silently overrides configured 'ask' across future project sessions"; #50600 (open) "allow-always is durable and project-scoped with no opt-out or TUI reset"; #56807 (open) scope choice for external_directory; #56284 (open) "Reject always". Searched gh search issues --repo anomalyco/opencode "always allow" permission scope.

Notes

Consolidated into one seat: the per-tool save values are one question ("how wide is always?") answered by one mechanism; listed above. A saved allow lifts an ask but never a configured deny (permission.ts:162-163). Saved rows can be edited to any action/resource through permission.saved.update (server/src/handlers/permission.ts:98-121); that is a person's settings screen, not a run-time seat.

D11 When a request is rejected, does the step end or does the model get the reason and continue, and what happens to the other requests waiting in that session? RULE · code
Answered how

Code: a reject that carries a message fails the waiting call with CorrectedError, which stays a typed tool failure so the model sees the feedback and continues; a reject without a message fails it with DeclinedError, which assert turns into a defect that (per the code comment) ends the step. Every other pending request in the same session is rejected the same way at once, with the same message if there is one.

Held by

packages/core/src/permission.ts:267-287 (a reject with a message -> CorrectedError, without -> DeclinedError; every other pending request of the same session is rejected the same way), packages/core/src/permission.ts:238-244 (DeclinedError becomes a defect that ends the step; CorrectedError stays a typed tool failure the model sees). Plugin-facing equivalent packages/core/src/plugin/host.ts:437-446.

Checked by

packages/core/test/permission.test.ts:326 "defects when an asked permission is declined" (verified import :9); packages/core/test/plugin/permission-assert.test.ts (named by grep, not opened).

Contract

not recorded

On error

n/a (this seat is the error path).

Failure history

#44267 (closed) "opencode run: when a permission is auto-rejected, the final message is empty"; #52846 (open) "pending requests destroyed on instance disposal without revocation event; dialog unanswerable".

Notes

The TUI offers the feedback text field only inside a child (subagent) session (tui/src/routes/session/permission.tsx:248-254); in a top-level session reject sends no message, so the step ends. Recorded as a TUI choice in D12.

D12 May this action run? (the person's Allow once / Always allow / Reject in the terminal UI) RULING · human
Answered how

Only requests that D1 and the evaluate hooks left at ask reach the TUI. In the default prompt mode the person picks Allow once, Always allow (offered only when the request carries save patterns) or Reject (Escape also rejects); in a subagent's session Reject first opens a feedback field. When the TUI runs with --auto/--yolo/--dangerously-skip-permissions or config session.permissions: "autoaccept", code replies "once" to every pending request of the open session and its descendants instead and no prompt is shown.

Held by

packages/tui/src/routes/session/permission.tsx:113-266 (PermissionPrompt; "always" offered only when the request carries save, packages/tui/src/routes/session/permission.tsx:232-240; Escape = reject, :241); prompts shown only in prompt mode (packages/tui/src/routes/session/index.tsx:212).

Checked by

packages/tui/test/cli/tui/input-scope.test.tsx:15,234,255 imports and mounts PermissionPrompt (top-level and child-session cases; what it asserts about the reply was not read). packages/tui/test/cli/tui/permission.test.ts:2-4 imports only the label helper. Found by Grep PermissionPrompt over packages//test/.

Contract

not recorded

On error

a failed reply shows a toast and the request stays pending (permission.tsx:136-140); the tool waits (packages/core/src/permission.ts:238): fail-closed.

Failure history

#58630 (closed) "TUI freezes to blank screen when external_directory permission prompt arrives while response is streaming"; #47545 (closed) "Auto mode causes repeated false permission notifications in terminals".

Notes

--auto / --yolo / --dangerously-skip-permissions or TUI config session.permissions: "autoaccept" makes code answer "once" for every ask (tui/src/context/permission.tsx:13-15, tui/src/routes/session/index.tsx:266-283; default "prompt", tui/src/config/index.tsx:327). "Always" persists a project-wide allow (D10). Family: D12-D16.

D13 May this action run? (the person's choice in the app's request dock) RULING · human
Answered how

Only requests that D1 and the evaluate hooks left at ask reach the app. With auto-approve off (the default, permissions.autoApprove: false unless a per-project override says otherwise) the dock shows the open session's pending request and the person's once / always / reject goes to permission.reply. With auto-approve on for that project, the dock hides the request and the client-side auto-approver replies "once" to it (and sweeps pending requests on every connect).

Held by

packages/app/src/session/requests/model.ts:98-106 (request shown unless the project auto-approves) and packages/app/src/session/requests/model.ts:166-185 (decide -> permission.reply).

Checked by

packages/app/e2e/regression/multi-server.spec.ts:250-278 drives the auto-accept settings and asserts which requests got replies (the app runtime wires the auto-approver at packages/app/src/runtime/server/runtime.tsx:10); nothing found for the manual decide path (Grep decide in packages/app/e2e: not run; PROVISIONAL).

Contract

not recorded

On error

a failed reply shows a toast and the request stays pending (model.ts:176-184): fail-closed.

Failure history

#55176 (open) "Project auto-accept Off is ignored for permission requests from unloaded sessions"; #51507 (open) "desktop: 'needs permission' system notification never fires"; #48579 (closed) permission sound while auto-approve is on.

Notes

App setting permissions.autoApprove (default false, packages/app/src/settings/model.tsx:289) with per-project overrides (:335-438) moves the seat to code (packages/app/src/session/requests/auto-approve.ts:15-169, replies "once"). Family: D12-D16.

D14 May this action run? (Allow once / Always with a confirmation step / Reject with a message, in the minimal terminal UI) RULING · human
Answered how

Only requests that D1 and the evaluate hooks left at ask reach opencode mini. The person picks Allow once, Always (offered only when the request carries save patterns, and followed by a confirmation step) or Reject (Escape), and Reject opens a text field for a message; the choice goes to permission.reply. No auto-accept path was found in the mini sources.

Held by

packages/tui/src/mini/footer.permission.tsx:167-169 (options; "always" hidden without save) in the three-stage prompt packages/tui/src/mini/footer.permission.tsx:1-371 (permission / always-confirm / reject), reply sent at packages/tui/src/mini/runtime.ts:267-274.

Checked by

packages/tui/test/mini/footer.view.test.tsx and packages/tui/test/mini/permission.shared.test.ts (named by grep, imports not opened; PROVISIONAL).

Contract

not recorded

On error

fail-closed: a failed reply only clears the submitting flag and leaves the prompt up (packages/tui/src/mini/footer.permission.tsx:208-222), so the request stays pending and the tool keeps waiting (packages/core/src/permission.ts:238).

Failure history

none found: not searched separately (time-box).

Notes

Separate seat from D12: different UI and an extra confirmation step before "always". No auto mode found in mini (Grep auto in tui/src/mini/runtime.ts: no hits). Family: D12-D16.

D15 May this action run? (the person's choice in the editor that drives opencode over ACP) RULING · human
Answered how

Only requests that D1 and the evaluate hooks left at ask reach the editor. opencode sends the editor an ACP requestPermission with three fixed options (Allow once, Always allow, Reject) and a preview of the tool call; whatever the editor's UI (or the editor itself) selects is replied, and any outcome other than a selected once/always counts as reject. Always is offered even when the request carries no save patterns, in which case core treats it as once.

Held by

packages/cli/src/acp/permission.ts:47-74 (request built and sent, options once / always / reject at packages/cli/src/acp/permission.ts:39-43), answered by the outside client's UI; queued at packages/cli/src/acp/turn.ts:165-167.

Checked by

nothing found: Grep ACPPermission|requestPermission over packages//test/ (no hits); cli/test/acp/additional-directories.test.ts covers D17 only. One route; PROVISIONAL.

Contract

not recorded

On error

fail-closed: any outcome other than a selected once/always is a reject (packages/cli/src/acp/permission.ts:72-73); if the turn is cancelled or the ask fails, queueAsk replies its fallback "reject" (packages/cli/src/acp/turn.ts:105-129, fallback passed at :165).

Failure history

none found: not searched separately (time-box).

Notes

The UI is outside this repo; a client could auto-answer. Recorded as human because opencode hands the question to a person-facing client and offers no auto setting of its own on this path. Family: D12-D16.

D16 In a headless opencode run, what happens to a request that would ask a person? RULE · code
Answered how

Code answers for the run: every ask in the run's own session or a descendant session is replied automatically, and other sessions on a shared server are left alone. Without --auto (also set by --yolo / --dangerously-skip-permissions) it rejects with a fixed message telling the model nobody can approve, so the tool fails and the model continues; in V1 compatibility mode it rejects without a message and interrupts the session. With --auto it replies "once" to everything that reached ask.

Held by

packages/cli/src/run/noninteractive.ts:158-186 (replyPermission: reject with model-visible feedback PERMISSION_REJECTED_FEEDBACK packages/cli/src/run/noninteractive.ts:73-74 and continue; in V1 compatibility reject and interrupt the session; with --auto reply "once"); scoped to the run's own session and its children (packages/cli/src/run/noninteractive.ts:141-156,221-224); leftovers swept after admission (packages/cli/src/run/noninteractive.ts:738-755).

Checked by

packages/cli/test/run/noninteractive.test.ts:8 imports runNonInteractivePrompt; its only permission line mocks permission.list to empty (:243) and passes auto: false (:279); nothing found that asserts the reject/approve reply. Two routes: Grep permission|auto in that file (only those two lines) and Grep replyPermission|auto-rejecting over packages//test/ (no hits).

Contract

not recorded

On error

reply errors are swallowed (packages/cli/src/run/noninteractive.ts:182, .catch(() => {})): the request stays pending and the run waits on session.wait (:761-762); not traced further.

Failure history

#44267 (closed) empty final message after auto-reject; #54477 (closed) "GitHub action: a permission resolving to 'ask' hangs the run forever".

Notes

The feedback string tells the model the action was rejected because nobody can approve (:73-74), so the model sees a policy fact, not a person's verdict. Family: D12-D16 (the code member).

D17 Which directories outside the project may this ACP session touch without asking? RULING · external
Answered how

The editor (ACP client) decides by sending an additionalDirectories list; code only checks each entry is absolute with no */?, drops duplicates and the cwd itself, and writes an external_directory <dir>/* allow session rule for each (for both the written and the resolved spelling) at the front of the session's rules, replacing the rules from the previous list. With no list, nothing is pre-approved; out of the box it changes nothing, because the built-in agents already allow external_directory.

Held by

packages/cli/src/acp/directories.ts:16-24 (the ACP client's additionalDirectories list, validated: absolute, no wildcards) and turned into leading external_directory ... allow session rules at packages/cli/src/acp/directories.ts:41-77.

Checked by

packages/cli/test/acp/additional-directories.test.ts:48,83 (wire fixture; :4 imports ./wire-fixture, which drives the ACP command; the path to directories.ts was not traced). PROVISIONAL.

Contract

not recorded

On error

fail-closed on a relative or wildcard path (directories.ts:18-19, InvalidAdditionalDirectoryError).

Failure history

none found: not searched separately.

Notes

The rules lead the session ruleset so the session's own later rules win (directories.ts:49-53). Moot out of the box because external_directory is already allowed by default (D8); it matters once a person sets external_directory: ask. Their security page says the permission system "is not designed to provide security isolation" (SECURITY.md:17).

D18 When a subagent starts, which permission rules govern it: its own agent's, the parent's, or both? RULE · code
Answered how

Code: the subagent tool creates the child session with the child agent's id and no explicit rules, so the child copies the parent SESSION's rules, and every check in the child merges the CHILD agent's rules with those session rules. The parent agent's own rules (for example Plan's edit deny) are not carried over. Policy (D4), saved approvals and plugin hooks apply as in any session.

Held by

packages/core/src/session.ts:298-301 (child inherits the parent SESSION's rules) with packages/core/src/permission.ts:145-150 (the child's AGENT rules come from the child agent; packages/core/src/tool/plugin/subagent.ts:188-191 creates the child with agent: agent.id).

Checked by

packages/core/test/tool-subagent.test.ts:36 imports @opencode/core/permission; :191-194 pushes an allow-all onto the caller agent for the launch assert; nothing found that asserts a parent restriction reaching the child (Grep inherit in that file: none). PROVISIONAL, one route.

Contract

not recorded

On error

n/a.

Failure history

#55814 (open) plan-mode escalation through the general subagent; #51815 (closed) "subagent tool call blocked indefinitely on unanswered external_directory request, reported as running".

Notes

This is the mechanism behind D3's subagent route: a parent agent's own restrictions (Plan's edit deny) do not travel; only session-level rules do. Whether that is intended is a design call; the Plan reminder's "or ask a subagent to do so" (plan.ts:18) shows the authors know the route exists and chose a prompt over a rule.

D19 May this HTTP client call the server (list and answer permission requests, set session rules, prompt sessions)? RULE · code
Answered how

Code: when no server password is configured (or it is the empty string), every request passes. With a password, a request passes if its Basic credential (from the Authorization header or an auth_token query parameter) has username opencode and either the password or a valid unexpired pairing session token, or if it carries a valid session cookie and has no Origin header or one naming the same host; ticketed PTY/MCP connects and the pairing route skip this check and are validated by their own handlers. Anything else gets 401.

Held by

packages/server/src/middleware/authorization.ts:74-104 (no password -> every request passes, :78; ticketed PTY/MCP connects and pairing links skip the check, :87-94), credential test packages/server/src/auth.ts:28-37 (basic auth or HMAC session token, packages/server/src/auth.ts:46-55), same-origin cookie rule packages/server/src/middleware/authorization.ts:65-72.

Checked by

packages/server/test/fetch.test.ts (named by the grep for password|authorization, imports not opened). PROVISIONAL.

Contract

not recorded

On error

an undecodable credential becomes an empty one and is refused (authorization.ts:16-30): fail-closed when a password is set; fail-open by design when none is set (auth.ts:28-30).

Failure history

none found: not searched (SECURITY.md:25-33 puts opted-in server access out of scope).

Notes

Password comparison is a plain === (auth.ts:36) while the session token uses timingSafeEqual (:54). In scope here only because a server client can answer every permission request (server/src/handlers/permission.ts:76-85) and raise asks on any session (:39-58). Not a finding by their threat model.

E. Execution and observation (21 of 21 seats shown)

E1 Should a failed model call be sent again, and after how long? RULING · code
Answered how

Code: a failure is proposed for retry when isRetryable says so (an x-should-retry header overrides; rate limit, provider-internal, unrecognised, incomplete-stream and undelivered or read-side transport failures yes; auth, quota, content policy, invalid request no), or when output had started and the stream was interrupted; context overflow is never retried here. The schedule waits 2 s doubling to a 10 s cap with jitter (at least a provider retry-after, capped at 15 min) for 10 retries per logical step, and stops once more than 3 transport timeouts have occurred; a plugin session.retry hook sees each decision and may flip it or change the delay.

Held by

packages/core/src/model-retry.ts:25-36 (schedule), eligibility packages/ai/src/provider-error.ts:87-122 (isRetryable), timeout cap packages/core/src/model-retry.ts:40-57, step wiring packages/core/src/session/runner/step.ts:189-204 and packages/core/src/session/runner/retry.ts:32-55.

Checked by

packages/core/test/session-runner-retry.test.ts (imports SessionRunnerRetry from @opencode/core/session/runner/retry at :7) :36 "stops retrying transport timeouts after three attempts", :42; packages/core/test/session-runner.test.ts (:50) :5611, :5629, :5652 "uses a larger provider retry-after delay", :5667 "caps an excessive provider retry-after delay at fifteen minutes", :6141 "stops once the retry allowance is spent", :6181, :6222 "does not retry non-eligible provider failures"; packages/ai/test/provider-error.test.ts (imports isRetryable via ../src/index.js :2, which re-exports it at packages/ai/src/index.ts:7).

Contract

not recorded

On error

fail-closed after the allowance: the error is recorded on the assistant message (step.ts:205) and the step fails. Retries happen only before any output is shown (step.ts:200); after output, see E3.

Failure history

#30510 (closed) "Rate limit retries every ~1 second with no backoff or max limit"; #44931 (closed) background subagents hang silently on repeated 429s; #58632 (closed) poisoned session, every retry fails; #54862 (open) feature: call the session.retry hook when built-in retries run out.

Notes

"Unrecognised failure → retry" is a stated design choice (provider-error.ts:104-108, :239-244). A plugin session.retry hook receives the decision and may flip a no into a yes or change the delay (retry.ts:39-53); it is not called when the schedule is exhausted (retry.ts:37-38, #54862). The policy is built once per logical step (llm.ts:209), so the 10-retry allowance resets each step. Buffered calls (title, generate) use the same policy without the hook (model-retry.ts:63-73, callers packages/core/src/session/generate.ts:64, packages/core/src/generate.ts:75).

E2 What kind of failure did the provider report (context overflow, payload too large, content policy, quota, auth, rate limit, provider-internal, invalid request, unknown)? RULING · code
Answered how

Code classifies by HTTP status, error codes pulled from the body, parsed data and message, and regex phrase lists, in a fixed order: context overflow (client-scoped status, minus exclusions such as rate-limit wording), payload too large, content policy, quota, auth, rate limit, provider-internal, invalid-request codes, any other 4xx as invalid request, else UnknownProvider.

Held by

packages/ai/src/provider-error.ts:245-315 (classifyProviderFailure), overflow phrase list :18-75, code sets :126-188.

Checked by

packages/ai/test/provider-error.test.ts (imports classifyProviderFailure from ../src/provider-error.js at :4).

Contract

not recorded

On error

anything unrecognised becomes UnknownProvider (:314), which E1 retries.

Failure history

#58794 (closed) "'too many tokens per minute' rate limit misclassified as context overflow"; #44888 (open) litellm ContextWindowExceededError not detected; #50574 (open) 1M-context 400 not treated as overflow; #50446 (open) gateway rejects ~148k input, 400 unrecoverable.

Notes

Split from E1 per the skill's rule (its output feeds automated actions: retry, and overflow compaction in F). Regex-and-code classification of free-text provider errors: two providers wording the same condition differently get different treatment, which is why this is a RULING.

E3 When a response breaks off after visible output, should the run continue with a "continue from where you left off" message, rebuild the request once, or fail? RULE · code
Answered how

Code: before any visible output, a transport failure marked retry-full or rotate-and-retry-full rebuilds the request, once per logical step. After output has started, a failure the retry policy accepts (an interrupted stream counts) ends the step with the partial output kept, waits the retry delay and adds the synthetic user text "The previous response was interrupted. Continue from where you left off ...", unless a tool call was declined or interrupted; otherwise the step fails.

Held by

packages/core/src/session/runner/step.ts:182-188 (RecoverFull, once per step, transport recovery retry-full/rotate-and-retry-full before output), :261-269 (Continue after output when retry is allowed and no tool was declined or interrupted), packages/core/src/session/runner/llm.ts:290-298 (synthetic CONTINUE_AFTER_INCOMPLETE_STREAM), :333-335.

Checked by

packages/core/test/session-runner.test.ts (:50) :5587, :5601 "bounds repeated continuation rejection to one immediate recovery", :5775, :5826, :5857, :5895, :5927, :6010, :6046, :6078, :6116.

Contract

not recorded

On error

shares E1's allowance; when spent, the step fails (session-runner.test.ts:6116).

Failure history

none found: gh search issues --repo anomalyco/opencode "retry" (8 results, none about continuation). PROVISIONAL.

Notes

The continuation message is synthetic user text, so the model is told its output was interrupted, not what was lost.

E4 How are a step's tool calls run, and which outcome ends the run versus goes back to the model (a person declining, a question dismissed, a tool defect, an interrupt)? RULE · code
Answered how

Code: each local tool call starts in its own fiber as soon as its tool-call event is published, with no concurrency cap found, and its result passes through output truncation (E5). Typed tool failures and unexpected tool defects are returned to the model and the run continues; a plain permission decline or a dismissed question (carried as defects, recovered at packages/core/src/session/model-request.ts:381-395) fails that call as declined and interrupts the run unless the location is closing. When toolChoice is none, every call is refused.

Held by

packages/core/src/session/runner/step.ts:91-101 (refuses all tools when toolChoice is none), :122-134 (each local call starts as soon as its tool-call event is published, concurrently, output passed through toolOutput.truncate), :207-225, :272-277, classifyToolExits :296-319; packages/core/src/session/model-request.ts:381-395 (declines recovered from defects).

Checked by

packages/core/test/session-runner.test.ts (:50) :4909, :4934 "returns unexpected local tool defects to the model and continues", :4973, :5031, :5092, :5126, :5156, :5192, :5226, :5291; packages/core/test/session-step.test.ts (imports SessionStep at :17); packages/core/test/session-runner-tool-events.test.ts (imports createLLMEventPublisher at :23).

Contract

not recorded

On error

a decline or dismissed question interrupts the run (step.ts:272-275) unless the location is closing; a decline with feedback, a configured deny, and an unexpected tool defect are returned to the model and the run continues (session-runner.test.ts:4934, :5092, :5126).

Failure history

#58778 (closed) plugin tool calls stall before dispatch, hanging the turn; #51223 (closed) asks from MCP tools inside Code Mode never surface, execute hangs until user interrupt.

Notes

Tools run in parallel with no per-step concurrency cap found (searched step.ts; one route, PROVISIONAL).

E5 How much of a tool's output does the model see, and which part? RULING · code
Answered how

Code: unless the tool already set metadata.truncated, the joined text content is kept from the head, up to 2,000 lines and 50 KiB (or config tool_output.max_lines / max_bytes); the full text is written to a file in the data directory, removed after 7 days by an hourly cleanup, and a marker naming the shown lines and the path is added. File and image parts pass through untouched.

Held by

packages/core/src/tool-output.ts:69-120 (truncate), defaults packages/core/src/tool-output.ts:13-15, config packages/core/src/config/plugin/tool-output.ts:15-21; called once per local tool result at packages/core/src/session/runner/step.ts:127.

Checked by

packages/core/test/tool-output.test.ts (imports ToolOutput from @opencode/core/tool-output at :6); packages/core/test/config/tool-output.test.ts (imports plugin and service, :4, :8).

Contract

not recorded

On error

raises (die) if the overflow file cannot be written (packages/core/src/tool-output.ts:89-90, Effect.orDie).

Failure history

#54148 (closed) skill tool output truncated at 50 KB, tail never reaches the model; #33650 (closed) "Tool output truncation loses the most important content (... grep head_limit bypassed)"; #19358 (closed) webfetch truncation causes duplicated responses; #35661 (closed) Figma MCP output truncated at 50 KB silently drops design data.

Notes

Any tool that sets metadata.truncated itself is exempt (packages/core/src/tool-output.ts:70). Shell (E6), read (packages/core/src/tool/plugin/read.ts:113), grep (packages/core/src/tool/plugin/grep.ts:156) and glob (packages/core/src/tool/plugin/glob.ts:127) all set it, so the generic bound applies mainly to web, MCP, Code Mode and plugin tools.

E6 Which part of a shell command's output does the model see? RULING · code
Answered how

Code: after the command ends, it reads the last max_bytes of the output file and keeps at most the last max_lines lines (config tool_output, else 2,000 lines and 50 KiB), adding a notice with the shown line range and the full-output file path when anything was cut.

Held by

packages/core/src/shell.ts:244-259: keeps the TAIL (last max_lines/max_bytes, same config keys and defaults as E5), notice with line range and file path.

Checked by

packages/core/test/tool-shell.test.ts (imports ShellTool :38 and ToolOutput :39); packages/core/test/shell.test.ts (import line not re-verified).

Contract

not recorded

On error

a missing shell record yields "Shell command output is no longer available." (packages/core/src/shell/result.ts:12-18), fail-soft.

Failure history

#55089 (open) feature: keep shell output collapsed (UI, not model view). Otherwise none found: gh search issues --repo anomalyco/opencode "shell output truncated tail" returned 0.

Notes

Separate seat from E5 (different holder function and opposite policy, head versus tail).

E7 How long may a shell command run, and should it run in the background? RULING · llm
Answered how

The model chooses: with background: true the call returns at once and has no timeout unless one is given; otherwise it blocks with the model's timeout or 2 minutes, where 0 means none. Code applies the number with no maximum, unless a shell create.before plugin hook changes it first (the hook receives the invocation, timeout included, as a mutable object before the timer reads it: packages/core/src/shell.ts:284, :393; packages/plugin/src/effect/shell.ts:3-8) and on timeout returns the output plus "Command exceeded timeout ..."; a blocking job later moved to the background has its timeout cleared (packages/core/src/tool/plugin/shell.ts:263-266).

Held by

packages/core/src/tool/plugin/shell.ts:201 (the model's timeout and background arguments; foreground default 2 min at packages/core/src/tool/plugin/shell.ts:24, none for background); timeout message at packages/core/src/tool/plugin/shell.ts:229-231.

Checked by

packages/core/test/tool-shell.test.ts (:38); test names not enumerated for timeout (PROVISIONAL).

Contract

asks (input descriptions shell.ts:54-60, tool description :44-45): "Set to 0 to disable the timeout"; background "DO NOT poll". Accepts any non-negative integer with no maximum (:54, NonNegativeInt); the no-poll instruction (BACKGROUND_INSTRUCTION, :26-27) is not enforced by code.

On error

a timed-out command returns its output plus "Command exceeded timeout ..." (:229-231), fail-soft.

Failure history

#21550 (closed) parallel/heavy shell commands crash the backend on Windows.

Notes

A blocking foreground job can be moved to the background later (shell.ts:263-266, via packages/core/src/job.ts:329-351); who triggers that is stage H.

E8 Which text in the file does an edit call replace? RULING · code
Answered how

Code: unless oldString and newString differ only in \r (then exact bytes are matched), it searches an LF view of the file for an exact match, then a typographic-normalised match, then a per-line trailing-whitespace match, and maps offsets back. After the edit permission is asked, zero matches, or more than one without replaceAll, fail back to the model; the replacement reuses the matched region's line endings.

Held by

packages/core/src/tool/plugin/edit.ts:168-188 (matchView: exact, then typographic-normalised, then per-line trailing-whitespace match, on an LF view mapped back to the file's bytes), uniqueness rule :264-293 (exactly one match unless replaceAll), line-ending reuse :118-162.

Checked by

packages/core/test/tool-edit.test.ts (imports EditTool :16) :379 "rejects no-op, empty, missing, and ambiguous exact replacements", :485, :520, :537, :589, :672, :719, :346 "denied edit does not disclose whether oldString matches".

Contract

not recorded

On error

fail-soft to the model: not found, ambiguous, or no-op are tool failures (:284-300).

Failure history

#53036 (closed) oldString with leading indentation fails to match; #45880 (closed) mixed line endings never match; #45892 (closed) dollar patterns expanded, corrupting files; #45924 (open) edit and apply_patch silently corrupt non-UTF-8 files.

Notes

Permission is asked before the match is counted (:275-283 then :284), so a person can be asked to approve an edit that then fails; the test at :346 shows this order is deliberate (a denied edit must not reveal whether oldString matches). Recorded as DESIGN, not a gap.

E9 Where in the file does each hunk of a patch call apply? RULING · code
Answered how

Code: for each hunk, starting after the previous hunk (or after its @@ change-context line), it tries the match tiers in order over all remaining offsets (exact, trailing whitespace, both-side trim, typographic-normalised) and takes the first offset of the first tier that matches; end-of-file hunks match only at the end. It retries once without a trailing blank line; no match fails the whole patch before any file is written.

Held by

packages/util/src/patch.ts:311-342 (computeReplacements), :344-360 (seek: tiers exact, trailing whitespace, BOTH-side trim, typographic-normalised; first matching offset wins, scanning from the previous chunk); applied by packages/core/src/tool/plugin/patch.ts:126-189, written file by file :210-254.

Checked by

packages/core/test/patch.test.ts (imports Patch from @opencode/util/patch :2) :289 "disambiguates updates with change context", :299 "matches leading, trailing, and Unicode punctuation differences", :343 "prefers a later exact match over an earlier normalized match"; packages/core/test/tool-patch.test.ts (imports PatchTool :18). No test of two equally good candidate locations found (test names read; one route, PROVISIONAL).

Contract

not recorded

On error

verification errors fail before writing (patch.ts:109-111,168-171); a write failure part way leaves earlier files changed and says which (:96-101), i.e. not atomic across files.

Failure history

#58651 (open) moving an executable script removes its executable bit; #45924 (open) non-UTF-8 corruption.

Notes

E8 and E9 answer the same question for the model, one per model family: patch is offered instead of edit/write when the model id contains gpt- but not oss or gpt-4 (patch.ts:296-306). They differ: edit refuses ambiguity and never ignores leading indentation; patch takes the first match and has a leading-whitespace tier.

E10 Should a file the agent just wrote be reformatted, and does the model see the formatted result? RULE · code
Answered how

Code: after edit, write or patch writes a file, each registered formatter whose extensions include the file's extension and whose command is enabled runs in turn until one exits 0. The model's text result from all three tools names the file(s) only (packages/core/src/tool/plugin/edit.ts:318, packages/core/src/tool/plugin/write.ts:38-39, packages/core/src/tool/plugin/patch.ts:39-45); edit and patch put a post-format diff in their structured output and write does not. Whether structured output reaches the model was not traced.

Held by

packages/core/src/formatter.ts:56-90 (formatters matched by extension, enabled per formatter); called at packages/core/src/tool/plugin/edit.ts:307-309, packages/core/src/tool/plugin/write.ts:89-91, packages/core/src/tool/plugin/patch.ts:256-273.

Checked by

packages/core/test/tool-edit.test.ts (:16) :189 "returns the diff for final formatted content"; packages/core/test/formatter.test.ts (import line not re-verified).

Contract

not recorded

On error

fail-soft: a failing formatter is logged (formatter.ts:87) and the unformatted file stands.

Failure history

#52941 (open) desktop shows stale file contents after agent edits (adjacent, not this seat). PROVISIONAL.

Notes

The diff the person approved (edit.ts:271-283) is pre-format; the file on disk is post-format.

E11 What does a web fetch return to the model, and in what form? RULING · code
Answered how

Code: only http and https URLs (checked before the webfetch permission is asked); a non-2xx status fails; a 403 Cloudflare challenge is retried once with User-Agent opencode; image types other than SVG, and non-textual MIME types, are refused (SVG passes as textual +xml); a body over MAX_RESPONSE_BYTES fails the fetch; text/html is converted to markdown (default) or text and other types are returned as they are; the model's timeout (default 30 s, at most 120 s) bounds the request. The result then goes through E5's bound.

Held by

packages/core/src/tool/plugin/webfetch.ts:126-154 (fetch, type checks, conversion), limits packages/core/src/tool/plugin/webfetch.ts:14-16.

Checked by

packages/core/test/tool-webfetch.test.ts (imports WebFetchTool :11); packages/core/test/tool-html-markdown-budget.test.ts (import not re-verified).

Contract

not recorded

On error

fail-soft: "Unable to fetch <url>" tool failure (packages/core/src/tool/plugin/webfetch.ts:155).

Failure history

#53357 (open) empty HTML extraction reported as success; #49322 (open) mixed-case media types skip conversion; #45905 (closed) raw XHTML returned; #45902 (closed) always UTF-8, mojibake; #32271 (open) blocked by Anubis; #55232 (open) URL policy matches raw strings.

Notes

The Code Mode fetch path (E12) keeps only a 30-second timeout and a refusal of file: URLs; it has none of the other checks above and asks no permission.

E12 What may a model-written Code Mode program do, for how long, and what comes back? RULING · code
Answered how

Code: inside the confined interpreter a tool call runs only if its path resolves to a callable tool in the inventory (else UnknownTool, with a closest-match hint) and it passes at most one argument (else InvalidToolInput); its input is checked inside Code Mode and again against the tool's schema at the host tool boundary (C5), where a failure comes back to the program as a tool failure; the program runs with no timeout, tool-call cap or output cap, because CodeMode.make is called without limits and limits have no defaults (packages/codemode/src/codemode.ts:22-35, packages/codemode/src/codemode.ts:133-137). Inner tool calls pass through tool.execute.before and each leaf tool's own checks; fetch adds no host, method or body limit beyond refusing file: URLs, times out at 30 s and reads the whole body. The formatted result or ok:false diagnostic returns as the tool's content, bounded only by E5.

Held by

packages/codemode/src/tool-runtime.ts:333-355 (a program-side call resolves only to a callable tool in the inventory, else UnknownTool) and packages/codemode/src/tool-runtime.ts:399-405 (zero arguments read as {}, more than one refused), in the runtime packages/core/src/codemode/tool.ts:253 builds without limits (absent means unlimited, packages/codemode/src/codemode.ts:22-35, :133-137); the program runs at packages/core/src/codemode/tool.ts:80-151, and each call's input is validated at the host tool boundary packages/core/src/tool/runtime.ts:64-85; fetch global packages/core/src/codemode/web.ts:14-41.

Checked by

packages/core/test/tool-execute.test.ts (imports CodeModeTool :2) :35 "execute rejects filesystem fetches"; packages/core/test/codemode.test.ts (not re-verified). No test of a non-file fetch being gated or of a program timeout in core found (grep of fetch|timeout|maxToolCalls in tool-execute.test.ts: only the file test).

Contract

n/a (the program is C1's output)

On error

program failures are data, returned as ok:false text (packages/codemode/src/interpreter/execute.ts:96-106); the result is then bounded only by E5.

Failure history

#54595 (open) "the execute tool should inherit webfetch permissions"; #56349 (open) "tool.use policy cannot deny the execute (Code Mode) tool"; #51223 (closed) Code Mode execute hangs until user interrupt; #57484 (closed) function expression silently returns null; #55087 (open) feature: call shell and read from Code Mode.

Notes

Code Mode is present unless the agent's rules wholly deny execute (packages/core/src/tool.ts:239, :293-296); the default rules allow execute (packages/schema/src/agent.ts:46-51). Their security page says the permission system "is not designed to provide security isolation" (SECURITY.md:17).

E13 Which web search provider answers a query? RULING · code
Answered how

Code: a provider just chosen in E14's form is used directly; otherwise the stored selection decides: false means disabled, a provider id means that provider, and random means the session's previous provider if it is not cooling down, else a uniformly random available one. Only in random mode does HTTP 429 put a provider on cooldown (its retry-after, else 60 s) and fail over to another; any other error fails the call. No stored selection raises ProviderRequired, which opens E14's form.

Held by

packages/core/src/websearch.ts:133-207 (configured provider, or "random" with affinity and 429 cooldown failover, Math.random at :143).

Checked by

packages/core/test/websearch.test.ts (imports WebSearch from @opencode/core/websearch :6); packages/core/test/tool-websearch.test.ts (:9, :13).

Contract

not recorded

On error

fail-soft: tool failure with HTTP status text (packages/core/src/tool/plugin/websearch.ts:166-178).

Failure history

#54311 (open) "random" can stick to a failing provider; #58327 (open) custom providers excluded from failover.

Notes

—

E14 May OpenCode search the web at all, and through which provider (asked on first use when none is set)? RULING · human
Answered how

Only when no selection is stored and at least one provider exists, after the websearch permission is granted: the person picks allow (stored as random over all providers), choose (a second form naming one provider), or disable (stored as false; the context hook at packages/core/src/tool/plugin/websearch.ts:184-191 then removes the tool from later requests). Cancel, or no answer within 1 minute, fails the call as "Web search cancelled". In opencode run code cancels the form without feedback and, outside v1 compatibility, sets exit code 1 (packages/cli/src/run/noninteractive.ts:188-207).

Held by

packages/core/src/tool/plugin/websearch.ts:67-147 (the person, via a form: "Allow search via ...", "Choose another provider", "Disable web search"), 1-minute timeout at packages/core/src/tool/plugin/websearch.ts:141-144.

Checked by

packages/core/test/tool-websearch.test.ts (:13); cases not enumerated.

Contract

not recorded

On error

cancel or timeout fails the call ("Web search cancelled", :100-101, :143), fail-closed.

Failure history

#54311 (open) keyless search undocumented.

Notes

The answer is stored (websearch.select, :103, :131, :136), so this is asked once. In a non-interactive run the form is cancelled by code (see E15; same mechanism, PROVISIONAL that it applies to websearch.provider forms: noninteractive.ts:191 gives the feedback message only to question forms, others are cancelled without one).

E15 What does the person answer when the agent asks a question? RULING · human
Answered how

After the question permission (a configured deny returns "Permission denied: question" to the model), a form asks the person each question with the model's options plus a free-text answer. Answers return to the model as one sentence; a dismissal with no message interrupts the run; a dismissal with a message, as opencode run sends outside v1 compatibility, is returned to the model and it continues.

Held by

packages/core/src/tool/plugin/question.ts:62-111 (the person answers via Form.ask, TUI or app form).

Checked by

packages/core/test/tool-question.test.ts (imports QuestionTool :9); packages/core/test/session-runner.test.ts (:50, imports QuestionTool :59) :5156 "interrupts runner continuation when a question is cancelled".

Contract

n/a for the person; the model's side is C1 (question tool description question.ts:12-21), and the answers are returned as one sentence (toModelContent, :38-46).

On error

a dismissal with no message interrupts the run (:97 defect, recovered at packages/core/src/session/model-request.ts:383-394, handled at packages/core/src/session/runner/step.ts:272-275); a dismissal with a message is fed back and the model continues (question.ts:95-96).

Failure history

#45524 (closed) model omits a required field on multi-question calls.

Notes

opencode run (non-interactive) cancels every question with the message "make reasonable assumptions and state them" (packages/cli/src/run/noninteractive.ts:70-71, :188-205), so code answers; ACP forwards the question to the client when it supports form elicitation (packages/cli/src/acp/elicitation.ts:50-51) and otherwise cancels with a similar message (:36, :67-69). The question action is allowed for the build and plan agents (packages/core/src/plugin/agent.ts:60, packages/core/src/plugin/plan.ts:38) and denied for general (agent.ts:69).

E16 Which part of a file or directory does the model see when it reads, and may a slightly different file name be read instead? RULING · code
Answered how

Code: a directory is listed in pages; PNG, JPEG, GIF, WebP and PDF detected from the bytes are sent whole up to 20 MiB; a text file of at most 50 KiB read with no offset or limit is returned whole (lines over 2,000 chars clamped), otherwise a page of up to 2,000 lines from the model's offset/limit; a NUL byte in what would be returned refuses it as binary. On not-found, the single sibling whose name matches after NFC, non-breaking-space and curly-quote normalisation is read instead (after its own read authorisation); otherwise up to three name suggestions are returned.

Held by

packages/core/src/tool/read-filesystem.ts:116-224 (whole file, page or media), sibling fallback packages/core/src/tool/plugin/read.ts:56-81 and packages/core/src/tool/plugin/read.ts:132-144, suggestions packages/core/src/tool/plugin/read.ts:146-165.

Checked by

packages/core/test/tool-read.test.ts (imports ReadTool :18); packages/core/test/tool-read-filesystem.test.ts (:6).

Contract

not recorded

On error

fail-soft (read.ts:115-125).

Failure history

#33650 (closed) "read offset ignored" (part of the truncation issue).

Notes

The read tool also loads nearby AGENTS.md files into context after a read (read.ts:82-107): that is stage B.

E17 How many grep or glob results come back to the model? RULING · llm
Answered how

The model picks limit (default 100, no maximum); code asks ripgrep for limit + 1 results, returns the first limit and adds a truncation note when there were more. A search over 30 s fails with a timeout message.

Held by

packages/core/src/tool/plugin/grep.ts:110-145 and packages/core/src/tool/plugin/glob.ts:92-119 (the model's limit argument; default 100 at packages/core/src/filesystem.ts:66, no maximum: PositiveInt at packages/core/src/filesystem.ts:73,82); search timeout packages/core/src/tool/plugin/grep.ts:122-130.

Checked by

packages/core/test/tool-search.test.ts (imports GlobTool :16, GrepTool :17).

Contract

asks: "Maximum number of matching lines to return (default: 100)" for grep (packages/core/src/tool/plugin/grep.ts:35-37) and "Maximum number of matching files to return (default: 100)" for glob (packages/core/src/tool/plugin/glob.ts:25-27). Accepts: any positive integer; and because both tools always set metadata.truncated (packages/core/src/tool/plugin/grep.ts:156, packages/core/src/tool/plugin/glob.ts:127), the generic 2,000-line/50 KiB bound (E5) never applies to them.

On error

fail-soft (grep.ts:158-164).

Failure history

#33650 (closed) "grep head_limit bypassed".

Notes

—

E18 How is an MCP tool's result turned into what the model sees? RULE · code
Answered how

Code: an isError result becomes a tool failure carrying its text; otherwise text parts pass through, PNG, JPEG, GIF, WebP and PDF parts go inline, other textual resources are decoded inline, and other binary is saved to a file and named. The structured output is the server's structured result, else the text, parsed as JSON when it looks like JSON and the server declares no output schema. Each call is bounded by the server's execution timeout.

Held by

packages/core/src/tool/mcp.ts:85-106 (isError becomes a failure; JSON-looking text parsed when the server declares no output schema :98-100), :145-160 (media the read tool accepts inline; other text decoded; other binary saved to a file and named); execution timeout packages/core/src/mcp/client.ts:368, :423-432.

Checked by

packages/core/test/mcp.test.ts (imports McpTool from @opencode/core/tool/mcp :37).

Contract

not recorded

On error

fail-soft (mcp.ts:79-84, :108-112).

Failure history

#35661 (closed) Figma MCP output truncated; #52854 (open) MCP connections ignore Retry-After; #52237 (open) remote MCP servers that fail once are never retried.

Notes

—

E19 Are images in a tool's output resized, or dropped with a note? RULE · code
Answered how

Code, after the tool.execute.after hooks: each base64 data-URI image in the result goes to Image.normalize to fit the image limits; if the resizer is unavailable the original is kept, and undecodable or still-too-large images are dropped and replaced by one "[n images omitted: ...]" note per reason.

Held by

packages/core/src/tool.ts:74-101 (normalizeImages: resize via Image.normalize; undecodable or over-limit images replaced by "[n images omitted: ...]"), limits packages/core/src/image/photon.ts:53-141.

Checked by

packages/core/test/config/image.test.ts (limits; import not re-verified). PROVISIONAL.

Contract

not recorded

On error

fail-soft: resizer unavailable keeps the original (tool.ts:86).

Failure history

#47721 (open) request accumulates 52 images, exceeds provider max of 50 (that cut is boundImages, packages/core/src/session/model-request.ts:181-209).

Notes

—

E20 At the end of a step (or after a crash), which tool calls are recorded as failed because no result arrived? RULE · code
Answered how

Code: at the end of each step, declined calls fail as declined, unsettled calls fail as interrupted or with the tool defect, hosted calls with no provider result fail as RESULT_MISSING, and started-but-uncalled inputs fail as incomplete. At the start of each drain, any tool still streaming or running in stored history (left by a dead process) is failed as "Tool execution interrupted" and never re-run.

Held by

packages/core/src/session/runner/step.ts:207-232 (declines, interruptions, RESULT_MISSING for hosted tools, incomplete inputs), packages/core/src/session/runner/llm.ts:371-394 (settleStaleToolCalls, run at the start of each drain, packages/core/src/session/runner/llm.ts:71).

Checked by

packages/core/test/session-runner.test.ts (:50) :4566 "durably fails local tools left running by a prior process before continuing", :4613, :4679, :4727, :6677, :6704, :6722, :6758.

Contract

not recorded

On error

n/a (this is the error path).

Failure history

none found: searches above. PROVISIONAL.

Notes

A tool interrupted by a crash is never re-run; the model sees "Tool execution interrupted" and decides (llm.ts:385-388).

E21 Is a tool's result or error rewritten before the model sees it? RULE · code
Answered how

Code passes each local result or error through every registered tool.execute.after hook, which may replace it. Built in, only Plan mode does: for the plan agent, an edit, write or patch failure caused by a configured deny (Permission.BlockedError) is replaced by "Cannot use <tool> to modify files outside the Plan directory: ...". With no such plugin the result is unchanged.

Held by

packages/core/src/tool.ts:131-155 (tool.execute.after hook may replace result or error); built-in user: Plan mode rewrites permission blocks on edit/write/patch into a plan-directory message (packages/core/src/plugin/plan.ts:44-53).

Checked by

packages/core/test/tool-registry.test.ts:883 registers an execute.after hook against the registry (import line not re-verified); packages/core/test/plugin/plan.test.ts:41-75 drives the Plan rewrite through a captured hook stub, not through tool.ts (so it checks plan.ts, not the dispatch wiring). Found by Grep "execute\.after" packages/core/test.

Contract

not recorded

On error

hooks run in registration order with no catch (packages/core/src/plugin/hooks.ts:88-95); the hook's declared failure type is never (packages/plugin/src/effect/tool.ts:51), so a failing hook surfaces as a defect (promise hooks via Effect.promise, packages/plugin/src/promise/adapter.ts:644). For a direct call that defect would be handled as an unexpected tool defect (E4); this is read from the types, not traced end to end.

Failure history

#57066 (closed) "Plan mode restriction inconsistency - files modified before mode enforcement"; #53955 (open) "Agent is making edits when on plan mode" (adjacent: the message rewrite is here).

Notes

Identity by default; with a plugin that registers tool.execute.after, the holder is external.

F. Limits and memory (18 of 18 seats shown)

F1 Has the conversation grown close enough to the model's window that it must be compacted before the next step? RULING · code
Answered how

Before each step code compares the context estimate (the current provider's last measured prompt, plus a character-based estimate of everything since) with a ceiling: the window minus compaction.buffer when that is set and smaller than the window, else the window minus the larger of 15% and 16k (16k only for windows of 32k or more). Skipped when compaction.auto is false, right after a completed compaction, while a native window is still unmeasured, and when the window is unknown.

Held by

packages/core/src/session/compaction.ts:409-418 (due), ceiling at packages/core/src/session/compaction.ts:1104-1112 (calculateCeiling, 15% or 16k reserve), called each step at packages/core/src/session/runner/llm.ts:217

Checked by

packages/core/test/session-compaction.test.ts:283 ("auto compaction estimates current content against the buffered prompt ceiling"; imports SessionCompaction at :10); packages/core/test/session-runner.test.ts:3328 ("stops after required automatic compaction fails"; imports SessionRunnerLLM at :50).

Contract

not recorded

On error

fail-closed: a failed automatic compaction stops the step (packages/core/src/session/runner/llm.ts:218 raises StepFailedError). An unknown window never triggers (compaction.ts:1108, returns +Infinity).

Failure history

#57187 (closed) auto-compaction summary budget collapses near the context limit, session dead-ends; #56784 (closed) retry loop wedges session when provider clamps summary at 1,024 tokens; #37871 (closed) compaction returns total=0 causing infinite loop. Search: "compaction summary", "output token limit", "max steps".

Notes

RESERVE_SHARE 0.15 and RESERVE_MIN 16k are design-time choices (compaction.ts:122-128). The estimate is the provider's last measured prompt plus a 4-chars-per-token estimate since (compaction.ts:1073-1097). Only the current provider's counts are trusted (compaction.ts:1122-1127).

F2 What does the checkpoint summary that replaces the older conversation say? RULING · llm
Answered how

Code picks the compaction agent's model when one is configured and resolves (summary mode only), else the session's model, and sends it the whole conversation (or a shortened transcript, F5) followed by the summary prompt; the reply text becomes the checkpoint if it passes G2. A session compaction plugin hook can supply the summary itself, and then no model is called (packages/core/src/session/compaction.ts:579).

Held by

the compaction agent's model if configured, else the session's model (packages/core/src/session/compaction.ts:264-282); summary call in summarize packages/core/src/session/compaction.ts:440-504

Checked by

packages/core/test/session-compaction.test.ts:255 (headings in order, prompt text only), :278 (prompt prohibits task execution), :773 (hook supplies summary); packages/core/test/session-runner.test.ts:3168 ("automatically compacts into a completed summary and retained recent turn"), :2734 ("bounds compaction network retries across a template correction"). None checks summary CONTENT.

Contract

the prompt (buildPrompt, compaction.ts:200-231) asks for a fixed sectioned template (Objective, Requirements, Decisions, Work State, Next Move, Relevant Files, Important Context) with "Do not continue the task or call tools". The consumer accepts any reply in which at least ONE line equals ANY template heading (compaction.ts:455-456, filled); otherwise one NUDGE retry (compaction.ts:470-475), then compaction.failed (compaction.ts:476-483). A length or content-filter finish is rejected (compaction.ts:780-786). Section order, completeness and the 15-file cap are not checked. The contract is compilable (required headings, order) and is not compiled.

On error

fail-closed for automatic compaction (step fails, runner/llm.ts:218); manual compaction publishes Compaction.Failed (runner/llm.ts:135-154). Provider errors re-enter the session retry policy (compaction.ts:635-644).

Failure history

#50816 (open) summary reports completed work as Active, stale Work State propagates; #57240 (open) degenerate empty <conversation-checkpoint> replies after compaction; #36682 (open, titled SECURITY) summary injects actionable instructions; #54294 (open) compaction is a one-way door; #54820 (closed) summary request sent max_tokens=1024. Search: "compaction summary".

Notes

Same summarize serves manual, automatic, overflow and background triggers. A compaction plugin hook can supply the summary and skip the model (packages/core/src/session/compaction.ts:579, :823-835).

F3 When the provider compacts natively, which earlier messages survive in the replacement window? RULING · external
Answered how

Only when the model's settings carry compaction.type: "native". In the provider's endpoint mechanism the provider returns the whole replacement window and so picks what survives; in the trigger mechanism the provider returns one encrypted checkpoint and opencode's code keeps beside it the newest whole real user messages within keep tokens (recentUserMessages). A route that supports neither fails without a model call.

Held by

provider compaction endpoint or trigger, packages/core/src/session/compaction.ts:518-556 (compactNatively); present only when the model's settings carry compaction.type: "native" (packages/core/src/session/compaction.ts:294)

Checked by

packages/core/test/session-native-compaction.test.ts (imports SessionCompaction at :15); packages/core/test/session-compaction.test.ts:824; packages/core/test/session-runner.test.ts:3232, :3284.

Contract

n/a (the reply is an opaque encrypted window; consumer stores it as native.messages, compaction.ts:529-536, and replays it only on the same model, packages/core/src/session/history.ts:40-46)

On error

raises: unsupported route fails without a model call (compaction.ts:552); misconfiguration rejected at model resolution (packages/core/src/model-resolver.ts:183-192).

Failure history

#53153 (closed) native compaction mentioned in docs not implemented; #55585 (closed) session export --sanitize left native compaction context unredacted; #50039 (closed) no way to select a different compaction model. Search: "native compaction".

Notes

In the "trigger" mechanism opencode itself retains the newest user messages within keep (packages/core/src/session/compaction.ts:539-545, recentUserMessages :1053-1065); in "endpoint" the provider picks (:547-550). Family with F2.

F4 Which recent messages are kept word for word beside the summary? RULING · code
Answered how

Code drops the oldest text-bearing messages until the rest fit keep (default 15,000 estimated tokens, compaction.keep.tokens), always keeping the newest, then moves the start back to a user message so tool calls stay with their results. If everything already fits it keeps only the latest exchange; with a single exchange it keeps nothing (or everything, when the previous summary already kept recent text). Summary mode only; a compaction hook may set its own kept range.

Held by

packages/core/src/session/compaction.ts:954-996 (splitConversation, recentStart), allowance keep default 15,000 tokens (packages/core/src/session/compaction.ts:255)

Checked by

packages/core/test/session-runner.test.ts:3168; packages/core/test/session-compaction.test.ts:528 ("manual compaction summarizes short context instead of no-op"), :639.

Contract

not recorded

On error

NOTHING_TO_COMPACT when no message has text (compaction.ts:449, :118).

Failure history

#58724 (closed) manual compaction omits required "recent", bricking the session. Search: "compaction summary".

Notes

Always starts at a user message boundary (compaction.ts:985-987); when everything fits keeps only the latest exchange (:989-991).

F5 When the summary request itself is too big, what history is clipped or dropped before the summarizer sees it? RULING · code
Answered how

Code sends the request unchanged if its estimate fits, else the history before the kept tail unchanged if that fits, else a text transcript in which the oldest exchanges are shortened first, level by level (tool outputs clipped to 2,000 characters, half from each end, outputs dropped and inputs clipped, user text only, omitted), never omitting the newest exchange. Each provider 'too long' rejection re-aims at 85%, 70%, 50%, then 35% of the first rejected request's estimate; a 'payload too large' on the unchanged request resends it as text without counting.

Held by

packages/core/src/session/compaction.ts:570-646 (deliver, shrink steps 85/70/50/35% packages/core/src/session/compaction.ts:120) and packages/core/src/session/compaction.ts:660-728 (flattenAndDropOldest, levels packages/core/src/session/compaction.ts:137-143, clip 2,000 chars packages/core/src/session/compaction.ts:1043-1050)

Checked by

packages/core/test/session-runner.test.ts:2853, :2881, :2915, :2984, :3027, :3068.

Contract

not recorded

On error

fail-closed: compaction.failed "cannot be reduced further" (compaction.ts:603-611); after four too-long rejections returns the provider error (:626-627).

Failure history

#50187 (closed) misleading error when summary exceeds the context window. Search: "output token limit".

Notes

The newest exchange is never omitted (compaction.ts:713-714). Omitted exchanges are counted in a note to the model (:699-700).

F6 Should a summary be written in the background now, and is the held one still good enough to swap in? RULING · code
Answered how

Only when experimental.background_compaction is true and compaction.auto is on (not for native compaction). Code starts a background summary of everything before the latest response once the estimate reaches 85% of the ceiling, the last measured prompt is at least 64k tokens, the model matches that response's model, and fewer than two background summaries have failed. At compaction it swaps the held summary in only if it still describes this conversation (same first message, instructions, model and variant), the trigger is not an overflow, and the tail since it is within max(2 x keep, 30k) capped at 25% of the budget.

Held by

packages/core/src/session/compaction.ts:328-376 (precompute, 85% of ceiling, 64k minimum, max 2 failures packages/core/src/session/compaction.ts:147-157) and packages/core/src/session/compaction.ts:383-407 (usePrecomputed, heldIndex packages/core/src/session/compaction.ts:921-926, tailLimit packages/core/src/session/compaction.ts:932-933)

Checked by

packages/core/test/session-compaction.test.ts:908-1080 (seven tests, from "background summaries stay off unless enabled" to "compaction summarizes as usual when the background summary no longer fits").

Contract

not recorded

On error

fail-soft: a failed or stale background summary is dropped and compaction summarizes as usual (compaction.ts:397-403).

Failure history

none found: gh search issues "background_compaction" returned [].

Notes

Absent by default (experimental.background_compaction, packages/core/src/config/plugin/compaction.ts:16; initial precompute: false packages/core/src/session/compaction.ts:255).

F7 When the provider rejects a request as too long before any output, should the session compact and retry once? RULE · code
Answered how

When the provider's error is a context-overflow failure and no output has started, code asks compaction for an 'overflow' compaction (skipped when compaction.auto is false) and, if it completes, reruns the step; this recovery is allowed once per step (recoverOverflow turns false after a compaction).

Held by

packages/core/src/session/runner/step.ts:155-167; once-per-step flag packages/core/src/session/runner/llm.ts:211, :267-273, :329-332

Checked by

packages/core/test/session-runner.test.ts:3353, :3446, :3505, :3522, :5506.

Contract

not recorded

On error

fail-closed: second overflow or auto off surfaces the provider error (packages/core/src/session/compaction.ts:289).

Failure history

#51063 (closed) V2 Bedrock sessions silently die mid-turn. Search: "output token limit".

Notes

not recorded

F8 Which stored checkpoint is the start of the history the model sees, given the model now selected? RULE · code
Answered how

Code reads history from the newest completed compaction row: a local summary always qualifies, a native window only when its stored provider and model id equal the selected model's (variant is not compared). If that window then fails to decode, the read restarts from the compaction before it.

Held by

packages/core/src/session/history.ts:48-81 (latestCompaction), packages/core/src/session/history.ts:40-46 (replayable: a native window replays only on the model that made it) and packages/core/src/session/history.ts:128-131 (fallback to the earlier checkpoint)

Checked by

packages/core/test/session-native.test.ts:314 ("falls back to an earlier compatible native or local checkpoint"), :347 ("skips a malformed native window and replays the history before it"), :188-190 (imports SessionHistory at :14); packages/core/test/session-runner.test.ts:3232, :3284.

Contract

not recorded

On error

fail-soft: a native window that no longer decodes is skipped, falling back to an earlier checkpoint (history.ts:38-46).

Failure history

#56191 (open, Portuguese) model routing loses the most recent context. Search: "compaction summary".

Notes

not recorded

F9 How many output tokens may this reply use? RULING · code
Answered how

For primary and compaction requests code sets max tokens to the catalog output limit (32k when unknown, capped at 256k), lowered to the room left in the context window after the measured prompt and the estimated prompt plus 15%, never below 1,024; with an unknown window the catalog value stands. Title and generate requests send no limit. Plugin context/compaction hooks may change or remove it, and compaction re-fits it per attempt.

Held by

packages/core/src/session/model-request.ts:85-90 (outputLimit: catalog limit or 32k fallback, max 256k, 15% estimate margin, 1,024 floor :47-54); applied only to primary and compaction requests (model-request.ts:244-255); compaction re-fit packages/core/src/session/compaction.ts:1138-1151

Checked by

packages/core/test/session-runner.test.ts:3547, :3559, :3567, :3586; packages/core/test/session-compaction.test.ts:730, :753.

Contract

not recorded

On error

not traced (pure arithmetic).

Failure history

#54401 (open) title generation request has no output limit and runs away on local thinking models; #54820 (closed). Search: "output token limit".

Notes

Titles and generate deliberately keep the provider default (model-request.ts:244-245). Plugin context/compaction hooks may change or remove the limit (model-request.ts:244).

F10 Has this agent used up its configured steps, so the next step must answer in text with tools off? RULE · code
Answered how

Only when the agent sets steps (unset means no cap). Before each request code checks step >= agent.steps; on that step it appends MAX_STEPS_PROMPT as an assistant message and sends toolChoice: none, so any tool call the model still emits fails with 'Tools are disabled after the maximum agent steps' and the step cannot ask for continuation.

Held by

packages/core/src/session/runner/llm.ts:224 (step >= agent.steps), :238-242 (appends MAX_STEPS_PROMPT and toolChoice: "none"); tool refusal packages/core/src/session/runner/step.ts:92-93

Checked by

packages/core/test/session-runner.test.ts:5326 ("forces a text response on an agent's configured final step"), which PINS the assistant role (:5347-5350); :6556.

Contract

n/a for the check. The forced final step is an llm call whose prompt is MAX_STEPS_PROMPT (packages/core/src/session/runner/max-steps.ts:1-16, literal), sent as an ASSISTANT message (llm.ts:239); the doc says "a special system prompt" (packages/web/src/content/docs/agents.mdx:322).

On error

fail-closed on tools (calls after the cap fail with "Tools are disabled", step.ts:92-93); the final step itself raises a provider 400 on providers that reject assistant prefill (#56250).

Failure history

#56250 (open) step-capped agents fail with an assistant prefill 400; #37685, #40455 (closed) same class; #45328, #55921 (closed) tools still sent after the cap; #37871 (closed). Search: "max steps".

Notes

Absent by default: steps unset means no cap (packages/schema/src/agent.ts:33 optional; doc agents.mdx:308). Set per agent (packages/core/src/config/plugin/agent.ts:121).

F11 When does an agent's step allowance start again from one? RULING · code
Answered how

Code resets the step counter to 1 whenever an inbox promotion at a step boundary promotes more than zero items, and promote counts both user prompts and synthetic items (such as background subagent and shell completions); it also resets at a fresh, non-continuing drain boundary whose next item is not a steer. A continued drain keeps its counter.

Held by

packages/core/src/session/runner/llm.ts:108-111 and packages/core/src/session/runner/llm.ts:176 (if (promoted > 0) step = 1); promoted counts user AND synthetic inbox items (packages/core/src/session/inbox.ts:476, packages/core/src/session/inbox.ts:496, packages/core/src/session/inbox.ts:505-538)

Checked by

packages/core/test/session-runner.test.ts:5359 ("resets the configured step allowance when steering input promotes") uses a USER prompt only; nothing pins whether a synthetic item resets: searched steps = 2 and synthetic in session-runner.test.ts.

Contract

not recorded

On error

not traced.

Failure history

#32561 (closed) feature: reset step counter after user approves continuation. Search: "max steps", "step counter reset" (returned []).

Notes

Synthetic items come from background subagent completions (packages/core/src/session/subagent-completion.ts:37), background shell completions (packages/core/src/tool/plugin/shell.ts:173), Plan-mode reminders (packages/core/src/plugin/plan.ts:67, :84) and restart recovery (packages/core/src/session/execution/restart.ts:116). Their own AGENTS.md (data) says "Promoting new user input resets the selected agent's step allowance". (a candidate gap, not on this edition).

F12 After a reply is cut off at the output limit, should the agent be told to continue, or has it been cut off too often? RULING · code
Answered how

When a step ends with finish length, code reads the three newest user/assistant rows; if all three are assistant replies cut at length it fails the step with output-limit. Otherwise, if the cut reply had local tool calls the run simply continues, and if not code posts the synthetic CONTINUE_AFTER_OUTPUT_LIMIT notice and reruns the step.

Held by

packages/core/src/session/runner/llm.ts:299-328 (three newest user/assistant rows all finish === "length" → output-limit failure; a reply with tool calls continues without a notice, else synthetic CONTINUE_AFTER_OUTPUT_LIMIT packages/core/src/session/runner/llm.ts:37-38)

Checked by

packages/core/test/session-runner.test.ts:5683, :5727.

Contract

not recorded

On error

fail-closed after two continuations (llm.ts:318-324).

Failure history

#17471 (closed) feature: auto-continue on length. Search: "output token limit".

Notes

not recorded

F13 After the server restarts mid-turn, should the turn be resumed automatically, or has it died too often? RULING · code
Answered how

When the managed server (or an embedder) calls the restart sweep at boot, code counts the session's durable resume attempts; at more than 10 (layer option maxAttempts) it publishes Execution.Failed and releases the claim, otherwise it posts a synthetic 'server restarted, continue' notice and resumes the turn.

Held by

packages/core/src/session/execution/restart.ts:77-99 (prepareResume, DEFAULT_MAX_ATTEMPTS = 10 :33)

Checked by

packages/core/test/session-execution.test.ts (imports SessionRestart at :18; resumeSuspendedSessions exercised from :209 on, background recovery describe at :378).

Contract

not recorded

On error

fail-closed after the budget: Execution.Failed "interrupted repeatedly" (restart.ts:82-90).

Failure history

#56032 (open) add option to disable session auto-resume on service start. Search: "subagent_depth" (surfaced it).

Notes

maxAttempts is a layer option only (restart.ts:30, :75); no shipped config surface found.

F14 Which file changes made during a step are recorded so they can later be undone? RULING · code
Answered how

Only in a Git project with snapshots on. Code captures a snapshot tree of the location directory when a step starts and again when it ends, skipping untracked files over 2 MiB and passing the source repo for ignore rules; the step records the changed paths between the two trees minus any the source repo ignores. A failed capture is logged and the step records no file list.

Held by

packages/core/src/snapshot.ts:128-143 (capture: skips untracked files over 2 MiB packages/core/src/snapshot.ts:136, passes the source repo's ignore rules packages/core/src/snapshot.ts:135, ignored paths filtered from the file list packages/core/src/snapshot.ts:155-174); per step packages/core/src/session/runner/step.ts:76, packages/core/src/session/runner/step.ts:234-245

Checked by

packages/core/test/snapshot.test.ts (imports Snapshot at :11); packages/core/test/config/snapshot.test.ts.

Contract

not recorded

On error

fail-soft: capture failure logs a warning and returns undefined (snapshot.ts:140-142); the step then records no file list (step.ts:238-245).

Failure history

#10589 (closed) snapshot .nothrow() caused silent data loss on undo/redo; #49732 (closed) restore overwrote the worktree with a stale baseline; #36093 (open) snapshot retention and disk GC policy. Search: "revert snapshot".

Notes

Off when snapshots: false or not a Git repo (snapshot.ts:114; packages/core/src/config/plugin/snapshot.ts:16-18).

F15 Should the conversation be compacted now (the person's /compact)? RULING · human
Answered how

The person runs /compact (full TUI command or the mini TUI); core commits any staged revert, admits a compaction control item into the inbox (steer by default) and wakes the session, and the runner then compacts at the next boundary regardless of compaction.auto.

Held by

person, via TUI packages/tui/src/routes/session/index.tsx:1091 (and packages/tui/src/mini/runtime.ts:927); admitted at packages/core/src/session/session.ts:256-274

Checked by

packages/core/test/session-runner.test.ts:2445, :2471, :2407; packages/core/test/session-compact.test.ts.

Contract

not recorded

On error

failure published as Compaction.Failed on the /compact item (packages/core/src/session/runner/llm.ts:145-154).

Failure history

#58724 (closed) manual compaction bricks session; #54294 (open) compaction is one-way. Search: "compaction summary".

Notes

not recorded

F16 Should the session (and its file edits) be rolled back to this earlier message? RULING · human
Answered how

The person picks 'Revert' on a message, or /undo (which picks the last user message with text and, if that message is still pending, cancels it; otherwise it first interrupts and waits for the session, then stages a revert to it). Core refuses with BusyError while the session is running, otherwise stages the revert (F17); nothing is permanent until F18.

Held by

person, via TUI packages/tui/src/routes/session/index.tsx:1127 and packages/tui/src/routes/session/dialog-message.tsx:48; served at packages/core/src/session/session.ts:329-340

Checked by

packages/core/test/session-revert.test.ts:95-115 (imports Session at :15, which calls SessionRevert.stage at session.ts:335).

Contract

not recorded

On error

raises BusyError while the session runs (session.ts:334).

Failure history

#47909 (closed) "Revert to this message silently saves data"; #20781 (closed) restore destroys user changes in a chain. Search: "revert snapshot".

Notes

not recorded

F17 When reverting, which files go back to which recorded state? RULE · code
Answered how

Code takes every assistant message after the chosen message, in order, and for each file in their recorded step file lists keeps the first step's start snapshot; it restores those files to those trees (unless files: false), after first putting back any files a previous staged revert changed. Steps with no start snapshot are skipped.

Held by

packages/core/src/session/revert.ts:22-56 (stage) and packages/core/src/session/revert.ts:83-112 (plan: first recorded start snapshot per file among assistant steps after the boundary)

Checked by

packages/core/test/session-revert.test.ts:95-115; packages/core/test/session-owned.test.ts:796 (imports SessionRevert at :31).

Contract

not recorded

On error

raises on restore failure (packages/core/src/snapshot.ts:206-212); files outside the project refused (snapshot.ts:195-204). Steps with no snapshot are silently skipped (revert.ts:107).

Failure history

#49732, #20781, #15391 (closed). Search: "revert snapshot".

Notes

not recorded

F18 When does a staged revert become permanent? RULE · code
Answered how

Code publishes RevertEvent.Committed on the person's next prompt once its preparation succeeds, on the next /compact, or on an explicit revert-commit call (refused with BusyError while running); until then the staged revert can still be cleared.

Held by

packages/core/src/session/session.ts:175 (next prompt, after preparation succeeds) and packages/core/src/session/session.ts:261 (next /compact); explicit commit packages/core/src/session/session.ts:350-354; packages/core/src/session/revert.ts:75-81

Checked by

packages/core/test/session-runner.test.ts:1728, :1742 (committed revert and instruction state).

Contract

not recorded

On error

not traced.

Failure history

#47909 (closed). Search: "revert snapshot".

Notes

not recorded

G. Judging the result (6 of 6 seats shown)

G1 Is the agent's turn finished, or does it need another step? RULING · llm
Answered how

The session model decides by whether it emits a local tool call. Code ends the run when a step finishes normally with no local (non-provider-executed) tool called or settled, or with tools forced off on the capped step, and no further input is pending in the inbox; a reply cut at length instead gets a continuation (F12), and nothing reads the final text.

Held by

the session model's choice to emit no local tool call; read by code at packages/core/src/session/runner/publish-llm-event.ts:649-652 (needsContinuation = some non-provider-executed tool was called or settled), packages/core/src/session/runner/step.ts:279-281, loop exit packages/core/src/session/runner/llm.ts:276-283

Checked by

packages/core/test/session-step.test.ts:37-41, :125-128 (imports SessionStep at :17; pins needsContinuation to tool-call presence). No test judges the content of a "done" reply: searched session-runner test names (listed in full) for "empty", "done", "complete".

Contract

no prompt in the primary request asks the model to declare completion; the consumer treats any reply that finishes normally without a local tool call (including an empty text reply) as done, and with toolChoice: none forces done (step.ts:279). A reply cut at length is not treated as done (F12, packages/core/src/session/runner/llm.ts:299-328). Nothing inspects the final text.

On error

fail-open for done-ness: an empty or degenerate reply settles finish=stop and the execution publishes Succeeded (packages/core/src/session/execution.ts:121-123).

Failure history

#57240 (open) empty <conversation-checkpoint> replies are marked succeeded with no tools and no output; #2149 (closed) task stops without finishing. Search: "compaction summary", "stops without finishing", "agent stops early" ([]).

Notes

Consolidated into one seat: Completed and OutputLimit outcomes both read the same needsContinuation.

G2 Is the compaction model's reply an acceptable summary? RULE · code
Answered how

Code accepts the summary reply if any one of its lines, trimmed, equals any heading line of the template (the seven ## headings or the three ### Work State subheadings); otherwise it sends one NUDGE and checks the retry the same way, then fails with compaction.failed. A reply that ended at length or was content-filtered was already rejected while streaming.

Held by

packages/core/src/session/compaction.ts:455-456 (filled), packages/core/src/session/compaction.ts:468-483

Checked by

packages/core/test/session-runner.test.ts:2734; packages/core/test/session-compaction.test.ts:255.

Contract

n/a (it is F2's consumer side)

On error

fail-closed after one nudge (compaction.ts:476-483).

Failure history

#50816 (open), #57240 (open). Search: "compaction summary".

Notes

The check is the presence of any one heading line. "Is this summary good enough to replace history" is a RULING; the code answers a much narrower RULE. (a candidate gap, not on this edition).

G3 What should this session be called in the list of past sessions? RULING · llm
Answered how

The model selected by G4 is sent the title agent's system prompt and the first user message (or, when the session already has a title, the original request plus recent conversation); code takes the first non-empty line of the reply, cuts it to 100 characters, and renames the session only if its title has not changed meanwhile. A title plugin hook can supply the title and skip the model.

Held by

title model chosen by G4; call at packages/core/src/session/title.ts:53-103 (attempt), :105-154 (generate)

Checked by

packages/core/test/session-title.test.ts (imports SessionTitle at :25; variant selection asserted at :304, :387, :433); packages/core/test/session-runner.test.ts:982, :996, :1008, :1041.

Contract

PROMPT_TITLE (packages/core/src/plugin/agent.ts:19-38) asks for one line, three to five words, no quotes or label. The consumer takes the first non-empty line of the reply (title.ts:98-102) and truncates at 100 chars (title.ts:20, :32); it does not check length in words, quotes or labels. Retries once on the primary model, when that differs from the selected one, if the selected one fails or returns no non-empty line (title.ts:135-139).

On error

fail-soft: a provider error yields no title and the session keeps its fallback (packages/core/src/session/title.ts:89-97, :140).

Failure history

#51997 (open) titles include model commentary; #54185 (open) title agent produces conversational replies; #58176 (open) titles never generated in v2; #54917 (open) title generation runs although disabled; #54161 (open) file-only prompts get a generic title. Search: "session title".

Notes

For GitHub Copilot sessions a built-in title hook asks Copilot's free utility model instead and sets the result, skipping the normal call (packages/core/src/plugin/provider/github-copilot.ts:294-316); still an LLM, so the holder kind does not change. Any third-party plugin can do the same.

G4 Which model should write the title? RULING · code
Answered how

Code uses the title agent's configured model (at its configured variant, else the lowest-reasoning one), else the provider's small model, else the session's model at a lower reasoning variant when one exists; if that candidate does not resolve, or none exists, it uses the session's model as is. No title agent means no title.

Held by

packages/core/src/session/context.ts:94-124 (selectTitle: title agent's model, else the provider's small model, else the primary model at a lower reasoning variant packages/core/src/session/context.ts:191-203, else primary)

Checked by

packages/core/test/session-title.test.ts:304, :381-387, :431-433.

Contract

not recorded

On error

fail-soft: resolution failure falls back to the primary model (context.ts:120-121).

Failure history

#54917 (open) small_model set to a fake model still titled with the request model. Search: "session title".

Notes

not recorded

G5 Should a title be generated for this session now? RULE · code
Answered how

Code starts a background title job whenever a step boundary promotes input in a root session (no parent) whose title is still the exact default fallback and no title job is already running for it; the title agent must exist (it is absent when disabled). Child sessions take their title from the launching call's description instead.

Held by

packages/core/src/session/runner/llm.ts:172-175 (root sessions only, still untitled, first promoted input); isUntitled packages/core/src/session/title.ts:38-42; title agent present packages/core/src/session/context.ts:95-96

Checked by

packages/core/test/session-runner.test.ts:982, :996, :1008.

Contract

not recorded

On error

fail-soft (title fiber never blocks the step, llm.ts:53-54).

Failure history

#58176 (open), #54917 (open). Search: "session title".

Notes

Child (subagent) sessions get their title from the launching tool call's description (packages/core/src/tool/plugin/subagent.ts:190), not from G3.

G6 Are these code changes correct (the person's /review)? RULING · llm
Answered how

The person types /review with an optional commit, branch or PR; code sends PROMPT_REVIEW with that argument as an ordinary prompt (the template has no ${path} slot, so the project path is not added), and the session model does the review with its normal tools. Only the person reads the result.

Held by

session model, prompted by the built-in review command packages/core/src/plugin/command.ts:42-54

Checked by

packages/core/test/plugin/command.test.ts:79 (registration and description only; imports CommandPlugin at :8). Nothing checks review quality: none found.

Contract

PROMPT_REVIEW (packages/core/src/plugin/command/review.txt:1-100) asks for bug-focused findings with stated severity and scenarios. The consumer is the person reading the reply; no code parses or acts on it.

On error

not traced (an ordinary session turn).

Failure history

none found: search "/review command" returned unrelated items.

Notes

not recorded

H. Delegation (9 of 9 seats shown)

H1 Should part of this task go to a subagent, which one, with what brief, and should it run in the background? RULING · llm
Answered how

The parent session's model decides by calling the subagent tool with an agent, a brief, and optionally a model, a sessionID to continue, and background. Code then refuses the call if nesting is too deep (H3), the agent is unknown or primary, the subagent permission denies it, the model is invalid or unavailable, or the continued session is not this session's child.

Held by

the parent session's model calling the subagent tool (packages/core/src/tool/plugin/subagent.ts:29-48 input, executed packages/core/src/tool/plugin/subagent.ts:108-256)

Checked by

packages/core/test/tool-subagent.test.ts:404, :467, :523, :576, :672 (imports SubagentTool at :37).

Contract

the tool description (subagent.ts:55-62) and field annotations (:29-48) ask the model to include all context for new children, to set model NEVER unless the user explicitly asks, and to use background only for independent work. The consumer enforces: agent exists and is not primary (:134-137), subagent permission (:138-151), model string parses and is available (:75-98), continued child belongs to this parent (:164-167). The "only if the user asks" rule on model is not enforced in code.

On error

raises ToolFailure back to the model on every rejected input (subagent.ts:79-96, :131-137).

Failure history

#51071 (open) v2 subagent model overrides accepted without user authorization; #58681 (closed) auto-selects a disabled model; #58402 (open) feature: wait_for_subagents tool. Search: "subagent model", "subagent" "background".

Notes

One tool call answers agent, brief, model and background together, so one seat.

H2 Which agents may this agent see as subagents it can call? RULE · code
Answered how

Through the context, compaction and generate session hooks, code appends to the subagent tool's description every agent that is not primary, not hidden, and not denied the subagent action by the calling agent's permissions, sorted by id. This is visibility only; the call is checked again at execution (H1).

Held by

packages/core/src/tool/plugin/subagent.ts:282-306 (not primary, not hidden, subagent permission not deny)

Checked by

nothing found for the list filter: searched Available subagents in packages/core/test (0 hits; positive control: the same search over packages finds subagent.ts:300).

Contract

not recorded

On error

fail-soft: no list appended when none qualify (subagent.ts:296).

Failure history

#57138 (open) agent docs created mid-session not discovered until restart. Search: "subagent model".

Notes

not recorded

H3 Is this subagent nested too deep to launch another? RULE · code
Answered how

Code counts the calling session's ancestors and refuses with a ToolFailure when that depth is at least experimental.subagent_depth (default 1), so by default a top-level session may launch subagents and a subagent may not.

Held by

packages/core/src/tool/plugin/subagent.ts:117-133 (experimental.subagent_depth, default 1)

Checked by

packages/core/test/tool-subagent.test.ts:321 ("prevents subagents from launching subagents by default"), :360.

Contract

not recorded

On error

fail-closed (ToolFailure, subagent.ts:130-133).

Failure history

#38960 (closed NOT_PLANNED) fan-out bounded by depth but not breadth; #43996, #39112, #49342 (closed) depth-2 permission and question prompts not surfaced. Search: "subagent_depth".

Notes

Custom-command subagents (H9) do not pass through this check.

H4 Which model does the child session run on? RULE · code
Answered how

For a new child code uses the call's explicit model, else the chosen agent's configured model, else the parent session's model. When continuing a child, an explicit model switches it; otherwise only a switch to a different agent applies that agent's model, and the child keeps its current model.

Held by

packages/core/src/tool/plugin/subagent.ts:168-184 (new child: explicit model > agent's model > parent's model; a continued child switches only on an explicit model or an agent switch, packages/core/src/tool/plugin/subagent.ts:171-182)

Checked by

packages/core/test/tool-subagent.test.ts:672 ("runs the child on an explicitly requested model"), :576.

Contract

not recorded

On error

raises ToolFailure for an unparseable or unavailable model (subagent.ts:75-98).

Failure history

#49765 (open) spawn ignores agents.*.model; #58499 (closed) subagent model package overridden. Search: "subagent model".

Notes

not recorded

H5 May this caller continue that existing child session? RULE · code
Answered how

When the call passes a sessionID, code loads that session and refuses with a ToolFailure unless its parentID is the calling session, so only a direct child can be continued.

Held by

packages/core/src/tool/plugin/subagent.ts:153-167 (must be a direct child of the calling session)

Checked by

packages/core/test/tool-subagent.test.ts:576 ("rejects unrelated children and switches agents on continuation").

Contract

not recorded

On error

fail-closed (ToolFailure).

Failure history

none found: searches "subagent" "background", "subagent model".

Notes

not recorded

H6 What does the subagent hand back as its result? RULING · code
Answered how

When the child's run returns, code takes the newest completed, error-free assistant message among the child's 20 newest messages (after a restart, among its whole context) and joins its non-empty text parts; with none it returns 'Subagent completed without a text response.' The job is completed whenever the run returns, cancelled on interruption, and error on any other failure.

Held by

packages/core/src/session/subagent-job.ts:53-61 (newest completed, error-free assistant among the child's 20 newest messages) and packages/core/src/session/subagent-completion.ts:10-19 (its text parts joined, else NO_TEXT); recovery copy packages/core/src/session/execution/restart.ts:174-183

Checked by

packages/core/test/tool-subagent.test.ts:404 ("runs a foreground child session and returns the final assistant text"), :729 ("returns child runner failures as tool errors").

Contract

not recorded

On error

a child run failure becomes job status error (packages/core/src/job.ts:220-224) and a ToolFailure to the parent (subagent.ts:246-251). A child that stops at its step cap, or ends its turn while its own background work runs, returns status completed.

Failure history

#48826 (open) subagent with pending background work marked completed early, real result stranded; #50751 (open) parent never receives completion notification; #54658 (open) parent gets cancelled when a background subagent is continued. Search: "subagent" "background".

Notes

Consolidated into one seat. The two holders differ: the job path looks at the 20 newest messages (subagent-job.ts:55), the restart path at the whole context (restart.ts:175-180).

H7 When a background child finishes, how is the parent told, and is the parent woken to act on it? RULE · code
Answered how

When a backgrounded child's job settles, code posts a synthetic <subagent sessionID state description> message carrying the result text into the parent (steer delivery), which wakes the parent unless resume: false is passed (restart recovery passes it for parents still suspended) or the parent has a staged revert. A still-running job posts nothing.

Held by

packages/core/src/session/subagent-completion.ts:21-46 (deliver: synthetic <subagent ... state=...> message, wakes the parent unless resume: false); observer packages/core/src/session/subagent-job.ts:30-42; restart path packages/core/src/session/execution/restart.ts:149-156

Checked by

packages/core/test/tool-subagent.test.ts:766 ("notifies once when background work completes"); packages/core/test/session-execution.test.ts:378 (background recovery describe).

Contract

not recorded

On error

not traced past Effect.orDie at restart.ts:155.

Failure history

#50751 (open), #55760 (open) ACP auto-resumed parent turn emits no updates, #48826 (open). Search: "subagent" "background".

Notes

The synthetic message is promoted through the inbox and resets the parent's step allowance (F11).

H8 Should the work currently blocking this session (foreground subagents and shells) be moved to the background? RULING · human
Answered how

The person runs the TUI command 'Background blocking tools'; core marks every running, not-yet-backgrounded job that is blocking this session as backgrounded (releasing the wait), and if any were moved posts a synthetic notice telling the model the work is unfinished and not to wait for it.

Held by

person, TUI command "Background blocking tools" packages/tui/src/routes/session/index.tsx:1380-1388; core packages/core/src/session.ts:476 (jobs.backgroundAll), packages/core/src/job.ts:388-410

Checked by

packages/core/test/job.test.ts:241, :293 ("persists backgroundAll ownership before releasing a blocked subagent"; imports Job at :2); packages/core/test/tool-shell.test.ts:1829. These cover the core call, not the TUI command.

Contract

not recorded

On error

not traced (TUI call is fire-and-forget, index.tsx:1386).

Failure history

#52016 (open) feature: stop a running background subagent from the web app; #36423 (open) no cancellation for background subagents. Search: "subagent" "background".

Notes

not recorded

H9 Does this custom slash command run in a separate child session instead of the current one? RULE · code
Answered how

Code reads the command's subagent setting (or legacy subtask); if set, it decides, and if unset the command runs as a child when its configured agent has mode subagent. A child command creates a child session (command model, else the agent's, else the parent's), prompts it without waking, and starts and backgrounds it as a subagent job.

Held by

packages/core/src/config/plugin/command.ts:73 (command.subagent ?? command.subtask), packages/core/src/config/plugin/command.ts:94-118 (child created and backgrounded when that is true, or when unset and the command's agent has mode subagent)

Checked by

packages/core/test/config/command-subagent.test.ts:109 ("subagent: false overrides subagent mode and the legacy alias"; imports Session at :15).

Contract

not recorded

On error

not traced.

Failure history

none found: searches "subagent" "background", "subagent_depth".

Notes

This path calls neither the depth check (H3) nor permission.assert for subagent (compare packages/core/src/tool/plugin/subagent.ts:129-151). In ordinary use a person types the command; core also exposes the same path to HTTP clients through session.command. Their security page lists "Malicious config files" as out of scope ("Users control their own config; modifying it is not an attack vector", SECURITY.md:33) and says the permission system "is not designed to provide security isolation" (SECURITY.md:17). (a candidate gap, not on this edition).

I. Learning and feedback (2 of 2 seats shown)

I1 When the person turns down a tool call, what correction do they give the agent? RULING · human
Answered how

When the person rejects a pending permission request and types a message, code fails that request, and every other pending request in the same session, with CorrectedError carrying the message; the tool turns it into a tool error whose text is the message, so the model reads it and the turn continues. A reject without a message interrupts the turn.

Held by

person's reject-with-message reply, packages/core/src/permission.ts:262-285 (CorrectedError when a message is given); delivered to the model as a tool failure (packages/core/src/plugin/host.ts:442, packages/core/src/session/to-session-error.ts:70)

Checked by

packages/core/test/session-runner.test.ts:5092 ("returns permission corrections to the model and continues").

Contract

not recorded

On error

a reject WITHOUT a message interrupts the turn instead (packages/core/src/session/runner/step.ts:272-275).

Failure history

none found: search "reject feedback permission" returned [].

Notes

The approval itself is a separate seat; this seat is the feedback content.

I2 What should future sessions be told about this repository (the person's /init writing AGENTS.md)? RULING · llm
Answered how

The person types /init (optionally with notes); code sends PROMPT_INITIALIZE, with the notes in its $ARGUMENTS slot and the project directory in ${path}, as an ordinary prompt, and the session model writes or edits AGENTS.md through its normal, permission-checked edit tools. Later sessions load the file as written.

Held by

session model, prompted by the built-in init command packages/core/src/plugin/command.ts:29-41

Checked by

packages/core/test/plugin/command.test.ts:75, :115, :125 (prompt assembly only). Nothing checks the written file.

Contract

PROMPT_INITIALIZE (packages/core/src/plugin/command/initialize.txt:1-65) asks for a compact, verified, high-signal AGENTS.md, at most one batch of questions. The consumer is instruction discovery on later sessions (packages/core/src/instruction-discovery.ts), which loads the file as written; nothing checks it.

On error

not traced (an ordinary session turn; file writes go through the edit/write permission path).

Failure history

none found: search "init AGENTS.md" returned only #1130 (2025, unrelated provider).

Notes

The only built-in path by which something learned in one session reaches the next, and it needs the person to invoke it.

How this map was made

Four readers each took a part of the agent and wrote seat cards from the source. The map's author then re-read all 123 seats against the cited lines, filled in how each is answered, and corrected what the source did not support; each correction is recorded with the lines opened. One review by a model from another family (read-only, at the same commit) checked the result, and each of its findings was checked against the code before anything changed. A checker confirms that each seat's holder line exists at the pinned commit and carries its anchor text. Nothing on this page was produced by running their code.