opencode: who decides what
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 · holder | Seats |
|---|---|
| RULE · code | 65 |
| RULING · code | 31 |
| RULING · human | 11 |
| RULING · llm | 11 |
| RULING · external | 5 |
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
A1 When a person runs opencode run --continue, which earlier session continues? RULE · code
- Answered how
Code, when no
--sessionis 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--forkthat session is forked first; with no match a new session is created. An explicit--sessiontakes priority over--continue.- Held by
packages/cli/src/session-target.ts:120-131withlatestSessionpackages/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
--forkforks 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:
findSessionturns aSessionNotFoundErrorinto undefined; without--forkthe run then creates a new session using the given id as its id; with--forkit throws "Session not found". Any other lookup error is re-thrown.- Held by
packages/cli/src/session-target.ts:105-108and create-with-idpackages/cli/src/session-target.ts:61-66; lookuppackages/cli/src/session-target.ts:134-138; documented by therunflag textpackages/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-inbuildagent if selectable; else the first selectable agent in registry order. If none is selectable,selectreturns the idbuildwith no agent info.- Held by
packages/core/src/agent.ts:74-86selectedDefault, used byselectpackages/core/src/agent.ts:98-102;default_agentread atpackages/core/src/config/plugin/agent.ts:89-90; built-in agentspackages/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:4and@opencode/core/plugin/agent:10, verified by import read)- Contract
not recorded
- On error
fail-soft: an unusable
default_agentfalls through silently tobuild(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 fromModelResolver.resolve(the configured default if it has a package, else the first available text model with a package); nothing found raisesModelNotSelectedError.- Held by
packages/core/src/session/runner/model.ts:77-99; catalog defaultModelResolver.resolvepackages/core/src/model-resolver.ts:416-432- Checked by
packages/core/test/model-free.test.ts:76(asserts the "Model unavailable" failure; importsSessionRunnerModel: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
defaultModelif 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; orderingpackages/core/src/model.ts:229-232;preferFreepackages/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-297says 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.defaultlooked 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 notsubagent(soprimaryorall) in the server's agent list (core lists its resolved default agent first).- Held by
packages/cli/src/acp/catalog.ts:150-167; current valuespackages/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:
CatalogNotReadyErrorwhen 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);loadretries 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 advertisesmodel.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.
compactstarts 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 withdelivery: "steer"(compaction viasession.compact).- Held by
packages/cli/src/acp/prompt.ts:30-41withdetectSlashCommandpackages/cli/src/acp/prompt.ts:53-59; built-in listpackages/cli/src/acp/catalog.ts:9-11; dispatchpackages/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..$npositional, the last placeholder taking the rest;$ARGUMENTSthe 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'sagentwhen set; model is the command'smodel, 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 markedsubagent/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;evaluateTemplatepackages/core/src/config/plugin/command.ts:185-237; command filespackages/core/src/config/plugin/command.ts:152-168- Checked by
packages/core/test/config/command.test.ts(importsConfigCommandPlugin:16) andpackages/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.ExecutionErrorwrapping 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 thepostplugin list (packages/core/src/plugin/internal.ts:259) after the built-ins (:226), andCommandstores by name (command.ts:61), so a config command namedinitorreviewreplaces 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!`overpackages/core/test/config).
A9 What do the built-in /init and /review commands send? RULE · code
- Answered how
Code:
/initsends theinitialize.txttemplate with the first${path}replaced by the project directory;/reviewsendsreview.txt. In both, the trimmed input replaces every$ARGUMENTSif 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-54withappendpackages/core/src/plugin/command.ts:86-90- Checked by
packages/core/test/plugin/command.test.ts(importsCommandPlugin:8and 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$ARGUMENTSslots). 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/getreply 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);parseArgumentspackages/core/src/plugin/command.ts:92-94;promptMessageTextpackages/core/src/plugin/command.ts:96-102- Checked by
packages/core/test/plugin/command.test.ts(importsCommandPlugin; 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
prompthook: after plugins finish activating, the prompt (text, files, agents, skills), metadata and delivery (defaultsteer) are passed throughhooks.trigger("session", "prompt", ...)and whatever the hooks leave is what gets admitted. No shipped plugin registers aprompthook, so by default the input passes unchanged.- Held by
packages/core/src/session/prompt.ts:38-52(hook trigger); result usedpackages/core/src/session/prompt.ts:52-87- Checked by
packages/core/test/session-activation.test.ts:43registers 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 boundarypackages/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.tsandpackages/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
LifecycleConflictdefect 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
steerorqueue(core defaults tosteerwhen 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 sendssteer; SDK callers choose) answer it in code.- Held by
packages/tui/src/routes/session/index.tsx:749-767(steer / queue / cancel a pending prompt); corepackages/core/src/session/session.ts:140-154; defaultpackages/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 runand ACP always submitdelivery: "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
resumenot false still wakes the session.- Held by
packages/core/src/session/inbox.ts:164-176(reconcile, withmatchespackages/core/src/session/inbox.ts:69-74andpromotedFromMessagepackages/core/src/session/inbox.ts:113-151), called frompackages/core/src/session/session.ts:161-167- Checked by
packages/core/test/session-prompt.test.ts(importsSessionInbox: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 andfile:URLs are accepted (anything else, an invalid URL, or an unreadable path rejects the prompt). Afile: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 adata:attachment. Directories, special files and files over 10 MiB stop the run.- Held by
packages/cli/src/run/run.ts:185-218withisBinaryContentpackages/cli/src/run/run.ts:220-224; send formpackages/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 withswitchAgent/switchModel.- Held by
packages/cli/src/run/run.ts:91-129withpackages/cli/src/session-target.ts:57-58; written to the sessionpackages/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
B1 Which standing system prompt does the model get? RULE · code
- Answered how
Code: the first system part is the agent's own
systemtext if it has one (the built-inexplore,titleandsummaryagents do, and any configured agent may); otherwise the shippedsystem.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(importsSessionSystemPrompt:2, verified by import read);packages/core/test/session-runner.test.ts:75imports 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,compactionandgeneraterequests, run in registration order (OpenAI, Anthropic, Kimi, Arcee, Meta): a lower-cased model id containinggptgetsgpt.txt(orgpt-astra.txtif it also containsastra) in place of the first system part;claudegetsanthropic.txtappended to it;kimi,trinityandmuseget their own file in place of it (musewith 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);makepackages/core/src/plugin/optimize.ts:78-104; registered after the patch toolpackages/core/src/plugin/internal.ts:232-236- Checked by
packages/core/test/plugin/optimize.test.ts(importsOptimizePlugin:8and 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
gptandkimi: the later override wins).OpenAIToolsPlugin/AnthropicToolsPlugin(:35-49) are defined but not inPlugins(: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,compactionandgeneraterequest (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(importsIdentityPlugin: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);combinepackages/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.tsimports 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 raisesAgentNotFoundError(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.mdin 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: EVERYAGENTS.mdfound 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 configuredinstructionsentries: 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); sourcespackages/core/src/config/plugin/instruction.ts:78-146; URLspackages/core/src/config/plugin/instruction.ts:151-179; walkpackages/util/src/fs-util.ts:167-190; renderedpackages/core/src/instruction-discovery.ts:123-125- Checked by
packages/core/test/instruction-discovery.test.ts(importsConfigInstructionPlugin:9andInstructionDiscovery: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.mdis never read as instructions at this pin (grepCLAUDE\.md|\.claude|CONTEXT\.mdoverpackages/core/src: only.claudeskill roots and a mention insideplugin/command/initialize.txt; the code commentpackages/cli/src/server-process.ts:126says ".claude is only read for skills"). Their docs say otherwise: (a candidate gap, not on this edition). Project discovery is switched off byOPENCODE_DISABLE_PROJECT_CONFIG/OPENCODE_CONFIG_PROJECT_DISABLE(server-process.ts:123-125->packages/server/src/routes.ts:133), and both scopes are off inpackages/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);diffpackages/core/src/instructions/index.ts:142-159; renderpackages/core/src/instruction-discovery.ts:123-149- Checked by
packages/core/test/instruction-state.test.ts(importsInstructionState: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 dedupepackages/core/src/session/instructions.ts:46-112- Checked by
packages/core/test/tool-read.test.ts(importsSessionInstructions: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
skillpermission for their id is notdenyunder the merged agent and session rules, that have a description and do not setautoinvoke: 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 filterpackages/core/src/skill.ts:33-34- Checked by
packages/core/test/skill/instructions.test.ts(importsSkillInstructions: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
.claudeand.agentsroots that config discovered and that exist as directories, scans<root>/skillsin 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 optionsclaude: false(skips~/.claudeand project.claude) andglobal: false(skips~/.claudeand~/.agents) remove roots.- Held by
packages/core/src/config/plugin/compatibility.ts:39-57; rootspackages/core/src/config.ts:409-413(options documentedpackages/core/src/config.ts:49-54); native loadingpackages/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
.clauderoots off withOPENCODE_DISABLE_CLAUDE_CODEorOPENCODE_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 isSKILL.md, up to the first 10 other files under its folder, subfolders included (a recursive scan, sorted). A skill activated directly publishes aSkill.Activatedevent carrying the raw skill content, then resumes the session unlessresume: false.- Held by
packages/core/src/session/prompt.ts:56-76(attached);packages/core/src/session/session.ts:233-255(activation, viapackages/core/src/session/skill.ts:10-16); contentpackages/core/src/skill.ts:36-74- Checked by
packages/core/test/session-skill.test.ts(importsSessionInbox: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
skillpermission (onlyskillService.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'scodemodeflag), only ifexecuteis not denied either. A server with no tools is never listed. Code Mode servers get an extra line telling the model to call them throughexecute.- Held by
packages/core/src/mcp/instructions.ts:75-111- Checked by
packages/core/test/mcp-instructions.test.ts(importsMcpInstructions: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(importsReferenceInstructions: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
permissionoption, else its name) is a deny on resource*under the merged agent and session rules. Tools withcodemode: falseare listed directly (sorted by name); every other visible tool is folded into a singleexecutetool, which appears only whenexecuteitself is not wholly denied. Plugincontext/compaction/generatehooks 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 offalse, 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-263withwhollyDisabledpackages/core/src/tool.ts:293-296; rulespackages/core/src/session/context.ts:136-139; hook passpackages/core/src/session/model-request.ts:239-267; websearch removalpackages/core/src/tool/plugin/websearch.ts:184-194- Checked by
packages/core/test/tool-registry.test.ts(importsTool:10, callssnapshot():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,compactionandgeneraterequests: if the model id containsgpt-and contains neitherossnorgpt-4(case-sensitive), theeditandwritetools are removed andpatchis kept; otherwisepatchis removed.- Held by
packages/core/src/tool/plugin/patch.ts:296-309- Checked by
packages/core/test/tool-patch.test.ts(file present, importsModel: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
contextrequest, the Plan plugin finds the last enter/leave reminder in the messages: if the agent isplanand the last reminder is not the enter text, it inserts the enter reminder; if the agent is notplanand 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, onsession.createdwith agentplan, or an agent switch into or out ofplan, it publishes the matching reminder as a synthetic message.- Held by
packages/core/src/plugin/plan.ts:57-73(per-request reconcile) andpackages/core/src/plugin/plan.ts:75-112(create/switch reminder)- Checked by
packages/core/test/plugin/plan.test.ts(importsPlanPlugin: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 rulepackages/core/src/session/history.ts:40-46; kept textpackages/core/src/session/history.ts:149-182; runner entrypackages/core/src/session/history.ts:192-209- Checked by
packages/core/test/session-native-compaction.test.ts(importsSessionHistory: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; appliedpackages/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(importsunsupportedParts: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; thresholdspackages/core/src/session/model-request.ts:42-45- Checked by
packages/core/test/session-model-request.test.ts(importsboundImages: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),compactionorgeneratehooks get the system parts, messages, tool definitions and options (with the default output limit) and may change any of them; atitlehook gets the same minus tools. Then amodel.requesthook may change the base URL and headers, and, only if a plugin registered them for this provider,http.request/http.responsehooks 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 viaagentHookpackages/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(importsSessionModelRequest: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 theirSECURITY.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 anopenai/id, or Bedrock Mantle with anopenai.id, and its id names gpt-6, or gpt-5, gpt-5-mini/nano or gpt-5.1 through gpt-5.6 (not-chator-image), verbosity is set tolow.- Held by
packages/core/src/plugin/verbosity.ts:26-58- Checked by
packages/core/test/plugin/verbosity.test.ts(importsVerbosityPlugin: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.tsandcodemode/instructions.test.ts(importCodeModeInstructions: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 textresourceis inlined into the prompt text as[path:line]plus its text; an embedded blob with a MIME type becomes adata:file attachment; afile://(orzed://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 mappingpackages/cli/src/acp/content.ts:16-67,packages/cli/src/acp/content.ts:138-162; unreadablefile://to linkpackages/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
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
executetool unlessexecuteis 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 astepslimit and the step reaches it does code send the max-steps prefill withtoolChoice: "none".- Held by
packages/core/src/session/runner/step.ts:107(onellm.streamcall per step to the session's resolved model); request built atpackages/core/src/session/runner/llm.ts:225-245andpackages/core/src/session/model-request.ts:272-295.- Checked by
packages/core/test/session-runner.test.ts(importsSessionRunnerLLMfrom@opencode/core/session/runner/llmat :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 inpackages/core/testandpackages/simulationfile list; PROVISIONAL, one route).- Contract
asks: tool definitions as JSON Schema (
packages/core/src/tool/runtime.ts:22-28, assembled per request atpackages/core/src/tool.ts:256-263: direct tools sorted by name plus oneexecuteCode Mode tool), the system prompt (packages/core/src/session/model-request.ts:102-106), and on the last allowed step an assistant-prefillMAX_STEPS_PROMPTwithtoolChoice: "none"(packages/core/src/session/runner/llm.ts:238-242). Accepts: provider stream parsed into tool calls; argument JSON parsed atpackages/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 atool-input-errorevent, never a repaired call (tool-stream.ts:93-97, comment:203-206); the call is resolved by name atpackages/core/src/tool.ts:272-284and its input validated against the same schema atpackages/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
executeprogram shape silently returns null.- Notes
Code Mode is ON by default for tools registered without
codemode: false(opencode-namespace tools, MCP tools by defaultpackages/core/src/tool/mcp.ts:52), and every built-in leaf tool opts out (codemode: falsein shell/read/edit/write/patch/grep/glob/webfetch/websearch/question/todo/skill/subagent plugins). So the model chooses between direct calls and oneexecute({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
lengthfinish with no tool work adds a synthetic continue message (failing after three consecutivelengthresponses,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 disabledpackages/core/src/session/runner/step.ts:279, loop atpackages/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_CALLwith 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 nostepsset on the agent (the default:packages/schema/src/agent.ts:39-52has 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-errorinstead 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 onlength, 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 ortool-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 withlength, 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
executecall 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 pluginopencode.tool.input.repair, hooktool.execute.before, always registered first atpackages/core/src/plugin/internal.ts:217-218), rulespackages/core/src/plugin/tool-input-repair.ts:38-170, depth cappackages/core/src/plugin/tool-input-repair.ts:19.- Checked by
packages/core/test/plugin/tool-input-repair.test.tsandpackages/core/test/plugin/tool-input-repair-integration.test.ts(both importToolInputRepairPluginfrom@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
executecall 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(importsdefinition, executefrom../src/tool/runtimeat :10);packages/core/test/tool-execute.test.ts(importsexecutefrom@opencode/core/tool/runtimeat :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 storesundefined(: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.beforehooks (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, routesexecuteto 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 thetool.execute.beforehook at:272, which may rewriteevent.toolandevent.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.beforehook can change the tool name and input before dispatch, so with plugins configured the effective holder isexternal. 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 passeddefinitions(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 sessioncontexthook never sees, because that hook only sees the singleexecutedefinition.
C7 What is the working plan, and which of its items are done? RULING · llm
- Answered how
Only when config
experimental.todois true: the model writes the whole list throughtodowrite; code asks thetodowritepermission, 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 thetodowritetool); registered only when configexperimental.todo === true(packages/core/src/tool/plugin/todo.ts:50).- Checked by
packages/core/test/tool-todo.test.ts(importsTodoToolfrom@opencode/core/tool/plugin/todoat :8).- Contract
asks (
packages/core/src/tool/todowrite.txt:1-7): send the FULL list every call, markcompleted"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:72and 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?
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.evaluatehooks (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), matcherpackages/core/src/permission.ts:74-84(evaluate, last match wins, default ask), deny precedencepackages/core/src/permission.ts:152-154,162.- Checked by
packages/core/test/permission.test.ts:100-445imports@opencode/core/permission(:9) and exercisesask/assert/reply(e.g.:139explicit allow/deny,:200deny-all when no default agent,:235merge order session-after-agent-before-saved,:263saved 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:
missingAgentPermissionsdeny-all (permission.ts:19,149); unknown session raisesSession.NotFoundError(:147); after the service closes everyaskreturns deny (:215) and everyassertdies 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) "
writepermission rules are silently ignored — write.ts asks underedit". Searchedgh 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 gatetool/plugin/subagent.ts:138-145(resource = agent id), and the HTTPsession.permission.createroutepackages/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_loopandexternal_directorydefault 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*:* allowand three.envread 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*:* allowin that list and run without a prompt (Plan adds its owneditdeny, 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
*:* allowrules and the Plan plugin appendsedit:* denythenedit:<home>/.opencode/plan/* allow, so D1's last-match lookup blocks an edit-family call outside that directory, unless a configeditrule (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, thenedit:<plan dir>/* allow), evaluated by D1; failure message rewritten atpackages/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:10and@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 (
ConfigAgentPluginruns in thepostlist,plugin/internal.ts:254-257, pushing global rules into every agent atconfig/plugin/agent.ts:85-93and per-agent rules at:122-124), so a useredit: allowwins by last match. That is the #55127 class; at the pin the order is unchanged. Only theeditaction 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
*:* allowletsshell,subagentand 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 reminderenter, 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 isAgent.Info.default(packages/schema/src/agent.ts:46-51,*:* allow) plusquestionallow and the twoeditrules (packages/core/src/plugin/plan.ts:38-40).- Checked by
nothing found: Grep
planinpackages/core/test/tool-subagent.test.tsandpackages/core/test/tool-shell.test.ts(no Plan-agent case);plan.test.ts:158-242test 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-145assertsshell, which matches the allow);subagentasserts 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*:* allowwithquestion/subagentdenied (packages/core/src/plugin/agent.ts:63-72). Nothing compares the model's action to the reminder. Compilable: partly (anedit-equivalent deny onshellwrites is not compilable; asubagentdeny 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 editand"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 foredit, 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
evaluatehook: for each resource it finds the lasttool.usestatement 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(permissionevaluatehook: last matchingtool.usestatement on"<action>:<resource>"; a deny sets effect deny with the statement's message). Statement orderpackages/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 throughManagedPolicy.set(packages/core/src/managed-policy.ts:35-44).- Checked by
packages/core/test/config/policy.test.ts:51-213importsConfigPolicyPlugin(:6),ManagedPolicy(:8),PluginHooks(:10) and triggershooks.trigger("permission","evaluate",...)(:41); cases:138"turns an ask into a deny but never grants",:63organization overrides authored,:92project cannot override user-global. Verified by import lines.- Contract
not recorded
- On error
not traced beyond: the hook is
Effect.syncwith no failure channel (plugin/hooks.ts:23-30, permission hooks haveNoFailures). Policy can only turn an effect to deny, never grant (policy.ts:65-67).- Failure history
#56349 (open) "
tool.usepolicy cannot deny theexecute(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
assertand only when D1 did not already deny (packages/core/src/permission.ts:162returns before the hook). Tools that never assert (todo,question, and any package-plugin tool whose author omitsctx.permission.assert) are outside it. Ordering:ConfigPolicyPluginis the LASTpostinternal plugin (plugin/internal.ts:271), after package plugins (plugin/supervisor.ts:99-103), so its hook runs after third-partyevaluatehooks and has the last word; it is protected from configremove(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.usestatement is deny is removed, as is an MCP server or skill whose last matchingintegration.usestatement onmcp:<name>/skill:<id>is deny; no matching statement means allow. Package plugins get the sameintegration.usecheck onplugin:<target>before loading and are skipped when denied.- Held by
packages/core/src/config/plugin/policy.ts:28-53(provider.use:28-41, integration.usemcp:<name>:42-47,skill:<id>:48-53), decision helperpackages/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 removalpackages/core/test/mcp.test.ts:2743-2778; skillspackages/core/test/skill.test.ts:48-117,tool-skill.test.ts:50,session-skill.test.ts:104; plugin loadpackages/core/test/plugin/module.test.ts:418,435,plugin/supervisor-reload.test.ts:358,378(each setsintegration.usestatements throughmanaged.setor config; found by Grepintegration\.useoverpackages//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 gatepackages/core/src/plugin/module.ts:50-60(integration.useonplugin:<target>->{ blocked: true }, honoured atplugin/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
evaluatehook gets the computed effect (ask or allow) and may overwriteeffectandmessage; 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'spermission.hook("evaluate", ...)), run byPluginHooks.triggerin registration order (packages/core/src/plugin/hooks.ts:88-95) frompackages/core/src/permission.ts:166-175; contract typepackages/plugin/src/effect/permission.ts:10-23(mutableeffect,message).- Checked by
packages/server/test/session-instances.test.ts:102registers an evaluate hook (not opened beyond the grep line; wiring not verified). Nothing else found: Grep"permission", "evaluate"overpackages(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 athooks.ts:91-92), soassertdies.- 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
askintoallowwith no person. Plugins can also push agent rules throughctx.agent.transform(host.tsagent 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
toolexecute.beforehooks in registration order; a hook may rewrite the event's input (and, on the direct path, its tool name) or fail with aTool.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(toolhookexecute.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'sexecute.beforethrows 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 throughPluginHooks.Service, the same servicetool.ts:103-111triggers).- Contract
not recorded
- On error
a
Tool.Errorfrom 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_directoryask 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 literalcd-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 allowexternal_directoryby 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 fromcd-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.tsandpackages/core/test/file-access-path.test.tsimport@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.statNotFound 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/varalias; #54869 (open) bound at instance directory instead of worktree. Searchedgh 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.defaultgrants*:* allow(packages/schema/src/agent.ts:46-51) and no built-in rule namesexternal_directorywithask(Grepeffect: "ask"overpackages, tests excluded: only the three.envread rules). The public docs say it defaults toask(packages/web/src/content/docs/permissions.mdx:177). A candidate gap. For shell, only directory-changing commands contribute paths;cat /outside/filecontributes 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_scanneris set, or by default when the bash wasm parser is absent or the app channel islocal/dev; otherwise tree-sitter) and asks about every non-cdcommand 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 theARITYtable andprefix(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 atpackages/core/src/tool/plugin/shell.ts:137-145.- Checked by
packages/core/test/shell-parse.test.ts:9-166imports@opencode/core/shell/parse(:5) ("derives reusable prefixes":9); parity and oracle suitespackages/core/test/shell-scan/*.test.tsandshell-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 *vsgit 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
allowrow 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(oneallowrow 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/patchsave: ["*"](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.adddies 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
savevalues are one question ("how wide is always?") answered by one mechanism; listed above. A saved allow lifts anaskbut never a configured deny (permission.ts:162-163). Saved rows can be edited to any action/resource throughpermission.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 withDeclinedError, whichassertturns 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 equivalentpackages/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
askreach the TUI. In the defaultpromptmode 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-permissionsor configsession.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 carriessave,packages/tui/src/routes/session/permission.tsx:232-240; Escape = reject,:241); prompts shown only inpromptmode (packages/tui/src/routes/session/index.tsx:212).- Checked by
packages/tui/test/cli/tui/input-scope.test.tsx:15,234,255imports and mountsPermissionPrompt(top-level and child-session cases; what it asserts about the reply was not read).packages/tui/test/cli/tui/permission.test.ts:2-4imports only the label helper. Found by GrepPermissionPromptoverpackages//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-permissionsor TUI configsession.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
askreach the app. With auto-approve off (the default,permissions.autoApprove: falseunless a per-project override says otherwise) the dock shows the open session's pending request and the person's once / always / reject goes topermission.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) andpackages/app/src/session/requests/model.ts:166-185(decide->permission.reply).- Checked by
packages/app/e2e/regression/multi-server.spec.ts:250-278drives the auto-accept settings and asserts which requests got replies (the app runtime wires the auto-approver atpackages/app/src/runtime/server/runtime.tsx:10); nothing found for the manual decide path (Grepdecideinpackages/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
askreachopencode 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 topermission.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 withoutsave) in the three-stage promptpackages/tui/src/mini/footer.permission.tsx:1-371(permission / always-confirm / reject), reply sent atpackages/tui/src/mini/runtime.ts:267-274.- Checked by
packages/tui/test/mini/footer.view.test.tsxandpackages/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
autointui/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
askreach the editor. opencode sends the editor an ACPrequestPermissionwith 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 atpackages/cli/src/acp/permission.ts:39-43), answered by the outside client's UI; queued atpackages/cli/src/acp/turn.ts:165-167.- Checked by
nothing found: Grep
ACPPermission|requestPermissionoverpackages//test/(no hits);cli/test/acp/additional-directories.test.tscovers 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,queueAskreplies 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--autoit replies "once" to everything that reachedask.- Held by
packages/cli/src/run/noninteractive.ts:158-186(replyPermission: reject with model-visible feedbackPERMISSION_REJECTED_FEEDBACKpackages/cli/src/run/noninteractive.ts:73-74and continue; in V1 compatibility reject and interrupt the session; with--autoreply "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:8importsrunNonInteractivePrompt; its only permission line mockspermission.listto empty (:243) and passesauto: false(:279); nothing found that asserts the reject/approve reply. Two routes: Greppermission|autoin that file (only those two lines) and GrepreplyPermission|auto-rejectingoverpackages//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 onsession.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
additionalDirectorieslist; code only checks each entry is absolute with no*/?, drops duplicates and the cwd itself, and writes anexternal_directory <dir>/* allowsession 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 allowexternal_directory.- Held by
packages/cli/src/acp/directories.ts:16-24(the ACP client'sadditionalDirectorieslist, validated: absolute, no wildcards) and turned into leadingexternal_directory ... allowsession rules atpackages/cli/src/acp/directories.ts:41-77.- Checked by
packages/cli/test/acp/additional-directories.test.ts:48,83(wire fixture;:4imports./wire-fixture, which drives the ACP command; the path todirectories.tswas 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 becauseexternal_directoryis already allowed by default (D8); it matters once a person setsexternal_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
editdeny) 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) withpackages/core/src/permission.ts:145-150(the child's AGENT rules come from the child agent;packages/core/src/tool/plugin/subagent.ts:188-191creates the child withagent: agent.id).- Checked by
packages/core/test/tool-subagent.test.ts:36imports@opencode/core/permission;:191-194pushes an allow-all onto the caller agent for the launch assert; nothing found that asserts a parent restriction reaching the child (Grepinheritin 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
editdeny) 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
Authorizationheader or anauth_tokenquery parameter) has usernameopencodeand either the password or a valid unexpired pairing session token, or if it carries a valid session cookie and has noOriginheader 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 testpackages/server/src/auth.ts:28-37(basic auth or HMAC session token,packages/server/src/auth.ts:46-55), same-origin cookie rulepackages/server/src/middleware/authorization.ts:65-72.- Checked by
packages/server/test/fetch.test.ts(named by the grep forpassword|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 usestimingSafeEqual(: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
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
isRetryablesays so (anx-should-retryheader 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 providerretry-after, capped at 15 min) for 10 retries per logical step, and stops once more than 3 transport timeouts have occurred; a pluginsession.retryhook sees each decision and may flip it or change the delay.- Held by
packages/core/src/model-retry.ts:25-36(schedule), eligibilitypackages/ai/src/provider-error.ts:87-122(isRetryable), timeout cappackages/core/src/model-retry.ts:40-57, step wiringpackages/core/src/session/runner/step.ts:189-204andpackages/core/src/session/runner/retry.ts:32-55.- Checked by
packages/core/test/session-runner-retry.test.ts(importsSessionRunnerRetryfrom@opencode/core/session/runner/retryat :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(importsisRetryablevia../src/index.js:2, which re-exports it atpackages/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.retryhook when built-in retries run out.- Notes
"Unrecognised failure → retry" is a stated design choice (
provider-error.ts:104-108,:239-244). A pluginsession.retryhook 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, callerspackages/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(importsclassifyProviderFailurefrom../src/provider-error.jsat :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-fullorrotate-and-retry-fullrebuilds 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 recoveryretry-full/rotate-and-retry-fullbefore output),:261-269(Continueafter output when retry is allowed and no tool was declined or interrupted),packages/core/src/session/runner/llm.ts:290-298(syntheticCONTINUE_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-callevent 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 atpackages/core/src/session/model-request.ts:381-395) fails that call as declined and interrupts the run unless the location is closing. WhentoolChoiceisnone, every call is refused.- Held by
packages/core/src/session/runner/step.ts:91-101(refuses all tools whentoolChoiceisnone),:122-134(each local call starts as soon as itstool-callevent is published, concurrently, output passed throughtoolOutput.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(importsSessionStepat :17);packages/core/test/session-runner-tool-events.test.ts(importscreateLLMEventPublisherat :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,
executehangs 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 configtool_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), defaultspackages/core/src/tool-output.ts:13-15, configpackages/core/src/config/plugin/tool-output.ts:15-21; called once per local tool result atpackages/core/src/session/runner/step.ts:127.- Checked by
packages/core/test/tool-output.test.ts(importsToolOutputfrom@opencode/core/tool-outputat :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.truncateditself 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_bytesof the output file and keeps at most the lastmax_lineslines (configtool_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 (lastmax_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(importsShellTool:38 andToolOutput: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: truethe call returns at once and has no timeout unless one is given; otherwise it blocks with the model'stimeoutor 2 minutes, where 0 means none. Code applies the number with no maximum, unless ashellcreate.beforeplugin 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'stimeoutandbackgroundarguments; foreground default 2 min atpackages/core/src/tool/plugin/shell.ts:24, none for background); timeout message atpackages/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, viapackages/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 withoutreplaceAll, 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 unlessreplaceAll), line-ending reuse:118-162.- Checked by
packages/core/test/tool-edit.test.ts(importsEditTool: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-283then:284), so a person can be asked to approve an edit that then fails; the test at:346shows this order is deliberate (a denied edit must not reveal whetheroldStringmatches). 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 bypackages/core/src/tool/plugin/patch.ts:126-189, written file by file:210-254.- Checked by
packages/core/test/patch.test.ts(importsPatchfrom@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(importsPatchTool: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:
patchis offered instead ofedit/writewhen the model id containsgpt-but notossorgpt-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 atpackages/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
webfetchpermission is asked); a non-2xx status fails; a 403 Cloudflare challenge is retried once with User-Agentopencode; image types other than SVG, and non-textual MIME types, are refused (SVG passes as textual+xml); a body overMAX_RESPONSE_BYTESfails the fetch;text/htmlis 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), limitspackages/core/src/tool/plugin/webfetch.ts:14-16.- Checked by
packages/core/test/tool-webfetch.test.ts(importsWebFetchTool: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
fetchpath (E12) keeps only a 30-second timeout and a refusal offile: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 (elseInvalidToolInput); 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, becauseCodeMode.makeis called withoutlimitsand limits have no defaults (packages/codemode/src/codemode.ts:22-35,packages/codemode/src/codemode.ts:133-137). Inner tool calls pass throughtool.execute.beforeand each leaf tool's own checks;fetchadds no host, method or body limit beyond refusingfile:URLs, times out at 30 s and reads the whole body. The formatted result orok:falsediagnostic 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, elseUnknownTool) andpackages/codemode/src/tool-runtime.ts:399-405(zero arguments read as{}, more than one refused), in the runtimepackages/core/src/codemode/tool.ts:253builds withoutlimits(absent means unlimited,packages/codemode/src/codemode.ts:22-35,:133-137); the program runs atpackages/core/src/codemode/tool.ts:80-151, and each call's input is validated at the host tool boundarypackages/core/src/tool/runtime.ts:64-85;fetchglobalpackages/core/src/codemode/web.ts:14-41.- Checked by
packages/core/test/tool-execute.test.ts(importsCodeModeTool: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 offetch|timeout|maxToolCallsintool-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:falsetext (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.usepolicy cannot deny theexecute(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 allowexecute(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:
falsemeans disabled, a provider id means that provider, andrandommeans 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 (itsretry-after, else 60 s) and fail over to another; any other error fails the call. No stored selection raisesProviderRequired, 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.randomat:143).- Checked by
packages/core/test/websearch.test.ts(importsWebSearchfrom@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
websearchpermission is granted: the person picks allow (stored asrandomover all providers), choose (a second form naming one provider), or disable (stored asfalse; the context hook atpackages/core/src/tool/plugin/websearch.ts:184-191then removes the tool from later requests). Cancel, or no answer within 1 minute, fails the call as "Web search cancelled". Inopencode runcode 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 atpackages/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 towebsearch.providerforms:noninteractive.ts:191gives the feedback message only toquestionforms, others are cancelled without one).
E15 What does the person answer when the agent asks a question? RULING · human
- Answered how
After the
questionpermission (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, asopencode runsends 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 viaForm.ask, TUI or app form).- Checked by
packages/core/test/tool-question.test.ts(importsQuestionTool:9);packages/core/test/session-runner.test.ts(:50, importsQuestionTool: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 (
:97defect, recovered atpackages/core/src/session/model-request.ts:383-394, handled atpackages/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 forgeneral(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 fallbackpackages/core/src/tool/plugin/read.ts:56-81andpackages/core/src/tool/plugin/read.ts:132-144, suggestionspackages/core/src/tool/plugin/read.ts:146-165.- Checked by
packages/core/test/tool-read.test.ts(importsReadTool: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.mdfiles 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 forlimit + 1results, returns the firstlimitand 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-145andpackages/core/src/tool/plugin/glob.ts:92-119(the model'slimitargument; default 100 atpackages/core/src/filesystem.ts:66, no maximum:PositiveIntatpackages/core/src/filesystem.ts:73,82); search timeoutpackages/core/src/tool/plugin/grep.ts:122-130.- Checked by
packages/core/test/tool-search.test.ts(importsGlobTool: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 setmetadata.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
isErrorresult 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(isErrorbecomes 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 timeoutpackages/core/src/mcp/client.ts:368,:423-432.- Checked by
packages/core/test/mcp.test.ts(importsMcpToolfrom@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.afterhooks: each base64 data-URI image in the result goes toImage.normalizeto 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 viaImage.normalize; undecodable or over-limit images replaced by "[n images omitted: ...]"), limitspackages/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 stillstreamingorrunningin 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_MISSINGfor 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.afterhook, which may replace it. Built in, only Plan mode does: for theplanagent, 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.afterhook may replaceresultorerror); 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:883registers anexecute.afterhook against the registry (import line not re-verified);packages/core/test/plugin/plan.test.ts:41-75drives the Plan rewrite through a captured hook stub, not throughtool.ts(so it checks plan.ts, not the dispatch wiring). Found byGrep "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 isnever(packages/plugin/src/effect/tool.ts:51), so a failing hook surfaces as a defect (promise hooks viaEffect.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 isexternal.
F. Limits and memory
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.bufferwhen 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 whencompaction.autois 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 atpackages/core/src/session/compaction.ts:1104-1112(calculateCeiling, 15% or 16k reserve), called each step atpackages/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"; importsSessionCompactionat :10);packages/core/test/session-runner.test.ts:3328("stops after required automatic compaction fails"; importsSessionRunnerLLMat :50).- Contract
not recorded
- On error
fail-closed: a failed automatic compaction stops the step (
packages/core/src/session/runner/llm.ts:218raisesStepFailedError). 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
compactionagent'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. Asessioncompactionplugin hook can supply the summary itself, and then no model is called (packages/core/src/session/compaction.ts:579).- Held by
the
compactionagent's model if configured, else the session's model (packages/core/src/session/compaction.ts:264-282); summary call insummarizepackages/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), thencompaction.failed(compaction.ts:476-483). Alengthorcontent-filterfinish 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 publishesCompaction.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
summarizeserves manual, automatic, overflow and background triggers. Acompactionplugin 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'sendpointmechanism the provider returns the whole replacement window and so picks what survives; in thetriggermechanism the provider returns one encrypted checkpoint and opencode's code keeps beside it the newest whole real user messages withinkeeptokens (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 carrycompaction.type: "native"(packages/core/src/session/compaction.ts:294)- Checked by
packages/core/test/session-native-compaction.test.ts(importsSessionCompactionat :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 --sanitizeleft 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), allowancekeepdefault 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_COMPACTwhen 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) andpackages/core/src/session/compaction.ts:660-728(flattenAndDropOldest, levelspackages/core/src/session/compaction.ts:137-143, clip 2,000 charspackages/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_compactionis true andcompaction.autois 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 failurespackages/core/src/session/compaction.ts:147-157) andpackages/core/src/session/compaction.ts:383-407(usePrecomputed,heldIndexpackages/core/src/session/compaction.ts:921-926,tailLimitpackages/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; initialprecompute: falsepackages/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.autois false) and, if it completes, reruns the step; this recovery is allowed once per step (recoverOverflowturns false after a compaction).- Held by
packages/core/src/session/runner/step.ts:155-167; once-per-step flagpackages/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) andpackages/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(importsSessionHistoryat :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
generaterequests send no limit. Plugincontext/compactionhooks 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-fitpackages/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
generatedeliberately keep the provider default (model-request.ts:244-245). Plugincontext/compactionhooks 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 checksstep >= agent.steps; on that step it appendsMAX_STEPS_PROMPTas an assistant message and sendstoolChoice: 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(appendsMAX_STEPS_PROMPTandtoolChoice: "none"); tool refusalpackages/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:
stepsunset means no cap (packages/schema/src/agent.ts:33optional; docagents.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
promotecounts 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-111andpackages/core/src/session/runner/llm.ts:176(if (promoted > 0) step = 1);promotedcounts 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: searchedsteps = 2andsyntheticinsession-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 atlengthit fails the step withoutput-limit. Otherwise, if the cut reply had local tool calls the run simply continues, and if not code posts the syntheticCONTINUE_AFTER_OUTPUT_LIMITnotice and reruns the step.- Held by
packages/core/src/session/runner/llm.ts:299-328(three newest user/assistant rows allfinish === "length"→output-limitfailure; a reply with tool calls continues without a notice, else syntheticCONTINUE_AFTER_OUTPUT_LIMITpackages/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 publishesExecution.Failedand 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(importsSessionRestartat :18;resumeSuspendedSessionsexercised 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
maxAttemptsis 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 MiBpackages/core/src/snapshot.ts:136, passes the source repo's ignore rulespackages/core/src/snapshot.ts:135, ignored paths filtered from the file listpackages/core/src/snapshot.ts:155-174); per steppackages/core/src/session/runner/step.ts:76,packages/core/src/session/runner/step.ts:234-245- Checked by
packages/core/test/snapshot.test.ts(importsSnapshotat :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: falseor 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 ofcompaction.auto.- Held by
person, via TUI
packages/tui/src/routes/session/index.tsx:1091(andpackages/tui/src/mini/runtime.ts:927); admitted atpackages/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.Failedon the/compactitem (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 withBusyErrorwhile 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:1127andpackages/tui/src/routes/session/dialog-message.tsx:48; served atpackages/core/src/session/session.ts:329-340- Checked by
packages/core/test/session-revert.test.ts:95-115(importsSessionat :15, which callsSessionRevert.stageatsession.ts:335).- Contract
not recorded
- On error
raises
BusyErrorwhile 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) andpackages/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(importsSessionRevertat :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.Committedon 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) andpackages/core/src/session/session.ts:261(next/compact); explicit commitpackages/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
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
lengthinstead 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 exitpackages/core/src/session/runner/llm.ts:276-283- Checked by
packages/core/test/session-step.test.ts:37-41,:125-128(importsSessionStepat :17; pinsneedsContinuationto 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: noneforces done (step.ts:279). A reply cut atlengthis 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=stopand the execution publishesSucceeded(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:
CompletedandOutputLimitoutcomes both read the sameneedsContinuation.
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 withcompaction.failed. A reply that ended atlengthor 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
titleplugin 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(importsSessionTitleat :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
titlehook 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
titleagent'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. Notitleagent 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 variantpackages/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_modelset 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
titleagent 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);isUntitledpackages/core/src/session/title.ts:38-42; title agent presentpackages/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
/reviewwith an optional commit, branch or PR; code sendsPROMPT_REVIEWwith 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
reviewcommandpackages/core/src/plugin/command.ts:42-54- Checked by
packages/core/test/plugin/command.test.ts:79(registration and description only; importsCommandPluginat :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
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
subagenttool with an agent, a brief, and optionally a model, a sessionID to continue, andbackground. Code then refuses the call if nesting is too deep (H3), the agent is unknown or primary, thesubagentpermission 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
subagenttool (packages/core/src/tool/plugin/subagent.ts:29-48input, executedpackages/core/src/tool/plugin/subagent.ts:108-256)- Checked by
packages/core/test/tool-subagent.test.ts:404,:467,:523,:576,:672(importsSubagentToolat :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 setmodelNEVER unless the user explicitly asks, and to use background only for independent work. The consumer enforces: agent exists and is notprimary(:134-137),subagentpermission (: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 onmodelis not enforced in code.- On error
raises
ToolFailureback 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,compactionandgeneratesession hooks, code appends to thesubagenttool's description every agent that is notprimary, nothidden, and not denied thesubagentaction 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(notprimary, nothidden,subagentpermission not deny)- Checked by
nothing found for the list filter: searched
Available subagentsinpackages/core/test(0 hits; positive control: the same search overpackagesfindssubagent.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 explicitmodelswitches 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: explicitmodel> 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
ToolFailurefor 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 itsparentIDis 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
completedwhenever the run returns,cancelledon interruption, anderroron 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) andpackages/core/src/session/subagent-completion.ts:10-19(its text parts joined, elseNO_TEXT); recovery copypackages/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 aToolFailureto 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 statuscompleted.- 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
cancelledwhen 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 unlessresume: falseis 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 unlessresume: false); observerpackages/core/src/session/subagent-job.ts:30-42; restart pathpackages/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.orDieatrestart.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; corepackages/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"; importsJobat :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
subagentsetting (or legacysubtask); if set, it decides, and if unset the command runs as a child when its configured agent has modesubagent. 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 modesubagent)- Checked by
packages/core/test/config/command-subagent.test.ts:109("subagent: false overrides subagent mode and the legacy alias"; importsSessionat :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.assertforsubagent(comparepackages/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 throughsession.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
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
CorrectedErrorcarrying 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(CorrectedErrorwhen 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 sendsPROMPT_INITIALIZE, with the notes in its$ARGUMENTSslot and the project directory in${path}, as an ordinary prompt, and the session model writes or editsAGENTS.mdthrough its normal, permission-checked edit tools. Later sessions load the file as written.- Held by
session model, prompted by the built-in
initcommandpackages/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-signalAGENTS.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.