CLAUDE.md@packages/tools · diff
git:20261018.51e5aac to git:20261019.c673508
7 added, 3 removed. Audit A to A.
# tools
## Shell directory selection
`working_directory.Door` reads and writes the authenticated caller's shell
default. `bash.tool_with_directory` accepts `cwd` and captures one canonical
directory after invocation permissions, passing it separately to foreground
`CallSpec` and to both `Jobs.start` and `Jobs.attend`. The workspace remains the
native file/LSP base and policy authority. `working_directory` reports cwd,
workspace and the actual `TMPDIR`; its setter has `Never` replay because a
relative selection cannot safely be repeated after a committed update.
On-demand `cap://` function surfaces retain runnable examples and indentation;
the cached type-only surface excludes them. Finite LSP plans can infer their
owner from explicit sources and advertise the actual SQL column names.
## LSP observer callback ownership
The retired top-level LSP constructors are removed. `lsp.diagnostics_observer`
projects only `Door.after_write` before retaining its callback; the full
`lsp/query.Door` remains with callers that need all eight operations, such as
code mode. The sibling-retention regression grows hover's captured payload,
requires the live observer to remain the same flat size, and checks both
hover's retained payload and the observer's settled-diagnostics result.
Shared rename landing and anchored previews remain covered independently.
## Code-mode description and repair
The code-mode introduction asks for any listable batch of reads, searches,
`git`/`gh` queries or checks, including the first round of an investigation
("judge between programs, not between commands"), and compact returned
evidence. The description retains the offered seams, concurrency contract,
recipes (workspace, orchestration, shell probe, `lsp_sql`), module index and
generated public types. A `BuildRejected` result says compilation and execution
never completed and asks for diagnostic repair; the warning rule is stated once,
in the description. Compiler diagnostics and the
structured failure fields remain intact; no automatic resubmission is added
by this tool. The pipeline itself rebuilds once after removing unused imports
and underscoring unused function arguments, `use` bindings and `let` variables,
all or nothing (`codemode/unused_repair`); `Execution.edits` carries what it
changed, and the result text and `details.edits` say so on success and on failure.
`sandbox_text` is the model-facing enforcement line, appended to every result,
so it is compact: layers appear by name only (the text before the first `:` or
`=`), skipped layers likewise, and `DEGRADED` stays whenever the report is
degraded. Build and node stages that read the same by name are stated once
(`sandbox: build and node enforced [...]; DEGRADED, skipped [...] (full report
in details).`); otherwise each is rendered separately. A stage with no report is
never merged and keeps its `made NO enforcement report ... not a claim that it
was confined` wording. The full report, with parameters and skip reasons, is
the `sandbox` field of the result details.
A compile error whose diagnostics mention an admitted capability module, and a
run whose call record shows a failed capability call, add one line naming the
`fs_read cap://<module>` reads (`tools/codemode_pointer`, at most two modules,
none when nothing applies). Vetting refusals never get the line.
A program that ended in failure (`Errored`, or a run failure such as the
deadline) also gets one line, `last failing command: ...`, from
`Execution.last_failure` (`tools/proc_failure`): the most recent `proc.run`
that exited non-zero or timed out, with the tail of its stderr. A completed
program gets none. `details.last_failed_command` carries the same record
whenever the host kept one. A compile error that says ``The module `M` does not
have a `X` value`` adds up to three more lines (`codemode_pointer.suggestions`):
a checked "`X` is in cap/git, not cap/proc" from the prelude surfaces, or an
unchecked `<p>.<rest>` guess from `gleam/<p>`; nothing otherwise, and nothing
for a module the program may not import.
## Saved program source
A synchronous `code_mode` run or background launch selects exactly one of
inline `program` or real-file `program_path` before authority or I/O. It
checks invocation permissions, then `fs.read_text` canonicalizes and authorizes
the exact file through the `fs_read` boundary before reading complete UTF-8 text
under `max_read_bytes` (eight MiB). Virtual references, windows, anchors and
image interpretation are not source inputs. The loaded string crosses the
existing execution seam and remains unchanged through an approval retry;
each later invocation reloads it. Async interaction modes do not read source.
Saved source preserves no grants, compiled binaries or observations.
## Explicit directory access and approvals
`directory_access.Access` carries explicit roots separately from the jail's
host and toolchain read paths. Native filesystem tools retain workspace-only
access by default. Added writable roots are readable as well, and protected
writes remain refused. Relative paths resolve against the workspace; authority
is checked against canonical targets.
`permissions` decodes optional `permissions` arguments on `bash` and
`code_mode`: `readable_roots`, `writable_roots`, and `network: "full"`.
`tool.authorize_policy` derives the missing grants using policy composition
and calls the existing action-bound escalation before execution. Native file
tools can request their exact target before I/O. Approval is confined to the
invocation and never updates session additions. Kernel errors after a program
starts remain ordinary results and never trigger automatic replay.
For a nonzero shell exit with a permission error on stderr, `bash` adds
fresh-call guidance to the tool result. A quoted absolute Git lock under
`.git` contributes a possible metadata root to that guidance, but stderr
never grants access; the next invocation's declared paths still go through
canonicalization, protected-path checks and operator approval.
## Purpose
The core tool set and the behaviour every tool implements: `bash` and
`grep` through the broker's jailed executor, `fs_read` / `fs_write` /
`fs_edit` harness-side with hashline anchoring and workspace path
discipline, plus content-addressed blob overflow for large output. `fs_read`
returns PNG, JPEG, GIF, and WebP bytes as `ToolResultImage` blocks, with a
text caption naming the file. MIME detection uses file signatures, and image
reads share the workspace checks and 8 MiB file bound with text reads. Only
text reads use offset/limit and hashline anchors; `cap/fs.read` remains text. WP-I.
Tool failures are data, never crashes.
Also the `agent_*` family — `agent_spawn`, `agent_wait`, `agent_send`,
`agent_note`, `agent_notes`, `agent_roster` — the six shells through
which a model reaches the messaging plane. They are shells only: each is
a thin wrapper over one call on the **Agency**, a record of closures
declared here and filled by whoever can see a live runtime
(`client/agency` in production). Everything with teeth — the addressing
rule, the caps, the deadline, the lineage ledger — lives on the far side
of that seam.
And the `job_*` family — `job_poll`, `job_kill`, `job_send` — the
model's door onto background jobs, on the same seam-of-closures shape:
a job is *started* by `bash` with `mode: "background"` rather than by a
fourth definition, because tool-surface cost is arithmetic, and the
three here watch, feed and stop it. Everything durable and everything
enforced — the per-strand ceiling, the wall clamp, the clearance, the
`job/<id>` fact writes — is on the far side of `Jobs`.
`bash`'s default mode is `auto`: the command starts through
`Jobs.attend`, the call looks at it every half second through `Jobs.poll`
for up to `timeout_ms`, and a job that ends in that window is rendered
through the same `exited` the foreground path uses, from the looks when
they saw every byte and from the spill ref when one missed some. A job
still running at the end of the window goes through `Jobs.release`, which
answers `Released` (the owner will be notified) or `AlreadyEnded` (the
call renders it); the host serializes that answer against the job's own
settlement, so the result is reported exactly once. Auto falls back to
the foreground path on `CeilingReached`, `NoJobsPlane` (what
`job.unavailable()`'s `attend` answers) and `ClearanceRefused`, and never on
`Unavailable`, which can come from a plane that timed out while a job was
already clearing; `ClearanceRefused` keeps the foreground clearance's structured
refusal, which is what an escalation reads. `mode: "foreground"` is the
old kill-at-timeout behaviour and `mode: "background"` still answers with
a handle at once. `heartbeat: true` asks for the idle heartbeat on a job
started either way; without it a job is silent until it ends.
And `history_search`, through which a model asks the repository's
full-text index what it once knew. Same shape, same reason: `events`
owns the index and depends on nothing here, so the tool is a shell over
a **History** record of closures declared here and filled by
`client/history`. What this side owns is the model's half — the schema,
the limit clamp, the empty-query refusal, and the fence that keeps
another session's text quoted as data.
And `remember`, the model's one door into the repository's durable
memory. Same shape again, and the same reason twice over: `client/memory`
owns the memory session file, and the two caps that matter — redaction
through `telemetry/field.scrub_text`, and a lifetime ceiling held in a
durable counter — are things this package can neither perform nor read.
So the tool is a shell over a **Memory** record of one closure, and the
constants it states in its description (`max_note_chars`, `max_notes`)
are the single definition the far side enforces. There is no tool to read
memory back: recall of memory is the bounded notes index the host injects at
run start, plus `history_search` with `kind=note`, so there is no read door
to poison and no argument that could name one.
And `code_mode`, through which a model submits a *program* instead of a
call. It has the same shape as the agent family and the same reason for
it: `codemode` already depends on this package — its capability router
renders a `tool.Collected` — so the edge cannot run the other way. The
whole
pipeline — vet, hermetic compile, jailed satellite — sits behind a
**CodeMode** record of closures declared here and filled by
`client/codemode`. What this side owns is the model's half of the
contract: the schema, the clamped budget, the resolution of which of the
host's seams a submission is judged against, the capability prelude's
public signatures rendered into the description so a model does not author
blind, and the rendering that turns a vetting rejection, a compile error, a
dead satellite or a program's own reported failure into something a model
can repair from.
And `advise`, the whole outward surface of an *advisor* strand — a second
strand that reads what the primary has been doing and says whether it
should carry on. Same shape once more, and the reason is the sharpest of
the set: the advisor has no authority at all. It cannot steer the
primary, cannot queue text into the primary's next run, and cannot write
anything durable; its one call leaves through an **Advice** record of one
closure, judged against `Ctx.strand`. What this side owns is the
three-point verdict vocabulary and the total decoder over it. What the
far side owns is what a verdict actually costs, which is why the call
answers with what happened rather than with an acknowledgement that it
was asked.
## Key Types
- `tools/tool.Tool` — the record every tool is: `name`, `description`,
`prompt_snippet`, `schema`, `replay` (`ReplaySafety`),
`execution_mode`, `requirements` (a function from workspace root to
`SandboxPolicy`), and `run: fn(Ctx, JsonValue) -> ToolOutcome`.
`prompt_snippet` is pi's `promptSnippet`: one line for the system
prompt's available-tools index, and `None` omits the tool from that
index without making it any less callable — the wire tool array is the
authoritative definition and the index is prose.
- `tools/tool.Ctx` — every seam a tool may touch: workspace root, the
driver's own coordinates (`strand`, `op_id`, `step_id`,
`source_index`), base policy and grants, enforcement demand, the
constructed env, the clock, a `FileSystem` record of functions
(`read`, `write`, `create_directory_all`, `is_file`, `read_link`, and
`rename` — the last for the atomic staging the blob store needs),
`blob_root`, `clear_call` — the broker seam every jailed execution
flows through — `raise_refusal`, the other door onto the same
escalation plane, and `observe_output`, the seam a running execution's
rolling output tail is shown to after every chunk (issue #186).
- `tools/tool.{OutputTail, tail_bytes, ignore_output, collect_observed}` —
the observation seam. `collect_observed` is `collect_events` showing
`observe` each stream's window after every `CallOutput` it folds;
`OutputTail(stream, tail, total_bytes)` is that observation, the *whole*
retained window (at most `tail_bytes`, 4 KiB, on character boundaries,
empty for bytes that are not UTF-8) rather than the chunk, so an
observer replaces what it shows and a dropped observation costs nothing
the next one does not restate. `ignore_output()` is the default seam.
`bash` and `grep` hand `ctx.observe_output` over; production wires it to
the event bus (`client/gateway.tool_output_observer`).
- `tools/tail.{Tail, Since, new, push, since, received}` — a pure, bounded
rolling window over one output stream with a monotone byte cursor,
shared by the foreground collector above and the background job runner
in `client/jobs` (it moved here from `client/jobtail`). `push` drops
from the front past the capacity; `since(cursor)` answers with what
arrived after the cursor, the cursor to ask with next, and how many
bytes fell out in between. Both edges respect UTF-8, and the trailing
edge asks the last character's *lead byte* whether it is incomplete or
simply not UTF-8, so binary output is passed through rather than held
back forever.
- `tools/tool.{RaisedRefusal, Escalated, no_raise}` — the raise seam a
tool knocks on when it met a policy refusal somewhere `clear_call` is
not. `RaisedRefusal` carries the broker's structured `Denial` (whose
`wanted` is the diff an approval may grant) and the refused work's own
budget deadline; `Escalated` is `Settle` or `Resume(grants:)`, the
mirror of `client/escalate.Decision` that `tools` cannot import;
`no_raise()` is the seam for a host with no escalation plane. It exists
for `code_mode`, whose clearances happen inside the code-mode pipeline
and so never pass `clear_call` (#97).
- `tools/agent.Agency` — the messaging seam: `spawn`, `send`, `wait`,
`note`, `notes`, `todos`, `roster`, plus the published `max_wait_ms`
the wait tool's schema states and `model_names` advertised by the spawn
schema, and `holds` (`Ok` when the caller's strand holds this tool in its
durable active set right now, `ToolNotHeld` when it genuinely does not,
and `AgencyUnavailable` or `PlaneFailed` on a fault), which only the
code-mode `tool_gate` asks because a model's own call is cleared
against that set by the registry.
Every closure takes a `Caller` first and is judged against it.
- `tools/agent.{Caller, Handle, SpawnRequest, Provenance, Spawned,
Waited, Outcome, Peer, Relation, Delivery, Refusal}` — the vocabulary
crossing that seam. `Delivery` mirrors `runtime/api.Delivery` (which
`tools` cannot import) the way `effects.ToolOutcome` mirrors the
broker's `CallOutcome`. `Delivery.Queued` is a send recorded for an
orchestrator that is away (protocol-change/084, phase 2): the tool renders it
without promising a landing.
- **Spawn selection is a catalogue name.** `SpawnRequest.model` is optional;
the Agency resolves an explicit name before creating the child. Omission
retains host routing. `Spawned.model` and `model_id` report the child's
current durable configuration on both creation and replay, and the tool
exposes them as text and structured details. They do not attest which
provider eventually answers a request.
- `tools/agent.{ResultSchema, ResultField, FieldType, Mismatch,
TerminalResult}` plus `{parse_result_schema, render_result_schema,
result_fields, validate_result, describe_mismatch, type_name,
field_type_name}` — the result contract: the shape a parent may demand
of a child, and the verdict a join hands back beside the prose report.
A schema is the subset of JSON Schema `tool.object_schema` already
emits — an object of named properties with one closed-set type each —
and nothing wider; `parse_result_schema` is total and refuses what this
harness cannot enforce rather than accepting it and ignoring it.
- `tools/agent.{slug, handle_to_string, parse_handle}` — the name and
handle grammar: `[a-z0-9-]`, capped, `/` and `#` rejected, and a total
parse back from `{strand}#{operation}`. A child on another orchestrator
(protocol-change/084) has a handle with `@{orchestrator}` after the
operation (`remote_handle_to_string`); `parse_handle` reads and drops the
suffix, and an empty or doubled one is `MalformedHandle`. The suffix is for the
model: the Agency decides whether a handle is remote from the parent's own
stubs, and nothing is routed by a suffix a model typed.
- `tools/agent.SpawnRequest.on` and `Agency.orchestrator_names` — `agent_spawn`
takes `on`, an enum of the names, and the schema omits the argument when the
list is empty. A remote spawn's success text and details carry the suffixed
handle and the orchestrator; a local spawn's are byte for byte what they were.
`Refusal` gains `RemoteRefused(orchestrator, reason)` (the orchestrator said
no) and `RemoteSilent(orchestrator)` (no answer in time, the child may exist).
`Waited.Pending` carries `unanswered: Option(String)`, the orchestrator that did
not answer a join, and the wait text says the state is unknown. `Peer` carries a
`Placement` (`Here` or `Elsewhere(orchestrator, Standing)`, with `Standing` =
`AwaitingAnswer | Running | PastDeadline | Finished | Declined(reason)`;
`PastDeadline` is a running child whose deadline has passed and whose outcome
nothing has read yet). The `on` property says that ending the run does not stop
the child and that `detach` has no effect with `on`: a remote child is always
detached, bounded by its deadline. `describe` words
`ParentRunEnded` for a strand that begins with `^` (the parent reference a
remote child holds) as a parent on another orchestrator, naming the final
answer, `agent_note` and the result as the way to report.
- `tools/agent.{Minter, minting_step, call_site_digest}` — who, inside
one planned tool call, minted a child. `ToolCall` is a model's own
`agent_spawn`; `Program(ordinal:)` is a code-mode program on its
`ordinal`-th spawn admission, which is a fact `{operation, step_id,
source_index}` has nowhere to put. `minting_step` is the step a spawn
is *recorded* under and reconciled against, and `call_site_digest` is
the constant-width, model-proof half of a minted child's name.
- `tools/todos.{Op, Target, tool, step, apply, settle, resolve, parse,
render, tool_name, note_key}` — the `todo` tool, registered by
`agent.tools` beside the `agent_*` family because its board is the
caller's own blackboard cell, `agent/{strand}/todo`. One op per call:
`init`, `start`, `done`, `drop`, `block`, `unblock`, `append`,
`remove`, `view`. `apply` is the whole semantics, pure: the op, then
`settle` (one active task; the first pending task is promoted when none
is active; a blocked task never is), then `todo_list.validate`. Tasks are
named by their text, matched exactly and then by a unique case- and
spacing-insensitive match. The shell hands `step` to `Agency.todos`,
which runs it under a compare-and-set on the cell's sequence and retries
on a conflict, so the tool is `Concurrent` without losing a sibling's
update, and `Safe` because every op is written to its postcondition
(`append` skips present items, `remove` of an absent task succeeds).
`details` carries `{op, todo}` with the landed board.
- `tools/codemode.CodeMode` — the code-mode seam: `execute`, plus the
published `seams`, `default_within_ms` and `max_within_ms` the tool's
description and schema state, so the sentence the model is charged for
on every request cannot drift from the policy the program is judged
against.
- `tools/codemode.{Seam, SeamOffer, Seams}` plus `{seam_name, offered,
one_seam}` — which allowlist a submission is judged against.
`Seam` mirrors `codemode/vet/policy.Seam` (`WorkspaceSeam`,
`OrchestrationSeam`); a `SeamOffer` is one seam's published
`allowed_imports`, `serviced_caps` and `extra_surfaces`; `Seams` is
what this host serves, as a named `default` plus `alternates`, so a
host can never offer none and an unnamed submission never has an
ambiguous seam. `extra_surfaces` is the one part of the description a
*host* supplies rather than the committed artifact: a `cap/mcp/<server>`
façade is generated from one host's configured server (issue #106), so
it cannot be in `tools/prelude`, and it renders after the committed
blocks under the same seam's heading. Installed language-profile hints
supplement the committed `cap/lsp` API on admitted offers rather than
creating a second module. `cap://lsp` returns the full API followed by
those notes, and its index has one entry. SQL guidance requires both the
`cap/lsp_sql` import and `lsp.snapshot` service on the same offer.
- `tools/call_record.{CallLog, CallRecord, CallStatus, Ledger}` (protocol
060) — the per-call record of a code-mode execution: the types, the
bounds (128 itemised calls, exact counters, 96-byte summaries with the `…`
counted, 64-byte capability names, 48-byte error codes), the redaction
allowlist (`summarise`) and the JSON encoder. Pure. `Execution.calls`
carries it from the satellite host to `ran_outcome` and
`run_failed_outcome`, which attach it as the `calls` key of `details`;
`execution_value` deliberately does not, so background executions carry no
record. `cap` is a dev dependency only, so the redaction tests can check
the allowlist against the keys the real `cap` functions write.
- `tools/codemode.{PolicyRefusal, Execution.refusal}` — whether policy
composition stopped this execution before it ran, and whether an
approval could overturn it. `NothingRefused` or `RunRefused(denial:,
deadline_ms:)`, and a *peer* of the enforcement report rather than a
field inside it, for the reason `codemode`'s `widening` is one.
- `tools/codemode.{Request, Execution, ExecResult, Outcome, Rejection,
Rule, Location, CompileFailure, RunFailure, Enforcement, Report}` — the
vocabulary crossing that seam, mirroring `codemode/vet`,
`codemode/compile`, `codemode/satellite` and `cap/report` rather than
importing them. `Request.seam` is the resolved seam the execution is
judged and routed under; `Request.observe_output` is the caller's
`Ctx.observe_output`, carried so the hermetic build can stream its tail. `RunFailure` is the one deliberate narrowing:
eight `satellite.RunError` variants become the four that read
differently to a model, with the pipeline's reason text carried
verbatim.
- `tools/prelude.surfaces` — **generated** (`make gen-prelude`): every
capability-prelude module paired with its public surface — `pub type`
declarations with their constructors, `pub const`, `pub fn` signatures,
each under the prelude's own `///` docs — rendered from `gleam export
package-interface` over `packages/cap`. Unfiltered by design;
`tools/codemode` selects from it through a seam's `allowed_imports`.
- `tools/prelude.type_surfaces` — the same generated modules cut after their
public type declarations. `code_mode` renders these smaller blocks in its
description; `cap://<module>` returns the matching whole block from
`surfaces`.
- `tools/history.{History, Hit, Query, Syntax, Scope, Refusal, tool,
tool_name, query, clamp_limit, min_limit, max_limit, max_around,
default_limit, utc_date, fence}` — the recall
seam and the `history_search` tool over it. `History.search` takes a
`Query` (trimmed text, `Words` or `Raw` syntax, optional kind, optional
`before` entry id, an already-clamped limit and a `Scope`
(`Repository` | `ThisSession`)) and answers hits or a `Refusal`
(`IndexUnavailable` | `IndexRefused` | `IndexNotReady` | `IndexBusy`).
The last two are transient and their rendered text says the request was
fine and to send the same call again; only `IndexRefused` tells the model
to change the query or check the IDs. `History.recent` takes an
already-clamped limit and answers the calling session's newest indexed
entries, newest first; the tool calls it for a search with no query (or
a blank one) in the `session` scope, and refuses the same call in the
repository scope with a message naming both the search and the browse.
`History.read` takes canonical
session and entry IDs and returns a complete codec entry. The host
resolves registered source paths and validates source identity without
acquiring a writer lease. `action=read` spills entries over 64 KiB through
`tools/blob`, returning bounded excerpts and a readable path; failed
spills refuse instead of silently clipping. Large single-line JSON needs
bounded shell reads rather than `fs_read` line pagination.
- `tools/context.{Context, Report, Boundary, tool, tool_name, render,
remaining}` — the `context_remaining` tool: the model's own door onto
the compaction arithmetic. `Context.report` takes the *calling*
strand's name — from `Ctx.strand`, never from an argument — and answers
a `Report`: which window the strand is in (one-based, counting the
compactions on its branch), the strand's context window, the tokens in
use as the threshold estimates them, the `Boundary` (`CheckpointAt`
with the threshold's cut point and the keep-recent budget, or
`NoCheckpoint` when compaction is off), and how many notes it has
written. The host fills the seam from the same projection and fold the
threshold reads (`client/checkpoint.remaining_seam`), so asked and told
are one number. Read-only, `Safe`, `Concurrent`, no sandbox
requirements.
- `tools/lsp.{land, diagnostics_observer, render_diagnostics, clip,
changed_spans, ChangedSpan, max_rendered_diagnostics}` keeps the shared
implementation used by code mode and observed writes. The seven top-level
`lsp_*` constructors, argument decoders and obsolete renderer tests are
removed. `land` needs no `Ctx`: code mode supplies `fs.write_target`, then
the shared path plans every file, resolves every target and checks every base
before its first write. A failure writes nothing and reports failed files as
`Rejected`, the rest as `NotAttempted`. Files then land in path order, and
`after_write` runs only for landed paths. Diagnostics are clean only for
`Settled([])`; `Unsettled` remains explicit even when empty. Language profile
hints belong to admitted code-mode discovery and `cap://lsp`, not a retired
tool description.
- `tools/advise.{Advice, Verdict, Ack, name, tool, decode_verdict}` — the
`advise` tool: an advisor strand's entire outward surface, a value over
a seam the host fills, exactly as `remember` and `schedule` are.
`Verdict` is the three-point vocabulary — `Quiet`, `Nudge(text)`,
`Block(text)` — ordered by what it costs the primary: nothing, a
paragraph delivered the moment the primary stops, an interruption now.
`Ack` is what the harness decided to do with one (`Delivered(how)` |
`Queued` | `Woke(how)` | `Downgraded(reason)` | `Dropped(reason)` |
`Acknowledged`), and it is the reason the call answers with an outcome
rather than an acknowledgement: an emission guard downgrades a block
raised inside its cooldown and drops advice the primary has already
been given, and an advisor reading a downgrade as a delivery would
believe it had stopped the primary when it had not. `Woke(how)` is the
nudge channel's own unsolicited delivery — the queue drained onto an
idle primary at once, through the same door a block uses, rather than
left to wait for its next run start — and `how` names the door and how
many nudges rode it, since the queue drains whole. A downgraded block
keeps `Downgraded` even when the queue it joined went out this way,
because a downgrade's whole meaning is that the primary was *not*
stopped for it, and `Woke` or `Delivered` would each claim the
opposite; the wake is appended to the downgrade's own reason instead.
`Advice.judge` is handed `Ctx.strand` — the driver's own durable name,
never an argument — so a verdict cannot be attributed to a strand that
did not produce it. `decode_verdict` is total and public because both
sides of the seam pin the same vocabulary against it; it decodes the
verdict and its text as one pair, so a `nudge` with nothing to say and
a `quiet` carrying text are in-band errors rather than half-decoded
advice. `Never`/`Exclusive`: a replayed call would say the same thing
twice or burn a cooldown the first call paid for, and two verdicts
racing for one window would make which of them was downgraded a matter
of scheduling. No sandbox requirements at all.
- `tools/schedule.{Schedules, Limits, Request, RequestedTiming, Created,
Listed, Wake, Refusal, tools, create_tool_name, list_tool_name,
cancel_tool_name, refusal_outcome, refusal_reason}` — the model's own
door onto scheduled heartbeats, a value over a seam the host fills
(`client/scheduleseam`), exactly as `remember` is. Three tools, exported
as one list so a host cannot register the writer without the reader.
`Limits` is passed **in** rather than defined here — the reverse of
`remember`'s caps — because these bounds are enforced on the far side
of the seam, and the direction is decided by which side owns the check.
`Request.wake` is what the model asked for and `Created.wake` is what
the operator's policy granted, which is the whole reason `create`
returns a record rather than `Nil`: under a `steer` policy the call
succeeds and the result says it will only steer, rather than refusing
and teaching the model to retry against a wall that will not move.
`wake_note` also covers the request never made: a self-targeted
schedule with `wake` omitted gets "will not start a run" and the advice
to pass `wake: true`, since the tool cannot see the policy and the
advice is true under either; a subagent target gets no note.
Both are `Wake` (`WakesIdle | SteersOnly`), this door's own name for a
distinction `client/schedule` holds under the same two names on the
durable side — `tools` may not import it, so `client/scheduleseam`
translates, exactly as it does for `Refusal`. The model still writes
`wake: true` and still reads a JSON boolean back.
`Refusal` gives each reason its own code because a model can act on the
difference; `refusal_reason` exposes the sentence without the
`ToolOutcome` wrapper, which is what `client/codemode` maps onto the
code-mode capability vocabulary. Both writers are `Never`/`Exclusive`:
a replayed create would silently replace a schedule the model believes
it already has, and two in one batch would race for the same ceiling.
- `tools/job.{Jobs, Started, Cursors, Streamed, Polled, Listed, JobState,
StopCause, LostReason, JobSpill, StdinEnd, IdleWake, Refusal, tools,
unavailable,
poll_tool_name, kill_tool_name, send_tool_name, is_pending, state_name,
cursor_to_string, parse_cursor, refusal_outcome, refusal_code,
refusal_reason}` — the model's door onto background jobs, a value over
a seam the host fills (`client/jobtools` over `client/jobseam.Door`),
exactly as `schedule` is. Three tools exported as one list, plus
`bash`'s own `mode` argument, which is what starts a job — a host
cannot register the stopper without the poller, and starting is not a
fourth definition because the wire tool array is the provider's cached
byte prefix.
`Jobs.max_wait_ms` is passed **in** rather than defined here, the same
direction `schedule.Limits` takes and for the same reason: the clamp
is applied on the far side, so the number a description promises and
the number a call meets are one fact.
`JobState` is a six-variant lifecycle restated from
`client/jobstate.JobState` — `tools` may not import `client` — but the
exit report inside `Exited` and `Killed` is `broker/exec.ExecResult`
carried across unchanged. `is_pending` is the live/terminal split and
is the only thing that decides `details.pending`, so the rendered text
and the structured half cannot disagree; a pending poll is a
**success**, the rule `agent_wait` follows.
`Cursors` travel to the model as one opaque token
(`cursor_to_string` / `parse_cursor`) rather than two integers,
because a model must not do arithmetic on either half; a token that
could not have been minted is refused rather than rewound to the start
of the stream. `StdinEnd` (`CloseStdin | KeepStdinOpen`) is the
no-naked-`Bool` shape of the `eof` enum.
`IdleWake` (`WakeWhenIdle | QuietUntilDone`) is `bash`'s `heartbeat`
boolean after the same translation, carried by `Jobs.start` and
`Jobs.attend`: only a job that asked wakes its idle owner, so a passive
watcher costs no turns while it waits.
`refusal_code` is half of a contract `codemode/workspace.job_denial`
and `cap/job.map_error` complete: the same five strings reach a model
in a tool result and a program in a denial. `unavailable()` is the
`tools`-side twin of `jobseam.none()`, for a host with no jobs actor.
All three are `Never`/`Concurrent`.
- `tools/remember.{Memory, Note, Kind, Refusal, tool, tool_name, note_type,
kinds, kind_name, kind_named, max_note_chars, max_notes,
max_title_chars, max_description_chars, check_line, refusal_outcome,
says_something}` — the memory door and the `remember` tool over it.
`remember` takes required `title` (80), one-line `description` (160), `note`
and `kind` (`feedback`, `fact`, `decision`). The seam receives a
`remember.Note`, and `check_line` judges the single-line fields after
redaction. A note never asks the user.
`Memory.remember` takes the model's text untrimmed and answers `Nil` or
a `Refusal` (`MemoryBusy` | `MemoryUnavailable` | `NoteTooLong` |
`CeilingReached` | `NothingToRemember` | `FieldInvalid`); `MemoryBusy` is what a caller
gets while another writer holds the memory session's lease, and it is a
refusal to say again later, not a fault. `note_type` is the only type this
door can produce.
- `tools/tool.ToolOutcome` — text plus `is_error` plus optional typed
`details`, mirroring pi's `ToolResultMessage.isError` (pi §3.8), plus
`terminate`.
- `tools/tool.Terminate` (`ContinueRun` | `TerminateRun`) — whether the
run ends once this call's batch settles. `success`, `failure` and
`with_details` all answer `ContinueRun`, so ending a run is something
a tool says rather than something it falls into;
`client/wiring.run_tool` converts the answer into the `Bool` on
`effects.ToolCompleted`, which is the one place the two vocabularies
touch. No built-in answers `TerminateRun`.
- `tools/tool.Registry` — opaque name → `Tool` lookup; `dispatch` is
total. It remembers registration order as well as the table:
`names` is sorted (the provider cache's byte prefix), while
`registered` and `snippets` read in the order tools were registered,
which is the order the prompt's index prints.
- `tools/tool.{Declaration, Declarations}` — the registry projected to one
`{replay, execution_mode}` pair per name, read with `declared`. It exists
for copy cost, not for convenience: the effect plane's
`replay_still_safe` and `execution_mode` slots are closures inside a
record that every process of a session assembly receives a copy of, and
a closure holding the whole registry to read two words duplicates the
entire tool table once per copy. `Declarations` is opaque so a caller
cannot reach back to the registry through it.
- `tools/hashline.{AnchoredLine, Ref, Hunk, Plan, Stale, ApplyError}` — the
pure anchor/window/plan core.
- `tools/blob.Bounded` — the overflow decision plus `{ref, size,
head_excerpt, tail_excerpt}` details.
## Relationships
- **Depends on** (tests also use `qcheck` and `statem`; `test/support/gen` holds the
hashline line and file-content generators): `core` (json, messages, ids, clock), `broker` (policy
vocabulary, `clear_call`, `exec` failure shapes, framing output streams —
the spec DAG's `I → G`), `simplifile` (the production `FileSystem`),
`gleam_erlang` (subjects for streamed call events), `lsp` (only
`lsp/query`, the door's vocabulary: types and a record of closures, no
process — `tools/lsp` renders it, and `lsp` depends on nothing here, so
the edge cannot cycle; it brings `mcp` and `weft` in transitively).
- **Depended on by**: `codemode` (the capability router renders a
`tool.Collected` into a `cap_result`, which is why `tools` cannot
import it back and `tools/codemode` mirrors its vocabulary instead),
`client` (`client/wiring` builds the per-call `Ctx` and resolves its
`observe_output` per run; `client/jobs` holds two `tools/tail` windows
per job
and dispatches through the registry; `client/agency` fills the
`agent.Agency` record, `client/codemode` fills the `CodeMode` record,
and `client/jobtools` fills the `job.Jobs` record, and `client/contributions` registers all five families; `client/history` fills
the `history.History` record and `client/memory` fills the
`remember.Memory` record),
`conformance` (the wiring/e2e suites drive the same adapter).
- **Generated from**: `packages/cap`, at build time and not as a
dependency edge — `scripts/gen-prelude.sh` runs `gleam export
package-interface` there, hands the JSON to the dev module
`dev/gen_prelude.gleam` (with `dev/gen_prelude/interface.gleam`, a total
decoder for the slice of the interface it reads, and `dev/gen_prelude/wrap.gleam`,
a port of Python's `textwrap.wrap` so the wrapped prose keeps its breaks),
and writes `tools/prelude`. The generator uses `gleam_json` and `argv` as
dev-dependencies and no `@external`; its tests are
`test/tools/gen_prelude_test.gleam`, and the three files are inputs the
artifact's header digests, so editing one means `make gen-prelude`. Nothing in this
package imports `cap`, and nothing at runtime shells out; the artifact
is committed and `scripts/gen-prelude.sh --check` (inside `make check`)
fails the build when it and its inputs part company.
- **FFI**: `tools/internal/ffi_hash` — SHA-256 for blob content addressing.
`tools/internal/ffi_path` — `read_link`, the lstat-level primitive
workspace containment is built on. Both backed by `tools_ffi.erl`.
## Traffic
- **Actor messages**: none of its own. Jailed tools open a
`Subject(broker.CallEvent)` and consume `CallOutput` chunks followed by
one `CallSettled(CallExited | CallFailed)`; `tool.collect_events`
gathers the stream respecting the helper's truncation flags.
- **Commits / registers**: none. Tools produce `ToolOutcome` values; the
runtime commits the resulting result entries.
- **Wire**: indirect — `CallSpec` → broker → `exec_start` frames; the
helper's `exec_out` / `exec_exit` come back as `CallOutput` /
`CallExited`. `grep` additionally parses ripgrep's `--json` event stream
into `Match` values.
- **Policy**: each tool declares `requirements(workspace)`. `bash` asks
workspace write, system paths readable, and network **off** in the
requirement it states, though it clears under the session base's own
network (`tool.asking_base_network`), which the daemon defaults to
full; `grep` asks
workspace readable, nothing writable, network off; `history_search`
asks for **nothing at all** — no readable root, no writable root, no
network — because it starts no jailed process and touches no path: the
index is read harness-side through the seam, and `remember` and the
three `job_*` tools ask for nothing for the same reason — a job's own
command is cleared by the host under the *job's* identity, never under
the polling call's; `code_mode` asks
workspace write and the whole filesystem readable (the Gleam and Erlang
toolchains live outside it), and declaratively only — it clears nothing
through `Ctx.clear_call`, because the build and the node are cleared
inside the pipeline against their own far narrower requirements. `RefuseNarrowed` means
an uncovered requirement settles in-band as a structured policy refusal
carrying the exact wanted grants, ready for the escalation flow.
## Invariants
- **Shell scratch uses the published path.** `bash` directs the model to
`${LOOM_SCRATCH_DIR:-$TMPDIR}`. macOS private scratch is a generated
directory, while Linux mounts it at `/tmp`. Private scratch lasts for
one command or background job, and both the description and the prompt
snippet say so: a file a later call reads goes in `$TMPDIR`, the
session's `<workspace>/.codemode/tmp`, which persists and which session
assembly keeps out of `git status`. Pointing a model at "the workspace"
instead sent its clones and notes into the repository it was editing.
The helper retains explicit `TMPDIR` for compilers and the session
fallback when no private scratch is available.
- **`bash` asks for every writable root the session base grants, not the
workspace alone.** `policy.compose` intersects roots, so a shell that
asked for `[workspace]` would get the workspace whatever the base said;
a linked git worktree's base also grants the git directories under the
main repository's `.git` (`client/serve.widening_linked_worktree`), and
without them `git commit` dies on the index lock. Asking for the base's
own roots cannot widen past the base. `grep` and the rest keep their
narrower requirements.
- **Tools never crash the strand.** Bad arguments, a policy refusal, a dead
helper, a stale anchor, an unknown tool name — every one comes back as a
structured `is_error` result the model can read. Dispatching an unknown
name yields the in-band unavailable-tool error with `details` omitted:
the harness must not invent a value for a tool's typed details contract.
- **Hashline plans are digest-bound; re-apply always rejects.** Per-line
anchors alone cannot make "apply at most once" true — after a delete, an
identical sibling line can shift into the removed position and its anchor
still matches. So a `Plan` carries the digest of the exact content it was
computed against and `apply` rejects any other content. A crash replay
therefore either repeats an edit that never landed or fails in-band as
stale; it cannot double-apply. The cost is deliberate: a concurrent edit
far from every hunk also rejects, buying one replan round trip for the
impossibility of silent double-application.
- **Edit prerequisites must reach model-visible text.** `fs_read` includes
its exact digest before anchored lines; successful edits include the new
digest, and stale-content or stale-anchor errors include the current digest.
Presentation-only `details` retain these fields too, but provider adapters
do not send that object to the model. Digest and anchor validation remain
unchanged; a stale plan still rejects before writing. A windowed `fs_read`
states its range and the offset that continues it for the same reason:
`has_more` and `total_lines` are in `details` and never reach the wire, so
without the statement a first window reads exactly like a whole file.
- **A successful edit returns the anchors of what it changed.** The digest
alone is half an edit prerequisite: every applied hunk shifts the anchors
around it, so a caller holding a fresh digest and no fresh `{line, anchor}`
pair has to read the file again before it can plan the next hunk.
`hashline.applied_regions` computes the post-image ranges each hunk landed
on — the pre-image position plus the net line change of every hunk starting
before it, which is exactly what `apply_placed` splices by — and
`edit_outcome` renders them under the same `Fresh anchors:` heading a
rejection uses. The two lines a success opens with are fixed: the hunk
count, then `digest:`, in that order. Bounded three ways: contexts that
touch are merged, a block over `max_fresh_anchor_bytes` becomes one line
naming the offset to read from, and an edit that leaves no lines says so.
- **`lines` are file text, and the display prefix is refused.** A model once
pasted `fs_read`'s `N:anchor|text` rendering into a hunk's `lines`, and the
edit applied verbatim. `run_edit` now runs `refuse_display_prefixes` on the
decoded hunks, before path resolution and before any anchor or digest
check, and rejects the whole edit when any replacement line starts with the
whole prefix: digits, a colon, eight lowercase hex digits, a pipe. The
shape is `hashline.display_prefix`, kept beside `render_line` so the two
cannot drift. A bare eight-hex token is not refused, since hex constants
begin real lines. `fs_write` takes one `content` string, so it has no
per-line paste to detect and is not checked. The write observer answers an
opaque `Option(String)`, so there is no structured diagnostic range for a
"this edit left an error here" hint, and none is attempted.
- **A successful write is enough to plan the next edit.** `write_outcome`
keeps its first line, then always adds `digest:` — one short line, and
`fs_edit` cannot be planned without it — then the whole written file as a
single region through the same `fresh_anchor_text`, renderer, heading and
cap as an edit. Before this, `fs_write` followed by `fs_edit` of the same
file always cost an `fs_read` in between, for content the harness held at
the moment it wrote it. `fs_write`'s `content` is a required *string*, so
a write is always text and there is no binary or image payload for anchors
to be wrong about. The size check decides on its own where it can: anchored
rendering is strictly larger than the content, so a file already at the cap
is never annotated to discover it does not fit. The two oversized wordings
differ because the advice does — an edit names the offset its regions begin
at, a write has the whole file and names the choice instead.
- **Annotate the post-image once, then slice it.** `edit_outcome` takes
`hashline.annotate(edited)` once and each region is a `drop`/`take` over
that list rendered by `hashline.render_lines` — the renderer `render` uses
for a window, so the text stays byte-identical to `fs_read`'s. Building a
`Window` per region instead re-split and re-annotated the whole file each
time, which cost about eight seconds on 199 hunks over a 648 KB file. The
cap is checked as the regions are rendered rather than on the finished
block, so an oversized edit stops at the region that crosses it instead of
rendering every region to discard them all.
- **Anchors depend only on line content** — first 8 hex of FNV-1a 64 over
the line's UTF-8 bytes, `anchor_version` 1, package-internal and never
stored durably. Unrelated edits never change a line's anchor, though they
may change its number, which is why refs carry both and both are checked.
The digest is the full 16 hex plus the byte length, so a
pre-image/post-image collision needs equal FNV-64 *and* equal length.
- **Path discipline is the sole boundary for the filesystem tools.**
`fs_*` run in the harness and never pass through the broker or the kernel
jail. `resolve_real` resolves symlinks component by component (at most
`max_link_follows` = 40) and the resolved path must land under the
equally-resolved workspace root, so neither `..` nor a symlink planted
inside the workspace reaches outside it.
- **Native reads follow the session's read policy (ADR-019).**
`fs.resolve_readable(filesystem, workspace, policy, path)` takes the same
`SandboxPolicy` the jail is built from, already widened by
`directory_access.widen`: the workspace and every `readable_roots` entry
are readable, `protected` is not, and nothing branches on the read scope.
`fs.resolve_for_read(ctx, path)` is the `Ctx` form; `fs_read`, `grep`'s
root and `working_directory` use it. `fs.read_path_outcome` words the
refusals: outside the roots names `permissions.readable_roots` (accepted by
`code_mode` and `bash`) and `/add-dir`; protected says no grant lifts it.
`search.glob` and `search.grep` take the protected list and skip a
protected subtree; a root outside the workspace renders absolute result
paths. Writes are untouched.
- **`protected` is enforced harness-side too, on the resolved path, and
no grant lifts it.** The base policy's never-writable list is masked by
bwrap for a *jailed* process, and the `fs_*` tools meet no jail — so
`fs_write` and `fs_edit` share one `resolve_for_write`, which applies
`resolve_real` and then refuses a target at or under any
`ctx.base_policy.protected` entry. The order is the property: a
workspace-internal symlink onto `.git/config` is only visible as such
once resolved. Containment alone left `.git/hooks/post-checkout`
writable by the model's own tool, which is code execution outside the
jail on the next checkout, and would have handed a vetted code-mode
loop more filesystem authority than its own jailed `proc.run` (issue
#105). Matching is by path component (`.gitx` is not under `.git`),
the same predicate `broker/policy.covered_by` and
`codemode/launch.covers` use, and it is applied to the entry's lexical
*and* resolved forms so a symlinked workspace root cannot separate the
two enforcement points. Grants are deliberately not consulted:
`policy.Grant` has no variant for `protected` and `apply_grant` never
writes the field. The refusal is in band as `PathError.ProtectedPath`,
opening `permission denied:` and naming the entry that matched, with
`details.error = "protected_path"`. Reads are refused the same way
(ADR-019): `resolve_readable` takes the session policy and refuses a
target under any `protected` entry, so the harness reads no more than the
jail. The one exception is the session's blob root
(`fs.exempting_blob_root`, ADR-019): blob refs are read with `fs_read`, so
reads drop that entry while writes keep it. A read under
`broker/policy.jail_replaced_roots` (`/proc`, `/dev`, `/tmp`) outside the
workspace is refused as `PathError.JailReplaced`, because the jail mounts
its own there; `read_bytes` reads only regular files.
- **A `protected` list the jail would refuse fails the harness closed
too.** A non-absolute entry cannot be applied: `normalize` roots it at
`/`, where it covers nothing under any workspace, so a list written as
`protected: [".git"]` used to protect nothing while reading as though
it did. The jail refuses the same value loudly —
`broker/policy.validate` answers `RelativePath` and the clearance
never happens — and a harness that quietly permitted what the jail
refuses is the worse half of that pair. So `resolve_writable` checks
absoluteness *before* it resolves anything and refuses the write as
`PathError.ProtectionMisconfigured`, in band, naming the entry, with
`details.error = "protection_misconfigured"`. It refuses **any** path,
not just the one the entry meant to cover: what a misconfigured list
intended is exactly what cannot be recovered from it. Reads fail
closed on the same list.
`client/serve.base_policy_fault` closes the other end, refusing the
boot outright, so the in-band refusal is the backstop rather than the
operator's first notice.
- **One whole-file write behind both doors.** `fs.write_whole` creates
missing parents and then writes, and both `fs_write` and the code-mode
bridge's `fs.write` closure call it — so `new_dir/file.txt` means the
same thing whichever door a write came through. Two doors onto one
workspace disagreeing about that is a difference discoverable only by
hitting it.
- **A blob is established by a rename, never by a write.** Content
addressing is only worth something if nothing can be reached under an
address but the content it names, and a torn direct write leaves
exactly that — a partial file whose SHA-256 name vouches for the whole
of it, believed by every later reader. So `blob.write_addressed`
stages the bytes under `blob.temp_path` in the blob root (same
filesystem, so `rename(2)` is atomic) and renames them into place; a
crash between the two leaves a stray `.tmp` nothing reads. The staging
name carries a tag unique to one write — the tool call's own
`{op_id, step_id, source_index}` here, random bytes for
`client/codemode`'s `report.emit` — because two concurrent first
writes of identical bytes are precisely the pair an address cannot
separate, and on a shared staging name they interleave.
- **Replay safety is declared per tool and load-bearing.** `bash` is
`Never` — an arbitrary external effect must yield a synthetic interrupted
result on crash, never a re-execution. `grep`, `fs_read`, `fs_write`, and
`fs_edit` are `Safe`: a read, a read, an idempotent write, and a
digest-bound edit respectively. `history_search` is `Safe` too, and
needs no budget headroom with it: it clears nothing through the
broker, so a batch of them shares no ledger to be refused out of.
`remember` is `Never`: it mints a fresh entry id per admission, so a
replay would write the note twice and spend two of the lifetime
ceiling's slots. The synthetic interrupted result is the right answer —
saying it again is cheap.
- **`execution_mode` is a scheduling constraint**: `Exclusive` for bash,
write, edit, and `remember` (they may mutate something shared — for
`remember`, the memory session, whose writer lease two calls in one
batch would fight over for no reason a caller could see);
`Concurrent` for read and grep. A `Concurrent` tool's own declared
budget must agree with that tag: the broker pools `max_outstanding`
per `{op_id, step_id}` — the whole batch, not one call's fan-out
(`docs/adr/005-budget-pooling-granularity.md`) — so a `Concurrent`
tool sharing a batch with itself
needs headroom above `1` in that same ledger, or a second concurrent
call is refused `OutstandingCapReached` for no reason a caller can see
(issue #50; `grep.max_concurrent_searches` is the one declared to
date).
- **A search result is quoted history, and the fence is the mechanism.**
`history_search` hits are text some model wrote, possibly in another
session months ago, on its way into *this* model's context. The
rendering fences them, says above the fence that nothing inside it is
addressed to the reader, and breaks any backtick run in a snippet that
could close the fence early. A snippet that could close it would make
everything after it read as the harness talking.
- **A model cannot choose what memory it writes.** `remember` takes a title,
a description, a note and a kind from a closed list, and no entry type: the
host writes `memory/note` and nothing else.
- **`propose` is a seam over `client/proposals` with no path argument.**
`target` names one of the two files the host owns, `rules` (the default) or
- `config` (the session's `loom.toml`, protocol-change/091), and is
+ `config` (the session's `loom.toml`, protocol-change/089), and is
`Submission.target` (`Rules | Configuration`). `Proposals.configuration` is
the session's configuration path, which the description names so the model
can read the file first, and `None` drops the config sentence. It validates
argument bounds only; the host validates against the file and the queue
depth (64).
- **`history_search` takes `syntax`, `kind`, `before` and `around`.** `syntax`
is `words` (the default) or `raw`; `kind` filters; `before` pages; and, for
`read`, `around` (0-5) is clamped here and in storage. The seam is
`search: fn(Query)` and `read: fn(session, entry, around) ->
List(JsonValue)`, and the tool answers a bare object when `around` is 0 and
an oldest-first array otherwise. Each hit renders its kind and UTC date, a
zero-hit answer echoes the query and filters and advises OR variants, and
the tool description carries the prefix, no-stemming and OR guidance.
- **The limit clamp is the tool's, not the index's.** A limit reaches SQL
`LIMIT ?`, and SQLite reads a *negative* limit as unbounded — the
opposite of `storage`'s convention, where a non-positive limit returns
nothing. `clamp_limit` is what stops a limit computed by subtraction
from pulling the whole repository index into a context.
- **Blob writes are idempotent by construction.** Content addressing
(SHA-256) puts the same bytes at the same ref, so replaying a `Safe` tool
or re-running an identical command never duplicates storage. Output past
`overflow_threshold_bytes` (64 KiB) carries `{ref, size, head_excerpt,
tail_excerpt}` at `excerpt_bytes` (2 KiB) each.
- **`fs_read` is exempt from blob overflow.** Text windowed reads are its
bounding mechanism, and anchors inside an elided blob would defeat
hashline editing. Image blocks are bounded by the 8 MiB file limit and
remain inline for vision providers. Bash and grep output do overflow.
- **Environments are allowlist-constructed, never inherited.** `Ctx.env`
carries what the caller built; the helper drops anything absent from the
policy's `env_allow` even if the broker sent it.
- **Timeouts are clamped tool-side** — `default_timeout_ms` 120 s,
`max_timeout_ms` 600 s for bash; 60 s for grep.
- **A minted name's discriminating half has a constant width and no
model input.** A child's name is `sub:{parent}/{slug}-{digest}`: the
slug is the purpose, bounded, and decorative, while the digest is
sixteen hex characters over `{operation, minting step, source index}`
and is the whole of who owns the child. The split is what closes two
ways of colliding two minters onto one name. A discriminator appended
to a *slugged* field is erased by that field's cap — a production step
id is a 36-character UUID and the slug cap is 24, which is exactly how
a `-program` step suffix came to reach no name at all — and a
constant-width field has no cap left to be truncated against. And a
discriminator sharing a field with model text can be steered by
choosing the text, which is why the digest takes none. Lengthening the
slug cap fixes neither; `agency_test`'s
`a_step_slug_cannot_carry_a_discriminator_test` is the arithmetic.
- **A model never supplies its own identity, a strand name, or a
blackboard prefix.** `agent.caller` is built from `Ctx` alone, so a
model that names another strand in its arguments does not become it;
`agent_spawn` takes a *purpose* and the Agency mints
`sub:{parent}/{slug}-{digest}` from the call's own durable
coordinates; `agent_note` writes under `agent/{caller}/` and
`agent_notes` reads under `agent/`. Each closes a class rather than a
case: identity forgery, name squatting, and namespace escape.
- **The unit of waiting is the call, not the handle.** `agent_wait` takes
an array and the Agency waits it against one deadline. Declaring the
tool `Concurrent` is honest — it only reads — and under the shipped
`tool_execution: Parallel` it is consulted for real, so single-handle
waits in one batch do overlap. The array is still the unit, for three
reasons the setting cannot touch: a session may set `tool_execution`
back to `sequential` through the gateway config key; one `Exclusive`
sibling (`bash`, `fs_write`, `code_mode`) fences the whole batch and
brings the serial deadline windows back; and eight waits are eight
effect processes with eight intents, settlements and deadlines to
reconcile where the array needs one of each. Overlap makes the
degenerate case cheaper — it does not carry the fan-out story, and no
invariant here rests on it.
- **The agent tools ask the broker for nothing.** Their `requirements`
are the empty policy: no writable roots, no readable roots, no env,
network off. They touch no filesystem and spawn no process, so they
compose with any session base.
- **`code_mode` is `ReplayNever` and `Exclusive`, and both are
load-bearing.** A program's capability calls are arbitrary external
effects with neither a minted identifier to reconcile onto (as
`agent_spawn` has) nor a digest-bound pre-image (as `fs_edit` has), so
a crash mid-execution must synthesize an interrupted result rather
than run the program again. `Exclusive` is not only about workspace
mutation: the broker pools budget per `{op_id, step_id}`, so a
concurrent call in the same step would open that ledger with *its*
budget — and a satellite needs two outstanding effects to exist at
all. Background launch assigns a separate broker step and releases the
tool invocation after admission. `Exclusive` does not serialize the
remaining lifetime of admitted satellites.
- **The description indexes types; virtual reads return full declarations.**
`tools/codemode.type_surface_text` filters generated `type_surfaces` by
each offered seam's `allowed_imports`. `cap_scheme` applies the same
filter to whole `prelude.surfaces` blocks and generated MCP façades.
`cap/runtime` is on neither allowlist and remains undiscoverable. Public
types stay in the description because a program needs fields such as
`proc.Output.stdout` to use a returned value. Function signatures and
documentation are read on demand through `fs_read` at `cap://<module>`.
- **A code-mode result never implies a jail that was not applied.** The
seam hands back an `Enforcement` naming *both* jailed stages — the
hermetic build and the satellite node — as a record rather than a
list, so neither can go unmentioned. Each is either `Enforced`, whose
applied and `skipped` layers are separate fields so a layer the kernel
did not provide can never render as one it did, or `Unreported` with
the reason there is no report. A vetting rejection reports both stages
as unreported *saying vetting refused the program*, which is a
different statement from silence (issue #5).
- **A refusal is a repair brief.** Every violation vetting found is
listed in one pass with its rule, its offending construct, its byte
span where one exists, the **seam** it was judged against and that
seam's allowlist; compiler diagnostics cross verbatim. One round trip
per rule is exactly what in-band repair exists to avoid. Parse failures
show the token in source syntax and a bounded excerpt with a one-based
line, grapheme column and caret, while retaining the original byte offset.
Positions refer to the submitted source, including EOF; unusable offsets
keep the refusal without fabricated coordinates. A grouping-parenthesis
hint teaches valid Gleam braces, without weakening vetting or adding a
workaround for syntax the shipped compiler already accepts.
- **A submission is judged against exactly one seam, and it is the one
it named.** `CodeMode.seams` is what this host serves; the shell
resolves the call's `seam` argument against it, defaults an unnamed
submission to `seams.default`, and refuses an unserved or unknown name
in band naming what is on offer — never reinterpreting it as the other
seam, which in one direction is a refusal the model cannot act on and
in the other a widening nobody chose. Nothing classifies a submission
by reading its imports: the description would then be a claim about a
decision already taken.
- **The choice costs nothing where there is no choice.** The `seam`
property appears in the schema and the second import list in the
description only when this host serves more than one seam, and where
it does, the seams' shared imports are stated once rather than
duplicated. Tool bytes render ahead of the system prompt and are the
byte prefix of the provider's cached region, so every word is paid on
every request of every strand for the life of the session — but the
lists stay *in* the description rather than being deferred to the
rejection, because a model that has to guess an import surface pays a
whole wasted submission in output tokens, which is the dearer side of
that ledger.
- **A child keeps a communication floor.** An explicit `tools` list on
`agent_spawn` replaces the default, so `child_floor_tools` (`agent_note`,
`agent_send`) is added back, limited to what the parent holds and never
including `agent_spawn`. The Agency's `child_notice` then tells the child
its real tool set and that its final message is its result.
- **A result contract is a lower bound, refused loudly at both ends.** A
spawn may carry a `result_schema`; the child records the matching value
as an ordinary `agent_note` under `result_note_key`, and `Waited.Ready`
carries the verdict as a `TerminalResult` beside the prose `report` —
never instead of it, because prose is what a human and a reading model
want and typed JSON is what a program branching on `found.files` wants.
Three properties are load-bearing. A malformed schema is refused in the
shell, before the Agency is called, so the parent learns about its own
mistake in the turn it made it rather than after joining a child that
could never satisfy it. A mismatch always names both sides — the
schema in full and what actually arrived — because a refusal that says
only "did not match" costs the reader a round trip to learn what it
could have been told. And `NoResultAsked` is its own variant rather
than an empty `Result`, so a spawn that named no schema renders exactly
the text and exactly the details object it rendered before contracts
existed. Surplus fields in a result are not a failure: the contract
says what the child owes, not all it may say.
- **A code-mode policy refusal is raised once, for the whole execution,
and only from the run phase.** The shell asks `Ctx.raise_refusal` at
most once per call and re-executes at most once on a `Resume`; a second
refusal stands in band. Per-clearance would park inside a live
satellite — the program's own capability call times out long before a
human answers, the pooled wall deadline runs down while they decide,
and the node holds one outstanding effect throughout — and, worse for
consent, would ask a human about a `cap_call` no client rendered, since
an approval binds to the *tool call's* arguments (#65) and a
`code_mode` call's arguments are the program. The other two clearance
points raise nothing: the hermetic build composes with this execution's
grants already dropped, so no approval can widen it, and a capability
call refused inside a *running* program is refused after effects have
happened, which is precisely what `replay: tool.Never` says must never
be repeated. Both are argued where the value is built
(`client/codemode`).
- **`agent_send` is `ReplayNever`; the rest are `ReplaySafe`.** A send
mints a fresh entry id per admission, so a replay would deliver twice;
a spawn's name derives from persisted coordinates, so a replay
reconciles onto the same child.
## Model-based tests
`test/tools/todos_model_test.gleam` and `test/tools/tail_model_test.gleam` run
`statem` sequences beside the example tests. The todo test drives
`todos.apply` with commands that name tasks and phases by index modulo the
model's size (or by a loose spelling of the text), checks each answered board
(`todos.valid`, `single_active`, `promotion`, `distinct`, `replay`, `step`,
`codec`, `view`) and then compares it with an independent reference
(`todos.model`). The tail test keeps the whole stream as the model and the
cursors earlier answers returned inside the system, and checks `received`,
`cursor`, `span` (dropped plus returned bytes equal the stream suffix),
`bounded`, `boundary` (exactly an incomplete final character is held back),
`front` and `decodes` at capacities 8, 11 and 24. Each file also asserts that
its generator reaches the states the rules are about. Seeds and counts are
named constants; a failure prints the shrunk command list.
## Deep Docs
- [docs/architecture/code-mode.md](../../docs/architecture/code-mode.md) —
the pipeline behind `code_mode`, and what each of its layers confines.
- [packages/codemode/CLAUDE.md](../codemode/CLAUDE.md) — the far side of
the code-mode seam.
- [docs/architecture/effects.md](../../docs/architecture/effects.md) —
"Tools", and the plane this package sits in.
- [docs/spec-gaps.md](../../docs/spec-gaps.md) — "From WP-I (`tools`)": the
anchor hash choice, `execution_mode`, workspace-relative requirements,
the `fs_read` overflow exemption, harness-side filesystem tools, blob-ref
readability, the timeout ceiling, ripgrep-missing detection.
- [docs/architecture/advisor.md](../../docs/architecture/advisor.md) — the
far side of the `advise` seam: who may call it, what the harness does
with each verdict, and the guard that decides whether a block is
delivered, downgraded or dropped.
- [packages/broker/CLAUDE.md](../broker/CLAUDE.md) — the door every jailed
call goes through.
- [Root CLAUDE.md](../../CLAUDE.md) — repo ground rules and the doc graph.
## Resumed agent runs
`Agency.send` receives an optional positive continuation budget. `Started`
contains the new operation and optional absolute deadline; the tool receipt
also renders a waitable handle. Spawn and roster expose `deadline_ms`, and
wait/roster keep wire outcome `aborted` while reporting a recorded
`abort_reason` separately. `BudgetExpired`, `ParentFinished`, `Released` (the
parent session on another orchestrator was deleted, protocol-change/084) and
`TokenBudgetSpent` (the child used its spawner's `child_token_budget`) are
internal outcome variants; unknown aborts remain `Aborted`. Tool guidance distinguishes
continuing a strand with `agent_send` from observing one operation with
`agent_wait`, and makes clear that the old handle remains historical.
## Async code-mode modes (protocol 048)
`codemode.CodeMode.background` optionally supplies `Background.launch` and
`interact`. When present, `code_mode` accepts `run` (the existing default),
`launch`, `send`, `check`, `join`, and `cancel`. A launch captures the ordinary
`Request`; interactions name an owner-checked execution handle. No interaction
can replace source or widen grants. `execution_value` renders terminal pipeline
outcomes. Generated `prelude.gleam` includes the new capability signatures and
remains digest-gated against `cap` source.
`send` accepts an optional endpoint name, defaulting to `default`. `check`
retains the flat execution record and adds readiness, registered endpoints,
and optional volatile progress/delivery observations. The host refuses sends
before readiness. `tool.Exclusive` covers each invocation, so a returned launch
handle does not prevent later tools or backgrounds from running concurrently.
## Durable code-mode notes
The code_mode description adds durable-analysis guidance only when an
offer admits cap/notes. Generated prelude signatures expose its put/get/list
API, and the guidance distinguishes session blackboard notes, evictable KV
scratch, and artifacts. The filesystem API documents conditional read-only
note:// JSON views. Existing agent_note and agent_notes retain their names,
full returned keys, and storage semantics.
See [protocol 045](../../protocol-change/045-code-mode-notes.md).
## Code-mode utilities
`codemode_recipes` holds complete program strings included in the tool
description only when the corresponding imports are offered. Client live tests
execute those exact strings and compare them with docs/examples; `shell_probes`
(`docs/examples/shell_probes.gleam`) is the `cap/proc` recipe, shown when an
offer admits every module it imports. The generated
prelude advertises report JSON conversion and bounded strand.map.
The `lsp_sql` recipe is the one that moved furthest: it is now the wrapper
form — `collect_seeds` and `references`, no `Plan`, `Target` or `RowDecoder` —
and its required-import list moved with it (`gleam/option` out; `gleam/int`
and `gleam/list` in), since that list decides whether the recipe is shown at
all. The codemode end-to-end test compiles the exact string, so the program
the description teaches is known to compile rather than merely to render.
## Virtual `fs_read` namespaces
`fs.Scheme` registers a read-only namespace by name, description sentence,
and total resolver. `fs.read_tool_with` routes `://` references before file
resolution. An unknown namespace returns `unknown_scheme`; a resolver returns
typed not-found, unavailable, or malformed refusals. A virtual answer is
- windowed by line and bounded inline, without a file digest or edit anchors.
+ windowed by line and bounded inline (clamping multi-line windows to fit within
+ `blob.overflow_threshold_bytes` and setting `has_more` so spilled `blob://`
+ reads succeed with the default 2000-line limit), without a file digest or edit
+ anchors.
Ordinary paths still use `resolve_invocation`, and ordinary images still return
- `ToolResultImage` blocks.
+ `ToolResultImage` blocks. `fs.blob_scheme()` is registered in
+ `client/contributions` alongside `cap://` and `job://`.
`codemode.cap_scheme` projects only offered-seam allowlists into `cap://`.
The index and full module blocks come from the same generated prelude artifact;
configured MCP façades are read from their host-generated surfaces. `job.scheme`
uses the caller's own `Ctx` for `job://` list and zero-wait poll, preserving
strand ownership. The poll starts at zero cursors and advances no state, so
the `fs_read` wrapper remains replay-safe.
## Explicit session-lifetime background jobs
Protocol-change/058 records the lifetime contract. Finite jobs retain their
existing default and fixed deadline. Bash `mode: "background", lifetime:
"session"` and `cap/job.start_for_session` explicitly request no wall deadline.
Code-mode callers declare `permissions.wall_s: 0`; the launching action must
receive the missing wall grant before execution, and the job captures it.
Zero job wall/deadline denotes this authorized lifetime. Clearance and drain
remain bounded, other resource limits remain active, and session shutdown,
owner kill or originating-operation abort cancels the execution. Quiet waiting
has no completion or heartbeat wake unless the caller explicitly asks for the
existing idle heartbeat. A VM restart loses the job and never replays it.
A subagent may not request `lifetime: "session"`. `bash.requested_lifetime`
refuses it ahead of every other check, so no approval is raised: a subagent's
jobs end with it, and the zero wall would only buy an operator prompt that the
finite default never raises. The test is `agent.is_subagent` on `Ctx.strand`,
the same `sub:` prefix the Agency mints and the runtime routes on
(`client/agency.subagent_prefix` now re-exports `agent.subagent_prefix`). The
`lifetime` description tells every caller that test runs, builds and anything
expected to finish use the default.
## Structured schedule responses (protocol 057)
`tools/schedule.Created` and `Listed` carry `Cadence` in addition to their
display sentence. `Interval` and `Cron` retain `Expiry`; `OneShot` retains the
resolved Unix second. The client supplies these from its admitted timing, and
code mode projects them without parsing the human-readable `when` field.
Generated capability declarations expose the satellite's typed responses and
identity helpers from the same compiled package interface.
See [protocol 057](../../protocol-change/057-typed-capability-results.md).
## Code-mode compiler guidance
The `code_mode` description states that warnings fail the build, except that a
build whose only diagnostics are unused imports, arguments or bindings is
repaired by the harness and the edits are listed in the result. It tells the
agent to use qualified standard-library helpers, to repair the named lines on a
compile failure and keep the rest, and to fix and rerun a program that fails at
runtime rather than move its remaining probes to other tools. It keeps the
compiler gate intact.