Paths (2026-09-02). The ten crates were merged into one package (
docs/MERGE-PLAN.md); everycrates/iota-*/…reference below has been mapped to itssrc/<module>/…ortests/<area>/…home. Thefile:linefigures were read before the merge and still locate the same code (files moved, bodies did not).
Seeded from POLICY.md §3 (approved fixes) and §4 (approved intentional divergences), plus the design-level decisions recorded in ARCHITECTURE.md. This file is copied to rust/docs/DIVERGENCES.md by the integration package and kept there.
Sources, not links.
POLICY.mdwas the porting-time approval list; it never entered this repository, so nobody can open it today.POLICY §3(approved fixes),POLICY §4(approved divergences) andPOLICY OUT(out of scope for the port) below are records of where a row was approved, kept because they say WHY it was — not files to look up. Citing this ledger: writeDIVERGENCES <id>(DIVERGENCES I-03,DIVERGENCES X-73), the form the code and tests already use. TheCOMPAT <id>form indocs/history/MIGRATION-ROADMAP.md§2 was dropped with the rename it served (docs/ROADMAP.md§4).
| id | area | Go behaviour | Rust behaviour | reason |
|---|---|---|---|---|
| F-01 | defer_mode capability check | ResolveDeferMode(pc.DeferMode, pc.Type, …) compares the RAW type: field (empty when the entry has no type:), so providers: anthropic: {defer_mode: reference} degrades to normal with a double-space warning |
the RESOLVED ProviderKind decides, and a mode the dialect cannot speak is refused by Config::load (models.<name>: defer_mode … does not apply to provider type …) instead of degrading to normal |
Go bug; POLICY §3. Amended 2026-09-10 by the three-layer config (brain page config-three-layers): defer_mode moved to models:, where the provider — and therefore the dialect — is already fixed, so the check happens once, at load. |
| F-02 | --mcp "" / whitespace |
panics (parts[0] on an empty slice) |
mcp::config::McpFlagError::EmptyFlag → ArgsError::McpFlag → Error: --mcp: empty server specification, exit 1 |
POLICY §3 |
| F-03 | -m "" |
indistinguishable from no -m; drops into the TUI |
Error: --message must not be empty, exit 1 |
POLICY §3 |
| F-04 | chat-completions sparse tool-call indices | iterates 0..len(map); a gap silently drops later calls |
iterate the sorted key set (BTreeMap<u64, _>) |
POLICY §3 |
| F-05 | "error": null in a stream chunk / error body |
json.RawMessage keeps the literal null → treated as an in-band error / envelope |
Option<Box<RawValue>> with null treated as ABSENT |
POLICY §3 |
| F-06 | read_file single oversized line |
row cut to 65536 bytes then …\n appended, then rejected by the output check → inescapable empty window |
row cut to CODE_MAX_OUTPUT - 4 bytes + …\n so it fits |
POLICY §3 |
| F-07 | MCP config-file server order | Go map iteration (random) → _2 segment suffix can swap between runs |
config-file servers sorted by name; segments assigned in config order (flags after) | POLICY §3 / §2 determinism |
| F-08 | delegate agent validation order | first failing agent is whichever Go's map visits first | agents validated in name order (BTreeMap) |
POLICY §3. Moot since X-01 — the toolset this fixed is gone |
| F-09 | unparseable tool-call arguments |
empty map, error ignored | same (empty map) — no change, listed for completeness | POLICY §3 |
Rows here are the opposite of every other row on this page: the Rust binary drifted from Go by ACCIDENT, the drift reached users, and the fix is a return to Go's behaviour rather than a decision about it. They are recorded because a released binary behaved this way, so "iota did X to my files" has an answer with a version attached to it.
| id | area | Go behaviour | Rust behaviour | reason |
|---|---|---|---|---|
| R-01 | edit_file and non-UTF-8 files (fixed 2026-09-14; shipped broken in 0.1.0 and 0.2.0) |
byte-faithful for free (tool/code.go:740-755): a Go string holds arbitrary bytes, so string(data) → strings.Count/strings.Replace → []byte(updated) counts, matches and writes back BYTES, and a file in Latin-1, Shift-JIS or GBK survives an edit untouched outside the replaced span |
was String::from_utf8_lossy(&data) decoded the whole file and updated.as_bytes() wrote the decoded result back (src/tool/builtins/code/tools.rs:732,753), so every byte in the file that is not valid UTF-8 became EF BF BD — across the WHOLE file, not just the edited span — and the unified diff, built from the same lossy buffer, showed the user nothing. Now the count, the uniqueness check and the replacement all run on &[u8] via memchr::memmem and the original bytes are written back; the needle is old_string.as_bytes(), which a JSON string already supplies. Only the three TEXT surfaces stay lossy — the diff artifact, the numbered snippet and the line number — because a terminal renders text; both sides of the diff come from the same conversion, so an undecodable byte outside the edit cancels out instead of inventing a hunk |
a port regression, not a decision. edit_file still has no binary gate of its own (neither does Go's): read_file's NUL sniff plus the read ledger is the gate, and a file whose first NUL is past the 8000-byte sniff window is editable — now byte-faithfully. Pinned by tests/tool/code.rs's five test_code_edit_file_* tests (Latin-1, Shift-JIS + GBK, a real EF BF BD beside a lone 0xFF, a multi-byte UTF-8 old_string, the binary policy) and tools.rs::splice_copies_every_other_byte_through. Roadmap §3 #1 |
| id | area | Go behaviour | Rust behaviour | reason |
|---|---|---|---|---|
| I-01 | YAML booleans | bool FIELDS accept true/yes/on (yaml.v3); the toolset-disable rule (SetDisabled) only matches a plain lowercase false |
every bool field AND the disable rule accept any YAML 1.1 spelling (true/yes/on, false/no/off, any case, quoted or plain) via tool::yaml11 |
POLICY §4 |
| I-02 | HTTP response-header timeout | 2 min only on the -l path (nil http client); -m uses the recording transport without one |
uniform 2-minute response-header timeout on EVERY client (wire::client::HEADER_TIMEOUT); never a whole-request timeout |
POLICY §4 |
| I-03 | SIGINT/SIGTERM | context.Background(); the process dies without closing MCP servers |
signal → CancellationToken; run cancelled, MCP closed, exit 130 — an interactive run too (SIGTERM or SIGHUP: raw mode makes SIGINT a key, and Ctrl+D still exits 0), after the same wind-down as a normal exit; text prints nothing, JSON prints the report with "error": "interrupted"; a second SIGTERM (or SIGINT, while armed) restores the terminal and exits 130 at once — a second SIGHUP does not |
POLICY §4 |
| I-04 | runtime error output | cobra prints Error: <msg> PLUS the usage block |
Error: <msg> only; clap prints usage only for argument errors (exit 2) |
POLICY §4 |
| I-05 | MCP connect failures on -m |
silent (events drained, logf nil) | Warning: mcp server <name>: <err> on stderr (full error text, including the captured stderr appendix); stdio stderr capture capped at 64 KiB (its LAST 64 KiB since 2026-10-15, X-78) |
POLICY §4 |
| I-06 | delegated children's images | saved to ~/.iota/images silently, paths discarded |
children never save images (images_dir = None) |
POLICY §4 |
| I-07 | Windows | supported | amended 2026-09-11 (W1): no longer a divergence of PLATFORM. The crate compiles for *-pc-windows-* and CI runs fmt, clippy, rustdoc and cargo test on windows-latest beside macOS and Linux. What Windows does not have yet is I-09 |
POLICY §4 |
| I-09 | Windows capabilities | supported | amended 2026-09-12 (W2): Windows builds and runs, and now has the shell toolset too (X-17). What it does NOT have is (1) a sandbox: exec::available() is false through sandbox/other.rs, so every shell call runs unsandboxed with the user's full permissions and asks for approval unless auto_run: true waives it — the description says so (BASH_DESC_UNSANDBOXED), and the two shipped implementations in this space (Codex, Gemini CLI) both cost far more than a v1 is worth; (2) OSC 11 background detect: ui::runtime::osc::open_tty declines outright rather than guess at CONIN$/CONOUT$, so the theme takes its documented default, dark. Also platform-shaped but not user-visible: exec::kill_group is a no-op there (a Job Object is addressed by handle, not pid — see src/shell/exec.rs), and a Started whose whole job outlives the leader is waited on until the tree drains, not just the leader. The set refuses to build (one warning, no tool) only where no interpreter exists at all |
W1, 2026-09-11; W2, 2026-09-12 |
| I-08 | chat-level retry | none on Once (interactive only) |
none — parity; only the wire client's 2 extra attempts | POLICY §4 |
| id | area | Go behaviour | Rust behaviour | reason |
|---|---|---|---|---|
| D-01 | rmcp custom headers | Header.Set on every request OVERRIDES SDK headers of the same name |
rmcp APPENDS custom headers; accept, mcp-session-id, last-event-id are REJECTED → that server's status is connect failed: Header name 'accept' is reserved and conflicts with default headers (per-server, non-fatal). Authorization is passed through custom_headers (never auth_header, which prefixes Bearer ) |
rmcp 3.1.4 semantics (http_header.rs:20-43) |
| D-02 | MCP handshake | go-sdk tries server/discover @2026-07-28 then initialize @2025-11-25 |
rmcp ServiceExt::serve() uses the legacy initialize handshake by default (ClientLifecycleMode::Initialize, client.rs:688-707); clientInfo {name:"iota",version:"1.0.0"}; capabilities = rmcp ClientCapabilities::default() (no roots.listChanged). rmcp 3.1.4 DOES implement server/discover (ClientLifecycleMode::{Discover, Auto}, serve_client_with_lifecycle, client.rs:628-800); Auto (discover → initialize fallback) would give go-sdk parity and can be switched on later with one call |
default kept: the handshake runs inside the caller's future before serve_inner spawns, so the connect-timeout child-kill reasoning (D-04) holds; mapping notes allow initialize-only |
| D-03 | stdio session close | close stdin → 5 s → SIGTERM → 5 s → SIGKILL | RunningService::cancel() → rmcp graceful_shutdown: close stdin → 3 s → SIGKILL; whole close bounded by 10 s; no SIGTERM stage |
rmcp lifecycle; no observable headless effect |
| D-04 | connect timeout child reaping | the never-cancelled command context leaves the child until close | dropping the timed-out connect future drops TokioChildProcess → child killed immediately (ChildWithCleanup::drop) |
strictly cleaner |
| D-05 | after Manager::close() |
Tools() still advertises; CallTool would panic |
tool index cleared; call_tool → unknown tool: <name>; tools() empty |
POLICY §3 spirit (no panic) |
| D-06 | MCP servers per Connect order |
segments assigned in connect-completion order | config order (see F-07) | determinism |
| D-07 | serde_json map key order |
Go marshals map[string]any with sorted keys |
serde_json::Map is BTreeMap-backed (no preserve_order) → sorted keys; identical |
none (listed because the proposals disagreed) |
| D-08 | JSON HTML escaping | Go request bodies escape <,>,& as <… |
serde_json does not; semantically identical JSON; golden tests compare parsed values | serde_json |
| D-09 | float formatting in errors | Go %v |
go_float reproduces %v for the CLI-visible ranges (3.5, 2, 0.7); extreme exponent forms differ |
approximation |
| D-10 | %q quoting |
Go quoting rules | format!("{:?}") — identical for ASCII; some non-ASCII escapes differ cosmetically |
approximation |
| D-11 | Message.interrupted, Message.usage |
present (session persistence) | restored in phase 2 slice 1: both fields are back on provider::model::Message in Go's field order (provider/provider.go:46,58 → src/provider/model.rs:161,169) so a session line round-trips losslessly. No dialect reads or writes either; interrupted is persistence-only headlessly (the headless loop has no interrupt seam that keeps a partial), usage is attached to every persisted assistant message (D-55) |
session round-trip fidelity |
| D-12 | interactive-only tool capabilities | HeaderSummary, Presentation implemented by shell/code/delegate tools; headerPath/headerCommand helpers; display tool names; the approval detail falls back to a sorted-key argument digest (chat.go:461-480) |
CLOSED by T-30 (tests completed T3/WP68, 2026-09-02). header_summary is implemented by every built-in that has something to say — shell (src/tool/builtins/shell.rs:170, the command IS the call, an explicit cwd folded into cd <path> && <cmd>), the four file tools (src/tool/builtins/code/tools.rs:182,231,290,340 over CodeSet::header_arg) and the delegator (src/chat/delegator.rs:154) — and presentation() returns Expanded for the writers. The whole headerfmt ladder is ported in src/tool/fmt.rs: tool_call_header (:24), tool_call_detail (:39, the sorted-key digest with 3 args / 15-rune values / … +N args), display_tool_name (:64), header_path (:145) and header_command (:223); routing goes through dispatch.rs:135,216, defer/mod.rs, defer/mode.rs. QuietHost::ask_approval therefore receives a REAL detail whenever the dispatcher has one. tool/codepath_test.go's seven are ported BY NAME, plus chat/toolname_test.go:5, the three toolcall_test.go ports and chat/approval_test.go :138/:171 |
the ONE surviving divergence is the ruler: fmt.rs measures RUNES where Go's headerfmt measured display columns via uniseg, so the TUI-free build carries no width crate (documented in the module) |
| D-13 | image edits / reference images (images provider) |
/images/edits multipart or JSON when the last user message has image attachments |
CLOSED (T3, WP64, 2026-09-02). Both forms ship: multipart through the hand-rolled mime/multipart.Writer byte-twin (src/llm/multipart.rs, transport decision T-41) and JSON through Images::edit_json, selected by json_edits (config key / /model switch / session meta). ImagesProvider::chat builds ImagesEditRequest from the trailing user turn's image/* attachments; EDIT_UNAVAILABLE and its test are gone. TestImagesGoldenEdit (:52) and TestImagesJSONEdits (:203) ported |
the ImagenProvider referenceImages exception recorded here is now the norm rather than an exception |
| D-14 | Responses input/Google contents with zero items |
null (nil slice) |
[] |
unreachable headlessly; [] is what the APIs accept |
| D-15 | toolset config decode error text | yaml.v3 error text after config must be a mapping (…): |
serde_norway's error text | different YAML library; tests assert only that the set is skipped with one warning |
| D-16 | shell spawn error text | chdir /x: no such file or directory |
Rust io::Error text (No such file or directory (os error 2)) after failed to run: |
OS-level text |
| D-17 | .gitignore semantics |
sabhiram/go-gitignore approximation (? literal, x/*.ext auto-anchored, vendor/ filters files but does not prune the dir) |
real git semantics via the ignore crate (vendor/ prunes the dir); vendor/dep.go exclusion (the only pinned case) holds |
better fidelity to git; documented |
| D-18 | grep pattern engine | invalid regular expression: error parsing regexp: … (Go RE2); \w/\d/\s/\b are ASCII-only |
invalid regular expression: <regex crate error>; prefix identical, detail differs; RE2 vs regex syntax differences (\C, (?U), \z); the regex crate's \w, \d, \s, \b are Unicode-aware, so grep can match more on non-ASCII text (e.g. \w+ spans café). NOT mitigated with RegexBuilder::unicode(false): on regex::Regex that makes any pattern that could match non-UTF-8 (., negated classes) FAIL to compile, a far larger divergence |
different engine; documented, not patched |
| D-19 | Artifact side channel (diff from edit/write_file, note from delegate) |
posted into a context slot; discarded headlessly | CLOSED by T-35. RunCtx.artifact carries the slot (src/tool/context.rs); edit_file/write_file post the udiff and the delegate posts its note; post_artifact with no slot is a silent no-op |
tests/repl/artifact.rs:78 mutating_code_tools_post_their_diff_as_an_artifact (tool/code_test.go) and src/tool/mod.rs::test_post_artifact_headless_no_op (tool/ask_test.go:85 TestArtifactSideChannel — the anchor was corrected in WP68; it previously cited a tool/tool_test.go that does not exist) |
| D-20 | tool-call stream observer, image-partial observer, Provider::StreamChat (no-tools streaming), SetToolCallObserver |
present for the TUI | CLOSED for BOTH observers — tool-call deltas by T-11 (WP55), image partials by T3 (WP63 streaming dialects, WP64 the unary images dialect, 2026-09-02). StreamSink::image_partial (with the ReasoningGate forwarder) carries decoded response.image_generation_call.partial_image frames out of openresponses; Provider::as_image_partial_provider() -> Option<&dyn ImagePartialProvider> is the unary dialects' call-scoped equivalent of Go's SetImagePartialObserver (T-51), and its presence is what puts stream:true, partial_images:1 on the request. repl::turn::PartialWatcher is watchImagePartials' twin (first frame raises the image widget, every frame renders at 64×12 into Ui::call_body, an undecodable frame is dropped). TestOpenResponsesImagePartials (:461), TestOpenResponsesComposingObserverLearnsName (:516) and the observer halves of images_test.go :310/:366 are ported |
TWO residues, both in §D: Provider.StreamChat (no-tools text streaming — T-36) and the INSTALL/DETACH shape of SetToolCallObserver, which has no Rust twin because a sink is a call ARGUMENT (IDIOM; the delivery law is pinned per dialect by tests/provider/tool_delta.rs) |
| D-21 | -l listing, unconfigured providers |
only configured providers with a key are listed | superseded by X-13: -l is gone. iota list providers shows every configured endpoint and says where its key comes from ([key: OPENAI_API_KEY] / [key: config] / [no key: set …]) instead of hiding the ones without one |
the listing's job is to explain a failing run, and a provider with no key is exactly what such a run trips over |
| D-23 | -S/--system-input, --no-save, blank --resume |
accepted | Error: flag <X> is not supported in headless mode, exit 1, for -S/--system-input and --no-save. --resume split in phase 2 slice 1: --resume=<id> with -m is now supported (D-41); only the BLANK form (bare --resume, or --resume="") is rejected, with its own text (D-42). Check order unchanged (src/cmd/args.rs:137-150) — -S first, then --no-save, then the blank resume, so --no-save --resume=x still reports --no-save |
POLICY OUT (picker), partial support added |
| D-24 | second positional argument | cobra accepts between 0 and 1 arg(s), received N |
clap's own error text, exit 2 | argument errors are clap-owned (I-04) |
| D-25 | image file names | time.Now().Format("20060102-150405") local time |
jiff::Zoned::now().strftime("%Y%m%d-%H%M%S") local time (tz-system + the system zoneinfo on Unix; on Windows, which has no system tz database, the copy jiff bundles into the binary via tzdb-bundle-platform); UTC only if a Unix device has no zoneinfo |
parity kept; noted |
| D-26 | ~ in write: extras, ${userHome}, skill roots, config discovery |
os.UserHomeDir ($HOME, error when unset) |
HostDirs.home = $HOME (unix), None when unset/empty; NEVER getpwuid |
parity + test injection |
| D-27 | AGENTS.md overlay freshness | Overlay.Refresh() per turn (interactive) |
CLOSED by T-37. Overlay::refresh() (src/agents/mod.rs:222) re-probes the chain's and the skill roots' stamps before every send and rebuilds only what changed; the run loop prints AGENTS.md reloaded (%d files) / Skills reloaded (%d skill(s)) / ⚠ %s ONLY on a real change and re-issues the completion table with the rebuilt per-skill rows (src/repl/run.rs:723-742). /skills calls refresh() first and discards its flags — the notices belong to the message path (run.go:924) |
the three freshness tests (agentsmd_test.go:126, skills_test.go:238,289), tests/repl/commands.rs:864 overlay_refresh_notices_fire_only_on_change and tests/repl/skills.rs::a_catalog_change_re_issues_the_table_with_the_new_row |
| D-29 | LlmError::ImageStreamIncomplete path |
reachable via the interactive partial-image observer | CLOSED (T3, WP63/WP64, 2026-09-02): reachable through the progressive-frame path — a watched turn asks for stream:true, partial_images:1, and a stream that ends without a .completed frame raises it |
TestImagesStreamWithoutCompletion (kept under G14 when the path was defensive-only) now covers a REACHABLE path |
| D-30 | reqwest redirect policy | Go: up to 10 redirects, 301/302 POST→GET | reqwest default (10 redirects, same method/body rules per RFC) | acceptable; no deployment relies on it |
| D-31 | TurnBudget shared-cap exhaustion by a child |
same | same — the child's first take() fails with the shared-cap error inside delegation to "x" failed: …, then the parent's next round fails with the same error |
parity, listed for clarity |
| D-32 | glob pattern syntax |
doublestar ([^x] and [!x] negation, ** anywhere in a segment, its own rejection set) |
globset (literal_separator(true) after the **/ prefix rule): [!x] only ([^x] is a literal ^ class), ** is special only as a whole segment (a/**/b, **/x), and invalid glob pattern: … fires on globset's own rejection set. Guaranteed subset: *, ?, [abc]/[a-z]/[!x], {a,b}, leading **/, and the no-slash → **/ prefix rule (every case TestCodeGlob pins) |
different engine; documented |
| D-33 | MCP tool call that returns an SEP-2322 input_required or SEP-2663 task result |
go-sdk returns the raw result (empty content) | Peer::call_tool_once is used (never call_tool, which would loop MRTR rounds through a handler that cannot answer); InputRequired/Task responses become the tool-result error texts McpError::{MRTR_UNSUPPORTED, TASK_UNSUPPORTED} (Error calling tool: …) |
rmcp 3.1.4 API; deterministic error instead of an empty result |
| D-34 | failed to resolve working directory: {e} (agent mode) |
os.Getwd()'s error, reported at root.go:207 |
HostDirs resolved the cwd once at the binary edge and swallowed the error, so the message re-asks the OS for its text; if that second call unexpectedly succeeds the text is working directory is unavailable |
one-shot directory resolution (ARCHITECTURE §7); unreachable in practice |
| D-35 | interactive ask toolset |
buildDispatcher enables ask whenever env.Interact != nil |
HEADLESSLY the seam is never created, so assemble::build_dispatcher never enables ask and the config key contributes no tools — Go parity (root.go:588-592). The interactive build DOES enable it (src/cmd/assemble.rs:93, env.interactor.is_some() && !set_disabled(…, "ask")); src/tool/builtins/ask.rs implements choose and confirm with Presentation::Surface and never batches them |
the row now records the headless half only; tool/ask_test.go is ported in full (src/tool/builtins/ask.rs + tests/tool/framework.rs:736) |
| D-36 | relative $XDG_CACHE_HOME (Linux sandbox writable paths) |
os.UserCacheDir errors on a relative value, so shell.go:130 leaves the cache dir OUT of the bwrap writable-path set |
cache_dir treats a relative $XDG_CACHE_HOME as unset and falls back to home/.cache, which joins the writable set — one extra writable path, only under a misconfigured environment |
CONTRACTS §2.9 rule ("absolute, else home/.cache") |
| D-37 | invalid UTF-8 in system_file, the AGENTS.md chain, SKILL.md and load_skill file reads |
string(data) keeps the bytes verbatim (they reach the provider unchanged) |
String::from_utf8_lossy — invalid sequences become U+FFFD before the text reaches the prompt/overlay |
Rust String cannot hold invalid UTF-8; only files that are not valid UTF-8 are affected |
| D-38 | skill frontmatter name:/description: with a non-scalar value (name: [a]) |
yaml.v3's strict decode fails with a decode-error text | the value reads as absent → frontmatter is missing required field "name" (SkillError::MissingName); scalars still stringify (name: 123 → "123", yaml.v3 parity) |
same rejection (skill skipped with one warning), different error class/text — D-15 class (different YAML library) |
| D-39 | partial results beside a provider error | a mid-stream failure returns the partial content/reasoning BESIDE the error (openai.go:213-215, google.go:314-316); a chat response with usage but no choices records lastUsage before failing |
Err(ProviderError::Stream(..)) / NoChoices alone — the partial content/reasoning/usage are dropped (the sink still saw every flushed byte and the reasoning gate closes first, so the sink traffic is identical) |
Result<RoundResult, ProviderError> has no channel for both; the only headless caller discards them (chat.go:311) |
| D-40 | config-file YAML parse-warning text | Warning: config {path}: {yaml.v3 error text} (file ignored) |
the same warning frame byte-for-byte; the embedded parse-error text is serde_norway's | different YAML library (D-15 class) |
Added by the session slice (src/session, the --resume stage in src/cmd/mod.rs, and
the history/delta amendment to headless). Every Rust file:line below was read in the shipped code.
| id | area | Go behaviour | Rust behaviour | reason |
|---|---|---|---|---|
| D-41 | headless --resume=<id|prefix> together with -m |
the -m branch returns at cmd/root.go:259-268 before any session code, so Go silently IGNORES --resume (and --no-save) on the -m path: no writer, no disk writes |
the session is resolved (chat/session.go:288-305 rules), loaded, its model and tuning replayed (cmd/root.go:323-333), the turn appended and persisted — the whole stage is src/cmd/mod.rs:178-222, placed after Go's interactive branch and before the MCP connect, so --resume=<id> without -m still ends in interactive mode is not available… and a bad session id never spawns a server. Bare -m alone remains stateless — Go parity, pinned by bare_m_run_writes_nothing_under_home (tests/cmd/session.rs), which asserts nothing appears under HOME |
phase 2 goal: schema-level interoperability with Go bundles; Go has no headless persistence to be a parity anchor |
| D-42 | blank --resume (bare — clap default_missing_value = " " — or --resume="") |
opens the interactive session picker (chat.PickSession, cmd/root.go:301-316) |
Error: --resume requires a session id in headless mode (--resume=<id>), exit 1 (src/cmd/args.rs:144-148, src/cmd/resolve.rs:188-191). The old D-23 text would now be false for the valued form; what is missing is the picker, not the flag |
the picker is a TUI surface (POLICY OUT); the message must stay true |
| D-43 | when a headless turn is persisted | interactive Go persists after every completed turn, after an interrupt that keeps a partial (interrupted: true), and for the /save backlog (chat/run.go:221-229); a FAILED turn is rolled back past the user message and persists nothing |
success only (src/cmd/mod.rs:272-277): one append_messages batch, one fsync, one meta rewrite — Go's per-turn I/O shape, since a headless run IS exactly one turn. An error or a cancellation persists nothing. Warning: failed to save session: {e} is byte-exact and non-fatal |
headless has no finalizeInterrupt seam; a half-turn a Go resume would replay is worse than none. The LOADER still handles every bundle shape Go's interrupt path produces, including a log ending in tool-result messages (resume_a_log_ending_in_tool_results) |
| D-44 | the resume banner | fmt.Printf("Resumed session %s (%d messages)\n\n", …) to stdout, before the TUI Program (cmd/root.go:334) |
the same text to stderr with a single \n (src/cmd/mod.rs:215-218) |
headless stdout is contractually the reply alone (text) or the JSON report; the trailing blank line separated a TUI frame that does not exist. The text is kept byte-exact — with prefix resolution the user must see which session was picked |
| D-45 | meta.json rewrite |
os.WriteFile(path, data, 0644) in place — not atomic, not fsynced (chat/session.go:488); a crash mid-rewrite can leave an unloadable meta.json beside a perfectly intact messages.jsonl |
write meta.json.tmp (0644) then rename it over meta.json (src/session/meta.rs:109-117). Verified safe against Go: findSessionDir keys on meta.json and listBucket iterates directories, so a stray temp file is invisible to Go's locator, lister and loader (probe: a planted meta.json.tmp left chat.LoadSession, ListSessions and ResolveSessionID all working). The name is fixed, so nothing accumulates |
a bundle whose log survived must not be lost to a torn meta write; invisible to Go |
| D-46 | unknown keys in meta.json |
ignored on read and DROPPED on a Go rewrite (plain json.Unmarshal into sessionMeta + MarshalIndent) |
preserved: SessionMeta carries #[serde(flatten)] extra: JsonObject (src/session/meta.rs:93-95), so a key a future Go build wrote survives a Rust rewrite. Rust NEVER invents a key of its own here (Go would drop it). Consequence: key ORDER differs once extras exist — the preserved keys are appended after the modelled ones, and an extra's own nested object members come back alphabetised (JsonObject is serde_json::Map, i.e. a BTreeMap; the cross-binary proof saw {"note":…,"n":42} rewritten as {"n":42,"note":…}) — and meta.json byte-equality across writers is explicitly NOT the bar — "Go loads it and loses nothing" is |
asymmetry required by the slice policy: a Rust rewrite must not damage a Go-written bundle |
| D-47 | a log record whose role is neither a known role nor "compaction" |
carried VERBATIM into the loaded view as a provider.Message with that role string (verified by probe: a planted {"role":"weird",…} came back as role=weird) |
the record is skipped, with the same tolerance as a corrupt line (src/session/loader.rs:148-154 returns None; :212-215 drops it) |
provider::model::Role is a closed 4-variant enum; mapping an unknown role to User would inject foreign text into a prompt. Nothing is lost on disk — lines are never rewritten |
| D-48 | meta.effort a value this build cannot parse (e.g. a future Go level) |
forwarded verbatim to SetEffort(string); the API rejects it later (chat/session.go:432) |
the current effort is left untouched and one warning is emitted (src/session/tuning.rs:38-46): Warning: session effort {e:?} is not recognised; keeping the current setting |
provider::Effort is a closed enum with a fallible parse; widening it to a string would unfreeze a phase-1 contract for a hypothetical |
| D-49 | tool_calls[].arguments on write |
null for a nil Go map, {} for an empty one; the loader accepts both (chat/session.go:117, no omitempty) |
always {} — provider::model::JsonObject has no nil state. On read, null, {} and an absent key all yield the empty map (src/session/record.rs:75-78,152-158). Proven loadable by the Go loader (probe and the round-trip script) |
no nil map in Rust; schema-equivalent, and the same choice phase 1 made in openai.rs |
| D-50 | raw.blob for anthropic / openresponses with an empty item list |
MarshalRawContent returns (nil, nil) → the raw key is OMITTED; a nil slice (as opposed to empty) would marshal to null |
an empty Vec likewise OMITS the raw key (src/session/rawcodec.rs:40-45); a non-empty one is a JSON array. Rust can never produce null here (a Vec serialises to []), so the null shape is read-only |
matches Go's observable output; [] vs null is loader-equivalent in Go |
| D-51a | raw.blob validation at load |
UnmarshalRawContent parses the blob into the dialect's type; a parse failure silently drops raw (chat/session.go:800-803) |
the blob is carried verbatim as provider::model::Raw into the matching RawContent variant and is never parsed; only the shape (value vs array) is checked (src/session/rawcodec.rs:56-72), with a literal null read as the EMPTY item list because Go's json.Unmarshal(null, &[]T) succeeds. A structurally wrong blob is dropped; a semantically wrong one reaches the dialect, which already falls back at replay (src/provider/google.rs:184 etc.) |
keeps session free of the wire layer; the dialects already own blob validation |
| D-51b | RFC3339 timestamps under UTC | time.Now().Format(time.RFC3339) prints Z at offset 0 |
jiff's %:z prints +00:00 at offset 0 (src/session/meta.rs:121-126); second precision and the local numeric offset otherwise match, and parse_rfc3339 accepts both spellings |
both are RFC3339 and both binaries parse either; a non-UTC host is byte-identical |
| D-52 | --model/-M is required when using --message/-m |
raised at cmd/root.go:107-109, unconditionally, before any session code |
deferred when --resume is present (src/cmd/resolve.rs:103) and re-raised byte-identically after the session's model replay (src/cmd/mod.rs:196-198). --resume=<id> -m "…" therefore works with no -M, while a provider-mismatched or model-less bundle still fails with the same message — since X-48 worded no model chosen: set agents.<name>.model or pass -M |
Go's own interactive resume supplies the model from meta (root.go:323-325); headless must do the same or --resume would be unusable without -M. Moves this error's precedence relative to config temperature … only when --resume is given |
| D-53 | images generated during a resumed headless run | a persisted interactive session puts them in the bundle's images/ and attaches them to the assistant message it persists — ONLY the images whose save SUCCEEDED, each Filename rewritten to the saved file's basename before persisting (chat/images.go:117-148 collectImages; chat/run.go:1094); a failed save is reported and NOT attached. It ALSO attaches each tool round's images to that round's persisted assistant (chat/run.go:1479). Go's -m path has no session and saves to ~/.iota/images |
on a resumed run images_dir is <bundle>/images (src/cmd/mod.rs:255-260) and the persisted FINAL assistant message carries exactly collectImages' subset — the successfully-saved images with filename = basename(saved path) and the original mime/data (bytes into attachments/<sha256>), via save_images_for_turn (src/headless/images.rs:74-95); a failed save lands in image_errors and is NOT attached (pinned by a golden on the persisted attachment record, resumed_image_turn_persists_the_saved_attachment). Mid-round images are NOT persisted: the frozen phase-1 loop surfaces only the terminating round's images (LoopOutcome.images), so earlier rounds' images are dropped — Go-HEADLESS parity, Go-INTERACTIVE divergence. Stateless -m is unchanged (~/.iota/images) |
Go-faithful for the final message (raw RoundResult attachments would persist "filename":"" records and failed saves — a shape no Go binary writes); the alternative would make a Rust-extended session lose data on the next Go resume. The mid-round gap is the phase-1 loop's frozen shape, recorded rather than left silent |
| D-54 | --no-save |
interactive-only ephemeral start; --no-save + --resume is an error (cmd/root.go:285) |
keeps its D-23 rejection unchanged HEADLESSLY (src/cmd/args.rs:141-143). Interactively (T-29) --no-save IS reachable and byte-exact: --no-save cannot be combined with --resume (src/cmd/interactive/mod.rs:256, ArgsError::NoSaveWithResume, raised BEFORE the tty check like root.go:284), and /save mints the writer late and flushes the whole backlog in ONE append (src/repl/commands/save.rs) |
a headless create flag would still be invented CLI surface. TestDeferredSaveBacklog (chat/session_test.go:518) is ported as the store-level twin tests/session/writer.rs::a_deferred_save_flushes_the_whole_backlog_in_one_append (mint → four-message backlog in one append → title + window → resume lossless, order intact); the run-loop half is tests/repl/commands.rs::save_mints_late_and_flushes_the_backlog |
| D-55 | Message.Usage on persisted assistant messages |
Go's INTERACTIVE loop sets it on every tool-round assistant and on the final assistant (chat/run.go:1093); Go's quiet -m loop sets it nowhere (chat/chat.go:326) |
attached to every persisted assistant message from RoundResult.usage — the tool-round assistants at src/headless/run.rs:374-377 and the final one at :188-197; the writer sums it so usage() stays equal to what the log sums to (src/session/writer.rs:134-136) |
a Rust-extended bundle otherwise makes the Go binary under-report after a resume, and breaks the store's own invariant. Probe and the round-trip script confirm Go sums Rust-written usage objects correctly |
| D-56 | a messages.jsonl line at or above the 32 MiB cap |
bufio.Scanner fails with its own token-too-long error, surfaced as read session log: bufio.Scanner: token too long (chat/session.go:827-836) |
the same read session log: {e} frame with Rust's own inner text (log line exceeds the 33554432-byte limit). The cap is enforced while reading, in bounded 64 KiB chunks (src/session/loader.rs:69-115), never after — an unbounded read_until would allocate the whole hostile line before the cap could fire |
different scanner; the error frame and the accept/reject boundary are identical, the inner text is not (D-16 class) |
Defer-mode system-tools mount — no longer persisted (recorded beside D-43/D-55; was a Go-parity
hazard, now a divergence). A defer-mode system-tools mount (Message::system_tools, pushed into
history at src/headless/run.rs:310) is runtime state (tool-defer.md: "runtime state, not
persisted"). Go persists it like any message (chat/run.go:1645-1656), and since sessionMessage /
SessionRecord have no tools field it lands as a bare {"role":"system"} record; on the next
resume "last system record wins" replaces the real system prompt with that empty one. Rust closes it
on both sides (bot-mode.md §2.7): the write side skips the mount (SessionWriter::append_messages
filters out any message carrying tools; it counts toward nothing, a batch of only mounts touches no
disk, and the caller's watermark still passes it so it is never retried), and the read side never
lets a content-less system record win (load_log takes only a system record with content — no real
system prompt is empty), so a bundle an older build or Go already wrote loads with its real prompt.
Pinned by tests/session/writer.rs::a_tools_mount_is_not_persisted,
tests/session/loader.rs::an_old_empty_system_record_does_not_replace_the_system_prompt and
tests/repl/commands.rs::a_frozen_tools_mount_is_skipped_on_write_and_the_watermark_passes_it.
Bundle hardening beyond Go (no bundle either binary writes can observe it). Two reads are bounded
or validated where Go's are not, in the spirit of the phase-1 security-3 cap: an attachment
data_ref whose digest contains a path separator or .. is skipped instead of being joined onto
attachments/ unchecked (src/session/loader.rs:125-139 — Go's readAttachmentRef would
read the file it points at; a genuine reference is 64 hex digits, and the observable result for such a
ref is the one Go already has for an unreadable attachment: skip it, keep the message), and D-56's cap
fires before the allocation rather than after it. Bucket and bundle directories are additionally read
NAME-SORTED with a stable sort (src/session/store.rs:251-259, :291-299) where Go's
sort.Slice is unstable, so the session id "x" is ambiguous: … candidate list is deterministic
across runs — Go's own os.ReadDir already returns sorted names, so the two agree in practice.
Merged from the slice's design record (TUI_DIVERGENCES.md) when the layer-4 gate closed
(WP52). These rows describe the interactive branch (a run with neither -m nor -l); the
headless -m / -l path is untouched by every one of them, and the D-rows above still
govern it. (D-22 and D-28, which described builds without the TUI or with features compiled
out, were deleted on 2026-09-01: there is one binary now, and it always carries everything.)
Four kinds:
- mech — the Go mechanism is replaced, not the behaviour; each names the test that replaces the Go test.
- decision — a deliberate design choice with a user-visible consequence.
- lift — a phase-1 D-row that the interactive build reverses (it was skipped for want of a consumer; the TUI is the consumer).
- T2-gap / T3 — features deferred by policy, listed so the gap is a record rather
than a surprise. A T2 gap closed inside this slice (WP53–WP55); every T3 row closed in the
T3 slice (2026-09-02/03) and now reads
CLOSED (T3, …)with the evidence in place — see §C.3 for what that slice introduced. The one row that stays open is T-21 (OSC52), which Go never shipped either.
| id | kind | area | Go | Rust (this slice) | why / replacement test |
|---|---|---|---|---|---|
| T-01 | mech | sanitizeOverflow | exact-width-multiple overflow lines trimmed 1 col (bubbletea insertAbove miscount) | NOT ported — wart W6 (Paragraph::line_count of the SAME Paragraph) removes the hazard class |
characterization: exact-width line inserts as exactly ONE row, right border intact (vt100 + tmux). The width−1 commit-wrap law itself STAYS |
| T-02 | mech | joinOverflow blank→" " | lone blank payload becomes " " (insertAbove drops empty strings) | NOT ported — a blank Line inserts as one blank row | ordering half of TestRegionBlankLineSurvivesOverflow ports unchanged |
| T-03 | mech | cursor bump / viewEquals defeat | every region snapshot mutates the view so the renderer restores the cursor | replaced by wart W4 (insert batch ⇒ draw + set_cursor_position in the SAME loop iteration) | loop unit (step-with-inserts-ends-with-draw) + tmux constant-cursor_y pin; idle frame still carries no spinner glyph |
| T-04 | mech | bare-ESC disambiguation | trailing-0x1b short peek distinguishes ESC from sequences | not needed — crossterm parses escape sequences and delivers KeyCode::Esc discretely (spike G5) |
interrupt matrix tests unchanged |
| T-05 | decision | committed-line representation | bubbletea renders strings directly | region stores raw ANSI strings; ONE ansi_to_spans parser at the insert/frame boundary |
round-trip goldens (16/256/truecolor, reverse, faint, OSC 8) gate WP44 |
| T-06 | decision | overflow chunking | max(2, h/2) chunks (anchor-drift fix) | KEPT although the ratatui hazard is unproven — cheap insurance (it kept the W2 arithmetic exact; W2 is gone since X-54, the chunking stays) | L2b >screen-height characterization + L4 big-resume-echo; re-evaluate note recorded |
| T-07 | decision | markdown Sink errors | io.Writer error plumbing threaded through every emit | Sink infallible (facade commits are fire-and-forget); new_writer_to ignores io errors |
markdown spec open-question resolved; no behavioral test change |
| T-08 | CLOSED (T3, WP61 inline + WP62 display, 2026-09-02) | math rendering | mathtext approximation/2D | closed: the T1 passthrough is deleted. Inline math renders through mathtext::approx_inline (markdown/inline.rs:153), display math through mathtext::render_2d (markdown/blocks/math.rs MathBlock::render), and the display fallback is clean_source — never raw LaTeX. markdown::blocks::math re-exports display_open/is_display_close from mathtext::delim; find_inline_math sits in markdown::inline |
the guard corpus is unchanged and now byte-identical to Go's; tests/markdown/{math_corpus,table}.rs (WP61) and {math_display,quote}.rs (WP62) carry Go's own goldens, and the four twins T1 skipped (markdown_test.go :1137, :1151, :1169, :1212) are ported |
| T-09 | CLOSED (WP55) | code highlighting | chroma 256-color in every build | shipped: syntect 5.3 + two-face (bat syntaxes; MonokaiExtended/Github for chroma's monokai/github) always compiled in (≈1.1 MiB of syntax/theme tables, docs/SIZE.md). One seam (markdown::highlight::active) shared by the code-block and chat-diff renderers, as Go shared the exported Highlight. Two deliberate improvements: an unknown language and every plain-scope run render UNCOLORED rather than in the theme's Text color (the bare-fence law generalized), and Go's whole-block green codeFallbackStyle is replaced by verbatim text (the frozen seam returns String, not Result) |
the foreground-only invariant (\x1b[48 never emitted) is pinned under BOTH themes on the Go adversarial line — tests/markdown/highlight.rs; the plain no-color shape stays pinned by tests/code.rs |
| T-10 | CLOSED (WP53) | token accounting / /compact / auto-offer / ctx+token status segments / thinking token detail | always present (tokenAware providers) | shipped: the meter, /compact, the auto-offer and the ctx/token status segments are always compiled in; the gate is Provider::reports_usage (T-38). src/repl/context/meter.rs, tokens.rs, commands/compact.rs and the /status token block carry the chat/{compact,tokens}_test.go ports |
the row's original "absent until WP53" text is history; a provider that reports no usage still renders TestStatusLineWithoutTokenAccounting's shapes |
| T-11 | CLOSED (WP55) | composing widget mid-stream | first NAMED arg delta raises the widget; content pipe cut + spill | shipped: openai / anthropic / openresponses emit StreamSink::tool_delta, so RenderSink reaches Composing mid-stream — the widget rises on the first NAMED delta, the content block closes there and later content spills below it. Anonymous deltas cut without raising (zombie-spinner rule); anthropic's server_tool_use never reports; google stays atomic BY DESIGN (Go notified with an empty delta the observer discarded, so the Rust twin emits nothing) |
tests/provider/tool_delta.rs (7 per-dialect units) meets WP49's the_content_tap_feeds_the_history_and_cuts_on_the_first_tool_delta (spill + mark-once + cut) at the typed seam; Go's SetToolCallObserver install/detach API has no Rust twin (the sink is passed in), so that half of observer_test.go stays unported under D-20 |
| T-12 | CLOSED (WP54) | full /model tabs (context/effort/temperature/image/system), Slider/Switch/Browser, /file, View jump-search | present | shipped, unconditionally (no feature flag): /model assembles Context / Effort / Temperature / Image / Aspect / Size / Negative / JSON edits / System by capability and commits by recorded index (src/repl/commands/settings.rs + systemtab.rs, which composes through the SAME compose_send_history the wire uses); /file heads the command table again, with the Attached+Add surface and the 20 MB / MIME table refusals (commands/file.rs). The Slider/Switch/Browser kinds and the View hit walker needed NO new ui code — WP47 had already ported them; WP54 landed their Go test suites |
src/ui/surface/{tabbed,search}.rs (in-file *_tests modules) (24 tests: all 5 Go panel-kind ports + all 7 TestViewSearch*), tests/repl/{settings,file}.rs (17 L3 flows) + the row-builder / capability-assembly / systemtab units. TestApplySessionTuning stays session's; WP54 pins the ROUND TRIP (/model writes the meta a resume replays) |
| T-13 | CLOSED (T3, WP67, 2026-09-02) | upload-progress phase | "Sending request" + byte detail for ≥128KB bodies via a RoundTripper seam | shipped: Client::send_payload wraps the payload in ProgressBody (reqwest::Body::wrap_stream + an EXPLICIT Content-Length, T-55) whenever the turn's TURN_PROGRESS task-local is set; watch_phases narrates Sending request with the %s / %s byte detail for bodies ≥ SEND_PROGRESS_MIN (128 KiB), throttled at 150 ms, sent() re-armed per attempt |
chat/progress_test.go ×3 ported — the wiremock body-progress twin lives in src/llm/progress.rs::wire_tests because the handles it installs are pub(crate) (DEVIATIONS3 [WP67]); the phase flows are tests/repl/turn.rs |
| T-14 | CLOSED (T3, WP67, 2026-09-02) | OSC 9 notify + OSC 9;4 progress + focus tracking | bubbletea renders/diffs/exit-clears natively | shipped: src/host/ carries the Presenter with its per-capability fallback and state dedup, the ANSI host and the cmux host (byte-exact set-status iota … / workspace loading …, last-wins mailbox T-52, 2 s/3 s bounds). OSC 9;4 rides Ui::set_progress (emit-on-change, exit-clear in Term::drop), OSC 9 + a ringing BEL rides Ui::notify gated by crossterm focus events (\x1b[?1004h/l), a "4;"-opening digest is defused by a leading space, and the notify: config key is honoured (pc.notify.unwrap_or(true)) |
internal/host/{ansi,background,cmux,host}_test.go ×15 + internal/ui/progress_test.go ×2 ported; the byte-level pins are src/ui/runtime/event_loop.rs's vt100 tests and tmux 14-osc-signals.sh (pipe-pane raw capture: mode 1004 on at startup, ]9;4;3 during a turn, ]9;4;0 at idle, 1004 off at exit). The two channels no CI can reach — an UNFOCUSED window and a live cmux (T-53) — are the manual gate docs/TUI-VERIFY.md §N |
| T-15 | CLOSED (T3, WP61+WP62, 2026-09-02) | mathtext 2D rendering | internal/mathtext | shipped: src/mathtext/ ports internal/mathtext file for file (pict.rs = box.go with Go's Box renamed Pict, layout.rs = layout.go, mod.rs = render.go). The 2D geometry is Go's verbatim: hconcat on a shared baseline with the extra centring column to the RIGHT, fractions with the baseline ON the bar, the ╲╱ radical whose ╱ arm climbs one column per radicand row under a _ vinculum, script columns with an explicit blank anchor row (plus the accent lift), ⌠⎮⌡ for a tall integral and a SINGLE glyph for every other big operator however tall the operand, per-column matrix widths with 2/2/1 gaps for matrix/cases/aligned and one blank column inside each drawn delimiter |
tests/mathtext/goldens_2d.rs drives 128/128 reader-generated Go Render2D records byte-exact (rows, trailing spaces, ok flag and each row's str_width against the fixture's w=), goldens_inline.rs the 176 inline inputs; layout_test.go ×24, box_test.go ×16, render_test.go ×9, p3fix_test.go ×5, parse_test.go, symbols_test.go, delim_test.go and inline_test.go are ported in file (101 tests). Every width goes through crate::text::width::str_width, and no combining mark (U+0300..=U+036F) is ever emitted |
| T-16 | CLOSED (T3, WP63+WP64, 2026-09-02) | imgterm, image widgets, call_body partial frames, /edit, /redo, image echo in replay |
present for image providers | shipped: src/imgterm.rs ports internal/imgterm — Go's integer geometry verbatim before the resize, ▀ cells with 24-bit fg/bg, the odd-row \x1b[49m fallback, \x1b[0m per line, decode image: %w (downscale filter: T-48). Ui::call_body is on the facade trait and Region::set_call_body replaces the widget body wholesale (a no-op unless a call preview is up). Transcript::{image, image_widget} port transcript.go:335-383 — activity settles first, and a raised generation widget MORPHS in place, paying no second separator. collect_images draws at min(72, width-4)×14 with the "🖼 saved: <hyperlink>" caption, falling back to "{caption} ({why})" on a decode failure; echo_rounds/echo_image re-rasterise a resumed session's images. /edit (bare → the picker, T-20; with a prompt → the last generated images re-attached and the prompt sent) and /redo (the last USER turn's prompt + attachments, optionally reworded) ship with their byte-exact notices and the fall-through send |
imgterm_test.go ×5 + the checked-in tests/fixtures/images/rb-2x2.png byte golden; compose_test.go :622/:644/:666; replay_test.go :128/:165/:192; chat/edit_test.go ×2; chat/editpicker_test.go :32/:73/:100; L3 tests/repl/{images,edit}.rs; tmux 13-edit-picker.sh (the widget, the progressive frame inside it, the half-blocks, the picker) |
| T-17 | CLOSED (T3, WP65, 2026-09-02) | /export | HTML/Markdown export | shipped: /export writes the self-contained HTML page (inline CSS, dark-mode toggle, highlighted code) or the Markdown document. A SAVED session exports its full on-disk log through load_full_history/SessionStore::load_full, so a /compact never truncates an export; an ephemeral one exports the in-memory view. Bare /export opens the Export format picker; the file name is iota-<slug|id|session>-<YYYYMMDD-HHMMSS>.<ext> and the write is O_EXCL (never overwrites) |
chat/export_test.go ×8 + session_test.go:410 + session_project_test.go:140 ported; tests/repl/export.rs (incl. the full-document golden against real Go bytes) and tmux 11-export-picker.sh. The Markdown document is BYTE-IDENTICAL to Go's and the HTML is byte-identical everywhere outside <style>; the two library-output regions are T-42 (code-token spans/CSS) and T-43 (tool-argument JSON), and T-61 records the infallible builder |
| T-18 | CLOSED (T3, WP66 + the WP69 tmux gate, 2026-09-03) | /debug request inspector + RequestLog ring | present | shipped: RequestLog (30 entries, 256 KiB body caps, added BEFORE the round-trip, streaming tee) is recorded per ATTEMPT inside llm::Client::send_payload — the one path that already owns every provider attempt, its retries, the title provider, delegated children and fetch_image relay GETs. `/debug on |
offflips recording (byte-exact notices) and publishes the status row'sdebugsegment; bare/debug opens the two-tab inspector (Messages, newest first, refreshed every 500 ms, beside the Verboseswitch) whose Enter drills into↑ Request/↓ Responseand whoseccopies. NOTHING is ever echoed to stderr — thecmd/root.go` comment claiming otherwise was stale — and MCP traffic is not recorded, Go parity by construction (T-56) |
| T-19 | CLOSED (T3, WP68, 2026-09-02) | /skills browser + expansion | present in agent mode | shipped: /skills registers in agent mode with one "/skills <name>" completion row per discovered skill (Label = the bare name, Desc = the description), rebuilt on every catalog change (src/repl/commands/mod.rs, run.rs:428,734). The arm (src/repl/commands/skills.rs, run.go:923-937) refreshes the overlay first, then either opens the read-only Skills view (skills_status_lines — bold name + yellow [source tag] + dim description, Skipped (invalid): for the warnings, the searched roots when the catalog is empty) or expands the named skill (expand_skill) into the <skill name=… location=…> block, prints Skill %s loaded (%s). and falls THROUGH to the message path: the echo shows the typed line while history and the compact offer carry the expansion |
chat/skills_test.go:13, chat/skillcmd_test.go :29/:64/:78/:97, chat/completion_test.go:21 and seven L3 flows in tests/repl/skills.rs; Overlay::{skills,skill_dirs} and agents::skills::skill_source_tag (home INJECTED, D-26) support it |
| T-20 | CLOSED (T3, WP64, 2026-09-02) | PanelKind::Picker (+ /edit editpicker) | present | shipped: PanelKind::Picker + Panel::preview + render_picker — preview pane left, list right, a detail line under the body following the cursor — with the Go geometry constants 64/44/2/18/14/12 and the (cursor, cols, rows) preview cache |
model_test.go :1746/:1807/:1825 ported in src/ui/surface/tabbed.rs::picker_tests; T-62 and T-63 record the two mechanism changes the Rust render path forced |
| T-21 | out of scope (Go parity) | OSC52 clipboard | exec-based tools + an OSC52 discussion Go never shipped | exec-based only (pbcopy/clip/wl-copy/xclip/xsel); no clipboard tool found |
porting OSC52 would LEAD Go rather than follow it, so it is deliberately outside T3 as well. The row stays as the record; it is one of the two remaining items in EVALUATION §7 |
| T-22 | CLOSED (T3, 2026-09-03) | transcript search, host cmux, desktop notifications, mid-turn slash dispatch | — | nothing outstanding. "Transcript search" is NOT a Go feature: internal/ui/search.go IS the panel/View search, and all 24 of search_test.go's tests are ported and anchored in src/ui/surface/{search,tabbed}.rs. "Mid-turn slash dispatch" has been parity since WP46 — Go queues a slash line typed mid-turn and runs it between turns (model.go:290-300 ≡ src/ui/runtime/event_loop.rs:348-357), which is exactly what the type-ahead law does. Host cmux and desktop notifications shipped under T-14 |
the row is kept as the record of what the T3 policy listed; each half now names where the behaviour actually lives |
| T-23 | decision | /mcp command | policy T2 names "/mcp"; Go has NO /mcp command | restated as the /tools MCP tab (mcpStatusLines) + the async failure lines — Go parity | chat-commands open question resolved |
| T-24 | decision | pending tool-def loads mount point | headless (Go chat.go + Rust run.rs) mounts at round TOP incl. before round 0; Go toolLoop mounts at BOTTOM after steer, never before round 0 | interactive follows Go toolLoop byte-parity (BOTTOM) | headless untouched; row documents the intentional asymmetry |
| T-25 | decision | effort plumbing | Go forwards effort strings verbatim to the wire | typed Effort::parse; unknown session effort keeps the D-48 warning path |
phase-1 frozen contract; D-48 already records it |
| T-26 | decision | Temperature slider max | p.Type()=="anthropic" string compare → 1.0 else 2.0 |
same literal comparison via ProviderKind::Anthropic (no new capability invented) |
WP54; smallest-divergence choice |
| T-27 | decision | slider bar fidelity | charm progress gradient + partial blocks | plain filled bar (origin-stability/padding pins honored) | shipped WP47, pinned WP54: the_slider_bar_keeps_its_origin_and_the_chip_titles_the_surface keeps Go's █▌░ probe and asserts only the origin, the blank-row padding and the presence of filled cells |
| T-28 | decision | Shift+Tab in surfaces | never shipped (forward Tab only) | same — not bound | port-as-is note |
| T-29 | lift | --no-save / blank --resume / -S | rejected headlessly (D-23/D-42/D-54) | lifted for interactive runs (no -m): ephemeral start / one-shot picker / in-loop prompt; headless -m keeps every rejection byte-exact |
policy §session-parity |
| T-30 | lift | D-12 tool display helpers | skipped headlessly (no consumer) | tool::fmt ports header/digest/display-name/result rows; shell/code/delegate gain header_summary/presentation — headless QuietHost approval detail improves as a side effect |
interactive REQUIRES the digest (headers, approval evidence, event rows) |
| T-31 | CLOSED (T3, WP68, 2026-09-02) | title-pass gate set | isReadOnlyViewer = {/debug,/status,/tools,/skills} | restored to Go's full set now that /debug (T-18) and /skills (T-19) register (src/repl/title.rs, chat.go:188-195) |
chat/debug_test.go:115 TestIsReadOnlyViewer is ported UNADJUSTED, including the /debugx negative that proves prefix matching does not over-match /debug. Superseded by X-64 (2026-10-01): nothing waits on the title pass and no command gives it up, so the gate and its test are gone |
| T-32 | decision | tea.ClearScreen on surface Tab switch | Ghostty CJK cell-diff insurance | kept as terminal.clear() full repaint regardless of whether ratatui reproduces the artifact |
cheap, user-paced; doc comment mandated |
| T-33 | note | countMessages toolcall-arg rendering | Go fmt %v over map values | serde_json compact string of each value (deterministic) | WP53; estimate-tolerance makes it low-stakes; recorded |
| T-34 | note | SIGINT handling | Go: no signal handler needed (bubbletea owns the tty) | interactive runs skip the headless SIGINT→token handler (raw mode delivers Ctrl+C as a key); SIGTERM still cancels the root token | signals.rs interactive path, WP51 |
| T-35 | lift | artifact side channel (lifts D-19) | tool.WithArtifact ctx slot; edit_file/write_file post unified-diff hunks, delegate posts the note (tool/tool.go:160-198, code.go:665-675, delegate.go:169-180) |
PORTED: Artifact{kind: Diff|Note, title, lines} + RunCtx.artifact: Option<ArtifactSlot> (last-post-wins; None headlessly — every post a no-op, Go parity). Slot/types land in WP40; producers (hand-ported udiff-twin differ + postDiff, delegate note) in WP48; consumption (showcase → render_diff, finishCall note) in WP49 |
the T1 showcase law needs a producer; TestMutationsPostDiffArtifact + delegate note assertions port (re-targeted from the ledger) |
| T-36 | decision | no-tools streaming (D-20 stays recorded) | Provider.StreamChat streams no-tools turns; imagen adapts StreamChat (TestImagenStreamChatAdapts) |
NOT ported. A ToolProvider-capable provider routes EVERY turn through stream_chat_with_tools — with an EMPTY tools slice when none: every wire dialect omits the empty array (wire/anthropic.rs:45, wire/chatcomp.rs:33, google.rs:437 guard, wire/responses.rs:33), so no-tools turns still stream and ESC yields partials. Providers WITHOUT ToolProvider (imagen/images) stay unary chat() interactively: no streaming, no partials — interrupts take the zero-yield finalize path |
L3 test: empty-dispatch turn streams + ESC partials; the image-provider streaming gap is the accepted cost of not porting StreamChat |
| T-37 | lift | AGENTS.md overlay freshness (lifts D-27) | Overlay.Refresh() per message (agentsmd.go:196) |
Overlay::refresh(&mut self) -> (agents_changed, skills_changed) ported (WP50); notices fire only on change; a skills change re-derives set_slash_commands |
TestOverlayFreshness/TestOverlaySkillsFreshness/TestOverlayDetectsSkillEdit port |
| T-38 | decision | tokenAware probe | p.(provider.UsageReporter) interface assertion (run.go:53) |
Provider::reports_usage() default false; openai/anthropic/google/openresponses → true, imagen/images stay false (pinned) — frozen at WP40, read by WP53 for /compact registration + ctx/token segments |
no Go interface-assertion idiom in Rust; the accessor-as-capability pattern the workspace already uses |
| T-39 | lift | generated-image persistence, interactive (D-53's HEADLESS mid-round gap unchanged) | per-round + final collectImages: save, attach the saved subset (rewritten filenames), render the image widget (run.go:1093/1479, images.go:117-146) |
reuses chat::images::save_images_for_turn per tool round AND on the final assistant; attaches the saved subset to the persisted message; "Saving image failed: %v" per failure + caption notice "🖼 saved: " + hyperlink — image WIDGETS stay T-16 (the notice replaces tr.image) |
LAW/session-parity: an interactive bundle must not lose attachments a Go resume expects; closes D-53's mid-round gap for the interactive loop only |
What layer 4 measured (tmux 3.7c, tests/ui_tmux/main.rs). T-01's
characterisation holds on a real terminal: a line exactly the terminal's width inserts as
exactly ONE row with its last column intact, and 81 columns of CJK wrap into exactly two
rows with the straddling rune moved whole. T-03's replacement holds: the cursor does not
drift across idle polls, and it lands at the draft's display column (2 for an empty draft,
6 after two double-width runes). T-06's chunking survives a resume echo three times the pane
height with every row present exactly once. One new characterisation was added by the same
gate:
| id | kind | area | Go | Rust (this slice) | why / replacement test |
|---|---|---|---|---|---|
| T-40 | CLOSED (VERIFY) | terminals shorter than the frame's floor | bubbletea's inline viewport had the same floor; never characterised | fixed. WP52 characterised it: with a fixed 4-row staging window the frame needed ~10 rows, so below ~12 the inline viewport and the inserted history OVERLAPPED and the overlapped scrollback rows kept the damage (scrollback is immutable). The staging window's cap is now DYNAMIC (Region::tail_keep): TAIL_KEEP wherever there is room, trimmed on a short terminal so the frame always leaves max(2, screen_h/2) rows above it — exactly the batch size chunk_overflow inserts. A height change re-applies it immediately (Job::Retrim), so the guard does not wait for the next line of output |
tests/region.rs::staging_window_has_a_floor_on_short_terminals (cap 4 at 24 rows, 1 at 12, 0 at 10, refills on grow); tests/tmux/scenarios/10-edge-pins.sh pin 4 now reports 60x10: the frame and all 12 rows survived intact (PASS) where it reported a WART — 4 consecutive runs, WARTS=0 |
The T3 slice closed every row §C.2 had marked T3 (T-08, T-13 … T-20, T-22, T-31) plus D-12,
D-13, D-19, D-20, D-27, D-29 and D-54's unported half. What it introduced along the way is
recorded here: mechanism swaps, deliberate decisions and the notes a later reader will want.
Kinds are §C.2's, plus hardening (a Rust-only bound Go did not need) and kept (a
pre-T3 divergence re-confirmed rather than changed).
| id | kind | area | Go | Rust (T3) | why / replacement test |
|---|---|---|---|---|---|
| T-41 | decision | /images/edits multipart transport |
mime/multipart.Writer inside the request body |
the body is encoded by a hand-rolled byte-twin of Go's writer (src/llm/multipart.rs) and sent through the ONE Client::send_payload path; reqwest's multipart feature stays OFF |
reqwest-0.13.4's Form::stream() is pub(crate) and RequestBuilder::multipart installs a streaming Body whose as_bytes() is None, so the T-18 recorder could not capture the request body and the retry loop could not clone it. The hand-rolled writer is ~80 lines, byte-exact to Go's layout (field order, quoting, CRLF), and TestImagesGoldenEdit parses it. Approved by the parent session 2026-09-02; byte layout re-verified against GOROOT mime/multipart/writer.go (T-59) |
| T-42 | decision | /export code-token spans + token CSS |
chroma github/github-dark in class mode: <span class="line"><span class="cl"> per line wrapping .k .s .n … token classes; WriteCSS(WithCSSComments(false)) emits one selector { … } rule per line |
syntect ClassedHTMLGenerator (ClassStyle::Spaced) over the two-face syntax set markdown::highlight already owns: nested SCOPE classes, no per-line wrapper. Light theme = two-face Github, dark stand-in = OneHalfDark (two-face has no github-dark). The CSS is emitted by our own markdown::html::theme_css_rules — one .chroma …{ color: … } rule per line, root rule first — so prefix_css_selectors ports prefixCSSSelectors verbatim. Per-scope BACKGROUNDS are dropped (syntect paints alarm backgrounds on invalid/illegal, which is exactly the "a parse failure deserves no styling" law markdown::highlight already enforces). comrak also appends one \n after </code></pre> where goldmark-highlighting appends none |
structure-only parity for the ONE region of the exported HTML that is library output. The WRAPPER tags match Go exactly in all three fence cases, verified against a compiled chat/export.go (goldmark 1.8.5 + chroma 2.27.0): <pre class="chroma"><code> for a known language, <pre><code> for a bare fence, <pre><code class="language-X"> for an unknown one. Pinned by markdown::html::tests ×9 and tests/repl/export.rs::html_document_matches_the_golden (the full document with only <style> and the per-run header line masked) |
| T-43 | decision | /export tool-argument JSON |
json.MarshalIndent(v, "", " ") with SetEscapeHTML(true): sorted keys, </>/& escaped, Go float spelling |
go_json_indent = serde_json::to_string_pretty (a Map over BTreeMap, so keys sort like Go's map[string]any) followed by the five encoding/json SetEscapeHTML(true) rewrites — < → \u003c, > → \u003e, & → \u0026, U+2028 → \u2028, U+2029 → \u2029. All five can only occur inside a JSON string literal, so the flat replacement is exact |
the <pre> bytes match Go for the pinned fixture; escapes pinned by go_json_indent_escapes_like_encoding_json, the block by test_build_export_html_details. Float spelling still differs only at exponent extremes (the D-09 class) |
| T-44 | decision | /debug response status line |
resp.Status verbatim ("200 OK"; a relay's custom reason phrase is kept) |
llm::reqlog::status_line renders "{code} {canonical_reason}", or "{code}" when the status registry knows no phrase — reqwest exposes no reason phrase |
rows show only the leading code (status_col), so only the ↓ Response head line can differ, and only for a non-standard phrase. Pinned by llm::reqlog::tests::status_line_shapes |
| T-45 | mech | /debug recorded response |
Go tees resp.Body in place inside its RoundTripper |
while recording is ON the response is REBUILT — http::Response::new + status/version/header setters around reqwest::Body::wrap_stream(RecordingStream { .. }), converted with reqwest::Response::from (reqwest-0.13.4 response.rs:459; http = "1" is a direct dependency for it, no new transitive crate). resp.url() becomes the placeholder http://no.url.provided.local/ and the response extensions are dropped. Recording OFF (and every client with no recorder) returns the response untouched |
nothing in the crate reads either — Sse, resp.bytes(), consume_images and read_error_body take only the body, and StatusError is built from the REQUEST's URL. Pinned both ways by llm::reqlog::tests::{rebuilt_response_keeps_everything_but_the_url, the_tee_captures_each_chunk_as_it_is_read, dropping_the_stream_early_still_stamps_the_duration} and tests/provider/reqlog.rs::{with_recording_off_nothing_is_captured, a_client_without_a_recorder_is_inert} |
| T-46 | decision | OSC-11 background "unsupported" latch | bgUnsupported latches after a >1 s termenv query so the terminal is never asked twice |
not ported: crate::ui::runtime::osc::detect_background has a 100 ms deadline (the query is chased by a DSR 6n that every usable terminal answers) and runs once at startup, so there is nothing for a latch to save |
internal/host/background_test.go:97 TestDetectBackgroundLatch is the one host test that stays unported, justified here and in §D; the cmux probe chain and the fallback closure ARE ported (src/host/background.rs) |
| T-47 | kept | terminal title OSC | bubbletea \x1b]2;<title>\x07 + \x1b]2;\x07 at exit |
crossterm SetTitle = \x1b]0;<title>\x07, no exit clear (the pre-loop title-stack push/pop restores the tab) |
terminals treat OSC 0 and OSC 2 alike for the tab label; shipped since WP45, src/ui/runtime/term.rs tests pin the bytes |
| T-48 | decision | imgterm downscale filter | draw.ApproxBiLinear on premultiplied RGBA, alpha dropped afterwards |
image::imageops::resize(.., FilterType::Triangle) on straight RGBA, then c*a/255 over black |
sub-pixel differences on downscaled edges with varying alpha only; every pinned test uses solid colours and geometry, and the cell/row byte templates are Go's |
| T-49 | hardening | inline math approx recursion |
unbounded on {{{…}}} nesting (a goroutine stack grows) |
INLINE_MAX_DEPTH = 256; beyond it braces are dropped and the rest copied |
the binary is built with panic = "abort" on a 2 MiB stack, so an adversarial model output would crash the process. No golden changes — the deepest real nesting in the corpus is 4 |
| T-50 | note | mathtext faithful quirks | \left| → a single │; \frac{1}{2}^2 → the 12/─ /2 collision; \frac12 → fallback; a \bmod b → abmodb |
reproduced verbatim (the goldens pin them) | Go parity. Flagged for a later Go-side fix — the Rust port follows the Go output, never leads it |
| T-51 | decision | image-partial observer shape | SetImagePartialObserver(fn) mutates the provider |
StreamSink::image_partial (streaming dialects) + ImagePartialProvider::chat_observed (the unary images dialect) — an observer is a CALL argument; the request carries stream:true, partial_images:1 iff one is passed |
the T-11 precedent (a sink is passed in, never installed); headless -m stays unary by construction (the_unary_images_request_carries_no_stream_key) |
| T-52 | decision | cmux last-wins mailbox | a capacity-1 channel with drain-and-retry | tokio::sync::watch (send_replace) + one spawned worker; close clears the batch, drops the sender and timeout(3 s, join)s |
identical observable law — the TestCmuxCoalescing port shows a superseded Needs input never executes — with no hand-rolled channel |
| T-53 | decision | cmux exec on CI | a live cmux binary |
every cmux test injects ExecFn/CmuxQuery; the live path is a manual gate, docs/TUI-VERIFY.md §N |
there is no cmux on CI, and tmux never blurs a pane; Go had the same manual pty smoke |
| T-54 | note | Presenter threading |
single-goroutine by contract | Send + Sync with a Mutex<State>, shared by the REPL task and the approval gate |
Rust reaches it from three tasks; the dedup law itself is unchanged |
| T-55 | mech | upload-progress body | progressBody wraps the io.ReadCloser inside the transport |
ProgressBody is a Stream of 64 KiB Bytes slices handed to reqwest::Body::wrap_stream, with Content-Length set EXPLICITLY (a streaming body has no size hint) |
src/llm/progress.rs::wire_tests asserts the wire carries Content-Length and no Transfer-Encoding, and that the bytes arrive intact; the first chunk always reports, then every ≥150 ms or the final byte |
| T-56 | decision | /debug MCP traffic |
not recorded (the MCP manager owns its own http.Client) |
not recorded (the seam is llm::Client; the manager keeps the bare reqwest::Client) |
parity BY CONSTRUCTION rather than by filtering. Pinned by tests/cmd/interactive_cli.rs::shared_request_log::the_bare_client_conversion_carries_no_recorder; the stale comment at cmd/mod.rs:86 is rewritten |
| T-57 | note | /edit picker home dir |
os.UserHomeDir() inside shortenPath |
shorten_path(path, width, home) is pure; cmd_edit supplies app::user_home() |
testable, and it matches the /file precedent (repl may read the environment, session may not) |
| T-58 | decision | Ui::set_dark_background |
applyCodeTheme reaches the ui input shades between turns (cmux host) |
a fire-and-forget facade verb + UiMsg::DarkBackground → theme::set_dark_background on the loop thread; code_theme is refreshed per turn |
parity for the only trigger there is (a cmux terminal.replay); 15 lines |
| T-59 | note | multipart header escaping | THREE rules (GOROOT mime/multipart/writer.go + internal/llm/images.go:170,200-204): text-field names through the stdlib escapeQuotes; the file part's name is the raw field constant (image/image[], never escaped); filename through images.go's quoteEscaper; headers written in sorted key order; Content-Type: emitted even for an empty mime |
identical (escape_std, verbatim, escape_quotes; sorted headers; unconditional Content-Type) |
byte parity. On real traffic no name or generated filename contains an escapable character, so the three rules are pinned only by the src/llm/multipart.rs unit test |
| T-60 | note | find_skill case-insensitivity |
strings.EqualFold (Unicode simple folding) |
a to_lowercase() comparison |
identical for every skill name valid_skill_name admits (ASCII letters, digits and dashes) |
| T-61 | mech | /export document builders |
buildExportHTML returns (string, error); its two error arms come from chroma.WriteCSS and goldmark.Convert |
build_export_html(&ExportMeta, &[Message]) -> String is INFALLIBLE: the CSS emitter writes into a String and comrak::markdown_to_html_with_plugins returns one. export_chat therefore has one fewer Error: %v arm than Go (export.go:588) |
no user-visible string is lost — the arm never fired in Go either (chroma's writer is a bytes.Buffer, and goldmark's Convert only errors on a writer failure). Every other Error: line is ported and pinned by tests/repl/export.rs |
| T-62 | mech | Picker preview closure | Panel.Preview stays on the panel; Bubble Tea's View() has the panel by value |
the closure is MOVED into PanelState when the surface opens (SurfaceState::new(&mut [Panel])), because the Rust render path holds the panels by shared reference and an FnMut needs &mut; Panel::preview reads None afterwards |
keeps render_surface(&SurfaceState, &[Panel], u16) unchanged — widening it would have made event_loop::frame_view take &mut self. has_preview is recorded at the tabbed() call, before the surface opens, so the facade summary is unaffected. picker_tests ×6 |
| T-63 | mech | Picker preview height | renderPicker reads m.height directly |
SurfaceState::set_term_height(&self, u16) is published by the loop before each render; 0 means unknown, which skips the clamp exactly as Go's m.height > 0 guard does |
the same clamp [4, max(4, h-12)]; preview_height_is_clamped_by_the_terminal pins both states |
Parity with the Go binary is the baseline, not the ceiling. Rows here record a deliberate divergence taken AFTER the port reached parity: a Go feature dropped (X-01 … X-04, X-11 … X-13), a Go law broken (X-05, X-06), a Go reading of the wire corrected (X-30, X-31, X-33, X-69, X-71, X-72), a Go retry budget restored and its REPL implementation and feedback unified (X-73), a capability added (X-07 … X-09, X-14, X-16, X-24, X-25, X-27 … X-29, X-32, X-43, X-44, X-50) or the command surface itself rebuilt (X-10, X-15).
Compatibility ended with X-15 (2026-09-10). A retired key used to stay ACCEPTED with a warning until
1.0 (docs/history/MIGRATION-ROADMAP.md); it is now an error naming the layer that owns it. The reason is that
there is no released binary whose configs need carrying — iota is a new program, and a config key that
silently does nothing is worse than one that says what to write instead.
| id | area | Go behaviour | Rust behaviour | why |
|---|---|---|---|---|
| X-01 | the delegate toolset |
tools: {delegate: …} advertises one delegate tool that runs a configured child agent IN-PROCESS (tool/delegate.go, chat/delegate.go, cmd/delegate.go), sharing the parent's TurnBudget and routing the child's gated calls back to the parent's approval prompt |
the toolset, the Delegator/DelegateSpec/DelegateApprover contracts and the whole ChildFactory wiring are GONE. A child agent is a shell subprocess — iota run <agent> -m "<task>" — which is a full run of that agents: entry, with its own MCP servers, session and cancellation (killpg) |
brain page subagents-via-bash (2026-09-10). An in-process child could never reach MCP tools (build_child_tools mounted built-ins + skills only) and needed a second implementation of everything a run already does. iota now supplies the capability (a strong shell tool) and leaves the dispatch policy to the user's prompt |
| X-02 | tools.delegate config |
a bad tools.delegate block fails the run at startup (tools.delegate: no agents configured …) |
superseded by X-15: the key was dropped with a warning while the migration layer existed; it is now the config error agents.<name>.tools.delegate: the delegate toolset was removed — run child agents from bash instead (see https://iota.sh/docs/builtin-toolsets), raised where it is written |
a retired key that warns is a kindness to configs a released binary wrote; there are none (X-15) |
| X-03 | --output-format json report |
a "delegated" object (rounds, usage) beside the run's own usage when anything was delegated |
the field does not exist — nothing delegates, and the parent no longer bills a child's tokens. A child subprocess prints its own report | X-01. RunRecorder::report lost the argument; DelegationLedger and DelegatedReport are gone |
| X-04 | --max-turns exhausted text |
… ({turns} turns, shared by this run and everything it delegated) |
… ({turns} turns, the whole run's budget), and --max-turns's help line drops "delegated children included" |
X-01: the clause named a thing that no longer exists. The TurnBudget itself stays — it is what --max-turns bounds |
| X-05 | shell concurrency |
supports_parallel unimplemented, so every shell call in a round runs one at a time |
ShellTool::supports_parallel is true whatever the arguments: the round's consecutive shell calls run in ONE batch (chat::batch::parallel_run unchanged — event rows, results and history stay in CALL order, and ESC cancels the batch) |
a deliberate break with Go's "only calls that cannot change state batch" law, for the shell tool alone. Dispatching child agents from it is pointless when the calls serialize, and the judgement Go's rule made for the model is the model's own here — it wrote both command lines, and &/wait/xargs -P inside ONE call were never gated. Pinned end to end by repl::toolloop::tests::shell_calls_share_one_parallel_batch (two sleep 1s, one cancel scope, under 1.8 s) |
| X-06 | shell timeout |
fixed at 10 minutes (BASH_TIMEOUT) |
No default and no ceiling (2026-09-23): a call without timeout runs until the command exits — or iota does, since a job never outlives it (X-50: a call lets go at 20 s and the command runs on as a job) — and a call with one is killed at that many seconds, whatever the number. timeout is any positive integer; 0, negative or not a number is the argument error timeout must be a positive number of seconds and the command does NOT run. The timed-out line names the deadline the call chose. From 2026-09-05 to 09-23 the default was 600 s and the range 1…3600 |
one number cannot serve both a lint (seconds) and a child agent's whole run (minutes), and once a long command is a job rather than a held turn there is nothing a default protects: it only killed servers, watchers and child agents at an arbitrary minute. The deadline is the model's to set when it wants one |
| X-07 | shell background mode |
none: a call holds its round until the command ends | "background": true hands the command to the run's job registry (src/shell/jobs.rs) and returns a receipt — job id, pid, log path — at once. The child is spawned exactly as a foreground one (same sandbox, setpgid, timeout, killpg), but fd 1 and fd 2 go to <temp>/iota-jobs/<pid>/<id>.log instead of the round's 32 KB buffer. Sixteen at a time; past that the call is refused. ToolEnv.jobs carries the registry, the same seam tools.delegate used to take (X-01) |
a child agent is a shell subprocess now, and one that owns the turn it was dispatched from is not much of a child agent. The log is a file rather than a buffer because nothing bounds how long a job runs; exec::read_capped reads it back with two seeks, so a job that wrote gigabytes costs the reader ~30 KB |
| X-08 | job completion notices | none | a finished job enters the conversation as a Body::Notice message: Role::User on the wire (every dialect can carry one), flagged everywhere the binary owns the text. Interactively the registry's sink Ui::enqueues it — a parked read_input is answered at once (an idle loop wakes and runs one turn), otherwise it joins the type-ahead queue and Steerer::drain takes it at the next round boundary. Headlessly execute_with_tools drains take_finished() each round and, when the model has no tool calls left but a job is still running, blocks on wait_any and spends another round on the notice (billed to --max-turns) |
ONE delivery path, and it is the one the user's own typing already uses: a notice cannot arrive between a tool call and its result, cannot race a turn, and cannot be lost to a retry (the steering injected record re-lands it). The transcript prints only the headline — the output is the model's to read, and the file is still there for tail |
| X-09 | notice persistence and lifetime | none | messages.jsonl records a notice as {"role":"user", …, "notice":true}; an old log without the key reads back as an ordinary user message, and a resume replays a notice as its one dim headline rather than a ❯ block. Jobs die with the process (Jobs::kill_all on every exit path, synchronous killpg), so a resumed session never inherits one — a job that must survive has to detach itself |
the flag is additive and omitted when false, so the format stays backward- and forward-compatible. Killing on exit is the honest half of the promise: iota supervises what it started, and cannot supervise across processes |
| X-10 | the positional argument | iota <name> looks name up in four namespaces — agents: → models: → providers: → a built-in provider type — so one word can mean four things and a name defined twice silently changes what runs |
the positional is the command: a closed set of verbs (run, list, resume, config, version), with a bare iota meaning run. iota run <name> resolves agents: ALONE; a models: or providers: entry is reached through an agent. unknown provider "x": not a configured alias or a built-in type… became unknown agent "x" + the configured agents (or run \iota config init`), and provider argument is required …becameno agent to run: …` |
brain page cli-surface-agent-first (2026-09-10). --help is now the complete map of what iota can do, and a verb added later can never shadow an agent someone named. The rejected alternative — verbs as reserved words, everything else an agent name — turns a future verb into a break of an existing config |
| X-11 | six configuration flags | -k/--key, -u/--url, -t/--temperature, -S/--system-input, --context-window and the boolean --agent |
all removed; the parser refuses them (exit 2). The key comes from the env var or providers.<name>.key, the URL from providers.<name>.url, the temperature and the window from models:/agents:, the prompt from agents.<name>.system, and agent mode from agents.<name>.workspace. API key is required: use -k/--key or set X → API key is required: set X or providers.<name>.key in your config |
a flag stays on the command line only if it describes THIS call; a key there lands in the shell history and in ps, and iota hands the model a shell tool (X-01). -M stays: models: is a candidate SET, and something has to pick from it |
| X-11a | -c/--config placement |
a cobra persistent flag: valid before or after the command | the same — declared once on the root as a clap GLOBAL argument (iota -c f.yaml list = iota list -c f.yaml), for every verb that reads a config. The other seven run flags are NOT global, and one given before another verb is refused by Cli::check_flag_placement rather than silently ignored |
clap's args_conflicts_with_subcommands would have said that itself, but with it set ANY root argument stops the verb from being recognised — a global -c and that setting are mutually exclusive, and the path that works in both positions (git -C, cargo --config) is worth more |
| X-12 | --resume |
--resume[=<id>], bound with clap's require_equals + default_missing_value=" " to emulate pflag's NoOptDefVal — so iota --resume abc -m hi read abc as the PROVIDER and the resume as blank (D-42) |
iota resume [<id>]. The pflag emulation and CliError::ResumeNeedsId are gone; the headless refusal is iota resume needs a session id with -m (the picker is interactive): try \iota list sessions`, and --no-save cannot be combined with --resumereads… with iota resume. A resume runs under agents.default` |
the id is a positional of its own verb, so nothing competes for the token |
| X-13 | -l |
-l lists configured providers with a key; -l <provider> FETCHES that provider's models over the network (failed to list models: failed to list models: …, D-21) |
iota list [agents|models|providers|sessions], all four read from the config and the session store — no key, no network. iota list models <agent> shows that agent's candidate set. CliError::ApiKeyRequiredForList and CliError::ListModels are gone; the network listing survives only where it is used, in the interactive /model picker |
with three layers, "list" had four possible answers and -l said which one only by accident. Reading your own config should not need an API key |
| X-14 | --version |
cobra's --version was disabled (disable_version_flag = true); the binary had no way to say its version |
iota version and --version both print iota <version> |
an omission, not a decision |
| X-15 | the one-layer config | a providers.<name> block carrying model- and agent-level keys is SPLIT into the three entries it means, with one deprecation line per block (src/config/migrate.rs); unknown keys are ignored everywhere; tools: {agent: …} is renamed to skills and tools: {delegate: …} dropped, each with a warning (X-02) |
the migration layer is deleted. Every key is audited against the layer it was written in BEFORE the document is decoded: a key of another layer, a retired key, an unknown key, an unknown top-level key and an unknown toolset are all ERRORS naming the coordinate and the file (config ~/.iota.yaml: providers.p.model: \model` is now a `models:` entry — …). AgentConfig.migrated/ModelConfig.migrated` and the candidate-set warning exemption they carried are gone, so every agent is treated alike |
the user's decision of 2026-09-10: iota is an agent tool, not a chat tool, and it is a NEW program with no released binary whose configs need carrying. A key that silently does nothing is the failure mode the audit exists to close; a YAML syntax error still drops the file with a warning, because the parser is the only thing that knows what went wrong |
| X-16 | iota config |
none — there was no way to create, locate or validate a config | iota config init writes a commented starter file (refusing to overwrite one), iota config path prints the files a run reads, iota config check loads them and reports the three layers, warning when no agents.default exists |
the zero-config start (OPENAI_API_KEY=… iota openai -M gpt-4o -m hi) was given up deliberately with X-10; init is where it went |
| X-17 | which shell runs a bash call | exec.LookPath("bash") + bash -c per call, and nothing else could ever run one | the same bash -c on Unix, and on Windows the first interpreter the machine has: Git Bash (IOTA_GIT_BASH_PATH, else the ../../bin/bash.exe sibling of the git.exe on PATH, else the two default install roots) → pwsh.exe → powershell.exe → cmd.exe (%COMSPEC%). The arguments follow the interpreter's file name, not the platform (-c; -NoLogo -NoProfile -NonInteractive -Command; /C), and the resolution is one pure function over an injected machine (shell::interp::detect), so the Windows ladder is unit-tested on macOS. A bare bash.exe on PATH is deliberately not accepted — on Windows that is usually the WSL launcher, whose filesystem is not the project's. On Windows without a sandbox, PWD is also not injected: what we would hand a POSIX shell there is a C:\… path, whose separators it reads as escapes | the three largest agent CLIs (Claude Code, Codex CLI, Gemini CLI) all drive the system shell rather than embed one; the embedded-interpreter route (crush) costs +2.95 MiB and a busybox-style multi-call binary for a platform with no users yet. cmd.exe is kept as the floor because dropping it has a price tag on record — Gemini CLI #26567, where a group policy blocking PowerShell left the CLI with nothing to run |
| X-18 | the shell tool's description | one fixed text, since one shell was the only possibility | the DESCRIPTION is the running interpreter's: its first sentence names it (Run a PowerShell command line…), and the dialect it teaches is that shell's own (; chaining, object pipelines, $null, here-strings for PowerShell; &&, NUL, %VAR%, ^ for cmd). The command argument's example, the background paragraph's tail/nohup spellings, the receipt's progress command and the call header's cd chaining all follow the same fork (shell::interp::Interpreter::{args,tail_command,chain_cd}). The NAME does not move with it (X-20), which is exactly why the first sentence must name the interpreter | the description is the model's only source of truth for the dialect it is writing — a fixed POSIX text in front of powershell.exe is not a small inaccuracy, since every command line it produced would be wrong |
| X-19 | IOTA_SHELL / IOTA_GIT_BASH_PATH | no such thing | IOTA_SHELL names the interpreter for every shell call on EVERY platform (absolute path, or a name on PATH; on Windows the extension may be left off), and IOTA_GIT_BASH_PATH names Git Bash on Windows. Either one naming something unrunnable FAILS the call — IOTA_SHELL is set to "…", which is not an executable on this system — rather than falling back down the ladder | goose's GOOSE_SHELL and Claude Code's CLAUDE_CODE_GIT_BASH_PATH are the two shapes users already know. All-or-nothing because the description is written from the resolution: a silent fallback would tell the model one shell while the user's configuration named another, and the failure would arrive one wrong command line at a time |
| X-20 | the shell tool's name | bash, the only shell Go could run | shell, on every platform and under every interpreter (tool::builtins::shell::SHELL_TOOL_NAME); the config key was already tools: shell:, so the two now agree. The header reads [shell git status], and the tool description carries the dialect instead — its first sentence names the interpreter, which is a REQUIREMENT of this name, not a nicety (X-18) | a 378-call experiment (7 models, 2×3 design, n=9 per cell) measured what a model writes when the name and the description disagree, and the answer is asymmetric: name bash + description PowerShell → 95% PowerShell, but name powershell + description bash → still 68% PowerShell, with gpt-5.5, gemini and glm going 9/9 by the NAME. powershell in either slot wins; bash is the unmarked default and carries almost no signal. A neutral name removes the conflict instead of betting on which slot wins: shell + a bash description produced 98% bash, shell + a PowerShell description 100% PowerShell — one call each from the matching-name controls, i.e. no measurable cost. The cell this does NOT cover is a generic name with a description that does not name the interpreter, which is why that combination is forbidden |
| X-21 | the release targets | .goreleaser.yml cross-compiled six: linux/darwin/windows × amd64/arm64, CGO_ENABLED=0 — pure Go needs no toolchain per platform, so one runner produced them all. tar.gz everywhere but Windows, .zip there, one checksums.txt | five targets, each on its own native runner (dist-workspace.toml): aarch64/x86_64-apple-darwin, aarch64/x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc. .tar.xz for the Unix three, .zip for Windows, a .sha256 per asset plus sha256.sum. Installers: iota-installer.sh, iota-installer.ps1 and the Homebrew formula. aarch64-pc-windows-msvc is not in the list | aws-lc-rs assembles C, so a cross-compile is not free the way Go's was — a native runner per target is the cheap answer, and NASM is installed on the Windows one through .github/build-setup.yml (X-23). ARM Windows is the one Go target dropped: dist 0.33.0's KNOWN_GITHUB_RUNNERS has no ARM Windows machine, so asking for that triple silently produces a messense/cargo-xwin cross-build in a Linux container instead — a toolchain this crate has never been through, which the Windows-gated NASM step would not even fire for, and which nothing in ci.yml would catch before a tag went red. The three conditions for adding it back are written where the target list is — as is the user-visible cost: the .ps1 installer stops with could not find binaries for this platform on an ARM machine rather than installing the x86-64 build under emulation |
| X-22 | signing the artifacts | none: goreleaser signed nothing on any platform, and a Go binary carries no signature of its own | macOS binaries arrive ad-hoc + linker-signed and verify-macos-signing (a global-artifacts-jobs custom job) blocks host if codesign -v fails on either Darwin archive. Windows artifacts are NOT signed, and README's Releases section, docs/install.md on the site and the installer's own page all say so rather than letting the user discover it from a SmartScreen dialog | the asymmetry is not an oversight but a cost: macOS signing is free because the linker already did it — the job only guards against strip = true invalidating it — while Authenticode needs a purchased certificate and an account with a signing service, which this project does not have. The honest substitute is the checksum, so every asset ships with one and both README and the site say to compare it |
| X-23 | build steps the release needs | .goreleaser.yml needed none: CGO_ENABLED=0 meant no compiler, no assembler, nothing to install | .github/build-setup.yml, pointed at by github-build-setup, is spliced into the generated build-local-artifacts job right after the checkout. It holds one step — ilammy/setup-nasm@v1, guarded by runner.os == 'Windows' — because aws-lc-sys will not assemble its x86 code without NASM | release.yml is generated and dist generate rewrites it whole, so the same step added there by hand would vanish at the next config change — silently, and the damage would only show on the next tag. ci.yml is hand-written and carries its own copy; one step duplicated across two files is cheaper than a mechanism to share it. The guard is needed because build-local-artifacts is one matrix job over all five targets |
| X-24 | when a model's parameters apply | the four tunables were read ONCE, at startup: applyTuning folded the agent's overrides into the model's and handed the result to the provider, and /model only ever called SetModel + m.SetModel afterwards. Switching model inside a session therefore kept the window, effort, temperature and top_p of the model it left — a 400k window still counting against a 128k model — and context_window existed on models: alone | the four are LAYERED and re-evaluated whenever the model changes: agents: (a hard override, now including context_window) → the new model's models: entry → the session's current value. That last tier carries a SOURCE (config / user / builtin, persisted in meta.json as param_sources), and a value the session inherited from a declaration is dropped rather than carried onto a model that declares none, while one the user typed in /model is kept. A models: entry whose provider:id the chat switches to is found by that pair; an id the config never mentions leaves the model layer silent. src/config/params.rs is the rule, src/repl/liveparams.rs applies it | brain page model-param-layering (2026-09-14). A window is a property of the MODEL, so carrying one across a switch is not a preserved preference but a wrong number; the source tag is what separates the two cases without asking the user to re-state a choice they already made. agents.<name>.context_window follows because an agent knows how long its conversations run, and forking a models: entry per usage to say so is the duplication the three layers exist to remove |
| X-25 | what meta.json carries | the Go struct's fields, top_p not among them: it was config-only, never recorded, never replayed | top_p and param_sources join it. Both are omitted when they have nothing to say, so a bundle that predates them keeps its byte shape and still resumes — and its silence is read the conservative way: every value it carries is treated as the USER's own (ParamSources::LEGACY), which KEEPS it across a model switch. The presence of param_sources is itself information: a bundle that has it has said everything, so a parameter it records no value for is one the session deliberately runs without, not a gap for the config to fill. 2026-10-13: a /session swap reads the bundle by this same rule (Declared::resume), sources included — until then it replayed the values but kept the swapped-out session's source map, and any knob the bundle did not record stayed as that session had it | X-24. A resume is not an evaluating moment — it restores what the session was running under — so the bundle has to be able to say "no effort" as distinct from "nothing recorded", and a pre-layering bundle cannot |
| X-26 | the /model picker | one listing of ONE provider — the one the run resolved to — and a BINARY outcome: a searchable list when it succeeded, a bare Model input field when it did not (manual = error.is_some() \|\| models.is_empty()), preceded by Fetching models failed: … — enter a model name manually. printed over the transcript | the rows are the AGENT'S CANDIDATE SET (agents.<name>.choices): models: entries and inline provider:ids go straight in, and every provider:* wildcard is asked CONCURRENTLY under one cancel scope (ESC drops all of them). A source that fails costs exactly its own rows — one line in the panel's PROMPT row, naming the provider — while every other source lists as if it had never been asked. The panel is a COMBO: its input row is open from the first frame, filters the list as you type, and commits its own text through a navigable use "…" as typed row. manual_panel, the manual fork and that printed error line are gone. A row is one (provider, id) pair and is NAMED the same wherever the chat is: provider:id as -M writes it, or the bare id when every choice is on one provider — read off the choices, not the rows, so a source that lists nothing rewrites no other row; the choices' models: entries serving the pair follow in brackets, all of them in declaration order (mock:fake [m], vendor/x [a, b]; an entry named after its own id adds nothing and is left out). The only annotation a row carries is (current) — where the chat IS; what picking a row DOES is not foretold on it. Picked, a row on another provider than the chat's MOVES the chat there (the end state of a late -M provider:id; once a model has replied, of iota resume <id> -M provider:id, after a confirmation) and says so (Model switched to {id} on {provider}), and in a chat that never moves (a bot) it is REPORTED rather than applied. The field reads typed text as -M reads its argument: provider:id when the provider is one iota knows, a models: entry by its name, listed among the choices or not (the choices decide the ROWS, not which names the config knows — as for -M), provider:* names no model and commits nothing, and anything else is a raw id on the chat's endpoint (src/repl/catalog.rs::{row_label, parse_typed}) | brain page config-three-layers (2026-09-10), roadmap Phase 1b §10. A provider that cannot list its models is usually one that never implements the endpoint — relays, mostly — not one having a bad minute, so an input that appears only after a FAILURE is the wrong shape: it is the first-class way to name a model, and the list is completion for it. The candidate set is what the agent says is good here, so it is what the picker offers. The move rebuilds the provider, its title twin, the parameter layers, the catalog and the bundle's provider (2026-10-10). From 2026-10-10 to 2026-10-12 a chat a model had replied in could not move — its history was taken to be its dialect's — while iota resume <id> -M moved the same history; the history is tagged per record and each dialect replays only its own payload, so the refusal was a product choice, and it sent the user to a NEW session (iota run … -M). A replied chat now moves the way resume -M moves it, in the process: what it holds is saved first, under the type it was said in (a reply saved after the writer's type changed would lose its payload; a save that fails, or an ephemeral chat, refuses the move — /save first); the endpoint must be the same kind of model (token accounting, image generation — nothing a run settles at startup is rebuilt); the log is read AGAIN for the target's type, because the view this process holds was loaded for the type it is on, and a reply the target's own dialect made in an earlier run lost its payload on the way in; and before anything is written the user is told how many replies' payload the target does not replay, where iota resume <id> continues afterwards and the way back, and agrees (Ui::confirm). A move that is refused names iota resume <id> -M provider:id when the chat has a bundle to resume, the new session otherwise. Until 2026-10-12 a row dropped the provider of the endpoint the chat was ON, so the prefix doubled as the mark of a row that moves the chat — and a chat that moved rewrote every row (deepseek-flash (current) became deepseek:deepseek-flash); the entry names were on no row, and a name typed in the field went out as a wire id (fast → Model switched to fast). The name now says what the row IS, and the field takes the words the rows show. From 2026-10-10 to 2026-10-13 a row on another provider also carried what picking it would do — (moves the chat), or (needs a new session) in a bot — added on 2026-10-10 because the dropped prefix had been the only mark of such a row, and a pick that changes where the chat runs should not be a surprise. It was removed on 2026-10-13 (owner's decision): every consequence is already reported by the action itself — Model switched to {id} on {provider} before the first reply, the confirmation (with what the target does not replay and the way back) after it, the refusal with the command to run in a chat that cannot move — so the list was announcing ahead of time what the action says when it happens. Until 2026-10-13 the field — and -M — read a bare word as an entry only when the agent's CHOICES named it, anything else as a raw id: a rule from 2026-09-10, made so that -M openai kept its meaning on a config the migration layer had turned into implicit agents; that layer went on 2026-09-11 (01d0296) and the reason with it, and once X-48 narrowed an agent's default choices to its model:, the rule sent -M deepseek-flash from agents.default: {model: glm} to glm as a wire id. A name the config knows is now that entry wherever it is typed (owner's decision, 2026-10-13); the choices still decide what is listed and what warns. Pinned by catalog::tests (a_rows_name_does_not_follow_the_chat — the rows read the same before and after a move, a_row_names_every_choice_serving_it, the_implicit_provider_is_the_sets_own, typed_text_follows_the_model_flag_rule) and tests/repl/commands.rs (model_moves_a_chat_with_no_reply_to_another_provider before and after the move, model_reads_a_typed_choice_by_name, model_reads_a_typed_entry_outside_the_choices_by_name, a_chat_that_has_replied_moves_once_the_user_agrees, a_move_back_to_a_dialect_the_session_ran_on_replays_its_payload) |
| X-27 | NO_COLOR on the chat side | fatih/color's process-global NoColor — set at init when NO_COLOR is non-empty, or TERM=dumb, or stdout is not a terminal — and every chat style (DimStyle, ErrorStyle, CodeStyle, Bold, Underline, UserBlock) is a fatih color.Color, so under any of the three the chat side wrote plain text: no color, no attribute, nothing | the same three tests, in the same order of authority, decide ONCE at the binary edge (color::ColorMode::detect, main.rs): (1) NO_COLOR set to a non-empty value — presence, not value, per no-color.org; an empty value is unset; (2) TERM=dumb; (3) stdout not a terminal. The answer is a process-wide OnceLock read by every repl::styles wrapper (which then hand the text back untouched — bold, faint and reverse included), by the RenderOptions { color } of the live turn, the replay and new_writer_to, by render_diff in the activity group and by every OSC 8 hyperlink. Until 2026-09-14 none of that existed: color: true was hard-coded at every entry and styles emitted unconditionally, so NO_COLOR=1 still received bold, faint, underline and — through crossterm's own gate, which fires inside an SGR it has already opened — a bare \x1b[m where each color had been, and TERM=dumb did nothing at all. The half-block image rasteriser (imgterm) is deliberately outside the switch: its truecolor cells are the picture, and a monochrome picture is not a less decorated one but a missing one | roadmap §3 #2. The chat side is TEXT — copied, exported, replayed — so "no color" has to mean no escape at all, which is also what Go's chat side did. A library process that never decides renders with color ON: that is what every SGR golden in the tree relies on, and what a terminal user gets. Pinned by src/app/color.rs (the parser), tests/nocolor/main.rs (a whole interactive run — banner, streamed sentence, tool header, diff artifact, a markdown document with every styled block, a red error — scanned byte by byte for \x1b[/\x1b] at the facade) and tmux scenario 16 (a committed heading row read back with capture-pane -e) |
| X-28 | NO_COLOR on the frame side (ui::render::theme, the composer, the status row, the surfaces) | lipgloss v2 over colorprofile: under NO_COLOR the renderer degrades to the Ascii profile, which strips every foreground and background color and KEEPS bold/faint/underline/reverse — a focused tab chip or a cursor row is still tellable from its neighbours | the frame is IN the switch, on lipgloss's terms rather than fatih's: every byte the terminal receives passes through the one ANSI→cell parser (ui::render::spans::ansi_to_spans — the frame rows, insert_before, the surfaces), and under ColorMode::Off that boundary drops each parsed foreground and background while the modifiers reach the cell. ui::render::theme itself is untouched: it is the palette, not the decision to use it, and the gate at the emitting boundary is exactly where lipgloss's profile sits. Crossterm's own NO_COLOR gate never fires because no color reaches it, so the bare-\x1b[m reset it used to write (X-27) is gone with it | roadmap §3 #2 asked for the decision in writing. "The frame also emits no attribute" was neither achievable — a reverse-video user block with no reverse is a block you cannot see — nor the parity target, since Go's frame kept them. Exit gate (ii) is therefore "no foreground or background color code in the frame's byte stream, attributes allowed", pinned by ui::render::spans::tests (a row carrying a named color, a 256-color background, a truecolor foreground and three attributes: colors gone, attributes and glyphs kept) and by tmux scenario 16, whose pipe-pane tap of a real NO_COLOR=1 session is grepped for \x1b[…(3[0-7]\|38\|4[0-7]\|48\|9[0-7]\|10[0-7])[;m] and must still carry a faint and a reverse SGR; a control run without the variable proves the assertion is live |
| X-29 | user-facing warnings vs developer diagnostics | Go's log is one channel for both: mcp/manager.go:328 printed Warning: MCP server %s: duplicate wire tool name %s, skipping through logf, which the binary wired to stderr, so the user saw it | two channels. Users hear cmd::io::Streams::warning (headless stderr) and the transcript (interactive) — the outlets every config and assembly warning already used. The one tracing::warn! that was meant for them, the duplicate wire name in Manager::merge_result, travels in the merged ServerStatus now (duplicates, rendered by ServerStatus::warnings) and is printed by both: Warning: mcp server <name>: duplicate wire tool name <wire>, skipping after connect_all, and a dim ⚠ MCP <name>: … notice from the background reporter. Developers hear tracing, and only when IOTA_LOG=<path> installs the file subscriber (src/app/diag.rs: tracing-subscriber with fmt+std, this crate at DEBUG, everything else at INFO, no ANSI); nothing a user must see is ever written to it, so the variable is a tap and never a precondition. Cargo.toml no longer says release_max_level_off — with the switch opt-in there was nothing to gain from stripping the release build, and an unsubscribed event costs a relaxed atomic load — but release_max_level_debug: trace is the one level nothing here asks for | roadmap §3 #7. Until 2026-09-15 the duplicate-wire-name warning reached nobody: no subscriber was ever installed, and the release build had compiled the call out. The two remaining tracing::debug!s (shell/exec.rs, a failed killpg/TerminateJobObject) are diagnostics and stay on the developer channel. Pinned by mcp::manager::tests::test_manager_skips_duplicate_wire_name (the status carries it), tests/repl/commands.rs::mcp_failures_and_warnings_reach_the_transcript (the notice), tests/cmd/cli.rs::cli_mcp_duplicate_wire_name_warning (the binary, a sh MCP server listing echo twice, the exact stderr line) and the two cli_iota_log_* tests (the file gets its timestamped hello and appends; an unopenable path is one warning and the run goes on) |
| X-30 | Anthropic usage: which event the input side is read from | message_start.message.usage IS the input side (input plus the two cache counts) and message_delta.usage contributes output_tokens alone — the reading both binaries were built to (docs/design/context-compaction.md, the per-provider usage table), and the Rust port's until 2026-09-15 | message_delta.usage is laid OVER the start's figures field by field (AnthropicUsage::overlay, src/llm/anthropic.rs): every field the delta carries — input_tokens, cache_read_input_tokens, cache_creation_input_tokens, a carried 0 included — replaces the start's, every field it omits keeps the start's, and the merged figure is published when the delta lands (src/provider/anthropic.rs). Fields beside the four (server_tool_use, service_tier) are ignored, not refused | Anthropic defines message_delta.usage as the message's CUMULATIVE usage, and its own API repeats the input side there, so against it the overlay changes nothing. GLM's Anthropic-compatible endpoint (…/api/anthropic/v1/messages, glm-5.3) sends placeholder zeros at message_start and the real counts only at message_delta — under the old reading its input tokens were 0 on every call while output was right. Deliberately no "non-zero wins" rule: the delta is the cumulative figure, so a 0 it carries is a 0 (tests/provider/anthropic.rs, the two usage_delta tests; usage_overlay_… in src/llm/anthropic.rs) |
| X-31 | OpenAI-shaped usage carrying BOTH namings | encoding/json never checks for duplicate or competing keys, so a usage object carrying input_tokens beside prompt_tokens decoded without complaint | OpenAiUsage (src/llm/chatcomp.rs) decodes the wire object name by name (OpenAiUsageWire: every name of both dialects, each optional) and folds it — input_tokens is the figure, prompt_tokens fills in when it is absent, likewise output_tokens/completion_tokens and input_tokens_details/prompt_tokens_details; a figure neither reports is 0, a missing total_tokens stays 0 for openai_usage to sum. The #[serde(alias)] that used to map the pairs is gone | zenmux's Responses-compatible endpoint (https://zenmux.ai/api/v1) sends both namings in one object, verbatim in tests/provider/openresponses.rs::a_completed_event_whose_usage_carries_both_namings_reports_its_figures. serde treats an alias as the same field and refused it as duplicate field 'input_tokens' — which lost the WHOLE response.completed event: the unary path failed the call (chat error: duplicate field …), the streaming path filed it as the stream error that the partial-result rule then swallowed, so the turn showed usage 0 and exit 0 (2026-09-15, a headless probe of the real endpoint). The rule itself stands; the swallowed error is now a tracing::warn! on the developer channel (X-29), so IOTA_LOG shows a terminal event that failed to decode. Chat-completions shares the struct and had the same hole. Pinned by the three *_usage_object_decodes units in src/llm/chatcomp.rs (both namings, prompt_* alone, input_* alone) and the streaming test above |
| X-32 | what -M brings with the model | -M was one flag on one provider: it replaced the model id and nothing else, and the tunables were whatever the config declared for the run | -M provider:id (and a bare -M id, which is <current provider>:id) resolves to the models: entry serving exactly that pair — a member of the agent's candidate set first, then any entry in name order — and brings THAT entry's knobs; a pair no entry serves is a bare ModelConfig of just the provider and id, every other field (effort, temperature, top_p, context_window, defer_mode, the image parameters) at its default. agents: overrides apply as before. -M "", -M provider:* and -M <entry name> are unchanged (src/cmd/resolve.rs::model_at) | Until 2026-09-15 the two raw forms kept Resolved.model as the FIRST candidate's entry and swapped only its id, so that candidate's knobs rode onto a model that declared none: agent chat with models: [deepseek-chat] (effort: max, temperature: 1.0, top_p: 0.95) run as iota run chat -M claude:claude-haiku-4-5-20251001 sent Claude both temperature and top_p — a 400, temperature and top_p cannot both be specified. That is the leak brain page model-param-layering forbids, and the same rule /model already follows: ParamLayers::declared looks the new id up by (provider, id) and leaves the model layer silent when no entry serves it. Pinned by tests/cmd/resolve.rs::model_flag_forms (a pair no entry serves brings nothing; the pair gpt5 serves brings its temperature and defer_mode; the bare-id twins of both) |
| X-33 | effort on the gemini / vertexai dialects | the effort string uppercased and sent as thinkingLevel verbatim — xhigh → XHIGH, max → MAX — with the server's refusal as the design: no mapping, no validation at this layer | provider::google::thinking_level: low → LOW, medium → MEDIUM, high / xhigh / max → HIGH. Gemini 3's thinking_level knows exactly LOW, MEDIUM and HIGH (MINIMAL exists on Flash alone and is never asked for), so the two efforts above high clamp to the top rather than fail. includeThoughts and every other field are untouched | Until 2026-09-15 effort: max on a Vertex model was a 400 on every turn (Invalid value at 'generation_config.thinking_config.thinking_level' … "MAX", the real endpoint) — and max is what a default agent tuned for the OpenAI-shaped dialects says, so the dialect was unusable under it. The earlier ruling that an unsupported value is the API's to refuse was reversed for this one knob (2026-09-15): the levels are a fixed three-step scale, the clamp is lossless in intent, and a knob shared across dialects must not make one of them refuse the whole run. Pinned by provider::google::tests::every_effort_maps_onto_a_gemini_thinking_level and tests/provider/google.rs::an_effort_above_high_is_sent_as_thinking_level_high |
| X-34 | iota list providers key source | the listing (D-21, X-13: a Rust-only surface) said [key: config] whenever key: was set, even when the type's environment variable was set too — and a run takes the variable first | ONE precedence, config::Endpoint::api_key → ApiKey::{Env, Config, Missing}: the TYPE's environment variable when set, else key:, else nothing. resolve_run takes the value, the listing names the source, the /model catalog builds the other endpoints with it. So a provider with key: in the config lists as [key: OPENAI_API_KEY] when the variable is set too | phase5-plan §1 5d. Until 2026-09-15 cmd/list.rs::key_source checked the config first and cmd/resolve.rs::resolve_key_from_env_or_config the environment first, so the listing could name a source the run would not use. Pinned by tests/cmd/resolve.rs::list_reads_the_config (both set → the variable) |
| X-35 | Ctrl+W in a surface input field (the /model combo field, /file's path field, the Ask "Other…" editor, the search query) | back to the last SPACE: trailing whitespace skipped, then everything after the last ' ' deleted — any other whitespace was no boundary, so a<Tab>b + Ctrl+W emptied the field | the composer's rule, now the one editor's (ui::input::editor::Editor, Phase 5 PR-15): trailing whitespace skipped, then delete to the start of the previous word, any Unicode whitespace a boundary, never past the line start — a<Tab>b + Ctrl+W leaves a<Tab> | One editing core for the composer and the fields (PR-15); the two rules differed only on a non-space whitespace before the word, and the composer's is the bubbles deleteWordLeft parity the composer already had. Decided 2026-09-16. Pinned by ui::input::keys::tests::the_field_ctrl_w_is_the_composers_rule (and row_8_ctrl_w_deletes_the_previous_word_within_the_line for the composer) |
| X-36 | the composer separators | ─ (U+2500), a solid rule above and below the composer (model.go:1022-1046) | ┄ (U+2504, light triple dash): dashed, so the composer reads as a strip inside the conversation rather than a wall across it. One constant, ui::render::frame::SEPARATOR_GLYPH; the composer geometry, the frame tests and the tmux suite find the row by it. User-asked, 2026-09-17. |
| X-37 | iota mcp and MCP OAuth 2.1 | no mcp verb: an MCP server is a hand-written mcp_servers: entry, and an HTTP server is authenticated by its static headers: alone | iota mcp add|list|get|remove|login|logout (cmd::mcp_cmd) edits the mcp_servers: block of ONE file — --scope user (~/.iota.yaml, the default) or --scope project (./.iota.yaml, where a header/env value must be a ${…} reference), or the -c file alone — and the block is MACHINE-MANAGED: config::edit locates the top-level section by line and rewrites that byte range alone, every other byte kept, comments inside the block not kept. auth: oauth (mcp::config::AuthMode) marks a server iota mcp login signs in to: rmcp's auth feature (discovery, dynamic registration, PKCE, the refreshing AuthClient) over iota's file token store (~/.iota/mcp/auth/<name>.json, 0600), the browser through $BROWSER / the platform opener, a loopback callback with a pasted-URL fallback; no usable token = that one server Failed(not logged in: run iota mcp login <name>), nothing else in the run touched. The REPL gains /mcp (the panel with the login state, /mcp login|logout <name>). | Brain page mcp-cli-and-oauth, decided 2026-09-18: namebeta's MCP (POST /api/mcp) is OAuth 2.1 behind RFC 9728 discovery, which static headers cannot reach; a Rust-only surface, like iota list/config (X-13). Pinned by tests/cmd/mcp.rs, tests/mcp/oauth.rs (a mock authorization server + protected MCP endpoint, tests/common/oauth_mock.rs) and tmux scenario 23. The REPL's /mcp was withdrawn on 2026-09-20 — see X-42. |
| X-38 | the authorization request's prompt | — (no OAuth) | prompt=consent on every authorization request (OpenID Connect Core §3.1.2.1; RFC 6749 §3.1 has a server ignore a parameter it does not know). rmcp's builder has no extra-parameter hook, so mcp::auth::with_consent_prompt appends it to the URL rmcp returns — the state and the PKCE verifier rmcp stored are untouched. | namebeta's Logto answered a bare access_denied to iota's CIMD client after a successful consent page while Claude Code's CIMD client worked on the same tenant. Four browser replays on 2026-09-19 isolated the one parameter Claude sends and iota did not: with prompt=consent the callback carries a code, without it access_denied, for 127.0.0.1 and localhost alike, for either metadata document. namebeta's answer (its issue #348, 2026-09-20): Logto shows a third-party client's consent page only the resource scopes the user's roles hold; with none held, the consent path writes an empty grant and passes, while a request without the prompt is checked against that grant and refused — so the parameter stays, unconditionally, for every other tenant shaped like it. Brain page mcp-cli-and-oauth. |
| X-39 | MCP auth need not be declared | — (no OAuth; an HTTP server is its static headers:) | auth is three-state (mcp::config::AuthMode): not written = auto (the default), oauth forces the login, none forbids it. An auto server connects through the OAuth transport when a token file exists for its name, else bare, and a 401/403 at the handshake (transport::is_auth_required: a challenge, insufficient_scope, or a bare status) is McpError::NotLoggedIn — "not logged in: run iota mcp login ", the server left out of the run — with ServerStatus::login_required set so Manager::servers() gives it a login state (--probe's needs login: …; the /mcp row and hint, until X-42); auto with an Authorization header of the entry's own is none (ServerConfig::effective_auth). login/logout take any HTTP entry that is not an effective none (ServerConfig::login_refusal), and a server that neither challenges nor publishes protected-resource metadata is LoginError::NoMetadata ("does not ask for a login") rather than a login against rmcp's synthesised endpoints. iota mcp add --url writes no auth: unless --auth oauth|none says so; the client flags stand on their own. | Decided 2026-09-19 (brain page mcp-cli-and-oauth) on the neighbours' behaviour and the protocol's own signal: Claude Code has no auth key and marks a server by its 401/403; Codex defaults to OAuth and falls back from a bearer/headers entry; only pi-mcp-adapter wants an explicit auth: oauth; and MCP's authorization spec makes the 401 with WWW-Authenticate resource_metadata THE discovery signal. Declaring it was one more thing to know before the first connect could tell you. Pinned by tests/mcp/oauth.rs (the undeclared entry's 401 → login → OAuth chain, the header and none cases, bare 401/403, the no-metadata refusal), tests/cmd/mcp.rs and tmux scenario 23 (now on an undeclared entry). |
| X-40 | the login's scopes, and what a refresh repeats | — (no OAuth) | offline_access goes on the authorization request whenever the authorization server lists it, with or without resource scopes (mcp::auth::login_scopes); the token store hands rmcp no empty scope (without_empty_scopes, on save and on load); a refresh the server refuses for a reason other than a dead refresh token is reported as rmcp's own sentence plus run iota mcp login <name> (transport::auth_failure), and a dead one (TokenRefreshRejected) as not logged in. | namebeta, 2026-09-20, after its resource stopped naming scopes (its issue #348): Logto refused an authorization request with no scope at all (access_denied, consent given), and took one with offline_access alone; its token for a grant with no resource scope says scope: "", which rmcp keeps as one empty scope and puts offline_access beside on a refresh — invalid_scope: refresh token missing requested scope — while a refresh naming no scope goes through. The run's report of that was a transport dump. Pinned by tests/mcp/oauth.rs (the pre-registered login asking for offline_access alone, the empty-scope refresh, the refused refresh). |
| X-41 | iota mcp add --url connects | — (no iota mcp) | Once the entry is written, add --url probes the endpoint with one bare initialize POST (mcp::auth::probe, 10 s: 401/403 = NeedsLogin with the challenge parsed, 2xx = Open, anything else = Unknown(why)) and on NeedsLogin runs the flow of iota mcp login then and there — The server asks for a login; starting it…, the same step lines, --no-browser passed through; Open adds nothing to Added …; Unknown keeps the entry and prints could not reach <url> (<why>); if the server asks for a login: iota mcp login <name>, exit 0. --auth oauth or --client-id skips the probe and logs in at once; --auth none, an own Authorization header (effective_auth() == None) and the new --no-login write the entry and stop, the last with the old Next: / if the server asks for a login: hint. A login that does not finish leaves the entry, reports as login does plus Retry with: iota mcp login <name> (RunError::McpAddLogin), exit 1. --scope project entries are probed and logged in the same way, the token still under ~/.iota/mcp/auth/<name>.json. The REPL has no /mcp add, and none is added. | Decided 2026-09-20, reversing the day before's "add does not connect" (X-39, brain page mcp-cli-and-oauth): Codex's codex mcp add runs OAuth discovery before it saves and, when the server supports it, prints "Detected OAuth support. Starting OAuth flow…" and opens the browser, staying silent otherwise and pointing at codex mcp login only when discovery could not tell (codex-rs/cli/src/mcp_cmd.rs, run_add) — the one command a user runs to add a server is where the login belongs, not a second command to remember; Claude Code's add still does not connect. The probe is a bare initialize rather than rmcp's discovery because the 401 at the endpoint IS the protocol's signal (X-39), and a server that answers it needs no discovery at all. Pinned by tests/cmd/mcp.rs (the 401 → login chain through $BROWSER and through --no-browser, --no-login, an open server, an unreachable one, --auth none and an own header, a refused login keeping the entry) and tests/mcp/oauth.rs + mcp::auth::tests (the probe's three answers); tmux scenario 23 has no add and stays as it is. |
| X-42 | /mcp leaves the chat | — (no /mcp, T-23) | The /mcp panel and /mcp login <name> / /mcp logout <name> of X-37 (0.3.1) are removed — src/repl/commands/mcp.rs, its command-table row, dispatch arm and tests. The chat's view of the servers is the MCP tab of /tools (mcp_status_lines: state, endpoint, tools, and for a Failed server the first line of why — a server waiting for a login reads not logged in: run iota mcp login <name>, mcp::auth::not_logged_in); the startup notice says that same sentence (⚠ MCP <name> not logged in: run iota mcp login <name>, render/mcpreport.rs) instead of naming a slash command; login and logout are the CLI's alone, and a chat already running does not pick a new token up — the next one connects. Kept: Manager::login/logout/reconnect, ServerStatus::login/login_required (list --probe reads it) and McpHooks::manager — the in-session entry the config toolset is to call. The 80-column banner's command row fits on one line again. | Decided 2026-09-20 (brain page config-tools): the REPL takes no more management slash commands. /tools already showed what /mcp showed, and configuration — adding a server, logging in, model parameters — is to be a built-in toolset the model calls with the user confirming, over the same config::edit the CLI writes through, rather than one slash command per verb; the chat surface is back at Go's parity (T-23). Pinned by tests/repl/commands.rs and src/repl/commands/mod.rs (the command table without /mcp) and tmux scenario 23 (the notice, the MCP tab, iota mcp login and logout run beside the pane, a restart that connects). |
| X-43 | the built-in harness prompt | none: the system message is the user's system:/system_file:/-s alone (plus the AGENTS.md overlay in agent mode), and a run with no prompt sends no system message at all | An agent with tools: (a key naming a set and not written false) sends a fixed paragraph AHEAD of its own prompt (agents::harness, brain page harness-prompt): two sentences of identity and two behaviour rules (a declined tool call is not retried; a sandbox refusal is reported as such), an <environment> block (project root, OS (arch), the interpreter the shell set runs, the local date, the canonical binary, the user and project config files or the -c file — (absent) where missing), and with the shell set an <iota_cli> block (iota runs outside the sandbox as the first word; the mcp/config/list/run verbs; MCP through iota mcp, everything else by editing the file and iota config check, changes from the next session). The whole paragraph is capped at 1.5 KB (HARNESS_CAP, pinned). compose_send_history(history, harness, overlay) builds the System message as sent — harness, <instructions>\n{system}\n</instructions> when the user wrote one, overlay — and synthesises one when the history has none; with an empty harness it is Go's composition byte for byte. Never persisted: a resumed session and an upgraded binary get the current version; /model's System tab renders the same composition. No configuration key. | Decided 2026-09-20 (brain page harness-prompt) on the neighbours: Claude Code, Codex, Gemini CLI, OpenCode and pi all put a harness paragraph of their own ahead of the user's prompt; iota's behaviour knowledge was scattered across tool descriptions and the overlay, and a fresh install with code + shell had no idea it could run iota mcp add. The trigger is tools: rather than a key because the users who need <iota_cli> most are the starter config's, whose workspace: is commented out; an agent without tools is a chat or a JSON pipeline and must not change. Pinned by agents::harness::tests, agents::tests::compose_send_history_puts_the_harness_first, repl::systemtab and tests/repl/settings.rs (the tab), tests/headless/output.rs (both send paths, the delta without it) and tests/cmd/cli.rs (the binary against wiremock: this run's own facts, no <iota_cli> for code alone, the bare prompt without tools). |
| X-44 | iota itself runs outside the shell sandbox | a sandboxed shell call is always sandboxed; a child agent (iota run <agent> -m …, X-01) therefore needed the parent's tools.shell.network: true, and iota mcp add/login from the model could not write $HOME or open a browser | shell::selfcall::is_self_invocation: a command whose first word — read with POSIX quoting — resolves (a path as written, a bare name on PATH) to the same canonical file as HostDirs::exe (app::canonical, resolved once at the binary edge), and whose line is one simple command (no \|, &&, \|\|, ;, &, newline, $(…) or backtick outside single quotes; redirections allowed), is spawned by the shell tool with sandbox: None. Nothing else changes: network/write are the sandbox's; approval is what an unsandboxed set would ask — Tool::requires_approval(Option<&JsonObject>) is per call now, auto_run waives — the header and prompt carry (outside the sandbox) from the one header_summary, and such a call never rides a parallel batch (X-05), which has no gate. Windows and sandbox: off have nothing to leave. | Decided 2026-09-20 (brain page harness-prompt, reversing subagents-via-bash premise 4): the sandbox refuses exactly what iota's own CLI does — write a config file, open a browser, reach an API — and a user who installed the binary trusts it; the first-word rule is what keeps iota mcp list \| head and $(…) in, and quoted punctuation literal so a child's task text is free. The mark is on the header because that is what the user is being asked: not "may this run" but "may this run where the others do not". Pinned by shell::selfcall::tests, tests/tool/shell.rs::the_sandbox_lets_iota_itself_out_and_nothing_else (a stand-in binary writes where the sandbox forbids; the same binary in a pipe cannot; the approval/mark/batch policy around it) and tmux scenario 24. |
| X-45 | a first run without a config | the Go binary ran on flags alone (-k/-u/-M), no file needed; the agent-first surface (X-10) then required an agents: entry and sent a bare iota to iota config init | a run with no -c and no .iota.yaml at either tier writes the starter to ~/.iota.yaml (cmd::config_cmd::auto_init, the same bytes as config init), names it on stderr and goes on; a config in either extension, a -c, or no home → nothing written. ArgsError::NoAgent and the UnknownAgent hint no longer point at config init (a run without a file never reaches them) but at where an agents: entry goes. | Decided 2026-09-21: the zero-config start given up on 2026-09-10 comes back as a written file rather than as flag-only runs — every neighbour (Claude Code, Codex, Gemini CLI) creates its state on first launch, and a user who has the key in the environment should not have to type one more command first. Pinned by tests/cmd/cli.rs (cli_first_run_*) and interactive_cli.rs. |
| X-46 | the startup banner | four to six dim rows printed before the loop (chat/run.go:86-105): Chat started. Press Ctrl+C to exit., Commands: /file, /session, … (the registered table), Session: <id> or the /save hint, and in agent mode Agent mode: AGENTS.md loaded (N files, K KB) / Agent mode: N skill(s) available, then the skill warnings | a card (render/banner.rs): three rows inside a rounded frame (╭ ─ ╮ │ ╰ ╯, dim) that hugs the widest of them — ι> iota (cyan) two blanks from the version (v0.4.0, dim); the mode row (chat, agent or bot, green, cyan or yellow, then session <id>, resumed <id> or not saved · /save keeps it, then in herdr or in cmux inside a detected host — the ANSI fallback is not one, X-47), every · in it dim, the one inside not saved · /save keeps it included; and the directory the chat runs in — the project root in agent mode, else the cwd, the home shortened to ~, and cut in the middle (text::width::truncate_middle: head + … + tail) to the columns inside the frame (the terminal's width less four) so the row never wraps. Each row is padded to the widest; widths are measured before the styling goes on (text::width::str_width). A terminal narrower than the mode row plus the frame's four columns — 38 in a plain terminal (agent · not saved · /save keeps it), more when the row names a host — gets the three rows bare, the directory cut to the whole width; NO_COLOR loses the colors and keeps the frame. The skill warnings still follow, dim; the AGENTS.md and skill counts do not. The ONE-TABLE law is pinned on the completion row now (tests/repl/commands.rs, file.rs, tokens.rs): what / offers is the registered table, in dispatch order. From 2026-09-21 to 2026-09-22 (0.4.0) the left of the three rows was a 20-column iota wordmark in half blocks (cyan), dropped under 57 columns. | Decided 2026-09-21: no neighbour lists its commands at startup (Claude Code and Codex print two or three lines of text, OpenCode a three-row wordmark, Gemini a large logo), the model is already on the status row, and the commands are one / away in the composer. What a first look needs is what the chat IS — which build, which mode, saved where, in which directory. The card replaced the wordmark on 2026-09-22: twenty columns of half blocks on every row took more of a first screen than three facts warrant, and the blocks sat badly beside the composer's dotted separators; a thin frame that hugs its content reads as one object next to them. Pinned by banner::tests, tests/nocolor (the card bare under NO_COLOR, frame included), tests/repl/{commands,host}.rs and tmux scenarios 01/02/05/08. |
| X-47 | the herdr host, and what a host tells the model | one multiplexer host, cmux, driven through its CLI (set-status, workspace loading, clear-status); every detector that matches is kept ahead of the ANSI fallback; a host is told the state and pinged, nothing more; the -m run has no presenter | host::herdr::HerdrHost, detected from HERDR_ENV=1 + HERDR_PANE_ID + HERDR_SOCKET_PATH (on a platform without Unix sockets HERDR_BIN_PATH too): the chat's state (Busy→working, NeedsInput→blocked, Idle and Error→idle — a failed turn leaves the user free to type, not a decision pending; the loop reports Idle as soon as the banner is up, and the presenter starts with no state rather than Go's Idle so that first report goes out and the pane is listed before its first turn), the session it persists into (pane.report_agent_session: the writer's id and bundle directory — at start-up, after /save, after a /session switch, after /new, X-75) and a release on exit (pane.release_agent) go to herdr as one JSON line per request over its Unix socket (pane.report_agent; source and agent are iota; id is iota:<seq>; seq starts at the start-up time in nanoseconds, so a restart in the same pane never goes backwards and herdr's "newest seq wins" holds across processes), from one worker task over a bounded, ordered queue — the call sites never wait, 500 ms per request, every failure a tracing::debug!; the CLI equivalent (herdr pane report-agent … --seq N) through HERDR_BIN_PATH where there is no Unix socket. herdr implements neither Notifier (the OSC 9 ping stays the terminal's, exactly as for cmux; herdr words its own notification from the state, so blocked carries no message) nor BackgroundReporter (a herdr pane answers OSC 11 itself). Two capabilities are new to Host: SessionReporter (served by the first host) and EnvironmentContributor (gathered from every host: host: herdr, herdr pane: <id>, herdr workspace: <id> and herdr tab: <id> when injected; host: cmux, cmux surface: <id>), whose key: value lines close the harness prompt's <environment> — which is therefore composed once the branch has its Presenter (cmd::assemble::HarnessInputs); the detected host's name (Presenter::detected_host) also ends the banner's mode row (· in herdr, X-46). The -m run has a presenter too (the detected host alone: no ANSI fallback, no pings): working from the resume stage to the reply, idle after it, released before the servers close. And the innermost host is exclusive: DETECTORS runs inner to outer (herdr, then cmux) and Presenter::new keeps the FIRST match plus the ANSI fallback, so a herdr pane inside a cmux window — whose CMUX_SURFACE_ID is the herdr server's, not the pane's — reports to herdr and never runs a cmux command. What every host is shown is the turn's state with one rewrite (2026-09-24): an idle turn while a background job runs — or while a job that ended still owes its notice, which the loop has not taken yet (Presenter::set_jobs, fed by the registry's watch; Presenter::notice_taken, at the loop's wake-up and at a round boundary's drain) — is shown Busy; Busy, NeedsInput and Error pass through, and the -m run is not wired (it is Busy to the end anyway). The owed notice closes the gap between the registry dropping a job and delivering its notice, which would otherwise flash idle. | Decided 2026-09-21 (brain page host-integration). herdr recognises an agent by watching the pane's screen until the agent reports; once a source has reported, it is the pane's lifecycle authority and screen recognition stops — its documented "integrate your own agent" channel — and its CONTRIBUTING closes pull requests from outside a named list, so reporting from iota's side is the way to have the pane's badge right, headlessly included. One detected host because hosts nest and a pane's environment is its spawner's. Pinned by host::herdr::tests (detection, the wire shapes, the sequence and seq, a stuck transport), host::tests::test_new_presenter_keeps_the_innermost_host_only, tests/host/herdr.rs (a mock socket: the presenter's calls as requests, a nonexistent socket never blocks, the environment lines), tests/repl/host.rs (the approval turn and /save over the mock; a yielded job held Busy until its notice turn ends) and tests/cmd/cli.rs (a headless run's three requests and its <environment>); the job rewrite by host::tests, tests/host/herdr.rs (a_running_job_keeps_the_pane_working) and tmux scenario 25 (OSC 9;4). A job counts because its result comes back as a turn of its own: the chat is not done until that turn is, and herdr agent prompt --wait waits for it (user, 2026-09-24). |
| X-48 | agents.<name>.models → model: + choices: | no agent layer: -M on a provider, or providers.<p>.model. Since X-15 the agent's ONE list, models: [a, b, "p:*"]: the first entry the model the run starts on, a wildcard first = start in the picker, models: at least one model is required | two keys. model: — the model the run starts on, a models: entry by name or an inline provider:id; a provider:* is refused at validation (agents.a.model: "openai:*" is a wildcard — put it in choices: and leave model: unset to start in the picker); unset = the run starts in the picker, on the endpoint of the first choice (its provider, no id, none of its knobs). choices: — what /model and -M pick from, the three reference forms as before (one_or_many); unset = the agent's model: alone (an entry by name, an inline provider:id as written), and only when model: is unset too — the agent that says "let me pick" — every models: entry in DECLARATION order (Config::model_order, kept beside the name-ordered map, first-declared place across the merged files; no implied wildcard); applied by Config::resolve_agent so Resolved.agent.choices is always the effective set. A model: the choices do not cover warns and stands (Warning: config agents.a.model: openai:gpt-4o is not in choices (using it anyway)), the rule -M already followed — whose own warning now ends …'s choices (using it anyway). The old key is refused: agents.a.models: `models` is now `choices:` (what /model and -M pick from) plus `model:` (the one the run starts on) (strict::RETIRED_KEYS, scoped to the agent layer; the providers.p.model hint now points at model:/choices:). Headless with neither -M nor model:: no model chosen: set agents.<name>.model or pass -M (Go's --model/-M is required when using --message/-m, D-52's deferral and re-raise unchanged). iota list agents prints the model beside the name ((picker) when unset) and the count of choices (1 choice for an agent with model: and no choices:); iota list models <agent> marks the model the run starts on with * — a * row of its own on top when the choices do not name it, none for an agent that starts in the picker. The starter writes model: gpt and a commented choices:. | Decided 2026-09-22. "The first entry is the default" made one list carry two meanings: reordering the picker's rows changed what a run starts on, and "start in the picker" needed a wildcard that also grew the list. Two keys say each thing once, and choices: defaults to what the config DECLARES because a wildcard is a network question a default should not ask. No released binary carries the old key (X-15's rule), so no compatibility — the hint alone. Reversed 2026-10-13 (the default of an agent that writes model:): from v0.4.0 (2026-09-22) to 2026-10-13 an unset choices: was every models: entry for EVERY agent, so the models a config declared for one agent leaked into every other agent's picker and -M (agents.default: {model: glm} beside a deepseek-flash entry listed both), and the outside-choices warning could never fire for an agent without choices: — nothing was outside the whole of models:. The candidate set is what this agent says is good here (X-26); an agent without choices: has said exactly one thing, its model:, so that is its set; the whole of models: stays the default only for the agent that names nothing, the picker start. The owner's decision; no switch back. Since then -M onto another model from such an agent warns (its prompt was written for the one it names) — by its pair or by its entry name, which -M reads as that entry whether or not the choices list it (X-26) — and the load-time warning fires only against a written choices:. Pinned by tests/cmd/config.rs (the wildcard refusal, the retired key, declaration order across the stack, the outside-choices warning, the picker start, an_agent_with_a_model_and_no_choices_offers_that_model_alone, an_agent_naming_no_model_offers_every_model_entry_as_declared), tests/cmd/resolve.rs (the headless refusal, -M against the choices, the listings, an_agent_naming_only_its_model_is_offered_nothing_else), tests/cmd/session.rs (a_bot_refuses_headless_and_ephemeral_runs: -M onto another model from a bot with model: alone warns), config_cmd::tests (the starter) and strict::tests. |
| X-49 | the exit repaints the frame | bubbletea's renderer flushes the last View() when the program stops (standard_renderer.stop → flush), so UI.Close — region.flush(), then Quit (ui.go:171-176) — left the screen with the flushed rows in the scrollback and a frame that no longer showed them | the same, made a line of run_loop: the round that drains Quit lands the flushed batches, PAINTS the frame the empty snapshot describes (shorter — ensure_height rebuilds the viewport in place and W3 clears what the old one covered) and only then leaves; park_cursor clears from the fresh shell line down. Through 0.4.0 the loop broke off before the draw, so the frame that still showed the staging window stayed on the screen under the rows the flush had just inserted above it — the banner twice at the end of a short chat (Ctrl+C or Ctrl+D right after start-up), the last rows of the reply twice after a longer one | Reported 2026-09-22, reproduced under tmux. Go's last render was a property of its renderer, not a step in Close, and the port wired Quit ahead of the draw. Pinned by vt100_tests::quit_repaints_the_frame_without_the_flushed_window, handle::tests::close_flushes_tail_ordering (the window empty after close(), each flushed row on the grid once, above the composer) and tmux scenarios 01 (Ctrl+D at start-up: the card's last rows once — the window is four rows, so the card's top two were already in the scrollback) and 05 (Ctrl+C after a turn: the last row once). |
| X-50 | a foreground shell call lets go after 20 s; /jobs; the status row's job segment | one switch: background: true returns a job receipt at once, anything else waits for the exit (or the 600 s cap) — the model has to guess before the call which commands will take long, and a wrong guess holds the turn for minutes | every shell call starts in job shape (Jobs::run: the output to a file under the registry's directory from the first byte) and waits for the exit or for SHELL_YIELD (20 s), whichever first; a command still running at the window is adopted where it stands — the next bN, the log renamed after it, a supervisor with the job's own token, the deadline it started with — and the call answers Still running after 20s as background job b3 (pid …). Output so far: … with the do-not-poll rule; a command that exits inside the window answers as before and its file is removed; at the cap (16) the call is waited for to its end and says so. background: true is "wait 0 s" — servers, watchers, a child agent — with the receipt it always had. The transcript: a lone call keeps the classic block with ⎿ still running after 20s → background job b3 over the output so far, a group says ◇ ran 3 tools in 20s · job b3 running (· 3 jobs running), the clock counting to the yield and the line never changing — the notice lands later as its own line. /jobs exists while a job runs (CmdFlags::jobs, flipped by the registry's watch, the table re-issued under one lock) and opens two LIVE tabs, re-read from the registry once a second (Panel::with_refresh, the /tools cadence pattern): Jobs, a single-select list — 1 job running above it, b3 1m 12s cargo test --test session resume per job, the clocks walking, a job gone with its end and one there with its start, the cursor following the job's id rather than the row (Refreshed::keys, 2026-09-23) — where Enter opens the job's page (job b3: the command in full, line by line; running: 1m 12s (started 14:02:11); the pid; the log path; the log's last twenty lines under the notice's byte caps; live too, and finished: exit 0 after 1m 12s / killed / timed out after … in the clock's place should the job end under it, Jobs::ended) and Esc goes back to the tabs, re-read, until Esc closes them; and Kill, the same rows with a checkbox each (the /session Delete shape), where Space checks and Enter kills the checked (Jobs::kill: the token and killpg, the job's own supervisor ending it as any job ends, so the notice finished: killed is the whole report and the surface just closes). No key on the page kills — letters on the list are the filter. An empty set is one notice, no background job is running. The status row closes with · job b3 cargo test 1m 12s (the command in the [shell …] header's cyan, cut to what the row has left, dropped below four columns) or · 3 jobs 3m 1s (the oldest's clock, text::elapsed like every timer in the program — the jobs had a zero-padded 1m12s clock of their own from 09-22 to 09-30), repainted once a second while the set is non-empty and never otherwise. Wherever a command is named as a label — the header, the notice, the /jobs row, the status row — it is cut by one rule, text::header_command (the first line, … for more, 64 runes), so the user reads the same text at the start and the end; the full text is the page's alone (2026-09-23). IOTA_SHELL_YIELD=<seconds> is a test hook read once at the binary edge into ToolEnv::shell_yield. | Decided 2026-09-22. The neighbours: Codex's exec_command returns after yield_time_ms (10 s) with the output so far and the command running on, so the model never predicts a duration; Claude Code and Gemini leave the background switch to the model; OpenCode times out and has the model retry with a bigger timeout. iota takes Codex's yield and keeps its own finished-job notice, so nothing is polled. Twenty seconds rather than ten: most builds and test runs a model asks about end inside it, and the ones that do not are exactly the ones worth a job. The window and the timeout are two marks on one clock (exec::Started remembers its spawn), so a job adopted after its yield keeps the deadline it started with and the notice's elapsed counts from the spawn. Pinned by tests/tool/jobs.rs (ended inside the window, yielded, one clock, the cap, the watch, one job killed through its supervisor), tests/tool/shell.rs (the tool's receipts and description), repl::render::transcript::tests (the receipt block, the summary segment, verbose), repl::commands::{mod,jobs}::tests and tests/repl/{commands,jobs}.rs (the row, the live rows and the page's verdict, the Kill tab, the one-table law), ui::surface::tests (the keyed refresh: the cursor and the checks following their rows), ui::render::frame::tests and event_loop::vt100_tests (the segment, the tick) and tmux scenario 25. The list was a snapshot from 09-22 to 09-23; a live list was the obvious follow-up once the clock on the status row walked beside a list that did not, and killing from the list — rather than a key on the page, where the searchable list's letters would have had to mean two things — is the /session Delete shape already in hand. |
| X-51 | the status row cut to the terminal's width | the plain row under one faint, the mode marker re-appended (model.go:1153-1163) | the styled row cut by display width (text::ansi::truncate_ansi, every escape kept, the closing reset included), the mode marker re-appended styled | Fixed 2026-09-23: on a narrow pane the token figures grow past the width after a few turns and the row lost every hue at once — what a user reads as "the colours disappeared". Pinned by frame::tests::status_line_keeps_its_hues_when_cut. |
| X-52 | a resize re-anchors the frame where the emulator left it; while a drag lasts the frame is a column (or a few) short | bubbletea v2's cursedRenderer.resize (cursed_renderer.go:619-631) only marks an erase, and the next frame repaints relative to its own cursor — no full clear, no move to row 0; a reflow could strand the frame's top rows above it (model.go:175-195 flushed the tail for that). The frame and the user block always span the full width | Term::resize (term.rs W5) clears only rows the old frame provably owned and scrolls or inserts nothing: ONE DSR before any byte, start = cursor − the frame row the last draw left it on; minus the growth of our OWN rows above the cursor (their drawn widths, a lower bound) once the emulator has SHOWN that it reflows (learned per session; transcript reflow is never predicted); a flush frame stays flush on S' − h, a band between is written into in place by the next output; staged rows the emulator archived are committed where they stand; each draw keeps a frame row's emulator line exactly as long as what it shows (ESC[2K + a rewrite); an erase from row 0 is never an erase-below from the home position. ratatui's own resize never runs. The burst layout (the owner's decision, 2026-09-24): full width at rest; every Event::Resize opens or extends a DRAG that ends DRAG_SETTLE (2 s) after the last one — or at once on an interaction (a key, a paste, a turn starting, a surface opening) — and while it lasts Model::frame_width, and through the shared width every row committed meanwhile, is the terminal's less twice the widest narrowing step seen (2..=DRAG_MARGIN_MAX = 8 columns), so the drag's next steps rewrap nothing; a run of resizes in one event batch is laid out once, at the last size. When it ends, the drag's band is closed (the region above the frame scrolls down over it — once per drag, never inside a resize pass) and the full width comes back with a plain repaint | Fixed 2026-09-23 — a divergence the port introduced, not one it chose: ratatui 0.30's inline resize (ratatui-core 0.1.2 terminal/resize.rs:42-44) answers a width SHRINK with viewport y = 0 and ESC[2J (Ghostty, herdr: up to 43 rows erased). A permanently one-column-short frame (2026-09-23) was withdrawn by the owner on 2026-09-24 with X-53; at full width every narrowing rewrapped both separators and left a blank band the next output started above (+2 rows a step, 48 blank rows in the history after a 30-step drag in a 24-row tmux pane) — the burst layout, the same day, removes it. Measured (tmux 3.7c, herdr 0.9.1; a 60-line transcript; the 750 ms window was falsified by the verifier for drags paced ≥ 0.8 s — each step a new drag, +2 rows a step, a 12–20-row hole inside the next turn — and replaced): fast (30 ms), paced (1 s) and mixed drags of 10–30 steps — no hole between the transcript and the frame at any point, the next turn directly under the previous one with no hole inside it (the same one-row gap as with no resize), the separators full width again after the drag, composer flush, nothing lost; a stream that starts as the drag ends leaves no band. The protocol is FROZEN here (the owner, 2026-09-24): native scrollback wins over zero residuals; loss = 0 is the hard constraint, ≤ 2 blank or duplicated rows per drag the budget; DRAG_SETTLE/DRAG_MARGIN_MAX are no longer tuned, and a report inside the budget is closed as by design (TUI-VERIFY §4.3; /tmp/ui-eval-report.md). Accepted residuals, measured on the frozen build: (1) since B3 (2026-09-26) the pass no longer infers what a reflow grew above the cursor (learn_reflow, growth_above/rows_pushed, line_len/rewrite_rows, the archived-row recovery are deleted): the frame's rows that far up stay behind as DUPLICATES — the staged rows, whose lines a diff lengthened with written blanks where a separator once was, wrap on any narrowing. Measured after stream 60 20, nothing lost anywhere: a 10-step one-column drag leaves 3 staged rows twice in tmux 3.7c, 2 in herdr 0.9.1 and Ghostty 1.3.1; one ≥ 2× narrowing leaves 3 (tmux), 4 (herdr, Ghostty); no separator piece in any of them; the tmux model in vt100_tests measures 2 per drag, 2 for 2×, 4 for 3× and 80→30, 3 for a first narrowing at startup, and scenario 26 caps each block at its tmux number (1–3 duplicated rows, ≤ 4 separator rows with /model open; named since 2026-09-28: B's 2 are the stream's last two staged rows, l#56/l#57, both for a width-only and a diagonal narrowing — 2 in 99 of 100 runs on tmux 3.7c and 3.4, alone and loaded; D's 1 is the user's own row, the frame's top staged row once the tool call runs — a narrowing that beat the Enter measured E's banner rows instead, 5 of 94 runs, until the block waited for the Enter to land); scenario 06, counted in copies since 2026-09-27 (it counted distinct lines, and the copies a drag added of a line already twice went unseen): 1 per idle narrowing, 1 for a drag whose first step rewraps rows the last full-width repaint drew (settled, fast — the fast one holds 2 until the band close takes one; since 2026-10-16 driven two steps a pass for that number, its blind twin capped at the per-drag 2, since the runner groups blind steps into passes — TUI-VERIFY §4.5), 0 for a drag that starts inside another's burst (paced), 2 for a mid-stream narrowing (15 of 15 runs, tmux 3.7c, loaded and not, and 3.4); a narrowing right after startup, with the banner still staged, leaves the banner's last two rows (a short checkout path) or one identical border piece (a path that fills the box) — measured 60x16→44x16 over scroll-on-clear on/off, with and without a draft, and after a 20-line turn leaves the last 2–4 staged rows instead; and a drag's first step rewraps what was drawn at full width: ≤ 2 blank rows per DRAG, moved to the top of the screen when it ends and reaching the history as output scrolls (tmux: paced +0, fast/mixed +2; herdr: +2 on the screen's top; scenario 06 caps ≤ 2 per drag); (2) a full-width user block already in the history rewraps its reversed padding when the terminal narrows: ⌈W/W'⌉ − 1 rows per block row (1-column step: 1, 2×: 1, ⅓ width: 2); (3) the session's first narrowing with a row shrink at the same time (tmux eats the rows below the cursor first): 1 duplicate, once; (4) a drag mid-turn duplicates the 2 rows staged when it starts (tmux, 10 steps 30 ms apart from l#10: 6 of 6 runs; the verifier saw 1 row on the build before). Over budget, known: a drag with /model open splits 4–6 separator rows into the history (verifier: 6; the frozen build: 4, 3 of 3 runs; steps fired blind 50 ms apart: 4–7 by how the runner groups them into passes, TUI-VERIFY §4.5) — scenario 26 H drags one step a pass and caps it at 4 by that name. Over budget, known — a slow cursor answer (2026-10-18, accepted; docs/design/drag-settle-stall.md): when a pass's cursor answer comes later than crossterm's 2 s (a starved process: background QoS on a loaded host, one CI run; never seen unloaded, where a pass takes ≤ 4 ms) while the hand is still dragging, the drag's staged rows are duplicated — 3–14 a drag against caps of 0–2, with a separator piece or two and a stale frame on blind and paced drags; nothing lost, the live frame whole, flush and full width (scenario 06 under scripts/dsr-delay.py 20 2.5, tmux 3.8). Two causes, both logged: apply_resize starts the settle clock BEFORE the pass's query blocks, and the same loop iteration runs settle_drag before it reads the events queued meanwhile (they are read ~50 ms later), so the drag is taken as over and the frame repainted at full width with its next steps unread; and those queued steps then reach one pass merged into a step wider than the margin — 26 H's family, which no settle fix touches. Not fixed now: A (restart the clock after the pass) changes the protocol's definition, cuts the duplicates by about two thirds only and stretches a stalled drag past 10 s; C (a non-blocking pass, the answer an event) rewrites the query layer X-81 just settled, at a risk out of proportion to a residual seen only under starvation; widening 06's caps would relax a check that is green unloaded. B, to do when this part of the loop is next touched: right before if !resizing && m.settle_drag() in run_loop, ask events.poll(Duration::ZERO) (the EventSource the loop already has) and skip the settle for this iteration when anything is queued — the next iteration reads it, and a queued resize extends the drag, which is what "DRAG_SETTLE after the last resize" already says (a resize that has arrived is the last one, read or not); no constant and no protocol text changes. About 3 lines plus a vt100_tests case (a LoopHarness event source that yields a queued resize after a slow pass and expects no close_band before it). Measured with B behind a switch, alternated with the unchanged build under the same injection: the same cut as A (settled 6→2, blind 8→3, paced 12–14→1–4, paired 3→≤1, the mid-stream shrink back inside its 2), still over the caps (the merged-step half), no new failure; unloaded, 06 green 4 of 4 with B deferring a settle not once. P0, 2026-09-25 (loss, fixed): in tmux a HEIGHT drag down and back up lost committed rows (window 30→20→30 at 100 ms: 1–2 rows in 6/6 runs; a split dragged up 2 and down 2: 1 row every run; a 60-event oscillation at 10 ms: 4–6 rows in 11/20) — tmux applies the next resize, pulling history rows back on a grow, while the loop is still writing the previous pass, and an erase computed on the older size wiped the row above the frame. Now a resize is only recorded; it is applied once the terminal has been quiet for RESIZE_QUIET (50 ms), nothing is written meanwhile, the pass takes the tty's size at that moment, and a recreation checked with a cursor query that the frame's bottom row was where it placed it before its W3 erase (since X-54 there is no recreation: the erase is counted up from the cursor and a second query after the frame is laid out resyncs the bookkeeping) (the cursor is the one current fact: neither the event nor the tty's size is; a cursor below the size just read raises the size, it is never clamped to it). Measured, tmux 3.7c, before (346ce2d) → after: window 30→20→30 at 100 ms 6/8 runs lost 1 row → 0/8; a split dragged up 2 and down 2 8/8 → 0/8; a 60-event height oscillation at 10 ms 4/8 runs lost 4–6 rows → 0/20. DRAG_SETTLE/DRAG_MARGIN_MAX are unchanged. Over budget, known (the verifier's adversarial run, not fixed here): herdr and Ghostty keep a height drag's band as blank history rows (up-10/down-10: ≈ 10 rows; a 60-event height oscillation in Ghostty: 48); a session's FIRST resize that also shrinks drastically (100x30→33x15, 25x10, 20x6; 200x50→40x8) duplicates 3–4 rows; a width→⅓ narrowing leaves +10 blank rows; a 60-event width+height jitter duplicates 1–4 rows; drags paced 2.1 s apart are separate drags, 2 blank rows each (in budget per drag, permanent). Loss closed (the verifier's re-verification of 4da669a, 2026-09-25): three losses, each an absolute row gone stale between iota's check and its write — (1c) a DSR that fails, the tracked top the anchor of an erase (a proxy swallowing the queries, height 30→20→30: lost 1–2 rows every run); the scroll-on-clear race, tmux growing between a cursor answer and the pass's next byte (the verifier: ~2/85 idle, 3/30 under load); (1b) a height drag during a stream (tmux 120x30, stream 40 60, bottom edge up 5 / down 5 at 100 ms: 2–4 rows in 6/6 runs), where the byte log showed each lost row overwritten by the NEXT insert (DECSTBM scroll + absolute move) that iota wrote before the resize and tmux parsed after it. A stopgap the same day made the pass's own moves and erases relative to the cursor and closed (1c) and the race; (1b) needed every write relative to the cursor, which ratatui's Terminal cannot do — the owned inline terminal, X-54, closed it (6/6 → 0/6) and supersedes the stopgap's mechanisms (the relative recreation, the blind resize, the re-parked cursor). Pinned by term::tests (a draw laying out against the size the pass took (the acknowledged-size canary until X-54), the failed-DSR fail-safe, a resize on a stale size never erasing above the frame), vt100_tests (a height drag down and back and an oscillating height with stale events, tmux's history pull modelled; the burst model: 1- and 2-column-step drags, a PACED drag one step a second, a key ending the drag, the next output directly under the transcript, the settle deadline in the poll; no ESC[2J, at most the one band-closing scroll-down, no home-position erase) and L4 scenarios 06 (settled, fast and paced drags) and 26 (a stream right at the drag's end, scroll-on-clear on). |
| X-53 | the composer as a closed rectangular box — withdrawn, never shipped | — | Built 2026-09-24 on the owner's request (square solid lines, one column of margin each side, the separators as the box's borders) and withdrawn the same day by the owner: "不太好看,我不喜欢这个效果" — back to X-36's dashed separators at full width. Recorded so the design, its costs (a closed box's full-width rows rewrap on every multi-column step) and the decision against it are not lost; brain composer-closed-box. |
| X-54 | the inline terminal is iota's own; every write is counted from the cursor | bubbletea v2's cursedRenderer owns its screen state and writes relative to its own cursor (cursed_renderer.go); Codex's custom_terminal.rs is a fork of ratatui's Terminal with the viewport as a plain field | src/ui/runtime/inline_term.rs: InlineTerminal keeps ratatui's Buffer and Buffer::diff and nothing of its Terminal. The viewport is a field; a height change keeps the frame's first row, erases only the rows below the rows it keeps (a taller frame takes rows below it, made with LF where the screen ends) and the next draw writes only what changed — no recreation, no clear (W1/W3 gone, ensure_height). After the one absolute move at startup (the row a DSR just reported) no byte names a row: moves are CUU, LF, CR+CUF; an insert is LF to the frame's last row and n past it, CUU to its first row, IL n there, the rows written into the gap (no DECSTBM; the screen scrolls the transcript into the native history the way a shell does, W9 gone); the band close is DL + SD from the band's first row. The absolute top and size are bookkeeping for the resize pass's floor, resynced from its DSR (W2 and the acknowledged size gone). Term::resize keeps X-52's protocol (the one DSR before any byte, the cursor over any size read, the overhang claim, the floor and the band, the post-placement recheck, the burst layout, DRAG_SETTLE/DRAG_MARGIN_MAX unchanged) but its erase goes UP from the cursor by the frame's own rows (plus the claimed overhang), never from a row number. ratatui's scrolling-regions feature is off | Decided 2026-09-25 (option B of the 2026-09-24 UI evaluation, /tmp/ui-eval-report.md §5; built in the inline-term worktree). The evaluation expected B to remove four warts and leave the losses to X-52; the verifier's 2026-09-25 re-verification showed three losses X-52 could not close, all of them an absolute row going stale between iota's check and its write: (a) a stream drawing and inserting under a tmux size iota had not read yet (tmux 120x30, stream 40 60, the window's bottom edge up 5 and down 5 at 100 ms: 6/6 runs lost 1–4 of the turn's lines); (b) a terminal that does not answer the DSR, whose fallback anchor was the recorded top (4/4 lost 1–2 rows); (c) tmux pulling history rows back between the pass's cursor check and its erase (scroll-on-clear on, CPU load). The emulator carries the cursor with its cell through any resize, so a write counted from it cannot land on a row that moved: CUU only stops short (into the frame, never above it) and LF moves exactly one content row even where it scrolls. Measured, tmux 3.7c, 4da669a → this build: (a) 6/6 → 0/6 (every run 40/40 of the turn's lines), (b) 4/4 → 0/4; (c) is a low-probability race — 0/30 on both builds this time (scroll-on-clear on, two CPU burners, the 60-event oscillation and the 30→20→30 drag, 15 runs each; the verifier's 3/30 on 4da669a), so its evidence is the deterministic replay below, which fails on 4da669a. The pass's mid-way DSR moves only the cursor's bookkeeping; the frame's follows the post-placement recheck (a stale frame there inflated the model's height by a row and left the frame two rows off the bottom after a tmux diagonal shrink — L4 06, term::tests::a_shrink_that_ate_the_rows_below_the_cursor_*). On the build merged with the stopgap (2026-09-26): (a) 0/6, (b) 0/4, (c) 0/30; the tmux matrix (a 10-step drag, halve, 30x12, grow, the next turn, both scroll-on-clear settings) LOST=0 with the same residuals as 4da669a (after a drastic shrink or a grow the frame sits 3 rows off the bottom until the next turn, as before); herdr 0.9.1 (a split dragged: 10 width steps, halve, height up 10/down 10, a stream through a height drag) and Ghostty 1.3.1 (the same through resize_split) LOST=0, the stream 40/40 in both, the height round trip leaving its known 10 blank rows (X-52). Since 2026-09-26: the overhang machinery is deleted (B3 — X-52 residual (1) carries the measured duplicates), and the insert's one-row jump is hidden by DEC 2026 (X-55). A frame taller than the room left for an insert (frame + rows > screen) is erased and laid out again under the rows — the one path that repaints. Pinned by term::tests (a grow between the DSR and the erase, writes before the resize is read, a DSR that fails through a grow, a stale size, a cursor beyond the size — each replayed through a vt100 that pulls history rows the way tmux does; the first three fail on 4da669a), vt100_tests::a_height_change_clears_nothing_it_keeps_and_rewrites_nothing_unchanged (one erase, below the kept rows; the kept rows never written; fails on 4da669a), inserts_ride_insert_line_with_no_region_and_no_per_insert_ed (no DECSTBM, one absolute move per session) and the home-position erase counted in the emulator (process_counting). |
| X-55 | a write larger than what a transport splits is one DEC 2026 synchronized update; a smaller one goes out bare — the size is the criterion, not the terminal | bubbletea v2 (the Go original's renderer) enables mode 2026 only after an environment heuristic (TERM_PROGRAM/SSH_TTY/WT_SESSION/TERM) says to ask and DECRQM answers 2; then each frame's diff is wrapped, but tea.Println/insertAbove is NOT — it goes out outside the block | InlineTerminal::commit (the one write point) wraps the bytes of one write_all in one ESC[?2026h … ESC[?2026l pair exactly when they are longer than SYNC_MIN (1024 B — the macOS pty read block, measured; Linux reads 4096 and a TCP segment carries ~1448 — an empirical threshold, not a size below which nothing is split); anything smaller goes out bare, as before 2026 existed — it usually lands in one read, but the threshold is empirical: nothing controls how many bytes the receiving side reads at once (TCP keeps no application write boundary, RFC 9293 §3.7), so small batches keep the reads few and large ones ask a 2026 terminal to present them at once (ARCHITECTURE §12). begin_batch/end_batch gather operations and the decision is made once, on the whole batch: the loop gathers one iteration (the band close, the inserts, the height change, the title/progress/notify OSCs, the frame), the resize pass its erase-to-layout between its two DSRs. No cursor query is ever made inside an open batch (it may become a block: WezTerm holds the answer, Alacritty the query itself; debug_assert in InlineTerminal::relayout). Never decided from a terminal's name or version, and there is NO switch | Decided 2026-09-26 in three steps (/tmp/ui-eval-2.md §2, /tmp/ui-eval-3.md). First every write was a block, inserts included (Codex, iocraft and grok-build put their inserts inside), with IOTA_SYNC_OUTPUT=off as the one switch. The third evaluation measured what that bought on a local pty: the insert's LF→IL window crossed a read boundary 0 of 105 times (2 at unthrottled output), the /model erase-then-redraw landed in one read 5 of 6 times — the one benefit that is real is a write the transport SPLITS (a panel opening, a 12-line insert, a resize's frame), which a 2026 terminal must not show half-drawn. And what it cost: tmux 3.7c repaints the whole pane at the end of ANY block, even an empty one — typing 1.4–2 KB a keystroke, a 20 ms stream 10×, over ssh to a remote tmux 3.7c on a slow link a sticky prompt. DECRQM cannot tell the cheap from the expensive (3.7c and 3.8-rc2 both answer 2, 27× apart in cost). The size rule keeps the one benefit with no knob — the switch is deleted: the user who needed it (ssh + tmux 3.7c) was the least likely to find it, and now pays one pane repaint per LARGE write (a panel opening, a big insert — a few KB each, a few a minute) instead of one per keystroke. Measured with the evaluation's harness (tmux 3.7c, 120×40 pane, outer bytes), a6d1f79 → this build: typing 11 characters 22 840 B → 13 B; deleting them 22 827 B → 31 B; stream 100 20 82 884 B (10.3×) → 8 581 B (1.2×); stream 100 2 12 256 B (2.7×) → 4 822 B (1.1×); /model open+close ×3 20 917 B (3.2×) → 6 115 B (1.0×) — with the mock's three models the panel's writes stay under 1 KiB, so none was a block (the evaluation's 1.3 KB panel would be one block, one pane repaint per opening). In that session two writes were blocks: the startup banner (1 091 B) and the stream's last batch (1 379 B). Pinned by vt100_tests::a_write_is_one_synchronized_update_exactly_when_it_is_longer_than_sync_min (a keystroke, a one-line insert, a height change, a clear, a small batch, an OSC: bare; a 12-line insert of colored rows and a panel-sized frame in one batch: exactly one pair around the whole write — it fails with the threshold at 0 and at infinity) and no_cursor_query_sits_inside_a_synchronized_update; vt100 0.16.2 ignores the mode, so every replay runs on the same bytes. |
| X-56 | backslash escapes in the terminal renderer: the escaped table pipe (backslash + pipe, BP below) and CommonMark's escape family | parseTableCells (markdown.go:1291-1313) split at every pipe — a BP cut the cell in two, in a code span and in the header too; renderInline knew one escape, \$ (markdown.go:541); every other backslash was printed and escaped nothing (\*x\* rendered as italic \x\, \`x\` as a code span) | Tables (GFM 0.29 §4.10, cmark-gfm's scanner): a pipe right after a backslash never separates cells, lexically — after an escaped backslash (\\ + pipe) either: the cell keeps both backslashes and shows one (a cell that must END in a backslash writes \\, a space, then the pipe); a trailing BP is content, not the row's closing pipe; each BP whose backslash is not itself escaped loses its backslash BEFORE the inline pass, so it is a literal pipe inside a code span in a cell; a delimiter row holding a BP is no delimiter row (it renders as a content row, the Go block needing no delimiter row — unchanged). Inline (CommonMark 0.31 §2.4): \ + ASCII punctuation is that character, opening and closing nothing (the emphasis and link close finders step over escaped pairs); \ + anything else stays a backslash; \\ is one backslash; no escapes in a code span, a URI autolink <scheme:…> (now kept verbatim) or a math span; a link's text and destination are unescaped (the header cells' plain path, strip_inline_markdown, resolves the same). Deliberate deviations: \(…\) stays inline math (CommonMark: a literal (), and \[…\] mid-line is […] (display math is a block, blocks/math.rs); a backslash at the end of a line stays visible (§6.7 makes it a hard break, but every source line is already its own terminal line and the renderer cannot see whether the paragraph continues); raw HTML is not recognised, so an escape inside a tag's text is resolved; link titles are not parsed (the whole (…) is the destination) | Fixed 2026-09-27 on the owner's report ("表格里使用了 backslash-pipe 这样来 escape 不会被正确识别"). A gap Go had, not one the port introduced. /export needed nothing: comrak (cmark-gfm's port) already does both, and the terminal's table rule is copied from its table_cell / unescape_pipes so one document gets one cell structure in both renderers. Pinned by blocks::table::tests::parse_table_cells_escaped_pipes, tests/markdown/table.rs (an_escaped_pipe_stays_inside_its_cell, backslash_escapes_resolve_in_header_and_body_cells), tests/markdown/inline.rs::backslash_escapes_follow_commonmark (all three fail on 555181e) and html::tests::backslash_escapes_match_the_terminal_renderer (the same matrix through comrak). |
| X-57 | an MCP tool's inputSchema on its way to a provider | Tool.InputSchema forwarded as the server wrote it | mcp::schema::normalize rewrites it once, where tools/list becomes ToolDefs (transport::list_tools, so deferred tools too): every pattern value and patternProperties key loses each backslash the ECMA-262 u flag forbids — before ASCII punctuation that is not a syntax character or / (nor - inside a class), or before a non-ASCII character (^agent\_run\_ → ^agent_run_). Letter and digit escapes stay as written; const, default, enum and examples are data and are not walked. Nothing else changes: $schema and every other keyword stay, and a pattern whose validity is not decidable here is kept, not dropped | Found 2026-09-29 on the owner's report: DeepSeek's /responses refused a whole turn (Invalid schema for function 'mcp__exa__agent_run': "^agent\\_run\\_" is not a "regex"). Measured: DeepSeek checks pattern's format only when the schema declares $schema (exa's says draft-07); the same tool without $schema, or with the rewritten pattern, is 200. JSON Schema's regex dialect is ECMA-262 in Unicode mode, where \_ is an error, so the rewrite is the standard reading, not a DeepSeek special case. Owner's call: keep $schema — the provider's validation stays, iota adapts the schema to it transparently (brain mcp-schema-normalize). Rust's regex is no judge (it rejects valid ECMA lookaheads), so no pattern is dropped on its say |
| X-58 | agents.<name>.workspace → mode: | agent mode was --agent or agent: true on the provider, one boolean (X-11 moved it to agents.<name>.workspace, still a boolean) | one enum, mode: chat \| agent \| bot (config::AgentMode, lowercase; absent = chat). chat is what leaving workspace: out was, agent what workspace: true was, byte for byte: the AGENTS.md overlay with its skills catalog, the skills set, a project-bucketed session, a resume scoped to the project's bucket first. bot (docs/design/bot-mode.md) contains agent and adds its own two things: one never-ending session per bot, kept flat (not in a project bucket) and found through ~/.iota/bots/<name>/bot.json, and its memory (MEMORY.md, the remember tool, the flush before a compaction). Any other value is refused by the key audit: agents.a.mode: unknown mode (want chat, agent, bot). workspace: is NOT a retired key: it gets the plain unknown key (want …) refusal, whose list names mode; the one retired hint that pointed at it — providers.<p>.agent — now reads `agent` is now `mode: agent` on an `agents:` entry. The starter's commented line is # mode: agent. | Decided 2026-09-30 (bot-mode.md §6 #17). A bot is an agent and more (chat ⊂ agent ⊂ bot); a second boolean beside workspace: would have made "a bot without a workspace" expressible and then refused by validation, where one enum cannot say it. The key count stays 14. No released binary needs the old key carried (X-15's rule), and the unknown-key error already lists the replacement, so no retirement row. Pinned by strict::tests (workspace unknown, mode: bots refused, the hint), tests/cmd/config.rs (decoding and has_workspace/is_bot), tests/cmd/resolve.rs (RunSettings.mode), tests/cmd/session.rs::mode_agent_requests_are_pinned (both requests of a fresh and a resumed run against tests/fixtures/mode/agent-requests.txt, baseline captured at 8f3c875, the last binary that read workspace: true; chat against no key; bot against agent) and config_cmd::tests (the starter's commented line uncomments to agent). |
| X-59 | consecutive user-role messages in the anthropic dialect | a user message appended its text to the pending tool-result blocks and flushed at once (provider/anthropic.go:105-110), so tool results followed by a user message were one user entry, but two user messages in a row — or a user message and whatever user-role content followed it — went out as two consecutive user entries | every run of user-role messages (tool results, user messages, host notices) is ONE user entry, their blocks in order; only a system or assistant message, or the end of the history, closes the run (AnthropicProvider no longer flushes after a user message; flush_tool_results emits the whole run) | Found 2026-09-30 building bot mode: a bot's memory-write notice (bot-mode.md §3.7 item 1) lands in the history at turn end as a user-role notice, so the next request carried assistant → notice → user, and the Messages API refuses two consecutive user-role messages — every turn after a remember would have failed. Go presumably never produced that shape (host notices are Rust's, X-08, and until now each one opened a turn of its own), so the flush was presumably harmless there — an inference, not checked against the Go source. Merging is the API's own reading of consecutive user turns, and it is what the google dialect already did (contents fold by role); the OpenAI dialects accept consecutive user messages as they are and are unchanged. Pinned by tests/provider/anthropic.rs::consecutive_user_role_messages_are_one_user_message and tests/provider/google.rs::a_notice_and_the_next_user_message_fold_into_one |
| X-60 | two processes writing one session | nothing stops it: two terminals that --resume the same id both append to its messages.jsonl, and the log interleaves two conversations | one writer per bundle, in every mode: whoever owns the bundle's SessionWriter holds <bundle>/.lock (File::try_lock, advisory, released by the OS when the process dies — no stale locks), taken in SessionStore::resume and when a new bundle is first written. A second iota resume <id> is refused with session <id> is open in another iota process (pid N); /session's Delete tab skips a held bundle (Skipped: …) and SessionStore::delete refuses it. The pid in the file only names the holder in that text. Go ignores the lock file (and flock), so a Go and a Rust process can still both write — docs/design/bot-mode.md §7.1 | bot-mode.md §2.3 (L0): the double write was a latent bug for every session, and a bot, which stays open for weeks, makes it likely. Pinned by tests/session/store.rs (resume_is_refused_while_another_store_holds_the_bundle, a_new_bundle_is_locked_from_its_first_append, delete_is_refused_while_the_bundle_is_held) and tests/repl/commands.rs::session_delete_tab_skips_a_bundle_open_elsewhere |
| X-61 | a log whose tail has open tool calls; a record too long to read back | loadLog replays the view as it is: a crash or power loss mid-batch (or a torn line the scanner skips) can leave an assistant message whose tool_calls no tool record answers, and every later request is refused (Anthropic: a 400 that is never retried) — the session loads and then fails forever. Nothing bounds a record on write, so one that reaches the scanner's 32 MiB cap makes the bundle unreadable for good (D-56) | a resume runs session::loader::repair_tail after the load: each unanswered call at the END of the view gets an is_error tool result interrupted: no result was recorded, appended to the log (so the next load has it too), and the transcript says Recovered N tool call(s) with no recorded result; they are marked as interrupted. Only the tail is repaired — an open call earlier in the view cannot be fixed by appending. On write, a record that would reach the cap is cut to fit (raw first, then content/reasoning, ending in [record truncated: N bytes over the log line cap]), with no new key and no format change | bot-mode.md §2.7 (L0, critique I3): damage that used to be permanent now heals on load or never gets written. Pinned by tests/session/store.rs::resume_answers_the_tool_calls_the_log_left_open, loader::tests (repair_tail_answers_open_calls_in_order, repair_tail_leaves_a_closed_view_alone) and tests/session/writer.rs::an_oversized_record_is_truncated_on_write_and_the_bundle_still_loads |
| X-62 | the summary an earlier compaction left, at the next compaction | summarize renders the middle as plain text, so the first retained message — which carries [Earlier conversation summary] … ——— woven into its content — goes in as User: [Earlier conversation summary]…: the old summary is summarized again as if the user had said it | compact_history lifts the woven summary out of the middle's first message (the same SUMMARY_PREFIX … SUMMARY_SEPARATOR frame, split at the first separator) and hands it to summarize as its own --- PREVIOUS SUMMARY (already condensed: carry forward what still matters, drop what is resolved) --- section, before the conversation (then fenced as --- NEW CONVERSATION START ---). What is left of that message stays in the conversation; it is dropped only when it has no text AND no tool calls. The disk format, SUMMARY_PREFIX and the loader's weave are unchanged | bot-mode.md §3.6.2 (L0): summaries of summaries lose detail with every pass and blur what the user said with what an earlier summary said; a never-ending session compacts many times. Pinned by compact::tests (a_woven_summary_is_carried_forward_not_resummarized, a_summary_only_message_is_dropped_from_the_body, without_a_previous_summary_the_prompt_keeps_one_section) |
| X-63 | where the retained tail of a compaction starts | retainTailCount (chat/compact.go) keeps everything from the last USER-ROLE message on | from the last user message that is not a notice (retain_tail_count: role == User && !is_notice()), in every mode. A host notice is user-role on the wire (X-08) — a job's completion notice, a bot's memory-flush notice — and with Go's anchor one arriving after the user's message took the anchor: the user's actual last turn was compressed into the summary and only the notice survived verbatim. Now the notice rides along after the user's turn. With no notice in the history the two agree | bot-mode.md §3.6.1 (review S2c). Go had no host notices, so this changes nothing a Go history can contain, but it IS a different rule from Go's and applies to chat and agent sessions as well as bots. Pinned by compact::tests::a_job_notice_does_not_take_the_last_turn and compact::tests::a_bot_keeps_the_users_last_turn_not_the_flush |
| X-64 | a hung request: the stream idle bound, the connection bounds, the title pass on ESC, the first-token phase | no body bound: a stream whose head arrived and whose bytes stopped waited forever (internal/llm/sse.go reads with the context alone); the nil-client fallback cloned http.DefaultTransport (its 30 s dial timeout, no HTTP/2 PING); one busy label, Waiting for the model, from the send to the first token | Stream idle bound: Sse::read_line races each body read against a fresh STREAM_IDLE_TIMEOUT (300 s) timer, reset by every BYTE (a ping event or : heartbeat keeps the stream alive); expiry is LlmError::StreamIdle — reported (Response stalled, … (stream idle timeout); the request was not retried, a hint to send the message again or check the base URL and model; no text names a setting), never retried by the client or the turn: it fires after the head, the tool rounds the turn already completed cannot be replayed (the stall keeps them, below), and every attempt would sit out the whole bound again — a replay would not run the round's own tool calls twice, which run only after its stream ends. A stall is still an error (red block, State::Error) but keeps what the turn completed, like ESC's table: the completed tool rounds stay in the history, the partial text lands as an assistant message marked cut short, and a notice says What arrived before the stall is kept — the reply may be incomplete.; with nothing to keep the turn rolls back as before. Terminal events: a stream ends at its protocol's end — [DONE] (chat completions), message_stop (Anthropic), response.completed (Responses) — not at the body's EOF; Go drained past [DONE] to EOF, which waited forever on a body a relay never closed, and under the idle bound failed a finished answer. Gemini has no terminal event and still ends at EOF. Known cost, accepted, and narrow: on HTTP/1.1 hyper reads once more when the body is dropped (poll_drain_or_close_read) and pools the connection if that read reaches the chunked end (0\r\n\r\n), so a server reached directly, which writes the end with the terminal event or in the write right after it (Go's and uvicorn's shape), keeps its connection; only an h1 relay whose forwarding lands the end ≥ 1 ms after the event costs a fresh connection per round (tool loops pay it per round). HTTP/2 resets only the stream and keeps the connection, and the official endpoints all negotiate HTTP/2 (measured on loopback and against the endpoints, 2026-10-03). Not done: a short bounded drain after the terminator (docs/history/request-cancellation-review-2-opus.md §3.1). Image streams (llm::images) are not bounded: a generation may be silent until it is done. The bound is a constant, not a user setting (decided 2026-10-01 — it is network policy, and a knob only adds configuration to think about; Claude Code clamps its own CLAUDE_STREAM_IDLE_TIMEOUT_MS to at least 5 min, i.e. does not let a user go lower). IOTA_STREAM_IDLE_TIMEOUT=<seconds> (0 = none) survives only as a test hook in the shape of IOTA_SHELL_YIELD: read once at the binary edge, so L4 27-hung-provider.sh sees a stall in two seconds; no message or user document names it. Connection: default_http_client sets connect_timeout 15 s and HTTP/2 PINGs every 30 s while a stream is open (20 s to answer); TCP keep-alive stays reqwest's default (15 s × 3). Title pass: nothing waits on it any more. Go joined it before every input but the read-only viewers and before exit, which held both for up to TITLE_TIMEOUT (30 s) with ESC and Ctrl+C dead; now a message or any command lets it keep running, and only the exit gives an unfinished pass up (the placeholder name stands). Giving it up is not what keeps it off another session's bundle — abort cannot interrupt a land already running on another worker — so /session settles the resumed bundle's name under the lock land writes under, BEFORE it swaps the writer (SessionTitle::switch_writer): a pass lands on the session it named or not at all. /save needs nothing: it mints the bundle of the same session and reapplies the name. A turn that gives its name back (unseed — a rollback or a discarded ESC) aborts the pass with it; a kept partial keeps the pass, so its model title still lands. Busy row: a success head moves it to Waiting for the first token | Found 2026-10-01 (docs/history/request-cancellation-recon.md, -research.md): ESC worked, but the 30 s title join and the unbounded body made a hung provider look like one that cannot be cancelled. 300 s is the top of the industry's 2–5 min range (Codex, opencode, Gemini CLI; Claude Code widened its 90 s to it): no request asks for a reasoning summary, so a reasoning model may think in total silence, and a stall is not retried — cutting a live think short costs more than noticing a dead stream later (docs/history/request-cancellation-review-opus.md §1.2). Not done: a short-then-long first-byte timeout (it changes the retry semantics) and any overall deadline. Pinned by tests/provider/wire.rs (a_silent_stream_fails_at_the_idle_bound, a_heartbeat_keeps_a_stream_alive_past_the_idle_bound, a_zero_idle_bound_never_times_a_stream_out, {chat_completions_ends_at_done,anthropic_ends_at_message_stop,responses_ends_at_completed}_although_the_body_stays_open, sse_frames_parse_across_chunk_boundaries), tests/repl/stall.rs (a_stall_after_a_tool_round_keeps_the_call_and_its_result, a_stall_with_nothing_to_keep_rolls_the_turn_back, a_hung_title_pass_never_holds_the_next_message, a_hung_title_pass_never_holds_the_exit, an_interrupt_that_keeps_a_partial_keeps_the_title_pass, a_command_during_the_title_pass_keeps_it), title::tests::a_pass_landing_across_a_session_switch_never_names_the_new_session, client::tests::{stream_idle_timeout_reads_the_override, the_default_client_carries_the_connection_bounds}, phases::tests::a_success_head_moves_the_row_to_the_first_token_phase, retry::tests::is_retryable_follows_the_status_table and L4 27-hung-provider.sh (sections 4–6 — a title pass that alone hangs, a complete answer over an open body — fail on the build before the review fixes) |
| X-65 | a newline in the composer: Ctrl+J, Alt+Enter, Shift+Enter | Enter submits, whatever its modifiers (model.go updateKey); no key inserts a newline — a multi-line draft came only from the interrupt fold-back, and the port sized such a draft by its logical lines | src/ui/input/keys.rs row 7a, ahead of Enter's submit: Char('j') + CONTROL, Enter + ALT and Enter + SHIFT insert \n at the cursor and end the history walk (any Enter carrying ALT or SHIFT inserts, Ctrl+Shift+Enter included); bare Enter submits, and so does Ctrl+Enter wherever it arrives. What a user gets depends on what the terminal sends, three cases: Ctrl+J inserts in every terminal — raw-mode LF is a different byte from Enter's CR, no protocol needed (tmux passes it unless a plugin such as vim-tmux-navigator binds C-j). Alt+Enter and Shift+Enter insert where the terminal reports the modifier: Alt+Enter is ESC CR (Ghostty and tmux by default, Terminal.app with Option-as-Meta — without it Option+Enter is a plain Enter and submits, as before; Windows Terminal takes Alt+Enter for full screen by default); Shift+Enter needs a CSI-u report (a terminal-side mapping, tmux extended-keys always + csi-u, the Windows console API). Unreported, Shift+Enter is a bare Enter in most terminals and submits, as before — but under Ghostty's default config it is ESC[27;2;13~, which crossterm 0.29 drops whole: no newline, no submit, nothing (the one-line mapping below fixes that). Ctrl+Enter stays a submit: with no protocol it carries no CONTROL — legacy terminals send the same CR as Enter, and Ghostty's default ESC[27;5;13~ is dropped like Shift+Enter's, so in Ghostty it does nothing. The binding lives in the composer's ladder, not in the shared Editor::on_key: the surface's one-line fields (/model's typed row, Ask's "Other…", the search boxes) run that too and are one line by design — their pastes flatten newlines. Two follow-ons: the composer's height is its WRAPPED row count, newline breaks included (Composer::effective_height; the explicit logical-line height and set_height are gone), so a long line inside a multi-line draft grows the box instead of scrolling it; a queued item renders its newlines as ⏎ and stays one frame row (frame.rs; the ratatui buffer drops a raw \n, which glued a\nb into » ab). For Ghostty users who want Shift+Enter: add the line keybind = shift+enter=text:\n to the Ghostty config file (ghostty +edit-config opens it; on macOS it is ~/Library/Application Support/com.mitchellh.ghostty/config.ghostty), then reload the config or restart Ghostty — Shift+Enter then sends LF, which is Ctrl+J here and also passes through tmux. Verified by hand 2026-10-04, Ghostty 1.3.1 on macOS: with that line in config.ghostty and the config reloaded, a physical Shift+Enter in iota's composer inserted a newline and did not submit (TUI-VERIFY §8d.3). Before that, in pieces: the line passes ghostty +validate-config (Ghostty 1.3.1), and the text:\n action, fired directly, sends the single byte 0x0A (composer-newline-review-2.md A.3). | Decided 2026-10-02 (docs/history/composer-newline-{research,recon,review,review-2}.md). Why not Ctrl+Enter: without a keyboard protocol it never carries CONTROL — a legacy terminal sends it as a bare CR (it submits), and Ghostty's default ESC[27;5;13~ is dropped inside crossterm 0.29 (nothing happens) — so a newline bound to it would work only under a CSI-u report; it stays a submit. Why not the Kitty keyboard protocol now: the gain is Ghostty alone (tmux does not forward the push, Terminal.app has no protocol, crossterm reports it unsupported on Windows), and the one-line terminal mapping above already gives Ghostty users Shift+Enter, through tmux too; the cost is measured — after a SIGKILL the pushed flags stay in the terminal (the next shell gets ESC[27u for Esc, ESC[13;2u for Shift+Enter), stty sane does not clear them, and with panic = "abort" no Drop runs to pop them. Revisit when a Ghostty or kitty user turns the mapping down; a panic hook that pops, an escape-hatch variable and a TUI-VERIFY pass come first (review §3.2). Pinned by keys::tests::row_7a_newline_keys_insert_and_never_submit (all three keys: no submit, then ONE two-line message to the reader), row_7a_inserts_at_the_cursor_and_submit_trims_the_edges, row_7a_newline_after_a_paste_tag_submits_both, row_7a_is_not_eaten_and_ends_the_cycle, row_7a_never_reaches_a_surface_field, row_7_enter_submits_trimmed_or_ignores_blank (Ctrl+Enter submits), composer::tests::a_multi_line_draft_grows_by_its_wrapped_rows, vt100_tests::a_queued_multi_line_draft_keeps_one_frame_row (reads » ab without the fix) and L4 28-composer-newline.sh (Ctrl+J, the LF byte, M-Enter and an injected ESC[13;2u through a real pty; the queued two-line row and its fold-back) |
| X-66 | -M on a resume | an explicit -M supplies the model for that run and never rewrites meta.model (root.go:323-325; Go never calls SetModel on the writer) | -M MIGRATES the session: the meta (v2) is rewritten, in one write, to the provider and model the run is on and the four parameters it runs under there with their sources (SessionWriter::move_to, crate::repl::liveparams::moved). Those four are the bundle's, evaluated against the new endpoint's declarations exactly as a /model move evaluates the chat's (Declared::resume): a value the user set comes along unless the config there declares the key, one the old endpoint's config supplied stays behind, a default stays a default — and the warning says so (… its model is not replayed, and of its tuning only what was set by hand carries over, unless this run's config sets it). The model and the image settings are not replayed. The move is written headlessly with the turn's delta, on success only (D-43); interactively when the session opens. iota resume <id> -M cheap:x -m "sum up" therefore moves the session, and the next resume continues on cheap | the records -M makes are tagged with the new dialect (raw.provider), so a meta that kept naming the old endpoint would be a lie the next resume acts on — replaying the old model and tuning onto it (decision of 2026-10-11, macro judgement §2.1/§3.4). The two ways of moving a session — /model in the chat, resume -M — follow ONE rule for the four parameters (decision of 2026-10-12): an earlier build dropped the user's own values on resume -M only |
| X-67 | the start-up order of a resume | config → resolve_run (the agent's key checked) → the agent's provider opened and tuned → tools → only then the bundle is read (cmd/root.go:46-334) | a resume reads the bundle's meta FIRST and opens the provider the session names (crate::cmd::open_resumed: resume_endpoint → open_provider → the tools warning); resolve_run leaves the agent's missing key to it. So under iota resume the session's errors — no such session, SessionProviderGone — come before API key is required …, and the agent's own endpoint having no key no longer stops a session that runs elsewhere. A bundle Go wrote — v1, as every Rust build's before v2 — is not read at all: the meta read refuses it (session <id> was written by an older iota (meta.json v1: its provider is a type, not a provider name) and is no longer read; convert it with scripts/session-v1-to-v2.py …, SessionError::OldFormat), before any provider is opened, with no fall back to the agent's endpoint; the one-time script converts it by the current config (docs/design/session-format.md §4.1) | a v2 session is relative to the config (its provider is a providers: name), so the session says where it runs and the config says how to get there; opening the agent's endpoint first put a provider the session never uses in its way (codex review F5). Go's byte order is no longer a contract (ROADMAP §4). A v1 bundle names a TYPE, which is no endpoint of the config; the build that read it by type and settled it as v2 on the first run was dropped for a conversion script (decision of 2026-10-13: one user, whose sessions the script converts — no compatibility path is kept) |
| X-68 | the anthropic dialect's max_tokens | a constant MaxTokens: 4096 on every request (provider/anthropic.go:121), no config key; docs/design/model-settings.md chose output_config.effort over budget_tokens and left "MaxTokens: 4096 … untouched" | the default is 32000 (llm::anthropic::DEFAULT_MAX_TOKENS), and max_tokens: is a key of models.<x> and of agents.<y> (≥ 1; 0 is config max_tokens 0: want 1 or more). It is the fifth layered parameter (brain model-param-layering): the agent's key over the model's, evaluated at a new session and a /model switch (a ceiling one model declared is dropped on a switch to a model that declares none, with a Max tokens: … notice), recorded in meta.json (max_tokens, param_sources.max_tokens) and restored on resume — not for a bot, whose config owns it. Resume: a bundle whose source map carries the max_tokens key restores what it says, the DEFAULT included (a key of builtin with no top-level value): the replay clears the ceiling startup installed from the current config, so the request and the meta agree however the config changed between restarts. A source map written before the key existed — main's four keys — says nothing about it (ParamSources.max_tokens is None, not builtin): the ceiling is evaluated from the config and the next write records it with its source. -M onto another endpoint replays nothing and evaluates the ceiling there. A recorded max_tokens: 0 — the config refuses it, so only a hand-edited meta carries one — is ignored on both paths as though the key were absent, without a warning (it is not a product path): a resume keeps the ceiling startup installed, a move evaluates the target's, and the settled meta records what ran; no upper bound is checked, as none is at the config (an endpoint's ceiling is its own 400, not ours to guess). There is no /model tab and no flag, as for top_p. A dialect that sends no ceiling (chat completions, responses, gemini, the image types) warns `max_tokens` does not apply to provider type {k} (ignored) | Thinking counts toward max_tokens — Anthropic's docs say so, and GLM-5.3's Anthropic-compatible endpoint behaves so: on a multi-option analysis it thinks 7k–15k tokens, and 4096 cut it off mid-thought in 4 runs out of 4, effort or no effort, while 32768 answered all 4 at 7351–14714 tokens (/tmp/iota-glm-reasoning-fable.md, 2026-10-12). 32000 is twice that measured peak and is under the ceilings we have evidence for — GLM-5.3 accepts 1–131072 and answers 131073 with a 400 ([1210] max_tokens参数非法, measured), and Anthropic documents ≥ 64k for its current models — which matters because a value over an endpoint's ceiling is a 400, not a clamp. It is NOT a guarantee for every Anthropic-compatible endpoint: older Claude models and other relays may cap lower, and that is what the key is for — such a model sets a lower max_tokens:, as one that needs more (Claude at xhigh/max, where Anthropic suggests 64k to start) sets a higher one. Billing does not change: the ceiling is a bound, tokens are billed as produced. Pinned by tests/provider/anthropic.rs (the_anthropic_request_body_is_byte_exact, a_configured_max_tokens_rides_the_wire), llm::anthropic::tests::request_omitempty_shape, tests/cmd/config.rs::the_max_tokens_field_parses_layers_and_stays_optional, cmd::tuning::tests::{config_effort_and_top_p_ranges_are_errors, warnings_follow_the_go_order}, config::params::tests::{the_output_ceiling_is_layered_like_the_rest, a_switch_drops_an_inherited_value_and_keeps_a_typed_one, a_declaration_outranks_the_session_value}, session::params::tests::the_sources_round_trip_through_json, tests/session/meta.rs::meta_records_where_each_layered_parameter_came_from, tests/session/tuning.rs::{a_recorded_max_tokens_replays_unless_the_config_owns_it, a_recorded_default_clears_the_installed_ceiling_and_a_four_key_map_does_not}, config::params::tests::a_four_key_source_map_evaluates_the_fifth_parameter, tests/cmd/session.rs (a_resume_runs_under_the_ceiling_the_bundle_records, a_four_key_source_map_evaluates_the_new_ceiling_and_records_it, a_move_evaluates_the_ceiling_on_the_new_endpoint, a_hand_edited_zero_ceiling_is_ignored — the real binary, request against meta), tests/repl/settings.rs::a_declared_max_tokens_lands_on_a_switch_and_leaves_with_it and tests/repl/stall.rs::the_title_request_carries_the_chats_ceiling (the title pass copies the chat's live ceiling onto its own instance; its effort is untouched) |
| X-69 | an output the ceiling cut off (stop_reason: max_tokens) | the stop reason is stored and only compared with tool_use (provider/anthropic.go:401,460,485): a round cut off mid-thought, mid-reply or mid-tool-arguments looks like a finished one, and a tool call whose arguments were still streaming is dropped without a word | RoundResult and ChatResult carry truncated: Option<Truncation> — dialect-neutral (reason in the dialect's spelling, the request's limit, the dropped_tools it was composing, by name); the anthropic dialect and the responses dialect (X-72) fill it (finish_reason: length and gemini's MAX_TOKENS are the same fact, not wired yet). The stop reason is merged over every message_delta (AnthropicStream::stop_reason, the one terminal fact the completion check of X-71 reads too): a delta without a stop_reason — a usage-only update — is no update of it, never a retraction; max_tokens, once reported, stays the round's reason whatever follows; any other later reason is ignored — the first one reported stands; later usage is still laid over. A truncation is an ERROR, and never retried (the same request hits the same ceiling): ChatError::Truncated, output truncated — stop_reason: max_tokens: the output hit its 32000-token limit[; the tool call it was composing was dropped (write_file)]. Interactively a red Output truncated block with that detail and ONE fixed hint that reads no state, so it cannot misstate what the session runs under: Raise max_tokens: in the config (an agents: entry overrides every model's). The config is read at startup: quit, resume, then /model away and back. Lowering effort applies at once. — a running chat re-evaluates only its startup snapshot of the config, a resume restores the recorded ceiling (X-68), re-picking the current model evaluates nothing, and there is no max_tokens tab, flag or reload (a cut where the request carried no ceiling, X-72, says instead The endpoint's own output limit cut it: lower the model's effort in /model, or ask for a shorter reply); the turn is then kept like a stall's (X-64) — the completed tool rounds stay, text the model DID write lands as an assistant message marked cut short with the notice What arrived before the cut-off is kept — the reply is incomplete. — together with the round's raw blocks and cost: the response completed (unlike an ESC or a stall, whose partial stays plain text), so a sealed thinking, a signature-only one or a redacted_thinking block is persisted with the text and replays ahead of it on the next request (brain anthropic-thinking-replay). With no text and no completed tool round the turn rolls back with its user message. A cut with no text still cost what the provider reported: that usage counts toward the session totals and /status's last call like a tool round in a rolled-back turn, but never measures the context budget, so neither the next request's figure nor a bot's compaction threshold sees a request the history no longer holds (tests/repl/stall.rs::{a_cut_with_no_text_still_counts_its_cost, a_rolled_back_cut_never_measures_the_next_turn}). The tool loop does all of this as it does for a finished final round — it builds the assistant message from the cut round (text, reasoning, raw blocks, cost, marked cut short), books it, keeps it when it has text, and only THEN fails the turn; the run loop's part is the red block, no retry, and the rollback when nothing of the turn stayed (a kept cut is booked once: a_kept_cut_counts_its_cost_once). Headlessly the run fails: exit 1, Error: output truncated — … on stderr, no delta persisted (D-43); with --output-format text nothing reaches stdout, with json stdout carries the report — reply empty, the error named, the billed rounds kept. Auxiliary tasks — model calls whose output is a by-product, not a reply — never use a cut-off output either, each failing the way it already fails: a compaction whose summary was cut off fails (summary stop_reason: max_tokens: …) instead of replacing the history with a fragment; a title pass that comes back truncated yields no title, however much text it carries, and the prompt-derived placeholder stands — exactly as for a failed or empty pass (no retry, no message; its own 30 s deadline, TITLE_TIMEOUT, is unchanged) | Found 2026-10-12 (/tmp/iota-glm-reasoning-fable.md §1): GLM-5.3 cut off at 4096 left a 12 355-character half-thought as the "answer", with no sign anywhere that anything had been cut — the ↓4096 on the status row looked like any other count — and the same silence covered a tool call cut mid-arguments (§1 step 9). However large the default, a long task can still reach it, so the cut has to be visible and must never be passed off as a reply. Kept-as-cut rather than discarded because visible text is what the model said; the responses dialect's precedent is the severity and the wording. Not done: continuing a cut-off output (resuming a truncated signed thinking block is untested on both endpoints). Pinned by tests/provider/anthropic.rs (a_round_cut_off_at_max_tokens_reports_the_truncation, a_tool_call_cut_off_mid_arguments_is_dropped_by_name), tests/repl/stall.rs (a_thought_cut_off_by_the_ceiling_is_never_the_answer, a_reply_cut_off_by_the_ceiling_keeps_its_text_marked_cut_short, a_reply_cut_off_by_the_ceiling_keeps_its_sealed_thinking — real dialect → REPL → session → next request, all three thinking shapes), tests/headless/toolloop.rs::a_truncated_reasoning_only_round_fails_the_run, compact::tests::a_truncated_summary_is_an_error, title::tests::a_truncated_title_pass_keeps_the_placeholder; the hint by tests/repl/stall.rs::a_cut_gives_the_one_hint_whatever_the_ceiling_came_from (the session's own value under a declaring agent: neither is named), its steps by tests/repl/stall.rs::{a_config_edit_on_disk_never_reaches_a_running_chat, after_a_restart_a_switch_away_and_back_adopts_the_edit}; the merge by tests/provider/anthropic.rs::{a_later_usage_only_delta_never_erases_a_reported_truncation, a_usage_only_tail_after_a_normal_end_is_still_a_normal_end}, tests/repl/stall.rs::a_later_usage_delta_never_turns_a_cut_into_an_answer, tests/cmd/session.rs::a_late_usage_delta_still_fails_a_cut_headless_run and compact::tests::a_late_usage_delta_does_not_commit_a_cut_summary; retry::tests::is_retryable_follows_the_status_table, errors::tests::describe_error_classifies_each_failure_into_headline_detail_and_hint and headless::error::tests::display_texts_match_go |
| X-70 | the reasoning-only rule under a truncation | Reasoning-only response: render the reasoning as the answer (chat/run.go:1812-1818; headless chat/chat.go:319-322) applies to every round with no text and no tool calls, whatever ended it | the rule applies only to a round that ENDED: a truncated round (X-69) never reaches it — no half-thought is rendered as the answer, written into the session as content, or replayed to the model as an assistant text block on the next request (provider::anthropic::build_request turns a non-empty content into one). The thought stays what it was: the thinking widget's segment, and the partial reasoning of a kept cut. A round that ended normally with only reasoning is unchanged | the rule's premise is a model that finished and put its answer in the reasoning channel; a draft cut off mid-sentence is not that, and promoting it both showed the user an internal monologue as the reply and poisoned every later request with it (/tmp/iota-glm-reasoning-fable.md §1 steps 6–8, §4). Pinned by repl::turn::tools::tests::a_truncated_reasoning_only_round_is_never_the_answer, tests/repl/stall.rs::a_thought_cut_off_by_the_ceiling_is_never_the_answer and tests/headless/toolloop.rs::a_truncated_reasoning_only_round_fails_the_run; the unchanged half by repl::turn::tools::tests::reasoning_only_response_becomes_the_answer and tests/headless/toolloop.rs::reasoning_only_reply_is_the_reply |
| X-71 | the anthropic dialect's unary call (Provider::chat: a headless run with no tools, the compaction summary, the title pass) | POST /v1/messages without stream and one JSON response (provider/anthropic.go Chat) | the unary call rides the STREAM — the same request the tool loop sends, with no tools and a discarding sink — and returns the text, the usage and the truncation (X-69). The non-streaming wire call (Anthropic::message, AnthropicResponse, RespBlock) is gone, so an endpoint that cannot stream (or ignores stream: true) now fails with NoEvents where it used to answer. Completion (both entry points): a message is complete at message_stop, or — for a compatible endpoint that closes the body without one — at EOF after a message_delta that carried a stop_reason; a body that ends anywhere else (after a ping, mid-text, after a message_delta with no stop_reason) is LlmError::IncompleteStream (llm: stream ended before the message did (no message_stop or stop_reason)), never a finished answer — Go's stream read every EOF after any event as the end. A finished message whose body stays open still ends at message_stop (X-64). Status errors, their bodies and the wire-level retries are unchanged; after the head, a decode failure is now a stream error (MalformedEvent/InBand/NoEvents/IncompleteStream) instead of a JSON one, under the 300 s stream idle bound | the client gives up on response HEADERS after HEADER_TIMEOUT (120 s, I-02) — it bounds the head, not the whole answer. A server that holds a non-streaming response's headers until the output is generated would hit it on a long generation, and with X-68's larger ceiling and thinking counted in it, a long generation is the expected case: GLM-5.3 took 139–260 s for complete streamed answers at a 32768 ceiling (total time, not a measured header time — no non-streaming header timing was taken). A streamed response sends its head first and then bytes as they are produced, so neither bound fires on a live generation; Anthropic recommends streaming above ~21k output tokens for the same reason. The completion check is what keeps the switch from weakening the unary call: a JSON body cut short failed to decode, and a stream cut short must fail too (review M4). Pinned by tests/provider/anthropic.rs (an_anthropic_unary_call_streams_and_reports_only_its_own_usage, the unary half of a_round_cut_off_at_max_tokens_reports_the_truncation, a_configured_max_tokens_rides_the_wire, a_stream_that_ends_before_the_message_did_is_incomplete), tests/provider/wire.rs::an_anthropic_unary_call_ends_at_message_stop_although_the_body_stays_open, compact::tests::a_cut_summary_stream_is_an_error and tests/cmd/session.rs::a_cut_unary_stream_fails_the_run_and_persists_nothing (the real binary: exit 1, nothing on stdout, the log untouched) |
| X-72 | a responses-dialect output the ceiling cut off (response.incomplete with incomplete_details.reason: max_output_tokens) | response.incomplete is a terminal FAILURE whatever its reason (internal/llm/responses.go:311); isRetryable (chat/chat.go:135-157) finds nothing non-retryable in it, so interactively the same request is sent 10 more times and hits the same ceiling each time. When text, reasoning or a function call had already streamed, the failure is dropped instead (provider/openresponses.go:474-479) and the partial round stands as finished — a cut reply is the answer, and a call completed beside the cut runs. The unary body's incomplete_details is not read at all (Chat): a cut summary or -m reply is the result | the ceiling's cut joins the dialect-neutral truncation of X-69: the stream's response.incomplete: max_output_tokens is a terminal event (RespEvent::CutOff, its usage kept as the round's cost) and the unary body's incomplete_details is read (RespResponse::cut_off); both fill Truncation { reason: "response.incomplete: max_output_tokens", limit: None, dropped_tools } — limit stays None because this dialect sends no ceiling, and every function call the round announced or completed is dropped, named once each however many calls shared the name — the list only goes into the error's text, and none of those calls runs. From there X-69's path is unchanged: ChatError::Truncated, never retried, Output truncated — response.incomplete: max_output_tokens: the output hit its token limit, the text kept marked cut short or the turn rolled back, headless exit 1, a cut summary not committed. The hint depends on limit: a ceiling the request carried names max_tokens: (X-69); none names what the user can lower — The endpoint's own output limit cut it: lower the model's effort in /model, or ask for a shorter reply. Only the ceiling's cut changed, layer by layer: a STREAM response.incomplete with any other reason (content_filter, …) still decodes to Failure — retryable when nothing streamed before it; the provider's partial-result arm is unchanged (an error after text, reasoning or a completed function call is still dropped and the partial round returned with truncated: None); the unary body with any other reason is unchanged too (its text returned, truncated: None) | Found 2026-10-12 by the max-tokens review (/tmp/iota-max-tokens-review-codex.md F1): a synthetic responses provider under a paused clock made 11 calls for one cut with tools and 121 without (X-73), every one hitting the same ceiling; the partial-content arm made the same cut a silent false answer. The anthropic dialect had just been given the contract (X-69); this dialect reuses its fields, error and REPL path rather than a second spelling. Not done: sending max_output_tokens (no knob on this dialect). Pinned by tests/provider/openresponses.rs (a_round_cut_off_at_max_output_tokens_reports_the_truncation, a_tool_call_cut_off_at_max_output_tokens_is_dropped_by_name, a_unary_response_cut_off_at_max_output_tokens_reports_the_truncation, and the_terminal_events_map_to_their_errors for content_filter staying a failure), llm::responses::tests::{a_response_the_ceiling_cut_off_is_a_terminal_event_not_a_failure, failure_maps_the_three_terminal_events}, tests/repl/stall.rs::a_responses_cut_is_shown_once_and_never_retried (real dialect → REPL, tools and none, text before the cut and none: one request each), tests/headless/toolloop.rs::a_responses_cut_fails_the_run_on_the_stream_and_the_unary_entry, compact::tests::a_responses_summary_cut_off_is_an_error and errors::tests::describe_error_classifies_each_failure_into_headline_detail_and_hint |
| X-73 | who owns a model call's retry budget | two layers, never stacked: a turn that advertised tools runs toolLoop, whose retryRound wraps each model call (10 retries, the turn's cancel beats the backoff, one ⟳ … recovered notice), and the turn-level retry steps aside (usedTools := isToolProvider && len(tools) > 0, chat/run.go:1032-1040); any other turn runs streamTurn, which has no ladder of its own, so the turn-level retry (chat/run.go:323-346) replays the WHOLE turn — history reset to the user message, the taken steering injections re-landed, meter and budget restored, a backoff time.Sleep that ESC cannot cut, no recovery notice, and a refusal when executed tool calls would run again (sideFx). Either way one failing call is sent at most 11 times | each high-level provider call is covered by ONE REPL retry budget, in ONE place: retry_round (MAX_RETRIES = 10) wraps the call where it is issued — every tool-loop round (repl::turn::tools::tool_loop; since T-36 a tool-capable provider sends every turn there, an empty tool set included) and the unary call of a provider without tool calling (repl::turn::stream_turn). There is no turn-level replay: TurnEngine::run books the user message on the meter, calls run_turn once and returns; the replay's bookkeeping went with it (TurnReport::{ran_tool_loop, side_fx}, the steerer's injected record). So the unary turn now retries the way a tool round does: the same request re-sent (composed once, nothing in the history to reset), the busy row, exactly one recovery notice, the turn's cancel beating the backoff (the turn ends Interrupted and takes the interrupt path, where Go slept the backoff out), and no retry at all for a dedicated image provider (can_retry). Which errors retry is unchanged (is_retryable); the transport layer's own retries up to the response head (llm::client::DEFAULT_RETRIES = 2) are a separate policy and unchanged, and headless still has no automatic retry | T-36 moved the no-tools turn into the tool loop but kept Go's guard, which only stepped aside for a NON-empty tool set: the loop's ladder ran inside the turn's, and a no-tools turn sent one failing call (1 + 10) × (1 + 10) = 121 times (any retryable error — a 5xx, a transport fault, the responses cut of X-72) with minutes of backoff, where Go sends 11. Found 2026-10-12 by the max-tokens review (F1) and fixed by stepping aside whenever the tool loop ran. Its review (/tmp/iota-trunc-retry-review-codex.md §7) then retired the second implementation: after that fix only the unary fallback entered the turn-level retry branch, and in production every provider without tool calling is a dedicated image provider whose retries are off, so production never entered it — two copies of the backoff, notice and cancel logic for a path only synthetic providers reached. Pinned by tests/repl/stall.rs::{a_transient_failure_climbs_one_ladder_with_or_without_tools, a_transient_failure_is_still_retried_until_it_clears, the_unary_fallback_climbs_the_same_ladder, the_unary_fallback_recovers_with_one_notice, an_image_provider_is_called_once, a_failing_call_after_a_tool_round_retries_without_replaying_it} (paused clock: 11 calls with tools, without, and on the unary fallback; 3 calls and one notice on a recovery; 1 call for an image provider; a completed tool round and a steered message sent once on every attempt of the next call, never re-run, and rolled back with the failed turn), repl::turn::tools::tests::{unary_provider_retries_on_the_round_ladder, unary_provider_backoff_yields_to_an_interrupt, unary_image_provider_is_never_retried, no_tools_turn_streams_markdown_through_the_tool_loop} and retry::tests::retry_round_stops_at_max_retries |
| X-74 | where a running compaction says so | compactNow raises the status row's busy segment Compacting context… (spinner and phase clock) for the summary pass and drops it when the pass returns (chat/run.go:255-257); nothing is written to the transcript until the outcome | the summary pass runs under the transcript's lifecycle widget — the spinner row thinking rides (group::ensure_widget), labelled Compacting context…, with its ⎿ <elapsed> clock — raised just before the call (Transcript::raise_widget) and taken down when it returns (Transcript::close_widget); the status row carries no busy segment of its own for it (boundary below). The widget rises only when the pass will run: with nothing older than the last turn (Compaction::Unchanged, decided by the same middle_bounds that compact_history uses) nothing rises, so the typed command shows Nothing to compact yet. alone and the auto-offer and a bot's compaction stay silent as before. Every path through compact_now behaves the same — /compact, an accepted auto-offer, a bot's compaction. The outcome lines are unchanged and land where the widget was: the raise pays the block separator, and the close leaves it to the outcome's block (orphan_sep, as a widget dropped at a turn's end does) rather than paying a second. Other busy users (the turn's phases, tool calls, the retry row, /model's listing) still use the status row. Boundary with X-76 (2026-10-14): the pre-send compaction — an accepted auto-offer, a bot's BeforeSend compaction — runs INSIDE the turn the message starts (X-76's start point), so the status row carries that turn's layer meanwhile, its phase named Compacting. That names which stretch of the turn this is, as Thinking does beside the thinking widget; the compaction's own progress — its label Compacting context…, spinner and clock — is still the widget's alone, and nothing about compact_now changed. /compact and a bot's compaction after a turn has ended run outside any turn: their status row stays bare | Owner's decision 2026-10-13: the in-progress state belongs in the scrolling transcript, shown by the same component as thinking. (It first landed the same day, f113643, as a static transcript notice that lost the spinner and the clock; the owner corrected that reading — the spinner moves with it — and the widget replaced the notice.) No deferral like image_widget's: a compaction runs only between turns — the typed command, the pre-send offer, and a bot's flush machine (bot_perform after TurnEnded, on a resume, a day change or a notice) — all after reset_turn, so no thinking, content or deferred call owns the slot. settle_group would leave the widget up (it settles only a group with tool or think events), hence the explicit close. Known: close_preview keeps the widget's rows on screen for the next commit to replace in place (the bounce-free morph); the one-line outcome replaces the label row and the clock row stays as one blank row of the live frame until the next turn ends (sink.done → drop_preview); it never reaches the scrollback. The busy segment's BusyOn also ended a resize drag (end_drag); the paths into compact_now either start from a key (/compact, the offer's Enter) or, for a bot, settle the drag by DRAG_SETTLE on their own, so nothing depends on it. ESC does not cancel a compaction, before or after (no cancel scope is pushed for the pass, so the widget shows no ESC to cancel). Pinned by transcript::tests::a_raised_widget_closes_into_the_outcome_with_one_separator and tests/repl/compact.rs (assert_ran_under_the_widget: the widget raised, closed, then the outcome, no busy segment — on the automatic path none but the turn's Compacting phase, named before the raise — and no printed Compacting context… — in compact_rebuilds_the_view_and_the_reload_agrees, a_failed_summary_pass_leaves_the_conversation_alone and, on the automatic path, the_auto_offer_fires_over_the_threshold_and_compacts_when_accepted; nothing_to_compact_is_reported_only_for_the_typed_command: no widget) |
| X-75 | /new — a new session without leaving the chat | no such command: the command table is /file /session /model /compact /export /status /tools /debug plus the conditional rows (chat/completion.go:10-37), and an unknown /xyz — /new, /clear — is sent to the model as a message; a new conversation is quitting and running iota again | /new (completion row "Start a new session; this one stays on disk", after /file) is iota run again inside the same process, without re-reading the config or reconnecting MCP (repl::commands::new). The session being left is persisted first — a backlog that will not land refuses the switch with New session failed: the conversation is not saved yet — its title pass is given up, and its writer dropped, which lets go of the bundle lock: it stays on disk for /session and iota resume. The new session gets a new id in the same bucket, minted from the run's own state (the endpoint and model it is on now, the agent, the scope or cwd), lazily as at startup, and is stamped with what the chat runs under — the five layered parameters with their sources, hand-set values included, never re-evaluated. Its history is the system prompt alone; the attachments, the meter (occupancy and ↑/↓ totals), the title (window back to iota), the compaction snooze and the deferred tools a search loaded (Dispatcher::reset_loads) start from nothing; the dispatcher, MCP, cwd, /debug, the approval grant ("allow for this session" is per run, as across /session) and saving-or-not carry over — an ephemeral chat's new session is ephemeral, /save still mints it. Background jobs keep running and a finished one's notice goes to the NEW session. The screen is not cleared: one dim divider in the transcript, New session <id> or New session · not saved · /save keeps it, then (N attachment(s) dropped) and · N background job(s) keep(s) running when there are any; the host hears the new id (pane.report_agent_session). Guards: /new <anything> → Usage: /new; a session nothing has been said in yet → Already a new session. (no id minted); an ephemeral chat a model has replied in asks Discard this unsaved chat and start a new one? (Discard / Keep; a failed ask keeps); typed mid-stream it queues and runs after the turn, like every command. /clear is the same command under a second name, matched but never listed. In a bot neither is listed and both are refused — A bot's session never ends: to start over, run another agent or rename the bot. — instead of reaching the model. | Decided 2026-10-13 (docs/design/new-session-command.md §3.7, all eleven points as recommended; the owner asked for a clear/new command like other coding agents have). /new because it is the one word every surveyed agent accepts, and iota neither clears the screen (inline scrollback, no clearing primitive) nor drops the session — the bundle and resume are what a "clear" here must not lose; /clear is still caught because otherwise the habit of Claude Code, Gemini, Cursor and Copilot users sends it to the model. No /reset, no argument in v1, no handoff summary. Reverses bot-mode.md's "no /new" for every chat but a bot's, whose session still never ends (bot-mode.md rev 4). Pinned by tests/repl/new_session.rs (old session on disk and resumable while the run is up, new id in the same bucket, parameters and sources carried, meter zeroed, the divider's text, /clear, the bot refusal, Usage: /new, both confirmation branches, the second /new, the system prompt carried), commands::new::tests, title::tests::restart_starts_the_name_over, tests/tool/framework.rs (reset_loads_hides_what_a_search_loaded), the command-table tests and tmux scenario 32 (the divider under the untouched transcript, the status row back to ≈0% with no ↑/↓, the two bundles in one bucket, iota resume <old id>). |
| X-76 | the status row's busy over a turn | the busy segment is ONE phase at a time (busyOnMsg restarts its clock, BusyOff clears it outright): Waiting for the model / Waiting for the first token / Sending request while the request is out, gone at the first byte of text, the first reasoning byte and the first named tool argument ("the widget takes over from the status spinner"), and never raised while the calls run — the row is bare through the text and the tools, and a call whose arguments preceded its name and that was followed by text ran with nothing on screen at all (busy-span §2.7); the call widget's ⎿ row carries its own · ESC to cancel; the host turns Busy at the top of run_turn | ONE whole-turn layer (ui::facade::TurnBusy, docs/design/busy-span.md): it rises when the run loop accepts a message for the model — before the day roll, the compaction offer, the title and the host's background query (up to a second under cmux) — and comes down when the turn has landed (interrupted, failed — a failed bot flush turn included — or answered). Its clock is the TURN's: a phase never restarts it, and while the turn waits on the user (an approval, an interactive tool, the compaction offer's question) it stands still, the wait taken out (ui::render::frame::TurnClock). Phases only name it: Waiting for the model (also its label between phases), Waiting for the first token, Sending request · x / y, the retry row, and three new ones — Responding while text streams, Thinking while reasoning does, Running tools from the first composed argument to the end of the round's calls (never a tool's name: the call widget says which call, and how long it has run) — plus Compacting for the pre-send compaction (X-74's boundary). The cancel hint (ESC to cancel) is the status row's alone; the widget's ⎿ row keeps its detail and clock. The host's Busy moved with the start, and the layer is derived where the host's state is: Presenter turns the turn's state BEFORE the jobs rewrite into the layer (Busy runs, NeedsInput waits, Idle/Error end it) in the same update that turns it AFTER the rewrite into the host's (Presenter::show_turn_on). So once a turn has landed with a job still running, the host stays Busy (X-47) while the status row shows only the job segment, static — no spinner, no hint. Outside a turn a busy phase keeps its own clock (/model's listing). The composing bug went with it: text after a composed call no longer marks a content block nobody closes, so the walk raises the call's widget before the call runs. | Owner's decisions 2026-10-14 (busy-span §3.7: points 1-4 and 6-8 as recommended, point 5 B — the jobs segment stays static outside the turn, "as most other agents"), after a survey of nine agents. The turn layer answers "is it working, on what stage, for how long, can I stop it"; the call widget answers "which call, its output, its time" — the two never repeat each other but for Thinking, which the widget meters and the row times. The host keeps the jobs-count-as-Busy rule on purpose: it says the conversation is not done, the row says no turn is running. Pinned by repl::turn::tools::tests (a_call_with_trailing_text_is_raised_before_it_runs, the_status_row_names_every_phase_of_a_turn, the_turn_layer_spans_the_turn_and_waits_with_the_user), host::tests::the_turn_layer_is_the_turn_state_before_the_jobs_rewrite, ui::render::frame::tests::{call_preview_rendering, the_turn_clock_takes_the_waits_out}, the event-loop unit the_turn_layer_owns_the_busy_clock, tests/repl/compact.rs (the offer's question as the turn's wait), tests/repl/bot_flush.rs (a failed flush turn's layer down before the compaction) and tmux scenarios 02, 33 (text after a call, anonymous and named) and 34 (each phase on a real terminal, the approval's wait out of the clock, the static job segment after the turn). |
| X-77 | what an MCP failure says | the manager wraps the SDK error text — connect failed: <err>, list tools: <err>, connection timed out after 30s — and a failed call returns the SDK's text as the tool result; reportMCPFailures prints ⚠ MCP <name> failed: <first line> | every MCP failure is worded by mcp::explain from the TYPED rmcp error (never its Display, which names generic transport types and internal steps): ONE summary line — what happened, to which url or command, what to do — then detail lines (the cause chain down to the OS error, a response body cut to a line or an HTML page by its <title> and size, a stdio server's stderr, whose last line is the reason when it exited during startup). Notice: ⚠ MCP <name>: <summary>, plus · details in /tools when there is detail; a server's page in /tools shows the summary and the detail (X-79); headless: Warning: mcp server <name>: <summary>, the detail indented under it; iota mcp list --probe the summary, --json the whole text. A failed call reads MCP server "<name>": <reason> — <hint> (disconnected (it exited or closed the connection) — reconnect it from /tools; can't reach it: connection refused — check that it is still running; it returned an error: <message> (code <n>)). rmcp's own text goes to IOTA_LOG. not logged in: run iota mcp login <name> is unchanged | Owner's report 2026-10-15: ⚠ MCP nb failed: connect failed: Send message error Transport [rmcp::transport::worker::WorkerTransport<…AuthClient<reqwest::async_impl::client::Client>…>] error: Client error: error sending request for url (https://namebeta.com/api/mcp), when send initialize request — a generic type name, an internal step, and the reason (Connection refused, three source()s down) nowhere. Behind a system proxy the same failure printed the proxy's whole HTML error page into the warning. The behaviour of a server that dies mid-session is unchanged (docs/design/mcp-child-death-research.md, awaiting the owner); only its words are. A call that hangs because a wrapper still holds the pipe produces no error at all, so no wording reaches it. Pinned by mcp::explain::tests, repl::render::mcpreport::tests, repl::commands::tools::tests::mcp_status_lines_render_every_server (the detail lines), tests/mcp/manager.rs (http_failures_say_what_happened_and_what_to_do: a refused port, a 404, a 503 HTML page, a web page; a_call_to_a_server_that_exited_names_it; the timeout, reserved-header, scheme, missing-target, stdio-exit and interrupted texts), tests/mcp/oauth.rs (a refused bearer, auth: none, a refused refresh), tests/cmd/cli.rs::cli_mcp_failed_server_warning, tests/cmd/mcp.rs::mcp_list_probe_reports_each_server and tests/repl/commands.rs (the notice) |
| X-78 | a server that dies mid-session, and a call nobody answers | as ported from mcp/manager.go, and in iota until 2026-10-15 (measured, docs/design/mcp-child-death-research.md §2): a stdio server killed mid-session stays connected with its tools offered, every call to it returns the transport's closed-connection error, nothing is announced; a call has no deadline, so a server that never answers holds the turn until it is cancelled (Go not re-read for this row) | a call that finds the connection gone marks the server ServerState::Down (once): /tools lists it as down: exited (its last stderr line: "<line>") — its tools will fail until it is reconnected from /tools and its page carries the stderr tail, the chat prints that once as ⚠ MCP <name>: … · details in /tools (whatever the startup did: the notice channel lives as long as the manager, so a server that failed at startup, was reconnected from /tools and then died is announced too — until 2026-10-16 the manager let it go when no server had connected at startup), and that call and every later one answer MCP server "<name>": it has exited — its tools won't work until it is reconnected, which the user can do from /tools without touching the session. The tools are NOT taken out: still offered to the model, still under their wire names. Every tools/call runs under a deadline — 30 minutes by default (DEFAULT_CALL_TIMEOUT), tool_timeout_sec: per mcp_servers entry (0 is a setup error naming the server, config mcp_servers.<name>.tool_timeout_sec 0: want 1 or more, the max_tokens: 0 shape — not "no deadline", which would hand a headless run the hang back; since 2026-10-19, until then 0 timed every call out at once) — past which rmcp cancels it and the call answers MCP server "<name>": no answer within <d>, so the call was given up — if the tool is meant to run that long, raise tool_timeout_sec for this server in the config; a timeout does not mark the server down. Down is left only by a Manager::reconnect that works: Enter on the server's page in /tools (X-79); one that fails for a server that had tools (down, or connected and restarted) ends in Down again — the failure's text as the reason, the tools as they were — not in a Failed without them, so a reconnect never changes the tool array unless it brings new tools. A status snapshot finds a server that died idle, before any call (X-79). Not done: a notice in a headless run, lazy reconnecting, process groups | Owner's decisions 2026-10-15 on docs/design/mcp-child-death-research.md §4: keep the tools listed and make the call's error actionable (§4.2 #2b — taking them out changes the tool array, which breaks the prompt cache and leaves the history calling tools that no longer exist), mark + announce (#1a), a large default call deadline (#4a); no lazy reconnect and no process groups for now. 30 minutes because the deadline is a backstop for a server that stopped answering without closing the pipe (the research's §2.6: a wrapper still holding it), not a speed limit: it is three times Gemini CLI's 10 minutes and six times Codex's 300 s, so a long build or crawl is not cut off, yet a headless run or a bot — which have no ESC — get their turn back; Claude Code's 100000000 ms (about 28 h) is no backstop at all. Pinned by mcp::manager::tests::a_call_that_finds_the_server_gone_marks_it_down_once (the state, the summary and tail, the tools kept, one notice for two calls), mcp::manager::tests::connect_background_forwards_every_status_and_stays_open_while_the_manager_lives, tests/mcp/manager.rs::a_server_that_dies_after_a_startup_with_none_connected_is_announced, tests/mcp/manager.rs::a_reconnect_that_fails_leaves_the_server_down_with_its_tools, mcp::explain::tests::a_server_that_went_down_and_a_call_that_timed_out_say_so, tests/mcp/manager.rs (a_call_to_a_server_that_exited_marks_it_down_and_names_it: a real sh server that writes to stderr and exits; a_call_nobody_answers_ends_at_the_deadline: the manager's deadline and an entry's tool_timeout_sec, not down), mcp::config::tests::expand_server_config_touches_values_only, the strict key list (tool_timeout_sec), cmd::assemble::tests::a_zero_tool_timeout_is_rejected_naming_the_server and tests/cmd/cli.rs::cli_mcp_tool_timeout_zero (0 refused before anything connects) |
| X-79 | /tools: one list of sources, a page each, a reconnect | two View tabs (chat/run.go:849-860, chat/chat.go:520-669): Tools — one row per tool, tagged [built-in] or [mcp: <server>]; MCP — per server its state, endpoint, tool names and the first line of an error. Read-only: Enter's commit is discarded; there is no way to reconnect a server short of restarting | ONE List panel, Tools, one row per SOURCE — built in (every tool iota ships with), then each configured server in config order — as <name> <tool count> <status>: available; connected; connecting…; down: <summary>; needs login: run iota mcp login <name>; failed: <summary> (X-77's words); prompt <n> MCP servers · <m> tools · Enter: open · Esc: close. Enter opens the source's page, a View titled with its name: for a server status, endpoint, transport, auth (none for stdio), tool names (mcp__<segment>__<tool>), call timeout (X-78's deadline, in words), then why — the whole failure text, stderr tail included — or, for a live stdio server, its last 10 stderr lines, then tools (N) by their own names, a description cut to 100 characters, the deferred state; for built in, what these tools are (no server, so nothing to reconnect) and the list. Esc goes back to the list with the cursor on the row; Esc there closes. Enter on a server's page reconnects it — Manager::reconnect in the background, the page re-rendering Reconnecting…, then Reconnected · N tools or Reconnect failed: <summary>; the transcript says MCP <name> reconnected · N tools, or the startup notice's own ⚠ MCP <name>: <summary> · details in /tools. The old session is closed first; while the new one connects, a server that had tools reads reconnecting… in the list with its tools still counted and offered (a call meanwhile answers MCP server "<name>": it is being reconnected — try again in a moment). It ends connected with the new tools (the old ones taken out, the same mcp__<segment>__ names); or, failing, down: <the failure's summary> with the tools it had (X-78) — failed only for a server that never had any. Both levels refresh every 500 ms. Manager::servers() marks down a connected server whose connection has closed before it answers, so the list never shows a server killed while idle as connected. X-78's "restart iota" texts point here: its tools will fail until it is reconnected from /tools. Durations read 30 minutes, not 30m0s | Owner's request 2026-10-15 (docs/design/tools-command.md): one tab, the list of MCP servers with name, state and tool count, the built-ins gathered into built in, a page per server with its tools, more detail and the reconnect. Built on the panels as they are — the /jobs shape (a list, a View page, Esc back), no change to src/ui/: every panel commits on Enter, so the page's Enter is its one action, said in its prompt line, and nothing on the page means two things (the /jobs rule of 2026-09-23). With the reconnect a chat picks a new login up without restarting (tmux scenario 23), which X-42 could not. Pinned by repl::commands::tools::tests (the list's six states and its keys and prompt, a server's page with why, a live stdio page's stderr, the built in page, the page prompts, the description cut), tests/repl/commands.rs (tools_is_one_list_and_a_page_per_source; tools_reconnects_a_server_from_its_page: a real sh server reconnected, one refused — and down with its tool, not failed — the transcript lines, the cursor back on its row), repl::commands::tools::tests::a_server_reconnecting_keeps_its_count_and_says_so, tests/mcp/manager.rs (a_reconnect_that_fails_leaves_the_server_down_with_its_tools: down → refused → down with its tools → reconnected under the same segment → connected and refused → down; one that never connected stays failed; a_server_reconnecting_keeps_its_tools_and_says_so), tests/mcp/manager.rs::a_snapshot_finds_a_server_that_died_idle, text::tests::human_duration_names_the_largest_whole_unit and tmux scenario 23 (the row and page of a server waiting for a login, a reconnect in the same chat after iota mcp login) |
| X-80 | what the chat's children inherit from a host | every child — the shell tool's commands (exec.Cmd with a nil Env), the MCP stdio servers (cmd.Env = append(os.Environ(), entry env…)) — inherits the whole process environment, the host's variables included; the same in iota until 2026-10-18 | a host may withhold variables from every child of the chat: the Host capability ChildEnvironment (as_child_env) names them, host::child_env gathers the names from the hosts it is given (in order, each once — the command gives it the detected host; the terminal fallback, built only once the screen exists, withholds nothing) into an app::env::ChildEnv — the mechanism, at the bottom of the module graph so shell and mcp apply it without naming host — whose apply removes them from a tokio::process::Command — in shell::exec::spawn, for every call the job registry (Jobs::withholding) runs, foreground or background, sandboxed or not, and in mcp::transport::spawn_stdio BEFORE the entry's own env: (a variable the config sets explicitly is the user's and stays). The herdr host withholds HERDR_ENV and says so in <environment> (herdr note: HERDR_ENV is unset in your shell on purpose; the herdr CLI works — one short line, because a host's lines may add at most a tenth of the harness cap to every request); HERDR_PANE_ID and HERDR_SOCKET_PATH stay. cmux and the ANSI terminal withhold nothing. The host is detected once, at assembly (host::detect, held in cmd::ToolAssembly::host), because the job registry and the MCP servers — which start connecting before the screen exists — need the names before a branch builds its Presenter (Presenter::over); nothing is reported to it before then. iota mcp list --probe spawns its servers the same way. | Decided 2026-10-18 (owner: knowledge of an environment belongs to the host layer, the core only supplies the mechanism — no HERDR_* outside src/host/; brain page host-integration). A claude -p the model ran from the shell tool inherited HERDR_ENV=1 and iota's HERDR_PANE_ID, so the SessionStart hook herdr installs for Claude claimed iota's pane as herdr:claude; herdr keeps one lifecycle authority per pane and silently dropped every later report of iota's, and the pane stayed working with the chat idle until it was closed (herdr maintainer, herdr#2824: "Herdr intentionally has one lifecycle authority per pane"). Every hook herdr ships reports only when HERDR_ENV=1 (34 hook files reference it); a new herdr pane gets its HERDR_* from herdr's server, the new pane's own, not from the client that asked for it — measured, so the model can still start an agent in a pane of its own (docs/design/child-env-identity-research.md §5, §7). Crush withholds herdr's variables from its bash tool only; iota withholds from both children. Pinned by host::tests::the_child_env_gathers_every_withholding_host_once, host::tests::apply_withholds_from_a_real_child_unless_the_caller_sets_it, host::herdr::tests::test_detect_herdr, host::herdr::tests::the_lines_fit_the_hosts_share_of_the_harness, host::cmux::tests::test_detect_cmux, host::ansi::tests::test_ansi_mapping and tests/cmd/cli.rs (cli_children_inside_herdr_are_spawned_without_herdr_env: a real run's shell call and MCP server each see HERDR_PANE_ID and no HERDR_ENV; cli_children_outside_herdr_inherit_everything). |
| X-81 | a cursor answer is taken only from the query that asked it | no cursor query on a resize: bubbletea v2's cursedRenderer.resize only marks an erase and repaints from its own cursor (X-52) — there is no answer to be late | InlineTerminal::ask, the one way a resize pass queries the cursor (both of relayout's DSRs): the cursor is put on a column of the query's own first — CR, then CUF to one of DSR_MARKS (8) columns in turn, never the last query's (one column on a one-column terminal) — and an answer naming another column is an earlier query's, late: dropped and the next one read, at most DSR_READS (16). A timeout is waited out once more (DSR_WAITS 2): the terminal answered the startup anchor's query, so a timeout is a slow answer, and the read after it takes that answer — it carries this query's column; after the second the pass goes on without one (no floor, no resync, laid out from the cursor as before). The startup anchor's DSR (handle::start) is the session's first query and has nothing ahead of it | Fixed 2026-10-18 — a bug the port introduced (X-52/X-54's DSRs). crossterm 0.29's cursor::position() gives up after 2 s (cursor/sys/unix.rs), but the answer still arrives and waits in its internal event queue — event::read skips it and keeps it — and the next query takes it at once; every query adds an answer and none is dropped, so one timeout leaves every later answer one query late for the rest of the session. The pass's first answer was then the last recheck's (the frame's first row, above rows over the cursor): a band of the frame's own rows each pass (Term::pad 0 → 5 → 9 → 12 logged; 17 in a 20-row pane), the model's frame above rows low, and the drag's end close_band deleted the frame with its top separator and staged rows; the repaint is a diff against the wrong model and did not restore them. Measured (TUI-VERIFY §4.5, tmux 3.8): under taskpolicy -b and ten burners the unchanged build is red in 5 of 29 runs, and the logged reds all had a timeout (none of 20 logged runs without one was red); alternated run by run with the fix, 2 of 8 → 0 of 8 (no fixed run met a timeout). Behind scripts/dsr-delay.py (every 20th cursor answer held 2.5 s, no load): the unchanged build loses the frame in 6 of 6, the fixed one in 0 of 6 — which stays red on the duplicate budgets (X-52 residual (1), the staged rows only) in 6 of 6: a pass that waits 2.5 s is outrun by the drag; open. This was TUI-VERIFY §4.5's open record (CI 36305402235; the loaded tmux 3.4 overwrite is the same shape, not proven). Pinned by vt100_tests::answers_one_query_late_through_a_height_drag_and_its_end_keep_the_frame (the Geometry seam answering 1, 2 or 3 queries late; red on the unfixed build for each) term::tests::a_slow_cursor_answer_still_gives_the_pass_its_floor (the second wait; red with one) and the pass's byte shape in vt100_tests::resize_anchors_at_the_cursor_row_minus_its_frame_row (CR CUF 1 before the climb). |
| X-82 | a failure after the round's text reached the user | isRetryable (chat/chat.go:136-158) reads the error alone, and a fault at a stream's tail — a cut body, a trailing frame that is not JSON, an in-band error event, a body that ends without its terminator — is as retryable after a whole answer as before one; retryRound re-sends the round, which streams the same answer again, up to 10 times; when the tail fault is deterministic every attempt fails the same way, and once the ladder gives up the turn rolls back whole, the answer the user read included | stream_round (repl::turn) wraps any failure that is not an interrupt or a stall as ChatError::AfterOutput once a byte of TEXT reached the sink, and carries the partials out with it; is_retryable never retries it, describe_error words it as the fault itself (Request failed, Network error, …) plus why nothing was retried, and TurnReport::cut_short keeps it like a stall: the completed tool rounds stay and the text lands as an assistant message marked cut short (What arrived before the failure is kept — the reply may be incomplete.). A failure before any text still climbs the ladder: a thought is only a meter row, and a composing tool call runs nothing until its round succeeds. StreamIdle, Truncated and the rest of the table are unchanged; the transport layer's retries up to the head are untouched; headless has no ladder and never raises it | Reported 2026-10-20 on a glm-5.3 session (anthropic dialect): the answer streamed, Request failed — retrying (attempt n/10), the same answer again, and again, until ESC — which kept only the tool round before it, since a non-stall failure carried no partials. Measured with the old code through the fake provider: 13 requests for one message, the answer printed 11 times, 10 retry rows, the tool run once (a retry never re-runs a tool: a round's calls run only after its stream succeeds, and the send is composed once). Which tail fault GLM sent is not recorded anywhere (the error text lives only on screen); every candidate is retryable on its own — pinned through the real dialect by retry::tests::a_whole_answer_with_a_failing_tail_is_retryable_on_its_own (cut body → Network error; malformed tail, no terminator, bad usage, in-band error → Request failed). Pinned by tests/repl/stall.rs::{a_failure_after_the_answer_streamed_is_shown_once_and_never_replayed, a_failure_before_any_text_is_still_retried}, retry::tests::is_retryable_follows_the_status_table and errors::tests |
| X-83 | the tool-call label painted in segments | toolCallHeader (chat/chat.go:461-480) returns the plain [name k:v k:v] and the caller wraps the whole of it in CodeStyle (cyan, SGR 36) — the composing [name …] (chat/run.go:1247-1253) the same; brackets, name, keys and values are one run of one color; the same in iota until 2026-10-21 | tool::fmt::tool_label builds a ToolLabel — the display name and a LabelBody: Composing; Phrase(String), one sentence with no structure worth marking (a legitimate shape with no producer: every header_summary today is an argument); Args { pairs: Vec<(Option<String>, String)>, more }, where a None key is a positional argument — a tool's own header_summary, cut by the tool (header_command 64 runes, header_path 48), never by the digest's 15 — and no pairs is the bare [name] — with every cut already made; its Display is the old plain text byte for byte (tool_call_header and the approval's tool_call_detail are thin wrappers over it, signatures and output unchanged; Dispatcher::header_summary untouched). repl::render::styles::paint_label paints the segments, each self-contained SGR: brackets, key:, … +N args and the composing … faint (2), the name bold cyan (1;36), positional values, keyed values and a phrase cyan (36, the status row's job command's color); blanks unpainted. It never measures or cuts, so a rune cut never meets an escape, and the one cut after painting — the widget body's truncate_ansi — copies every escape. The three painters (the serial and the parallel call in repl/turn/tools.rs, repl::render::group::composing_label) call it; every consumer downstream (the widget label row, the ✓/✗ rows, the classic block, the showcase head) takes the label as an opaque string and is unchanged. Under ColorMode::Off every segment is handed back bare (X-27), so the label is the old text exactly | the owner asked for brackets that read apart from their content and for each shape to show its own structure (docs/design/tool-label-style.md, the four shapes and the composing state settled over two corrections): a keyed digest — an MCP tool's, glob's — whose values hold blanks was one cyan smear. The visible text does not change, so every plain-text pin stands (the 6 tests/repl/toolfmt.rs and 4 tests/headless/approval.rs pins, the tmux scenarios' capture-pane -p reads). Pinned by repl::render::styles::tests (every shape strips back to its plain text; colour off is the plain text; the segments byte for byte — faint frame and keys, bold cyan name, cyan values, a positional value with a colon wearing no key), tests/repl/toolfmt.rs a_label_takes_the_shape_its_source_gives_it (summary → one positional pair uncut, empty summary → bare, digest → three keyed pairs and the tail, the composing label's name) and tmux scenario 25, which reads the committed [shell …] header with capture-pane -e and wants a faint [ and a cyan name in two different SGR states (a control build painting the brackets cyan fails it) |
| X-84 | a rate limit the ladder could not clear, and host notices behind an interrupted turn | retryRound (chat/run.go:1417-1447) climbs the same 10-rung linear ladder (attempt × 1 s) for a 429 as for a 5xx and never sees retry-after: only the transport loop (internal/llm/client.go:188-212) reads it — and sleeps whatever it says, however long; a turn that gives up rolls back and the loop reads its next input. In iota a finished background job's notice IS an input (the job sink → Ui::enqueue), so under a hard quota every notice opened a turn, climbed the ladder (up to 11 outer calls, each up to 3 wire requests), printed a ✗ Rate limited (429) block, pinged the host and was rolled back with its job's result; and fire_cancel (ui/runtime/event_loop.rs) leaves notices queued on purpose, so ESC was answered by the next queued notice's turn | The ladders. StatusError carries retry_after, read once from the refused response by llm::client::retry_after (retry-after-ms, retry-after seconds or HTTP-date — the parse retry_delay had, which now calls it; Display unchanged). The transport loop's should_retry hands up at once a status whose retry-after exceeds RETRY_AFTER_CAP (60 s) instead of sleeping it — except under an explicit x-should-retry: true, which still wins over every rule and whose wait is then slept as asked; headless gets the same. A retry-after is taken to name at most RETRY_AFTER_HORIZON (24 h): retry_after clamps a longer one at the parse, so the wire's sleep, the ladder, the gate's next and its held until HH:MM line all see one bounded value — a day is past any quota window a header names today, as long as Claude Code waits for a reset, and as far as a dateless HH:MM reads; an unbounded one (1e19 s parses as a Duration) overflowed Instant + Duration in the gate and took the loop down. retry_round takes a Retries (All / NoRateLimit / None, which replaced TurnCtx::can_retry). A RATE LIMIT is a 429 whatever it says, or any status whose retry-after is past the cap (a 503 asking for an hour): a 429 gets at most RATE_RETRIES = 3 outer retries (each one a wire call with its own 2 retries), waiting its retry-after when ≤ 60 s (Rate limited (429) — retrying in 37s (attempt 1/3)) and 2/4/8 s when it named none; a rate limit asking for longer stops at once; other 5xx, transport and in-band faults keep the 10-rung linear ladder, and each ladder counts its own rungs (three 503s do not use up a 429's three, nor two 429s a 503's ten; the recovery notice counts every retry made). The gate. repl::noticegate::NoticeGate on Repl holds automatic work: a turn that ends in a rate limit leaves RateLimited { next, step } — next = now + retry-after, else now + 60 s; each refused probe waits twice as long, capped at 30 min; a refusal the user's own message drew never moves next earlier — an interrupted turn leaves Interrupted, an answered turn or any other failure clears it. While it holds, a job's notice the prompt delivers is put aside with one dim ⏸ <headline> — held until HH:MM / — held; it goes with your next message line and the host settled at once (Presenter::notice_held); it opens no turn. A typed message always goes and carries every held notice ahead of it, oldest first, as Message::notices before the user's message (the anthropic and gemini dialects coalesce the run into one user message; chat-completions sends them as consecutive user messages, which it accepts); RateLimited also lets them out on its own at next as one probe turn (▶ Trying again with n held notice(s)) whose rate limit gets no outer ladder (Retries::NoRateLimit), and a /model switch onto another configured provider, dialect or model id moves next to now. Admission is one decision. The prompt is always read, even past next: a timer cancels the read's own child token at the deadline, and ReadCancel is acknowledged — the UI loop sends any reply it owed the waiter (a submit, a notice, an idle interrupt) before it acks, the mailbox is FIFO, and TuiHandle::read_input keeps the reply channel open until the ack — so input already submitted or queued is returned, never swallowed and never overtaken; only a revoke with nothing delivered (UiError::Cancelled), root and facade alive, admits the probe, and an idle Ctrl+C or a close at the same moment ends the session as before. Nothing a rollback takes is lost. The notices a turn carried, its own notice, and the jobs' notices its steering injected at round boundaries (recorded by Steerer, without settling host debt twice) go back to the gate in arrival order when a rate limit rolls the turn back, or an interrupt discards it whole, or no model gets picked; a kept turn keeps them in its history. The compaction offer prices the whole batch a turn sends and the turn's meter is seeded with all of it, so a long-held batch is offered the compaction instead of overflowing the window. A refused probe leaves one dim ⏸ Still rate limited (429) — n notice(s) held; next try HH:MM, no block and no Failed ping. The block keeps the provider's message as its detail (GLM's reset time is in it) and gains a hint naming the next try — promising an automatic try only when notices are waiting (… wait until HH:MM, then iota tries again; send a message to try now, or /model to switch; with none, … wait until HH:MM; send a message to try again, or /model to switch). A running turn still takes notices at its round boundaries; a bot's flush notice never reads or moves the gate; typed messages queued at ESC still fold back into the composer; the status row is unchanged; nothing is persisted — held notices do not survive an exit, and a resume starts with no hold. No provider body is parsed: whether a 429 was a short throttle or a quota is learned from whether the ladder cleared it, and retry-after only says when to look again | reported 2026-10-23 on a glm-5.3 session (anthropic dialect) that hit GLM's 5-hour cap: every background-job notice opened a turn and a new 429 block; ESC printed Interrupted. and the next queued notice started another. GLM's 429 has no retry-after (curl, 2026-10-23) and its type is the generic rate_limit_error for a throttle and a 5-hour quota alike — the difference lives only in a vendor code (1308) and Chinese prose, so a body parser would be a GLM parser. Claude Code (Anthropic's quota headers), Codex CLI (OpenAI body codes such as usage_limit_reached) and opencode (a code table plus an English regex written for Z.ai) each stop on a hard limit only when they recognise it by a vendor signal; only Claude Code resumes on its own, at the reset time its headers give it (docs/design/rate-limit-and-interrupt.md §2). The owner settled the design's four questions on 2026-10-23: probe on a doubling schedule, leave notices lost to non-rate-limit failures alone (a separate issue: any other failed turn's rollback still drops them), no status-row segment, keep ESC's fold-back. An adversarial review of the first cut (2026-10-23) found a revoke that could swallow an Enter or a notice, a steered notice lost to a later round's 429, a long retry-after on a 503 still laddered, a due probe overtaking queued input, a held batch the compaction offer did not price and a same-model switch to another account not counted; all are fixed above and each has its test. A second, independent review of the merged branch (2026-10-23) found an unbounded retry-after that could panic the gate, the two ladders sharing one rung count, and a CHANGELOG line overstating the no-loss claim; fixed, the design's §11.2 says how, and its one uncovered path (the real TuiHandle under the gate's timer) is on the design's gap list. Control: scenario 35 run against b9010d2 (the wire half alone, no gate) is red, 12 PASS / 15 FAIL — after ESC the queued notice opened its own turn; the 429 climbed the old attempt n/10 ladder with no hint; the second job's notice was not put aside and was answered; on this branch it is green. Pinned by tests/provider/wire.rs (retry_after_reads_the_three_header_shapes — the horizon included, a_retry_after_past_the_cap_is_handed_up, a_long_retry_after_is_not_slept_by_the_wire, a_503_with_a_long_retry_after_is_one_request), repl::turn::retry::tests (a_rate_limit_climbs_three_rungs_with_doubling_backoff — each call's time, a_short_retry_after_is_slept_and_shown, a_long_retry_after_is_not_slept, under_a_hold_a_rate_limit_is_not_retried, a_long_retry_after_on_any_status_is_a_limit, rate_limit_reads_the_status_and_its_wait, each_ladder_counts_its_own_rungs), repl::noticegate::tests (the transitions, the backoff to the cap, the order kept, the lines), the UI's ownership of a revoke (event_loop::queue_tests::a_revoke_is_acknowledged_after_anything_it_lost_the_race_to; handle::tests::a_submit_racing_a_revoke_is_never_lost, 20 rounds with no grace period, and a_notice_ahead_of_a_revoke_is_returned_not_dropped), repl::errors::tests::a_quota_429_keeps_the_providers_own_sentence (both hint forms), host::tests::a_held_notice_settles_the_host_without_a_turn, tests/repl/rate_limit.rs (18 cases under the paused clock: the notices behind a refusal send nothing; one probe turn carrying all of them in order a minute later; a refused probe backing off with one line; a typed message carrying them; /model probing at once, onto another model or another account with the same model; queued input and a deadline crossed during a command not overtaken; an exit at the deadline not taken for the timer; ESC holding the queue; a 503 asking for an hour holding like a quota; the compaction offer pricing the batch; a steered notice surviving a later 429 and kept once in a kept turn; no notice lost to a 429, a discarded interrupt or a cancelled pick; no retry promised with nothing held — the elapsed time asserted end to end, each call's schedule in the unit tests), tests/repl/bot_flush.rs::the_flush_notice_is_never_held and tmux scenario 35 (ESC: one ⏸ … held line, no turn, the headline going with the next message; a 429: the attempt n/3 row, ONE block for two finished jobs, the second put aside) |
| X-85 | another process messages a running chat | nothing reaches a running chat from outside but keystrokes (tmux send-keys, herdr agent prompt): indistinguishable from the user, a leading / runs a command, no receipt; iota resume <id> -m on a held bundle and iota run <bot> -m are refused (SessionError::Locked, ArgsError::BotHeadless) | every interactive process binds ONE Unix socket, <home>/.iota/run/<pid>.sock — a 0700 directory checked to be ours, the socket 0600, every peer's uid asked of the kernel (peer_cred) and another uid refused unread; no TCP — and links <bundle>/sock to it from the bundle it writes into (moved by /new, /session, /save; removed on exit with the socket). One connection, one JSON line ({"v":1,"type":"message","from","text","reply"?}), one receipt: woken | queued (+ahead) | held | blocked (the chat is NeedsInput) | too-large (> 64 KiB) | refused | flooded (8 queued, 5 per 10 s, a repeat within 60 s); a chat whose screen is gone closes the connection without one. A sender reads only the receipt's ok and prints the line as written. Taking a bundle's lock removes any sock link on it, so a link only ever names the holder. A message is an InputKind::Message delivered by Ui::deliver — enqueue's two branches, answered — and rides the job notice's path: a Body::Notice with the headline [message from <who>] <first line> and a body naming the origin, saying the user did not type it and that it is no approval, and the reply address; no ❯, no slash dispatch, never at a point other than a round boundary. A chat whose tools waive approval — the built dispatcher's waives_approval: shell.auto_run, code.auto_write; a sandboxed shell without auto_run does not (an open question) — holds it: the receipt is held, then NeedsInput and a select before the model reads it, held out of a running turn and put back after it. Children get IOTA_SOCKET (ChildEnv::giving) when the bind succeeded; a failed bind is one warning naming its cause, printed after the terminal check. -m on a held bundle or a running bot sends through the socket and prints the receipt (a refusal exits 1). Windows: none of it (no listener, no variable). The notice gate (X-84) does not hold a message: it is asked for, not automatic work, and a bot has no user to speak and let it out — it passes a rate-limit or interrupt hold as a typed line does, carrying the held notices ahead of it; refused, it leaves the probe schedule alone (typed), and a 429 rollback loses one steered in as it loses a steered typed line, handing only the jobs' notices back to the gate | Decided 2026-10-22 (the owner: "做 socket(方案 B),不走 inbox"; bot-mode.md rev 5 reverses its §6 #24). The socket is not <bundle>/sock itself as the research sketched: an agent-mode bundle path is 110 bytes in this worktree, past macOS's 104-byte sun_path, and a bundle directory is not private. Hold over a token for approval-waiving chats: the same uid that can write the socket can read any token file. Pinned by session::sock::tests, repl::inbound::tests, queue_tests::{deliver_wakes_a_parked_reader_else_queues_with_its_place, deliver_refuses_past_the_cap_of_messages, a_message_never_answers_an_open_approval}, tools::tests::{a_socket_message_lands_at_the_round_boundary_after_the_tool, a_held_socket_message_never_reaches_the_turn}, tests/repl/messaging.rs, tests/repl/rate_limit.rs::{a_message_from_another_process_passes_a_rate_limit_hold, a_message_from_another_process_passes_an_interrupt_hold, a_refused_message_leaves_the_probe_schedule_alone, a_message_steered_in_before_a_429_is_not_put_aside}, tool::sets::tests::the_dispatcher_says_whether_approval_is_waived, cmd::tests::a_failed_bind_warns_with_its_cause, tests/cmd/session.rs::{a_message_to_a_held_session_goes_to_its_socket, a_message_to_a_running_bot_goes_to_its_socket, a_stale_link_never_reaches_another_chat} (docs/design/agent-messaging.md §8) |
After T3 this list is four items long. The phase-1 blanket entry (an interactive-only
file list) is gone: every chat/*_test.go, tool/codepath_test.go, internal/{ui,host,mathtext, imgterm,agents} and provider/* test it named is ported, each carrying its // Go: <file>:<line>
anchor. The audit that establishes it — every func Test… of the mirrored Go packages matched
against the Rust tree — is scratchpad/t3/audit.py; run it and the residue is exactly these
survivors, each a Go IDIOM or a Go feature the port deliberately does not have:
provider/observer_test.go:10 TestToolCallObserver— the install/detach half only.SetToolCallObserverhas no Rust twin because a sink is a call ARGUMENT, never installed on a provider (T-11/T-51). The DELIVERY law it also asserts (deltas in order, under the name accumulated so far, anonymous when unnamed) is ported per dialect intests/provider/tool_delta.rs.provider/imagen_test.go:172 TestImagenStreamChatAdapts— imagen has no streaming form in the Rust port (Provider::StreamChatis not ported, T-36;as_image_partial_provider()isNonefor imagen), so there is nothing for the adapter test to adapt. The unary path it wraps is pinned by the other nineimagen_test.goports.internal/host/background_test.go:97 TestDetectBackgroundLatch— Go latched because termenv's OSC 11 query could block for seconds;crate::ui::runtime::osc::detect_backgroundhas a 100 ms deadline and runs once, so there is no latch to test (T-46). Recorded beside its Go anchor insrc/host/background.rs:209.- Windows-only expectations (I-07). Go targeted unix; the port's Windows-specific behaviour is
asserted in tests of its own instead —
shell::interp's unit tests walk the whole Windows ladder over an injected machine (on every platform),tests/tool/framework.rspins what the toolset builds there, and the four modules that spawn real children skip at runtime when the resolved interpreter is not a POSIX shell.
tool/ask_test.go came off this list with the TUI slice: the interactive build DOES create the
ask seam, and every one of its tests is ported (src/tool/builtins/ask.rs and
tests/tool/framework.rs:736 for TestSetFalseDisables). D-35 is annotated accordingly.
chat/session_test.go and chat/session_project_test.go are no longer covered by the blanket
entry above. Phase 2 slice 1 ported 18 of their 21 test functions into tests/session/; the TUI
slice and T3 ported the other three, so all 21 are in (Go's TestApplySessionTuning has five
subtests, each a separate #[test]), every one carrying its // Go: chat/<file>.go:<line> anchor:
TestSessionRoundTrip, TestSessionUsageRoundTrip,
TestInterruptedFlagRoundTrip, TestSessionMetaTuningRoundTrip, TestApplySessionTuning (×5),
TestApplySessionTuningImageParams, TestApplySessionTuningJSONEdits,
TestRawContentDroppedOnProviderMismatch, TestLazySessionCreation, TestNewSessionID,
TestResolveSessionID, TestProjectSlug, TestProjectSessionWriter,
TestSessionLocatorAcrossLayouts, TestListSessionsScoped, TestOldSessionCompat,
TestSessionIDTaken, TestLoadFullHistoryIgnoresCompaction (in full since T3),
TestDeferredSaveBacklog, TestDeleteSessionInBucket and
TestSessionLabelFlattensStoredTitle.
The four entries this list used to carry are struck out — T3 (and the TUI slice) closed them. Only the last bullet is still true:
— ported at the store level in T3 asTestDeferredSaveBacklogtests/session/writer.rs::a_deferred_save_flushes_the_whole_backlog_in_one_append(D-54).— ported in the TUI slice asTestDeleteSessionInBuckettests/repl/commands.rs:767.— ported asTestSessionLabelFlattensStoredTitle,PickSession,sessionLabel,humanizeTimesrc/repl/commands/session.rs:248-289(session_label,humanize_time) andsrc/cmd/interactive/mod.rs:757(the blank--resumepicker).— ported in FULL in T3 (T-17):TestLoadFullHistoryIgnoresCompactiontests/session/ loader.rs::load_full_history_ignores_compactiondrives both halves of the pair,SessionStore::load_full(Go's id-takingLoadFullHistory) and the bare-directorysession::load_full_history.- the project-hint half of
TestListSessionsScoped—SessionInfo.Projectis picker-only; the mode-isolation and merge halves ARE ported.
Two phase-1 skips are now in scope and covered at the session layer: the
MarshalRawContent/UnmarshalRawContent round trips that phase 1 recorded as "session persistence
only, out of scope" for TestAnthropicServerBlockCapture (provider/defermode_wire_test.go:226-230),
TestOpenResponsesGoldenRequest (provider/openresponses_wire_test.go:58,239) and
TestGoogleRawContentBlobCompat (provider/google_wire_test.go:23-35) are exercised as the
tag-and-shape codec in tests/session/rawcodec.rs (anthropic_blocks_array_round_trip,
openresponses_items_array_round_trip, gemini_blob_does_not_restore_under_vertexai,
wrongly_shaped_blobs_are_dropped). The provider-side halves of those Go tests — the wire assertions
around them — remain where phase 1 put them.
The blanket entry above is a phase-1 record and is superseded for the files the TUI slice
implements. chat/{errors,retry,retryround,run,toolname,toolcall,interrupt,compose,replay, uisink,toolstatus,userblock,completion,title,titlestate}_test.go, chat/approval_test.go,
tool/codepath_test.go, tool/code_test.go's TestMutationsPostDiffArtifact,
tool/delegate_test.go's note-artifact assertions and the internal/agents freshness tests
(D-27) are ported into src/{markdown,ui,repl}/ and
src/tool/fmt.rs, each carrying its // Go: <file>:<line> anchor; run
grep -rn '// Go: ' src/{markdown,ui,repl} for the live index.
T3 emptied the "still unported" half of this entry. The files whose FEATURE was deferred are
all in: chat/{reqlog,debug}_test.go (T-18), chat/skills_test.go and chat/skillcmd_test.go
(T-19), chat/export_test.go (T-17), chat/{edit,editpicker}_test.go (T-16),
chat/progress_test.go (T-13), internal/{mathtext,imgterm,host} (T-15/T-16/T-14) and the
image-partial observer halves of the image dialects (D-20). What is left is listed in §D above:
the install/detach half of observer_test.go and TestImagenStreamChatAdapts, both consequences
of T-36 / the sink-as-argument idiom.
chat/{compact,tokens}_test.go came off that list with WP53 (T-10): all three of
compact_test.go and all seven token/meter tests of tokens_test.go are ported beside
src/repl/{commands/compact.rs,meter.rs,tokens.rs} with their anchors, and
the VERIFY pass added the /status token block those figures feed. chat/{settings,systemtab}_test.go
came off that list with WP54 (T-12): every one of their tests is ported, TestChoiceRows
included, with TestSystemPromptPanelFromConfig adjusted — its config-resolution half is
src/cmd's (the loop module keeps no config knowledge; the split predates the merge).
chat/toolstatus_test.go was listed as ported before its tests existed; the
VERIFY pass actually ported all three (TestToolStatusLines{Deferred,NoDefer,AllHidden})
when /tools took the MCP view as data, so the entry is now true. provider/observer_test.go is a partial exception since WP55: its DELIVERY law
(deltas in order under the name accumulated so far, anonymous when unnamed) is ported per
dialect in tests/provider/tool_delta.rs; only its install/detach half is unported,
because SetToolCallObserver has no Rust twin — the sink is a call argument.